Add selectable RTA filter characteristics
This commit is contained in:
+34
-14
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(§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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user