test: harden mirrored probe audio detection

This commit is contained in:
Brad Stein 2026-05-02 16:32:03 -03:00
parent 37ea941750
commit c82c61c652
10 changed files with 88 additions and 12 deletions

View File

@ -462,3 +462,20 @@ variables via `os.environ`.
- [x] Add contract coverage so provisional calibration defaults cannot silently stop reaching the Python decision helper.
- [x] Run shell syntax checks, focused contract tests, and package checks.
- [x] Push clean semver `0.17.22` for installed client/server testing.
## 0.17.23 Audio Probe Robustness Checklist
Context: recent mirrored runs consistently detected more video events than audio events. That can
represent a real audio path problem, but the probe should not under-count audio just because the
room/speaker/mic path is quieter or mildly chopped. Harden the test tooling before interpreting
low paired-pulse counts as product failure.
- [x] Raise the default local stimulus tone level and expose it as `PROBE_AUDIO_GAIN`.
- [x] Pass the configured audio gain into the local stimulus browser page.
- [x] Lower the analyzer audio peak floor so faint but valid probe tones are accepted.
- [x] Smooth the audio envelope before thresholding so single-window dips do not erase pulses.
- [x] Merge longer internal tone dropouts inside one pulse without merging adjacent 1s pulses.
- [x] Add analyzer tests for faint tones and longer within-pulse audio dropouts.
- [x] Update manual probe contract coverage for the audio-gain control.
- [x] Run focused analyzer/manual-probe tests and package checks.
- [x] Push clean semver `0.17.23` for installed client/server testing.

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.17.22"
version = "0.17.23"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.17.22"
version = "0.17.23"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.17.22"
version = "0.17.23"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.17.22"
version = "0.17.23"
edition = "2024"
[dependencies]

View File

