diff --git a/TODO.md b/TODO.md index 9c1f391..83f84e3 100644 --- a/TODO.md +++ b/TODO.md @@ -72,7 +72,7 @@ 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:** 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. + - **Ist:** 2,5 s, 4 s und manueller Hold werden samplezeitbasiert im Backend geführt. Der Modus `Hold` springt nach Ablauf eindeutig auf den aktuellen Detektorwert. Der zusätzliche Standardmodus `Hold + Fall` hält zunächst genauso lange und läuft danach mit einstellbaren 1 bis 60 dB/s zurück; Standard sind 20 dB/s. 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. diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index 73d92f9..5931462 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -22,8 +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_MODE: 'fall', RTA_PEAK_HOLD_SEC: 4, + RTA_PEAK_DECAY_DB_PER_S: 20, RTA_PEAK_RESET_TOKEN: 9, CORR_RESPONSE_S: 2.5, CORR_RESET_TOKEN: 7, @@ -34,8 +35,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.rtaPeakHoldMode, 'fall'); assert.equal(forced.rtaPeakHoldSeconds, 4); +assert.equal(forced.rtaPeakDecayDbPerSecond, 20); assert.equal(forced.rtaPeakResetToken, 9); assert.equal(forced.tauFast, 0.125); assert.equal(forced.rtwCenters.length, 121); diff --git a/src/audio.rs b/src/audio.rs index de81586..2aa7334 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -207,6 +207,7 @@ impl RtaPeakHold { sample_rate: u32, config: &PhoenixRtaConfig, ) { + let previous_clock = self.sample_clock; 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)) @@ -242,7 +243,7 @@ impl RtaPeakHold { }; if incoming >= self.values[index] { self.values[index] = incoming; - if mode == "auto" { + if mode == "auto" || mode == "fall" { self.expires_at_sample[index] = self.sample_clock.saturating_add(hold_samples); } } else if mode == "auto" && self.sample_clock >= self.expires_at_sample[index] { @@ -250,6 +251,14 @@ impl RtaPeakHold { // 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); + } else if mode == "fall" && self.sample_clock >= self.expires_at_sample[index] { + let decay_start = previous_clock.max(self.expires_at_sample[index]); + let decay_samples = self.sample_clock.saturating_sub(decay_start); + let decay_db = config.rta_peak_decay_db_per_second * decay_samples as f32 + / sample_rate.max(1) as f32; + self.values[index] = (self.values[index] - decay_db) + .max(incoming) + .max(RTA_PEAK_FLOOR_DB); } } } @@ -1944,8 +1953,10 @@ 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", + "fall" => "fall", "manual" => "manual", - _ => "auto", + "auto" => "auto", + _ => "fall", } } @@ -2317,6 +2328,7 @@ mod tests { #[test] fn rta_peak_hold_has_timed_manual_off_and_reset_semantics() { let mut config = PhoenixRtaConfig::default(); + config.rta_peak_hold_mode = "auto".to_string(); config.rta_peak_hold_seconds = 0.1; let mut hold = RtaPeakHold::new(1); @@ -2329,6 +2341,20 @@ mod tests { "timed hold must release directly to current" ); + config.rta_peak_hold_mode = "fall".to_string(); + config.rta_peak_decay_db_per_second = 20.0; + hold.update(&[-10.0], 1, 1_000, &config); + hold.update(&[-40.0], 100, 1_000, &config); + assert_eq!( + hold.values[0], -10.0, + "fall mode must first observe the hold time" + ); + hold.update(&[-40.0], 500, 1_000, &config); + assert!( + (hold.values[0] - -20.0).abs() < 1.0e-6, + "fall must use the configured dB/s rate" + ); + config.rta_peak_hold_mode = "manual".to_string(); hold.update(&[-20.0], 1, 1_000, &config); hold.update(&[-5.0], 1, 1_000, &config); diff --git a/src/model.rs b/src/model.rs index 27cc7c1..bb8b65c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -70,6 +70,7 @@ pub struct PhoenixRtaConfig { pub layout: String, pub rta_peak_hold_mode: String, pub rta_peak_hold_seconds: f32, + pub rta_peak_decay_db_per_second: f32, pub rta_peak_reset_token: u64, pub input_offset_db_l: f32, pub input_offset_db_r: f32, @@ -103,8 +104,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_mode: "fall".to_string(), rta_peak_hold_seconds: 2.5, + rta_peak_decay_db_per_second: 20.0, rta_peak_reset_token: 0, input_offset_db_l: -5.0, input_offset_db_r: -5.0, diff --git a/src/state.rs b/src/state.rs index 76caf1e..a00bb36 100644 --- a/src/state.rs +++ b/src/state.rs @@ -414,8 +414,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { .as_str() { "off" => "off".to_string(), + "fall" => "fall".to_string(), "manual" => "manual".to_string(), - _ => "auto".to_string(), + "auto" => "auto".to_string(), + _ => "fall".to_string(), }; let requested_hold = if config.rta_peak_hold_seconds.is_finite() { config.rta_peak_hold_seconds @@ -431,6 +433,11 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { } else { requested_hold.clamp(0.0, 30.0) }; + config.rta_peak_decay_db_per_second = if config.rta_peak_decay_db_per_second.is_finite() { + config.rta_peak_decay_db_per_second.clamp(1.0, 60.0) + } else { + 20.0 + }; let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { @@ -792,16 +799,18 @@ mod tests { } #[test] - fn rta_peak_hold_config_is_normalized_without_decay_parameter() { + fn rta_peak_hold_config_is_normalized() { let rtw = normalize_rta_config(PhoenixRtaConfig { rta_peak_hold_mode: "MANUAL".to_string(), rta_peak_hold_seconds: 3.7, + rta_peak_decay_db_per_second: 25.0, 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_decay_db_per_second, 25.0); assert_eq!(rtw.rta_peak_reset_token, 12); let extension = normalize_rta_config(PhoenixRtaConfig { @@ -810,7 +819,7 @@ mod tests { layout: "iec".to_string(), ..PhoenixRtaConfig::default() }); - assert_eq!(extension.rta_peak_hold_mode, "auto"); + assert_eq!(extension.rta_peak_hold_mode, "fall"); assert_eq!(extension.rta_peak_hold_seconds, 30.0); } diff --git a/www/core/audio.js b/www/core/audio.js index 710d912..774ab6c 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -916,10 +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) + rtaPeakHoldMode: ['off', 'auto', 'fall', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE) ? CONFIG.RTA_PEAK_HOLD_MODE - : 'auto', + : 'fall', rtaPeakHoldSeconds: Number.isFinite(CONFIG.RTA_PEAK_HOLD_SEC) ? CONFIG.RTA_PEAK_HOLD_SEC : 2.5, + rtaPeakDecayDbPerSecond: Number.isFinite(CONFIG.RTA_PEAK_DECAY_DB_PER_S) ? CONFIG.RTA_PEAK_DECAY_DB_PER_S : 20, 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, diff --git a/www/core/config.js b/www/core/config.js index b42893d..368fc4e 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -62,8 +62,9 @@ const CONFIG = { RTA_DETECTOR: 'average', // 'average' | 'peak' RTA_INTEGRATION: 'fast', // 'impulse' | 'fast' | 'medium' | 'slow' RTA_BALLISTICS_MODE: 'average', // 'average' | 'peak' | 'both' - RTA_PEAK_HOLD_MODE: 'auto', // 'off' | 'auto' | 'manual' + RTA_PEAK_HOLD_MODE: 'fall', // 'off' | 'auto' | 'fall' | '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, @@ -683,6 +684,7 @@ const VU_OFFSET_ALIGNMENT_MIGRATION_KEY = 'migrate_vu_offset_alignment_plus2_v1' const INPUT_OFFSET_DB_MIN = -60; const INPUT_OFFSET_DB_MAX = 20; const LR_DELAY_ZERO_MIGRATION_KEY = 'migrate_lr_delay_zero_to_0_05_v1'; +const RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY = 'migrate_rta_peak_fall_default_v1'; let configLoadedOnce = false; function normalizeSoftwarePresetId(value) { @@ -774,9 +776,16 @@ function loadConfig(opts = {}) { let saved = null; try { const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return; + if (!raw) { + localStorage.setItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY, '1'); + return; + } saved = JSON.parse(raw); for (const k of Object.keys(saved)) if (k in CONFIG) CONFIG[k] = saved[k]; + if (!localStorage.getItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY)) { + if (CONFIG.RTA_PEAK_HOLD_MODE === 'auto') CONFIG.RTA_PEAK_HOLD_MODE = 'fall'; + localStorage.setItem(RTA_PEAK_FALL_DEFAULT_MIGRATION_KEY, '1'); + } if (typeof saved.PPM_TOP === 'number' && !('PPM_EBU_TOP' in saved)) CONFIG.PPM_EBU_TOP = saved.PPM_TOP; if (typeof saved.PPM_BOTTOM === 'number' && !('PPM_EBU_BOTTOM' in saved)) CONFIG.PPM_EBU_BOTTOM = saved.PPM_BOTTOM; if (typeof saved.PPM_RED_START === 'number' && !('PPM_EBU_RED_START' in saved)) CONFIG.PPM_EBU_RED_START = saved.PPM_RED_START; @@ -810,9 +819,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 = ['off', 'auto', 'fall', 'manual'].includes(CONFIG.RTA_PEAK_HOLD_MODE) ? CONFIG.RTA_PEAK_HOLD_MODE - : 'auto'; + : 'fall'; + CONFIG.RTA_PEAK_DECAY_DB_PER_S = Math.max(1, Math.min(60, Number(CONFIG.RTA_PEAK_DECAY_DB_PER_S) || 20)); 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; diff --git a/www/index.html b/www/index.html index a7e5da3..1e60ae0 100644 --- a/www/index.html +++ b/www/index.html @@ -386,15 +386,20 @@
- Auto gibt den Peak nach 2,5 oder 4 Sekunden direkt auf den aktuellen Messwert frei; Manuell hält bis zum Reset. + Hold gibt nach Ablauf direkt frei; Hold + Fall läuft danach weich zurück; Manuell hält bis zum Reset.
Dauer der Hold-Anzeige
+
+ + Rücklaufgeschwindigkeit im Modus Hold + Fall +
Zusatzhalt für Anzeige diff --git a/www/ui/options.js b/www/ui/options.js index 47b1c66..e92cc68 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -293,6 +293,7 @@ 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], @@ -1164,9 +1165,10 @@ function wireHandlers(env) { return val; }); h('opt_rtaHoldMode', v => { - const allowed = new Set(['off','auto','manual']); - CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'auto'; + const allowed = new Set(['off','auto','fall','manual']); + CONFIG.RTA_PEAK_HOLD_MODE = allowed.has(v) ? v : 'fall'; }); + h('opt_rtaDecay', v => { CONFIG.RTA_PEAK_DECAY_DB_PER_S = clamp(+v, 1, 60); }); h('opt_rtaHoldTime', v => { const requested = clamp(+v, 0, 30); CONFIG.RTA_PEAK_HOLD_SEC = CONFIG.RTA_BAR_LAYOUT === 'rtw' diff --git a/www/views/realtime.js b/www/views/realtime.js index d9a60eb..f5fa2e0 100644 --- a/www/views/realtime.js +++ b/www/views/realtime.js @@ -21,6 +21,7 @@ const STATIC_STROKE_PAD = 2; const PEAK_HOLD_MAP = new Map(Object.entries({ off: 0, auto: null, + fall: null, '1s': 1, '2s': 2, '4s': 4, @@ -290,6 +291,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, @@ -413,13 +415,15 @@ function applyIntegration(state, levels, CONFIG, range) { } function applyPeakHold(state, levels, CONFIG, range) { - const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'auto'; + const mode = CONFIG.RTA_PEAK_HOLD_MODE || 'fall'; const len = levels.length; const mapped = PEAK_HOLD_MAP.get(mode); 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) || 20); const now = performance.now(); + const dt = Math.max(0, (now - (state.holdSampleTs || now)) / 1000); state.holdSampleTs = now; if (!state.peakHold || state.peakHold.length !== len) { @@ -445,6 +449,8 @@ function applyPeakHold(state, levels, CONFIG, range) { if (mode === 'auto' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) { buffer[i] = current; state.lastPeakTime[i] = now; + } else if (mode === 'fall' && (now - (state.lastPeakTime[i] || 0)) >= holdSec * 1000) { + buffer[i] = Math.max(current, buffer[i] - decayRate * dt); } buffer[i] = clamp(buffer[i], range.bottom, range.top); }