diff --git a/src/audio.rs b/src/audio.rs index 8e0237b..3d4a5e3 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -21,9 +21,9 @@ use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; use crate::ppm::{PpmDetector, PpmStandard}; #[cfg(target_os = "linux")] use crate::rta::{ - design_fractional_octave_band, exact_fractional_octave_center, fractional_octave_edges, - integrate_power, normalize_weighting, weighting_power_correction, weighting_reference_db, - FrequencyWeighting, StereoCascade, RTW_THIRD_OCTAVE_CENTERS, + 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, }; use crate::state::NativeWavRecorder; use crate::{ @@ -1329,8 +1329,9 @@ fn build_meter_frame( #[cfg(target_os = "linux")] fn ensure_rta_state(state: &mut PpmState, sample_rate: u32, config: &PhoenixRtaConfig) { let signature = format!( - "{}|{}|{}|{}|{}|{}|{}|{}", + "{}|{}|{}|{}|{}|{}|{}|{}|{}", normalize_engine(&config.engine), + normalize_filterbank(&config.filterbank), normalize_layout(&config.layout), normalize_bpo_key(&config.bpo), normalize_freq_range(&config.freq_range), @@ -1439,9 +1440,9 @@ fn build_active_rta_bands( #[cfg(target_os = "linux")] fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { let (active_layout, bpo_key, active_bands) = build_active_rta_bands(sample_rate, config); - // The RTW profile uses a third-order Butterworth prototype, resulting in - // a sixth-order bandpass. Phoenix extension profiles retain selectable - // even total orders. + // In Butterworth mode the RTW profile uses a third-order prototype, + // resulting in a sixth-order bandpass. Phoenix extension profiles retain + // selectable even total orders. let prototype_order = if active_layout == "rtw" { 3 } else { @@ -1461,13 +1462,22 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { .unwrap_or(20_000.0); let mut filters = Vec::with_capacity(active_bands.len()); for band in &active_bands { - let coefficients = design_fractional_octave_band( - band.measurement_center, - band.f_lo, - band.f_hi, - sample_rate, - prototype_order, - ); + let coefficients = if normalize_filterbank(&config.filterbank) == "legacy" { + design_legacy_repeated_band( + band.center, + sample_rate, + bpo_value(bpo_key), + config.order.clamp(2, 8) as usize, + ) + } else { + design_fractional_octave_band( + band.measurement_center, + band.f_lo, + band.f_hi, + sample_rate, + prototype_order, + ) + }; filters.push(StereoCascade::new(coefficients)); } let len = active_bands.len(); @@ -1739,6 +1749,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool { fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConfig) -> RtaFrame { RtaFrame { engine: "iir".to_string(), + filterbank: normalize_filterbank(&config.filterbank).to_string(), bands_avg: bank.levels.clone(), bands_peak: bank.peaks.clone(), centers: bank.centers.clone(), @@ -1759,6 +1770,7 @@ fn build_fft_rta_frame( ) -> RtaFrame { RtaFrame { engine: "fft".to_string(), + filterbank: "none".to_string(), bands_avg: state.levels.clone(), bands_peak: state.peaks.clone(), centers: state.centers.clone(), @@ -1803,6 +1815,14 @@ fn normalize_engine(value: &str) -> &'static str { } } +#[cfg(target_os = "linux")] +fn normalize_filterbank(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "butterworth" | "butter" => "butterworth", + _ => "legacy", + } +} + #[cfg(target_os = "linux")] fn normalize_fft_size(value: u32) -> usize { match value { diff --git a/src/model.rs b/src/model.rs index d20c797..2bf1437 100644 --- a/src/model.rs +++ b/src/model.rs @@ -19,6 +19,7 @@ pub struct ServiceStatus { #[derive(Clone, Debug, Serialize)] pub struct RtaFrame { pub engine: String, + pub filterbank: String, pub bands_avg: Vec, pub bands_peak: Vec, pub centers: Vec, @@ -51,6 +52,7 @@ pub struct WaveEnvFrame { #[serde(default, rename_all = "camelCase")] pub struct PhoenixRtaConfig { pub engine: String, + pub filterbank: String, pub fft_size: u32, pub mono_input: bool, pub lr_fractional_delay_enabled: bool, @@ -81,6 +83,7 @@ impl Default for PhoenixRtaConfig { fn default() -> Self { Self { engine: "iir".to_string(), + filterbank: "legacy".to_string(), fft_size: 8192, mono_input: false, lr_fractional_delay_enabled: true, diff --git a/src/rta.rs b/src/rta.rs index d8aa8e9..18690a8 100644 --- a/src/rta.rs +++ b/src/rta.rs @@ -1,8 +1,7 @@ //! Realtime-analyzer filter primitives. //! -//! Fractional-octave bands are designed as complete Butterworth bandpasses: -//! an analog low-pass prototype is transformed to a bandpass, pre-warped and -//! mapped with the bilinear transform, then emitted as distinct SOS sections. +//! Provides both the original repeated-RBJ Phoenix characteristic and complete +//! Butterworth fractional-octave bandpasses for controlled comparison. 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, @@ -155,6 +154,35 @@ pub fn design_fractional_octave_band( sections } +/// Reproduces the original Phoenix analyzer characteristic: one constant-peak +/// RBJ bandpass is repeated for every second order of the requested cascade. +/// It is retained for comparison with existing Phoenix/RTW captures and is +/// not presented as a filter implementation prescribed by IEC 61260. +pub fn design_legacy_repeated_band( + center_hz: f32, + sample_rate: u32, + bands_per_octave: usize, + total_order: usize, +) -> Vec { + let fs = f64::from(sample_rate.max(8_000)); + let center = f64::from(center_hz).clamp(0.01, fs * 0.499_9); + let bpo = bands_per_octave.max(1) as f64; + let upper = 2.0f64.powf(1.0 / (2.0 * bpo)); + let lower = 2.0f64.powf(-1.0 / (2.0 * bpo)); + let q = 1.0 / (upper - lower); + let omega = 2.0 * std::f64::consts::PI * center / fs; + let alpha = omega.sin() / (2.0 * q); + let a0 = 1.0 + alpha; + let section = BiquadCoeffs { + b0: alpha / a0, + b1: 0.0, + b2: -alpha / a0, + a1: -2.0 * omega.cos() / a0, + a2: (1.0 - alpha) / a0, + }; + vec![section; (total_order.clamp(2, 8) / 2).max(1)] +} + pub fn cascade_magnitude(sections: &[BiquadCoeffs], freq_hz: f32, sample_rate: u32) -> f64 { let omega = 2.0 * std::f64::consts::PI * f64::from(freq_hz) / f64::from(sample_rate.max(8_000)); let z1 = Complex::new(omega.cos(), -omega.sin()); @@ -396,6 +424,27 @@ mod tests { assert!(((lower * upper).sqrt() - center).abs() < 0.001); } + #[test] + fn legacy_sixth_order_repeats_three_identical_sections() { + let sections = design_legacy_repeated_band(1_000.0, 48_000, 6, 6); + assert_eq!(sections.len(), 3); + assert_eq!(sections[0].b0, sections[1].b0); + assert_eq!(sections[1].a2, sections[2].a2); + assert!(cascade_db(§ions, 1_000.0, 48_000).abs() < 0.001); + } + + #[test] + fn legacy_and_butterworth_characteristics_remain_distinct() { + let factor = 2.0f32.powf(1.0 / 12.0); + let legacy = design_legacy_repeated_band(1_000.0, 48_000, 6, 6); + let butterworth = + design_fractional_octave_band(1_000.0, 1_000.0 / factor, 1_000.0 * factor, 48_000, 3); + let legacy_lower = cascade_db(&legacy, 1_000.0 / factor, 48_000); + let butterworth_lower = cascade_db(&butterworth, 1_000.0 / factor, 48_000); + assert!(legacy_lower < -8.5); + assert!((butterworth_lower + 3.0103).abs() < 0.08); + } + #[test] fn neighboring_third_octave_centers_are_suppressed() { let factor = 2.0f32.powf(1.0 / 6.0); diff --git a/src/state.rs b/src/state.rs index fc3f1ff..d1bf365 100644 --- a/src/state.rs +++ b/src/state.rs @@ -321,6 +321,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { "fft" => "fft".to_string(), _ => "iir".to_string(), }; + config.filterbank = match config.filterbank.trim().to_ascii_lowercase().as_str() { + "butterworth" | "butter" => "butterworth".to_string(), + _ => "legacy".to_string(), + }; config.fft_size = match config.fft_size { 2048 | 4096 | 8192 | 16384 => config.fft_size, n if n < 3072 => 2048, @@ -721,6 +725,7 @@ mod tests { ..PhoenixRtaConfig::default() }); assert_eq!(config.engine, "iir"); + assert_eq!(config.filterbank, "legacy"); assert_eq!(config.bpo, "1_12"); assert_eq!(config.order, 6); assert_eq!(config.freq_range, "norm"); @@ -730,6 +735,7 @@ mod tests { fn phoenix_profile_retains_explicit_extensions() { let config = normalize_rta_config(PhoenixRtaConfig { engine: "fft".to_string(), + filterbank: "butterworth".to_string(), bpo: "1_12".to_string(), order: 8, freq_range: "lf".to_string(), @@ -738,6 +744,7 @@ mod tests { ..PhoenixRtaConfig::default() }); assert_eq!(config.engine, "fft"); + assert_eq!(config.filterbank, "butterworth"); assert_eq!(config.bpo, "1_12"); assert_eq!(config.order, 8); assert_eq!(config.freq_range, "lf"); diff --git a/www/core/audio.js b/www/core/audio.js index 13fceff..bd556fd 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -516,6 +516,7 @@ function buildPhoenixMeterPacket(frame) { xyR, rta: rta ? { engine: String(rta?.engine || 'iir'), + filterbank: String(rta?.filterbank || 'legacy'), bands_avg: Array.isArray(rta?.bands_avg) ? rta.bands_avg : (Array.isArray(rta?.bands) ? rta.bands : []), bands_peak: Array.isArray(rta?.bands_peak) ? rta.bands_peak : [], bands: Array.isArray(rta?.bands) ? rta.bands : (Array.isArray(rta?.bands_avg) ? rta.bands_avg : []), @@ -871,6 +872,7 @@ export function buildRtaRuntimeConfig(CONFIG = {}) { const runtimeBpoMode = bpoMode; return { engine: rtwProfile ? 'iir' : (CONFIG.RTA_ENGINE || 'iir'), + filterbank: CONFIG.RTA_IIR_FILTERBANK === 'butterworth' ? 'butterworth' : 'legacy', fftSize: CONFIG.FFT_SIZE || 4096, monoInput: !!CONFIG.MONO_INPUT, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, diff --git a/www/core/config.js b/www/core/config.js index 6bad79c..eb714ad 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -50,6 +50,7 @@ const CONFIG = { REALTIME_BAR_HOLD_MS: 800, REALTIME_BAR_DECAY_DB_PER_S: 20, RTA_ENGINE: 'iir', // 'fft' | 'iir' + RTA_IIR_FILTERBANK: 'legacy', // 'legacy' | 'butterworth' RTA_FREQ_RANGE: 'norm', // 'norm' | 'lf' RTA_BPO_MODE: '1_3', RTA_WEIGHTING: 'z', // 'z' | 'a' | 'c' @@ -784,6 +785,9 @@ function loadConfig(opts = {}) { if (typeof saved.PPM_DIN_DECAY_DB_PER_S === 'number') CONFIG.PPM_DIN_DECAY_DB_PER_S = saved.PPM_DIN_DECAY_DB_PER_S; if (typeof saved.PPM_DIN_HOLD_MS === 'number') CONFIG.PPM_DIN_HOLD_MS = saved.PPM_DIN_HOLD_MS; if (CONFIG.RTA_BAR_LAYOUT === 'classic') CONFIG.RTA_BAR_LAYOUT = 'rtw'; + CONFIG.RTA_IIR_FILTERBANK = CONFIG.RTA_IIR_FILTERBANK === 'butterworth' + ? 'butterworth' + : 'legacy'; if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_IIR_ORDER = 6; diff --git a/www/index.html b/www/index.html index 6e38890..57c6b92 100644 --- a/www/index.html +++ b/www/index.html @@ -303,6 +303,14 @@ FFT (fix) oder IIR-Filterbank +
+ + + Beide Varianten sind IIR. Legacy bildet die bisherige Anzeige nach; Butterworth ist die neue mathematische Konstruktion. +