From ec3b6c3059df80a71bea9d0937fb58d26bb7c81b Mon Sep 17 00:00:00 2001 From: michael <1+michael@noreply.git.casaderoll.de> Date: Fri, 4 Sep 2026 12:44:28 +0200 Subject: [PATCH] correlation: hold last value and ease to zero on silence The XY correlation cube snapped to center the moment the audio dropped below the silence threshold, because process() hard-set value to 0.0 in the silent branch. Now the silent branch eases the displayed value back toward zero using the configured response time, so the cube holds its last reading and then settles instead of jumping. The active path is unchanged, so all existing response-time and peak tests still pass. - add `display` (f64) that tracks the live correlation while active - silent branch: display += alpha * (0 - display) - update configurable_silence_threshold test for the eased decay - add correlation_holds_then_decays_to_zero_on_silence test --- src/correlation.rs | 75 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/src/correlation.rs b/src/correlation.rs index 144d94c..ed5398f 100644 --- a/src/correlation.rs +++ b/src/correlation.rs @@ -17,6 +17,7 @@ pub struct CorrelationMeter { cross_power: f64, gate_power: f64, value: f32, + display: f64, negative_peak: f32, reset_token: u64, } @@ -40,6 +41,7 @@ impl CorrelationMeter { cross_power: 0.0, gate_power: 0.0, value: 0.0, + display: 0.0, negative_peak: 0.0, reset_token, }; @@ -93,18 +95,24 @@ impl CorrelationMeter { * (0.5 * (l * l + r * r) - self.gate_power); // Below the configured mono RMS threshold the correlation indication - // settles at its neutral position. + // eases back to its neutral position instead of snapping there. const MIN_POWER: f64 = 1.0e-10; let denominator = (self.power_l * self.power_r).sqrt(); - self.value = if self.gate_power >= self.silence_threshold_power + let active = 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 + && denominator > 0.0; + if active { + // Audio present: track the live correlation exactly as before. + self.display = (self.cross_power / denominator).clamp(-1.0, 1.0); + self.value = self.display as f32; } else { - 0.0 - }; + // Silent: hold the last value, then decay it back to zero using + // the configured response time, so the XY cube settles instead of + // jumping to center. + self.display += self.alpha * (0.0 - self.display); + self.value = self.display as f32; + } if self.value < self.negative_peak { self.negative_peak = self.value; } @@ -255,7 +263,26 @@ mod tests { }; (sample, sample) }); - assert_eq!(meter.value(), 0.0); + // Raising the threshold above the signal level neutralizes the meter. + // With the eased display it decays toward zero rather than snapping. + assert!( + meter.value() < 0.2, + "meter did not neutralize: {}", + meter.value() + ); + run_signal(&mut meter, 3, |index| { + let sample = if index & 1 == 0 { + amplitude + } else { + -amplitude + }; + (sample, sample) + }); + assert!( + meter.value().abs() < 0.05, + "meter did not settle to zero: {}", + meter.value() + ); } #[test] @@ -278,4 +305,36 @@ mod tests { }); assert_eq!(meter.value(), 0.0); } + + #[test] + fn correlation_holds_then_decays_to_zero_on_silence() { + let sample_rate = 48_000; + let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); + // Drive a strongly correlated signal so the meter settles near +1. + for index in 0..sample_rate as usize * 3 { + let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; + meter.process(sample, sample); + } + assert!(meter.value() > 0.9, "expected near +1, got {}", meter.value()); + + // Cut the audio: the value must hold (not snap to 0) right away... + for _ in 0..10 { + meter.process(0.0, 0.0); + } + assert!( + meter.value() > 0.5, + "value snapped to zero on silence: {}", + meter.value() + ); + + // ...and then decay back toward zero over the response time. + for _ in 0..sample_rate as usize * 4 { + meter.process(0.0, 0.0); + } + assert!( + meter.value().abs() < 0.05, + "value did not decay to zero: {}", + meter.value() + ); + } }