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 @@