From 3a0bdbb5ad951a6722db6b723c1c3297a708fc7f Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:42:32 +0200 Subject: [PATCH] Fix RTA peak hold and FFT band energy --- TODO.md | 13 +- scripts/test_fft_band_energy.mjs | 26 +++ scripts/test_rta_profile.mjs | 6 + src/audio.rs | 272 ++++++++++++++++++++++++++----- src/model.rs | 6 + src/state.rs | 47 ++++++ www/core/audio.js | 5 + www/core/config.js | 6 +- www/core/utils.js | 6 +- www/index.html | 6 +- www/main.js | 3 + www/ui/options.js | 2 - www/views/realtime.js | 83 +++++++--- 13 files changed, 394 insertions(+), 87 deletions(-) create mode 100644 scripts/test_fft_band_energy.mjs diff --git a/TODO.md b/TODO.md index fe35ef2..9c1f391 100644 --- a/TODO.md +++ b/TODO.md @@ -72,9 +72,9 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - [ ] **7. Peak Hold und Bandspeicher wie beim PortaMonitor ergänzen** - **Soll:** Peak Hold 2,5 s, 4 s oder manuell; Speicher für acht Bänder plus Hold. - - **Ist:** Die Anzeige unterstützt jetzt Peak Hold 2,5 s, 4 s und manuell. Der ältere kontinuierliche Backend-Peak sowie der achtbandige RTW-Speicher sind noch nicht vollständig ersetzt beziehungsweise ergänzt. - - **Falsch/unvollständig:** Ein kontinuierlich fallender Peak ist funktional nicht dasselbe wie Peak Hold. - - **Aufgabe:** Hold-Zeit, manuellen Hold, Reset, Rücklauf nach Hold-Ende und acht auswählbare Speicherbänder implementieren. Aktuellwert und Holdwert visuell eindeutig trennen. + - **Ist:** 2,5 s, 4 s und manueller Hold werden samplezeitbasiert im Backend geführt. Nach Zeitablauf springt der Hold eindeutig auf den aktuellen Detektorwert; ein kontinuierlicher Rücklauf findet nicht mehr statt. Der Reset erreicht über ein Token sowohl Backend als auch Anzeige. FFT und IIR verwenden dieselbe Hold-Semantik, ohne eine zweite Browser-Holdstufe. + - **Noch offen:** Der achtbandige RTW-Speicher ist noch nicht ergänzt. + - **Aufgabe:** Acht auswählbare Speicherbänder implementieren und Aktuell-, Hold- und Speicherwert visuell eindeutig trennen. - **Abnahme:** Hold-Zeiten und Reset-Verhalten stimmen zeitlich und visuell mit der Referenz überein. - [ ] **8. RTA-Anzeigeoptionen am PortaMonitor-Profil ausrichten** @@ -114,11 +114,10 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - **Noch extern zu prüfen:** Pegelkalibrierung und die optische Übereinstimmung mit dem konkreten RTW-PortaMonitor am analogen Eingang. - **Abnahme:** Softwaretests bestehen; endgültige Geräteübereinstimmung folgt mit der analogen Referenzmessung. -- [ ] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren** +- [x] **13. FFT-Modus als optionale Spektrumsansicht fachlich korrigieren** - **Soll:** FFT ist eine korrekte Zusatzansicht, aber nicht die RTW-IIR-Referenz. - - **Ist:** FFT-RTA und Spektrogramm sind vorhanden. - - **Falsch:** FFT-Bins werden innerhalb eines Bandes normiert gemittelt statt zur Bandenergie summiert. Rauschsignale und unterschiedlich breite Bänder werden dadurch falsch bewertet. - - **Aufgabe:** Binleistungen energetisch integrieren, Fensterleistung korrekt kompensieren und FFT-Ergebnisse gegen die IIR-Referenz testen. + - **Ist:** Das FFT-RTA summiert einseitige Binenergien mit Hann-Fensterleistungskompensation. Rand-Bins werden entsprechend ihrem tatsächlichen Bandüberlappungsanteil berücksichtigt; es findet keine Normierung auf die Anzahl oder Gesamtgewichtung der Bins mehr statt. Die native FFT-Integration wird im Browser nicht erneut ausgeführt. Der getrennte Spektrogramm-Amplitudenmaßstab bleibt unverändert. + - **Geprüft:** Sinusenergie bleibt bei 2048, 4096, 8192 und 16384 Punkten innerhalb der Testtoleranz konstant. Ein zusätzlicher Test prüft die geometrische Bandenergie bei allen FFT-Größen. - **Abnahme:** Konsistente Pegel bei FFT-Größenwechseln und eindeutig getrennte Kennzeichnung im UI. ## Priorität 3 - Stereoanzeigen und visuelles RTW-Verhalten diff --git a/scripts/test_fft_band_energy.mjs b/scripts/test_fft_band_energy.mjs new file mode 100644 index 0000000..48241fc --- /dev/null +++ b/scripts/test_fft_band_energy.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { buildBandBinMapping, computeBandLevels } from '../www/core/utils.js'; + +const band = [{ center: 1000, fLo: 875, fHi: 1125 }]; + +for (const binCount of [2048, 4096, 8192, 16384]) { + const nyquist = 24000; + const binWidth = nyquist / binCount; + const mapping = buildBandBinMapping(band, nyquist, binCount); + const totalWeight = mapping[0].bins.reduce((sum, bin) => sum + bin.weight, 0); + assert.ok( + Math.abs(totalWeight * binWidth - 250) < 1e-6, + `bin overlaps must cover the complete band at FFT size ${binCount * 2}`, + ); + + // Constant power density: every bin contains density × bin width. The + // integrated band level must therefore be independent of FFT resolution. + const binPower = binWidth * 1e-6; + const binsDb = new Float32Array(binCount); + binsDb.fill(10 * Math.log10(binPower)); + const level = computeBandLevels(mapping, binsDb)[0]; + const expected = 10 * Math.log10(250e-6); + assert.ok(Math.abs(level - expected) < 1e-5, `FFT size ${binCount * 2}: ${level} vs ${expected}`); +} + +console.log('FFT band-energy regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index 4b9ead4..73d92f9 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -22,6 +22,9 @@ const forced = context.buildRtaRuntimeConfig({ RTA_FREQ_RANGE: 'lf', RTA_IIR_ORDER: 2, RTA_DETECTOR: 'peak', + RTA_PEAK_HOLD_MODE: 'manual', + RTA_PEAK_HOLD_SEC: 4, + RTA_PEAK_RESET_TOKEN: 9, CORR_RESPONSE_S: 2.5, CORR_RESET_TOKEN: 7, XY_POINTS: 256, @@ -31,6 +34,9 @@ assert.equal(forced.bpo, '1_12'); assert.equal(forced.freqRange, 'norm'); assert.equal(forced.order, 6); assert.equal(forced.detector, 'peak'); +assert.equal(forced.rtaPeakHoldMode, 'manual'); +assert.equal(forced.rtaPeakHoldSeconds, 4); +assert.equal(forced.rtaPeakResetToken, 9); assert.equal(forced.tauFast, 0.125); assert.equal(forced.rtwCenters.length, 121); assert.equal(forced.correlationResponseS, 2.5); diff --git a/src/audio.rs b/src/audio.rs index 897a865..de81586 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -40,9 +40,6 @@ use crate::{ // 128-sample periods at 48 kHz -> 62.5 visual updates/s. The DSP history is // still updated sample-by-sample; only transport snapshots are rate-limited. const XY_TARGET_UPDATES_PER_SECOND: u64 = 60; -#[cfg(target_os = "linux")] -const RTA_PEAK_DECAY_DB_PER_S: f32 = 10.0; -#[cfg(target_os = "linux")] const RTA_PEAK_FLOOR_DB: f32 = -150.0; #[cfg(target_os = "linux")] const WAVE_ENV_COLUMNS_PER_SEC: f32 = 9600.0; @@ -133,7 +130,7 @@ struct RtaBank { peak_windows: Option>, energies: Vec, levels: Vec, - peaks: Vec, + peak_hold: RtaPeakHold, } #[cfg(target_os = "linux")] @@ -173,7 +170,89 @@ struct FftRtaState { power_bins: Vec, energies: Vec, levels: Vec, - peaks: Vec, + peak_hold: RtaPeakHold, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FftPowerScale { + BinAmplitude, + IntegratedEnergy, +} + +struct RtaPeakHold { + values: Vec, + expires_at_sample: Vec, + sample_clock: u64, + mode: String, + hold_samples: u64, + reset_token: u64, +} + +impl RtaPeakHold { + fn new(len: usize) -> Self { + Self { + values: vec![RTA_PEAK_FLOOR_DB; len], + expires_at_sample: vec![0; len], + sample_clock: 0, + mode: String::new(), + hold_samples: 0, + reset_token: 0, + } + } + + fn update( + &mut self, + current: &[f32], + advanced_samples: usize, + sample_rate: u32, + config: &PhoenixRtaConfig, + ) { + self.sample_clock = self.sample_clock.saturating_add(advanced_samples as u64); + let mode = normalize_rta_peak_hold_mode(&config.rta_peak_hold_mode); + let hold_samples = (f64::from(sample_rate.max(1)) + * f64::from(config.rta_peak_hold_seconds.max(0.0))) + .round() + .clamp(0.0, u64::MAX as f64) as u64; + let reset = self.mode != mode + || self.hold_samples != hold_samples + || self.reset_token != config.rta_peak_reset_token; + if self.values.len() != current.len() { + self.values.resize(current.len(), RTA_PEAK_FLOOR_DB); + self.expires_at_sample.resize(current.len(), 0); + } + if reset { + self.mode = mode.to_string(); + self.hold_samples = hold_samples; + self.reset_token = config.rta_peak_reset_token; + self.values.copy_from_slice(current); + self.expires_at_sample.fill(0); + } + + if mode == "off" { + self.values.copy_from_slice(current); + self.expires_at_sample.fill(0); + return; + } + + for (index, &incoming) in current.iter().enumerate() { + let incoming = if incoming.is_finite() { + incoming.max(RTA_PEAK_FLOOR_DB) + } else { + RTA_PEAK_FLOOR_DB + }; + if incoming >= self.values[index] { + self.values[index] = incoming; + if mode == "auto" { + self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples); + } + } else if mode == "auto" && self.sample_clock >= self.expires_at_sample[index] { + // A timed hold ends with a hard release to the current detector + // value. There is deliberately no second, continuous peak decay. + self.values[index] = incoming; + self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples); + } + } + } } #[cfg(target_os = "linux")] @@ -1164,7 +1243,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); + finalize_rta_bank(bank, sample_rate, frames, rta_config); ppm_state.last_rta = Some(build_iir_rta_frame(bank, sample_rate, rta_config)); } RtaEngineState::Fft(fft) => { @@ -1440,7 +1519,7 @@ fn create_rta_bank(sample_rate: u32, config: &PhoenixRtaConfig) -> RtaBank { peak_windows, energies: vec![0.0; len], levels: vec![-120.0; len], - peaks: vec![-120.0; len], + peak_hold: RtaPeakHold::new(len), } } @@ -1478,7 +1557,7 @@ fn create_fft_state(sample_rate: u32, config: &PhoenixRtaConfig) -> FftRtaState power_bins: vec![0.0; fft_size / 2], energies: vec![0.0; len], levels: vec![-120.0; len], - peaks: vec![-120.0; len], + peak_hold: RtaPeakHold::new(len), } } @@ -1568,23 +1647,19 @@ 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) { - let dt = block_size as f32 / sample_rate as f32; - let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; +fn finalize_rta_bank( + bank: &mut RtaBank, + sample_rate: u32, + block_size: usize, + config: &PhoenixRtaConfig, +) { for i in 0..bank.energies.len() { 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; - - let prev_peak = bank.peaks[i]; - if !prev_peak.is_finite() || level_db >= prev_peak { - bank.peaks[i] = level_db; - } else { - bank.peaks[i] = (prev_peak - release_step) - .max(level_db) - .max(RTA_PEAK_FLOOR_DB); - } } + bank.peak_hold + .update(&bank.levels, block_size, sample_rate, config); } #[cfg(target_os = "linux")] @@ -1607,10 +1682,9 @@ fn finalize_fft_state( &mut state.fft_re_r, &mut state.fft_im_r, &mut state.power_bins, + FftPowerScale::IntegratedEnergy, ); - let dt = state.fft_step_samples as f32 / sample_rate as f32; - let release_step = RTA_PEAK_DECAY_DB_PER_S * dt; let weighting = normalize_weighting(&config.weighting); for (i, band) in state.mapping.iter().enumerate() { let mut band_power = 0.0f32; @@ -1652,15 +1726,10 @@ fn finalize_fft_state( .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]; - } else { - state.peaks[i] = (prev_peak - release_step) - .max(state.levels[i]) - .max(RTA_PEAK_FLOOR_DB); - } } + state + .peak_hold + .update(&state.levels, state.fft_step_samples, sample_rate, config); true } @@ -1680,6 +1749,7 @@ fn finalize_spectro_state(state: &mut SpectroState, sample_rate: u32) -> bool { &mut state.fft_re_r, &mut state.fft_im_r, &mut state.bins, + FftPowerScale::BinAmplitude, ); let floor = -160.0f32; @@ -1702,7 +1772,7 @@ fn build_iir_rta_frame(bank: &RtaBank, sample_rate: u32, config: &PhoenixRtaConf 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(), + bands_peak: bank.peak_hold.values.clone(), centers: bank.centers.clone(), freq_min: bank.freq_min, freq_max: bank.freq_max, @@ -1725,7 +1795,7 @@ fn build_fft_rta_frame( 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(), + bands_peak: state.peak_hold.values.clone(), centers: state.centers.clone(), freq_min: state.freq_min, freq_max: state.freq_max, @@ -1871,6 +1941,14 @@ fn normalize_rta_integration(value: &str) -> &'static str { } } +fn normalize_rta_peak_hold_mode(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "off" => "off", + "manual" => "manual", + _ => "auto", + } +} + #[cfg(target_os = "linux")] fn build_rta_bands( sample_rate: u32, @@ -1962,7 +2040,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V .ceil() .min((bin_count.saturating_sub(1)) as f32) as usize; let mut bins = Vec::new(); - let mut weight_sum = 0.0f32; for index in start..=end { let bin_start = index as f32 * bin_width; let bin_end = bin_start + bin_width; @@ -1970,9 +2047,8 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V if overlap > 0.0 { bins.push(FftBandSeg { index, - weight: overlap, + weight: (overlap / bin_width).clamp(0.0, 1.0), }); - weight_sum += overlap; } } if bins.is_empty() { @@ -1983,10 +2059,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V index: idx, weight: 1.0, }); - weight_sum = 1.0; - } - for seg in &mut bins { - seg.weight /= weight_sum.max(1.0e-12); } result.push(FftBandMap { center: band.center, @@ -1998,7 +2070,6 @@ fn build_fft_band_mapping(bands: &[RtaBandDef], nyq: f32, bin_count: usize) -> V result } -#[cfg(target_os = "linux")] fn compute_fft_power_bins( ring_l: &[f32], ring_r: &[f32], @@ -2008,14 +2079,17 @@ fn compute_fft_power_bins( fft_re_r: &mut [f32], fft_im_r: &mut [f32], out_power_bins: &mut [f32], + scale: FftPowerScale, ) { let fft_size = fft_re_l.len(); let mut window_sum = 0.0f32; + let mut window_power_sum = 0.0f32; for i in 0..fft_size { let src_idx = (ring_pos + i) % fft_size; let phase = (2.0 * std::f32::consts::PI * i as f32) / (fft_size.saturating_sub(1) as f32); let win = 0.5 - 0.5 * phase.cos(); window_sum += win; + window_power_sum += win * win; fft_re_l[i] = ring_l[src_idx] * win; fft_im_l[i] = 0.0; fft_re_r[i] = ring_r[src_idx] * win; @@ -2026,10 +2100,18 @@ fn compute_fft_power_bins( fft_in_place(fft_re_r, fft_im_r); let fft_norm = ((window_sum * 0.5).max(1.0)).powi(2); + let energy_norm = (fft_size as f32 * window_power_sum).max(1.0); for bin in 0..out_power_bins.len() { let power_l = fft_re_l[bin] * fft_re_l[bin] + fft_im_l[bin] * fft_im_l[bin]; let power_r = fft_re_r[bin] * fft_re_r[bin] + fft_im_r[bin] * fft_im_r[bin]; - out_power_bins[bin] = 0.5 * (power_l + power_r) / fft_norm; + let stereo_power = 0.5 * (power_l + power_r); + out_power_bins[bin] = match scale { + FftPowerScale::BinAmplitude => stereo_power / fft_norm, + FftPowerScale::IntegratedEnergy => { + let one_sided = if bin == 0 { 1.0 } else { 2.0 }; + stereo_power * one_sided / energy_norm + } + }; } } @@ -2047,7 +2129,6 @@ fn db_gain(db: f32) -> f32 { 10.0f32.powf(db / 20.0) } -#[cfg(target_os = "linux")] fn fft_in_place(re: &mut [f32], im: &mut [f32]) { let n = re.len(); if n == 0 || n != im.len() || !n.is_power_of_two() { @@ -2134,6 +2215,59 @@ fn band_weighting_gain(f_lo: f32, center: f32, f_hi: f32, mode: &str) -> f32 { mod tests { use super::*; + fn integrated_fft_power(fft_size: usize) -> f32 { + let cycles = 64.0f32; + let signal: Vec = (0..fft_size) + .map(|index| { + (2.0 * std::f32::consts::PI * cycles * index as f32 / fft_size as f32).sin() + }) + .collect(); + let mut re_l = vec![0.0; fft_size]; + let mut im_l = vec![0.0; fft_size]; + let mut re_r = vec![0.0; fft_size]; + let mut im_r = vec![0.0; fft_size]; + let mut bins = vec![0.0; fft_size / 2]; + compute_fft_power_bins( + &signal, + &signal, + 0, + &mut re_l, + &mut im_l, + &mut re_r, + &mut im_r, + &mut bins, + FftPowerScale::IntegratedEnergy, + ); + bins.iter().sum() + } + + fn fft_tone_bin_amplitude(fft_size: usize) -> f32 { + let tone_bin = 64usize; + let signal: Vec = (0..fft_size) + .map(|index| { + (2.0 * std::f32::consts::PI * tone_bin as f32 * index as f32 / fft_size as f32) + .sin() + }) + .collect(); + let mut re_l = vec![0.0; fft_size]; + let mut im_l = vec![0.0; fft_size]; + let mut re_r = vec![0.0; fft_size]; + let mut im_r = vec![0.0; fft_size]; + let mut bins = vec![0.0; fft_size / 2]; + compute_fft_power_bins( + &signal, + &signal, + 0, + &mut re_l, + &mut im_l, + &mut re_r, + &mut im_r, + &mut bins, + FftPowerScale::BinAmplitude, + ); + 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); @@ -2159,4 +2293,58 @@ mod tests { assert!((run_rms(period) - reference).abs() < 1.0e-7); } } + + #[test] + fn fft_integrated_energy_compensates_hann_window_and_fft_size() { + let expected = 0.5f32; + for fft_size in [2048, 4096, 8192, 16384] { + let measured = integrated_fft_power(fft_size); + assert!( + (measured - expected).abs() < 2.0e-4, + "FFT {fft_size}: expected {expected}, got {measured}" + ); + } + } + + #[test] + fn spectrogram_bin_amplitude_scale_remains_unchanged() { + for fft_size in [2048, 4096, 8192] { + let measured = fft_tone_bin_amplitude(fft_size); + assert!((measured - 1.0).abs() < 2.0e-4); + } + } + + #[test] + fn rta_peak_hold_has_timed_manual_off_and_reset_semantics() { + let mut config = PhoenixRtaConfig::default(); + config.rta_peak_hold_seconds = 0.1; + let mut hold = RtaPeakHold::new(1); + + hold.update(&[-10.0], 0, 1_000, &config); + hold.update(&[-30.0], 99, 1_000, &config); + assert_eq!(hold.values[0], -10.0); + hold.update(&[-30.0], 1, 1_000, &config); + assert_eq!( + hold.values[0], -30.0, + "timed hold must release directly to current" + ); + + config.rta_peak_hold_mode = "manual".to_string(); + hold.update(&[-20.0], 1, 1_000, &config); + hold.update(&[-5.0], 1, 1_000, &config); + hold.update(&[-40.0], 1_000, 1_000, &config); + assert_eq!(hold.values[0], -5.0, "manual hold must not decay"); + + config.rta_peak_reset_token += 1; + hold.update(&[-40.0], 1, 1_000, &config); + assert_eq!(hold.values[0], -40.0, "reset must release manual hold"); + + config.rta_peak_hold_mode = "off".to_string(); + hold.update(&[-12.0], 1, 1_000, &config); + hold.update(&[-35.0], 1, 1_000, &config); + assert_eq!( + hold.values[0], -35.0, + "off must always follow current value" + ); + } } diff --git a/src/model.rs b/src/model.rs index 06e50a2..27cc7c1 100644 --- a/src/model.rs +++ b/src/model.rs @@ -68,6 +68,9 @@ pub struct PhoenixRtaConfig { pub tau_slow: f32, pub integration: String, pub layout: String, + pub rta_peak_hold_mode: String, + pub rta_peak_hold_seconds: f32, + pub rta_peak_reset_token: u64, pub input_offset_db_l: f32, pub input_offset_db_r: f32, pub ppm_din_attack_ms: f32, @@ -100,6 +103,9 @@ impl Default for PhoenixRtaConfig { tau_slow: 1.0, integration: "fast".to_string(), layout: "rtw".to_string(), + rta_peak_hold_mode: "auto".to_string(), + rta_peak_hold_seconds: 2.5, + rta_peak_reset_token: 0, input_offset_db_l: -5.0, input_offset_db_r: -5.0, ppm_din_attack_ms: 10.0, diff --git a/src/state.rs b/src/state.rs index 7d180a8..76caf1e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -407,6 +407,30 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { // Keep the serialized fields for old clients, but normalize their values. config.tau_fast = 0.125; config.tau_slow = 1.0; + config.rta_peak_hold_mode = match config + .rta_peak_hold_mode + .trim() + .to_ascii_lowercase() + .as_str() + { + "off" => "off".to_string(), + "manual" => "manual".to_string(), + _ => "auto".to_string(), + }; + let requested_hold = if config.rta_peak_hold_seconds.is_finite() { + config.rta_peak_hold_seconds + } else { + 2.5 + }; + config.rta_peak_hold_seconds = if config.layout == "rtw" { + if requested_hold >= 3.25 { + 4.0 + } else { + 2.5 + } + } else { + requested_hold.clamp(0.0, 30.0) + }; let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { @@ -767,6 +791,29 @@ mod tests { assert_eq!(config.integration, "medium"); } + #[test] + fn rta_peak_hold_config_is_normalized_without_decay_parameter() { + let rtw = normalize_rta_config(PhoenixRtaConfig { + rta_peak_hold_mode: "MANUAL".to_string(), + rta_peak_hold_seconds: 3.7, + rta_peak_reset_token: 12, + layout: "rtw".to_string(), + ..PhoenixRtaConfig::default() + }); + assert_eq!(rtw.rta_peak_hold_mode, "manual"); + assert_eq!(rtw.rta_peak_hold_seconds, 4.0); + assert_eq!(rtw.rta_peak_reset_token, 12); + + let extension = normalize_rta_config(PhoenixRtaConfig { + rta_peak_hold_mode: "invalid".to_string(), + rta_peak_hold_seconds: 45.0, + layout: "iec".to_string(), + ..PhoenixRtaConfig::default() + }); + assert_eq!(extension.rta_peak_hold_mode, "auto"); + assert_eq!(extension.rta_peak_hold_seconds, 30.0); + } + #[test] fn legacy_peak_mode_migrates_to_peak_detector_with_fast_response() { let config = normalize_rta_config(PhoenixRtaConfig { diff --git a/www/core/audio.js b/www/core/audio.js index e77d182..710d912 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -916,6 +916,11 @@ export function buildRtaRuntimeConfig(CONFIG = {}) { tauSlow: CONFIG.RTA_IIR_TAU_SLOW || 1.0, integration: CONFIG.RTA_INTEGRATION || 'fast', layout, + rtaPeakHoldMode: ['off', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE) + ? CONFIG.RTA_PEAK_HOLD_MODE + : 'auto', + rtaPeakHoldSeconds: Number.isFinite(CONFIG.RTA_PEAK_HOLD_SEC) ? CONFIG.RTA_PEAK_HOLD_SEC : 2.5, + rtaPeakResetToken: Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)), inputOffsetDbL: Number.isFinite(CONFIG.INPUT_OFFSET_DB_L) ? CONFIG.INPUT_OFFSET_DB_L : -5, inputOffsetDbR: Number.isFinite(CONFIG.INPUT_OFFSET_DB_R) ? CONFIG.INPUT_OFFSET_DB_R : -5, ppmDinAttackMs: 10, diff --git a/www/core/config.js b/www/core/config.js index 76efce9..b42893d 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -64,7 +64,7 @@ const CONFIG = { RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' RTA_PEAK_HOLD_SEC: 2.5, - RTA_PEAK_DECAY_DB_PER_S: 20, + RTA_PEAK_RESET_TOKEN: 0, RTA_DISPLAY_HOLD_SEC: 0, RTA_IIR_ORDER: 6, RTA_IIR_TAU_FAST: 0.125, @@ -810,6 +810,10 @@ function loadConfig(opts = {}) { const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC); CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5; } + CONFIG.RTA_PEAK_HOLD_MODE = ['off', 'auto', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE) + ? CONFIG.RTA_PEAK_HOLD_MODE + : 'auto'; + CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)); if (!Number.isFinite(CONFIG.DBFS_TOP)) CONFIG.DBFS_TOP = CONFIG_DEFAULTS.DBFS_TOP; if (!Number.isFinite(CONFIG.DBFS_BOTTOM)) CONFIG.DBFS_BOTTOM = CONFIG_DEFAULTS.DBFS_BOTTOM; // Clamp to sensible bounds while keeping the user-selected display range. diff --git a/www/core/utils.js b/www/core/utils.js index 15ff19d..c3c305a 100644 --- a/www/core/utils.js +++ b/www/core/utils.js @@ -138,22 +138,18 @@ export function buildBandBinMapping(bands, nyq, binCount) { const bins = []; const start = Math.max(0, Math.floor((band.fLo / nyq) * binCount)); const end = Math.min(binCount - 1, Math.ceil((band.fHi / nyq) * binCount)); - let weightSum = 0; for (let i = start; i <= end; i++) { const binStart = i * binWidth; const binEnd = binStart + binWidth; const overlap = Math.max(0, Math.min(binEnd, band.fHi) - Math.max(binStart, band.fLo)); if (overlap > 0) { - bins.push({ index: i, weight: overlap }); - weightSum += overlap; + bins.push({ index: i, weight: Math.min(1, overlap / binWidth) }); } } if (!bins.length) { const idx = Math.max(0, Math.min(binCount - 1, Math.round((band.center / nyq) * binCount))); bins.push({ index: idx, weight: 1 }); - weightSum = 1; } - bins.forEach((b) => { b.weight /= weightSum || 1; }); result.push({ center: band.center, fLo: band.fLo, diff --git a/www/index.html b/www/index.html index 8a0830f..a7e5da3 100644 --- a/www/index.html +++ b/www/index.html @@ -389,16 +389,12 @@ - Peak-Hold Verhalten + Auto gibt den Peak nach 2,5 oder 4 Sekunden direkt auf den aktuellen Messwert frei; Manuell hält bis zum Reset.
Dauer der Hold-Anzeige
-
- - Abfall des Hold-Peaks -
Zusatzhalt für Anzeige diff --git a/www/main.js b/www/main.js index 5e3de9f..27c1469 100644 --- a/www/main.js +++ b/www/main.js @@ -2065,6 +2065,9 @@ if (peakHistScrollSel) { } if (resetPeakBtn) { resetPeakBtn.addEventListener('click', () => { + CONFIG.RTA_PEAK_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.RTA_PEAK_RESET_TOKEN) || 0)) + 1; + saveConfig(); + try { env.audio.updateRtaConfig?.(); } catch (_) {} try { if (typeof window.resetRealTimeAnalyzerPeakHold === 'function') { window.resetRealTimeAnalyzerPeakHold(); diff --git a/www/ui/options.js b/www/ui/options.js index cbdb140..47b1c66 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -293,7 +293,6 @@ function syncUI() { ['opt_rtaBallistics', CONFIG.RTA_BALLISTICS_MODE || 'average'], ['opt_rtaHoldMode', CONFIG.RTA_PEAK_HOLD_MODE || 'auto'], ['opt_rtaHoldTime', CONFIG.RTA_PEAK_HOLD_SEC ?? 2.5], - ['opt_rtaDecay', CONFIG.RTA_PEAK_DECAY_DB_PER_S ?? 20], ['opt_rtaDisplayHold', CONFIG.RTA_DISPLAY_HOLD_SEC ?? 0], ['opt_spectroGamma', CONFIG.SPECTRO_GAMMA ?? 0.9, 'val_spectroGamma', (v)=>Number(v).toFixed(2)], ['opt_spectroScroll', CONFIG.SPECTRO_SCROLL_MODE ?? 1], @@ -1175,7 +1174,6 @@ function wireHandlers(env) { : requested; return CONFIG.RTA_PEAK_HOLD_SEC; }); - h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); }); h('opt_rtaDisplayHold', v => { CONFIG.RTA_DISPLAY_HOLD_SEC = clamp(+v, 0, 5); }); h('opt_spectroGamma', v => { const num = clamp(parseFloat(v), 0.3, 1.2); diff --git a/www/views/realtime.js b/www/views/realtime.js index 2db96c1..d9a60eb 100644 --- a/www/views/realtime.js +++ b/www/views/realtime.js @@ -180,7 +180,7 @@ export async function render(env, state) { // Native IIR values already contain the selected power-domain // integration. A second browser attack/hold stage would falsify it. const display = displayBase; - applyPeakHold(state, integrated, CONFIG, range); + syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'iir'); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; @@ -200,16 +200,20 @@ export async function render(env, state) { ); } } else if (useNativeFftEngine) { - const nativeLevels = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length); + const ballisticsData = mapNativeFftLevels(displayRtaPacket, CONFIG, range, state.mapping.length); + const nativeLevels = ballisticsData?.primary; if (!nativeLevels || !nativeLevels.length) { drawWaiting(g, plotX, plotY); } else { - const integrated = applyIntegration(state, nativeLevels, CONFIG, range); + // Native FFT packets already contain the selected power-domain + // integration. Integrating them again in the browser adds lag and + // changes the measured value. + const integrated = nativeLevels; const displayBase = applyDisplayHold(state, integrated, CONFIG, range); const display = (CONFIG.REALTIME_RENDER_STYLE || 'bars') === 'bars' ? applyRealtimeBarBallistics(state, displayBase, CONFIG, range) : displayBase; - applyPeakHold(state, integrated, CONFIG, range); + syncNativePeakHold(state, displayRtaPacket, CONFIG, range, state.mapping.length, 'fft'); state.displayLevels = display; state.currentRange = range; if (typeof window !== 'undefined') window.__RTA_STATE__ = state; @@ -224,8 +228,8 @@ export async function render(env, state) { CONFIG, range, freqBounds, - null, - CONFIG.RTA_BALLISTICS_MODE || 'average' + ballisticsData?.overlay || null, + ballisticsData?.mode || CONFIG.RTA_BALLISTICS_MODE || 'average' ); } } else if (!analyser || !buf) { @@ -286,7 +290,7 @@ function buildConfigSignature(CONFIG, nyq, binCount) { CONFIG.RTA_INTEGRATION, CONFIG.RTA_PEAK_HOLD_MODE, CONFIG.RTA_PEAK_HOLD_SEC, - CONFIG.RTA_PEAK_DECAY_DB_PER_S, + CONFIG.RTA_PEAK_RESET_TOKEN, CONFIG.RTA_DISPLAY_HOLD_SEC, CONFIG.REALTIME_RENDER_STYLE, CONFIG.REALTIME_BAR_HOLD_MS, @@ -415,9 +419,7 @@ function applyPeakHold(state, levels, CONFIG, range) { const holdSec = Number.isFinite(mapped) ? Math.max(0, mapped) : Math.max(0, Number(CONFIG.RTA_PEAK_HOLD_SEC) || 0); - const decayRate = Math.max(0, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 0); const now = performance.now(); - const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000); state.holdSampleTs = now; if (!state.peakHold || state.peakHold.length !== len) { @@ -440,18 +442,37 @@ function applyPeakHold(state, levels, CONFIG, range) { continue; } if (mapped === Infinity || mode === 'manual') continue; - let allowDecay = mode === 'off'; - if (mode === 'auto') { - allowDecay = (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000; - } - if (allowDecay && decayRate > 0) { - buffer[i] = Math.max(current, buffer[i] - decayRate * dt); + if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) { + buffer[i] = current; + state.lastPeakTime[i] = now; } buffer[i] = clamp(buffer[i], range.bottom, range.top); } return buffer; } +function syncNativePeakHold(state, packet, CONFIG, range, expectedLen, engine) { + const src = isVectorLike(packet?.bands_peak) && packet.bands_peak.length === expectedLen + ? packet.bands_peak + : null; + if (!src) return applyPeakHold(state, state.displayLevels, CONFIG, range); + const gain = engine === 'iir' + ? (Number(CONFIG.RTA_DISPLAY_GAIN_IIR_DB ?? 0) || 0) + : (Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0); + if (!state.peakHold || state.peakHold.length !== expectedLen) { + state.peakHold = new Float32Array(expectedLen); + } + for (let i = 0; i < expectedLen; i++) { + const value = Number(src[i]); + state.peakHold[i] = clamp( + (Number.isFinite(value) ? value : range.bottom) + gain, + range.bottom, + range.top, + ); + } + return state.peakHold; +} + function applyDisplayHold(state, levels, CONFIG, range) { const holdSec = Math.max(0, Number(CONFIG.RTA_DISPLAY_HOLD_SEC) || 0); const len = levels.length; @@ -912,16 +933,28 @@ function mapNativeFftLevels(packet, CONFIG, range, expectedLen) { const bottom = (range && Number.isFinite(range.bottom)) ? range.bottom : FLOOR_DB; const top = (range && Number.isFinite(range.top)) ? range.top : 9; const gain = Number(CONFIG.RTA_DISPLAY_GAIN_FFT_DB ?? 0) || 0; - const src = isVectorLike(packet.bands_avg || packet.bands) - ? (packet.bands_avg || packet.bands) - : null; - if (!src || (expectedLen && src.length !== expectedLen)) return null; - const out = new Float32Array(src.length); - for (let i = 0; i < src.length; i++) { - const v = Number(src[i]); - out[i] = clamp((Number.isFinite(v) ? v : bottom) + gain, bottom, top); - } - return out; + const ensureLen = (arr) => isVectorLike(arr) && (!expectedLen || arr.length === expectedLen) ? arr : null; + const avgRaw = ensureLen(packet.bands_avg || packet.bands); + const peakRaw = ensureLen(packet.bands_peak); + const requested = ['average', 'peak', 'both'].includes(CONFIG.RTA_BALLISTICS_MODE) + ? CONFIG.RTA_BALLISTICS_MODE + : 'average'; + const convert = (src) => { + if (!src) return null; + const out = new Float32Array(src.length); + for (let i = 0; i < src.length; i++) { + const value = Number(src[i]); + out[i] = clamp((Number.isFinite(value) ? value : bottom) + gain, bottom, top); + } + return out; + }; + const average = convert(avgRaw); + const peak = convert(peakRaw); + if (requested === 'peak' && peak) return { primary: peak, overlay: null, mode: 'peak' }; + if (requested === 'both' && average && peak) return { primary: average, overlay: peak, mode: 'both' }; + if (average) return { primary: average, overlay: null, mode: 'average' }; + if (peak) return { primary: peak, overlay: null, mode: 'peak' }; + return null; } function isVectorLike(v) {