@ -16,7 +16,10 @@ const MAX_VIDEO_FLICKER_SEGMENT_FRAME_MULTIPLIER: f64 = 1.5;
const MIN_COLOR_PULSE_SATURATION: u8 = 36;
const MIN_COLOR_PULSE_VALUE: u8 = 70;
const MAX_COLOR_DISTANCE_SQUARED: u32 = 24_000;
const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.09;
const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.16;
const MIN_AUDIO_PROBE_PEAK: f64 = 25.0;
const AUDIO_ENVELOPE_THRESHOLD_FRACTION: f64 = 0.30;
const AUDIO_SAMPLE_THRESHOLD_FRACTION: f64 = 0.22;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct VideoColorFrame {
@ -331,7 +334,7 @@ pub(crate) fn detect_audio_segments(
}
let window_samples = ((sample_rate_hz as usize * window_ms as usize) / 1000).max(1);
let envelope = samples
let raw_envelope = samples
.chunks(window_samples)
.map(|chunk| {
let total: u64 = chunk
@ -341,19 +344,21 @@ pub(crate) fn detect_audio_segments(
total as f64 / chunk.len() as f64
})
.collect::<Vec<_>>();
let envelope = smooth_envelope(&raw_envelope);
let peak = envelope.iter().copied().fold(0.0_f64, f64::max);
if peak < 50.0 {
if peak < MIN_AUDIO_PROBE_PEAK {
bail!("audio probe peaks are too quiet to detect sync pulses");
}
let baseline = median(envelope.clone());
let threshold = baseline + ((peak - baseline) * 0.45);
let threshold = baseline + ((peak - baseline) * AUDIO_ENVELOPE_THRESHOLD_FRACTION);
let sample_abs = samples
.iter()
.map(|sample| i32::from(*sample).unsigned_abs() as f64)
.collect::<Vec<_>>();
let sample_peak = sample_abs.iter().copied().fold(0.0_f64, f64::max);
let sample_baseline = median(sample_abs.clone());
let sample_threshold = sample_baseline + ((sample_peak - sample_baseline) * 0.35);
let sample_threshold =
sample_baseline + ((sample_peak - sample_baseline) * AUDIO_SAMPLE_THRESHOLD_FRACTION);
let mut segments = Vec::new();
let mut previous_active = false;
let mut segment_start = 0usize;
@ -388,6 +393,20 @@ pub(crate) fn detect_audio_segments(
Ok(merge_nearby_audio_segments(segments))
}
fn smooth_envelope(envelope: &[f64]) -> Vec<f64> {
if envelope.len() < 3 {
return envelope.to_vec();
}
(0..envelope.len())
.map(|index| {
let start = index.saturating_sub(1);
let end = (index + 2).min(envelope.len());
envelope[start..end].iter().sum::<f64>() / (end - start) as f64
})
.collect()
}
pub(super) fn edge_midpoint(previous_s: f64, current_s: f64) -> f64 {
previous_s + ((current_s - previous_s) / 2.0)
}

View File

@ -147,6 +147,36 @@ fn detect_audio_segments_merges_short_internal_dropouts_inside_one_pulse() {
assert!(segments[0].duration_s > 0.11);
}
#[test]
fn detect_audio_segments_accepts_faint_probe_tones() {
let mut samples = vec![0i16; 48_000];
for start in [4_800usize, 24_000] {
for sample in samples.iter_mut().skip(start).take(5_760) {
*sample = 40;
}
}
let segments = detect_audio_segments(&samples, 48_000, 5).expect("faint audio segments");
assert_eq!(segments.len(), 2);
assert!((segments[0].start_s - 0.1).abs() < 0.01);
assert!((segments[1].start_s - 0.5).abs() < 0.01);
}
#[test]
fn detect_audio_segments_merges_longer_probe_dropouts_inside_one_pulse() {
let mut samples = vec![0i16; 48_000];
for sample in samples.iter_mut().skip(4_800).take(12_000) {
*sample = 1_200;
}
for sample in samples.iter_mut().skip(7_200).take(5_760) {
*sample = 0;
}
let segments = detect_audio_segments(&samples, 48_000, 5).expect("dropout audio segment");
assert_eq!(segments.len(), 1);
assert!(segments[0].duration_s > 0.24);
}
#[test]
fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() {
let timestamps = [0.0, 0.1, 0.2, 0.3];

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.17.22"
version = "0.17.23"
edition = "2024"
build = "build.rs"

View File

@ -24,9 +24,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--pulse-period-ms", type=int, default=1000)
parser.add_argument("--pulse-width-ms", type=int, default=120)
parser.add_argument("--marker-tick-period", type=int, default=5)
parser.add_argument("--audio-gain", type=float, default=0.55)
parser.add_argument("--event-width-codes", default=DEFAULT_EVENT_WIDTH_CODES)
args = parser.parse_args()
args.event_width_codes = parse_event_width_codes(args.event_width_codes)
args.audio_gain = max(0.0, min(1.0, args.audio_gain))
return args
@ -78,6 +80,7 @@ class StimulusState:
"pulse_period_ms": self.args.pulse_period_ms,
"pulse_width_ms": self.args.pulse_width_ms,
"marker_tick_period": self.args.marker_tick_period,
"audio_gain": self.args.audio_gain,
"event_width_codes": self.args.event_width_codes,
})
return snap
@ -180,7 +183,8 @@ async function runStimulus(command) {
stage.style.setProperty('--pulse-color', pulseColors[widthCode] || pulseColors[1]);
stage.classList.toggle('active', active);
oscillator.frequency.setTargetAtTime(pulseFrequencies[widthCode] || pulseFrequencies[1], audioCtx.currentTime, 0.003);
gain.gain.setTargetAtTime(active ? 0.28 : 0.0, audioCtx.currentTime, 0.005);
const audioGain = Math.max(0, Math.min(1, Number(command.audio_gain ?? 0.55)));
gain.gain.setTargetAtTime(active ? audioGain : 0.0, audioCtx.currentTime, 0.005);
setStatus(`running\nelapsed=${(elapsed / 1000).toFixed(2)}s\nactive=${active}\nevent=${pulseIndex}\nwidth_code=${widthCode}\nPoint the real webcam at this window and keep the real microphone hearing the tone.`);
if (elapsed <= command.duration_seconds * 1000 + 500) {
requestAnimationFrame(tick);

View File

@ -23,6 +23,7 @@ PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5}
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2}
PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}
LESAVKA_SYNC_CALIBRATION_SEGMENTS_SET=${LESAVKA_SYNC_CALIBRATION_SEGMENTS+x}
LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}
LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_CALIBRATION:-0}
@ -606,6 +607,7 @@ start_local_stimulus() {
--pulse-period-ms "${PROBE_PULSE_PERIOD_MS}" \
--pulse-width-ms "${PROBE_PULSE_WIDTH_MS}" \
--marker-tick-period "${PROBE_MARKER_TICK_PERIOD}" \
--audio-gain "${PROBE_AUDIO_GAIN}" \
--event-width-codes "${PROBE_EVENT_WIDTH_CODES}" \
>"${ARTIFACT_DIR}/stimulus-server.log" 2>&1 &
STIMULUS_PID=$!

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.17.22"
version = "0.17.23"
edition = "2024"
autobins = false

View File

@ -127,6 +127,7 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}",
"PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}",
"LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}",
"LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}",
@ -148,6 +149,7 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"browser_analysis_required=${analysis_required}",
"BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"",
"BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"",
"--audio-gain \"${PROBE_AUDIO_GAIN}\"",
"LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer",
"run_mirrored_segments",
"summarize_adaptive_probe_metrics",
@ -206,8 +208,10 @@ fn local_stimulus_matches_sync_analyzer_pulse_contract() {
"--pulse-period-ms",
"--pulse-width-ms",
"--marker-tick-period",
"--audio-gain",
"--event-width-codes",
"event_width_codes",
"audio_gain",
"widthCode",
"oscillator.frequency.value = 880",
"setStatus(`ready",