fix(sync): harden probe analysis and uac auto selection

This commit is contained in:
Brad Stein 2026-04-24 16:33:28 -03:00
parent 0650965e52
commit 5d6d07d375
12 changed files with 151 additions and 37 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.13.0"
version = "0.13.1"
dependencies = [
"anyhow",
"async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.13.0"
version = "0.13.1"
dependencies = [
"anyhow",
"base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.13.0"
version = "0.13.1"
dependencies = [
"anyhow",
"base64",

View File

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

View File

@ -289,9 +289,9 @@ fn server_chip_state_tracks_connection_not_just_reachability() {
assert_eq!(server_version_label(&state), "-");
state.set_server_available(true);
state.set_server_version(Some("0.13.0".to_string()));
state.set_server_version(Some("0.13.1".to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Live);
assert_eq!(server_version_label(&state), "v0.13.0");
assert_eq!(server_version_label(&state), "v0.13.1");
assert_eq!(
server_light_state(&state, true),

View File

@ -24,7 +24,7 @@ pub fn analyze_capture(
options: &SyncAnalysisOptions,
) -> Result<SyncAnalysisReport> {
let timestamps = extract_video_timestamps(capture_path)?;
let brightness = extract_video_brightness(capture_path)?;
let brightness = extract_video_brightness(capture_path, timestamps.len())?;
let video_segments = detect_video_segments(&timestamps, &brightness)?;
let audio_samples = extract_audio_samples(capture_path)?;
@ -47,7 +47,8 @@ pub fn analyze_capture(
#[cfg(test)]
mod tests {
use super::test_support::{
audio_samples_to_bytes, click_track_samples, frame_json, with_fake_media_tools,
audio_samples_to_bytes, click_track_samples, frame_json, thumbnail_video_bytes,
with_fake_media_tools,
};
use super::{SyncAnalysisOptions, analyze_capture};
@ -63,7 +64,7 @@ mod tests {
with_fake_media_tools(
&frame_json(&timestamps),
&brightness,
&thumbnail_video_bytes(&brightness),
&audio_samples_to_bytes(&audio),
|capture_path| {
let report = analyze_capture(

View File

@ -3,6 +3,8 @@ use serde::Deserialize;
use std::path::Path;
use std::process::Command;
const VIDEO_ANALYSIS_SIDE_PX: usize = 32;
#[derive(Debug, Deserialize)]
struct ProbeFrameResponse {
#[serde(default)]
@ -44,7 +46,13 @@ pub(super) fn extract_video_timestamps(capture_path: &Path) -> Result<Vec<f64>>
Ok(timestamps)
}
pub(super) fn extract_video_brightness(capture_path: &Path) -> Result<Vec<u8>> {
pub(super) fn extract_video_brightness(
capture_path: &Path,
expected_frames: usize,
) -> Result<Vec<u8>> {
if expected_frames == 0 {
bail!("expected at least one video frame when extracting brightness");
}
let output = run_command(
Command::new("ffmpeg")
.arg("-hide_banner")
@ -55,7 +63,10 @@ pub(super) fn extract_video_brightness(capture_path: &Path) -> Result<Vec<u8>> {
.arg("-map")
.arg("0:v:0")
.arg("-vf")
.arg("scale=1:1,format=gray")
.arg(format!(
"scale={side}:{side}:flags=area,format=gray",
side = VIDEO_ANALYSIS_SIDE_PX
))
.arg("-f")
.arg("rawvideo")
.arg("-pix_fmt")
@ -66,7 +77,27 @@ pub(super) fn extract_video_brightness(capture_path: &Path) -> Result<Vec<u8>> {
if output.is_empty() {
bail!("ffmpeg did not emit any video brightness data");
}
Ok(output)
let frame_pixels = VIDEO_ANALYSIS_SIDE_PX * VIDEO_ANALYSIS_SIDE_PX;
if output.len() % frame_pixels != 0 {
bail!(
"ffmpeg emitted {} bytes of video brightness data, which is not divisible by the {}-pixel analysis frame size",
output.len(),
frame_pixels
);
}
let extracted_frames = output.len() / frame_pixels;
if extracted_frames < expected_frames {
bail!(
"ffmpeg emitted only {extracted_frames} brightness frames for {expected_frames} expected timestamps"
);
}
Ok(output
.chunks_exact(frame_pixels)
.take(expected_frames)
.map(summarize_frame_brightness)
.collect())
}
pub(super) fn extract_audio_samples(capture_path: &Path) -> Result<Vec<i16>> {
@ -110,13 +141,27 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result<Ve
Ok(output.stdout)
}
fn summarize_frame_brightness(frame: &[u8]) -> u8 {
let crop_start = VIDEO_ANALYSIS_SIDE_PX / 4;
let crop_end = VIDEO_ANALYSIS_SIDE_PX - crop_start;
let mut total = 0u64;
let mut pixels = 0u64;
for y in crop_start..crop_end {
for x in crop_start..crop_end {
total += u64::from(frame[y * VIDEO_ANALYSIS_SIDE_PX + x]);
pixels += 1;
}
}
((total / pixels.max(1)).min(u64::from(u8::MAX))) as u8
}
#[cfg(test)]
mod tests {
use super::{
extract_audio_samples, extract_video_brightness, extract_video_timestamps, run_command,
};
use crate::sync_probe::analyze::test_support::{
audio_samples_to_bytes, frame_json, with_fake_media_tools,
audio_samples_to_bytes, frame_json, thumbnail_video_bytes, with_fake_media_tools,
};
use std::process::Command;
@ -162,11 +207,11 @@ mod tests {
let brightness = vec![5u8, 100, 250];
with_fake_media_tools(
br#"{"frames":[{"best_effort_timestamp_time":"0.0"}]}"#,
&brightness,
&thumbnail_video_bytes(&brightness),
&[1, 0],
|capture_path| {
let parsed = extract_video_brightness(capture_path).expect("video brightness");
assert_eq!(parsed, brightness);
let parsed = extract_video_brightness(capture_path, 1).expect("video brightness");
assert_eq!(parsed, vec![brightness[0]]);
},
);
}
@ -178,7 +223,8 @@ mod tests {
&[],
&[1, 0],
|capture_path| {
let error = extract_video_brightness(capture_path).expect_err("empty brightness");
let error =
extract_video_brightness(capture_path, 1).expect_err("empty brightness");
assert!(
error
.to_string()
@ -188,6 +234,29 @@ mod tests {
);
}
#[test]
fn extract_video_brightness_uses_center_weighted_thumbnail_average() {
let brightness = vec![20u8, 45, 20];
with_fake_media_tools(
&frame_json(&[0.0, 0.1, 0.2]),
&thumbnail_video_bytes(&brightness),
&[1, 0],
|capture_path| {
let parsed = extract_video_brightness(capture_path, 3).expect("video brightness");
assert_eq!(parsed, brightness);
},
);
}
#[test]
fn extract_video_brightness_rejects_truncated_frame_data() {
with_fake_media_tools(&frame_json(&[0.0]), &[1, 2, 3], &[1, 0], |capture_path| {
let error =
extract_video_brightness(capture_path, 1).expect_err("truncated frame bytes");
assert!(error.to_string().contains("not divisible"));
});
}
#[test]
fn extract_audio_samples_reads_fake_ffmpeg_output() {
let samples = vec![1i16, -2, 32_000];

View File

@ -7,7 +7,10 @@ mod tests;
pub(crate) use correlation::correlate_segments;
pub(super) const DEFAULT_AUDIO_SAMPLE_RATE_HZ: u32 = 48_000;
const MIN_VIDEO_CONTRAST: u8 = 16;
// Real HDMI/capture paths can preserve pulse shape while compressing its absolute
// luma swing into a narrower band, so keep this guard modest and let the
// segment logic reject genuinely flat/noisy traces.
const MIN_VIDEO_CONTRAST: u8 = 8;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PulseSegment {

View File

@ -69,6 +69,21 @@ pub(super) fn click_track_samples(click_times_s: &[f64], total_samples: usize) -
samples
}
pub(super) fn thumbnail_video_bytes(brightness_values: &[u8]) -> Vec<u8> {
const SIDE: usize = 32;
let mut bytes = Vec::with_capacity(brightness_values.len() * SIDE * SIDE);
for brightness in brightness_values {
let mut frame = vec![20u8; SIDE * SIDE];
for y in SIDE / 4..SIDE - SIDE / 4 {
for x in SIDE / 4..SIDE - SIDE / 4 {
frame[y * SIDE + x] = *brightness;
}
}
bytes.extend_from_slice(&frame);
}
bytes
}
pub(super) fn audio_samples_to_bytes(samples: &[i16]) -> Vec<u8> {
samples
.iter()

View File

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

View File

@ -182,11 +182,11 @@
},
"client/src/sync_probe/analyze.rs": {
"line_percent": 97.92,
"loc": 86
"loc": 87
},
"client/src/sync_probe/analyze/media_extract.rs": {
"line_percent": 98.36,
"loc": 240
"line_percent": 97.88,
"loc": 309
},
"client/src/sync_probe/analyze/onset_detection.rs": {
"line_percent": 96.77,
@ -201,8 +201,8 @@
"loc": 59
},
"client/src/sync_probe/analyze/test_support.rs": {
"line_percent": 98.44,
"loc": 85
"line_percent": 98.67,
"loc": 100
},
"client/src/sync_probe/capture.rs": {
"line_percent": 100.0,
@ -377,8 +377,8 @@
"loc": 260
},
"server/src/runtime_support/audio_discovery.rs": {
"line_percent": 98.54,
"loc": 279
"line_percent": 98.55,
"loc": 281
},
"server/src/runtime_support/hid_recovery.rs": {
"line_percent": 100.0,

View File

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

View File

@ -129,15 +129,17 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
"hw:Lesavka,0",
];
let allow_aliases = auto_family.contains(&preferred);
push_audio_candidate_family(&mut out, &mut seen, preferred);
if allow_aliases {
for detected in detect_uac_card_candidates() {
push_audio_candidate_family(&mut out, &mut seen, &detected);
}
for alias in auto_family {
push_audio_candidate_family(&mut out, &mut seen, alias);
if alias != preferred {
push_audio_candidate_family(&mut out, &mut seen, alias);
}
}
}
push_audio_candidate_family(&mut out, &mut seen, preferred);
out
}
@ -177,13 +179,13 @@ fn detect_uac_card_candidates() -> Vec<String> {
.map(parse_uac_numeric_card_ids)
.unwrap_or_default();
if let Some(cards) = card_data.as_deref() {
for candidate in parse_uac_named_card_candidates(cards) {
if let Some(pcm) = asound_pcm_snapshot() {
for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) {
push_audio_candidate(&mut out, &mut seen, &candidate);
}
}
if let Some(pcm) = asound_pcm_snapshot() {
for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) {
if let Some(cards) = card_data.as_deref() {
for candidate in parse_uac_named_card_candidates(cards) {
push_audio_candidate(&mut out, &mut seen, &candidate);
}
}

View File

@ -85,23 +85,33 @@ fn audio_candidate_helpers_dedupe_and_pair_hw_plughw_forms() {
#[test]
#[cfg(coverage)]
#[serial]
fn preferred_uac_candidates_include_detected_cards_before_static_aliases() {
fn preferred_uac_candidates_include_detected_cards_before_alias_fallbacks() {
temp_env::with_vars(
[
(
"LESAVKA_TEST_ASOUND_CARDS",
Some(
" 7 [DetectedGadget ]: USB-Audio - UAC2 Gadget\n\
8 [LesavkaAudio ]: USB-Audio - Lesavka\n",
" 7 [DetectedGadget ]: USB-Audio - UAC2 Gadget
\
8 [LesavkaAudio ]: USB-Audio - Lesavka
",
),
),
(
"LESAVKA_TEST_ASOUND_PCM",
Some("07-03: USB Audio : USB Audio : playback 1 : capture 1\n"),
Some(
"07-03: USB Audio : USB Audio : playback 1 : capture 1
",
),
),
],
|| {
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
assert_eq!(
candidates.first().map(String::as_str),
Some("hw:7,3")
);
assert!(candidates.iter().any(|value| value == "plughw:7,3"));
assert!(
candidates
.iter()
@ -109,6 +119,20 @@ fn preferred_uac_candidates_include_detected_cards_before_static_aliases() {
);
assert!(candidates.iter().any(|value| value == "hw:LesavkaAudio,0"));
assert!(candidates.iter().any(|value| value == "hw:7,3"));
let preferred_index = candidates
.iter()
.position(|value| value == "hw:UAC2Gadget,0")
.expect("preferred alias present");
let numeric_index = candidates
.iter()
.position(|value| value == "hw:7,3")
.expect("numeric candidate present");
let detected_index = candidates
.iter()
.position(|value| value == "hw:DetectedGadget,0")
.expect("detected candidate present");
assert!(numeric_index < detected_index);
assert!(detected_index < preferred_index);
},
);
}