fix: harden mirrored av probe detection
This commit is contained in:
parent
060e09336e
commit
d7aa38b1c1
18
AGENTS.md
18
AGENTS.md
@ -612,3 +612,21 @@ sink handoff p95 near 240ms.
|
|||||||
- [x] Add regression coverage for default-disabled blind healing and noisy sink-handoff refusal.
|
- [x] Add regression coverage for default-disabled blind healing and noisy sink-handoff refusal.
|
||||||
- [ ] Re-run the normal probe-calibrate-confirm flow; `calibration_source` should remain non-blind unless the server was explicitly started with blind healing.
|
- [ ] Re-run the normal probe-calibrate-confirm flow; `calibration_source` should remain non-blind unless the server was explicitly started with blind healing.
|
||||||
- [ ] If the probe still produces only one or two visual events while blind metrics stay stable, move the next fix to stimulus/browser/probe detection instead of transport timing.
|
- [ ] If the probe still produces only one or two visual events while blind metrics stay stable, move the next fix to stimulus/browser/probe detection instead of transport timing.
|
||||||
|
|
||||||
|
## 0.17.33 Probe Detection Robustness Checklist
|
||||||
|
|
||||||
|
Context: the 0.17.32 mirrored run proved hidden blind healing stayed off and calibration remained
|
||||||
|
stable, but the external browser probe still produced too few pairs. The capture path is now limited
|
||||||
|
by analyzer robustness: the webcam sees the screen plus room background, and the microphone hears the
|
||||||
|
stimulus plus environmental noise.
|
||||||
|
|
||||||
|
- [x] Treat probe pairing as the top priority before applying more calibration logic.
|
||||||
|
- [x] Replace whole-frame color/brightness averaging with adaptive video ROI detection that follows the changing stimulus region.
|
||||||
|
- [x] Add regression coverage for a small flashing screen region inside a larger static frame.
|
||||||
|
- [x] Add tone-aware audio detection using the stimulus frequency palette so steady hum/noise is less likely to become a pulse.
|
||||||
|
- [x] Add regression coverage for test-tone pulses under strong low-frequency background hum.
|
||||||
|
- [x] Add coded-video fallbacks for overexposed screen captures: pulse-shaped color filtering, brightness fallback, and duplicate-frame normalization.
|
||||||
|
- [x] Make the local stimulus more probe-friendly by defaulting to kiosk mode and darker saturated colors.
|
||||||
|
- [x] Generate a `manual-review/index.html` with embedded segment captures so runs are easy to inspect by eye.
|
||||||
|
- [ ] Re-run the mirrored probe and confirm pair counts rise enough for calibration-ready evidence.
|
||||||
|
- [ ] If pair counts improve but p95 remains high, move next to server sink handoff jitter and late-run queue pressure.
|
||||||
|
|||||||
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.32"
|
version = "0.17.33"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.32"
|
version = "0.17.33"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.32"
|
version = "0.17.33"
|
||||||
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.32"
|
version = "0.17.33"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -6,7 +6,7 @@ mod report;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) mod test_support;
|
pub(super) mod test_support;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use media_extract::{
|
use media_extract::{
|
||||||
@ -14,7 +14,8 @@ use media_extract::{
|
|||||||
};
|
};
|
||||||
use onset_detection::{
|
use onset_detection::{
|
||||||
DEFAULT_AUDIO_SAMPLE_RATE_HZ, correlate_coded_segments, correlate_segments,
|
DEFAULT_AUDIO_SAMPLE_RATE_HZ, correlate_coded_segments, correlate_segments,
|
||||||
detect_audio_segments, detect_color_coded_video_segments, detect_video_segments,
|
detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments,
|
||||||
|
detect_video_segments,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use onset_detection::{detect_audio_onsets, detect_video_onsets};
|
pub use onset_detection::{detect_audio_onsets, detect_video_onsets};
|
||||||
@ -30,29 +31,51 @@ pub fn analyze_capture(
|
|||||||
options: &SyncAnalysisOptions,
|
options: &SyncAnalysisOptions,
|
||||||
) -> Result<SyncAnalysisReport> {
|
) -> Result<SyncAnalysisReport> {
|
||||||
let raw_timestamps = extract_video_timestamps(capture_path)?;
|
let raw_timestamps = extract_video_timestamps(capture_path)?;
|
||||||
let video_segments = if options.event_width_codes.is_empty() {
|
let (video_segments, coded_video_events) = if options.event_width_codes.is_empty() {
|
||||||
let brightness = extract_video_brightness(capture_path)?;
|
let brightness = extract_video_brightness(capture_path)?;
|
||||||
let timestamps = reconcile_video_timestamps(raw_timestamps, brightness.len())?;
|
let timestamps = reconcile_video_timestamps(raw_timestamps, brightness.len())?;
|
||||||
detect_video_segments(×tamps, &brightness)?
|
(detect_video_segments(×tamps, &brightness)?, false)
|
||||||
} else {
|
} else {
|
||||||
let colors = extract_video_colors(capture_path)?;
|
let colors = extract_video_colors(capture_path)?;
|
||||||
let timestamps = reconcile_video_timestamps(raw_timestamps, colors.len())?;
|
let timestamps = reconcile_video_timestamps(raw_timestamps.clone(), colors.len())?;
|
||||||
detect_color_coded_video_segments(
|
match detect_color_coded_video_segments(
|
||||||
×tamps,
|
×tamps,
|
||||||
&colors,
|
&colors,
|
||||||
&options.event_width_codes,
|
&options.event_width_codes,
|
||||||
options.pulse_width_s,
|
options.pulse_width_s,
|
||||||
|
) {
|
||||||
|
Ok(segments) => (segments, true),
|
||||||
|
Err(color_error) => {
|
||||||
|
let brightness = extract_video_brightness(capture_path)?;
|
||||||
|
let timestamps = reconcile_video_timestamps(raw_timestamps, brightness.len())?;
|
||||||
|
(
|
||||||
|
detect_video_segments(×tamps, &brightness).with_context(|| {
|
||||||
|
format!("color-coded video pulse detection failed: {color_error}")
|
||||||
|
})?,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let audio_samples = extract_audio_samples(capture_path)?;
|
||||||
|
let audio_segments = if options.event_width_codes.is_empty() {
|
||||||
|
detect_audio_segments(
|
||||||
|
&audio_samples,
|
||||||
|
DEFAULT_AUDIO_SAMPLE_RATE_HZ,
|
||||||
|
options.audio_window_ms,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
detect_coded_audio_segments(
|
||||||
|
&audio_samples,
|
||||||
|
DEFAULT_AUDIO_SAMPLE_RATE_HZ,
|
||||||
|
options.audio_window_ms,
|
||||||
|
&options.event_width_codes,
|
||||||
|
options.pulse_width_s,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let audio_samples = extract_audio_samples(capture_path)?;
|
if !coded_video_events {
|
||||||
let audio_segments = detect_audio_segments(
|
|
||||||
&audio_samples,
|
|
||||||
DEFAULT_AUDIO_SAMPLE_RATE_HZ,
|
|
||||||
options.audio_window_ms,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if options.event_width_codes.is_empty() {
|
|
||||||
correlate_segments(
|
correlate_segments(
|
||||||
&video_segments,
|
&video_segments,
|
||||||
&audio_segments,
|
&audio_segments,
|
||||||
|
|||||||
@ -5,7 +5,13 @@ use std::process::Command;
|
|||||||
|
|
||||||
use super::onset_detection::VideoColorFrame;
|
use super::onset_detection::VideoColorFrame;
|
||||||
|
|
||||||
const VIDEO_ANALYSIS_SIDE_PX: usize = 32;
|
const VIDEO_ANALYSIS_SIDE_PX: usize = 64;
|
||||||
|
const VIDEO_ANALYSIS_FPS: usize = 60;
|
||||||
|
const MIN_ADAPTIVE_ROI_PIXELS: usize = 16;
|
||||||
|
const MAX_ADAPTIVE_ROI_FRACTION: f64 = 0.35;
|
||||||
|
const ADAPTIVE_ROI_SCORE_FRACTION: f64 = 0.30;
|
||||||
|
const MIN_RGB_ROI_SCORE: f64 = 24.0;
|
||||||
|
const MIN_GRAY_ROI_SCORE: f64 = 8.0;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ProbeFrameResponse {
|
struct ProbeFrameResponse {
|
||||||
@ -60,7 +66,8 @@ pub(super) fn extract_video_brightness(capture_path: &Path) -> Result<Vec<u8>> {
|
|||||||
.arg("0:v:0")
|
.arg("0:v:0")
|
||||||
.arg("-vf")
|
.arg("-vf")
|
||||||
.arg(format!(
|
.arg(format!(
|
||||||
"scale={side}:{side}:flags=area,format=gray",
|
"fps={fps},scale={side}:{side}:flags=area,format=gray",
|
||||||
|
fps = VIDEO_ANALYSIS_FPS,
|
||||||
side = VIDEO_ANALYSIS_SIDE_PX
|
side = VIDEO_ANALYSIS_SIDE_PX
|
||||||
))
|
))
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
@ -84,11 +91,10 @@ pub(super) fn extract_video_brightness(capture_path: &Path) -> Result<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
let extracted_frames = output.len() / frame_pixels;
|
let extracted_frames = output.len() / frame_pixels;
|
||||||
|
|
||||||
Ok(output
|
Ok(summarize_gray_frames_with_adaptive_roi(
|
||||||
.chunks_exact(frame_pixels)
|
output.chunks_exact(frame_pixels).take(extracted_frames),
|
||||||
.take(extracted_frames)
|
frame_pixels,
|
||||||
.map(summarize_frame_brightness)
|
))
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn extract_video_colors(capture_path: &Path) -> Result<Vec<VideoColorFrame>> {
|
pub(super) fn extract_video_colors(capture_path: &Path) -> Result<Vec<VideoColorFrame>> {
|
||||||
@ -103,7 +109,8 @@ pub(super) fn extract_video_colors(capture_path: &Path) -> Result<Vec<VideoColor
|
|||||||
.arg("0:v:0")
|
.arg("0:v:0")
|
||||||
.arg("-vf")
|
.arg("-vf")
|
||||||
.arg(format!(
|
.arg(format!(
|
||||||
"scale={side}:{side}:flags=area,format=rgb24",
|
"fps={fps},scale={side}:{side}:flags=area,format=rgb24",
|
||||||
|
fps = VIDEO_ANALYSIS_FPS,
|
||||||
side = VIDEO_ANALYSIS_SIDE_PX
|
side = VIDEO_ANALYSIS_SIDE_PX
|
||||||
))
|
))
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
@ -127,11 +134,10 @@ pub(super) fn extract_video_colors(capture_path: &Path) -> Result<Vec<VideoColor
|
|||||||
}
|
}
|
||||||
let extracted_frames = output.len() / frame_bytes;
|
let extracted_frames = output.len() / frame_bytes;
|
||||||
|
|
||||||
Ok(output
|
Ok(summarize_rgb_frames_with_adaptive_roi(
|
||||||
.chunks_exact(frame_bytes)
|
output.chunks_exact(frame_bytes).take(extracted_frames),
|
||||||
.take(extracted_frames)
|
VIDEO_ANALYSIS_SIDE_PX * VIDEO_ANALYSIS_SIDE_PX,
|
||||||
.map(summarize_frame_color)
|
))
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn extract_audio_samples(capture_path: &Path) -> Result<Vec<i16>> {
|
pub(super) fn extract_audio_samples(capture_path: &Path) -> Result<Vec<i16>> {
|
||||||
@ -175,18 +181,57 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result<Ve
|
|||||||
Ok(output.stdout)
|
Ok(output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_frame_brightness(frame: &[u8]) -> u8 {
|
fn summarize_gray_frames_with_adaptive_roi<'a>(
|
||||||
let mean = frame.iter().map(|value| u64::from(*value)).sum::<u64>() / frame.len().max(1) as u64;
|
frames: impl Iterator<Item = &'a [u8]>,
|
||||||
|
pixel_count: usize,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let frames = frames.collect::<Vec<_>>();
|
||||||
|
let mask = adaptive_gray_roi_mask(&frames, pixel_count);
|
||||||
|
frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| summarize_frame_brightness(frame, mask.as_deref()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_rgb_frames_with_adaptive_roi<'a>(
|
||||||
|
frames: impl Iterator<Item = &'a [u8]>,
|
||||||
|
pixel_count: usize,
|
||||||
|
) -> Vec<VideoColorFrame> {
|
||||||
|
let frames = frames.collect::<Vec<_>>();
|
||||||
|
let mask = adaptive_rgb_roi_mask(&frames, pixel_count);
|
||||||
|
frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| summarize_frame_color(frame, mask.as_deref()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_frame_brightness(frame: &[u8], mask: Option<&[bool]>) -> u8 {
|
||||||
|
let mut sum = 0u64;
|
||||||
|
let mut selected = 0u64;
|
||||||
|
for (index, value) in frame.iter().copied().enumerate() {
|
||||||
|
if mask.is_none_or(|mask| mask.get(index).copied().unwrap_or(false)) {
|
||||||
|
sum += u64::from(value);
|
||||||
|
selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected == 0 {
|
||||||
|
sum = frame.iter().map(|value| u64::from(*value)).sum();
|
||||||
|
selected = frame.len().max(1) as u64;
|
||||||
|
}
|
||||||
|
let mean = sum / selected;
|
||||||
mean.min(u64::from(u8::MAX)) as u8
|
mean.min(u64::from(u8::MAX)) as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_frame_color(frame: &[u8]) -> VideoColorFrame {
|
fn summarize_frame_color(frame: &[u8], mask: Option<&[bool]>) -> VideoColorFrame {
|
||||||
let mut r_sum = 0u64;
|
let mut r_sum = 0u64;
|
||||||
let mut g_sum = 0u64;
|
let mut g_sum = 0u64;
|
||||||
let mut b_sum = 0u64;
|
let mut b_sum = 0u64;
|
||||||
let mut selected = 0u64;
|
let mut selected = 0u64;
|
||||||
|
|
||||||
for pixel in frame.chunks_exact(3) {
|
for (index, pixel) in frame.chunks_exact(3).enumerate() {
|
||||||
|
if !mask.is_none_or(|mask| mask.get(index).copied().unwrap_or(false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let r = pixel[0];
|
let r = pixel[0];
|
||||||
let g = pixel[1];
|
let g = pixel[1];
|
||||||
let b = pixel[2];
|
let b = pixel[2];
|
||||||
@ -201,13 +246,26 @@ fn summarize_frame_color(frame: &[u8]) -> VideoColorFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if selected == 0 {
|
if selected == 0 {
|
||||||
selected = (frame.len() / 3).max(1) as u64;
|
for (index, pixel) in frame.chunks_exact(3).enumerate() {
|
||||||
|
if !mask.is_none_or(|mask| mask.get(index).copied().unwrap_or(false)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
r_sum += u64::from(pixel[0]);
|
||||||
|
g_sum += u64::from(pixel[1]);
|
||||||
|
b_sum += u64::from(pixel[2]);
|
||||||
|
selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == 0 {
|
||||||
for pixel in frame.chunks_exact(3) {
|
for pixel in frame.chunks_exact(3) {
|
||||||
r_sum += u64::from(pixel[0]);
|
r_sum += u64::from(pixel[0]);
|
||||||
g_sum += u64::from(pixel[1]);
|
g_sum += u64::from(pixel[1]);
|
||||||
b_sum += u64::from(pixel[2]);
|
b_sum += u64::from(pixel[2]);
|
||||||
|
selected += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selected = selected.max(1);
|
||||||
|
|
||||||
VideoColorFrame {
|
VideoColorFrame {
|
||||||
r: (r_sum / selected).min(u64::from(u8::MAX)) as u8,
|
r: (r_sum / selected).min(u64::from(u8::MAX)) as u8,
|
||||||
@ -216,6 +274,187 @@ fn summarize_frame_color(frame: &[u8]) -> VideoColorFrame {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn adaptive_gray_roi_mask(frames: &[&[u8]], pixel_count: usize) -> Option<Vec<bool>> {
|
||||||
|
if frames.len() < 2 || pixel_count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut scores = vec![0.0; pixel_count];
|
||||||
|
for pixel_index in 0..pixel_count {
|
||||||
|
let mut min = u8::MAX;
|
||||||
|
let mut max = u8::MIN;
|
||||||
|
for frame in frames {
|
||||||
|
let value = frame[pixel_index];
|
||||||
|
min = min.min(value);
|
||||||
|
max = max.max(value);
|
||||||
|
}
|
||||||
|
scores[pixel_index] = f64::from(max.saturating_sub(min)) * dark_roi_factor(min);
|
||||||
|
}
|
||||||
|
adaptive_roi_mask_from_scores(&scores, MIN_GRAY_ROI_SCORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adaptive_rgb_roi_mask(frames: &[&[u8]], pixel_count: usize) -> Option<Vec<bool>> {
|
||||||
|
if frames.len() < 2 || pixel_count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut scores = vec![0.0; pixel_count];
|
||||||
|
for pixel_index in 0..pixel_count {
|
||||||
|
let mut min_r = u8::MAX;
|
||||||
|
let mut min_g = u8::MAX;
|
||||||
|
let mut min_b = u8::MAX;
|
||||||
|
let mut max_r = u8::MIN;
|
||||||
|
let mut max_g = u8::MIN;
|
||||||
|
let mut max_b = u8::MIN;
|
||||||
|
let mut min_luma = u8::MAX;
|
||||||
|
let mut max_luma = u8::MIN;
|
||||||
|
let mut best_palette_score = 0.0_f64;
|
||||||
|
|
||||||
|
for frame in frames {
|
||||||
|
let offset = pixel_index * 3;
|
||||||
|
let r = frame[offset];
|
||||||
|
let g = frame[offset + 1];
|
||||||
|
let b = frame[offset + 2];
|
||||||
|
min_r = min_r.min(r);
|
||||||
|
min_g = min_g.min(g);
|
||||||
|
min_b = min_b.min(b);
|
||||||
|
max_r = max_r.max(r);
|
||||||
|
max_g = max_g.max(g);
|
||||||
|
max_b = max_b.max(b);
|
||||||
|
let luma = luma_u8(r, g, b);
|
||||||
|
min_luma = min_luma.min(luma);
|
||||||
|
max_luma = max_luma.max(luma);
|
||||||
|
best_palette_score = best_palette_score.max(palette_match_score(r, g, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rgb_span = f64::from(max_r.saturating_sub(min_r))
|
||||||
|
+ f64::from(max_g.saturating_sub(min_g))
|
||||||
|
+ f64::from(max_b.saturating_sub(min_b));
|
||||||
|
let luma_span = f64::from(max_luma.saturating_sub(min_luma));
|
||||||
|
scores[pixel_index] =
|
||||||
|
(rgb_span + (2.0 * luma_span)) * (1.0 + best_palette_score) * dark_roi_factor(min_luma);
|
||||||
|
}
|
||||||
|
adaptive_roi_mask_from_scores(&scores, MIN_RGB_ROI_SCORE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adaptive_roi_mask_from_scores(scores: &[f64], min_score: f64) -> Option<Vec<bool>> {
|
||||||
|
let max_score = scores.iter().copied().fold(0.0_f64, f64::max);
|
||||||
|
if max_score < min_score {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranked = scores
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, score)| score.is_finite() && *score > 0.0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ranked.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||||
|
|
||||||
|
let max_selected = ((scores.len() as f64 * MAX_ADAPTIVE_ROI_FRACTION).round() as usize)
|
||||||
|
.max(MIN_ADAPTIVE_ROI_PIXELS)
|
||||||
|
.min(scores.len());
|
||||||
|
let score_floor = (max_score * ADAPTIVE_ROI_SCORE_FRACTION).max(min_score);
|
||||||
|
let mut mask = vec![false; scores.len()];
|
||||||
|
let mut selected = 0usize;
|
||||||
|
for (index, score) in ranked.into_iter().take(max_selected) {
|
||||||
|
if score < score_floor && selected >= MIN_ADAPTIVE_ROI_PIXELS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mask[index] = true;
|
||||||
|
selected += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask = retain_largest_connected_roi(mask);
|
||||||
|
let selected = mask.iter().filter(|selected| **selected).count();
|
||||||
|
(selected >= MIN_ADAPTIVE_ROI_PIXELS).then_some(mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retain_largest_connected_roi(mask: Vec<bool>) -> Vec<bool> {
|
||||||
|
let side = (mask.len() as f64).sqrt().round() as usize;
|
||||||
|
if side == 0 || side * side != mask.len() {
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited = vec![false; mask.len()];
|
||||||
|
let mut best_component = Vec::<usize>::new();
|
||||||
|
for start in 0..mask.len() {
|
||||||
|
if !mask[start] || visited[start] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut stack = vec![start];
|
||||||
|
let mut component = Vec::new();
|
||||||
|
visited[start] = true;
|
||||||
|
while let Some(index) = stack.pop() {
|
||||||
|
component.push(index);
|
||||||
|
let x = index % side;
|
||||||
|
let y = index / side;
|
||||||
|
let mut push_neighbor = |neighbor: usize| {
|
||||||
|
if mask[neighbor] && !visited[neighbor] {
|
||||||
|
visited[neighbor] = true;
|
||||||
|
stack.push(neighbor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if x > 0 {
|
||||||
|
push_neighbor(index - 1);
|
||||||
|
}
|
||||||
|
if x + 1 < side {
|
||||||
|
push_neighbor(index + 1);
|
||||||
|
}
|
||||||
|
if y > 0 {
|
||||||
|
push_neighbor(index - side);
|
||||||
|
}
|
||||||
|
if y + 1 < side {
|
||||||
|
push_neighbor(index + side);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if component.len() > best_component.len() {
|
||||||
|
best_component = component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_component.len() < MIN_ADAPTIVE_ROI_PIXELS {
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
let mut retained = vec![false; mask.len()];
|
||||||
|
for index in best_component {
|
||||||
|
retained[index] = true;
|
||||||
|
}
|
||||||
|
retained
|
||||||
|
}
|
||||||
|
|
||||||
|
fn luma_u8(r: u8, g: u8, b: u8) -> u8 {
|
||||||
|
((u16::from(r) * 77 + u16::from(g) * 150 + u16::from(b) * 29) / 256) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dark_roi_factor(min_luma: u8) -> f64 {
|
||||||
|
match min_luma {
|
||||||
|
0..=80 => 1.0,
|
||||||
|
81..=120 => 0.55,
|
||||||
|
121..=160 => 0.25,
|
||||||
|
_ => 0.10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn palette_match_score(r: u8, g: u8, b: u8) -> f64 {
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
if max < 50 || max.saturating_sub(min) < 20 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE: [(u8, u8, u8); 4] =
|
||||||
|
[(255, 45, 45), (0, 230, 118), (41, 121, 255), (255, 179, 0)];
|
||||||
|
let best_distance = PALETTE
|
||||||
|
.into_iter()
|
||||||
|
.map(|(pr, pg, pb)| {
|
||||||
|
let dr = f64::from(r) - f64::from(pr);
|
||||||
|
let dg = f64::from(g) - f64::from(pg);
|
||||||
|
let db = f64::from(b) - f64::from(pb);
|
||||||
|
dr * dr + dg * dg + db * db
|
||||||
|
})
|
||||||
|
.fold(f64::INFINITY, f64::min);
|
||||||
|
(1.0 - (best_distance / 65_025.0)).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
@ -274,7 +513,7 @@ mod tests {
|
|||||||
&[1, 0],
|
&[1, 0],
|
||||||
|capture_path| {
|
|capture_path| {
|
||||||
let parsed = extract_video_brightness(capture_path).expect("video brightness");
|
let parsed = extract_video_brightness(capture_path).expect("video brightness");
|
||||||
assert_eq!(parsed, vec![16, 40, 77]);
|
assert_eq!(parsed, brightness);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -305,7 +544,7 @@ mod tests {
|
|||||||
&[1, 0],
|
&[1, 0],
|
||||||
|capture_path| {
|
|capture_path| {
|
||||||
let parsed = extract_video_brightness(capture_path).expect("video brightness");
|
let parsed = extract_video_brightness(capture_path).expect("video brightness");
|
||||||
assert_eq!(parsed, vec![20, 26, 20]);
|
assert_eq!(parsed, brightness);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -334,6 +573,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_video_colors_tracks_small_flashing_screen_region() {
|
||||||
|
const SIDE: usize = 64;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for color in [(24, 28, 32), (255, 45, 45), (24, 28, 32), (0, 230, 118)] {
|
||||||
|
let mut frame = vec![34u8; SIDE * SIDE * 3];
|
||||||
|
for y in 6..18 {
|
||||||
|
for x in 40..54 {
|
||||||
|
let offset = (y * SIDE + x) * 3;
|
||||||
|
frame[offset] = color.0;
|
||||||
|
frame[offset + 1] = color.1;
|
||||||
|
frame[offset + 2] = color.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
with_fake_media_tools(
|
||||||
|
&frame_json(&[0.0, 0.1, 0.2, 0.3]),
|
||||||
|
&bytes,
|
||||||
|
&[1, 0],
|
||||||
|
|capture_path| {
|
||||||
|
let parsed = extract_video_colors(capture_path).expect("video colors");
|
||||||
|
assert!(
|
||||||
|
parsed[1].r > 220 && parsed[1].g < 80,
|
||||||
|
"red pulse should dominate selected ROI: {:?}",
|
||||||
|
parsed[1]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed[3].g > 190 && parsed[3].r < 60,
|
||||||
|
"green pulse should dominate selected ROI: {:?}",
|
||||||
|
parsed[3]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_audio_samples_reads_fake_ffmpeg_output() {
|
fn extract_audio_samples_reads_fake_ffmpeg_output() {
|
||||||
let samples = vec![1i16, -2, 32_000];
|
let samples = vec![1i16, -2, 32_000];
|
||||||
|
|||||||
@ -16,10 +16,17 @@ 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 DOMINANT_COLOR_MARGIN: i16 = 28;
|
||||||
|
const MAX_COLOR_OBSERVED_DURATION_MULTIPLIER: f64 = 1.55;
|
||||||
|
const MAX_COLOR_OBSERVED_DURATION_SLACK_S: f64 = 0.08;
|
||||||
const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.16;
|
const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.16;
|
||||||
const MIN_AUDIO_PROBE_PEAK: f64 = 25.0;
|
const MIN_AUDIO_PROBE_PEAK: f64 = 25.0;
|
||||||
const AUDIO_ENVELOPE_THRESHOLD_FRACTION: f64 = 0.30;
|
const AUDIO_ENVELOPE_THRESHOLD_FRACTION: f64 = 0.30;
|
||||||
const AUDIO_SAMPLE_THRESHOLD_FRACTION: f64 = 0.22;
|
const AUDIO_SAMPLE_THRESHOLD_FRACTION: f64 = 0.22;
|
||||||
|
const AUDIO_TONE_FREQUENCIES_HZ: [f64; 4] = [660.0, 880.0, 1100.0, 1320.0];
|
||||||
|
const MIN_TONE_ENVELOPE_PEAK: f64 = 18.0;
|
||||||
|
const MIN_TONE_CONTRAST_FRACTION_OF_AMPLITUDE: f64 = 0.12;
|
||||||
|
const MIN_TONE_CODE_DOMINANCE_RATIO: f64 = 1.35;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) struct VideoColorFrame {
|
pub(super) struct VideoColorFrame {
|
||||||
@ -140,6 +147,7 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frame_step_s = median_frame_step_seconds(×tamps_s[..frame_count]).max(1.0 / 120.0);
|
let frame_step_s = median_frame_step_seconds(×tamps_s[..frame_count]).max(1.0 / 120.0);
|
||||||
|
let max_event_code = event_codes.iter().copied().max().unwrap_or(1);
|
||||||
let mut segments = Vec::new();
|
let mut segments = Vec::new();
|
||||||
let mut previous_code = None::<u32>;
|
let mut previous_code = None::<u32>;
|
||||||
let mut segment_start = 0.0_f64;
|
let mut segment_start = 0.0_f64;
|
||||||
@ -168,6 +176,7 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
timestamp,
|
timestamp,
|
||||||
),
|
),
|
||||||
pulse_width_s,
|
pulse_width_s,
|
||||||
|
max_event_code,
|
||||||
&segment_codes,
|
&segment_codes,
|
||||||
frame_step_s,
|
frame_step_s,
|
||||||
);
|
);
|
||||||
@ -183,6 +192,7 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
segment_start,
|
segment_start,
|
||||||
last_timestamp + frame_step_s / 2.0,
|
last_timestamp + frame_step_s / 2.0,
|
||||||
pulse_width_s,
|
pulse_width_s,
|
||||||
|
max_event_code,
|
||||||
&segment_codes,
|
&segment_codes,
|
||||||
frame_step_s,
|
frame_step_s,
|
||||||
);
|
);
|
||||||
@ -214,12 +224,20 @@ fn push_color_segment(
|
|||||||
start_s: f64,
|
start_s: f64,
|
||||||
observed_end_s: f64,
|
observed_end_s: f64,
|
||||||
pulse_width_s: f64,
|
pulse_width_s: f64,
|
||||||
|
max_event_code: u32,
|
||||||
codes: &[u32],
|
codes: &[u32],
|
||||||
frame_step_s: f64,
|
frame_step_s: f64,
|
||||||
) {
|
) {
|
||||||
let Some(code) = dominant_event_code(codes) else {
|
let Some(code) = dominant_event_code(codes) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let observed_duration_s = observed_end_s - start_s;
|
||||||
|
let max_observed_duration_s =
|
||||||
|
(pulse_width_s * f64::from(max_event_code) * MAX_COLOR_OBSERVED_DURATION_MULTIPLIER)
|
||||||
|
+ MAX_COLOR_OBSERVED_DURATION_SLACK_S;
|
||||||
|
if observed_duration_s > max_observed_duration_s {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let encoded_duration_s = pulse_width_s * f64::from(code);
|
let encoded_duration_s = pulse_width_s * f64::from(code);
|
||||||
segments.push(PulseSegment {
|
segments.push(PulseSegment {
|
||||||
start_s,
|
start_s,
|
||||||
@ -255,6 +273,30 @@ fn color_event_code(frame: VideoColorFrame) -> Option<u32> {
|
|||||||
.map(|(code, color)| (code, color_distance_squared(frame, color)))
|
.map(|(code, color)| (code, color_distance_squared(frame, color)))
|
||||||
.min_by_key(|(_, distance)| *distance)
|
.min_by_key(|(_, distance)| *distance)
|
||||||
.and_then(|(code, distance)| (distance <= MAX_COLOR_DISTANCE_SQUARED).then_some(code))
|
.and_then(|(code, distance)| (distance <= MAX_COLOR_DISTANCE_SQUARED).then_some(code))
|
||||||
|
.or_else(|| dominant_color_event_code(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominant_color_event_code(frame: VideoColorFrame) -> Option<u32> {
|
||||||
|
let r = i16::from(frame.r);
|
||||||
|
let g = i16::from(frame.g);
|
||||||
|
let b = i16::from(frame.b);
|
||||||
|
|
||||||
|
if r - b >= DOMINANT_COLOR_MARGIN
|
||||||
|
&& g - b >= DOMINANT_COLOR_MARGIN
|
||||||
|
&& (r - g).abs() <= DOMINANT_COLOR_MARGIN * 3
|
||||||
|
{
|
||||||
|
return Some(4);
|
||||||
|
}
|
||||||
|
if r - g >= DOMINANT_COLOR_MARGIN && r - b >= DOMINANT_COLOR_MARGIN {
|
||||||
|
return Some(1);
|
||||||
|
}
|
||||||
|
if g - r >= DOMINANT_COLOR_MARGIN && g - b >= DOMINANT_COLOR_MARGIN {
|
||||||
|
return Some(2);
|
||||||
|
}
|
||||||
|
if b - r >= DOMINANT_COLOR_MARGIN && b - g >= DOMINANT_COLOR_MARGIN {
|
||||||
|
return Some(3);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn color_for_event_code(code: u32) -> Option<VideoColorFrame> {
|
fn color_for_event_code(code: u32) -> Option<VideoColorFrame> {
|
||||||
@ -322,6 +364,45 @@ pub(crate) fn detect_audio_segments(
|
|||||||
samples: &[i16],
|
samples: &[i16],
|
||||||
sample_rate_hz: u32,
|
sample_rate_hz: u32,
|
||||||
window_ms: u32,
|
window_ms: u32,
|
||||||
|
) -> Result<Vec<PulseSegment>> {
|
||||||
|
detect_audio_segments_with_optional_codes(samples, sample_rate_hz, window_ms, &[], 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detect_coded_audio_segments(
|
||||||
|
samples: &[i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
window_ms: u32,
|
||||||
|
event_codes: &[u32],
|
||||||
|
pulse_width_s: f64,
|
||||||
|
) -> Result<Vec<PulseSegment>> {
|
||||||
|
if pulse_width_s <= 0.0 {
|
||||||
|
bail!("pulse width must stay positive");
|
||||||
|
}
|
||||||
|
if event_codes.is_empty() {
|
||||||
|
bail!("event code list must not be empty");
|
||||||
|
}
|
||||||
|
if let Some(unsupported) = event_codes
|
||||||
|
.iter()
|
||||||
|
.find(|code| audio_frequency_for_event_code(**code).is_none())
|
||||||
|
{
|
||||||
|
bail!("event code {unsupported} has no audio tone signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_audio_segments_with_optional_codes(
|
||||||
|
samples,
|
||||||
|
sample_rate_hz,
|
||||||
|
window_ms,
|
||||||
|
event_codes,
|
||||||
|
pulse_width_s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_audio_segments_with_optional_codes(
|
||||||
|
samples: &[i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
window_ms: u32,
|
||||||
|
event_codes: &[u32],
|
||||||
|
pulse_width_s: f64,
|
||||||
) -> Result<Vec<PulseSegment>> {
|
) -> Result<Vec<PulseSegment>> {
|
||||||
if samples.is_empty() {
|
if samples.is_empty() {
|
||||||
bail!("capture did not contain any audio samples");
|
bail!("capture did not contain any audio samples");
|
||||||
@ -334,7 +415,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 raw_envelope = samples
|
let amplitude_envelope = samples
|
||||||
.chunks(window_samples)
|
.chunks(window_samples)
|
||||||
.map(|chunk| {
|
.map(|chunk| {
|
||||||
let total: u64 = chunk
|
let total: u64 = chunk
|
||||||
@ -344,7 +425,15 @@ 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 tone_windows = samples
|
||||||
|
.chunks(window_samples)
|
||||||
|
.map(|chunk| strongest_probe_tone_window(chunk, sample_rate_hz, event_codes))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let tone_envelope = tone_windows
|
||||||
|
.iter()
|
||||||
|
.map(|window| window.level)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let envelope = choose_audio_detection_envelope(&litude_envelope, &tone_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 < MIN_AUDIO_PROBE_PEAK {
|
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");
|
||||||
@ -362,35 +451,133 @@ pub(crate) fn detect_audio_segments(
|
|||||||
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;
|
||||||
|
let mut segment_codes = Vec::<u32>::new();
|
||||||
for (index, level) in envelope.iter().copied().enumerate() {
|
for (index, level) in envelope.iter().copied().enumerate() {
|
||||||
let active = level >= threshold;
|
let active = level >= threshold;
|
||||||
if active && !previous_active {
|
if active && !previous_active {
|
||||||
segment_start = index;
|
segment_start = index;
|
||||||
|
segment_codes.clear();
|
||||||
|
}
|
||||||
|
if !event_codes.is_empty()
|
||||||
|
&& active
|
||||||
|
&& let Some(code) = tone_windows.get(index).and_then(|window| window.code)
|
||||||
|
{
|
||||||
|
segment_codes.push(code);
|
||||||
}
|
}
|
||||||
if previous_active && !active {
|
if previous_active && !active {
|
||||||
segments.push(window_segment(
|
push_audio_segment(
|
||||||
|
&mut segments,
|
||||||
samples,
|
samples,
|
||||||
sample_rate_hz,
|
sample_rate_hz,
|
||||||
window_samples,
|
window_samples,
|
||||||
segment_start,
|
segment_start,
|
||||||
index,
|
index,
|
||||||
sample_threshold,
|
sample_threshold,
|
||||||
));
|
dominant_event_code(&segment_codes).map(|code| pulse_width_s * f64::from(code)),
|
||||||
|
);
|
||||||
|
segment_codes.clear();
|
||||||
}
|
}
|
||||||
previous_active = active;
|
previous_active = active;
|
||||||
}
|
}
|
||||||
if previous_active {
|
if previous_active {
|
||||||
segments.push(window_segment(
|
push_audio_segment(
|
||||||
|
&mut segments,
|
||||||
samples,
|
samples,
|
||||||
sample_rate_hz,
|
sample_rate_hz,
|
||||||
window_samples,
|
window_samples,
|
||||||
segment_start,
|
segment_start,
|
||||||
envelope.len(),
|
envelope.len(),
|
||||||
sample_threshold,
|
sample_threshold,
|
||||||
));
|
dominant_event_code(&segment_codes).map(|code| pulse_width_s * f64::from(code)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(merge_nearby_audio_segments(segments))
|
if event_codes.is_empty() {
|
||||||
|
Ok(merge_nearby_audio_segments(segments))
|
||||||
|
} else {
|
||||||
|
Ok(merge_nearby_coded_audio_segments(segments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_audio_detection_envelope(amplitude_envelope: &[f64], tone_envelope: &[f64]) -> Vec<f64> {
|
||||||
|
let smoothed_amplitude = smooth_envelope(amplitude_envelope);
|
||||||
|
let smoothed_tone = smooth_envelope(tone_envelope);
|
||||||
|
let amplitude_peak = smoothed_amplitude.iter().copied().fold(0.0_f64, f64::max);
|
||||||
|
let amplitude_baseline = median(smoothed_amplitude.clone());
|
||||||
|
let tone_peak = smoothed_tone.iter().copied().fold(0.0_f64, f64::max);
|
||||||
|
let tone_baseline = median(smoothed_tone.clone());
|
||||||
|
let amplitude_contrast = (amplitude_peak - amplitude_baseline).max(0.0);
|
||||||
|
let tone_contrast = (tone_peak - tone_baseline).max(0.0);
|
||||||
|
|
||||||
|
if tone_peak >= MIN_TONE_ENVELOPE_PEAK
|
||||||
|
&& tone_contrast >= amplitude_contrast * MIN_TONE_CONTRAST_FRACTION_OF_AMPLITUDE
|
||||||
|
{
|
||||||
|
smoothed_tone
|
||||||
|
} else {
|
||||||
|
smoothed_amplitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ProbeToneWindow {
|
||||||
|
code: Option<u32>,
|
||||||
|
level: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strongest_probe_tone_window(
|
||||||
|
samples: &[i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
event_codes: &[u32],
|
||||||
|
) -> ProbeToneWindow {
|
||||||
|
let code_iter: Box<dyn Iterator<Item = u32> + '_> = if event_codes.is_empty() {
|
||||||
|
Box::new(1..=AUDIO_TONE_FREQUENCIES_HZ.len() as u32)
|
||||||
|
} else {
|
||||||
|
Box::new(event_codes.iter().copied())
|
||||||
|
};
|
||||||
|
let mut candidates = code_iter
|
||||||
|
.filter_map(|code| {
|
||||||
|
audio_frequency_for_event_code(code)
|
||||||
|
.map(|frequency_hz| (code, goertzel_level(samples, sample_rate_hz, frequency_hz)))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candidates.sort_by(|(_, left_level), (_, right_level)| right_level.total_cmp(left_level));
|
||||||
|
|
||||||
|
let Some((code, level)) = candidates.first().copied() else {
|
||||||
|
return ProbeToneWindow {
|
||||||
|
code: None,
|
||||||
|
level: 0.0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let runner_up = candidates.get(1).map(|(_, level)| *level).unwrap_or(0.0);
|
||||||
|
ProbeToneWindow {
|
||||||
|
code: (level >= MIN_TONE_ENVELOPE_PEAK
|
||||||
|
&& level >= runner_up * MIN_TONE_CODE_DOMINANCE_RATIO)
|
||||||
|
.then_some(code),
|
||||||
|
level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_frequency_for_event_code(code: u32) -> Option<f64> {
|
||||||
|
AUDIO_TONE_FREQUENCIES_HZ
|
||||||
|
.get(code.checked_sub(1)? as usize)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn goertzel_level(samples: &[i16], sample_rate_hz: u32, frequency_hz: f64) -> f64 {
|
||||||
|
if samples.is_empty() || sample_rate_hz == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let omega = 2.0 * std::f64::consts::PI * frequency_hz / f64::from(sample_rate_hz);
|
||||||
|
let coefficient = 2.0 * omega.cos();
|
||||||
|
let mut q1 = 0.0_f64;
|
||||||
|
let mut q2 = 0.0_f64;
|
||||||
|
for sample in samples {
|
||||||
|
let q0 = f64::from(*sample) + coefficient * q1 - q2;
|
||||||
|
q2 = q1;
|
||||||
|
q1 = q0;
|
||||||
|
}
|
||||||
|
let power = q1 * q1 + q2 * q2 - coefficient * q1 * q2;
|
||||||
|
power.max(0.0).sqrt() / samples.len() as f64
|
||||||
}
|
}
|
||||||
|
|
||||||
fn smooth_envelope(envelope: &[f64]) -> Vec<f64> {
|
fn smooth_envelope(envelope: &[f64]) -> Vec<f64> {
|
||||||
@ -444,6 +631,44 @@ pub(super) fn window_segment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_audio_segment(
|
||||||
|
segments: &mut Vec<PulseSegment>,
|
||||||
|
samples: &[i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
window_samples: usize,
|
||||||
|
start_window_index: usize,
|
||||||
|
end_window_index_exclusive: usize,
|
||||||
|
sample_threshold: f64,
|
||||||
|
encoded_duration_s: Option<f64>,
|
||||||
|
) {
|
||||||
|
let mut segment = window_segment(
|
||||||
|
samples,
|
||||||
|
sample_rate_hz,
|
||||||
|
window_samples,
|
||||||
|
start_window_index,
|
||||||
|
end_window_index_exclusive,
|
||||||
|
sample_threshold,
|
||||||
|
);
|
||||||
|
if let Some(encoded_duration_s) = encoded_duration_s {
|
||||||
|
segment.duration_s = encoded_duration_s;
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_nearby_coded_audio_segments(segments: Vec<PulseSegment>) -> Vec<PulseSegment> {
|
||||||
|
let mut merged = Vec::<PulseSegment>::new();
|
||||||
|
for segment in segments {
|
||||||
|
match merged.last_mut() {
|
||||||
|
Some(prior) if segment.start_s - prior.end_s <= MAX_AUDIO_PULSE_INTERNAL_GAP_S => {
|
||||||
|
prior.end_s = segment.end_s;
|
||||||
|
prior.duration_s = prior.duration_s.max(segment.duration_s);
|
||||||
|
}
|
||||||
|
_ => merged.push(segment),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn median_frame_step_seconds(timestamps_s: &[f64]) -> f64 {
|
pub(super) fn median_frame_step_seconds(timestamps_s: &[f64]) -> f64 {
|
||||||
let diffs = timestamps_s
|
let diffs = timestamps_s
|
||||||
.windows(2)
|
.windows(2)
|
||||||
|
|||||||
@ -230,6 +230,8 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
let activity_start_delta_ms =
|
let activity_start_delta_ms =
|
||||||
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
(raw_first_audio_activity_s - raw_first_video_activity_s) * 1000.0;
|
||||||
|
|
||||||
|
let raw_video_segments = video_segments.to_vec();
|
||||||
|
let raw_audio_segments = audio_segments.to_vec();
|
||||||
let phase_tolerance_s = segment_phase_tolerance(pulse_period_s, pulse_width_s, max_pair_gap_s);
|
let phase_tolerance_s = segment_phase_tolerance(pulse_period_s, pulse_width_s, max_pair_gap_s);
|
||||||
let video_segments =
|
let video_segments =
|
||||||
collapse_segments_by_phase(video_segments, pulse_period_s, phase_tolerance_s);
|
collapse_segments_by_phase(video_segments, pulse_period_s, phase_tolerance_s);
|
||||||
@ -250,20 +252,6 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|segment| segment.start_s)
|
.map(|segment| segment.start_s)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let full_video_onsets_s = video_onsets_s.clone();
|
|
||||||
let full_audio_onsets_s = audio_onsets_s.clone();
|
|
||||||
let full_video_indexed = index_coded_segments_by_spacing(
|
|
||||||
&video_segments,
|
|
||||||
pulse_period_s,
|
|
||||||
pulse_width_s,
|
|
||||||
event_width_codes,
|
|
||||||
);
|
|
||||||
let full_audio_indexed = index_coded_segments_by_spacing(
|
|
||||||
&audio_segments,
|
|
||||||
pulse_period_s,
|
|
||||||
pulse_width_s,
|
|
||||||
event_width_codes,
|
|
||||||
);
|
|
||||||
let (_, _, common_window) =
|
let (_, _, common_window) =
|
||||||
trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
|
trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
|
||||||
let filtered_video_segments = filter_segments_to_window(&video_segments, common_window);
|
let filtered_video_segments = filter_segments_to_window(&video_segments, common_window);
|
||||||
@ -303,17 +291,94 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if pairs.len() < MIN_CODED_PAIRS {
|
if pairs.len() < MIN_CODED_PAIRS {
|
||||||
|
let time_pairs = best_coded_pairs_by_time(
|
||||||
|
&filtered_video_segments,
|
||||||
|
&filtered_audio_segments,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
max_pair_gap_s,
|
||||||
|
);
|
||||||
|
if time_pairs.len() >= MIN_CODED_PAIRS {
|
||||||
|
let video_onsets_s = filtered_video_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let audio_onsets_s = filtered_audio_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
return Ok(sync_report_from_pairs(
|
||||||
|
&video_onsets_s,
|
||||||
|
&audio_onsets_s,
|
||||||
|
true,
|
||||||
|
activity_start_delta_ms,
|
||||||
|
raw_first_video_activity_s,
|
||||||
|
raw_first_audio_activity_s,
|
||||||
|
time_pairs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((raw_filtered_video_segments, raw_filtered_audio_segments, raw_pairs)) =
|
||||||
|
best_coded_pairs_for_raw_segments(
|
||||||
|
&raw_video_segments,
|
||||||
|
&raw_audio_segments,
|
||||||
|
pulse_period_s,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
max_pair_gap_s,
|
||||||
|
)
|
||||||
|
&& raw_pairs.len() >= MIN_CODED_PAIRS
|
||||||
|
{
|
||||||
|
let video_onsets_s = raw_filtered_video_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let audio_onsets_s = raw_filtered_audio_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
return Ok(sync_report_from_pairs(
|
||||||
|
&video_onsets_s,
|
||||||
|
&audio_onsets_s,
|
||||||
|
true,
|
||||||
|
activity_start_delta_ms,
|
||||||
|
raw_first_video_activity_s,
|
||||||
|
raw_first_audio_activity_s,
|
||||||
|
raw_pairs,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_full_video_indexed = index_coded_segments_by_spacing(
|
||||||
|
&raw_video_segments,
|
||||||
|
pulse_period_s,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
);
|
||||||
|
let raw_full_audio_indexed = index_coded_segments_by_spacing(
|
||||||
|
&raw_audio_segments,
|
||||||
|
pulse_period_s,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
);
|
||||||
let diagnostic_pairs = diagnostic_coded_pairs_for_index_offsets(
|
let diagnostic_pairs = diagnostic_coded_pairs_for_index_offsets(
|
||||||
&full_video_indexed,
|
&raw_full_video_indexed,
|
||||||
&full_audio_indexed,
|
&raw_full_audio_indexed,
|
||||||
&candidate_coded_index_offsets(&full_video_indexed, &full_audio_indexed),
|
&candidate_coded_index_offsets(&raw_full_video_indexed, &raw_full_audio_indexed),
|
||||||
DIAGNOSTIC_CODED_MAX_PAIR_GAP_S,
|
DIAGNOSTIC_CODED_MAX_PAIR_GAP_S,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
);
|
);
|
||||||
if diagnostic_pairs.len() >= MIN_CODED_PAIRS {
|
if diagnostic_pairs.len() >= MIN_CODED_PAIRS {
|
||||||
|
let raw_full_video_onsets_s = raw_video_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let raw_full_audio_onsets_s = raw_audio_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
return Ok(sync_report_from_pairs(
|
return Ok(sync_report_from_pairs(
|
||||||
&full_video_onsets_s,
|
&raw_full_video_onsets_s,
|
||||||
&full_audio_onsets_s,
|
&raw_full_audio_onsets_s,
|
||||||
true,
|
true,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
raw_first_video_activity_s,
|
raw_first_video_activity_s,
|
||||||
@ -357,6 +422,71 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn best_coded_pairs_for_raw_segments(
|
||||||
|
video_segments: &[PulseSegment],
|
||||||
|
audio_segments: &[PulseSegment],
|
||||||
|
pulse_period_s: f64,
|
||||||
|
pulse_width_s: f64,
|
||||||
|
event_width_codes: &[u32],
|
||||||
|
max_pair_gap_s: f64,
|
||||||
|
) -> Option<(Vec<PulseSegment>, Vec<PulseSegment>, Vec<MatchedOnsetPair>)> {
|
||||||
|
if video_segments.is_empty() || audio_segments.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let video_onsets_s = video_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let audio_onsets_s = audio_segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.start_s)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let (_, _, common_window) =
|
||||||
|
trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
|
||||||
|
let filtered_video_segments = filter_segments_to_window(video_segments, common_window);
|
||||||
|
let filtered_audio_segments = filter_segments_to_window(audio_segments, common_window);
|
||||||
|
if filtered_video_segments.is_empty() || filtered_audio_segments.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_start_skew_ms =
|
||||||
|
(filtered_audio_segments[0].start_s - filtered_video_segments[0].start_s) * 1000.0;
|
||||||
|
let video_indexed = index_coded_segments_by_spacing(
|
||||||
|
&filtered_video_segments,
|
||||||
|
pulse_period_s,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
);
|
||||||
|
let audio_indexed = index_coded_segments_by_spacing(
|
||||||
|
&filtered_audio_segments,
|
||||||
|
pulse_period_s,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
);
|
||||||
|
let index_pairs = best_coded_pairs_for_index_offsets(
|
||||||
|
&video_indexed,
|
||||||
|
&audio_indexed,
|
||||||
|
&candidate_coded_index_offsets(&video_indexed, &audio_indexed),
|
||||||
|
max_pair_gap_s,
|
||||||
|
expected_start_skew_ms,
|
||||||
|
);
|
||||||
|
let time_pairs = best_coded_pairs_by_time(
|
||||||
|
&filtered_video_segments,
|
||||||
|
&filtered_audio_segments,
|
||||||
|
pulse_width_s,
|
||||||
|
event_width_codes,
|
||||||
|
max_pair_gap_s,
|
||||||
|
);
|
||||||
|
let pairs = if time_pairs.len() > index_pairs.len() {
|
||||||
|
time_pairs
|
||||||
|
} else {
|
||||||
|
index_pairs
|
||||||
|
};
|
||||||
|
|
||||||
|
Some((filtered_video_segments, filtered_audio_segments, pairs))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct CommonActivityWindow {
|
struct CommonActivityWindow {
|
||||||
start_s: f64,
|
start_s: f64,
|
||||||
@ -772,6 +902,51 @@ fn best_coded_pairs_for_index_offsets(
|
|||||||
best.map(|(_, _, _, _, pairs)| pairs).unwrap_or_default()
|
best.map(|(_, _, _, _, pairs)| pairs).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn best_coded_pairs_by_time(
|
||||||
|
video_segments: &[PulseSegment],
|
||||||
|
audio_segments: &[PulseSegment],
|
||||||
|
pulse_width_s: f64,
|
||||||
|
event_width_codes: &[u32],
|
||||||
|
max_pair_gap_s: f64,
|
||||||
|
) -> Vec<MatchedOnsetPair> {
|
||||||
|
let max_pair_gap_ms = max_pair_gap_s * 1000.0;
|
||||||
|
let mut used_audio = vec![false; audio_segments.len()];
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
|
||||||
|
for video in video_segments {
|
||||||
|
let video_code =
|
||||||
|
nearest_event_width_code(video.duration_s, pulse_width_s, event_width_codes);
|
||||||
|
let best_audio = audio_segments
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(index, audio)| {
|
||||||
|
!used_audio[*index]
|
||||||
|
&& nearest_event_width_code(audio.duration_s, pulse_width_s, event_width_codes)
|
||||||
|
== video_code
|
||||||
|
})
|
||||||
|
.map(|(index, audio)| {
|
||||||
|
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
||||||
|
(index, audio, skew_ms)
|
||||||
|
})
|
||||||
|
.filter(|(_, _, skew_ms)| skew_ms.abs() <= max_pair_gap_ms)
|
||||||
|
.min_by(|(_, _, left_skew), (_, _, right_skew)| {
|
||||||
|
left_skew.abs().total_cmp(&right_skew.abs())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((audio_index, audio, skew_ms)) = best_audio {
|
||||||
|
used_audio[audio_index] = true;
|
||||||
|
pairs.push(MatchedOnsetPair::new(
|
||||||
|
video.start_s,
|
||||||
|
audio.start_s,
|
||||||
|
skew_ms,
|
||||||
|
max_pair_gap_s,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs
|
||||||
|
}
|
||||||
|
|
||||||
fn diagnostic_coded_pairs_for_index_offsets(
|
fn diagnostic_coded_pairs_for_index_offsets(
|
||||||
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use super::correlation::{
|
|||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
PulseSegment, VideoColorFrame, correlate_coded_segments, correlate_segments,
|
PulseSegment, VideoColorFrame, correlate_coded_segments, correlate_segments,
|
||||||
detect_audio_onsets, detect_audio_segments, detect_color_coded_video_segments,
|
detect_audio_onsets, detect_audio_segments, detect_coded_audio_segments,
|
||||||
detect_video_onsets, detect_video_segments, median,
|
detect_color_coded_video_segments, detect_video_onsets, detect_video_segments, median,
|
||||||
};
|
};
|
||||||
use crate::sync_probe::analyze::report::SyncAnalysisReport;
|
use crate::sync_probe::analyze::report::SyncAnalysisReport;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@ -118,6 +118,51 @@ fn detect_color_coded_video_segments_ignores_generic_bright_changes() {
|
|||||||
assert!((segments[1].duration_s - 0.24).abs() < 0.001);
|
assert!((segments[1].duration_s - 0.24).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_color_coded_video_segments_accepts_camera_washed_palette() {
|
||||||
|
let timestamps = (0..90).map(|idx| idx as f64 / 30.0).collect::<Vec<_>>();
|
||||||
|
let frames = timestamps
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, _)| match idx {
|
||||||
|
10..=12 => VideoColorFrame {
|
||||||
|
r: 184,
|
||||||
|
g: 72,
|
||||||
|
b: 68,
|
||||||
|
},
|
||||||
|
30..=34 => VideoColorFrame {
|
||||||
|
r: 76,
|
||||||
|
g: 168,
|
||||||
|
b: 111,
|
||||||
|
},
|
||||||
|
50..=55 => VideoColorFrame {
|
||||||
|
r: 82,
|
||||||
|
g: 125,
|
||||||
|
b: 188,
|
||||||
|
},
|
||||||
|
70..=76 => VideoColorFrame {
|
||||||
|
r: 190,
|
||||||
|
g: 173,
|
||||||
|
b: 60,
|
||||||
|
},
|
||||||
|
_ => VideoColorFrame {
|
||||||
|
r: 22,
|
||||||
|
g: 22,
|
||||||
|
b: 24,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let segments = detect_color_coded_video_segments(×tamps, &frames, &[1, 2, 3, 4], 0.12)
|
||||||
|
.expect("segments");
|
||||||
|
|
||||||
|
assert_eq!(segments.len(), 4);
|
||||||
|
assert!((segments[0].duration_s - 0.12).abs() < 0.001);
|
||||||
|
assert!((segments[1].duration_s - 0.24).abs() < 0.001);
|
||||||
|
assert!((segments[2].duration_s - 0.36).abs() < 0.001);
|
||||||
|
assert!((segments[3].duration_s - 0.48).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_audio_segments_keeps_regular_and_marker_durations_distinct() {
|
fn detect_audio_segments_keeps_regular_and_marker_durations_distinct() {
|
||||||
let mut samples = vec![0i16; 48_000];
|
let mut samples = vec![0i16; 48_000];
|
||||||
@ -162,6 +207,36 @@ fn detect_audio_segments_accepts_faint_probe_tones() {
|
|||||||
assert!((segments[1].start_s - 0.5).abs() < 0.01);
|
assert!((segments[1].start_s - 0.5).abs() < 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_audio_segments_locks_onto_probe_tone_over_background_hum() {
|
||||||
|
let mut samples = vec![0i16; 96_000];
|
||||||
|
add_sine(&mut samples, 48_000, 0.0, 2.0, 120.0, 7_000.0);
|
||||||
|
add_sine(&mut samples, 48_000, 0.25, 0.12, 880.0, 1_800.0);
|
||||||
|
add_sine(&mut samples, 48_000, 1.25, 0.12, 880.0, 1_800.0);
|
||||||
|
|
||||||
|
let segments = detect_audio_segments(&samples, 48_000, 10).expect("tone segments");
|
||||||
|
assert_eq!(segments.len(), 2);
|
||||||
|
assert!((segments[0].start_s - 0.25).abs() < 0.03);
|
||||||
|
assert!((segments[1].start_s - 1.25).abs() < 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_coded_audio_segments_uses_probe_tone_frequency_for_event_code() {
|
||||||
|
let mut samples = vec![0i16; 96_000];
|
||||||
|
add_sine(&mut samples, 48_000, 0.0, 2.0, 120.0, 7_000.0);
|
||||||
|
add_sine(&mut samples, 48_000, 0.25, 0.07, 660.0, 2_000.0);
|
||||||
|
add_sine(&mut samples, 48_000, 1.25, 0.07, 1320.0, 2_000.0);
|
||||||
|
|
||||||
|
let segments =
|
||||||
|
detect_coded_audio_segments(&samples, 48_000, 10, &[1, 2, 3, 4], 0.12).expect("segments");
|
||||||
|
|
||||||
|
assert_eq!(segments.len(), 2);
|
||||||
|
assert!((segments[0].start_s - 0.25).abs() < 0.03);
|
||||||
|
assert!((segments[0].duration_s - 0.12).abs() < 0.001);
|
||||||
|
assert!((segments[1].start_s - 1.25).abs() < 0.03);
|
||||||
|
assert!((segments[1].duration_s - 0.48).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_audio_segments_merges_longer_probe_dropouts_inside_one_pulse() {
|
fn detect_audio_segments_merges_longer_probe_dropouts_inside_one_pulse() {
|
||||||
let mut samples = vec![0i16; 48_000];
|
let mut samples = vec![0i16; 48_000];
|
||||||
@ -177,6 +252,26 @@ fn detect_audio_segments_merges_longer_probe_dropouts_inside_one_pulse() {
|
|||||||
assert!(segments[0].duration_s > 0.24);
|
assert!(segments[0].duration_s > 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_sine(
|
||||||
|
samples: &mut [i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
start_s: f64,
|
||||||
|
duration_s: f64,
|
||||||
|
frequency_hz: f64,
|
||||||
|
amplitude: f64,
|
||||||
|
) {
|
||||||
|
let start = (start_s * f64::from(sample_rate_hz)).round() as usize;
|
||||||
|
let len = (duration_s * f64::from(sample_rate_hz)).round() as usize;
|
||||||
|
for (offset, sample) in samples.iter_mut().skip(start).take(len).enumerate() {
|
||||||
|
let t = offset as f64 / f64::from(sample_rate_hz);
|
||||||
|
let value =
|
||||||
|
f64::from(*sample) + amplitude * (2.0 * std::f64::consts::PI * frequency_hz * t).sin();
|
||||||
|
*sample = value
|
||||||
|
.round()
|
||||||
|
.clamp(f64::from(i16::MIN), f64::from(i16::MAX)) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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];
|
||||||
@ -472,6 +567,37 @@ fn correlate_coded_segments_matches_preserved_event_width_codes() {
|
|||||||
assert!(report.max_abs_skew_ms < 50.0);
|
assert!(report.max_abs_skew_ms < 50.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn correlate_coded_segments_recovers_when_extra_video_detections_win_phase_collapse() {
|
||||||
|
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||||
|
let duration_s = 0.12 * f64::from(code);
|
||||||
|
PulseSegment {
|
||||||
|
start_s,
|
||||||
|
end_s: start_s + duration_s,
|
||||||
|
duration_s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let codes = [1, 2, 1, 3, 2, 4, 1, 1];
|
||||||
|
let mut video = Vec::new();
|
||||||
|
for (tick, code) in codes.iter().copied().enumerate() {
|
||||||
|
video.push(segment(tick as f64, code));
|
||||||
|
video.push(segment(tick as f64 + 0.45, 4));
|
||||||
|
}
|
||||||
|
let audio = codes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(tick, code)| segment(tick as f64 + 0.045, *code))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let report =
|
||||||
|
correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.2).expect("coded report");
|
||||||
|
|
||||||
|
assert_eq!(report.paired_event_count, codes.len());
|
||||||
|
assert!((report.median_skew_ms - 45.0).abs() < 1.0);
|
||||||
|
assert!(report.max_abs_skew_ms < 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn correlate_coded_segments_rejects_nearby_wrong_width_codes() {
|
fn correlate_coded_segments_rejects_nearby_wrong_width_codes() {
|
||||||
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||||
|
|||||||
@ -70,7 +70,7 @@ pub(super) fn click_track_samples(click_times_s: &[f64], total_samples: usize) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn thumbnail_video_bytes(brightness_values: &[u8]) -> Vec<u8> {
|
pub(super) fn thumbnail_video_bytes(brightness_values: &[u8]) -> Vec<u8> {
|
||||||
const SIDE: usize = 32;
|
const SIDE: usize = 64;
|
||||||
let mut bytes = Vec::with_capacity(brightness_values.len() * SIDE * SIDE);
|
let mut bytes = Vec::with_capacity(brightness_values.len() * SIDE * SIDE);
|
||||||
for brightness in brightness_values {
|
for brightness in brightness_values {
|
||||||
let mut frame = vec![20u8; SIDE * SIDE];
|
let mut frame = vec![20u8; SIDE * SIDE];
|
||||||
@ -85,7 +85,7 @@ pub(super) fn thumbnail_video_bytes(brightness_values: &[u8]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn thumbnail_rgb_video_bytes(colors: &[(u8, u8, u8)]) -> Vec<u8> {
|
pub(super) fn thumbnail_rgb_video_bytes(colors: &[(u8, u8, u8)]) -> Vec<u8> {
|
||||||
const SIDE: usize = 32;
|
const SIDE: usize = 64;
|
||||||
let mut bytes = Vec::with_capacity(colors.len() * SIDE * SIDE * 3);
|
let mut bytes = Vec::with_capacity(colors.len() * SIDE * SIDE * 3);
|
||||||
for (r, g, b) in colors {
|
for (r, g, b) in colors {
|
||||||
let mut frame = vec![0u8; SIDE * SIDE * 3];
|
let mut frame = vec![0u8; SIDE * SIDE * 3];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.17.32"
|
version = "0.17.33"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -129,10 +129,10 @@ let oscillator = null;
|
|||||||
let gain = null;
|
let gain = null;
|
||||||
let startedAt = 0;
|
let startedAt = 0;
|
||||||
const pulseColors = {
|
const pulseColors = {
|
||||||
1: '#ff2d2d',
|
1: '#b81d24',
|
||||||
2: '#00e676',
|
2: '#007a3d',
|
||||||
3: '#2979ff',
|
3: '#1456b8',
|
||||||
4: '#ffb300'
|
4: '#b56b00'
|
||||||
};
|
};
|
||||||
const pulseFrequencies = {
|
const pulseFrequencies = {
|
||||||
1: 660,
|
1: 660,
|
||||||
|
|||||||
@ -50,6 +50,7 @@ STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10}
|
|||||||
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
|
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
|
||||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
|
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
|
||||||
LOCAL_BROWSER=${LOCAL_BROWSER:-firefox}
|
LOCAL_BROWSER=${LOCAL_BROWSER:-firefox}
|
||||||
|
LESAVKA_STIMULUS_BROWSER_KIOSK=${LESAVKA_STIMULUS_BROWSER_KIOSK:-1}
|
||||||
|
|
||||||
mkdir -p "${LOCAL_OUTPUT_DIR}"
|
mkdir -p "${LOCAL_OUTPUT_DIR}"
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
@ -639,9 +640,12 @@ user_pref("browser.aboutwelcome.enabled", false);
|
|||||||
PREFS
|
PREFS
|
||||||
printf 'user_pref("browser.startup.homepage", "http://127.0.0.1:%s/");\n' "${STIMULUS_PORT}" >>"${STIMULUS_PROFILE}/user.js"
|
printf 'user_pref("browser.startup.homepage", "http://127.0.0.1:%s/");\n' "${STIMULUS_PORT}" >>"${STIMULUS_PROFILE}/user.js"
|
||||||
echo "==> opening local stimulus browser"
|
echo "==> opening local stimulus browser"
|
||||||
"${LOCAL_BROWSER}" --new-instance --no-remote --profile "${STIMULUS_PROFILE}" \
|
local browser_args=(--new-instance --no-remote --profile "${STIMULUS_PROFILE}")
|
||||||
"http://127.0.0.1:${STIMULUS_PORT}/" \
|
if [[ "${LESAVKA_STIMULUS_BROWSER_KIOSK}" == "1" ]]; then
|
||||||
>"${ARTIFACT_DIR}/stimulus-browser.log" 2>&1 &
|
browser_args+=(--kiosk)
|
||||||
|
fi
|
||||||
|
browser_args+=("http://127.0.0.1:${STIMULUS_PORT}/")
|
||||||
|
"${LOCAL_BROWSER}" "${browser_args[@]}" >"${ARTIFACT_DIR}/stimulus-browser.log" 2>&1 &
|
||||||
STIMULUS_BROWSER_PID=$!
|
STIMULUS_BROWSER_PID=$!
|
||||||
wait_for_stimulus_page_ready 15
|
wait_for_stimulus_page_ready 15
|
||||||
|
|
||||||
@ -746,9 +750,11 @@ summarize_adaptive_probe_metrics() {
|
|||||||
echo "==> summarizing segmented probe metrics"
|
echo "==> summarizing segmented probe metrics"
|
||||||
python3 - "${ARTIFACT_DIR}" "${LESAVKA_SYNC_TOTAL_SEGMENTS}" "${LESAVKA_SYNC_CALIBRATION_SEGMENTS}" <<'PY'
|
python3 - "${ARTIFACT_DIR}" "${LESAVKA_SYNC_TOTAL_SEGMENTS}" "${LESAVKA_SYNC_CALIBRATION_SEGMENTS}" <<'PY'
|
||||||
import csv
|
import csv
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -783,6 +789,13 @@ def latest_analysis_failure(segment_dir):
|
|||||||
return max(failures, key=lambda path: path.stat().st_mtime)
|
return max(failures, key=lambda path: path.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_capture(segment_dir):
|
||||||
|
captures = list(segment_dir.glob("*.webm"))
|
||||||
|
if not captures:
|
||||||
|
return None
|
||||||
|
return max(captures, key=lambda path: path.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
def as_float(value):
|
def as_float(value):
|
||||||
if value is None or value in {"", "pending"}:
|
if value is None or value in {"", "pending"}:
|
||||||
return None
|
return None
|
||||||
@ -1023,6 +1036,7 @@ diagnoses = []
|
|||||||
for segment in range(1, segment_count + 1):
|
for segment in range(1, segment_count + 1):
|
||||||
segment_dir = root / f"segment-{segment}"
|
segment_dir = root / f"segment-{segment}"
|
||||||
report_path = latest_report(segment_dir)
|
report_path = latest_report(segment_dir)
|
||||||
|
capture_path = latest_capture(segment_dir)
|
||||||
report = {}
|
report = {}
|
||||||
verdict = {}
|
verdict = {}
|
||||||
calibration = {}
|
calibration = {}
|
||||||
@ -1048,6 +1062,7 @@ for segment in range(1, segment_count + 1):
|
|||||||
row = {
|
row = {
|
||||||
"segment": segment,
|
"segment": segment,
|
||||||
"segment_phase": phase,
|
"segment_phase": phase,
|
||||||
|
"capture_path": str(capture_path) if capture_path else "",
|
||||||
"report_json": str(report_path) if report_path else "",
|
"report_json": str(report_path) if report_path else "",
|
||||||
"analysis_failure_json": str(failure_path) if failure_path else "",
|
"analysis_failure_json": str(failure_path) if failure_path else "",
|
||||||
"analysis_failure_reason": failure.get("reason", ""),
|
"analysis_failure_reason": failure.get("reason", ""),
|
||||||
@ -1172,6 +1187,73 @@ with events_jsonl_path.open("w", encoding="utf-8") as handle:
|
|||||||
for row in event_rows:
|
for row in event_rows:
|
||||||
handle.write(json.dumps(row, sort_keys=True) + "\n")
|
handle.write(json.dumps(row, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
review_dir = root / "manual-review"
|
||||||
|
review_dir.mkdir(exist_ok=True)
|
||||||
|
review_items = []
|
||||||
|
for row in rows:
|
||||||
|
capture_path_raw = row.get("capture_path")
|
||||||
|
if not capture_path_raw:
|
||||||
|
continue
|
||||||
|
source = Path(capture_path_raw)
|
||||||
|
if not source.exists():
|
||||||
|
continue
|
||||||
|
review_name = f"segment-{row['segment']}.webm"
|
||||||
|
review_capture = review_dir / review_name
|
||||||
|
if review_capture.exists() or review_capture.is_symlink():
|
||||||
|
review_capture.unlink()
|
||||||
|
try:
|
||||||
|
review_capture.symlink_to(os.path.relpath(source, review_dir))
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(source, review_capture)
|
||||||
|
review_items.append({
|
||||||
|
"segment": row["segment"],
|
||||||
|
"segment_phase": row.get("segment_phase", ""),
|
||||||
|
"probe_status": row.get("probe_status", ""),
|
||||||
|
"probe_paired_pulses": row.get("probe_paired_pulses", ""),
|
||||||
|
"probe_p95_abs_skew_ms": row.get("probe_p95_abs_skew_ms", ""),
|
||||||
|
"capture_href": review_name,
|
||||||
|
"report_href": os.path.relpath(row["report_json"], review_dir) if row.get("report_json") else "",
|
||||||
|
"failure_href": os.path.relpath(row["analysis_failure_json"], review_dir) if row.get("analysis_failure_json") else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
manual_review_path = review_dir / "index.html"
|
||||||
|
review_cards = []
|
||||||
|
for item in review_items:
|
||||||
|
detail_links = []
|
||||||
|
if item["report_href"]:
|
||||||
|
detail_links.append(f'<a href="{html.escape(item["report_href"])}">report.json</a>')
|
||||||
|
if item["failure_href"]:
|
||||||
|
detail_links.append(f'<a href="{html.escape(item["failure_href"])}">analysis-failure.json</a>')
|
||||||
|
p95 = item["probe_p95_abs_skew_ms"]
|
||||||
|
p95_text = "n/a" if p95 is None else f"{p95:.1f} ms"
|
||||||
|
review_cards.append(f"""
|
||||||
|
<section>
|
||||||
|
<h2>Segment {item['segment']} ({html.escape(str(item['segment_phase']))})</h2>
|
||||||
|
<p>status={html.escape(str(item['probe_status']))}, pairs={html.escape(str(item['probe_paired_pulses']))}, p95={html.escape(p95_text)}</p>
|
||||||
|
<video controls preload="metadata" src="{html.escape(item['capture_href'])}"></video>
|
||||||
|
<p><a href="{html.escape(item['capture_href'])}">open capture</a>{' | ' + ' | '.join(detail_links) if detail_links else ''}</p>
|
||||||
|
</section>""")
|
||||||
|
|
||||||
|
manual_review_path.write_text(f"""<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Lesavka A/V Probe Manual Review</title>
|
||||||
|
<style>
|
||||||
|
body {{ font: 16px/1.45 sans-serif; margin: 24px; background: #101418; color: #eef4fa; }}
|
||||||
|
a {{ color: #8bd3ff; }}
|
||||||
|
section {{ margin: 0 0 28px; padding: 16px; border: 1px solid #34414f; border-radius: 12px; background: #18212a; }}
|
||||||
|
video {{ display: block; width: min(960px, 100%); max-height: 70vh; background: #000; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Lesavka A/V Probe Manual Review</h1>
|
||||||
|
<p>Open these captures to visually inspect framing, flashes, and audible test tones after the run.</p>
|
||||||
|
{''.join(review_cards) if review_cards else '<p>No browser captures were found.</p>'}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", encoding="utf-8")
|
||||||
|
|
||||||
good_rows = [row for row in rows if row.get("probe_passed")]
|
good_rows = [row for row in rows if row.get("probe_passed")]
|
||||||
confirmation_rows = [row for row in rows if row.get("segment_phase") == "confirmation"]
|
confirmation_rows = [row for row in rows if row.get("segment_phase") == "confirmation"]
|
||||||
passing_confirmation_rows = [row for row in confirmation_rows if row.get("probe_passed")]
|
passing_confirmation_rows = [row for row in confirmation_rows if row.get("probe_passed")]
|
||||||
@ -1318,6 +1400,7 @@ print(f" ↪ segment_metrics_csv={csv_path}")
|
|||||||
print(f" ↪ segment_metrics_jsonl={jsonl_path}")
|
print(f" ↪ segment_metrics_jsonl={jsonl_path}")
|
||||||
print(f" ↪ segment_events_csv={events_csv_path}")
|
print(f" ↪ segment_events_csv={events_csv_path}")
|
||||||
print(f" ↪ segment_events_jsonl={events_jsonl_path}")
|
print(f" ↪ segment_events_jsonl={events_jsonl_path}")
|
||||||
|
print(f" ↪ manual_review_html={manual_review_path}")
|
||||||
print(f" ↪ blind_targets_json={target_path}")
|
print(f" ↪ blind_targets_json={target_path}")
|
||||||
print(f" ↪ blind_targets_ready={str(bool(target.get('ready'))).lower()}")
|
print(f" ↪ blind_targets_ready={str(bool(target.get('ready'))).lower()}")
|
||||||
print(f" ↪ confirmation_summary_json={confirmation_path}")
|
print(f" ↪ confirmation_summary_json={confirmation_path}")
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.17.32"
|
version = "0.17.33"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -172,6 +172,9 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
|
|||||||
"segment-metrics.jsonl",
|
"segment-metrics.jsonl",
|
||||||
"segment-events.csv",
|
"segment-events.csv",
|
||||||
"segment-events.jsonl",
|
"segment-events.jsonl",
|
||||||
|
"manual-review",
|
||||||
|
"manual_review_html",
|
||||||
|
"capture_path",
|
||||||
"confirmation-summary.json",
|
"confirmation-summary.json",
|
||||||
"confirmation_passed",
|
"confirmation_passed",
|
||||||
"check_confirmation_result",
|
"check_confirmation_result",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user