Compare commits

..
2 Commits
14 changed files with 375 additions and 91 deletions
+9 -8
View File
@@ -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.
+31
View File
@@ -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');
+21
View File
@@ -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',
@@ -38,4 +44,19 @@ assert.equal(extension.bpo, '1_12');
assert.equal(extension.freqRange, 'lf');
assert.equal(extension.order, 8);
const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8');
const selectionSource = configSource.match(/function applyRtaBpoSelection[\s\S]*?\n\}/)?.[0];
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
const selectionContext = vm.createContext({
String,
CONFIG: { RTA_BPO_MODE: '1_3', RTA_BAR_LAYOUT: 'rtw' },
});
vm.runInContext(selectionSource, selectionContext);
assert.equal(selectionContext.applyRtaBpoSelection('1_6'), '1_6');
assert.equal(selectionContext.CONFIG.RTA_BPO_MODE, '1_6');
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'iec');
selectionContext.CONFIG.RTA_BAR_LAYOUT = 'rtw';
assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3');
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
console.log('RTA profile regression tests passed');
+16 -15
View File
@@ -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<f32>,
xy_pending_r: Vec<f32>,
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<f
} else {
// Preserve chronological sample pairs while reducing transport to the
// selected display density. Endpoints are always retained.
for index in 0..count {
let source = index * (available - 1) / (count - 1);
for source in selected_sample_indices(available, count) {
left.push(state.xy_pending_l[source]);
right.push(state.xy_pending_r[source]);
}
@@ -1284,15 +1285,15 @@ fn build_meter_frame(
let ppm_ebu_l = dbfs(ppm_ebu_amp_l);
let ppm_ebu_r = dbfs(ppm_ebu_amp_r);
let wave_env = wave_env_flush(&mut ppm_state.wave_env);
ppm_state.xy_emit_phase = ppm_state.xy_emit_phase.saturating_add(
(frames as u64).saturating_mul(XY_TARGET_UPDATES_PER_SECOND),
);
let (xy_l, xy_r) = if ppm_state.xy_emit_phase >= 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,
+16 -7
View File
@@ -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)
+70
View File
@@ -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<usize> {
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]));
}
}
+1
View File
@@ -1,6 +1,7 @@
mod audio;
mod config;
mod correlation;
mod goniometer;
mod model;
mod ppm;
mod routes;
+13
View File
@@ -663,6 +663,9 @@ fn apply_global_to_rta(
) -> PhoenixRtaConfig {
config.fft_size = global.fft_size;
config.bpo = global.rta_bpo_mode.clone();
if config.bpo != "1_3" && config.layout == "rtw" {
config.layout = "iec".to_string();
}
config.input_offset_db_l = global.input_offset_db_l;
config.input_offset_db_r = global.input_offset_db_r;
config.mono_input = global.mono_input;
@@ -744,4 +747,14 @@ mod tests {
assert_eq!(config.freq_range, "lf");
assert_eq!(config.integration, "medium");
}
#[test]
fn non_third_octave_global_selection_leaves_locked_rtw_profile() {
let mut global = PhoenixGlobalConfig::default();
global.rta_bpo_mode = "1_12".to_string();
let config = apply_global_to_rta(PhoenixRtaConfig::default(), &global);
assert_eq!(config.layout, "iec");
assert_eq!(config.bpo, "1_12");
assert_eq!(config.engine, "iir");
}
}
+2
View File
@@ -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') {
+23 -1
View File
@@ -281,6 +281,17 @@ const CONFIG = {
SOFTWARE_PRESET: 'default',
};
const CONFIG_DEFAULTS = JSON.parse(JSON.stringify(CONFIG));
function applyRtaBpoSelection(value) {
const mode = ['1_3', '1_6', '1_12'].includes(String(value)) ? String(value) : '1_3';
CONFIG.RTA_BPO_MODE = mode;
// RTW remains the original 31-band third-octave reference profile. Finer
// resolutions are Phoenix extensions and switch visibly to the IEC profile.
if (mode !== '1_3' && CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_BAR_LAYOUT = 'iec';
}
return mode;
}
const SOFTWARE_PRESET_IDS = Object.freeze(['default', 'claus', 'michael']);
const LAYOUT_PRESET_IDS = Object.freeze(['1', '2', '3', '4']);
const LAYOUT_PRESET_KEYS = Object.freeze([
@@ -778,6 +789,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';
if (CONFIG.RTA_BAR_LAYOUT === 'rtw' && CONFIG.RTA_BPO_MODE !== '1_3') {
CONFIG.RTA_BAR_LAYOUT = 'iec';
}
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_BPO_MODE = '1_3';
@@ -934,6 +948,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);
}
@@ -1101,7 +1122,7 @@ function applyPhoenixGlobalConfig(payload = {}) {
}
if ('rtaBpoMode' in payload) {
const mode = String(payload.rtaBpoMode || '').trim();
CONFIG.RTA_BPO_MODE = (mode === '1_3' || mode === '1_6' || mode === '1_12') ? mode : '1_6';
applyRtaBpoSelection(mode);
}
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
@@ -1271,6 +1292,7 @@ export {
saveConfig,
buildPhoenixGlobalConfigPayload,
applyPhoenixGlobalConfig,
applyRtaBpoSelection,
applyBroadcastStandard,
applySoftwarePreset,
loadSoftwarePreset,
+47 -14
View File
@@ -310,7 +310,7 @@
<option value="1_6">1/6 Oktave</option>
<option value="1_12">1/12 Oktave</option>
</select>
<small>Filterauflösung Anzahl Bänder</small>
<small>RTW verwendet fest 1/3; 1/6 und 1/12 wechseln automatisch zum IEC-/Phoenix-Profil.</small>
</div>
<div class="opt"><label>IIR Ordnung</label>
<input id="opt_rtaOrder" type="number" min="2" max="8" step="2" style="width:110px">
@@ -507,23 +507,23 @@
<input id="opt_xySilenceGate" type="checkbox" checked>
Silence-Gate aktivieren (RMS-basiert)
</label>
<small>Gatet Goniometer &amp; Korrelation bei Pegeln unterhalb der Schwelle</small>
<small>Blendet nur die Goniometer-Spur bei Stille aus; die Korrelation wird unabhängig kontinuierlich gemessen.</small>
</div>
<div class="opt">
<label>Silence-Threshold [dBFS RMS]</label>
<input id="opt_xySilenceThr" type="number" min="-90" max="-40" step="1" style="width:120px">
<small>Default: -75 dBFS RMS (Mono)</small>
</div>
<div class="opt"><label>Korrelation Glättung</label>
<input id="opt_corrSmooth" type="range" min="0.5" max="0.98" step="0.01"><span id="val_corrSmooth"></span>
<small>Glättet die Korrelation für ruhigere Anzeige</small>
<div class="opt"><label>Korrelation Ansprechzeit</label>
<select id="opt_corrResponse">
<option value="1">1,0 s</option>
<option value="2.5">2,5 s</option>
</select>
<small>Kontinuierliche DSP-Integration; unabhängig von Bildrate und ALSA-Periode.</small>
</div>
<div class="opt">
<label class="row" style="align-items:center;gap:8px">
<input id="opt_corrZeroOnSilence" type="checkbox">
Sofortige Nullstellung wenn kein Pegel
</label>
<small>Setzt den KorrelationsWürfel bei Stille sofort auf 0 (ohne Hold/Drift).</small>
<button id="opt_corrResetPeak" type="button">Negativspitze zurücksetzen</button>
<small>Setzt den Negative-Peak-Memory auf 0 zurück; der rote Marker verschwindet.</small>
</div>
<div class="opt"><label>Goniometer Display Gain (dB)</label>
<input id="opt_goniGain" type="range" min="-35" max="35" step="5"><span id="val_goniGain"></span>
@@ -536,9 +536,18 @@
<input id="opt_goniGap" type="range" min="2" max="24" step="1"><span id="val_goniGap"></span>
<small>Abstand zwischen den XY-Meter-Slots</small>
</div>
<div class="opt"><label>Goniometer Fade-Out</label>
<div class="opt"><label>Goniometer-Persistenz</label>
<select id="opt_goniPersistence">
<option value="fast">Fast (50 ms)</option>
<option value="medium">Medium (150 ms)</option>
<option value="slow">Slow (300 ms)</option>
<option value="custom">Phoenix frei</option>
</select>
<small>Fast/Medium/Slow sind reproduzierbare Profile; die endgültige RTW-Abstimmung erfolgt am Referenzgerät.</small>
</div>
<div class="opt"><label>Freies Fade-Out</label>
<input id="opt_goniFade" type="range" min="0" max="600" step="10"><span id="val_goniFade"></span>
<small>Nachleuchten der Spur (ms)</small>
<small>Wird beim Verschieben automatisch als Phoenix-Profil aktiviert.</small>
</div>
</div>
</details>
@@ -1236,8 +1245,32 @@
</p>
<details style="margin:20px 0 0 5px">
<summary>Version / Änderungen</summary>
<div class="opt-group">
<details style="margin-left:12px" open>
<div class="opt-group">
<details style="margin-left:12px" open>
<summary>3.8 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
<p style="margin:0; color:#8fd3d4;">Veröffentlichung: 21.07.2026</p>
<p style="margin:4px 0 0 12px; color:#8fd3d4;">Änderungen:</p>
<ul style="margin:2px 0 0 24px; color:#b2c7d9;">
<li><strong>Analysegrundlage:</strong> Die tiefgehende Code- und DSP-Prüfung, durch die die nachfolgenden Fehler und Verbesserungsmöglichkeiten gefunden werden konnten, wurde durch den Umstieg auf das LLM-Modell GPT-5.6 SOL ermöglicht.</li>
<li><strong>Echtzeit-Datenweg:</strong> WebSocket-Verarbeitung auf „latest value wins“ umgestellt. Messwerte, Spektrogramm sowie Goniometer/Waveform verwenden getrennte, begrenzte Datenwege; alte Frames können keine anwachsende Anzeigeverzögerung mehr bilden.</li>
<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-Dritteloktav-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>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. 1/6 und 1/12 wechseln sichtbar in das IEC-/Phoenix-Erweiterungsprofil; das originale RTW-Profil bleibt korrekt auf 1/3 verriegelt.</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>
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
</ul>
</div>
</div>
</details>
<details style="margin-left:12px" open>
<summary>3.3.5 (Phoenix 1.0)</summary>
<div class="opt-group">
<div class="opt">
+3 -2
View File
@@ -20,7 +20,7 @@ try {
}
} catch(_) {}
import { CONFIG, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js';
import { CONFIG, applyRtaBpoSelection, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js';
import * as utils from './core/utils.js';
import { meterFacade, registerMeter } from './core/registry.js';
import { initAudio, audioLost, reloadAudio } from './core/audio.js';
@@ -2152,7 +2152,7 @@ function handlePeakHistScrollSelection(value){
function handleRtaBpoSelection(value){
const mode = normalizeRtaBpo(value);
CONFIG.RTA_BPO_MODE = mode;
applyRtaBpoSelection(mode);
saveConfig();
if (rtaBpoSel) {
rtaBpoSel.value = mode;
@@ -2162,6 +2162,7 @@ function handleRtaBpoSelection(value){
select.value = mode;
}
try { env.audio.updateRtaConfig?.(); } catch (_) {}
try { env.audio.updatePhoenixGlobalConfig?.(); } catch (_) {}
}
function getNearestWaveWindow(value){
+28 -13
View File
@@ -1,7 +1,7 @@
// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import
// Erwartet vorhandenes DOM aus index.html. Nutzt localStorage-Key 'analyzer_config'.
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js';
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js';
import { clamp, clampPow2 } from '../core/utils.js';
// DOM helper
@@ -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;
@@ -1059,10 +1075,9 @@ function wireHandlers(env) {
return CONFIG.RTA_ENGINE;
});
h('opt_rtaBpo', v => {
const allowed = new Set(['1_3', '1_6', '1_12']);
CONFIG.RTA_BPO_MODE = CONFIG.RTA_BAR_LAYOUT === 'rtw'
? '1_3'
: (allowed.has(v) ? v : '1_6');
applyRtaBpoSelection(v);
const layoutControl = E('opt_rtaLayout');
if (layoutControl) layoutControl.value = CONFIG.RTA_BAR_LAYOUT;
notifyPhoenixGlobalConfig();
return CONFIG.RTA_BPO_MODE;
});
+95 -31
View File
@@ -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();