Correct audio metering and realtime displays
This commit is contained in:
+31
-95
@@ -18,8 +18,12 @@ use crate::goniometer::{selected_sample_indices, GoniometerClock};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::phase_wheel::PhaseWheelAnalyzer;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::ppm::{PpmDetector, PpmStandard};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::rms::{RmsIntegration, TrueRmsDetector};
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::rta::{
|
||||
design_fractional_octave_band, design_legacy_repeated_band, exact_fractional_octave_center,
|
||||
fractional_octave_edges, integrate_power, integrate_power_asymmetric, normalize_weighting,
|
||||
@@ -82,7 +86,6 @@ const BOX_MIN_DB: f32 = -90.0;
|
||||
#[cfg(target_os = "linux")]
|
||||
const BOX_MAX_DB: f32 = 9.0;
|
||||
#[cfg(target_os = "linux")]
|
||||
const VU_WINDOW_MS: f32 = 300.0;
|
||||
#[cfg(target_os = "linux")]
|
||||
const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -335,16 +338,6 @@ struct LoudnessBiquadState {
|
||||
y2: f32,
|
||||
}
|
||||
|
||||
struct MovingAverageWindow {
|
||||
window_len: usize,
|
||||
ring_l: Vec<f32>,
|
||||
ring_r: Vec<f32>,
|
||||
pos: usize,
|
||||
fill: usize,
|
||||
sum_l: f64,
|
||||
sum_r: f64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct LufsState {
|
||||
sample_rate: u32,
|
||||
@@ -438,6 +431,10 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
|
||||
period_size: deps.config.period_size,
|
||||
correlation: 0.0,
|
||||
correlation_negative_peak: 0.0,
|
||||
phase_angle_rad: None,
|
||||
phase_coherence: 0.0,
|
||||
phase_level: 0.0,
|
||||
phase_peak: 0.0,
|
||||
rms_l: -120.0,
|
||||
rms_r: -120.0,
|
||||
vu_l: -120.0,
|
||||
@@ -578,7 +575,7 @@ struct PpmState {
|
||||
din_ppm: PpmDetector,
|
||||
ebu_ppm: PpmDetector,
|
||||
vu_meter: VuMeter,
|
||||
rms_window: MovingAverageWindow,
|
||||
rms_meter: TrueRmsDetector,
|
||||
rta_signature: String,
|
||||
rta_state: Option<RtaEngineState>,
|
||||
spectro_signature: String,
|
||||
@@ -599,6 +596,7 @@ struct PpmState {
|
||||
transport_clock: GoniometerClock,
|
||||
transport_peaks: TransportPeaks,
|
||||
correlation: CorrelationMeter,
|
||||
phase_wheel: PhaseWheelAnalyzer,
|
||||
xy_pending_l: Vec<f32>,
|
||||
xy_pending_r: Vec<f32>,
|
||||
}
|
||||
@@ -610,7 +608,7 @@ impl Default for PpmState {
|
||||
din_ppm: PpmDetector::new(48_000, PpmStandard::Din),
|
||||
ebu_ppm: PpmDetector::new(48_000, PpmStandard::EbuTypeIib),
|
||||
vu_meter: VuMeter::new(48_000),
|
||||
rms_window: create_moving_average_window(48_000, VU_WINDOW_MS),
|
||||
rms_meter: TrueRmsDetector::new(48_000, RmsIntegration::Fast),
|
||||
rta_signature: String::new(),
|
||||
rta_state: None,
|
||||
spectro_signature: String::new(),
|
||||
@@ -631,6 +629,7 @@ impl Default for PpmState {
|
||||
transport_clock: GoniometerClock::default(),
|
||||
transport_peaks: TransportPeaks::default(),
|
||||
correlation: CorrelationMeter::new(48_000, 1.0, 0),
|
||||
phase_wheel: PhaseWheelAnalyzer::new(48_000),
|
||||
xy_pending_l: Vec::with_capacity(1024),
|
||||
xy_pending_r: Vec::with_capacity(1024),
|
||||
}
|
||||
@@ -779,50 +778,6 @@ fn wave_env_flush(state: &mut WaveEnvState) -> Option<WaveEnvFrame> {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_moving_average_window(sample_rate: u32, window_ms: f32) -> MovingAverageWindow {
|
||||
let sr = sample_rate.max(8_000) as f32;
|
||||
let window_len = ((window_ms.max(0.1) / 1000.0) * sr).round().max(1.0) as usize;
|
||||
MovingAverageWindow {
|
||||
window_len,
|
||||
ring_l: vec![0.0; window_len],
|
||||
ring_r: vec![0.0; window_len],
|
||||
pos: 0,
|
||||
fill: 0,
|
||||
sum_l: 0.0,
|
||||
sum_r: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn ensure_moving_average_window(state: &mut MovingAverageWindow, sample_rate: u32, window_ms: f32) {
|
||||
let desired_len = ((window_ms.max(0.1) / 1000.0) * sample_rate.max(8_000) as f32)
|
||||
.round()
|
||||
.max(1.0) as usize;
|
||||
if state.window_len == desired_len {
|
||||
return;
|
||||
}
|
||||
*state = create_moving_average_window(sample_rate, window_ms);
|
||||
}
|
||||
|
||||
fn moving_average_push(state: &mut MovingAverageWindow, l: f32, r: f32) -> (f32, f32) {
|
||||
if state.fill < state.window_len {
|
||||
state.fill += 1;
|
||||
} else {
|
||||
state.sum_l -= f64::from(state.ring_l[state.pos]);
|
||||
state.sum_r -= f64::from(state.ring_r[state.pos]);
|
||||
}
|
||||
state.ring_l[state.pos] = l;
|
||||
state.ring_r[state.pos] = r;
|
||||
state.sum_l += f64::from(l);
|
||||
state.sum_r += f64::from(r);
|
||||
state.pos = (state.pos + 1) % state.window_len;
|
||||
let denom = state.fill.max(1) as f32;
|
||||
(
|
||||
(state.sum_l / denom as f64) as f32,
|
||||
(state.sum_r / denom as f64) as f32,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn create_biquad_from_coeffs(b0: f32, b1: f32, b2: f32, a1: f32, a2: f32) -> LoudnessBiquadState {
|
||||
LoudnessBiquadState {
|
||||
@@ -1198,25 +1153,25 @@ fn process_audio_block(
|
||||
rta_config: &PhoenixRtaConfig,
|
||||
spectro_requested: bool,
|
||||
) -> (Option<MeterFrame>, Option<SpectroFrame>) {
|
||||
let mut rms_power_l = 0.0f32;
|
||||
let mut rms_power_r = 0.0f32;
|
||||
let mut rms_amp_l = 0.0f32;
|
||||
let mut rms_amp_r = 0.0f32;
|
||||
let mut vu_l_amp = 0.0f32;
|
||||
let mut vu_r_amp = 0.0f32;
|
||||
let frames = interleaved.len() / 2;
|
||||
ensure_wave_env_state(&mut ppm_state.wave_env, sample_rate);
|
||||
ensure_lufs_state(&mut ppm_state.lufs, sample_rate);
|
||||
|
||||
let din_standard = if rta_config.ppm_din_fast_attack {
|
||||
PpmStandard::DinSample
|
||||
} else {
|
||||
PpmStandard::Din
|
||||
};
|
||||
ppm_state.din_ppm.ensure_profile(sample_rate, din_standard);
|
||||
ppm_state
|
||||
.din_ppm
|
||||
.ensure_profile(sample_rate, PpmStandard::Din);
|
||||
ppm_state
|
||||
.ebu_ppm
|
||||
.ensure_profile(sample_rate, PpmStandard::EbuTypeIib);
|
||||
ppm_state.vu_meter.ensure_sample_rate(sample_rate);
|
||||
ensure_moving_average_window(&mut ppm_state.rms_window, sample_rate, VU_WINDOW_MS);
|
||||
ppm_state.rms_meter.configure(
|
||||
sample_rate,
|
||||
RmsIntegration::from_config(&rta_config.rms_integration),
|
||||
);
|
||||
let gain_l = db_gain(configured_input_offset_db(rta_config.input_offset_db_l));
|
||||
let gain_r = db_gain(configured_input_offset_db(rta_config.input_offset_db_r));
|
||||
|
||||
@@ -1229,6 +1184,7 @@ fn process_audio_block(
|
||||
ensure_rta_state(ppm_state, sample_rate, rta_config);
|
||||
ensure_spectro_state(ppm_state, sample_rate, rta_config);
|
||||
ensure_lr_delay_state(ppm_state, rta_config);
|
||||
ppm_state.phase_wheel.configure(sample_rate);
|
||||
|
||||
for frame in interleaved.chunks_exact(2) {
|
||||
let mut l = ((frame[0] as f32) / 32768.0) * gain_l;
|
||||
@@ -1242,7 +1198,7 @@ fn process_audio_block(
|
||||
wave_env_accumulate(&mut ppm_state.wave_env, l, r, 2);
|
||||
process_lufs_sample(&mut ppm_state.lufs, l, r, rta_config);
|
||||
|
||||
(rms_power_l, rms_power_r) = moving_average_push(&mut ppm_state.rms_window, l * l, r * r);
|
||||
(rms_amp_l, rms_amp_r) = ppm_state.rms_meter.process(l, r);
|
||||
let abs_l = l.abs();
|
||||
let abs_r = r.abs();
|
||||
ppm_state.din_ppm.process(l, r);
|
||||
@@ -1255,6 +1211,7 @@ fn process_audio_block(
|
||||
.observe(abs_l, abs_r, true_peak_l, true_peak_r);
|
||||
|
||||
ppm_state.correlation.process(l, r);
|
||||
ppm_state.phase_wheel.process(l, r);
|
||||
ppm_state.xy_pending_l.push(l);
|
||||
ppm_state.xy_pending_r.push(r);
|
||||
|
||||
@@ -1313,8 +1270,8 @@ fn process_audio_block(
|
||||
let tp_l = dbfs(transport_peak_l);
|
||||
let tp_r = dbfs(transport_peak_r);
|
||||
|
||||
let rms_l = dbfs(rms_power_l.max(0.0).sqrt());
|
||||
let rms_r = dbfs(rms_power_r.max(0.0).sqrt());
|
||||
let rms_l = dbfs(rms_amp_l);
|
||||
let rms_r = dbfs(rms_amp_r);
|
||||
let vu_l = dbfs(vu_l_amp);
|
||||
let vu_r = dbfs(vu_r_amp);
|
||||
update_box_meter(&mut ppm_state.lufs);
|
||||
@@ -1326,6 +1283,7 @@ fn process_audio_block(
|
||||
let ppm_ebu_r = dbfs(ppm_ebu_amp_r);
|
||||
let wave_env = wave_env_flush(&mut ppm_state.wave_env);
|
||||
let (xy_l, xy_r) = take_goniometer_samples(ppm_state, rta_config.xy_points as usize);
|
||||
let phase = ppm_state.phase_wheel.take_snapshot();
|
||||
|
||||
let frame = MeterFrame {
|
||||
seq: seq.fetch_add(1, Ordering::Relaxed) + 1,
|
||||
@@ -1337,6 +1295,10 @@ fn process_audio_block(
|
||||
period_size: frames.min(u32::MAX as usize) as u32,
|
||||
correlation: ppm_state.correlation.value(),
|
||||
correlation_negative_peak: ppm_state.correlation.negative_peak(),
|
||||
phase_angle_rad: phase.angle_rad,
|
||||
phase_coherence: phase.coherence,
|
||||
phase_level: phase.level,
|
||||
phase_peak: phase.peak,
|
||||
rms_l,
|
||||
rms_r,
|
||||
vu_l,
|
||||
@@ -2323,32 +2285,6 @@ mod tests {
|
||||
bins[tone_bin]
|
||||
}
|
||||
|
||||
fn run_rms(period: usize) -> f32 {
|
||||
let sample_rate = 48_000;
|
||||
let mut window = create_moving_average_window(sample_rate, 300.0);
|
||||
let mut result = 0.0;
|
||||
let samples: Vec<f32> = (0..sample_rate as usize)
|
||||
.map(|index| {
|
||||
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
|
||||
})
|
||||
.collect();
|
||||
for block in samples.chunks(period) {
|
||||
for &sample in block {
|
||||
result = moving_average_push(&mut window, sample * sample, sample * sample).0;
|
||||
}
|
||||
}
|
||||
result.sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_rms_is_independent_of_capture_period() {
|
||||
let reference = run_rms(1);
|
||||
assert!((reference - std::f32::consts::FRAC_1_SQRT_2).abs() < 1.0e-4);
|
||||
for period in [64, 127, 128, 192, 512] {
|
||||
assert!((run_rms(period) - reference).abs() < 1.0e-7);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fft_integrated_energy_compensates_hann_window_and_fft_size() {
|
||||
let expected = 0.5f32;
|
||||
|
||||
@@ -3,7 +3,9 @@ mod config;
|
||||
mod correlation;
|
||||
mod goniometer;
|
||||
mod model;
|
||||
mod phase_wheel;
|
||||
mod ppm;
|
||||
mod rms;
|
||||
mod routes;
|
||||
mod rta;
|
||||
mod state;
|
||||
|
||||
+9
-4
@@ -76,9 +76,9 @@ pub struct PhoenixRtaConfig {
|
||||
pub input_offset_db_r: f32,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub rms_integration: String,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub correlation_response_s: f32,
|
||||
@@ -112,9 +112,9 @@ impl Default for PhoenixRtaConfig {
|
||||
input_offset_db_r: -5.0,
|
||||
ppm_din_attack_ms: 10.0,
|
||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||
rms_integration: "fast".to_string(),
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
correlation_response_s: 1.0,
|
||||
@@ -140,9 +140,9 @@ pub struct PhoenixGlobalConfig {
|
||||
pub panel_dividers_enabled: bool,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub rms_integration: String,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub ppm_din_loudness_boxes: bool,
|
||||
@@ -209,9 +209,9 @@ impl Default for PhoenixGlobalConfig {
|
||||
panel_dividers_enabled: true,
|
||||
ppm_din_attack_ms: 10.0,
|
||||
ppm_din_decay_db_per_s: 20.0 / 1.5,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 24.0 / 2.8,
|
||||
rms_integration: "fast".to_string(),
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
ppm_din_loudness_boxes: true,
|
||||
@@ -279,6 +279,11 @@ pub struct MeterFrame {
|
||||
pub period_size: u32,
|
||||
pub correlation: f32,
|
||||
pub correlation_negative_peak: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase_angle_rad: Option<f32>,
|
||||
pub phase_coherence: f32,
|
||||
pub phase_level: f32,
|
||||
pub phase_peak: f32,
|
||||
pub rms_l: f32,
|
||||
pub rms_r: f32,
|
||||
pub vu_l: f32,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Continuous phase-wheel analysis on the native-rate audio stream.
|
||||
|
||||
const HILBERT_TAPS: usize = 33;
|
||||
const HILBERT_HALF: usize = (HILBERT_TAPS - 1) / 2;
|
||||
const BANDPASS_LOW_HZ: f64 = 300.0;
|
||||
const BANDPASS_HIGH_HZ: f64 = 5_000.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PhaseWheelSnapshot {
|
||||
pub angle_rad: Option<f32>,
|
||||
pub coherence: f32,
|
||||
pub level: f32,
|
||||
pub peak: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct BandpassChannel {
|
||||
hp_x: f64,
|
||||
hp_y: f64,
|
||||
lp_y: f64,
|
||||
}
|
||||
|
||||
impl BandpassChannel {
|
||||
fn process(&mut self, sample: f64, hp_alpha: f64, lp_alpha: f64) -> f64 {
|
||||
let hp = hp_alpha * (self.hp_y + sample - self.hp_x);
|
||||
self.hp_x = sample;
|
||||
self.hp_y = hp;
|
||||
self.lp_y = lp_alpha * hp + (1.0 - lp_alpha) * self.lp_y;
|
||||
self.lp_y
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PhaseWheelAnalyzer {
|
||||
sample_rate: u32,
|
||||
hp_alpha: f64,
|
||||
lp_alpha: f64,
|
||||
band_l: BandpassChannel,
|
||||
band_r: BandpassChannel,
|
||||
ring_l: [f64; HILBERT_TAPS],
|
||||
ring_r: [f64; HILBERT_TAPS],
|
||||
hilbert: [f64; HILBERT_TAPS],
|
||||
write: usize,
|
||||
fill: usize,
|
||||
cross_re: f64,
|
||||
cross_im: f64,
|
||||
weight_sum: f64,
|
||||
amplitude_sum: f64,
|
||||
amplitude_peak: f64,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl PhaseWheelAnalyzer {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let mut analyzer = Self {
|
||||
sample_rate: 0,
|
||||
hp_alpha: 0.0,
|
||||
lp_alpha: 0.0,
|
||||
band_l: BandpassChannel::default(),
|
||||
band_r: BandpassChannel::default(),
|
||||
ring_l: [0.0; HILBERT_TAPS],
|
||||
ring_r: [0.0; HILBERT_TAPS],
|
||||
hilbert: build_hilbert_kernel(),
|
||||
write: 0,
|
||||
fill: 0,
|
||||
cross_re: 0.0,
|
||||
cross_im: 0.0,
|
||||
weight_sum: 0.0,
|
||||
amplitude_sum: 0.0,
|
||||
amplitude_peak: 0.0,
|
||||
count: 0,
|
||||
};
|
||||
analyzer.configure(sample_rate);
|
||||
analyzer
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, sample_rate: u32) {
|
||||
let rate = sample_rate.max(8_000);
|
||||
if self.sample_rate == rate {
|
||||
return;
|
||||
}
|
||||
self.sample_rate = rate;
|
||||
self.hp_alpha = highpass_alpha(rate, BANDPASS_LOW_HZ);
|
||||
self.lp_alpha = lowpass_alpha(rate, BANDPASS_HIGH_HZ);
|
||||
self.band_l = BandpassChannel::default();
|
||||
self.band_r = BandpassChannel::default();
|
||||
self.ring_l.fill(0.0);
|
||||
self.ring_r.fill(0.0);
|
||||
self.write = 0;
|
||||
self.fill = 0;
|
||||
self.clear_accumulator();
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) {
|
||||
let filtered_l = self
|
||||
.band_l
|
||||
.process(left as f64, self.hp_alpha, self.lp_alpha);
|
||||
let filtered_r = self
|
||||
.band_r
|
||||
.process(right as f64, self.hp_alpha, self.lp_alpha);
|
||||
self.ring_l[self.write] = filtered_l;
|
||||
self.ring_r[self.write] = filtered_r;
|
||||
self.write = (self.write + 1) % HILBERT_TAPS;
|
||||
self.fill = (self.fill + 1).min(HILBERT_TAPS);
|
||||
if self.fill < HILBERT_TAPS {
|
||||
return;
|
||||
}
|
||||
|
||||
// `write` points at the oldest sample. The real component is delayed
|
||||
// by half the FIR length, so it is aligned with the causal Hilbert FIR.
|
||||
let real_index = (self.write + HILBERT_HALF) % HILBERT_TAPS;
|
||||
let l_re = self.ring_l[real_index].clamp(-1.0, 1.0);
|
||||
let r_re = self.ring_r[real_index].clamp(-1.0, 1.0);
|
||||
let mut l_im = 0.0;
|
||||
let mut r_im = 0.0;
|
||||
// The ideal odd Hilbert kernel has zero coefficients at every even
|
||||
// offset; with a 33-tap kernel those are the even tap indices.
|
||||
for tap in (1..HILBERT_TAPS).step_by(2) {
|
||||
let index = (self.write + tap) % HILBERT_TAPS;
|
||||
l_im += self.ring_l[index] * self.hilbert[tap];
|
||||
r_im += self.ring_r[index] * self.hilbert[tap];
|
||||
}
|
||||
|
||||
let mag_l = l_re.hypot(l_im).min(1.0);
|
||||
let mag_r = r_re.hypot(r_im).min(1.0);
|
||||
let weight = mag_l * mag_r;
|
||||
// zL * conj(zR): its argument is the energy-weighted L/R phase.
|
||||
self.cross_re += l_re * r_re + l_im * r_im;
|
||||
self.cross_im += l_im * r_re - l_re * r_im;
|
||||
self.weight_sum += weight;
|
||||
let amplitude = 0.5 * (mag_l + mag_r);
|
||||
self.amplitude_sum += amplitude;
|
||||
self.amplitude_peak = self.amplitude_peak.max(amplitude);
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
pub fn take_snapshot(&mut self) -> PhaseWheelSnapshot {
|
||||
let level = if self.count > 0 {
|
||||
(self.amplitude_sum / self.count as f64) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let resultant = self.cross_re.hypot(self.cross_im);
|
||||
let angle_rad = if self.weight_sum > 1e-12 && resultant > self.weight_sum * 1e-9 {
|
||||
Some(self.cross_im.atan2(self.cross_re) as f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let coherence = if self.weight_sum > 1e-12 {
|
||||
(resultant / self.weight_sum).clamp(0.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let snapshot = PhaseWheelSnapshot {
|
||||
angle_rad,
|
||||
coherence,
|
||||
level,
|
||||
peak: self.amplitude_peak as f32,
|
||||
};
|
||||
self.clear_accumulator();
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn clear_accumulator(&mut self) {
|
||||
self.cross_re = 0.0;
|
||||
self.cross_im = 0.0;
|
||||
self.weight_sum = 0.0;
|
||||
self.amplitude_sum = 0.0;
|
||||
self.amplitude_peak = 0.0;
|
||||
self.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn highpass_alpha(sample_rate: u32, cutoff: f64) -> f64 {
|
||||
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0));
|
||||
let dt = 1.0 / sample_rate.max(1) as f64;
|
||||
(rc / (rc + dt)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn lowpass_alpha(sample_rate: u32, cutoff: f64) -> f64 {
|
||||
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff.max(1.0));
|
||||
let dt = 1.0 / sample_rate.max(1) as f64;
|
||||
(dt / (rc + dt)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn build_hilbert_kernel() -> [f64; HILBERT_TAPS] {
|
||||
let mut kernel = [0.0; HILBERT_TAPS];
|
||||
for (index, value) in kernel.iter_mut().enumerate() {
|
||||
let offset = index as isize - HILBERT_HALF as isize;
|
||||
if offset == 0 || offset % 2 == 0 {
|
||||
continue;
|
||||
}
|
||||
let window = 0.54
|
||||
- 0.46
|
||||
* ((2.0 * std::f64::consts::PI * index as f64) / (HILBERT_TAPS - 1) as f64).cos();
|
||||
*value = 2.0 / (std::f64::consts::PI * offset as f64) * window;
|
||||
}
|
||||
kernel
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn feed_tone(analyzer: &mut PhaseWheelAnalyzer, phase: f64, samples: usize) {
|
||||
let omega = 2.0 * std::f64::consts::PI * 1_000.0 / 48_000.0;
|
||||
for index in 0..samples {
|
||||
let t = omega * index as f64;
|
||||
analyzer.process((0.5 * t.sin()) as f32, (0.5 * (t - phase).sin()) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_analyzer_tracks_tone_phase() {
|
||||
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
|
||||
feed_tone(&mut analyzer, std::f64::consts::FRAC_PI_2, 4_800);
|
||||
let snapshot = analyzer.take_snapshot();
|
||||
let angle = snapshot.angle_rad.expect("coherent tone has a phase");
|
||||
assert!((angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 0.03);
|
||||
assert!(
|
||||
snapshot.coherence > 0.9,
|
||||
"coherence was {}",
|
||||
snapshot.coherence
|
||||
);
|
||||
assert!(snapshot.level > 0.1);
|
||||
assert!(snapshot.peak >= snapshot.level);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reset_does_not_reset_filter_or_hilbert_history() {
|
||||
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
|
||||
feed_tone(&mut analyzer, 0.4, 2_400);
|
||||
let first = analyzer.take_snapshot().angle_rad.unwrap();
|
||||
feed_tone(&mut analyzer, 0.4, 800);
|
||||
let second = analyzer.take_snapshot().angle_rad.unwrap();
|
||||
assert!((first - second).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_signal_does_not_invent_a_phase() {
|
||||
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
|
||||
for index in 0..2_400 {
|
||||
let t = 2.0 * std::f64::consts::PI * 1_000.0 * index as f64 / 48_000.0;
|
||||
analyzer.process((0.5 * t.sin()) as f32, 0.0);
|
||||
}
|
||||
let snapshot = analyzer.take_snapshot();
|
||||
assert!(snapshot.angle_rad.is_none());
|
||||
assert_eq!(snapshot.coherence, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn energetic_component_dominates_a_quiet_conflicting_tone() {
|
||||
let mut analyzer = PhaseWheelAnalyzer::new(48_000);
|
||||
for index in 0..9_600 {
|
||||
let t = index as f64 / 48_000.0;
|
||||
let strong = 2.0 * std::f64::consts::PI * 1_000.0 * t;
|
||||
let quiet = 2.0 * std::f64::consts::PI * 2_000.0 * t;
|
||||
let left = 0.5 * strong.sin() + 0.04 * quiet.sin();
|
||||
let right = 0.5 * strong.sin() + 0.04 * (quiet - std::f64::consts::FRAC_PI_2).sin();
|
||||
analyzer.process(left as f32, right as f32);
|
||||
}
|
||||
let angle = analyzer.take_snapshot().angle_rad.unwrap();
|
||||
assert!(angle.abs() < 0.03, "quiet tone pulled phase to {angle}");
|
||||
}
|
||||
}
|
||||
+23
-45
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The detector is deliberately independent of ALSA block boundaries. Its two
|
||||
//! attack branches are calibrated against the 5 kHz tone-burst response in EBU
|
||||
//! Tech 3205-E. DIN uses the RTW PortaMonitor/Peakmeter norm profile (10 ms
|
||||
//! integration, 20 dB return in 1.5 s). The optional DIN sample mode is kept
|
||||
//! separate and must never be labelled as a standards-compliant DIN reading.
|
||||
//! Tech 3205-E. DIN uses the DIN 45406 / IEC 60268-10 profile (10 ms
|
||||
//! integration, 20 dB return in 1.5 s). Both standards define the normal
|
||||
//! quasi-peak attack by the 5 kHz, 10 ms burst reaching 2 dB below the
|
||||
//! continuous-tone indication; their normal-mode return times differ.
|
||||
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
@@ -19,21 +20,16 @@ const INTERP_TAPS: usize = INTERP_RADIUS * 2 + 1;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PpmStandard {
|
||||
Din,
|
||||
DinSample,
|
||||
EbuTypeIib,
|
||||
}
|
||||
|
||||
impl PpmStandard {
|
||||
fn return_db_per_second(self) -> f32 {
|
||||
match self {
|
||||
Self::Din | Self::DinSample => 20.0 / 1.5,
|
||||
Self::Din => 20.0 / 1.5,
|
||||
Self::EbuTypeIib => 24.0 / 2.8,
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_sample_attack(self) -> bool {
|
||||
matches!(self, Self::DinSample)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -61,11 +57,7 @@ pub struct PpmDetector {
|
||||
impl PpmDetector {
|
||||
pub fn new(sample_rate: u32, standard: PpmStandard) -> Self {
|
||||
let sr = sample_rate.max(8_000);
|
||||
let detector_rate = if standard.uses_sample_attack() {
|
||||
sr as f32
|
||||
} else {
|
||||
(sr * OVERSAMPLE as u32) as f32
|
||||
};
|
||||
let detector_rate = (sr * OVERSAMPLE as u32) as f32;
|
||||
let coeff = |tau_s: f32| (-1.0 / (detector_rate * tau_s)).exp();
|
||||
let release_coeff = 10.0f32.powf(-standard.return_db_per_second() / (20.0 * detector_rate));
|
||||
Self {
|
||||
@@ -91,31 +83,10 @@ impl PpmDetector {
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
if self.standard.uses_sample_attack() {
|
||||
let l = Self::process_channel(
|
||||
&mut self.left,
|
||||
left.abs(),
|
||||
self.standard,
|
||||
self.fast_coeff,
|
||||
self.slow_coeff,
|
||||
self.release_coeff,
|
||||
);
|
||||
let r = Self::process_channel(
|
||||
&mut self.right,
|
||||
right.abs(),
|
||||
self.standard,
|
||||
self.fast_coeff,
|
||||
self.slow_coeff,
|
||||
self.release_coeff,
|
||||
);
|
||||
return (l, r);
|
||||
}
|
||||
|
||||
self.raw_l[self.raw_pos] = left;
|
||||
self.raw_r[self.raw_pos] = right;
|
||||
self.raw_pos = (self.raw_pos + 1) % INTERP_TAPS;
|
||||
|
||||
let standard = self.standard;
|
||||
let fast_coeff = self.fast_coeff;
|
||||
let slow_coeff = self.slow_coeff;
|
||||
let release_coeff = self.release_coeff;
|
||||
@@ -131,7 +102,6 @@ impl PpmDetector {
|
||||
Self::process_channel(
|
||||
&mut self.left,
|
||||
l.abs(),
|
||||
standard,
|
||||
fast_coeff,
|
||||
slow_coeff,
|
||||
release_coeff,
|
||||
@@ -139,7 +109,6 @@ impl PpmDetector {
|
||||
Self::process_channel(
|
||||
&mut self.right,
|
||||
r.abs(),
|
||||
standard,
|
||||
fast_coeff,
|
||||
slow_coeff,
|
||||
release_coeff,
|
||||
@@ -183,18 +152,10 @@ impl PpmDetector {
|
||||
fn process_channel(
|
||||
state: &mut PpmChannel,
|
||||
input: f32,
|
||||
standard: PpmStandard,
|
||||
fast_coeff: f32,
|
||||
slow_coeff: f32,
|
||||
release_coeff: f32,
|
||||
) -> f32 {
|
||||
if standard.uses_sample_attack() {
|
||||
state.output = input.max(state.output * release_coeff);
|
||||
state.fast = state.output;
|
||||
state.slow = state.output;
|
||||
return state.output;
|
||||
}
|
||||
|
||||
state.fast = if input > state.fast {
|
||||
fast_coeff * state.fast + (1.0 - fast_coeff) * input
|
||||
} else {
|
||||
@@ -273,6 +234,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn din_matches_normative_ten_millisecond_tone_burst() {
|
||||
// DIN 45406 / IEC 60268-10 integration time: a 5 kHz burst at
|
||||
// reference level must indicate 2 dB below the continuous-tone value
|
||||
// after 10 ms. RTW specifies the same 10 ms normal integration for
|
||||
// its DIN peakmeters.
|
||||
let mut continuous = PpmDetector::new(SR, PpmStandard::Din);
|
||||
let reference = run_tone(&mut continuous, 5_000.0, 0.5, 500.0);
|
||||
let mut detector = PpmDetector::new(SR, PpmStandard::Din);
|
||||
let measured = run_tone(&mut detector, 5_000.0, 0.5, 10.0);
|
||||
let relative_db = db(measured / reference);
|
||||
assert!(
|
||||
(relative_db - (-2.0)).abs() <= 0.5,
|
||||
"10 ms: measured {relative_db:.3} dB, expected -2.000 +/- 0.500 dB"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ebu_return_time_is_24_db_in_2_8_seconds() {
|
||||
assert_return_time(PpmStandard::EbuTypeIib, 24.0, 2.8, 0.02);
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RmsIntegration {
|
||||
Fast,
|
||||
Slow,
|
||||
Window300,
|
||||
}
|
||||
|
||||
impl RmsIntegration {
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn from_config(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"slow" => Self::Slow,
|
||||
"window" | "window300" | "none" => Self::Window300,
|
||||
_ => Self::Fast,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample-continuous true-RMS detector.
|
||||
///
|
||||
/// Time weighting is applied to linear squared samples. Taking the square root
|
||||
/// and converting to decibels happens only after integration. This is important:
|
||||
/// averaging already-logarithmic dB values does not produce an RMS value.
|
||||
pub struct TrueRmsDetector {
|
||||
sample_rate: u32,
|
||||
mode: RmsIntegration,
|
||||
power_l: f64,
|
||||
power_r: f64,
|
||||
fast_alpha: f64,
|
||||
slow_alpha: f64,
|
||||
window_l: Vec<f64>,
|
||||
window_r: Vec<f64>,
|
||||
window_pos: usize,
|
||||
window_fill: usize,
|
||||
window_sum_l: f64,
|
||||
window_sum_r: f64,
|
||||
}
|
||||
|
||||
impl TrueRmsDetector {
|
||||
pub fn new(sample_rate: u32, mode: RmsIntegration) -> Self {
|
||||
let mut detector = Self {
|
||||
sample_rate: 0,
|
||||
mode,
|
||||
power_l: 0.0,
|
||||
power_r: 0.0,
|
||||
fast_alpha: 0.0,
|
||||
slow_alpha: 0.0,
|
||||
window_l: Vec::new(),
|
||||
window_r: Vec::new(),
|
||||
window_pos: 0,
|
||||
window_fill: 0,
|
||||
window_sum_l: 0.0,
|
||||
window_sum_r: 0.0,
|
||||
};
|
||||
detector.configure(sample_rate, mode);
|
||||
detector
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, sample_rate: u32, mode: RmsIntegration) {
|
||||
let sample_rate = sample_rate.max(8_000);
|
||||
if self.sample_rate == sample_rate && self.mode == mode {
|
||||
return;
|
||||
}
|
||||
self.sample_rate = sample_rate;
|
||||
self.mode = mode;
|
||||
self.fast_alpha = alpha(sample_rate, 0.125);
|
||||
self.slow_alpha = alpha(sample_rate, 1.0);
|
||||
if mode == RmsIntegration::Window300 {
|
||||
let window_len = ((sample_rate as f64 * 0.300).round() as usize).max(1);
|
||||
self.window_l = vec![0.0; window_len];
|
||||
self.window_r = vec![0.0; window_len];
|
||||
} else {
|
||||
self.window_l.clear();
|
||||
self.window_r.clear();
|
||||
}
|
||||
self.reset();
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.power_l = 0.0;
|
||||
self.power_r = 0.0;
|
||||
self.window_l.fill(0.0);
|
||||
self.window_r.fill(0.0);
|
||||
self.window_pos = 0;
|
||||
self.window_fill = 0;
|
||||
self.window_sum_l = 0.0;
|
||||
self.window_sum_r = 0.0;
|
||||
}
|
||||
|
||||
pub fn process(&mut self, left: f32, right: f32) -> (f32, f32) {
|
||||
let input_l = f64::from(left) * f64::from(left);
|
||||
let input_r = f64::from(right) * f64::from(right);
|
||||
match self.mode {
|
||||
RmsIntegration::Fast => {
|
||||
self.power_l += self.fast_alpha * (input_l - self.power_l);
|
||||
self.power_r += self.fast_alpha * (input_r - self.power_r);
|
||||
}
|
||||
RmsIntegration::Slow => {
|
||||
self.power_l += self.slow_alpha * (input_l - self.power_l);
|
||||
self.power_r += self.slow_alpha * (input_r - self.power_r);
|
||||
}
|
||||
RmsIntegration::Window300 => self.process_window(input_l, input_r),
|
||||
}
|
||||
(
|
||||
self.power_l.max(0.0).sqrt() as f32,
|
||||
self.power_r.max(0.0).sqrt() as f32,
|
||||
)
|
||||
}
|
||||
|
||||
fn process_window(&mut self, input_l: f64, input_r: f64) {
|
||||
if self.window_fill < self.window_l.len() {
|
||||
self.window_fill += 1;
|
||||
} else {
|
||||
self.window_sum_l -= self.window_l[self.window_pos];
|
||||
self.window_sum_r -= self.window_r[self.window_pos];
|
||||
}
|
||||
self.window_l[self.window_pos] = input_l;
|
||||
self.window_r[self.window_pos] = input_r;
|
||||
self.window_sum_l += input_l;
|
||||
self.window_sum_r += input_r;
|
||||
self.window_pos = (self.window_pos + 1) % self.window_l.len();
|
||||
let denom = self.window_fill.max(1) as f64;
|
||||
self.power_l = self.window_sum_l / denom;
|
||||
self.power_r = self.window_sum_r / denom;
|
||||
}
|
||||
}
|
||||
|
||||
fn alpha(sample_rate: u32, tau_seconds: f64) -> f64 {
|
||||
1.0 - (-1.0 / (sample_rate as f64 * tau_seconds)).exp()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sine_rms(mode: RmsIntegration, block_size: usize) -> f32 {
|
||||
let sample_rate = 48_000u32;
|
||||
let mut detector = TrueRmsDetector::new(sample_rate, mode);
|
||||
let samples: Vec<f32> = (0..sample_rate * 12)
|
||||
.map(|index| {
|
||||
(2.0 * std::f32::consts::PI * 1_000.0 * index as f32 / sample_rate as f32).sin()
|
||||
})
|
||||
.collect();
|
||||
let mut result = 0.0;
|
||||
for block in samples.chunks(block_size) {
|
||||
for &sample in block {
|
||||
result = detector.process(sample, sample).0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sine_level_is_true_rms_for_every_integration() {
|
||||
for mode in [
|
||||
RmsIntegration::Fast,
|
||||
RmsIntegration::Slow,
|
||||
RmsIntegration::Window300,
|
||||
] {
|
||||
let measured = sine_rms(mode, 127);
|
||||
assert!(
|
||||
(measured - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-3,
|
||||
"{mode:?}: {measured}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_is_independent_of_capture_blocks() {
|
||||
for mode in [
|
||||
RmsIntegration::Fast,
|
||||
RmsIntegration::Slow,
|
||||
RmsIntegration::Window300,
|
||||
] {
|
||||
let reference = sine_rms(mode, 1);
|
||||
for block_size in [64, 127, 128, 192, 511, 512] {
|
||||
assert!((sine_rms(mode, block_size) - reference).abs() < 1.0e-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_and_slow_apply_the_declared_power_time_constants() {
|
||||
let sample_rate = 48_000;
|
||||
for (mode, tau) in [(RmsIntegration::Fast, 0.125), (RmsIntegration::Slow, 1.0)] {
|
||||
let mut detector = TrueRmsDetector::new(sample_rate, mode);
|
||||
let samples = (sample_rate as f64 * tau).round() as usize;
|
||||
let mut value = 0.0;
|
||||
for _ in 0..samples {
|
||||
value = detector.process(1.0, 1.0).0;
|
||||
}
|
||||
let expected = (1.0f64 - (-1.0f64).exp()).sqrt() as f32;
|
||||
assert!((value - expected).abs() < 2.0e-5, "{mode:?}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_window_is_exactly_300_milliseconds() {
|
||||
for sample_rate in [44_100, 48_000, 96_000] {
|
||||
let mut detector = TrueRmsDetector::new(sample_rate, RmsIntegration::Window300);
|
||||
let window_len = (sample_rate as f64 * 0.300).round() as usize;
|
||||
assert_eq!(detector.window_l.len(), window_len);
|
||||
let mut value = detector.process(1.0, 0.5).0;
|
||||
for _ in 1..window_len {
|
||||
value = detector.process(0.0, 0.0).0;
|
||||
}
|
||||
assert!(value > 0.0);
|
||||
let (left, right) = detector.process(0.0, 0.0);
|
||||
assert_eq!(left, 0.0);
|
||||
assert_eq!(right, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1281,6 +1281,10 @@ mod tests {
|
||||
period_size: 128,
|
||||
correlation: 0.25,
|
||||
correlation_negative_peak: -0.5,
|
||||
phase_angle_rad: Some(0.25),
|
||||
phase_coherence: 0.75,
|
||||
phase_level: 0.2,
|
||||
phase_peak: 0.3,
|
||||
rms_l: -20.0,
|
||||
rms_r: -21.0,
|
||||
vu_l: -20.0,
|
||||
|
||||
+46
-5
@@ -167,7 +167,10 @@ impl AppState {
|
||||
let updated_global = PhoenixGlobalConfig {
|
||||
fft_size: normalized.fft_size,
|
||||
input_source: current.input_source,
|
||||
rta_bpo_mode: current.rta_bpo_mode.clone(),
|
||||
// RTA resolution has one authoritative value. Keeping the old
|
||||
// global value here allowed /rta-config and /global-config to
|
||||
// disagree until the service was restarted.
|
||||
rta_bpo_mode: normalized.bpo.clone(),
|
||||
input_offset_db_l: normalized.input_offset_db_l,
|
||||
input_offset_db_r: normalized.input_offset_db_r,
|
||||
mono_input: normalized.mono_input,
|
||||
@@ -178,9 +181,9 @@ impl AppState {
|
||||
panel_dividers_enabled: current.panel_dividers_enabled,
|
||||
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
|
||||
ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s,
|
||||
ppm_din_fast_attack: normalized.ppm_din_fast_attack,
|
||||
ppm_ebu_attack_ms: normalized.ppm_ebu_attack_ms,
|
||||
ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s,
|
||||
rms_integration: normalized.rms_integration.clone(),
|
||||
lufs_i_window_min: normalized.lufs_i_window_min,
|
||||
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
||||
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
||||
@@ -260,6 +263,15 @@ impl AppState {
|
||||
let normalized = normalize_global_config(payload, &self.config);
|
||||
let current_global = self.global_config.read().await.clone();
|
||||
if current_global == normalized {
|
||||
// A legacy or racing /rta-config request may have changed the
|
||||
// runtime engine without changing the persisted global config.
|
||||
// Re-apply the authoritative global fields even when persistence
|
||||
// itself does not need an update.
|
||||
let repaired_rta = {
|
||||
let current = self.rta_config.read().await.clone();
|
||||
apply_global_to_rta(current, &normalized)
|
||||
};
|
||||
*self.rta_config.write().await = repaired_rta;
|
||||
return Ok(PhoenixGlobalConfigEnvelope {
|
||||
revision: self.global_config_revision(),
|
||||
config: current_global,
|
||||
@@ -462,9 +474,9 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||
// compatibility with older clients without allowing silent mistuning.
|
||||
config.ppm_din_attack_ms = 10.0;
|
||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = 10.0;
|
||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||
config.rms_integration = normalize_rms_integration(&config.rms_integration);
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.correlation_response_s =
|
||||
@@ -550,9 +562,9 @@ fn normalize_global_config(
|
||||
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
||||
config.ppm_din_attack_ms = 10.0;
|
||||
config.ppm_din_decay_db_per_s = 20.0 / 1.5;
|
||||
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = 10.0;
|
||||
config.ppm_ebu_decay_db_per_s = 24.0 / 2.8;
|
||||
config.rms_integration = normalize_rms_integration(&config.rms_integration);
|
||||
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
||||
@@ -728,15 +740,23 @@ fn apply_global_to_rta(
|
||||
config.lr_fractional_delay_samples = global.lr_fractional_delay_samples;
|
||||
config.ppm_din_attack_ms = global.ppm_din_attack_ms;
|
||||
config.ppm_din_decay_db_per_s = global.ppm_din_decay_db_per_s;
|
||||
config.ppm_din_fast_attack = global.ppm_din_fast_attack;
|
||||
config.ppm_ebu_attack_ms = global.ppm_ebu_attack_ms;
|
||||
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
||||
config.rms_integration = global.rms_integration.clone();
|
||||
config.lufs_i_window_min = global.lufs_i_window_min;
|
||||
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
||||
config.xy_points = global.xy_points;
|
||||
normalize_rta_config(config)
|
||||
}
|
||||
|
||||
fn normalize_rms_integration(value: &str) -> String {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"slow" => "slow".to_string(),
|
||||
"window" | "window300" | "none" => "window".to_string(),
|
||||
_ => "fast".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
||||
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
|
||||
let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else {
|
||||
@@ -866,4 +886,25 @@ mod tests {
|
||||
assert_eq!(config.bpo, "1_12");
|
||||
assert_eq!(config.engine, "iir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_octave_resolution_repairs_a_divergent_runtime_config() {
|
||||
let mut global = PhoenixGlobalConfig::default();
|
||||
global.rta_bpo_mode = "1_6".to_string();
|
||||
let runtime = PhoenixRtaConfig {
|
||||
bpo: "1_12".to_string(),
|
||||
..PhoenixRtaConfig::default()
|
||||
};
|
||||
let repaired = apply_global_to_rta(runtime, &global);
|
||||
assert_eq!(repaired.bpo, "1_6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_integration_rejects_non_rms_impulse_mode() {
|
||||
assert_eq!(normalize_rms_integration("fast"), "fast");
|
||||
assert_eq!(normalize_rms_integration("slow"), "slow");
|
||||
assert_eq!(normalize_rms_integration("window300"), "window");
|
||||
assert_eq!(normalize_rms_integration("none"), "window");
|
||||
assert_eq!(normalize_rms_integration("impulse"), "fast");
|
||||
}
|
||||
}
|
||||
|
||||
+128
-70
@@ -1,80 +1,105 @@
|
||||
//! Continuous, block-boundary-independent 4x true-peak interpolation.
|
||||
//! Sample-continuous true-peak detector according to ITU-R BS.1770 Annex 2.
|
||||
//!
|
||||
//! The four polyphase FIR branches are the reference 4x interpolation filter
|
||||
//! specified by BS.1770. Keeping the twelve input samples in a persistent ring
|
||||
//! makes the result independent of ALSA capture and WebSocket block boundaries.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
const PHASES: usize = 4;
|
||||
const TAPS: usize = 12;
|
||||
|
||||
const OVERSAMPLE: usize = 4;
|
||||
const RADIUS: usize = 8;
|
||||
const BUFFER_LEN: usize = RADIUS * 2 + 1;
|
||||
// ITU-R BS.1770 Annex 2, Table 2: coefficients for 4x oversampling.
|
||||
const INTERPOLATOR: [[f32; TAPS]; PHASES] = [
|
||||
[
|
||||
0.001_708_984_4,
|
||||
-0.010_986_328,
|
||||
0.019_653_32,
|
||||
-0.033_203_125,
|
||||
0.059_448_242,
|
||||
-0.137_329_1,
|
||||
0.972_167_97,
|
||||
0.188_598_63,
|
||||
-0.071_289_06,
|
||||
0.037_597_656,
|
||||
-0.021_362_305,
|
||||
0.010_986_328,
|
||||
],
|
||||
[
|
||||
-0.029_174_805,
|
||||
0.029_296_875,
|
||||
-0.051_757_813,
|
||||
0.089_111_33,
|
||||
-0.166_503_9,
|
||||
0.465_087_9,
|
||||
0.779_785_16,
|
||||
-0.200_317_38,
|
||||
0.101_562_5,
|
||||
-0.058_227_54,
|
||||
0.033_081_055,
|
||||
-0.018_920_898,
|
||||
],
|
||||
[
|
||||
-0.018_920_898,
|
||||
0.033_081_055,
|
||||
-0.058_227_54,
|
||||
0.101_562_5,
|
||||
-0.200_317_38,
|
||||
0.779_785_16,
|
||||
0.465_087_9,
|
||||
-0.166_503_9,
|
||||
0.089_111_33,
|
||||
-0.051_757_813,
|
||||
0.029_296_875,
|
||||
-0.029_174_805,
|
||||
],
|
||||
[
|
||||
0.010_986_328,
|
||||
-0.021_362_305,
|
||||
0.037_597_656,
|
||||
-0.071_289_06,
|
||||
0.188_598_63,
|
||||
0.972_167_97,
|
||||
-0.137_329_1,
|
||||
0.059_448_242,
|
||||
-0.033_203_125,
|
||||
0.019_653_32,
|
||||
-0.010_986_328,
|
||||
0.001_708_984_4,
|
||||
],
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TruePeakDetector {
|
||||
samples: VecDeque<f32>,
|
||||
history: [f32; TAPS],
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl Default for TruePeakDetector {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
samples: VecDeque::with_capacity(BUFFER_LEN + 1),
|
||||
history: [0.0; TAPS],
|
||||
next: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TruePeakDetector {
|
||||
pub fn process(&mut self, sample: f32) -> f32 {
|
||||
self.history[self.next] = sample;
|
||||
self.next = (self.next + 1) % TAPS;
|
||||
|
||||
let mut peak = sample.abs();
|
||||
self.samples.push_back(sample);
|
||||
if self.samples.len() < BUFFER_LEN {
|
||||
return peak;
|
||||
for phase in &INTERPOLATOR {
|
||||
let mut interpolated = 0.0f32;
|
||||
for (tap, &coefficient) in phase.iter().enumerate() {
|
||||
let index = (self.next + tap) % TAPS;
|
||||
interpolated += self.history[index] * coefficient;
|
||||
}
|
||||
peak = peak.max(interpolated.abs());
|
||||
}
|
||||
|
||||
let mut contiguous = [0.0f32; BUFFER_LEN];
|
||||
for (target, source) in contiguous.iter_mut().zip(self.samples.iter()) {
|
||||
*target = *source;
|
||||
}
|
||||
for phase in 1..OVERSAMPLE {
|
||||
let position = RADIUS as f32 + phase as f32 / OVERSAMPLE as f32;
|
||||
peak = peak.max(interpolate(&contiguous, position).abs());
|
||||
}
|
||||
self.samples.pop_front();
|
||||
peak
|
||||
}
|
||||
}
|
||||
|
||||
fn sinc(value: f32) -> f32 {
|
||||
if value.abs() < 1.0e-6 {
|
||||
1.0
|
||||
} else {
|
||||
let x = std::f32::consts::PI * value;
|
||||
x.sin() / x
|
||||
}
|
||||
}
|
||||
|
||||
fn blackman(value: f32) -> f32 {
|
||||
let span = (RADIUS * 2) as f32;
|
||||
let phase = 2.0 * std::f32::consts::PI * (value + RADIUS as f32) / span;
|
||||
0.42 - 0.5 * phase.cos() + 0.08 * (2.0 * phase).cos()
|
||||
}
|
||||
|
||||
fn interpolate(samples: &[f32], position: f32) -> f32 {
|
||||
let base = position.floor() as isize;
|
||||
let mut sum = 0.0;
|
||||
let mut norm = 0.0;
|
||||
for index in (base - RADIUS as isize + 1)..=(base + RADIUS as isize) {
|
||||
if !(0..samples.len() as isize).contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let distance = position - index as f32;
|
||||
let weight = sinc(distance) * blackman(distance);
|
||||
sum += samples[index as usize] * weight;
|
||||
norm += weight;
|
||||
}
|
||||
if norm.abs() > 1.0e-6 {
|
||||
sum / norm
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,19 +112,49 @@ mod tests {
|
||||
peak = peak.max(detector.process(sample));
|
||||
}
|
||||
}
|
||||
for _ in 0..BUFFER_LEN {
|
||||
// Drain the fixed FIR delay without resetting its sample history.
|
||||
for _ in 0..TAPS {
|
||||
peak = peak.max(detector.process(0.0));
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
fn sine(divisor: f32, amplitude: f32, phase_degrees: f32) -> Vec<f32> {
|
||||
let phase = phase_degrees.to_radians();
|
||||
let count = 4_800usize;
|
||||
let fade = 480usize;
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let angle = 2.0 * std::f32::consts::PI * index as f32 / divisor + phase;
|
||||
let edge = index.min(count - 1 - index);
|
||||
let taper = if edge < fade {
|
||||
let x = edge as f32 / fade as f32;
|
||||
0.5 - 0.5 * (std::f32::consts::PI * x).cos()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
angle.sin() * amplitude * taper
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dbtp(value: f32) -> f32 {
|
||||
20.0 * value.max(1.0e-12).log10()
|
||||
}
|
||||
|
||||
fn assert_ebu_true_peak(divisor: f32, amplitude: f32, phase_degrees: f32, expected: f32) {
|
||||
let measured = dbtp(run_in_blocks(&sine(divisor, amplitude, phase_degrees), 128));
|
||||
let low = expected - 0.4;
|
||||
let high = expected + 0.2;
|
||||
assert!(
|
||||
(low..=high).contains(&measured),
|
||||
"measured {measured:.4} dBTP, expected {expected:.1} dBTP (+0.2/-0.4)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_is_independent_of_capture_block_boundaries() {
|
||||
let samples: Vec<f32> = (0..4_800)
|
||||
.map(|index| {
|
||||
(2.0 * std::f32::consts::PI * 11_025.0 * index as f32 / 48_000.0 + 0.31).sin() * 0.9
|
||||
})
|
||||
.collect();
|
||||
let samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692);
|
||||
let reference = run_in_blocks(&samples, 1);
|
||||
for size in [64, 127, 128, 192, 511] {
|
||||
assert!((run_in_blocks(&samples, size) - reference).abs() < 1.0e-7);
|
||||
@@ -108,19 +163,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_an_intersample_peak_above_sample_peak() {
|
||||
let samples: Vec<f32> = (0..4_800)
|
||||
.map(|index| {
|
||||
(2.0 * std::f32::consts::PI * 11_025.0 * index as f32 / 48_000.0 + 0.31).sin() * 0.9
|
||||
})
|
||||
.collect();
|
||||
let samples = sine(48_000.0 / 11_025.0, 0.9, 17.761_692);
|
||||
let sample_peak = samples
|
||||
.iter()
|
||||
.fold(0.0f32, |peak, value| peak.max(value.abs()));
|
||||
let detected = run_in_blocks(&samples, 128);
|
||||
assert!(detected > sample_peak + 0.001);
|
||||
assert!(
|
||||
detected <= 0.91,
|
||||
"unexpected interpolation overshoot: {detected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_ebu_tech_3341_true_peak_tests_15_to_19() {
|
||||
// EBU Tech 3341 v4, minimum-requirements tests 15-19. Frequency is
|
||||
// expressed as a divisor of fs, so these remain valid at every rate.
|
||||
assert_ebu_true_peak(4.0, 0.50, 0.0, -6.0);
|
||||
assert_ebu_true_peak(4.0, 0.50, 45.0, -6.0);
|
||||
assert_ebu_true_peak(6.0, 0.50, 60.0, -6.0);
|
||||
assert_ebu_true_peak(8.0, 0.50, 67.5, -6.0);
|
||||
assert_ebu_true_peak(4.0, 1.41, 45.0, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user