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
This commit is contained in:
2026-09-04 12:44:28 +02:00
parent 684c5fe3d3
commit ec3b6c3059
+67 -8
View File
@@ -17,6 +17,7 @@ pub struct CorrelationMeter {
cross_power: f64, cross_power: f64,
gate_power: f64, gate_power: f64,
value: f32, value: f32,
display: f64,
negative_peak: f32, negative_peak: f32,
reset_token: u64, reset_token: u64,
} }
@@ -40,6 +41,7 @@ impl CorrelationMeter {
cross_power: 0.0, cross_power: 0.0,
gate_power: 0.0, gate_power: 0.0,
value: 0.0, value: 0.0,
display: 0.0,
negative_peak: 0.0, negative_peak: 0.0,
reset_token, reset_token,
}; };
@@ -93,18 +95,24 @@ impl CorrelationMeter {
* (0.5 * (l * l + r * r) - self.gate_power); * (0.5 * (l * l + r * r) - self.gate_power);
// Below the configured mono RMS threshold the correlation indication // 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; const MIN_POWER: f64 = 1.0e-10;
let denominator = (self.power_l * self.power_r).sqrt(); 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_l > MIN_POWER
&& self.power_r > MIN_POWER && self.power_r > MIN_POWER
&& denominator > 0.0 && denominator > 0.0;
{ if active {
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32 // 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 { } 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 { if self.value < self.negative_peak {
self.negative_peak = self.value; self.negative_peak = self.value;
} }
@@ -255,7 +263,26 @@ mod tests {
}; };
(sample, sample) (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] #[test]
@@ -278,4 +305,36 @@ mod tests {
}); });
assert_eq!(meter.value(), 0.0); 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()
);
}
} }