diff --git a/Cargo.lock b/Cargo.lock index f2a605f..cc28233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index d4f0525..bf3fcbb 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.0" +version = "0.13.1" edition = "2024" [dependencies] diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 8f8eec3..c6b8ac0 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -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), diff --git a/client/src/sync_probe/analyze.rs b/client/src/sync_probe/analyze.rs index 149f90f..69b5978 100644 --- a/client/src/sync_probe/analyze.rs +++ b/client/src/sync_probe/analyze.rs @@ -24,7 +24,7 @@ pub fn analyze_capture( options: &SyncAnalysisOptions, ) -> Result { 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( diff --git a/client/src/sync_probe/analyze/media_extract.rs b/client/src/sync_probe/analyze/media_extract.rs index 900afbc..6cf7f7e 100644 --- a/client/src/sync_probe/analyze/media_extract.rs +++ b/client/src/sync_probe/analyze/media_extract.rs @@ -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> Ok(timestamps) } -pub(super) fn extract_video_brightness(capture_path: &Path) -> Result> { +pub(super) fn extract_video_brightness( + capture_path: &Path, + expected_frames: usize, +) -> Result> { + 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> { .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> { 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> { @@ -110,13 +141,27 @@ pub(super) fn run_command(command: &mut Command, description: &str) -> Result 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]; diff --git a/client/src/sync_probe/analyze/onset_detection.rs b/client/src/sync_probe/analyze/onset_detection.rs index 5196668..c073c9f 100644 --- a/client/src/sync_probe/analyze/onset_detection.rs +++ b/client/src/sync_probe/analyze/onset_detection.rs @@ -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 { diff --git a/client/src/sync_probe/analyze/test_support.rs b/client/src/sync_probe/analyze/test_support.rs index 8636c36..889cb7c 100644 --- a/client/src/sync_probe/analyze/test_support.rs +++ b/client/src/sync_probe/analyze/test_support.rs @@ -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 { + 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 { samples .iter() diff --git a/common/Cargo.toml b/common/Cargo.toml index 353ef6f..2dd7d47 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.0" +version = "0.13.1" edition = "2024" build = "build.rs" diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index dfdfc25..ab9b393 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -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, diff --git a/server/Cargo.toml b/server/Cargo.toml index d86ae71..5d10b34 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.0" +version = "0.13.1" edition = "2024" autobins = false diff --git a/server/src/runtime_support/audio_discovery.rs b/server/src/runtime_support/audio_discovery.rs index 2340dc7..f31f7ee 100644 --- a/server/src/runtime_support/audio_discovery.rs +++ b/server/src/runtime_support/audio_discovery.rs @@ -129,15 +129,17 @@ fn preferred_uac_device_candidates(preferred: &str) -> Vec { "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 { .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); } } diff --git a/server/src/tests/runtime_support.rs b/server/src/tests/runtime_support.rs index 70ea6da..e190934 100644 --- a/server/src/tests/runtime_support.rs +++ b/server/src/tests/runtime_support.rs @@ -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); }, ); }