diff --git a/AGENTS.md b/AGENTS.md index ed02d37..f6ec176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,12 +123,12 @@ path. - [x] Add dense server-generated smoothness evidence on the normal UVC/UAC path: per-frame video continuity watermark, quiet audio pilot, cadence jitter, duplicate/missing frame estimates, and low-RMS audio gap counts. -- [ ] Keep UI/profile controls authoritative for UVC output profiles beyond - `640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is +- [ ] Keep UI/profile controls authoritative for webcam-backed UVC output + profiles; validate `1280x720@20/30` and `1920x1080@20/30` after sync is locked. - [x] Add a server-to-RC mode-matrix harness so the same sync/freshness/ - smoothness contract can be run against `640x480@20`, `1280x720@30`, - `1920x1080@20`, and `1920x1080@30`. + smoothness contract can be run against the core Logitech-backed modes: + `1280x720@20`, `1280x720@30`, `1920x1080@20`, and `1920x1080@30`. - [ ] Run the mode matrix on Theia/Tethys and record per-mode static delay center points before changing the normal advertised profiles. - [ ] Keep the UI +/-5ms calibration nudges available as small post-baseline diff --git a/Cargo.lock b/Cargo.lock index d39aa32..a15460c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.19.14" +version = "0.19.15" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.19.14" +version = "0.19.15" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.19.14" +version = "0.19.15" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index c884910..f26b499 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.19.14" +version = "0.19.15" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka-sync-analyze.rs b/client/src/bin/lesavka-sync-analyze.rs index 13634c8..ea3efa7 100644 --- a/client/src/bin/lesavka-sync-analyze.rs +++ b/client/src/bin/lesavka-sync-analyze.rs @@ -68,7 +68,7 @@ where let args = args.into_iter().map(Into::into).collect::>(); if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { println!( - "Usage: lesavka-sync-analyze [--json] [--report-dir ] [--event-width-codes 1,2,1,3]" + "Usage: lesavka-sync-analyze [--json] [--report-dir ] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]" ); std::process::exit(0); } @@ -111,6 +111,48 @@ where options.event_width_codes = parse_event_width_codes(raw_codes)?; continue; } + if arg == "--analysis-window-s" { + let Some(raw_window) = iter.next() else { + bail!("--analysis-window-s requires START:END seconds"); + }; + parse_analysis_window(&raw_window, &mut options)?; + continue; + } + if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") { + if raw_window.is_empty() { + bail!("--analysis-window-s requires START:END seconds"); + } + parse_analysis_window(raw_window, &mut options)?; + continue; + } + if arg == "--analysis-start-s" { + let Some(raw_start) = iter.next() else { + bail!("--analysis-start-s requires seconds"); + }; + options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?); + continue; + } + if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") { + if raw_start.is_empty() { + bail!("--analysis-start-s requires seconds"); + } + options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?); + continue; + } + if arg == "--analysis-end-s" { + let Some(raw_end) = iter.next() else { + bail!("--analysis-end-s requires seconds"); + }; + options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?); + continue; + } + if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") { + if raw_end.is_empty() { + bail!("--analysis-end-s requires seconds"); + } + options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?); + continue; + } if capture_path.is_some() { bail!("unexpected extra argument `{arg}`"); } @@ -150,6 +192,36 @@ fn parse_event_width_codes(raw: &str) -> Result> { Ok(codes) } +#[cfg(any(not(coverage), test))] +fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> { + let Some((start, end)) = raw.split_once(':') else { + bail!("--analysis-window-s requires START:END seconds"); + }; + options.analysis_start_s = if start.trim().is_empty() { + None + } else { + Some(parse_analysis_seconds(start, "analysis start")?) + }; + options.analysis_end_s = if end.trim().is_empty() { + None + } else { + Some(parse_analysis_seconds(end, "analysis end")?) + }; + Ok(()) +} + +#[cfg(any(not(coverage), test))] +fn parse_analysis_seconds(raw: &str, label: &str) -> Result { + let value = raw + .trim() + .parse::() + .with_context(|| format!("parsing {label} `{raw}`"))?; + if !value.is_finite() || value < 0.0 { + bail!("{label} must be a finite non-negative timestamp"); + } + Ok(value) +} + #[cfg(not(coverage))] fn format_human_report( capture_path: &std::path::Path, @@ -358,11 +430,20 @@ mod tests { assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]); } + #[test] + fn parse_args_accepts_analysis_window() { + let args = parse_args(["capture.mkv", "--analysis-window-s", "8.25:26.5"]).expect("args"); + assert_eq!(args.options.analysis_start_s, Some(8.25)); + assert_eq!(args.options.analysis_end_s, Some(26.5)); + } + #[test] fn parse_args_rejects_extra_positional_arguments() { assert!(parse_args(["one.mkv", "two.mkv"]).is_err()); assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err()); assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err()); + assert!(parse_args(["one.mkv", "--analysis-window-s", "wat:10"]).is_err()); + assert!(parse_args(["one.mkv", "--analysis-start-s", "-1"]).is_err()); } #[test] diff --git a/client/src/launcher/devices.rs b/client/src/launcher/devices.rs index 75c5710..81d7cca 100644 --- a/client/src/launcher/devices.rs +++ b/client/src/launcher/devices.rs @@ -196,7 +196,9 @@ fn discover_camera_modes_for(camera: &str) -> Vec { pub fn lesavka_supported_camera_modes() -> Vec { vec![ CameraMode::new(1920, 1080, 30), + CameraMode::new(1920, 1080, 20), CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20), ] } @@ -224,7 +226,7 @@ pub fn parse_supported_camera_modes(stdout: &str) -> Vec { discovered.iter().any(|actual| { actual.width == supported.width && actual.height == supported.height - && actual.fps >= supported.fps + && actual.fps == supported.fps }) }) .collect() diff --git a/client/src/launcher/tests/devices.rs b/client/src/launcher/tests/devices.rs index 600f29c..d51d712 100644 --- a/client/src/launcher/tests/devices.rs +++ b/client/src/launcher/tests/devices.rs @@ -83,9 +83,12 @@ ioctl: VIDIOC_ENUM_FMT [0]: 'MJPG' (Motion-JPEG, compressed) Size: Discrete 1920x1080 Interval: Discrete 0.033s (30.000 fps) + Interval: Discrete 0.050s (20.000 fps) Interval: Discrete 0.067s (15.000 fps) Size: Discrete 1280x720 Interval: Discrete 0.017s (60.000 fps) + Interval: Discrete 0.033s (30.000 fps) + Interval: Discrete 0.050s (20.000 fps) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps) [1]: 'YUYV' (YUYV 4:2:2) @@ -97,7 +100,41 @@ ioctl: VIDIOC_ENUM_FMT parse_supported_camera_modes(stdout), vec![ CameraMode::new(1920, 1080, 30), - CameraMode::new(1280, 720, 30) + CameraMode::new(1920, 1080, 20), + CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20) + ] + ); +} + +#[test] +fn camera_mode_parser_requires_exact_fps_for_supported_qualities() { + let stdout = r#" +ioctl: VIDIOC_ENUM_FMT + Type: Video Capture + + [0]: 'MJPG' (Motion-JPEG, compressed) + Size: Discrete 1280x720 + Interval: Discrete 0.033s (30.000 fps) + Size: Discrete 1920x1080 + Interval: Discrete 0.017s (60.000 fps) +"#; + + assert_eq!( + parse_supported_camera_modes(stdout), + vec![CameraMode::new(1280, 720, 30)] + ); +} + +#[test] +fn supported_camera_modes_match_the_core_logitech_profiles() { + assert_eq!( + lesavka_supported_camera_modes(), + vec![ + CameraMode::new(1920, 1080, 30), + CameraMode::new(1920, 1080, 20), + CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20) ] ); } diff --git a/client/src/launcher/tests/state.rs b/client/src/launcher/tests/state.rs index 9e4f08a..3c65fa5 100644 --- a/client/src/launcher/tests/state.rs +++ b/client/src/launcher/tests/state.rs @@ -275,7 +275,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() { "cam-a".to_string(), vec![ CameraMode::new(1920, 1080, 30), + CameraMode::new(1920, 1080, 20), CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20), ], ), ("cam-b".to_string(), vec![CameraMode::new(1280, 720, 30)]), @@ -292,7 +294,9 @@ fn camera_quality_tracks_selected_camera_supported_modes() { state.camera_quality_options(&catalog), vec![ CameraMode::new(1920, 1080, 30), - CameraMode::new(1280, 720, 30) + CameraMode::new(1920, 1080, 20), + CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20) ] ); diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 620a7de..d8f5512 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -780,7 +780,9 @@ fn realistic_device_catalog() -> DeviceCatalog { "usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(), vec![ CameraMode::new(1920, 1080, 30), + CameraMode::new(1920, 1080, 20), CameraMode::new(1280, 720, 30), + CameraMode::new(1280, 720, 20), ], )] .into_iter() diff --git a/client/src/sync_probe/analyze.rs b/client/src/sync_probe/analyze.rs index 0e30bbe..c99b5aa 100644 --- a/client/src/sync_probe/analyze.rs +++ b/client/src/sync_probe/analyze.rs @@ -13,7 +13,7 @@ use media_extract::{ extract_audio_samples, extract_video_brightness, extract_video_colors, extract_video_timestamps, }; use onset_detection::{ - DEFAULT_AUDIO_SAMPLE_RATE_HZ, correlate_coded_segments, correlate_segments, + DEFAULT_AUDIO_SAMPLE_RATE_HZ, PulseSegment, correlate_coded_segments, correlate_segments, detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments, detect_video_segments, }; @@ -75,6 +75,9 @@ pub fn analyze_capture( )? }; + let video_segments = filter_segments_to_analysis_window(video_segments, options, "video")?; + let audio_segments = filter_segments_to_analysis_window(audio_segments, options, "audio")?; + if !coded_video_events { correlate_segments( &video_segments, @@ -96,6 +99,45 @@ pub fn analyze_capture( } } +fn filter_segments_to_analysis_window( + segments: Vec, + options: &SyncAnalysisOptions, + stream_name: &str, +) -> Result> { + let start_s = options.analysis_start_s; + let end_s = options.analysis_end_s; + if start_s.is_none() && end_s.is_none() { + return Ok(segments); + } + if let Some(start_s) = start_s + && (!start_s.is_finite() || start_s < 0.0) + { + bail!("{stream_name} analysis start must be a finite non-negative timestamp"); + } + if let Some(end_s) = end_s + && (!end_s.is_finite() || end_s < 0.0) + { + bail!("{stream_name} analysis end must be a finite non-negative timestamp"); + } + if let (Some(start_s), Some(end_s)) = (start_s, end_s) + && end_s <= start_s + { + bail!("{stream_name} analysis window end must be after its start"); + } + + let filtered = segments + .into_iter() + .filter(|segment| { + start_s.is_none_or(|start_s| segment.start_s >= start_s) + && end_s.is_none_or(|end_s| segment.start_s <= end_s) + }) + .collect::>(); + if filtered.is_empty() { + bail!("{stream_name} analysis window removed all detected sync pulses"); + } + Ok(filtered) +} + fn reconcile_video_timestamps(timestamps: Vec, frame_count: usize) -> Result> { if frame_count == 0 { bail!("capture did not contain any decoded video brightness frames"); diff --git a/client/src/sync_probe/analyze/report.rs b/client/src/sync_probe/analyze/report.rs index 5c55836..85ae22a 100644 --- a/client/src/sync_probe/analyze/report.rs +++ b/client/src/sync_probe/analyze/report.rs @@ -304,6 +304,8 @@ pub struct SyncAnalysisOptions { pub pulse_width_s: f64, pub marker_tick_period: u32, pub event_width_codes: Vec, + pub analysis_start_s: Option, + pub analysis_end_s: Option, } impl Default for SyncAnalysisOptions { @@ -315,6 +317,8 @@ impl Default for SyncAnalysisOptions { pulse_width_s: DEFAULT_PULSE_WIDTH_S, marker_tick_period: DEFAULT_MARKER_TICK_PERIOD, event_width_codes: Vec::new(), + analysis_start_s: None, + analysis_end_s: None, } } } diff --git a/common/Cargo.toml b/common/Cargo.toml index 21b01ba..386e937 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.19.14" +version = "0.19.15" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 02a2295..9b77560 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -1011,6 +1011,10 @@ fi printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}" printf 'LESAVKA_SERVER_BIND_ADDR=%s\n' "${INSTALL_SERVER_BIND_ADDR}" printf 'LESAVKA_UVC_CODEC=%s\n' "${INSTALL_UVC_CODEC}" + printf 'LESAVKA_UVC_WIDTH=%s\n' "${LESAVKA_UVC_WIDTH:-1280}" + printf 'LESAVKA_UVC_HEIGHT=%s\n' "${LESAVKA_UVC_HEIGHT:-720}" + printf 'LESAVKA_UVC_FPS=%s\n' "${LESAVKA_UVC_FPS:-30}" + printf 'LESAVKA_UVC_INTERVAL=%s\n' "${LESAVKA_UVC_INTERVAL:-333333}" printf 'LESAVKA_REQUIRE_TLS=%s\n' "${LESAVKA_REQUIRE_TLS:-1}" printf 'LESAVKA_TLS_CERT=%s\n' "${LESAVKA_TLS_CERT:-$LESAVKA_TLS_DIR/server.crt}" printf 'LESAVKA_TLS_KEY=%s\n' "${LESAVKA_TLS_KEY:-$LESAVKA_TLS_DIR/server.key}" diff --git a/scripts/manual/run_server_to_rc_mode_matrix.sh b/scripts/manual/run_server_to_rc_mode_matrix.sh index 75e3ed0..9881e54 100755 --- a/scripts/manual/run_server_to_rc_mode_matrix.sh +++ b/scripts/manual/run_server_to_rc_mode_matrix.sh @@ -4,6 +4,10 @@ # the UVC modes the UI advertises. This is still a hardware-in-the-loop probe: # it captures the real Tethys UVC/UAC endpoints and summarizes sync, # freshness, and smoothness for each mode. +# +# Reconfigure mode is intentionally a fast runtime path: it updates the remote +# Lesavka env files and cycles the UVC gadget, but it does not rebuild or +# reinstall the server binary for each mode. set -euo pipefail @@ -25,13 +29,34 @@ LESAVKA_TLS_DOMAIN=${LESAVKA_TLS_DOMAIN:-lesavka-server} LESAVKA_SERVER_REPO=${LESAVKA_SERVER_REPO:-auto} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"} -LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-640x480@20,1280x720@30,1920x1080@20,1920x1080@30} +LESAVKA_SERVER_RC_CORE_WEBCAM_MODES=${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30} +LESAVKA_SERVER_RC_MODES=${LESAVKA_SERVER_RC_MODES:-${LESAVKA_SERVER_RC_CORE_WEBCAM_MODES}} +LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US:-${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}} LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US=${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US:-170000} -LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-640x480@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000} +LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US:-1280x720@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1280x720@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@20=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US},1920x1080@30=${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}} +LESAVKA_SERVER_RC_MODE_DELAYS_US=${LESAVKA_SERVER_RC_MODE_DELAYS_US:-1280x720@20=170000,1280x720@30=170000,1920x1080@20=170000,1920x1080@30=170000} +LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES=${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES:-1280x720,1920x1080} +LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS=${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS:-20,30} +LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX:-Logitech|BRIO|C9[0-9]+|HD UVC WebCam|USB2[.]0 HD|Integrated Camera|Webcam|Camera} +LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX=${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX:-Lesavka|UGREEN|MACROSILICON|Composite|Capture} +LESAVKA_SERVER_RC_MODE_SOURCE=${LESAVKA_SERVER_RC_MODE_SOURCE:-configured} LESAVKA_SERVER_RC_RECONFIGURE=${LESAVKA_SERVER_RC_RECONFIGURE:-0} LESAVKA_SERVER_RC_RECONFIGURE_REF=${LESAVKA_SERVER_RC_RECONFIGURE_REF:-master} LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE:-0} LESAVKA_SERVER_RC_RECONFIGURE_COMMAND=${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND:-} +LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY:-runtime} +LESAVKA_SERVER_RC_RECONFIGURE_CODEC=${LESAVKA_SERVER_RC_RECONFIGURE_CODEC:-mjpeg} +LESAVKA_SERVER_RC_ALLOW_GADGET_RESET=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET:-1} +LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD=${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD:-1} +LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS=${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS:-4} +LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE=${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE:-0} +LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY:-1} +LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD=${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD:-} +LESAVKA_SERVER_RC_WAIT_TETHYS_READY=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY:-1} +LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS:-60} +LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS:-6} +LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS:-3} +LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1} LESAVKA_SERVER_RC_CONTINUE_ON_FAIL=${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL:-1} LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350} @@ -80,10 +105,12 @@ parse_mode() { printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" } -lookup_video_delay_us() { +lookup_mode_delay_us() { local mode=$1 + local delay_map=$2 + local default_value=$3 local entry key value - IFS=',' read -r -a delay_entries <<<"${LESAVKA_SERVER_RC_MODE_DELAYS_US}" + IFS=',' read -r -a delay_entries <<<"${delay_map}" for entry in "${delay_entries[@]}"; do key=${entry%%=*} value=${entry#*=} @@ -92,7 +119,212 @@ lookup_video_delay_us() { return 0 fi done - printf '%s\n' "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}" + printf '%s\n' "${default_value}" +} + +lookup_audio_delay_us() { + lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_AUDIO_DELAY_US}" +} + +lookup_video_delay_us() { + lookup_mode_delay_us "$1" "${LESAVKA_SERVER_RC_MODE_DELAYS_US}" "${LESAVKA_SERVER_RC_DEFAULT_VIDEO_DELAY_US}" +} + +discover_local_webcam_modes() { + if ! command -v python3 >/dev/null 2>&1; then + printf 'LESAVKA_SERVER_RC_MODES=auto requires python3 for local webcam mode discovery.\n' >&2 + exit 64 + fi + if ! command -v v4l2-ctl >/dev/null 2>&1; then + printf 'LESAVKA_SERVER_RC_MODES=auto requires v4l2-ctl for local webcam mode discovery.\n' >&2 + exit 64 + fi + python3 - <<'PY' \ + "${LESAVKA_SERVER_RC_MODE_DISCOVERY_SIZES}" \ + "${LESAVKA_SERVER_RC_MODE_DISCOVERY_FPS}" \ + "${LESAVKA_SERVER_RC_MODE_DISCOVERY_INCLUDE_REGEX}" \ + "${LESAVKA_SERVER_RC_MODE_DISCOVERY_EXCLUDE_REGEX}" +import glob +import re +import subprocess +import sys + +sizes_raw, fps_raw, include_raw, exclude_raw = sys.argv[1:5] + +def parse_sizes(raw): + sizes = set() + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + match = re.fullmatch(r"(\d+)x(\d+)", entry) + if not match: + raise SystemExit(f"invalid discovery size {entry!r}; expected WIDTHxHEIGHT") + sizes.add((int(match.group(1)), int(match.group(2)))) + return sizes + +def parse_fps(raw): + values = set() + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + if not entry.isdigit(): + raise SystemExit(f"invalid discovery fps {entry!r}; expected integer FPS") + values.add(int(entry)) + return values + +allowed_sizes = parse_sizes(sizes_raw) +allowed_fps = parse_fps(fps_raw) +include = re.compile(include_raw, re.IGNORECASE) if include_raw else None +exclude = re.compile(exclude_raw, re.IGNORECASE) if exclude_raw else None +all_modes = set() +camera_modes = [] + +for dev in sorted(glob.glob("/dev/video*")): + try: + info = subprocess.run( + ["v4l2-ctl", "-d", dev, "--info"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ).stdout + formats = subprocess.run( + ["v4l2-ctl", "-d", dev, "--list-formats-ext"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ).stdout + except Exception: + continue + + card = dev + for line in info.splitlines(): + if "Card type" in line: + card = line.split(":", 1)[1].strip() + break + label = f"{card} {dev}" + if exclude and exclude.search(label): + continue + if include and not include.search(label): + continue + + current_size = None + modes = set() + for line in formats.splitlines(): + size_match = re.search(r"Size:\s+Discrete\s+(\d+)x(\d+)", line) + if size_match: + current_size = (int(size_match.group(1)), int(size_match.group(2))) + continue + fps_match = re.search(r"Interval:\s+Discrete\s+[^()]+\((\d+(?:\.\d+)?) fps\)", line) + if not fps_match or current_size not in allowed_sizes: + continue + fps_float = float(fps_match.group(1)) + fps = int(round(fps_float)) + if abs(fps_float - fps) > 0.05 or fps not in allowed_fps: + continue + modes.add((current_size[0], current_size[1], fps)) + + if modes: + all_modes.update(modes) + camera_modes.append((label, modes)) + +if not all_modes: + print( + "no matching local webcam modes found; " + f"sizes={sizes_raw} fps={fps_raw} include={include_raw!r} exclude={exclude_raw!r}", + file=sys.stderr, + ) + raise SystemExit(64) + +for label, modes in camera_modes: + rendered = ",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(modes)) + print(f" -> local webcam {label}: {rendered}", file=sys.stderr) + +print(",".join(f"{w}x{h}@{fps}" for w, h, fps in sorted(all_modes)), end="") +PY +} + +clear_remote_sudo_password() { + LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD="" +} +trap clear_remote_sudo_password EXIT + +ensure_remote_sudo_password() { + [[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0 + [[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0 + [[ -n "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}" ]] && return 0 + + if [[ "${LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY}" == "0" ]]; then + printf 'remote sudo password is required for automatic reconfigure; set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD or leave LESAVKA_SERVER_RC_PROMPT_SUDO_EARLY=1.\n' >&2 + exit 64 + fi + if [[ ! -t 0 ]]; then + printf 'remote sudo password is required, but stdin is not a terminal.\n' >&2 + printf 'Re-run from a terminal or set LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD in the environment.\n' >&2 + exit 64 + fi + + printf 'Theia sudo password for %s: ' "${LESAVKA_SERVER_HOST}" >&2 + IFS= read -r -s LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD + printf '\n' >&2 +} + +run_remote_root_script() { + local description=$1 + shift + local script_file remote_wrapper ssh_cmd status + script_file="$(mktemp)" + cat >"${script_file}" + + remote_wrapper='set -euo pipefail +read -r __lesavka_sudo_password +__lesavka_script=$(mktemp) +cleanup() { rm -f "$__lesavka_script"; } +trap cleanup EXIT +cat >"$__lesavka_script" +printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" -v +printf "%s\n" "$__lesavka_sudo_password" | sudo -S -p "" bash "$__lesavka_script" "$@" +' + printf -v ssh_cmd 'bash -c %q _' "${remote_wrapper}" + for arg in "$@"; do + printf -v ssh_cmd '%s %q' "${ssh_cmd}" "${arg}" + done + + set +e + { + printf '%s\n' "${LESAVKA_SERVER_RC_REMOTE_SUDO_PASSWORD}" + cat "${script_file}" + } | ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${ssh_cmd}" + status=$? + set -e + rm -f "${script_file}" + if [[ "${status}" -ne 0 ]]; then + printf '%s failed on %s with exit %s\n' "${description}" "${LESAVKA_SERVER_HOST}" "${status}" >&2 + fi + return "${status}" +} + +prime_remote_sudo() { + [[ "${LESAVKA_SERVER_RC_RECONFIGURE}" != "0" ]] || return 0 + [[ -z "${LESAVKA_SERVER_RC_RECONFIGURE_COMMAND}" ]] || return 0 + ensure_remote_sudo_password + echo "==> priming remote sudo on ${LESAVKA_SERVER_HOST}" + run_remote_root_script "remote sudo prime" <<'REMOTE_SUDO_PRIME' +set -euo pipefail +true +REMOTE_SUDO_PRIME +} + +prebuild_probe_tools() { + [[ "${LESAVKA_SERVER_RC_PROBE_PREBUILD}" != "0" ]] || return 0 + echo "==> prebuilding relay control/analyzer once for the mode matrix" + ( + cd "${REPO_ROOT}" + cargo build -p lesavka_client --bin lesavka-sync-analyze --bin lesavka-relayctl + ) } reconfigure_server_mode() { @@ -112,71 +344,281 @@ reconfigure_server_mode() { return 0 fi - local prepare_output repo interval remote_cmd - prepare_output="$( - ssh ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" bash -s -- \ - "${LESAVKA_SERVER_REPO}" \ - "${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \ - "${LESAVKA_SERVER_RC_RECONFIGURE_UPDATE}" <<'REMOTE_PREPARE' -set -euo pipefail -repo=$1 -ref=$2 -update_checkout=$3 -if [[ "${repo}" == "auto" ]]; then - for candidate in \ - "${HOME}/Development/lesavka" \ - /home/theia/Development/lesavka \ - /home/brad/Development/lesavka \ - "${HOME}/lesavka" \ - /opt/lesavka \ - /srv/lesavka \ - /tmp/lesavka-server-rc-matrix-source - do - if [[ -d "${candidate}/.git" && -f "${candidate}/scripts/install/server.sh" ]]; then - repo="${candidate}" - break - fi - done -fi -if [[ "${repo}" == "auto" || ! -f "${repo}/scripts/install/server.sh" ]]; then - printf 'could not find a Lesavka checkout on this server host.\n' >&2 - printf 'Set LESAVKA_SERVER_REPO=/path/to/lesavka, or create one of the standard checkout paths.\n' >&2 - exit 65 -fi -printf ' ↪ using server repo: %s\n' "${repo}" -cd "${repo}" -if [[ "${update_checkout}" != "0" ]]; then - if [[ -n "$(git status --porcelain)" ]]; then - printf 'server checkout has local changes; refusing to update it for the mode matrix.\n' >&2 - printf 'Run with LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=0, or clean/update the checkout intentionally.\n' >&2 - exit 66 - fi - git fetch --all --prune - git checkout "${ref}" - git pull --ff-only -else - printf ' ↪ leaving server checkout untouched; set LESAVKA_SERVER_RC_RECONFIGURE_UPDATE=1 to update it first\n' -fi -printf '__LESAVKA_SERVER_REPO__=%s\n' "${repo}" -REMOTE_PREPARE - )" - printf '%s\n' "${prepare_output}" | sed '/^__LESAVKA_SERVER_REPO__=/d' - repo="$(awk -F= '/^__LESAVKA_SERVER_REPO__=/{print $2; exit}' <<<"${prepare_output}")" - if [[ -z "${repo}" ]]; then - printf 'could not determine the prepared server repo path on %s\n' "${LESAVKA_SERVER_HOST}" >&2 - return 65 + if [[ "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" != "runtime" ]]; then + printf 'unsupported LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY=%s\n' "${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY}" >&2 + printf 'Use runtime for fast mode changes, or LESAVKA_SERVER_RC_RECONFIGURE_COMMAND for a custom install/reconfigure command.\n' >&2 + return 64 fi + local interval interval=$((10000000 / fps)) - printf -v remote_cmd \ - 'cd %q && sudo env LESAVKA_REF=%q LESAVKA_INSTALL_UVC_CODEC=mjpeg LESAVKA_UVC_WIDTH=%q LESAVKA_UVC_HEIGHT=%q LESAVKA_UVC_FPS=%q LESAVKA_UVC_INTERVAL=%q ./scripts/install/server.sh' \ - "${repo}" \ - "${LESAVKA_SERVER_RC_RECONFIGURE_REF}" \ + run_remote_root_script "runtime UVC reconfigure for ${mode}" \ + "${mode}" \ "${width}" \ "${height}" \ "${fps}" \ - "${interval}" - ssh -tt ${SSH_OPTS} "${LESAVKA_SERVER_HOST}" "${remote_cmd}" + "${interval}" \ + "${LESAVKA_SERVER_RC_RECONFIGURE_CODEC}" \ + "${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" \ + "${LESAVKA_SERVER_RC_FORCE_GADGET_REBUILD}" \ + "${LESAVKA_SERVER_RC_RECONFIGURE_SETTLE_SECONDS}" \ + "${LESAVKA_SERVER_RC_RECONFIGURE_VERBOSE}" <<'REMOTE_RECONFIGURE' +set -euo pipefail +mode=$1 +width=$2 +height=$3 +fps=$4 +interval=$5 +codec=$6 +allow_gadget_reset=$7 +force_gadget_rebuild=$8 +settle_seconds=$9 +verbose=${10} + +set_env_value() { + local file=$1 + local key=$2 + local value=$3 + local tmp + tmp=$(mktemp) + if [[ -f "${file}" ]]; then + awk -v key="${key}" -v value="${value}" ' + BEGIN { wrote = 0 } + $0 ~ "^" key "=" { + print key "=" value + wrote = 1 + next + } + { print } + END { + if (!wrote) { + print key "=" value + } + } + ' "${file}" >"${tmp}" + else + printf '%s=%s\n' "${key}" "${value}" >"${tmp}" + fi + install -m 0644 "${tmp}" "${file}" + rm -f "${tmp}" +} + +if [[ ! -x /usr/local/bin/lesavka-core.sh ]]; then + printf 'missing /usr/local/bin/lesavka-core.sh; run the server installer once before using fast runtime reconfigure.\n' >&2 + exit 65 +fi +if [[ ! -x /usr/local/bin/lesavka-server || ! -x /usr/local/bin/lesavka-uvc ]]; then + printf 'missing installed Lesavka binaries; run the server installer once before using fast runtime reconfigure.\n' >&2 + exit 65 +fi + +install -d -m 0755 /etc/lesavka +touch /etc/lesavka/server.env /etc/lesavka/uvc.env + +set_env_value /etc/lesavka/server.env LESAVKA_CAM_OUTPUT uvc +set_env_value /etc/lesavka/server.env LESAVKA_UVC_CODEC "${codec}" +set_env_value /etc/lesavka/server.env LESAVKA_UVC_WIDTH "${width}" +set_env_value /etc/lesavka/server.env LESAVKA_UVC_HEIGHT "${height}" +set_env_value /etc/lesavka/server.env LESAVKA_UVC_FPS "${fps}" +set_env_value /etc/lesavka/server.env LESAVKA_UVC_INTERVAL "${interval}" + +set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_CODEC "${codec}" +set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_WIDTH "${width}" +set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_HEIGHT "${height}" +set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_FPS "${fps}" +set_env_value /etc/lesavka/uvc.env LESAVKA_UVC_INTERVAL "${interval}" + +printf ' ↪ fast runtime env updated: CAM_OUTPUT=uvc UVC_MODE=%s codec=%s\n' "${mode}" "${codec}" +systemctl daemon-reload +systemctl stop lesavka-server >/dev/null 2>&1 || true +systemctl stop lesavka-uvc >/dev/null 2>&1 || true +systemctl reset-failed lesavka-core lesavka-uvc lesavka-server >/dev/null 2>&1 || true + +if [[ "${allow_gadget_reset}" != "0" && "${force_gadget_rebuild}" != "0" ]]; then + printf ' ↪ cycling UVC gadget descriptors for %s\n' "${mode}" + mode_log_id=$(printf '%s' "${mode}" | tr -c '[:alnum:]_.-' '_') + core_log="/tmp/lesavka-core-reconfigure-${mode_log_id}.log" + core_cmd=( + env + LESAVKA_ALLOW_GADGET_RESET=1 \ + LESAVKA_FORCE_GADGET_REBUILD=1 \ + LESAVKA_ATTACH_WRITE_UDC=1 \ + LESAVKA_DETACH_CLEAR_UDC=1 \ + LESAVKA_UVC_FALLBACK=0 \ + LESAVKA_UVC_CODEC="${codec}" \ + LESAVKA_UVC_WIDTH="${width}" \ + LESAVKA_UVC_HEIGHT="${height}" \ + LESAVKA_UVC_FPS="${fps}" \ + LESAVKA_UVC_INTERVAL="${interval}" \ + /usr/local/bin/lesavka-core.sh + ) + if [[ "${verbose}" != "0" ]]; then + "${core_cmd[@]}" + else + if "${core_cmd[@]}" >"${core_log}" 2>&1; then + printf ' ↪ lesavka-core reconfigure log: %s\n' "${core_log}" + else + rc=$? + printf 'lesavka-core reconfigure failed; tail of %s follows\n' "${core_log}" >&2 + tail -n 80 "${core_log}" >&2 || true + exit "${rc}" + fi + fi +elif [[ "${allow_gadget_reset}" != "0" ]]; then + printf ' ↪ gadget reset allowed, but force rebuild disabled; refreshing services only\n' +else + printf ' ↪ preserving attached UVC gadget; descriptors may remain on the previous mode\n' +fi + +systemctl start lesavka-uvc +systemctl restart lesavka-server +sleep "${settle_seconds}" +systemctl is-active lesavka-core lesavka-uvc lesavka-server >/dev/null +printf ' ↪ services active after %s reconfigure\n' "${mode}" +REMOTE_RECONFIGURE +} + +wait_tethys_media_ready() { + local mode=$1 + local width=$2 + local height=$3 + local fps=$4 + [[ "${LESAVKA_SERVER_RC_WAIT_TETHYS_READY}" != "0" ]] || return 0 + + echo "==> waiting for Tethys media endpoints for ${mode}" + ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ + "${mode}" \ + "${width}" \ + "${height}" \ + "${fps}" \ + "${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}" \ + "${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}" \ + "${REMOTE_CAPTURE_STACK}" \ + "${REMOTE_AUDIO_SOURCE}" <<'REMOTE_TETHYS_READY' +set -euo pipefail +mode=$1 +width=$2 +height=$3 +fps=$4 +timeout_seconds=$5 +settle_seconds=$6 +capture_stack=$7 +audio_source=$8 + +sleep "${settle_seconds}" + +find_lesavka_video_device() { + if [[ -d /dev/v4l/by-id ]]; then + while IFS= read -r path; do + [[ -e "${path}" ]] || continue + printf '%s\n' "${path}" + return 0 + done < <(find /dev/v4l/by-id -maxdepth 1 -type l -name '*Lesavka*video-index0' 2>/dev/null | sort) + fi + + if command -v v4l2-ctl >/dev/null 2>&1; then + v4l2-ctl --list-devices 2>/dev/null \ + | awk ' + BEGIN { want=0 } + /Lesavka Composite|Lesavka.*UVC/ { want=1; next } + /^[^ \t]/ { want=0 } + want && /^[ \t]+\/dev\/video[0-9]+/ { + gsub(/^[ \t]+/, "", $0) + print + exit + } + ' + fi +} + +video_ready() { + local dev=$1 + [[ -n "${dev}" ]] || return 1 + [[ -e "${dev}" ]] || return 1 + if ! command -v v4l2-ctl >/dev/null 2>&1; then + return 0 + fi + + local listing + listing="$(v4l2-ctl -d "${dev}" --list-formats-ext 2>/dev/null || true)" + grep -q "Size: Discrete ${width}x${height}" <<<"${listing}" || return 1 + grep -Eq "(^|[^0-9])${fps}(\\.0+)? fps" <<<"${listing}" || return 1 +} + +pulse_ready() { + if ! command -v pactl >/dev/null 2>&1; then + return 1 + fi + if [[ "${audio_source}" == pulse:* ]]; then + local requested=${audio_source#pulse:} + pactl list short sources 2>/dev/null | awk -v requested="${requested}" '$2 == requested { found=1 } END { exit found ? 0 : 1 }' + return + fi + pactl list short sources 2>/dev/null | grep -Eq 'alsa_input\..*Lesavka_Composite|Lesavka_Composite' +} + +alsa_ready() { + if [[ "${audio_source}" == alsa:* ]]; then + [[ -e "${audio_source#alsa:}" ]] || return 1 + return 0 + fi + command -v arecord >/dev/null 2>&1 || return 1 + arecord -l 2>/dev/null | grep -Eq 'Lesavka|UAC2_Gadget|UAC2Gadget|Composite' +} + +audio_ready() { + case "${capture_stack}" in + pulse) + pulse_ready + ;; + alsa) + alsa_ready + ;; + auto) + pulse_ready || alsa_ready + ;; + pwpipe) + command -v pw-dump >/dev/null 2>&1 && pw-dump 2>/dev/null | grep -Eq 'Lesavka_Composite|Lesavka Composite' + ;; + *) + pulse_ready || alsa_ready + ;; + esac +} + +deadline=$((SECONDS + timeout_seconds)) +last_video='none' +last_audio='none' +while (( SECONDS <= deadline )); do + video_dev="$(find_lesavka_video_device || true)" + last_video="${video_dev:-none}" + if [[ -n "${video_dev}" ]] && video_ready "${video_dev}"; then + if audio_ready; then + printf ' ↪ Tethys media ready: video=%s mode=%s audio_stack=%s\n' "${video_dev}" "${mode}" "${capture_stack}" + exit 0 + fi + last_audio='not-ready' + fi + sleep 0.5 +done + +printf 'timed out waiting for Tethys Lesavka media endpoints for %s after %ss\n' "${mode}" "${timeout_seconds}" >&2 +printf 'last video candidate: %s\n' "${last_video}" >&2 +printf 'last audio candidate: %s\n' "${last_audio}" >&2 +if command -v v4l2-ctl >/dev/null 2>&1; then + v4l2-ctl --list-devices >&2 || true + if [[ "${last_video}" != "none" ]]; then + v4l2-ctl -d "${last_video}" --list-formats-ext >&2 || true + fi +fi +if command -v pactl >/dev/null 2>&1; then + pactl list short sources | grep -iE 'lesavka|uac|composite' >&2 || true +fi +if command -v arecord >/dev/null 2>&1; then + arecord -l >&2 || true +fi +exit 70 +REMOTE_TETHYS_READY } write_mode_result() { @@ -185,10 +627,11 @@ write_mode_result() { local height=$3 local fps=$4 local video_delay_us=$5 - local run_status=$6 - local run_log=$7 - local artifact_dir=$8 - local output_json=$9 + local audio_delay_us=$6 + local run_status=$7 + local run_log=$8 + local artifact_dir=$9 + local output_json=${10} python3 - <<'PY' \ "${mode}" \ @@ -196,6 +639,7 @@ write_mode_result() { "${height}" \ "${fps}" \ "${video_delay_us}" \ + "${audio_delay_us}" \ "${run_status}" \ "${run_log}" \ "${artifact_dir}" \ @@ -221,6 +665,7 @@ import sys height_raw, fps_raw, video_delay_raw, + audio_delay_raw, run_status_raw, run_log, artifact_dir_raw, @@ -276,6 +721,7 @@ def nested(mapping, *keys, default=None): artifact_dir = pathlib.Path(artifact_dir_raw) if artifact_dir_raw else pathlib.Path() report = load_json(artifact_dir / "report.json") correlation = load_json(artifact_dir / "output-delay-correlation.json") +calibration = load_json(artifact_dir / "output-delay-calibration.json") freshness = correlation.get("freshness") or {} smoothness = correlation.get("smoothness") or {} video = smoothness.get("video") or {} @@ -328,13 +774,14 @@ artifact = { "width": as_int(width_raw), "height": as_int(height_raw), "fps": as_int(fps_raw), - "audio_delay_us": 0, + "audio_delay_us": as_int(audio_delay_raw), "video_delay_us": as_int(video_delay_raw), "run_status": run_status, "run_log": run_log, "artifact_dir": str(artifact_dir), "report_json": str(artifact_dir / "report.json"), "correlation_json": str(artifact_dir / "output-delay-correlation.json"), + "calibration_json": str(artifact_dir / "output-delay-calibration.json"), "passed": not reasons, "failure_reasons": reasons, "sync": { @@ -371,6 +818,21 @@ artifact = { "audio_low_rms_windows": low_rms, "audio_median_rms": as_float(nested(audio_rms, "rms_stats", "median_rms"), 0.0), }, + "output_delay_calibration": { + "ready": calibration.get("ready") is True, + "decision": calibration.get("decision", "unknown"), + "target": calibration.get("target", ""), + "paired_event_count": as_int(calibration.get("paired_event_count"), 0), + "measured_device_skew_ms": as_float(calibration.get("measured_device_skew_ms"), 0.0), + "p95_abs_skew_ms": as_float(calibration.get("p95_abs_skew_ms"), 0.0), + "max_abs_skew_ms": as_float(calibration.get("max_abs_skew_ms"), 0.0), + "drift_ms": as_float(calibration.get("drift_ms"), 0.0), + "audio_offset_adjust_us": as_int(calibration.get("audio_offset_adjust_us"), 0), + "video_offset_adjust_us": as_int(calibration.get("video_offset_adjust_us"), 0), + "audio_target_offset_us": as_int(calibration.get("audio_target_offset_us"), 0), + "video_target_offset_us": as_int(calibration.get("video_target_offset_us"), 0), + "note": calibration.get("note", ""), + }, } pathlib.Path(output_json).write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") PY @@ -407,6 +869,7 @@ fieldnames = [ "mode", "passed", "video_delay_us", + "audio_delay_us", "sync_status", "p95_abs_skew_ms", "median_skew_ms", @@ -419,6 +882,12 @@ fieldnames = [ "video_undecodable", "audio_hiccups", "audio_low_rms", + "calibration_ready", + "calibration_decision", + "calibration_target", + "calibration_measured_skew_ms", + "calibration_video_target_offset_us", + "calibration_audio_target_offset_us", "artifact_dir", ] with summary_csv.open("w", newline="", encoding="utf-8") as handle: @@ -429,6 +898,7 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle: "mode": result.get("mode"), "passed": result.get("passed"), "video_delay_us": result.get("video_delay_us"), + "audio_delay_us": result.get("audio_delay_us"), "sync_status": (result.get("sync") or {}).get("status"), "p95_abs_skew_ms": (result.get("sync") or {}).get("p95_abs_skew_ms"), "median_skew_ms": (result.get("sync") or {}).get("median_skew_ms"), @@ -441,6 +911,12 @@ with summary_csv.open("w", newline="", encoding="utf-8") as handle: "video_undecodable": (result.get("smoothness") or {}).get("video_undecodable_frames"), "audio_hiccups": (result.get("smoothness") or {}).get("audio_hiccups"), "audio_low_rms": (result.get("smoothness") or {}).get("audio_low_rms_windows"), + "calibration_ready": (result.get("output_delay_calibration") or {}).get("ready"), + "calibration_decision": (result.get("output_delay_calibration") or {}).get("decision"), + "calibration_target": (result.get("output_delay_calibration") or {}).get("target"), + "calibration_measured_skew_ms": (result.get("output_delay_calibration") or {}).get("measured_device_skew_ms"), + "calibration_video_target_offset_us": (result.get("output_delay_calibration") or {}).get("video_target_offset_us"), + "calibration_audio_target_offset_us": (result.get("output_delay_calibration") or {}).get("audio_target_offset_us"), "artifact_dir": result.get("artifact_dir"), }) @@ -453,13 +929,22 @@ for result in results: sync = result.get("sync") or {} freshness = result.get("freshness") or {} smooth = result.get("smoothness") or {} + calibration = result.get("output_delay_calibration") or {} marker = "PASS" if result.get("passed") else "FAIL" lines.append( f"- {marker} {result.get('mode')}: " + f"delays video={result.get('video_delay_us', 0)}us audio={result.get('audio_delay_us', 0)}us; " f"sync {sync.get('status')} p95={sync.get('p95_abs_skew_ms', 0.0):.1f}ms median={sync.get('median_skew_ms', 0.0):+.1f}ms; " f"freshness {freshness.get('status')} budget={freshness.get('worst_event_age_with_uncertainty_ms') or 0.0:.1f}ms; " f"smooth video hiccups={smooth.get('video_hiccups', 0)} missing={smooth.get('video_estimated_missing_frames', 0)} undecodable={smooth.get('video_undecodable_frames', 0)} audio hiccups={smooth.get('audio_hiccups', 0)}" ) + lines.append( + " calibration: " + f"{calibration.get('decision', 'unknown')} ready={calibration.get('ready', False)} " + f"target={calibration.get('target', '')} measured={calibration.get('measured_device_skew_ms', 0.0):+.1f}ms " + f"video_target={calibration.get('video_target_offset_us', 0)}us " + f"audio_target={calibration.get('audio_target_offset_us', 0)}us" + ) for reason in result.get("failure_reasons") or []: lines.append(f" reason: {reason}") summary_txt.write_text("\n".join(lines) + "\n") @@ -467,26 +952,41 @@ print("\n".join(lines)) PY } +if [[ "${LESAVKA_SERVER_RC_MODES}" == "auto" ]]; then + echo "==> discovering local webcam-backed Lesavka modes" + LESAVKA_SERVER_RC_MODES="$(discover_local_webcam_modes)" + LESAVKA_SERVER_RC_MODE_SOURCE=local-v4l2 +fi + echo "==> server-to-RC mode matrix" echo " ↪ modes=${LESAVKA_SERVER_RC_MODES}" -echo " ↪ delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}" +echo " ↪ mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}" +echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}" +echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}" +echo " ↪ reconfigure=${LESAVKA_SERVER_RC_RECONFIGURE} strategy=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY} allow_gadget_reset=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" +echo " ↪ tethys_ready=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY} settle=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}s timeout=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}s preroll_discard=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}s" echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}" +prime_remote_sudo +prebuild_probe_tools + IFS=',' read -r -a modes <<<"${LESAVKA_SERVER_RC_MODES}" for mode in "${modes[@]}"; do mode="${mode//[[:space:]]/}" [[ -n "${mode}" ]] || continue read -r width height fps < <(parse_mode "${mode}") video_delay_us="$(lookup_video_delay_us "${mode}")" + audio_delay_us="$(lookup_audio_delay_us "${mode}")" id="$(mode_id "${mode}")" mode_dir="${MATRIX_REPORT_DIR}/${id}" mode_log="${mode_dir}/mode-run.log" mode_result="${mode_dir}/mode-result.json" mkdir -p "${mode_dir}" - echo "==> mode ${mode}: video_delay_us=${video_delay_us}" + echo "==> mode ${mode}: video_delay_us=${video_delay_us} audio_delay_us=${audio_delay_us}" reconfigure_server_mode "${mode}" "${width}" "${height}" "${fps}" + wait_tethys_media_ready "${mode}" "${width}" "${height}" "${fps}" set +e TETHYS_HOST="${TETHYS_HOST}" \ @@ -500,13 +1000,15 @@ for mode in "${modes[@]}"; do REMOTE_PULSE_VIDEO_MODE="${REMOTE_PULSE_VIDEO_MODE}" \ REMOTE_CAPTURE_STACK="${REMOTE_CAPTURE_STACK}" \ REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \ + REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS="${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}" \ + PROBE_PREBUILD=0 \ VIDEO_SIZE="${width}x${height}" \ VIDEO_FPS="${fps}" \ VIDEO_FORMAT=mjpeg \ REMOTE_EXPECT_UVC_WIDTH="${width}" \ REMOTE_EXPECT_UVC_HEIGHT="${height}" \ REMOTE_EXPECT_UVC_FPS="${fps}" \ - LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US}" \ + LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US="${audio_delay_us}" \ LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US="${video_delay_us}" \ LESAVKA_OUTPUT_DELAY_APPLY=0 \ LESAVKA_OUTPUT_DELAY_SAVE=0 \ @@ -526,7 +1028,7 @@ for mode in "${modes[@]}"; do if [[ -z "${artifact_dir}" ]]; then artifact_dir="$(find "${mode_dir}" -mindepth 1 -maxdepth 1 -type d -name 'lesavka-output-delay-probe-*' | sort | tail -n1 || true)" fi - write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}" + write_mode_result "${mode}" "${width}" "${height}" "${fps}" "${video_delay_us}" "${audio_delay_us}" "${run_status}" "${mode_log}" "${artifact_dir}" "${mode_result}" if [[ "${run_status}" -ne 0 && "${LESAVKA_SERVER_RC_CONTINUE_ON_FAIL}" == "0" ]]; then break diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 6bd9046..249f22f 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -41,8 +41,11 @@ REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst} REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-copy} REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto} REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto} +REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0} ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0} ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280} +ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1} +ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS=${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS:-1.0} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"} PROBE_PREBUILD=${PROBE_PREBUILD:-1} ANALYZE_BIN=${ANALYZE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-analyze"} @@ -553,35 +556,40 @@ server_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/server.env) runtime_uvc_width=$(read_env_value "LESAVKA_UVC_WIDTH" /etc/lesavka/uvc.env) runtime_uvc_height=$(read_env_value "LESAVKA_UVC_HEIGHT" /etc/lesavka/uvc.env) runtime_uvc_fps=$(read_env_value "LESAVKA_UVC_FPS" /etc/lesavka/uvc.env) +effective_uvc_codec=${server_uvc_codec:-$runtime_uvc_codec} +effective_uvc_width=${server_uvc_width:-$runtime_uvc_width} +effective_uvc_height=${server_uvc_height:-$runtime_uvc_height} +effective_uvc_fps=${server_uvc_fps:-$runtime_uvc_fps} printf ' ↪ server.env CAM_OUTPUT=%s\n' "${cam_output:-}" printf ' ↪ server.env UVC_CODEC=%s\n' "${server_uvc_codec:-}" printf ' ↪ uvc.env UVC_CODEC=%s\n' "${runtime_uvc_codec:-}" printf ' ↪ server.env UVC_MODE=%sx%s@%s\n' "${server_uvc_width:-}" "${server_uvc_height:-}" "${server_uvc_fps:-}" printf ' ↪ uvc.env UVC_MODE=%sx%s@%s\n' "${runtime_uvc_width:-}" "${runtime_uvc_height:-}" "${runtime_uvc_fps:-}" +printf ' ↪ effective UVC_MODE=%sx%s@%s\n' "${effective_uvc_width:-}" "${effective_uvc_height:-}" "${effective_uvc_fps:-}" if [[ -n "${expect_cam_output}" && "${cam_output}" != "${expect_cam_output}" ]]; then printf 'expected CAM_OUTPUT=%s but found %s\n' "${expect_cam_output}" "${cam_output:-}" >&2 exit 64 fi -if [[ -n "${expect_uvc_codec}" && "${server_uvc_codec}" != "${expect_uvc_codec}" ]]; then - printf 'expected server.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${server_uvc_codec:-}" >&2 +if [[ -n "${expect_uvc_codec}" && "${effective_uvc_codec}" != "${expect_uvc_codec}" ]]; then + printf 'expected effective UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${effective_uvc_codec:-}" >&2 exit 65 fi if [[ -n "${expect_uvc_codec}" && "${runtime_uvc_codec}" != "${expect_uvc_codec}" ]]; then printf 'expected uvc.env UVC_CODEC=%s but found %s\n' "${expect_uvc_codec}" "${runtime_uvc_codec:-}" >&2 exit 66 fi -if [[ -n "${expect_uvc_width}" && "${server_uvc_width}" != "${expect_uvc_width}" ]]; then - printf 'expected server.env UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${server_uvc_width:-}" >&2 +if [[ -n "${expect_uvc_width}" && "${effective_uvc_width}" != "${expect_uvc_width}" ]]; then + printf 'expected effective UVC_WIDTH=%s but found %s\n' "${expect_uvc_width}" "${effective_uvc_width:-}" >&2 exit 67 fi -if [[ -n "${expect_uvc_height}" && "${server_uvc_height}" != "${expect_uvc_height}" ]]; then - printf 'expected server.env UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${server_uvc_height:-}" >&2 +if [[ -n "${expect_uvc_height}" && "${effective_uvc_height}" != "${expect_uvc_height}" ]]; then + printf 'expected effective UVC_HEIGHT=%s but found %s\n' "${expect_uvc_height}" "${effective_uvc_height:-}" >&2 exit 68 fi -if [[ -n "${expect_uvc_fps}" && "${server_uvc_fps}" != "${expect_uvc_fps}" ]]; then - printf 'expected server.env UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${server_uvc_fps:-}" >&2 +if [[ -n "${expect_uvc_fps}" && "${effective_uvc_fps}" != "${expect_uvc_fps}" ]]; then + printf 'expected effective UVC_FPS=%s but found %s\n' "${expect_uvc_fps}" "${effective_uvc_fps:-}" >&2 exit 69 fi if [[ -n "${expect_uvc_width}" && "${runtime_uvc_width}" != "${expect_uvc_width}" ]]; then @@ -851,6 +859,81 @@ timeline_path.write_text(json.dumps(timeline, indent=2, sort_keys=True) + "\n") PY } +compute_analysis_window_arg() { + [[ "${ANALYSIS_TIMELINE_WINDOW}" != "0" ]] || return 0 + [[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0 + [[ -f "${LOCAL_CAPTURE_LOG}" ]] || return 0 + [[ -f "${LOCAL_CLOCK_ALIGNMENT_JSON}" ]] || return 0 + + python3 - <<'PY' \ + "${LOCAL_SERVER_TIMELINE_JSON}" \ + "${LOCAL_CAPTURE_LOG}" \ + "${LOCAL_CLOCK_ALIGNMENT_JSON}" \ + "${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS}" || true +import json +import math +import pathlib +import sys + +timeline_path, capture_log_path, clock_path, padding_raw = sys.argv[1:] + + +def as_int(value): + try: + return int(str(value).strip()) + except Exception: + return None + + +def as_float(value, default): + try: + parsed = float(str(value).strip()) + except Exception: + return default + return parsed if math.isfinite(parsed) else default + + +def capture_start_ns(path): + try: + lines = pathlib.Path(path).read_text(errors="replace").splitlines() + except Exception: + return None + for line in lines: + if line.startswith("capture_start_unix_ns="): + return as_int(line.split("=", 1)[1]) + return None + + +try: + timeline = json.loads(pathlib.Path(timeline_path).read_text()) + clock = json.loads(pathlib.Path(clock_path).read_text()) +except Exception: + raise SystemExit(0) + +capture_start = capture_start_ns(capture_log_path) +offset = as_int(clock.get("theia_to_tethys_offset_ns")) if clock.get("available") else None +if capture_start is None or offset is None: + raise SystemExit(0) + +times = [] +for event in timeline.get("events", []): + for key in ("audio_push_unix_ns", "video_feed_unix_ns"): + event_ns = as_int(event.get(key)) + if event_ns is not None: + times.append((event_ns + offset - capture_start) / 1_000_000_000.0) + +if not times: + raise SystemExit(0) + +padding = max(0.0, as_float(padding_raw, 1.0)) +start = max(0.0, min(times) - padding) +end = max(times) + padding +if end <= start: + raise SystemExit(0) +print(f"--analysis-window-s {start:.3f}:{end:.3f}") +PY +} + write_output_delay_correlation() { [[ -f "${LOCAL_ANALYSIS_JSON}" ]] || return 0 [[ -f "${LOCAL_SERVER_TIMELINE_JSON}" ]] || return 0 @@ -998,6 +1081,7 @@ def stats(rows, key): "first_ms": None, "last_ms": None, "mean_ms": None, + "min_ms": None, "median_ms": None, "p95_ms": None, "max_ms": None, @@ -1008,6 +1092,7 @@ def stats(rows, key): "first_ms": values[0], "last_ms": values[-1], "mean_ms": sum(values) / len(values), + "min_ms": min(values), "median_ms": percentile(values, 50.0), "p95_ms": percentile(values, 95.0), "max_ms": max(values), @@ -1018,7 +1103,7 @@ def shift_stats(base, delta_ms): shifted = dict(base) if not shifted.get("available"): return shifted - for key in ["first_ms", "last_ms", "mean_ms", "median_ms", "p95_ms", "max_ms"]: + for key in ["first_ms", "last_ms", "mean_ms", "min_ms", "median_ms", "p95_ms", "max_ms"]: value = shifted.get(key) shifted[key] = value + delta_ms if value is not None else None shifted["intentional_delay_ms"] = delta_ms @@ -1480,6 +1565,8 @@ audio_freshness_stats = stats(joined, "audio_freshness_ms") video_event_age_stats = shift_stats(video_freshness_stats, video_delay_ms) audio_event_age_stats = shift_stats(audio_freshness_stats, audio_delay_ms) server_observed_correlation = correlation(joined, "server_feed_delta_ms", "observed_skew_ms") +sync_verdict = report.get("verdict") or {} +sync_passed = sync_verdict.get("passed") is True observed_drift = observed_model.get("drift_ms", 0.0) server_drift = server_model.get("drift_ms", 0.0) @@ -1532,15 +1619,37 @@ freshness_worst_event_with_uncertainty_ms = ( if freshness_worst_event_p95_ms is not None else None ) +event_age_min_values = [ + value + for value in [ + video_event_age_stats.get("min_ms"), + audio_event_age_stats.get("min_ms"), + ] + if value is not None and math.isfinite(value) +] +freshness_min_event_age_ms = min(event_age_min_values) if event_age_min_values else None if not event_age_p95_values: freshness_status = "unknown" freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available" +elif not sync_passed: + freshness_status = "unknown" + freshness_reason = ( + "sync did not pass, so freshness from paired signatures is not trustworthy: " + f"{sync_verdict.get('status', 'unknown')} - {sync_verdict.get('reason', '')}" + ) elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms: freshness_status = "unknown" freshness_reason = ( f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds " f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit" ) +elif freshness_min_event_age_ms is not None and freshness_min_event_age_ms < -clock_uncertainty_ms: + freshness_status = "invalid" + freshness_reason = ( + f"one media stream has impossible negative RC event age beyond clock uncertainty: " + f"minimum event age {freshness_min_event_age_ms:.1f} ms, uncertainty " + f"{clock_uncertainty_ms:.1f} ms" + ) elif freshness_worst_event_p95_ms < -clock_uncertainty_ms: freshness_status = "invalid" freshness_reason = ( @@ -1595,6 +1704,8 @@ artifact = { "worst_p95_path_freshness_ms": freshness_worst_p95_ms, "worst_event_age_p95_ms": freshness_worst_event_p95_ms, "worst_event_age_with_uncertainty_ms": freshness_worst_event_with_uncertainty_ms, + "minimum_event_age_ms": freshness_min_event_age_ms, + "sync_passed": sync_passed, "worst_p95_freshness_ms": freshness_worst_event_p95_ms, "worst_freshness_drift_ms": freshness_worst_drift_ms, "video_freshness_stats": video_freshness_stats, @@ -1867,6 +1978,7 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${REMOTE_PULSE_VIDEO_MODE}" \ "${REMOTE_AUDIO_SOURCE}" \ "${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \ + "${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \ > >(tee "${LOCAL_CAPTURE_LOG}") \ 2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' & set -euo pipefail @@ -1881,6 +1993,7 @@ remote_pulse_capture_tool=$8 remote_pulse_video_mode=$9 remote_audio_source=${10} remote_audio_quiesce_user_audio=${11} +remote_capture_preroll_discard_seconds=${12} rm -f "${remote_capture}" @@ -2213,7 +2326,6 @@ resolved_video_size="$(resolve_video_size "${video_size}")" resolved_video_fps="$(resolve_video_fps "${video_fps}")" printf 'using video device: %s\n' "${resolved_video_device}" >&2 printf 'using video mode: %s @ %s fps (%s)\n' "${resolved_video_size}" "${resolved_video_fps}" "${video_format:-driver-default}" >&2 -printf '%s\n' "__LESAVKA_CAPTURE_READY__" video_args=(-f video4linux2 -framerate "${resolved_video_fps}" -video_size "${resolved_video_size}") if [[ -n "${video_format}" ]]; then video_args+=(-input_format "${video_format}") @@ -2223,7 +2335,7 @@ gst_decode_chain="$(gst_video_decode_chain)" run_ffmpeg_capture() { local rc=0 - timeout --signal=INT "$((capture_seconds + 5))" "$@" || rc=$? + timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" "$@" &2 + case "${capture_mode}" in + pulse) + if command -v ffmpeg >/dev/null 2>&1; then + timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \ + -thread_queue_size 1024 \ + "${video_args[@]}" \ + -i "${resolved_video_device}" \ + -thread_queue_size 1024 \ + -f pulse \ + -i "${pulse_source}" \ + -t "${seconds}" \ + -c:v copy \ + -c:a pcm_s16le \ + "${discard}" /dev/null 2>&1 || true + fi + ;; + alsa) + if command -v ffmpeg >/dev/null 2>&1; then + timeout --kill-after=3 --signal=INT "$((seconds + 5))" ffmpeg -nostdin -hide_banner -loglevel error -y \ + -thread_queue_size 1024 \ + "${video_args[@]}" \ + -i "${resolved_video_device}" \ + -thread_queue_size 1024 \ + -f alsa -ac 2 -ar 48000 \ + -i "${alsa_audio_dev}" \ + -t "${seconds}" \ + -c:v copy \ + -c:a pcm_s16le \ + "${discard}" /dev/null 2>&1 || true + fi + ;; + *) + sleep "${seconds}" + ;; + esac + rm -f "${discard}" +} + +discard_preroll_capture "${remote_capture_preroll_discard_seconds}" + +printf '%s\n' "__LESAVKA_CAPTURE_READY__" printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2 if [[ "${capture_mode}" == "pwpipe" ]]; then @@ -2293,6 +2452,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then -thread_queue_size 1024 \ -f pulse \ -i "${pulse_source}" \ + -f lavfi \ + -i anullsrc=channel_layout=stereo:sample_rate=48000 \ + -filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \ + -map 0:v:0 \ + -map "[aout]" \ -t "${capture_seconds}" \ -c:v copy \ -c:a pcm_s16le \ @@ -2306,6 +2470,11 @@ elif [[ "${capture_mode}" == "pulse" ]]; then -thread_queue_size 1024 \ -f pulse \ -i "${pulse_source}" \ + -f lavfi \ + -i anullsrc=channel_layout=stereo:sample_rate=48000 \ + -filter_complex "[1:a][2:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]" \ + -map 0:v:0 \ + -map "[aout]" \ -t "${capture_seconds}" \ -vf "fps=${resolved_video_fps}" \ -c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \ @@ -2325,18 +2494,24 @@ elif [[ "${capture_mode}" == "pulse" ]]; then printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2 exit 64 fi - timeout --signal=INT "$((capture_seconds + 3))" \ + timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ v4l2src device="${resolved_video_device}" do-timestamp=true ! \ ${gst_source_caps} ! \ queue ! mux. \ + audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \ + audio/x-raw,rate=48000,channels=2 ! \ + queue ! mix. \ pulsesrc device="${pulse_source}" do-timestamp=true ! \ audio/x-raw,rate=48000,channels=2 ! \ - audioconvert ! audioresample ! queue ! mux. || true + audioconvert ! audioresample ! queue ! mix. \ + audiomixer name=mix ! \ + audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \ + queue ! mux. || true ;; cfr) - timeout --signal=INT "$((capture_seconds + 3))" \ + timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \ gst-launch-1.0 -q -e \ matroskamux name=mux ! filesink location="${remote_capture}" \ v4l2src device="${resolved_video_device}" do-timestamp=true ! \ @@ -2346,9 +2521,15 @@ elif [[ "${capture_mode}" == "pulse" ]]; then x264enc tune=zerolatency speed-preset=ultrafast key-int-max=1 bitrate=5000 ! \ h264parse ! \ queue ! mux. \ + audiotestsrc wave=silence is-live=true samplesperbuffer=480 ! \ + audio/x-raw,rate=48000,channels=2 ! \ + queue ! mix. \ pulsesrc device="${pulse_source}" do-timestamp=true ! \ audio/x-raw,rate=48000,channels=2 ! \ - audioconvert ! audioresample ! queue ! mux. || true + audioconvert ! audioresample ! queue ! mix. \ + audiomixer name=mix ! \ + audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \ + queue ! mux. || true ;; *) printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2 @@ -2475,9 +2656,13 @@ REMOTE_NORMALIZE_SCRIPT echo "==> copying sync analyzer to ${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}" scp ${SSH_OPTS} "${ANALYZE_BIN}" "${TETHYS_HOST}:${REMOTE_ANALYZE_BIN}" fi + analysis_window_arg="$(compute_analysis_window_arg)" + if [[ -n "${analysis_window_arg}" ]]; then + echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }" + fi echo "==> analyzing capture on ${TETHYS_HOST}" ssh ${SSH_OPTS} "${TETHYS_HOST}" \ - "chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}'" \ + "chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}' ${analysis_window_arg}" \ > "${LOCAL_ANALYSIS_JSON}" fi @@ -2570,9 +2755,14 @@ else exit 93 fi echo "==> analyzing capture" + analysis_window_arg="$(compute_analysis_window_arg)" + if [[ -n "${analysis_window_arg}" ]]; then + echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }" + fi ( cd "${REPO_ROOT}" - "${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}" + # shellcheck disable=SC2086 + "${ANALYZE_BIN}" "${LOCAL_CAPTURE}" --report-dir "${LOCAL_REPORT_DIR}" --event-width-codes "${PROBE_EVENT_WIDTH_CODES}" ${analysis_window_arg} ) fi diff --git a/server/Cargo.toml b/server/Cargo.toml index 407861c..97a578e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.19.14" +version = "0.19.15" edition = "2024" autobins = false diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index 3fbc2e7..ae83c52 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -56,12 +56,19 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000}", "LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}", "LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}", + "REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}", + "discarding %ss of post-enumeration capture before probe", + "ffmpeg -nostdin -hide_banner", + "\"$@\" priming remote sudo on ${LESAVKA_SERVER_HOST}", + "==> prebuilding relay control/analyzer once for the mode matrix", + "LESAVKA_SERVER_RC_MODES=auto", + "discover_local_webcam_modes", + "lookup_audio_delay_us", + "local webcam", + "mode_source=${LESAVKA_SERVER_RC_MODE_SOURCE}", + "video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}", + "audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}", + "fast runtime env updated: CAM_OUTPUT=uvc", + "cycling UVC gadget descriptors", + "lesavka-core reconfigure log:", + "missing /usr/local/bin/lesavka-core.sh", + "wait_tethys_media_ready", + "==> waiting for Tethys media endpoints for ${mode}", + "Tethys media ready: video=%s mode=%s audio_stack=%s", + "timed out waiting for Tethys Lesavka media endpoints", "LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}", "LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}", "LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}", @@ -207,13 +255,21 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() { "mode-matrix-summary.txt", "schema\": \"lesavka.server-rc-mode-result.v1\"", "schema\": \"lesavka.server-rc-mode-matrix-summary.v1\"", + "output_delay_calibration", + "calibration_ready", + "calibration_video_target_offset_us", + "calibration_audio_target_offset_us", + "calibration:", "REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"", "REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"", + "REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=\"${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}\"", + "PROBE_PREBUILD=0", "VIDEO_SIZE=\"${width}x${height}\"", "VIDEO_FPS=\"${fps}\"", "REMOTE_EXPECT_UVC_WIDTH=\"${width}\"", "REMOTE_EXPECT_UVC_HEIGHT=\"${height}\"", "REMOTE_EXPECT_UVC_FPS=\"${fps}\"", + "LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=\"${audio_delay_us}\"", "LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=\"${video_delay_us}\"", "LESAVKA_OUTPUT_DELAY_APPLY=0", "LESAVKA_OUTPUT_DELAY_SAVE=0",