From a4fa0ceb47442d12619561f82ac914eb10234e06 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:13:07 +0200 Subject: [PATCH] Improve phase trail and correlation controls --- TODO.md | 4 +- scripts/test_phase_wheel.mjs | 43 +++++++++++ scripts/test_rta_profile.mjs | 22 +++++- src/audio.rs | 3 +- src/correlation.rs | 133 ++++++++++++++++++++++++++++++----- src/model.rs | 2 + src/state.rs | 4 ++ www/changelog.html | 2 +- www/core/audio.js | 12 +++- www/core/config.js | 10 ++- www/index.html | 3 +- www/ui/options.js | 9 +-- www/views/phase_wheel.js | 62 ++++++++++++---- 13 files changed, 268 insertions(+), 41 deletions(-) diff --git a/TODO.md b/TODO.md index d23f003..6eef058 100644 --- a/TODO.md +++ b/TODO.md @@ -143,8 +143,8 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f - **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:** 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. + - **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 0,5 s, 1,0 s und 2,5 s sowie Negative-Peak-Memory. + - **Ist:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 0,5 s, 1,0 s und 2,5 s sind wählbar; ein konfigurierbarer Mono-RMS-Silence-Threshold setzt die Anzeige bei Grundrauschen auf Neutralstellung. 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. diff --git a/scripts/test_phase_wheel.mjs b/scripts/test_phase_wheel.mjs index a4c83bf..79389bf 100644 --- a/scripts/test_phase_wheel.mjs +++ b/scripts/test_phase_wheel.mjs @@ -16,4 +16,47 @@ assert.ok(Math.abs(twoFrames - context.smoothingAlpha(1 / 30, 0.2)) < 1e-12, assert.doesNotMatch(source, /auto\.gain \* PHASE_AGC_BASE_GAIN/, 'AGC target gain must not be multiplied a second time'); +const maxAngleStep = Number( + source.match(/PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=\s*\(([\d.]+)\s*\*\s*Math\.PI\)\s*\/\s*180/)?.[1], +) * Math.PI / 180; +assert.ok(Number.isFinite(maxAngleStep) && maxAngleStep <= (2 * Math.PI) / 180, + 'phase trail interpolation must limit angular steps to at most two degrees'); + +const interpolationSource = source.match(/function appendPhaseTrailSegment[\s\S]*?\n\}/)?.[0]; +assert.ok(interpolationSource, 'phase trail must interpolate polar segments'); + +const trailContext = vm.createContext({ Number, Math }); +const trailHelpers = [ + source.match(/const PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=.*?;/)?.[0], + source.match(/function radToDeg[\s\S]*?\n\}/)?.[0], + source.match(/function trailColorForAngle[\s\S]*?\n\}/)?.[0], + source.match(/function wrapAngle[\s\S]*?\n\}/)?.[0], + source.match(/function lerp[\s\S]*?\n\}/)?.[0], + source.match(/function createPhaseTrailPoint[\s\S]*?\n\}/)?.[0], + interpolationSource, +]; +assert.ok(trailHelpers.every(Boolean), 'phase trail interpolation helpers must remain testable'); +vm.runInContext(trailHelpers.join('\n'), trailContext); + +const wheel = { cx: 0, cy: 0, radius: 100 }; +const trail = [trailContext.createPhaseTrailPoint(wheel, -Math.PI / 2, 1, 0)]; +trailContext.appendPhaseTrailSegment(trail, wheel, trail[0], Math.PI / 2, 1, 100); +assert.equal(trail.length, 91, + 'a 180-degree phase change must be split into 90 two-degree segments'); +for (let index = 1; index < trail.length; index++) { + const previousAngle = Math.atan2(trail[index - 1].y, trail[index - 1].x); + const currentAngle = Math.atan2(trail[index].y, trail[index].x); + const angularStep = Math.abs(Math.atan2( + Math.sin(currentAngle - previousAngle), + Math.cos(currentAngle - previousAngle), + )); + assert.ok(angularStep <= maxAngleStep + 1e-12, + 'interpolated trail segments must respect the maximum angular step'); + assert.ok(Math.abs(Math.hypot(trail[index].x, trail[index].y) - wheel.radius) < 1e-9, + 'constant-radius phase movement must remain on a circular arc'); +} + +assert.match(source, /PHASE_TRAIL_MAX_POINTS/, + 'interpolated phase trail history must remain bounded'); + console.log('phase wheel regression tests passed'); diff --git a/scripts/test_rta_profile.mjs b/scripts/test_rta_profile.mjs index ef63a63..d8a6d03 100644 --- a/scripts/test_rta_profile.mjs +++ b/scripts/test_rta_profile.mjs @@ -5,10 +5,26 @@ import { getRtwCenters } from '../www/core/rtw_centers.js'; import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js'; const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); +const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8'); +const correlationNormalizerSource = configSource.match( + /export function normalizeCorrelationResponseSeconds[\s\S]*?\n\}/, +)?.[0]; +assert.ok(correlationNormalizerSource, 'correlation response normalization must remain testable'); +const correlationContext = vm.createContext({ Number }); +vm.runInContext( + correlationNormalizerSource.replace('export function', 'function'), + correlationContext, +); +assert.equal(correlationContext.normalizeCorrelationResponseSeconds(0.5), 0.5); +assert.equal(correlationContext.normalizeCorrelationResponseSeconds(1.0), 1.0); +assert.equal(correlationContext.normalizeCorrelationResponseSeconds(2.5), 2.5); +assert.equal(correlationContext.normalizeCorrelationResponseSeconds(Number.NaN), 1.0); + const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0]; assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable'); const context = vm.createContext({ Number, + normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds, getRtwCenters(mode) { if (mode === '1_12') return new Array(121).fill(0); if (mode === '1_6') return new Array(61).fill(0); @@ -29,6 +45,7 @@ const forced = context.buildRtaRuntimeConfig({ RTA_PEAK_DECAY_DB_PER_S: 20, RTA_PEAK_RESET_TOKEN: 9, CORR_RESPONSE_S: 2.5, + CORR_SILENCE_THRESHOLD_RMS_DBFS: -50, CORR_RESET_TOKEN: 7, XY_POINTS: 256, }); @@ -44,9 +61,13 @@ assert.equal(forced.rtaPeakResetToken, 9); assert.equal(forced.tauFast, 0.125); assert.equal(forced.rtwCenters.length, 121); assert.equal(forced.correlationResponseS, 2.5); +assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50); assert.equal(forced.correlationResetToken, 7); assert.equal(forced.xyPoints, 256); +const veryFastCorrelation = context.buildRtaRuntimeConfig({ CORR_RESPONSE_S: 0.5 }); +assert.equal(veryFastCorrelation.correlationResponseS, 0.5); + const extension = context.buildRtaRuntimeConfig({ RTA_BAR_LAYOUT: 'iec', RTA_ENGINE: 'fft', @@ -59,7 +80,6 @@ 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({ diff --git a/src/audio.rs b/src/audio.rs index eaeb6d0..cc59036 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -628,7 +628,7 @@ impl Default for PpmState { true_peak_r: TruePeakDetector::default(), transport_clock: GoniometerClock::default(), transport_peaks: TransportPeaks::default(), - correlation: CorrelationMeter::new(48_000, 1.0, 0), + correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0), phase_wheel: PhaseWheelAnalyzer::new(48_000), xy_pending_l: Vec::with_capacity(1024), xy_pending_r: Vec::with_capacity(1024), @@ -1178,6 +1178,7 @@ fn process_audio_block( ppm_state.correlation.configure( sample_rate, rta_config.correlation_response_s, + rta_config.correlation_silence_threshold_rms_dbfs, rta_config.correlation_reset_token, ); diff --git a/src/correlation.rs b/src/correlation.rs index e9808c7..144d94c 100644 --- a/src/correlation.rs +++ b/src/correlation.rs @@ -9,39 +9,73 @@ pub struct CorrelationMeter { sample_rate: u32, response_seconds: f32, alpha: f64, + gate_alpha: f64, + silence_threshold_db: f32, + silence_threshold_power: f64, power_l: f64, power_r: f64, cross_power: f64, + gate_power: f64, value: f32, negative_peak: f32, reset_token: u64, } impl CorrelationMeter { - pub fn new(sample_rate: u32, response_seconds: f32, reset_token: u64) -> Self { + pub fn new( + sample_rate: u32, + response_seconds: f32, + silence_threshold_db: f32, + reset_token: u64, + ) -> Self { let mut meter = Self { sample_rate: 0, response_seconds: 0.0, alpha: 1.0, + gate_alpha: 1.0, + silence_threshold_db: -75.0, + silence_threshold_power: 10.0_f64.powf(-7.5), power_l: 0.0, power_r: 0.0, cross_power: 0.0, + gate_power: 0.0, value: 0.0, negative_peak: 0.0, reset_token, }; - meter.configure(sample_rate, response_seconds, reset_token); + meter.configure( + sample_rate, + response_seconds, + silence_threshold_db, + reset_token, + ); meter } - pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) { + pub fn configure( + &mut self, + sample_rate: u32, + response_seconds: f32, + silence_threshold_db: f32, + reset_token: u64, + ) { let sample_rate = sample_rate.max(8_000); let response_seconds = normalize_response_seconds(response_seconds); + let silence_threshold_db = normalize_silence_threshold_db(silence_threshold_db); 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(); + // The silence decision must react independently of the selected + // correlation response time. A 50 ms RMS envelope avoids both + // sample-zero chatter and multi-second threshold lag. + self.gate_alpha = 1.0 - (-1.0 / (f64::from(sample_rate) * 0.05)).exp(); + } + if self.silence_threshold_db != silence_threshold_db { + self.silence_threshold_db = silence_threshold_db; + self.silence_threshold_power = + 10.0_f64.powf(f64::from(silence_threshold_db) / 10.0); } if self.reset_token != reset_token { self.reset_token = reset_token; @@ -55,12 +89,18 @@ impl CorrelationMeter { self.power_l += self.alpha * (l * l - self.power_l); self.power_r += self.alpha * (r * r - self.power_r); self.cross_power += self.alpha * (l * r - self.cross_power); + self.gate_power += self.gate_alpha + * (0.5 * (l * l + r * r) - self.gate_power); - // Below roughly -100 dBFS RMS per channel the quotient is no longer a - // useful phase measurement and should settle at the neutral position. + // Below the configured mono RMS threshold the correlation indication + // settles at its neutral position. const MIN_POWER: f64 = 1.0e-10; let denominator = (self.power_l * self.power_r).sqrt(); - self.value = if self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 { + self.value = if self.gate_power >= self.silence_threshold_power + && self.power_l > MIN_POWER + && self.power_r > MIN_POWER + && denominator > 0.0 + { (self.cross_power / denominator).clamp(-1.0, 1.0) as f32 } else { 0.0 @@ -80,10 +120,22 @@ impl CorrelationMeter { } pub fn normalize_response_seconds(value: f32) -> f32 { - if value.is_finite() && value >= 1.75 { - 2.5 - } else { + if !value.is_finite() || value <= 0.0 { 1.0 + } else if value < 0.75 { + 0.5 + } else if value < 1.75 { + 1.0 + } else { + 2.5 + } +} + +pub fn normalize_silence_threshold_db(value: f32) -> f32 { + if value.is_finite() { + value.clamp(-90.0, -40.0) + } else { + -75.0 } } @@ -111,7 +163,7 @@ mod tests { (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0), ] { - let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0); + let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); run_signal(&mut meter, 5, |index| { let angle = phase_step * index as f32; (angle.sin(), (angle + phase).sin()) @@ -127,8 +179,8 @@ mod tests { #[test] fn response_time_and_peak_reset_are_deterministic() { let sample_rate = 48_000; - let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0); - let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0); + let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); + let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0); for index in 0..sample_rate as usize { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; fast.process(sample, sample); @@ -141,33 +193,82 @@ mod tests { (sample, -sample) }); assert!(fast.negative_peak() < -0.7); - fast.configure(sample_rate, 1.0, 1); + fast.configure(sample_rate, 1.0, -75.0, 1); assert_eq!(fast.negative_peak(), 0.0); } #[test] fn fast_response_tracks_a_phase_reversal_before_slow_response() { let sample_rate = 48_000; - let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0); - let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0); + let mut very_fast = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0); + let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); + let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0); for index in 0..sample_rate as usize * 5 { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + very_fast.process(sample, sample); fast.process(sample, sample); slow.process(sample, sample); } for index in 0..sample_rate as usize { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + very_fast.process(sample, -sample); fast.process(sample, -sample); slow.process(sample, -sample); } + assert!(very_fast.value() < fast.value() - 0.35); assert!(fast.value() < slow.value() - 0.35); + assert!(very_fast.negative_peak() < fast.negative_peak()); assert!(fast.negative_peak() < 0.0); assert_eq!(slow.negative_peak(), 0.0); } + #[test] + fn response_time_normalization_accepts_all_three_profiles() { + assert_eq!(normalize_response_seconds(0.5), 0.5); + assert_eq!(normalize_response_seconds(1.0), 1.0); + assert_eq!(normalize_response_seconds(2.5), 2.5); + assert_eq!(normalize_response_seconds(f32::NAN), 1.0); + assert_eq!(normalize_response_seconds(0.0), 1.0); + } + + #[test] + fn configurable_silence_threshold_neutralizes_the_meter() { + let sample_rate = 48_000; + let amplitude = 10.0_f32.powf(-64.0 / 20.0); + let mut meter = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0); + run_signal(&mut meter, 1, |index| { + let sample = if index & 1 == 0 { + amplitude + } else { + -amplitude + }; + (sample, sample) + }); + assert!(meter.value() > 0.99); + + meter.configure(sample_rate, 0.5, -50.0, 0); + run_signal(&mut meter, 1, |index| { + let sample = if index & 1 == 0 { + amplitude + } else { + -amplitude + }; + (sample, sample) + }); + assert_eq!(meter.value(), 0.0); + } + + #[test] + fn silence_threshold_is_normalized_to_the_ui_range() { + assert_eq!(normalize_silence_threshold_db(-50.0), -50.0); + assert_eq!(normalize_silence_threshold_db(-100.0), -90.0); + assert_eq!(normalize_silence_threshold_db(-20.0), -40.0); + assert_eq!(normalize_silence_threshold_db(f32::NAN), -75.0); + } + #[test] fn silence_and_single_channel_are_neutral() { - let mut meter = CorrelationMeter::new(48_000, 1.0, 0); + let mut meter = CorrelationMeter::new(48_000, 1.0, -75.0, 0); run_signal(&mut meter, 2, |_| (0.0, 0.0)); assert_eq!(meter.value(), 0.0); assert_eq!(meter.negative_peak(), 0.0); diff --git a/src/model.rs b/src/model.rs index c8cfc2c..64802de 100644 --- a/src/model.rs +++ b/src/model.rs @@ -82,6 +82,7 @@ pub struct PhoenixRtaConfig { pub lufs_i_window_min: u32, pub lufs_i_norm_enabled: bool, pub correlation_response_s: f32, + pub correlation_silence_threshold_rms_dbfs: f32, pub correlation_reset_token: u64, pub xy_points: u32, } @@ -118,6 +119,7 @@ impl Default for PhoenixRtaConfig { lufs_i_window_min: 4, lufs_i_norm_enabled: false, correlation_response_s: 1.0, + correlation_silence_threshold_rms_dbfs: -75.0, correlation_reset_token: 0, xy_points: 1024, } diff --git a/src/state.rs b/src/state.rs index b6fba0f..4087a3f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -487,6 +487,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig { config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled; config.correlation_response_s = crate::correlation::normalize_response_seconds(config.correlation_response_s); + config.correlation_silence_threshold_rms_dbfs = + crate::correlation::normalize_silence_threshold_db( + config.correlation_silence_threshold_rms_dbfs, + ); config.xy_points = match config.xy_points { 128 | 256 | 512 | 1024 | 2048 => config.xy_points, n if n < 192 => 128, diff --git a/www/changelog.html b/www/changelog.html index fe4030b..e020c00 100644 --- a/www/changelog.html +++ b/www/changelog.html @@ -17,7 +17,7 @@
  • Hardwareparameter: 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.
  • Goniometer: 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.
  • Goniometer-Persistenz: 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.
  • -
  • Korrelation: 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.
  • +
  • Korrelation: Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 0,5/1,0/2,5 Sekunden und konfigurierbarem Mono-RMS-Silence-Threshold ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.
  • Phasenrad: Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.
  • True Peak, RMS und VU: True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.
  • Qualitätssicherung: Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.
  • diff --git a/www/core/audio.js b/www/core/audio.js index fc8bb1e..8d1e0fc 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -1,7 +1,12 @@ // core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers // Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters. -import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js'; +import { + applyPhoenixGlobalConfig, + buildPhoenixGlobalConfigPayload, + normalizeCorrelationResponseSeconds, + saveConfig, +} from './config.js'; import { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js'; import { getRtwCenters } from './rtw_centers.js'; @@ -943,7 +948,10 @@ export function buildRtaRuntimeConfig(CONFIG = {}) { ppmEbuDecayDbPerS: 24 / 2.8, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4, lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, - correlationResponseS: Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0, + correlationResponseS: normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S), + correlationSilenceThresholdRmsDbfs: Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS) + ? Math.max(-90, Math.min(-40, CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)) + : -75, correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)), xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS)) ? Number(CONFIG.XY_POINTS) diff --git a/www/core/config.js b/www/core/config.js index 84772f4..7a9743a 100644 --- a/www/core/config.js +++ b/www/core/config.js @@ -1,5 +1,13 @@ // core/config.js — zentrale CONFIG + Presets + Persistenz +export function normalizeCorrelationResponseSeconds(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return 1.0; + if (seconds < 0.75) return 0.5; + if (seconds < 1.75) return 1.0; + return 2.5; +} + function isLoopbackHost(host) { const h = String(host || '').trim().toLowerCase(); return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; @@ -971,7 +979,7 @@ 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_RESPONSE_S = normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S); const corrNegativeMarkerMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase(); CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(corrNegativeMarkerMode) ? corrNegativeMarkerMode diff --git a/www/index.html b/www/index.html index 611c96c..5c86d79 100644 --- a/www/index.html +++ b/www/index.html @@ -521,7 +521,7 @@ Silence-Gate aktivieren (RMS-basiert) - Blendet nur die Goniometer-Spur bei Stille aus; die Korrelation wird unabhängig kontinuierlich gemessen. + Blendet die Goniometer-Spur bei Stille aus; der Threshold setzt außerdem die Korrelation auf Neutralstellung.
    @@ -530,6 +530,7 @@
    diff --git a/www/ui/options.js b/www/ui/options.js index 7bed35a..1312690 100644 --- a/www/ui/options.js +++ b/www/ui/options.js @@ -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, applyRtaBpoSelection, 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, normalizeCorrelationResponseSeconds } from '../core/config.js'; import { clamp, clampPow2 } from '../core/utils.js'; import { bindOptionsViewportAssist } from './options_viewport.js'; import { setupLazyChangelog } from './changelog.js'; @@ -184,7 +184,7 @@ function syncUI() { ['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL], ['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN], - ['opt_corrResponse', String(Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1)], + ['opt_corrResponse', String(normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S))], ['opt_corrNegativeMarkerMode', CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory'], ['opt_xyPoints', String(CONFIG.XY_POINTS)], ['opt_xyStyle', CONFIG.XY_STYLE], @@ -457,7 +457,8 @@ function wireHandlers(env) { if (!el) return; const notifyRta = /^opt_rta/i.test(id) || id === 'opt_lufsIWindowMin' - || id === 'opt_lufsINorm'; + || id === 'opt_lufsINorm' + || id === 'opt_xySilenceThr'; const inputType = String(el.type || '').toLowerCase(); const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color'; @@ -813,7 +814,7 @@ function wireHandlers(env) { }); h('opt_corrResponse', v => { - CONFIG.CORR_RESPONSE_S = Number(v) >= 1.75 ? 2.5 : 1.0; + CONFIG.CORR_RESPONSE_S = normalizeCorrelationResponseSeconds(v); try { env?.notifyRtaConfig?.(); } catch (_) {} return String(CONFIG.CORR_RESPONSE_S); }); diff --git a/www/views/phase_wheel.js b/www/views/phase_wheel.js index 4e63b0e..e39741d 100644 --- a/www/views/phase_wheel.js +++ b/www/views/phase_wheel.js @@ -29,6 +29,8 @@ const PHASE_RADIUS_TAU_S = 0.085; const PHASE_TRAIL_FADE_MS = 2000; const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45; const PHASE_TRAIL_MIN_DIST_PX = 1.5; +const PHASE_TRAIL_MAX_ANGLE_STEP_RAD = (2 * Math.PI) / 180; +const PHASE_TRAIL_MAX_POINTS = 1024; const PHASE_SECTORS = [ { startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' }, { startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' }, @@ -517,31 +519,64 @@ function updatePhaseTrail(state, wheel, CONFIG, now) { } const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0); if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) { - const length = radiusNorm * wheel.radius; - const x = wheel.cx + Math.cos(state.smoothPhase) * length; - const y = wheel.cy + Math.sin(state.smoothPhase) * length; - const color = trailColorForAngle(state.smoothPhase); + const phase = wrapAngle(state.smoothPhase); + const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now); const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null; if (!last) { - state.phaseTrail.push({ x, y, t: now, color }); + state.phaseTrail.push(point); } else { const dt = now - last.t; - const dx = x - last.x; - const dy = y - last.y; + const dx = point.x - last.x; + const dy = point.y - last.y; const dist2 = dx * dx + dy * dy; - if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) { - state.phaseTrail.push({ x, y, t: now, color }); + if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) { + appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now); } else { - last.x = x; - last.y = y; + last.x = point.x; + last.y = point.y; last.t = now; - last.color = color; + last.color = point.color; + last.phase = phase; + last.radiusNorm = radiusNorm; } } } trimPhaseTrail(state, CONFIG, now); } +function createPhaseTrailPoint(wheel, phase, radiusNorm, timestamp) { + const length = radiusNorm * wheel.radius; + return { + x: wheel.cx + Math.cos(phase) * length, + y: wheel.cy + Math.sin(phase) * length, + t: timestamp, + color: trailColorForAngle(phase), + phase, + radiusNorm, + }; +} + +function appendPhaseTrailSegment(trail, wheel, last, phase, radiusNorm, timestamp) { + const startPhase = Number.isFinite(last?.phase) ? last.phase : phase; + const startRadius = Number.isFinite(last?.radiusNorm) ? last.radiusNorm : radiusNorm; + const phaseDelta = wrapAngle(phase - startPhase); + const steps = Math.max(1, Math.ceil(Math.abs(phaseDelta) / PHASE_TRAIL_MAX_ANGLE_STEP_RAD)); + const startTime = Number.isFinite(last?.t) ? last.t : timestamp; + + for (let step = 1; step <= steps; step++) { + const fraction = step / steps; + const interpolatedPhase = wrapAngle(startPhase + phaseDelta * fraction); + const interpolatedRadius = lerp(startRadius, radiusNorm, fraction); + const interpolatedTime = lerp(startTime, timestamp, fraction); + trail.push(createPhaseTrailPoint( + wheel, + interpolatedPhase, + interpolatedRadius, + interpolatedTime, + )); + } +} + function trimPhaseTrail(state, CONFIG, now) { if (!state.phaseTrail) state.phaseTrail = []; if (!CONFIG?.PHASE_TRAIL_ENABLED) { @@ -557,6 +592,9 @@ function trimPhaseTrail(state, CONFIG, now) { } } state.phaseTrail.length = write; + if (state.phaseTrail.length > PHASE_TRAIL_MAX_POINTS) { + state.phaseTrail.splice(0, state.phaseTrail.length - PHASE_TRAIL_MAX_POINTS); + } } function drawPhaseTrail(g, wheel, state, CONFIG, now) {