test: harden mirrored probe audio detection
This commit is contained in:
parent
37ea941750
commit
c82c61c652
17
AGENTS.md
17
AGENTS.md
@ -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] 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] Run shell syntax checks, focused contract tests, and package checks.
|
||||||
- [x] Push clean semver `0.17.22` for installed client/server testing.
|
- [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
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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_SATURATION: u8 = 36;
|
||||||
const MIN_COLOR_PULSE_VALUE: u8 = 70;
|
const MIN_COLOR_PULSE_VALUE: u8 = 70;
|
||||||
const MAX_COLOR_DISTANCE_SQUARED: u32 = 24_000;
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) struct VideoColorFrame {
|
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 window_samples = ((sample_rate_hz as usize * window_ms as usize) / 1000).max(1);
|
||||||
let envelope = samples
|
let raw_envelope = samples
|
||||||
.chunks(window_samples)
|
.chunks(window_samples)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
let total: u64 = chunk
|
let total: u64 = chunk
|
||||||
@ -341,19 +344,21 @@ pub(crate) fn detect_audio_segments(
|
|||||||
total as f64 / chunk.len() as f64
|
total as f64 / chunk.len() as f64
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let envelope = smooth_envelope(&raw_envelope);
|
||||||
let peak = envelope.iter().copied().fold(0.0_f64, f64::max);
|
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");
|
bail!("audio probe peaks are too quiet to detect sync pulses");
|
||||||
}
|
}
|
||||||
let baseline = median(envelope.clone());
|
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
|
let sample_abs = samples
|
||||||
.iter()
|
.iter()
|
||||||
.map(|sample| i32::from(*sample).unsigned_abs() as f64)
|
.map(|sample| i32::from(*sample).unsigned_abs() as f64)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let sample_peak = sample_abs.iter().copied().fold(0.0_f64, f64::max);
|
let sample_peak = sample_abs.iter().copied().fold(0.0_f64, f64::max);
|
||||||
let sample_baseline = median(sample_abs.clone());
|
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 segments = Vec::new();
|
||||||
let mut previous_active = false;
|
let mut previous_active = false;
|
||||||
let mut segment_start = 0usize;
|
let mut segment_start = 0usize;
|
||||||
@ -388,6 +393,20 @@ pub(crate) fn detect_audio_segments(
|
|||||||
Ok(merge_nearby_audio_segments(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 {
|
pub(super) fn edge_midpoint(previous_s: f64, current_s: f64) -> f64 {
|
||||||
previous_s + ((current_s - previous_s) / 2.0)
|
previous_s + ((current_s - previous_s) / 2.0)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -147,6 +147,36 @@ fn detect_audio_segments_merges_short_internal_dropouts_inside_one_pulse() {
|
|||||||
assert!(segments[0].duration_s > 0.11);
|
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]
|
#[test]
|
||||||
fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() {
|
fn detect_video_segments_closes_a_pulse_that_stays_active_until_the_last_frame() {
|
||||||
let timestamps = [0.0, 0.1, 0.2, 0.3];
|
let timestamps = [0.0, 0.1, 0.2, 0.3];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -24,9 +24,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--pulse-period-ms", type=int, default=1000)
|
parser.add_argument("--pulse-period-ms", type=int, default=1000)
|
||||||
parser.add_argument("--pulse-width-ms", type=int, default=120)
|
parser.add_argument("--pulse-width-ms", type=int, default=120)
|
||||||
parser.add_argument("--marker-tick-period", type=int, default=5)
|
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)
|
parser.add_argument("--event-width-codes", default=DEFAULT_EVENT_WIDTH_CODES)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.event_width_codes = parse_event_width_codes(args.event_width_codes)
|
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
|
return args
|
||||||
|
|
||||||
|
|
||||||
@ -78,6 +80,7 @@ class StimulusState:
|
|||||||
"pulse_period_ms": self.args.pulse_period_ms,
|
"pulse_period_ms": self.args.pulse_period_ms,
|
||||||
"pulse_width_ms": self.args.pulse_width_ms,
|
"pulse_width_ms": self.args.pulse_width_ms,
|
||||||
"marker_tick_period": self.args.marker_tick_period,
|
"marker_tick_period": self.args.marker_tick_period,
|
||||||
|
"audio_gain": self.args.audio_gain,
|
||||||
"event_width_codes": self.args.event_width_codes,
|
"event_width_codes": self.args.event_width_codes,
|
||||||
})
|
})
|
||||||
return snap
|
return snap
|
||||||
@ -180,7 +183,8 @@ async function runStimulus(command) {
|
|||||||
stage.style.setProperty('--pulse-color', pulseColors[widthCode] || pulseColors[1]);
|
stage.style.setProperty('--pulse-color', pulseColors[widthCode] || pulseColors[1]);
|
||||||
stage.classList.toggle('active', active);
|
stage.classList.toggle('active', active);
|
||||||
oscillator.frequency.setTargetAtTime(pulseFrequencies[widthCode] || pulseFrequencies[1], audioCtx.currentTime, 0.003);
|
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.`);
|
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) {
|
if (elapsed <= command.duration_seconds * 1000 + 500) {
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
|
|||||||
@ -23,6 +23,7 @@ PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
|
|||||||
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
||||||
PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5}
|
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_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_CALIBRATION_SEGMENTS_SET=${LESAVKA_SYNC_CALIBRATION_SEGMENTS+x}
|
||||||
LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}
|
LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}
|
||||||
LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_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-period-ms "${PROBE_PULSE_PERIOD_MS}" \
|
||||||
--pulse-width-ms "${PROBE_PULSE_WIDTH_MS}" \
|
--pulse-width-ms "${PROBE_PULSE_WIDTH_MS}" \
|
||||||
--marker-tick-period "${PROBE_MARKER_TICK_PERIOD}" \
|
--marker-tick-period "${PROBE_MARKER_TICK_PERIOD}" \
|
||||||
|
--audio-gain "${PROBE_AUDIO_GAIN}" \
|
||||||
--event-width-codes "${PROBE_EVENT_WIDTH_CODES}" \
|
--event-width-codes "${PROBE_EVENT_WIDTH_CODES}" \
|
||||||
>"${ARTIFACT_DIR}/stimulus-server.log" 2>&1 &
|
>"${ARTIFACT_DIR}/stimulus-server.log" 2>&1 &
|
||||||
STIMULUS_PID=$!
|
STIMULUS_PID=$!
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.22"
|
version = "0.17.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -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_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_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}",
|
"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_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
|
||||||
"LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}",
|
"LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}",
|
||||||
"LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}",
|
"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_analysis_required=${analysis_required}",
|
||||||
"BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"",
|
"BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"",
|
||||||
"BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"",
|
"BROWSER_ANALYSIS_REQUIRED=\"${analysis_required}\"",
|
||||||
|
"--audio-gain \"${PROBE_AUDIO_GAIN}\"",
|
||||||
"LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer",
|
"LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer",
|
||||||
"run_mirrored_segments",
|
"run_mirrored_segments",
|
||||||
"summarize_adaptive_probe_metrics",
|
"summarize_adaptive_probe_metrics",
|
||||||
@ -206,8 +208,10 @@ fn local_stimulus_matches_sync_analyzer_pulse_contract() {
|
|||||||
"--pulse-period-ms",
|
"--pulse-period-ms",
|
||||||
"--pulse-width-ms",
|
"--pulse-width-ms",
|
||||||
"--marker-tick-period",
|
"--marker-tick-period",
|
||||||
|
"--audio-gain",
|
||||||
"--event-width-codes",
|
"--event-width-codes",
|
||||||
"event_width_codes",
|
"event_width_codes",
|
||||||
|
"audio_gain",
|
||||||
"widthCode",
|
"widthCode",
|
||||||
"oscillator.frequency.value = 880",
|
"oscillator.frequency.value = 880",
|
||||||
"setStatus(`ready",
|
"setStatus(`ready",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user