Add selectable RTA filter characteristics

This commit is contained in:
Mikei386
2026-07-21 21:12:19 +02:00
parent af974b92ec
commit 3478bfb495
8 changed files with 116 additions and 18 deletions
+29 -9
View File
@@ -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(
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 {
+3
View File
@@ -19,6 +19,7 @@ pub struct ServiceStatus {
#[derive(Clone, Debug, Serialize)]
pub struct RtaFrame {
pub engine: String,
pub filterbank: String,
pub bands_avg: Vec<f32>,
pub bands_peak: Vec<f32>,
pub centers: Vec<f32>,
@@ -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,
+52 -3
View File
@@ -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<BiquadCoeffs> {
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(&sections, 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);
+7
View File
@@ -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");
+2
View File
@@ -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,
+4
View File
@@ -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;
+9 -1
View File
@@ -303,6 +303,14 @@
</select>
<small>FFT (fix) oder IIR-Filterbank</small>
</div>
<div class="opt">
<label>IIR-Filtercharakteristik</label>
<select id="opt_rtaFilterbank" style="width:230px">
<option value="legacy" selected>Legacy (Phoenix/RTW-Vergleich)</option>
<option value="butterworth">Butterworth 6. Ordnung</option>
</select>
<small>Beide Varianten sind IIR. Legacy bildet die bisherige Anzeige nach; Butterworth ist die neue mathematische Konstruktion.</small>
</div>
<div class="opt">
<label>Bänder pro Oktave</label>
<select id="opt_rtaBpo" style="width:150px">
@@ -1258,7 +1266,7 @@
<li><strong>Browserlast:</strong> Single-Slot-Puffer und Sequenzprüfung für Mess-, Spektrogramm- und Visualisierungsdaten ergänzt. Große Rohsamplefelder werden nicht mehr in jedem JSON-Messpaket wiederholt.</li>
<li><strong>Spektrogramm:</strong> Langzeitstillstand und stotterndes Nachholen beseitigt. Inkrementelles Spaltenzeichnen, Worker-ACK, Watchdog/Neustart und begrenztes Überspringen veralteter Spalten ergänzt. 0,5×, 1×, 2×, 4× und 6× besitzen nun eine feste, FFT- und DPI-unabhängige Zeitbasis.</li>
<li><strong>DIN-/EBU-PPM:</strong> Blockunabhängige Quasi-Peak-Detektoren mit bandbegrenzter Interpolation, korrekter Attack- und Rücklaufballistik sowie automatischen Tonburst-, Frequenzgang- und Polaritätstests implementiert. Eine zweite Browser-Anstiegsballistik wurde entfernt.</li>
<li><strong>RTW-RTA:</strong> RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Die Filterbank verwendet vollständige Butterworth-Bandpässe sechster Ordnung aus unterschiedlichen SOS-Sektionen, interne f64-Rechnung sowie exakte IEC-Basis-10-Mitten und -Bandkanten bei vertrauten Nominalbeschriftungen.</li>
<li><strong>RTW-RTA:</strong> RTW-Profil verbindlich auf die IIR-Oktavteilband-Filterbank festgelegt. Bis zur Vergleichsmessung am realen RTW-Gerät sind die bisherige Phoenix/RTW-Charakteristik und vollständige Butterworth-Bandpässe sechster Ordnung getrennt auswählbar.</li>
<li><strong>RTA-Bewertung und Integration:</strong> A-, C- und Z-Bewertung sampleweise vor der Filterbank ergänzt. Fast, Medium, Slow, Impulse, Average und Peak rechnen im Leistungsbereich und unabhängig von der ALSA-Periodengröße.</li>
<li><strong>RTA-Auflösung:</strong> Umschaltung zwischen 1/3, 1/6 und 1/12 repariert. Alle drei Auflösungen bleiben im RTW-Profil aktiv und verwenden dessen IIR-Filterbank, Darstellung und Ballistik.</li>
<li><strong>Hardwareparameter:</strong> Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.</li>
+5
View File
@@ -278,6 +278,7 @@ function syncUI() {
['opt_barThin', CONFIG.METER_BAR_THIN, 'val_barThin', (v)=>Number(v).toFixed(2)],
['opt_headerTextColor', CONFIG.HEADER_TEXT_COLOR || '#ffe066'],
['opt_rtaEngine', CONFIG.RTA_ENGINE || 'iir'],
['opt_rtaFilterbank', CONFIG.RTA_IIR_FILTERBANK || 'legacy'],
['opt_rtaBpo', CONFIG.RTA_BPO_MODE || '1_6'],
['opt_rtaOrder', CONFIG.RTA_IIR_ORDER ?? 4],
['opt_rtaDisplayGainFft', CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 12, 'val_rtaDisplayGainFft', (v)=>`${v} dB`],
@@ -1074,6 +1075,10 @@ function wireHandlers(env) {
CONFIG.RTA_ENGINE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'iir' : ((v === 'iir') ? 'iir' : 'fft');
return CONFIG.RTA_ENGINE;
});
h('opt_rtaFilterbank', v => {
CONFIG.RTA_IIR_FILTERBANK = v === 'butterworth' ? 'butterworth' : 'legacy';
return CONFIG.RTA_IIR_FILTERBANK;
});
h('opt_rtaBpo', v => {
applyRtaBpoSelection(v);
const layoutControl = E('opt_rtaLayout');