diff --git a/TODO.md b/TODO.md index e3a2cf7..4aeb059 100644 --- a/TODO.md +++ b/TODO.md @@ -126,17 +126,18 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - [ ] **14. Goniometer-Datenweg und Persistenz optimieren** - **Soll:** Aktuelle XY-Daten erscheinen im nächsten möglichen Bildschirmframe; Fast/Medium/Slow-Persistenz wirkt wie das RTW-Instrument. - - **Ist:** M/S-Darstellung, Gain, AGC, Linien/Punkte und eine 300-ms-Spur sind vorhanden. - - **Datenweg erledigt:** XY-Punkte werden mit begrenzter Rate über den eigenen Binärstrom übertragen; JSON-Kopien und unbeschränkte Warteschlangen entfallen. - - **Noch offen:** RTW-Persistenzmodi sind nicht verbindlich abgeglichen. - - **Aufgabe:** Fast/Medium/Slow-Persistenz am Referenzgerät abstimmen und die vorhandene Ringpufferdarstellung darauf kalibrieren. + - **Ist:** M/S-Darstellung, manueller Gain, AGC, Linien/Punkte sowie reproduzierbare Fast-/Medium-/Slow- und freie Phoenix-Persistenz sind vorhanden. + - **Datenweg erledigt:** Es werden nur die seit der letzten Ausgabe neu eingegangenen Samplepaare mit im Mittel exakt 60 Hz über den eigenen Binärstrom übertragen. Die Punktzahl begrenzt bereits den Transport; der Browser erfindet keine interpolierten XY-Samples. JSON-Kopien und unbeschränkte Warteschlangen entfallen. + - **Darstellung erledigt:** Die Nachleuchtspuren sind auf 48 Zustände begrenzt und verwenden freigegebene `Float32Array`-Puffer erneut. Kosmetische Bézier-Verformung der Messspur ist im RTW-Profil deaktiviert. AGC wird nur bei einem neuen XY-Paket weitergerechnet; das Silence-Gate verwendet das periodengrößenunabhängige XY-Fenster. + - **Geprüft:** Automatische Tests prüfen 60-Hz-Taktung bei 44,1/48/96 kHz und unterschiedlichen Periodengrößen, Punktbegrenzung, Endpunkte, Persistenzprofile und Pufferbegrenzung. + - **Noch offen:** Die gewählten 50/150/300 ms müssen visuell am echten RTW-Gerät abgeglichen werden; deshalb bleibt der Gesamtpunkt offen. - **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten. - [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen** - **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 1,0 s und 2,5 s sowie Negative-Peak-Memory. - - **Ist:** Korrelations- und Phasendarstellungen sind grundsätzlich vorhanden. - - **Ungeklärt:** Zeitkonstanten, Speicherverhalten und Genauigkeit sind nicht systematisch gegen das PortaMonitor geprüft. - - **Aufgabe:** Testsignale für +1, 0 und -1 sowie gleitende Phasenlagen verwenden; Memory und Reset ergänzen beziehungsweise validieren. + - **Ist:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 1,0 s und 2,5 s sind wählbar; der Browser zeigt den fertigen Wert ohne zweite bildratenabhängige Glättung. Negative-Peak-Memory, Marker und manueller Reset sind implementiert. + - **Geprüft:** Automatische Tests prüfen +1, 0 und -1, Stille, einseitiges Signal, beide Ansprechzeiten und den Memory-Reset. + - **Noch offen:** Dynamische Phasenlagen und das exakte Zeit-/Memory-Verhalten müssen am PortaMonitor verglichen werden; deshalb bleibt der Gesamtpunkt offen. - **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein. ## Priorität 4 - Nachweis und dauerhafte Absicherung @@ -158,7 +159,7 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - [ ] **18. Automatisierte DSP- und Darstellungsregressionstests aufbauen** - **Soll:** Keine Änderung kann unbemerkt Pegel, Frequenzgang, Ballistik, Latenz oder RTW-Darstellung verschlechtern. - **Ist:** DIN-/EBU-PPM, Spektrogramm-Zeitbasis und Langlauf, Worker-Verhalten sowie die Binärprotokolle sind automatisiert abgesichert. Servertests prüfen außerdem das Zusammenführen der Waveform-Hüllkurve und die Trennung großer Nutzdaten vom JSON-Messstrom. - - **Noch offen:** Der RTA besitzt nun Tests für alle Bandmitten und -kanten, Nachbarbandunterdrückung, Zeitbereich, A/C/Z, mehrere Sampleraten und blockgrößenunabhängige Integration. True Peak, VU/RMS, Korrelation, reale Periodenvariation und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen. + - **Noch offen:** RTA und Korrelation besitzen nun DSP-Tests; der Goniometerweg prüft mehrere Sampleraten, Periodengrößen, Punktreduktion und Persistenzprofile. True Peak, VU/RMS, dynamische Referenzvergleiche und End-to-End-Latenz besitzen noch keine vollständige automatische Regression. Deshalb bleibt dieser Gesamtpunkt offen. - **Aufgabe:** Einzeltöne aller 31 Bänder, Sweeps, Weiß-/Rosarauschen, Pegelsprünge, Tonbursts, Phasen-/Korrelationssignale und Intersample-Peaks testen. Sampleraten, Perioden und Blockgrenzen variieren. RTW-Screenshots und Messprotokolle als Referenz verwenden, soweit rechtlich möglich. - **Abnahme:** Automatischer Bericht mit Erwartungswerten und Toleranzen für jede Messfunktion. diff --git a/scripts/test_goniometer.mjs b/scripts/test_goniometer.mjs new file mode 100644 index 0000000..bb824d3 --- /dev/null +++ b/scripts/test_goniometer.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../www/views/goniometer_rtw.js', import.meta.url), 'utf8'); +const functionSource = source.match(/export function resolveGoniometerPersistenceMs[\s\S]*?\n\}/)?.[0]; +assert.ok(functionSource, 'persistence resolver must remain testable'); +const context = vm.createContext({ Number, String, Math }); +vm.runInContext(functionSource.replace('export function', 'function'), context); + +assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'fast' }), 50); +assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'medium' }), 150); +assert.equal(context.resolveGoniometerPersistenceMs({ GONIO_PERSISTENCE_MODE: 'slow' }), 300); +assert.equal(context.resolveGoniometerPersistenceMs({ + GONIO_PERSISTENCE_MODE: 'custom', + GONIO_LINE_FADE_MS: 470, +}), 470); +assert.equal(context.resolveGoniometerPersistenceMs({ + GONIO_PERSISTENCE_MODE: 'custom', + GONIO_LINE_FADE_MS: 900, +}), 600); + +assert.match(source, /Math\.min\(targetPoints, total, MAX_TRACE_POINTS\)/, + 'drawing must not upsample invented XY points'); +assert.match(source, /while \(trails\.length >= MAX_TRAIL_FRAMES\)/, + 'trail history must remain bounded'); +assert.match(source, /pool\.pop\(\)/, 'trail buffers must be reused'); +assert.doesNotMatch(source, /utils\.correlation\(/, + 'browser must not recalculate or smooth backend correlation'); + +console.log('goniometer regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index e816687..cdbaaef 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -19,12 +19,18 @@ const forced = context.buildRtaRuntimeConfig({ RTA_BPO_MODE: '1_12', RTA_FREQ_RANGE: 'lf', RTA_IIR_ORDER: 2, + CORR_RESPONSE_S: 2.5, + CORR_RESET_TOKEN: 7, + XY_POINTS: 256, }); assert.equal(forced.engine, 'iir'); assert.equal(forced.bpo, '1_3'); assert.equal(forced.freqRange, 'norm'); assert.equal(forced.order, 6); assert.equal(forced.rtwCenters.length, 31); +assert.equal(forced.correlationResponseS, 2.5); +assert.equal(forced.correlationResetToken, 7); +assert.equal(forced.xyPoints, 256); const extension = context.buildRtaRuntimeConfig({ RTA_BAR_LAYOUT: 'iec', diff --git a/src/audio.rs b/src/audio.rs index b5b52b2..6d370e7 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -11,11 +11,13 @@ use std::{ use tracing::warn; -#[cfg(target_os = "linux")] -use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; #[cfg(target_os = "linux")] use crate::correlation::CorrelationMeter; #[cfg(target_os = "linux")] +use crate::goniometer::{selected_sample_indices, GoniometerClock}; +#[cfg(target_os = "linux")] +use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; +#[cfg(target_os = "linux")] use crate::ppm::{PpmDetector, PpmStandard}; #[cfg(target_os = "linux")] use crate::rta::{ @@ -481,7 +483,7 @@ struct PpmState { correlation: CorrelationMeter, xy_pending_l: Vec, xy_pending_r: Vec, - xy_emit_phase: u64, + xy_clock: GoniometerClock, } #[cfg(target_os = "linux")] @@ -513,7 +515,7 @@ impl Default for PpmState { correlation: CorrelationMeter::new(48_000, 1.0, 0), xy_pending_l: Vec::with_capacity(1024), xy_pending_r: Vec::with_capacity(1024), - xy_emit_phase: 0, + xy_clock: GoniometerClock::default(), } } } @@ -1153,8 +1155,7 @@ fn take_goniometer_samples(state: &mut PpmState, target_points: usize) -> (Vec= u64::from(sample_rate.max(1)) { - ppm_state.xy_emit_phase %= u64::from(sample_rate.max(1)); - take_goniometer_samples(ppm_state, rta_config.xy_points as usize) - } else { - (Vec::new(), Vec::new()) - }; + let (xy_l, xy_r) = + if ppm_state + .xy_clock + .advance(frames, sample_rate, XY_TARGET_UPDATES_PER_SECOND as u32) + { + take_goniometer_samples(ppm_state, rta_config.xy_points as usize) + } else { + (Vec::new(), Vec::new()) + }; MeterFrame { seq: seq.fetch_add(1, Ordering::Relaxed) + 1, diff --git a/src/correlation.rs b/src/correlation.rs index 503d44a..2f184cb 100644 --- a/src/correlation.rs +++ b/src/correlation.rs @@ -27,7 +27,7 @@ impl CorrelationMeter { power_r: 0.0, cross_power: 0.0, value: 0.0, - negative_peak: 1.0, + negative_peak: 0.0, reset_token, }; meter.configure(sample_rate, response_seconds, reset_token); @@ -40,12 +40,12 @@ impl CorrelationMeter { if self.sample_rate != sample_rate || self.response_seconds != response_seconds { self.sample_rate = sample_rate; self.response_seconds = response_seconds; - self.alpha = 1.0 - - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp(); + self.alpha = + 1.0 - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp(); } if self.reset_token != reset_token { self.reset_token = reset_token; - self.negative_peak = 1.0; + self.negative_peak = 0.0; } } @@ -106,13 +106,21 @@ mod tests { fn detects_positive_negative_and_quadrature_signals() { let sample_rate = 48_000; let phase_step = 2.0 * std::f32::consts::PI * 1_000.0 / sample_rate as f32; - for (phase, expected) in [(0.0, 1.0), (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0)] { + for (phase, expected) in [ + (0.0, 1.0), + (std::f32::consts::PI, -1.0), + (std::f32::consts::FRAC_PI_2, 0.0), + ] { let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0); run_signal(&mut meter, 5, |index| { let angle = phase_step * index as f32; (angle.sin(), (angle + phase).sin()) }); - assert!((meter.value() - expected).abs() < 0.002, "phase {phase}: {}", meter.value()); + assert!( + (meter.value() - expected).abs() < 0.002, + "phase {phase}: {}", + meter.value() + ); } } @@ -134,7 +142,7 @@ mod tests { }); assert!(fast.negative_peak() < -0.7); fast.configure(sample_rate, 1.0, 1); - assert_eq!(fast.negative_peak(), 1.0); + assert_eq!(fast.negative_peak(), 0.0); } #[test] @@ -142,6 +150,7 @@ mod tests { let mut meter = CorrelationMeter::new(48_000, 1.0, 0); run_signal(&mut meter, 2, |_| (0.0, 0.0)); assert_eq!(meter.value(), 0.0); + assert_eq!(meter.negative_peak(), 0.0); run_signal(&mut meter, 2, |index| { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; (sample, 0.0) diff --git a/src/goniometer.rs b/src/goniometer.rs new file mode 100644 index 0000000..de27292 --- /dev/null +++ b/src/goniometer.rs @@ -0,0 +1,70 @@ +//! Timing and sample-selection helpers for the realtime goniometer stream. + +#[derive(Clone, Debug, Default)] +pub struct GoniometerClock { + phase: u64, +} + +impl GoniometerClock { + /// Returns true at the first capture boundary after the next display tick. + /// The fractional phase is retained, so the average rate is independent + /// of ALSA period size. + pub fn advance(&mut self, frames: usize, sample_rate: u32, updates_per_second: u32) -> bool { + let sample_rate = u64::from(sample_rate.max(1)); + self.phase = self + .phase + .saturating_add((frames as u64).saturating_mul(u64::from(updates_per_second.max(1)))); + if self.phase < sample_rate { + return false; + } + self.phase %= sample_rate; + true + } +} + +pub fn selected_sample_indices(available: usize, requested: usize) -> Vec { + if available == 0 || requested == 0 { + return Vec::new(); + } + let count = available.min(requested.max(2)); + if count >= available { + return (0..available).collect(); + } + (0..count) + .map(|index| index * (available - 1) / (count - 1)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_rate_is_independent_of_capture_period() { + for (sample_rate, period) in [(44_100, 128), (48_000, 128), (48_000, 192), (96_000, 512)] { + let mut clock = GoniometerClock::default(); + let mut emissions = 0usize; + let mut processed = 0usize; + let target = sample_rate as usize * 10; + while processed < target { + let frames = period.min(target - processed); + emissions += usize::from(clock.advance(frames, sample_rate, 60)); + processed += frames; + } + assert!( + (599..=600).contains(&emissions), + "{sample_rate}/{period}: {emissions}" + ); + } + } + + #[test] + fn selection_never_upsamples_and_keeps_endpoints() { + assert_eq!(selected_sample_indices(3, 1024), vec![0, 1, 2]); + let indices = selected_sample_indices(800, 128); + assert_eq!(indices.len(), 128); + assert_eq!(indices[0], 0); + assert_eq!(indices[127], 799); + assert!(indices.windows(2).all(|pair| pair[0] < pair[1])); + } +} diff --git a/src/main.rs b/src/main.rs index bafa470..9d14df1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod audio; mod config; mod correlation; +mod goniometer; mod model; mod ppm; mod routes; diff --git a/www/core/audio.js b/www/core/audio.js index be4b537..fede1e3 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -351,6 +351,7 @@ function applyVisualsBuffer(env, buffer) { env.audio.xyL = left; env.audio.xyR = right; env.audio.xySeq = seq; + env.audio.xyLastSampleTs = performance.now(); } if (wave) updateWaveformEnvelopeStore(env.audio, wave); env.audio.phoenixVisualsSeq = seq; @@ -409,6 +410,7 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa env.audio.xyL = d.xyL; env.audio.xyR = d.xyR; env.audio.xySeq = Number.isFinite(d.seq) ? d.seq : (env.audio.xySeq || 0); + env.audio.xyLastSampleTs = sampleTs; } if (typeof d.correlation === 'number') env.audio.correlation = d.correlation; if (typeof d.correlationNegativePeak === 'number') { diff --git a/www/core/config.js b/www/core/config.js index 38662c9..cdb8ce4 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -934,6 +934,13 @@ function loadConfig(opts = {}) { const fallback = Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0; CONFIG.GONIO_MANUAL_GAIN_DB = Math.max(-35, Math.min(35, Math.round(fallback / 5) * 5)); } + CONFIG.CORR_RESPONSE_S = Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0; + CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)); + const persistenceMode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase(); + CONFIG.GONIO_PERSISTENCE_MODE = ['fast', 'medium', 'slow', 'custom'].includes(persistenceMode) + ? persistenceMode + : 'fast'; + CONFIG.GONIO_LINE_FADE_MS = Math.max(0, Math.min(600, Number(CONFIG.GONIO_LINE_FADE_MS) || 0)); } catch (e) { console.warn('Config load error:', e); } diff --git a/www/index.html b/www/index.html index 6d4c73e..4118680 100644 --- a/www/index.html +++ b/www/index.html @@ -507,23 +507,23 @@ Silence-Gate aktivieren (RMS-basiert) - Gatet Goniometer & Korrelation bei Pegeln unterhalb der Schwelle + Blendet nur die Goniometer-Spur bei Stille aus; die Korrelation wird unabhängig kontinuierlich gemessen.
Default: -75 dBFS RMS (Mono)
-
- - Glättet die Korrelation für ruhigere Anzeige +
+ + Kontinuierliche DSP-Integration; unabhängig von Bildrate und ALSA-Periode.
- - Setzt den Korrelations‑Würfel bei Stille sofort auf 0 (ohne Hold/Drift). + + Setzt den Negative-Peak-Memory auf 0 zurück; der rote Marker verschwindet.
@@ -536,9 +536,18 @@ Abstand zwischen den XY-Meter-Slots
-
+
+ + Fast/Medium/Slow sind reproduzierbare Profile; die endgültige RTW-Abstimmung erfolgt am Referenzgerät. +
+
- Nachleuchten der Spur (ms) + Wird beim Verschieben automatisch als Phoenix-Profil aktiviert.
diff --git a/www/ui/options.js b/www/ui/options.js index 98509cd..09986ae 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -270,8 +270,7 @@ function syncUI() { ['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL], ['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN], - ['opt_corrSmooth', CONFIG.CORR_SMOOTH, 'val_corrSmooth', (v)=>Number(v).toFixed(2)], - ['opt_corrZeroOnSilence', CONFIG.CORR_ZERO_ON_SILENCE, null, null, 'checkbox'], + ['opt_corrResponse', String(Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1)], ['opt_xyPoints', String(CONFIG.XY_POINTS)], ['opt_xyStyle', CONFIG.XY_STYLE], ['opt_xySilenceGate', CONFIG.XY_SILENCE_GATE_ENABLED, null, null, 'checkbox'], @@ -316,6 +315,7 @@ function syncUI() { ['opt_phaseTrail', CONFIG.PHASE_TRAIL_ENABLED, null, null, 'checkbox'], ['opt_goniGap', CONFIG.GONI_METER_GAP, 'val_goniGap', (v)=>`${v} px`], ['opt_goniFade', CONFIG.GONIO_LINE_FADE_MS, 'val_goniFade', (v)=>`${Math.round(v)} ms`], + ['opt_goniPersistence', CONFIG.GONIO_PERSISTENCE_MODE || 'fast'], ['opt_panelDividers', CONFIG.PANEL_DIVIDERS_ENABLED, null, null, 'checkbox'], ['opt_lufsRedThr', CONFIG.LUFS_RED_START], ['opt_lufsYellowThr', CONFIG.LUFS_YELLOW_START], @@ -916,11 +916,17 @@ function wireHandlers(env) { notifyPhoenixGlobalConfig(); }); - h('opt_corrSmooth', v => CONFIG.CORR_SMOOTH = clamp(+v, 0.5, 0.98), 'val_corrSmooth', v=>Number(v).toFixed(2)); - h('opt_corrZeroOnSilence', v => { - CONFIG.CORR_ZERO_ON_SILENCE = !!v; - return CONFIG.CORR_ZERO_ON_SILENCE; - }, null, null, true); + h('opt_corrResponse', v => { + CONFIG.CORR_RESPONSE_S = Number(v) >= 1.75 ? 2.5 : 1.0; + try { env?.notifyRtaConfig?.(); } catch (_) {} + return String(CONFIG.CORR_RESPONSE_S); + }); + const corrResetPeak = E('opt_corrResetPeak'); + if (corrResetPeak) corrResetPeak.onclick = () => { + CONFIG.CORR_RESET_TOKEN = Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)) + 1; + saveConfig(); + try { env?.notifyRtaConfig?.(); } catch (_) {} + }; h('opt_xyPoints', v => { const raw = Number(v); CONFIG.XY_POINTS = [128, 256, 512, 1024, 2048].includes(raw) @@ -1047,7 +1053,17 @@ function wireHandlers(env) { notifyPhoenixGlobalConfig(); }, null, null, true); h('opt_goniGap', v => CONFIG.GONI_METER_GAP = clamp(+v, 2, 24), 'val_goniGap', v=>`${v} px`); - h('opt_goniFade', v => { CONFIG.GONIO_LINE_FADE_MS = clamp(+v, 0, 600); }, 'val_goniFade', v=>`${Math.round(v)} ms`); + h('opt_goniPersistence', v => { + const mode = String(v).toLowerCase(); + CONFIG.GONIO_PERSISTENCE_MODE = ['fast', 'medium', 'slow', 'custom'].includes(mode) ? mode : 'fast'; + return CONFIG.GONIO_PERSISTENCE_MODE; + }); + h('opt_goniFade', v => { + CONFIG.GONIO_LINE_FADE_MS = clamp(+v, 0, 600); + CONFIG.GONIO_PERSISTENCE_MODE = 'custom'; + const persistence = E('opt_goniPersistence'); + if (persistence) persistence.value = 'custom'; + }, 'val_goniFade', v=>`${Math.round(v)} ms`); h('opt_panelDividers', v => { CONFIG.PANEL_DIVIDERS_ENABLED = !!v; return CONFIG.PANEL_DIVIDERS_ENABLED; diff --git a/www/views/goniometer_rtw.js b/www/views/goniometer_rtw.js index 29b47cb..c38ffea 100644 --- a/www/views/goniometer_rtw.js +++ b/www/views/goniometer_rtw.js @@ -41,6 +41,7 @@ export function init() { agcEnv: 1e-3, agcGainDb: 0, agcLastTs: 0, + agcLastXySeq: 0, traceBuffer: new Float32Array(0), lineTrails: [], trailPool: [], @@ -51,7 +52,12 @@ export function init() { } export function resize() {} -export function destroy() {} +export function destroy(state) { + if (!state) return; + clearTrails(state); + state.trailPool.length = 0; + state.traceBuffer = new Float32Array(0); +} export async function render(env, state) { const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env; @@ -68,12 +74,14 @@ export async function render(env, state) { const layout = computeGoniometerLayout(rect, CONFIG, slots.length, topInset); const settings = resolveScopeSettings(CONFIG); const style = CONFIG.XY_STYLE === 'points' ? 'points' : 'lines'; - const gate = resolveSilenceGate(env, CONFIG, state); drawStaticLayer(g, state, rect, layout, CONFIG, slots.length); const xyData = extractXYData(audio); - const lastSampleTs = Number.isFinite(env?.audio?.lastSampleTs) ? Number(env.audio.lastSampleTs) : 0; + const gate = resolveSilenceGate(CONFIG, state, xyData); + const lastSampleTs = Number.isFinite(env?.audio?.xyLastSampleTs) + ? Number(env.audio.xyLastSampleTs) + : 0; const audioAgeMs = lastSampleTs ? (frameNow - lastSampleTs) : Infinity; const audioFresh = Number.isFinite(audioAgeMs) && audioAgeMs >= 0 && audioAgeMs <= 250; const xyReady = xyData.ready && audioFresh; @@ -396,6 +404,7 @@ function extractXYData(audio) { ready, xyL, xyR, + seq: Number(audio?.xySeq) || 0, length: ready ? Math.min(xyL.length, xyR.length) : 0, }; } @@ -403,7 +412,7 @@ function extractXYData(audio) { function resolveScopeSettings(CONFIG) { let gainDb = Number.isFinite(CONFIG?.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0; gainDb = Math.max(GONIO_GAIN_MIN_DB, Math.min(GONIO_GAIN_MAX_DB, Math.round(gainDb / 5) * 5)); - const lineFadeMs = Number.isFinite(CONFIG?.GONIO_LINE_FADE_MS) ? CONFIG.GONIO_LINE_FADE_MS : 300; + const lineFadeMs = resolveGoniometerPersistenceMs(CONFIG); const rtwClassic = true; return { gainDb, @@ -417,16 +426,37 @@ function resolveScopeSettings(CONFIG) { pointBaseAlpha: rtwClassic ? 0.14 : 0.16, pointBoostAlpha: rtwClassic ? 0.34 : 0.36, pointSize: rtwClassic ? 1.4 : 2.2, - curveSmoothing: rtwClassic, + // Do not bend the measured M/S sample path with cosmetic Bézier curves. + curveSmoothing: false, }; } -function resolveSilenceGate(env, CONFIG, state) { +export function resolveGoniometerPersistenceMs(CONFIG = {}) { + const mode = String(CONFIG.GONIO_PERSISTENCE_MODE || 'fast').toLowerCase(); + if (mode === 'medium') return 150; + if (mode === 'slow') return 300; + if (mode === 'custom') { + const custom = Number(CONFIG.GONIO_LINE_FADE_MS); + return Number.isFinite(custom) ? Math.max(0, Math.min(600, custom)) : 50; + } + return 50; +} + +function resolveSilenceGate(CONFIG, state, xyData) { const enabled = CONFIG?.XY_SILENCE_GATE_ENABLED !== false; const threshold = Number.isFinite(CONFIG?.XY_SILENCE_THRESHOLD_RMS_DBFS) ? CONFIG.XY_SILENCE_THRESHOLD_RMS_DBFS : CORR_SILENCE_THRESHOLD_DEFAULT; - const rmsMono = Number.isFinite(env.audio?.rmsDb?.mono) ? env.audio.rmsDb.mono : -120; + let power = 0; + if (xyData?.ready && xyData.length > 0) { + for (let index = 0; index < xyData.length; index++) { + const left = Number(xyData.xyL[index]) || 0; + const right = Number(xyData.xyR[index]) || 0; + power += left * left + right * right; + } + power /= 2 * xyData.length; + } + const rmsMono = power > 1e-12 ? 10 * Math.log10(power) : -120; const targetActive = enabled ? (rmsMono >= threshold) : true; const now = getNow(); const dt = state.gateLastTs ? Math.max(0, (now - state.gateLastTs) / 1000) : 0; @@ -450,7 +480,8 @@ function buildTrace(state, xyData, scope, scale, CONFIG) { if (targetPoints <= 1 || total <= 1) { return { buffer: null, count: 0 }; } - const sampleCount = Math.max(2, Math.min(targetPoints, MAX_TRACE_POINTS)); + // The browser may reduce density but must never invent interpolated samples. + const sampleCount = Math.max(2, Math.min(targetPoints, total, MAX_TRACE_POINTS)); if (sampleCount <= 1) { return { buffer: null, count: 0 }; } @@ -497,7 +528,7 @@ function renderTrace(g, state, trace, scope, style, xyReady, settings) { if (style === 'lines') { if (lineFadeMs <= 0) { - trails.length = 0; + clearTrails(state); if (hasTrace && trace.count > 1) { drawImmediateLine(g, trace, scope, settings); } else if (!xyReady) { @@ -505,14 +536,14 @@ function renderTrace(g, state, trace, scope, style, xyReady, settings) { } } else { if (hasTrace && trace.count > 1) { - addLineTrail(trails, trace, now); + addLineTrail(state, trace, now); } - const rendered = drawLineTrails(g, trails, scope, now, lineFadeMs, settings); + const rendered = drawLineTrails(g, trails, state.trailPool, scope, now, lineFadeMs, settings); if (!rendered && !xyReady) drawIdleMessage(g, scope); } } else { if (lineFadeMs <= 0) { - trails.length = 0; + clearTrails(state); if (hasTrace) { drawPointTrace(g, trace, scope, settings); } else if (!xyReady) { @@ -520,9 +551,9 @@ function renderTrace(g, state, trace, scope, style, xyReady, settings) { } } else { if (hasTrace && trace.count > 0) { - addLineTrail(trails, trace, now); + addLineTrail(state, trace, now); } - const rendered = drawPointTrails(g, trails, scope, now, lineFadeMs, settings); + const rendered = drawPointTrails(g, trails, state.trailPool, scope, now, lineFadeMs, settings); if (!rendered && !xyReady) { drawIdleMessage(g, scope); } @@ -530,17 +561,36 @@ function renderTrace(g, state, trace, scope, style, xyReady, settings) { } } -function addLineTrail(trails, trace, timestamp) { - const coords = new Float32Array(trace.count * 2); +function addLineTrail(state, trace, timestamp) { + const trails = state.lineTrails || (state.lineTrails = []); + const pool = state.trailPool || (state.trailPool = []); + while (trails.length >= MAX_TRAIL_FRAMES) releaseTrail(pool, trails.shift()); + let coords = pool.pop(); + if (!(coords instanceof Float32Array) || coords.length < trace.count * 2) { + coords = new Float32Array(trace.count * 2); + } coords.set(trace.buffer.subarray(0, trace.count * 2)); trails.push({ coords, count: trace.count, time: timestamp }); } -function drawLineTrails(g, trails, scope, now, fadeMs, settings) { +function releaseTrail(pool, trail) { + if (trail?.coords instanceof Float32Array && pool.length < MAX_TRAIL_FRAMES) { + pool.push(trail.coords); + } +} + +function clearTrails(state) { + const trails = state.lineTrails || []; + const pool = state.trailPool || (state.trailPool = []); + for (const trail of trails) releaseTrail(pool, trail); + trails.length = 0; +} + +function drawLineTrails(g, trails, pool, scope, now, fadeMs, settings) { if (!trails.length) return false; const cutoff = now - fadeMs; - const keep = []; + let keepCount = 0; let drawn = false; g.save(); @@ -549,13 +599,19 @@ function drawLineTrails(g, trails, scope, now, fadeMs, settings) { g.clip(); for (const trail of trails) { - if (trail.time < cutoff) continue; + if (trail.time < cutoff) { + releaseTrail(pool, trail); + continue; + } const age = now - trail.time; const fade = Math.max(0, 1 - age / fadeMs); - if (fade <= 0) continue; + if (fade <= 0) { + releaseTrail(pool, trail); + continue; + } drawn = true; - keep.push(trail); + trails[keepCount++] = trail; g.save(); g.globalAlpha = (settings?.trailBaseAlpha ?? 0.35) + (settings?.trailBoostAlpha ?? 0.65) * fade; @@ -568,8 +624,7 @@ function drawLineTrails(g, trails, scope, now, fadeMs, settings) { g.restore(); - trails.length = 0; - Array.prototype.push.apply(trails, keep); + trails.length = keepCount; return drawn; } @@ -611,11 +666,11 @@ function beginTracePath(g, coords, count, settings) { g.quadraticCurveTo(coords[prev], coords[prev + 1], coords[last], coords[last + 1]); } -function drawPointTrails(g, trails, scope, now, fadeMs, settings) { +function drawPointTrails(g, trails, pool, scope, now, fadeMs, settings) { if (!trails.length) return false; const cutoff = now - fadeMs; - const keep = []; + let keepCount = 0; let drawn = false; const pointSize = settings?.pointSize ?? 1.4; const half = pointSize / 2; @@ -626,13 +681,19 @@ function drawPointTrails(g, trails, scope, now, fadeMs, settings) { g.clip(); for (const trail of trails) { - if (trail.time < cutoff) continue; + if (trail.time < cutoff) { + releaseTrail(pool, trail); + continue; + } const age = now - trail.time; const fade = Math.max(0, 1 - age / fadeMs); - if (fade <= 0) continue; + if (fade <= 0) { + releaseTrail(pool, trail); + continue; + } drawn = true; - keep.push(trail); + trails[keepCount++] = trail; for (let i = 0; i < trail.count; i++) { const idx = i * 2; @@ -645,8 +706,7 @@ function drawPointTrails(g, trails, scope, now, fadeMs, settings) { g.restore(); - trails.length = 0; - Array.prototype.push.apply(trails, keep); + trails.length = keepCount; return drawn; } @@ -703,6 +763,10 @@ function getNow() { function computeAgcGain(state, xyData, nowTs) { const attackTau = 0.001; // 1 ms const releaseDbPerS = 10; + if (xyData.seq > 0 && xyData.seq === state.agcLastXySeq) { + return { gainDb: state.agcGainDb, gain: dbToLinear(state.agcGainDb) }; + } + state.agcLastXySeq = xyData.seq; const dt = state.agcLastTs ? Math.max(0, (nowTs - state.agcLastTs) / 1000) : 0; state.agcLastTs = nowTs; @@ -785,7 +849,7 @@ function drawCorrelationBar(g, centerX, y, w, h, val, negativePeak) { g.lineWidth = 1; g.strokeRect(cubeX, cubeY, cubeSize, cubeSize); g.beginPath(); g.moveTo(mid, y); g.lineTo(mid, y + h); g.stroke(); - if (Number.isFinite(negativePeak) && negativePeak < 0.999) { + if (Number.isFinite(negativePeak) && negativePeak < -0.001) { const markerX = clampCenter(mid + (clamp1(negativePeak) * 0.5) * w); g.fillStyle = WARN_COLOR; g.beginPath();