Correct audio metering and realtime displays

This commit is contained in:
Mikei386
2026-07-22 10:50:15 +02:00
parent 97352ecfce
commit 2e868bfa3a
26 changed files with 999 additions and 903 deletions
+31 -95
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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,
+264
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}