fix(sync): harden probe analysis and uac auto selection
This commit is contained in:
parent
0650965e52
commit
5d6d07d375
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(×tamps, &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(×tamps),
|
||||
&brightness,
|
||||
&thumbnail_video_bytes(&brightness),
|
||||
&audio_samples_to_bytes(&audio),
|
||||
|capture_path| {
|
||||
let report = analyze_capture(
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user