Correct RTA detector timing and peak integration

This commit is contained in:
Mikei386 committed 2026-07-21 21:29:41 +02:00
1 parent 3478bfb495
commit 9b5250d42b
10 files changed
+312 -112

No files matched your search

+103 -62
View File
@@ -22,8 +22,9 @@ use crate::ppm::{PpmDetector, PpmStandard};
#[cfg(target_os = "linux")]
use crate::rta::{
design_fractional_octave_band, design_legacy_repeated_band, exact_fractional_octave_center,
fractional_octave_edges, integrate_power, normalize_weighting, weighting_power_correction,
weighting_reference_db, FrequencyWeighting, StereoCascade, RTW_THIRD_OCTAVE_CENTERS,
fractional_octave_edges, integrate_power, integrate_power_asymmetric, normalize_weighting,
weighting_power_correction, weighting_reference_db, FrequencyWeighting, SlidingPeak,
StereoCascade, RTW_THIRD_OCTAVE_CENTERS,
};
use crate::state::NativeWavRecorder;
use crate::{
@@ -127,11 +128,9 @@ struct RtaBank {
filters: Vec<StereoCascade>,
weighting: FrequencyWeighting,
weighting_corrections: Vec<f64>,
acc_l: Vec<f64>,
acc_r: Vec<f64>,
block_peaks: Vec<f64>,
average_acc: Vec<f64>,
average_samples: u64,
attack_alpha: f64,
release_alpha: f64,
peak_windows: Option<Vec<SlidingPeak>>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
@@ -172,6 +171,7 @@ struct FftRtaState {
fft_re_r: Vec<f32>,
fft_im_r: Vec<f32>,
power_bins: Vec<f32>,
energies: Vec<f64>,
levels: Vec<f32>,
peaks: Vec<f32>,
}
@@ -1235,7 +1235,7 @@ fn build_meter_frame(
if let Some(state) = ppm_state.rta_state.as_mut() {
match state {
RtaEngineState::Iir(bank) => {
finalize_rta_bank(bank, sample_rate, frames, 2, rta_config);
finalize_rta_bank(bank, sample_rate, frames);
ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config));
}
RtaEngineState::Fft(fft) => {
@@ -1329,7 +1329,7 @@ fn build_meter_frame(
#[cfg(target_os = "linux")]
fn ensure_rta_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) {
let signature = format!(
"{}|{}|{}|{}|{}|{}|{}|{}|{}",
"{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}",
normalize_engine(&config.engine),
normalize_filterbank(&config.filterbank),
normalize_layout(&config.layout),
@@ -1338,7 +1338,11 @@ fn ensure_rta_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaC
config.order.clamp(2, 8),
normalize_weighting(&config.weighting),
normalize_fft_size(config.fft_size),
sample_rate
sample_rate,
normalize_rta_detector(&config.detector),
normalize_rta_integration(&config.integration),
config.tau_fast,
config.tau_slow,
);
if state.rta_state.is_some() && state.rta_signature == signature {
return;
@@ -1481,6 +1485,16 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank {
filters.push(StereoCascade::new(coefficients));
}
let len = active_bands.len();
let (attack_tau, release_tau) = detector_time_constants(config);
let sample_interval = 1.0 / f64::from(sample_rate.max(1));
let attack_alpha = 1.0 - (-sample_interval / f64::from(attack_tau)).exp();
let release_alpha = 1.0 - (-sample_interval / f64::from(release_tau)).exp();
let peak_window_samples = (sample_rate / 100).max(1) as usize;
let peak_windows = (normalize_rta_detector(&config.detector) == "peak").then(|| {
(0..len)
.map(|_| SlidingPeak::new(peak_window_samples))
.collect()
});
RtaBank {
layout: active_layout,
bpo_key,
@@ -1490,11 +1504,9 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank {
filters,
weighting: FrequencyWeighting::new(&config.weighting, sample_rate),
weighting_corrections,
acc_l: vec![0.0; len],
acc_r: vec![0.0; len],
block_peaks: vec![0.0; len],
average_acc: vec![0.0; len],
average_samples: 0,
attack_alpha,
release_alpha,
peak_windows,
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
@@ -1533,6 +1545,7 @@ fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState
fft_re_r: vec![0.0; fft_size],
fft_im_r: vec![0.0; fft_size],
power_bins: vec![0.0; fft_size / 2],
energies: vec![0.0; len],
levels: vec![-120.0; len],
peaks: vec![-120.0; len],
}
@@ -1589,11 +1602,19 @@ fn process_rta_sample(bank: &mut RtaBank, in_l: f32, in_r: f32) {
let (out_l, out_r) = filter.process(weighted_l, weighted_r);
let out_l = f64::from(out_l);
let out_r = f64::from(out_r);
bank.acc_l[band_index] += out_l * out_l;
bank.acc_r[band_index] += out_r * out_r;
bank.block_peaks[band_index] = bank.block_peaks[band_index]
.max(out_l * out_l)
.max(out_r * out_r);
if let Some(windows) = bank.peak_windows.as_mut() {
let power = (out_l * out_l).max(out_r * out_r);
bank.energies[band_index] = windows[band_index].push(power);
continue;
}
let power = 0.5 * (out_l * out_l + out_r * out_r);
let previous = bank.energies[band_index];
let alpha = if power >= previous {
bank.attack_alpha
} else {
bank.release_alpha
};
bank.energies[band_index] = previous + alpha * (power - previous);
}
}
@@ -1616,35 +1637,10 @@ fn push_spectro_sample(state: &mut SpectroState, l: f32, r: f32) {
}
#[cfg(target_os = "linux")]
fn finalize_rta_bank(
bank: &mut RtaBank,
sample_rate: u32,
block_size: usize,
channel_count: usize,
config: &PhoenixRtaConfig,
) {
fn finalize_rta_bank(bank: &mut RtaBank, sample_rate: u32, block_size: usize) {
let dt = block_size as f32 / sample_rate as f32;
let integration = normalize_rta_integration(&config.integration);
let tau = integration_tau(config);
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
let denom = (channel_count.max(1) * block_size.max(1)) as f64;
bank.average_samples = bank.average_samples.saturating_add(block_size as u64);
for i in 0..bank.energies.len() {
let block_sum = bank.acc_l[i]
+ if channel_count > 1 {
bank.acc_r[i]
} else {
0.0
};
let power = block_sum / denom;
bank.average_acc[i] += block_sum / channel_count.max(1) as f64;
bank.energies[i] = match integration {
"peak" => bank.block_peaks[i].max(1.0e-12),
"average" => bank.average_acc[i] / bank.average_samples.max(1) as f64,
_ => integrate_power(bank.energies[i], power, block_size, sample_rate, tau),
}
.max(1.0e-12);
let corrected_energy = bank.energies[i] * bank.weighting_corrections[i];
let level_db = (10.0 * corrected_energy.max(1.0e-12).log10()) as f32;
bank.levels[i] = level_db;
@@ -1657,10 +1653,6 @@ fn finalize_rta_bank(
.max(level_db)
.max(RTA_PEAK_FLOOR_DB);
}
bank.acc_l[i] = 0.0;
bank.acc_r[i] = 0.0;
bank.block_peaks[i] = 0.0;
}
}
@@ -1687,8 +1679,6 @@ fn finalize_fft_state(
);
let dt = state.fft_step_samples as f32 / sample_rate as f32;
let tau = integration_tau(config);
let k = 1.0 - (-dt / tau.max(0.001)).exp();
let release_step = RTA_PEAK_DECAY_DB_PER_S * dt;
let weighting = normalize_weighting(&config.weighting);
for (i, band) in state.mapping.iter().enumerate() {
@@ -1698,11 +1688,39 @@ fn finalize_fft_state(
band_power += *power * seg.weight;
}
}
let weighted = band_power.max(1.0e-12)
* band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting);
let db = 10.0 * weighted.max(1.0e-12).log10();
let prev = state.levels[i];
state.levels[i] = prev + k * (db - prev);
let weighted = f64::from(
band_power.max(1.0e-12)
* band_weighting_gain(band.f_lo, band.center, band.f_hi, weighting),
);
state.energies[i] = match normalize_rta_detector(&config.detector) {
"peak" => integrate_power(
state.energies[i],
weighted,
state.fft_step_samples,
sample_rate,
0.010,
),
_ if normalize_rta_integration(&config.integration) == "impulse" => {
integrate_power_asymmetric(
state.energies[i],
weighted,
state.fft_step_samples,
sample_rate,
0.035,
1.5,
)
}
_ => integrate_power(
state.energies[i],
weighted,
state.fft_step_samples,
sample_rate,
integration_tau(config),
),
}
.max(1.0e-12);
let db = (10.0 * state.energies[i].log10()) as f32;
state.levels[i] = db;
let prev_peak = state.peaks[i];
if !prev_peak.is_finite() || state.levels[i] >= prev_peak {
state.peaks[i] = state.levels[i];
@@ -1750,6 +1768,8 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf
RtaFrame {
engine: "iir".to_string(),
filterbank: normalize_filterbank(&config.filterbank).to_string(),
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: bank.levels.clone(),
bands_peak: bank.peaks.clone(),
centers: bank.centers.clone(),
@@ -1771,6 +1791,8 @@ fn build_fft_rta_frame(
RtaFrame {
engine: "fft".to_string(),
filterbank: "none".to_string(),
detector: normalize_rta_detector(&config.detector).to_string(),
response: normalize_rta_integration(&config.integration).to_string(),
bands_avg: state.levels.clone(),
bands_peak: state.peaks.clone(),
centers: state.centers.clone(),
@@ -1880,10 +1902,31 @@ fn freq_range_bounds(sample_rate: u32, freq_range: &str) -> (f32, f32) {
#[cfg(target_os = "linux")]
fn integration_tau(config: &PhoenixRtaConfig) -> f32 {
match normalize_rta_integration(&config.integration) {
"impulse" => 0.035,
"medium" => 0.5,
"slow" => config.tau_slow.max(0.05),
_ => config.tau_fast.max(0.01),
"slow" => 1.0,
_ => 0.125,
}
}
#[cfg(target_os = "linux")]
fn detector_time_constants(config: &PhoenixRtaConfig) -> (f32, f32) {
if normalize_rta_detector(&config.detector) == "peak" {
return (0.010, 0.010);
}
match normalize_rta_integration(&config.integration) {
"impulse" => (0.035, 1.5),
_ => {
let tau = integration_tau(config);
(tau, tau)
}
}
}
#[cfg(target_os = "linux")]
fn normalize_rta_detector(value: &str) -> &'static str {
match value.trim().to_ascii_lowercase().as_str() {
"peak" => "peak",
_ => "average",
}
}
@@ -1893,8 +1936,6 @@ fn normalize_rta_integration(value: &str) -> &'static str {
"impulse" => "impulse",
"medium" => "medium",
"slow" => "slow",
"average" => "average",
"peak" => "peak",
_ => "fast",
}
}
+5 -1
View File
@@ -20,6 +20,8 @@ pub struct ServiceStatus {
pub struct RtaFrame {
pub engine: String,
pub filterbank: String,
pub detector: String,
pub response: String,
pub bands_avg: Vec<f32>,
pub bands_peak: Vec<f32>,
pub centers: Vec<f32>,
@@ -53,6 +55,7 @@ pub struct WaveEnvFrame {
pub struct PhoenixRtaConfig {
pub engine: String,
pub filterbank: String,
pub detector: String,
pub fft_size: u32,
pub mono_input: bool,
pub lr_fractional_delay_enabled: bool,
@@ -84,6 +87,7 @@ impl Default for PhoenixRtaConfig {
Self {
engine: "iir".to_string(),
filterbank: "legacy".to_string(),
detector: "average".to_string(),
fft_size: 8192,
mono_input: false,
lr_fractional_delay_enabled: true,
@@ -92,7 +96,7 @@ impl Default for PhoenixRtaConfig {
freq_range: "norm".to_string(),
weighting: "z".to_string(),
order: 6,
tau_fast: 0.12,
tau_fast: 0.125,
tau_slow: 1.0,
integration: "fast".to_string(),
layout: "rtw".to_string(),
+90
View File
@@ -3,6 +3,8 @@
//! Provides both the original repeated-RBJ Phoenix characteristic and complete
//! Butterworth fractional-octave bandpasses for controlled comparison.
use std::collections::VecDeque;
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[
20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0,
500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0, 3_150.0, 4_000.0, 5_000.0,
@@ -218,6 +220,58 @@ pub fn integrate_power(
previous + alpha * (block_power - previous)
}
pub fn integrate_power_asymmetric(
previous: f64,
block_power: f64,
block_samples: usize,
sample_rate: u32,
rise_tau_seconds: f32,
fall_tau_seconds: f32,
) -> f64 {
let tau = if block_power >= previous {
rise_tau_seconds
} else {
fall_tau_seconds
};
integrate_power(previous, block_power, block_samples, sample_rate, tau)
}
pub struct SlidingPeak {
window_samples: u64,
sample_index: u64,
candidates: VecDeque<(u64, f64)>,
}
impl SlidingPeak {
pub fn new(window_samples: usize) -> Self {
let window_samples = window_samples.max(1);
Self {
window_samples: window_samples as u64,
sample_index: 0,
candidates: VecDeque::with_capacity(window_samples + 1),
}
}
pub fn push(&mut self, power: f64) -> f64 {
while self.candidates.back().is_some_and(|entry| entry.1 <= power) {
self.candidates.pop_back();
}
self.candidates.push_back((self.sample_index, power));
let oldest = self
.sample_index
.saturating_sub(self.window_samples.saturating_sub(1));
while self
.candidates
.front()
.is_some_and(|entry| entry.0 < oldest)
{
self.candidates.pop_front();
}
self.sample_index = self.sample_index.wrapping_add(1);
self.candidates.front().map_or(0.0, |entry| entry.1)
}
}
#[derive(Clone, Copy)]
struct StereoBiquad {
coeffs: BiquadCoeffs,
@@ -492,6 +546,42 @@ mod tests {
}
}
#[test]
fn impulse_integration_uses_fast_rise_and_slow_fall() {
let sample_rate = 48_000;
let risen = integrate_power_asymmetric(0.0, 1.0, 1_680, sample_rate, 0.035, 1.5);
let fallen = integrate_power_asymmetric(risen, 0.0, 1_680, sample_rate, 0.035, 1.5);
assert!((risen - (1.0 - (-1.0f64).exp())).abs() < 1.0e-6);
assert!(fallen > risen * 0.97);
}
#[test]
fn ten_millisecond_power_integration_is_block_size_independent() {
fn run(block_size: usize) -> f64 {
let sample_rate = 48_000;
let mut value = 0.0;
let mut processed = 0usize;
while processed < 480 {
let count = block_size.min(480 - processed);
value = integrate_power(value, 1.0, count, sample_rate, 0.010);
processed += count;
}
value
}
assert!((run(48) - run(128)).abs() < 1.0e-12);
assert!((run(128) - (1.0 - (-1.0f64).exp())).abs() < 1.0e-6);
}
#[test]
fn sliding_peak_retains_a_transient_for_exact_window() {
let mut detector = SlidingPeak::new(4);
assert_eq!(detector.push(1.0), 1.0);
assert_eq!(detector.push(0.1), 1.0);
assert_eq!(detector.push(0.2), 1.0);
assert_eq!(detector.push(0.3), 1.0);
assert_eq!(detector.push(0.4), 0.4);
}
#[test]
fn weighting_is_normalized_and_directionally_correct() {
for mode in ["a", "c"] {
+44 -17
View File
@@ -325,6 +325,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
"butterworth" | "butter" => "butterworth".to_string(),
_ => "legacy".to_string(),
};
config.detector = match config.detector.trim().to_ascii_lowercase().as_str() {
"peak" => "peak".to_string(),
_ => "average".to_string(),
};
config.fft_size = match config.fft_size {
2048 | 4096 | 8192 | 16384 => config.fft_size,
n if n < 3072 => 2048,
@@ -362,8 +366,16 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
"impulse" => "impulse".to_string(),
"medium" => "medium".to_string(),
"slow" => "slow".to_string(),
"average" => "average".to_string(),
"peak" => "peak".to_string(),
// Compatibility with configurations saved before detector and response
// became separate settings.
"peak" => {
config.detector = "peak".to_string();
"fast".to_string()
}
"average" => {
config.detector = "average".to_string();
"fast".to_string()
}
_ => "fast".to_string(),
};
config.order = config.order.clamp(2, 8);
@@ -376,21 +388,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
config.freq_range = "norm".to_string();
}
let tau_fast = if config.tau_fast.is_finite() {
config.tau_fast
} else {
0.12
};
let tau_slow = if config.tau_slow.is_finite() {
config.tau_slow
} else {
1.0
};
config.tau_fast = tau_fast.clamp(0.01, 3.0);
config.tau_slow = tau_slow.clamp(0.05, 10.0);
if config.tau_slow < config.tau_fast {
config.tau_slow = config.tau_fast;
}
// Fast and Slow are named standard responses, not free tuning controls.
// Keep the serialized fields for old clients, but normalize their values.
config.tau_fast = 0.125;
config.tau_slow = 1.0;
let offset_l = if config.input_offset_db_l.is_finite() {
config.input_offset_db_l
} else {
@@ -751,6 +752,32 @@ mod tests {
assert_eq!(config.integration, "medium");
}
#[test]
fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {
detector: String::new(),
integration: "peak".to_string(),
..PhoenixRtaConfig::default()
});
assert_eq!(config.detector, "peak");
assert_eq!(config.integration, "fast");
}
#[test]
fn legacy_average_mode_migrates_to_average_detector_with_fast_response() {
let config = normalize_rta_config(PhoenixRtaConfig {
detector: String::new(),
integration: "average".to_string(),
tau_fast: 0.12,
tau_slow: 0.8,
..PhoenixRtaConfig::default()
});
assert_eq!(config.detector, "average");
assert_eq!(config.integration, "fast");
assert_eq!(config.tau_fast, 0.125);
assert_eq!(config.tau_slow, 1.0);
}
#[test]
fn non_third_octave_global_selection_retains_rtw_profile() {
let mut global = PhoenixGlobalConfig::default();