diff --git a/Cargo.lock b/Cargo.lock index cc28233..ba27e2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.13.1" +version = "0.13.2" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.13.1" +version = "0.13.2" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.13.1" +version = "0.13.2" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index bf3fcbb..2ae9ef1 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.1" +version = "0.13.2" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 2dd7d47..85bb87c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.1" +version = "0.13.2" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 7231e00..e36c82e 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -98,6 +98,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_HDMI_CONNECTOR` | server hardware/device override | | `LESAVKA_HDMI_DRIVER` | server hardware/device override | | `LESAVKA_HDMI_FBDEV` | server hardware/device override | +| `LESAVKA_HDMI_PRESENTATION_DELAY_US` | server HDMI video latency override | | `LESAVKA_HDMI_HEIGHT` | server hardware/device override | | `LESAVKA_HDMI_MODES` | server hardware/device override | | `LESAVKA_HDMI_RESTORE_CRTC` | server hardware/device override | @@ -184,7 +185,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_UAC_BUFFER_TIME_US` | server audio sink latency override | | `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override | | `LESAVKA_UAC_DEV` | server hardware/device override | -| `LESAVKA_UAC_HDMI_COMPENSATION_US` | server audio sink latency override | +| `LESAVKA_UAC_HDMI_COMPENSATION_US` | server HDMI audio sink latency override | | `LESAVKA_UAC_LATENCY_TIME_US` | server audio sink latency override | | `LESAVKA_TEST_CAM_U32` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_CAP_CAMERA` | test/build contract variable; not runtime operator config | diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index ab9b393..5568cbb 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -278,7 +278,7 @@ }, "server/src/audio/voice_input.rs": { "line_percent": 100.0, - "loc": 358 + "loc": 360 }, "server/src/bin/lesavka_uvc/control_payloads.rs": { "line_percent": 100.0, @@ -410,7 +410,7 @@ }, "server/src/video_sinks/hdmi_sink.rs": { "line_percent": 100.0, - "loc": 393 + "loc": 423 }, "server/src/video_sinks/webcam_sink.rs": { "line_percent": 100.0, diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 91cbc0f..9ac68b0 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -433,8 +433,10 @@ fi printf 'LESAVKA_HDMI_SINK=%s\n' "${LESAVKA_HDMI_SINK:-fbdevsink}" printf 'LESAVKA_HDMI_FBDEV=%s\n' "${LESAVKA_HDMI_FBDEV:-/dev/fb0}" printf 'LESAVKA_HDMI_DRIVER=%s\n' "${LESAVKA_HDMI_DRIVER:-vc4}" + printf 'LESAVKA_HDMI_PRESENTATION_DELAY_US=%s\n' "${LESAVKA_HDMI_PRESENTATION_DELAY_US:-180000}" printf 'LESAVKA_UAC_DEV=%s\n' "${LESAVKA_UAC_DEV:-hw:UAC2Gadget,0}" printf 'LESAVKA_ALSA_DEV=%s\n' "${LESAVKA_ALSA_DEV:-hw:UAC2Gadget,0}" + printf 'LESAVKA_UAC_HDMI_COMPENSATION_US=%s\n' "${LESAVKA_UAC_HDMI_COMPENSATION_US:-0}" } | sudo tee /etc/lesavka/server.env >/dev/null echo "==> 6a. Systemd units - lesavka-core" diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 1f3d06b..db4146f 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -21,6 +21,7 @@ LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} VIDEO_SIZE=${VIDEO_SIZE:-1280x720} VIDEO_FPS=${VIDEO_FPS:-30} VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg} +REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"} mkdir -p "${LOCAL_OUTPUT_DIR}" @@ -33,13 +34,15 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${CAPTURE_SECONDS}" \ "${VIDEO_SIZE}" \ "${VIDEO_FPS}" \ - "${VIDEO_FORMAT}" <<'REMOTE_CAPTURE_SCRIPT' & + "${VIDEO_FORMAT}" \ + "${REMOTE_AUDIO_SOURCE}" <<'REMOTE_CAPTURE_SCRIPT' & set -euo pipefail remote_capture=$1 capture_seconds=$2 video_size=$3 video_fps=$4 video_format=$5 +remote_audio_source=$6 rm -f "${remote_capture}" video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${video_size}") @@ -47,17 +50,73 @@ if [[ -n "${video_format}" ]]; then video_args+=(-input_format "${video_format}") fi -ffmpeg -hide_banner -loglevel error -y \ - -thread_queue_size 1024 \ - "${video_args[@]}" \ - -i /dev/video0 \ - -thread_queue_size 1024 \ - -f alsa -ac 2 -ar 48000 \ - -i hw:3,0 \ - -t "${capture_seconds}" \ - -c:v ffv1 -level 3 -g 1 \ - -c:a pcm_s16le \ - "${remote_capture}" +resolve_pulse_source() { + if ! command -v pactl >/dev/null 2>&1; then + return 1 + fi + pactl list short sources 2>/dev/null \ + | awk ' + /alsa_input\..*Lesavka_Composite/ { print $2; found=1; exit } + /Lesavka_Composite/ && !fallback { fallback=$2 } + END { + if (found) exit 0 + if (fallback != "") { print fallback; exit 0 } + exit 1 + } + ' +} + +audio_mode="alsa" +alsa_audio_dev="hw:3,0" +pulse_source="" +if [[ "${remote_audio_source}" == "auto" ]]; then + if pulse_source="$(resolve_pulse_source)"; then + audio_mode="pipewire" + else + printf 'PipeWire Lesavka source not found; falling back to hw:3,0\n' >&2 + fi +elif [[ "${remote_audio_source}" == pulse:* ]]; then + audio_mode="pipewire" + pulse_source="${remote_audio_source#pulse:}" +elif [[ "${remote_audio_source}" == alsa:* ]]; then + alsa_audio_dev="${remote_audio_source#alsa:}" +else + printf 'unsupported REMOTE_AUDIO_SOURCE=%s\n' "${remote_audio_source}" >&2 + exit 64 +fi + +if [[ "${audio_mode}" == "pipewire" ]]; then + printf 'using PipeWire source: %s\n' "${pulse_source}" >&2 + pw-record --target "${pulse_source}" \ + --rate 48000 \ + --channels 2 \ + --format s16 \ + --latency 10ms \ + --raw - | \ + ffmpeg -hide_banner -loglevel error -y \ + -thread_queue_size 1024 \ + "${video_args[@]}" \ + -i /dev/video0 \ + -thread_queue_size 1024 \ + -f s16le -ac 2 -ar 48000 \ + -i pipe:0 \ + -t "${capture_seconds}" \ + -c:v ffv1 -level 3 -g 1 \ + -c:a pcm_s16le \ + "${remote_capture}" +else + ffmpeg -hide_banner -loglevel error -y \ + -thread_queue_size 1024 \ + "${video_args[@]}" \ + -i /dev/video0 \ + -thread_queue_size 1024 \ + -f alsa -ac 2 -ar 48000 \ + -i "${alsa_audio_dev}" \ + -t "${capture_seconds}" \ + -c:v ffv1 -level 3 -g 1 \ + -c:a pcm_s16le \ + "${remote_capture}" +fi REMOTE_CAPTURE_SCRIPT capture_pid=$! @@ -87,9 +146,13 @@ if [[ "${probe_status}" -ne 0 ]]; then exit "${probe_status}" fi if [[ "${capture_status}" -ne 0 ]]; then + if [[ "${capture_status}" -eq 141 && -f "${LOCAL_CAPTURE}" ]]; then + echo "Tethys capture ended with PipeWire SIGPIPE after ffmpeg closed; accepting preserved capture ${LOCAL_CAPTURE}" >&2 + else echo "Tethys capture failed with status ${capture_status}" >&2 [[ -f "${LOCAL_CAPTURE}" ]] && echo "partial capture preserved at ${LOCAL_CAPTURE}" >&2 exit "${capture_status}" + fi fi echo "==> analyzing capture" diff --git a/server/Cargo.toml b/server/Cargo.toml index 5d10b34..ff2704c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.1" +version = "0.13.2" edition = "2024" autobins = false diff --git a/server/src/audio/voice_input.rs b/server/src/audio/voice_input.rs index cb33e2f..b1ccc99 100644 --- a/server/src/audio/voice_input.rs +++ b/server/src/audio/voice_input.rs @@ -81,7 +81,9 @@ fn voice_sink_compensation_us() -> i64 { fn default_voice_sink_compensation_us() -> i64 { let cfg = crate::camera::current_camera_config(); if cfg.output == crate::camera::CameraOutput::Hdmi { - non_negative_voice_sink_timing_env("LESAVKA_UAC_HDMI_COMPENSATION_US", 105_000) + // HDMI now prefers MJPEG on hosts without hardware H.264 decode, which + // removed the old video-side lag that justified the large audio pad. + non_negative_voice_sink_timing_env("LESAVKA_UAC_HDMI_COMPENSATION_US", 0) } else { 0 } @@ -336,8 +338,8 @@ mod voice_sink_timing_tests { temp_env::with_var_unset("LESAVKA_UAC_HDMI_COMPENSATION_US", || { temp_env::with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { update_camera_config(); - assert_eq!(default_voice_sink_compensation_us(), 105_000); - assert_eq!(voice_sink_compensation_us(), 105_000); + assert_eq!(default_voice_sink_compensation_us(), 0); + assert_eq!(voice_sink_compensation_us(), 0); }); }); }); diff --git a/server/src/tests/runtime_support.rs b/server/src/tests/runtime_support.rs index e190934..b72f944 100644 --- a/server/src/tests/runtime_support.rs +++ b/server/src/tests/runtime_support.rs @@ -107,10 +107,7 @@ fn preferred_uac_candidates_include_detected_cards_before_alias_fallbacks() { ], || { let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0"); - assert_eq!( - candidates.first().map(String::as_str), - Some("hw:7,3") - ); + assert_eq!(candidates.first().map(String::as_str), Some("hw:7,3")); assert!(candidates.iter().any(|value| value == "plughw:7,3")); assert!( candidates diff --git a/server/src/video_sinks/hdmi_sink.rs b/server/src/video_sinks/hdmi_sink.rs index b840c91..66b1087 100644 --- a/server/src/video_sinks/hdmi_sink.rs +++ b/server/src/video_sinks/hdmi_sink.rs @@ -10,6 +10,7 @@ pub struct HdmiSink { pipe: gst::Pipeline, next_pts_us: AtomicU64, frame_step_us: u64, + presentation_delay_us: u64, } #[cfg(any(not(coverage), test))] @@ -17,6 +18,15 @@ fn hdmi_queue_buffers() -> u32 { crate::video_support::env_u32("LESAVKA_HDMI_QUEUE_BUFFERS", 1).max(1) } +#[cfg(any(not(coverage), test))] +fn hdmi_presentation_delay_us() -> u64 { + // The HDMI + capture-adapter path still lands video earlier than the UAC + // path on Tethys-style consumers, even after the stale audio holdback was + // removed. Start with a measured default that keeps A/V within one video + // frame instead of requiring per-host hand tuning. + crate::video_support::env_u32("LESAVKA_HDMI_PRESENTATION_DELAY_US", 180_000) as u64 +} + impl HdmiSink { /// Build a new HDMI sink pipeline. /// @@ -49,6 +59,7 @@ impl HdmiSink { pipe: pipeline, next_pts_us: AtomicU64::new(0), frame_step_us, + presentation_delay_us: 0, }) } @@ -82,6 +93,7 @@ impl HdmiSink { .build()?; let queue_depth = hdmi_queue_buffers(); + let presentation_delay_us = hdmi_presentation_delay_us(); let queue = gst::ElementFactory::make("queue") .property("max-size-buffers", queue_depth) .property("max-size-bytes", 0u32) @@ -94,6 +106,7 @@ impl HdmiSink { tracing::info!( target: "lesavka_server::video", queue_depth, + presentation_delay_us, "📺 HDMI sink queue depth armed" ); @@ -184,6 +197,7 @@ impl HdmiSink { pipe: pipeline, next_pts_us: AtomicU64::new(0), frame_step_us, + presentation_delay_us, }) } @@ -203,8 +217,11 @@ impl HdmiSink { pub fn push(&self, pkt: VideoPacket) { let mut buf = gst::Buffer::from_slice(pkt.data); if let Some(meta) = buf.get_mut() { - let pts_us = - crate::video_support::reserve_local_pts(&self.next_pts_us, pkt.pts, self.frame_step_us); + let pts_us = crate::video_support::reserve_local_pts( + &self.next_pts_us, + pkt.pts.saturating_add(self.presentation_delay_us), + self.frame_step_us, + ); let ts = gst::ClockTime::from_useconds(pts_us); meta.set_pts(Some(ts)); meta.set_dts(Some(ts)); @@ -369,7 +386,7 @@ fn read_bool_env(name: &str) -> Option { #[cfg(test)] mod hdmi_queue_tests { - use super::hdmi_queue_buffers; + use super::{hdmi_presentation_delay_us, hdmi_queue_buffers}; #[test] fn hdmi_queue_depth_defaults_to_one_frame() { @@ -390,4 +407,17 @@ mod hdmi_queue_tests { assert_eq!(hdmi_queue_buffers(), 1); }); } + + #[test] + fn hdmi_presentation_delay_defaults_to_measured_holdback_and_accepts_override() { + temp_env::with_var_unset("LESAVKA_HDMI_PRESENTATION_DELAY_US", || { + assert_eq!(hdmi_presentation_delay_us(), 180_000); + }); + temp_env::with_var("LESAVKA_HDMI_PRESENTATION_DELAY_US", Some("185000"), || { + assert_eq!(hdmi_presentation_delay_us(), 185_000); + }); + temp_env::with_var("LESAVKA_HDMI_PRESENTATION_DELAY_US", Some("nope"), || { + assert_eq!(hdmi_presentation_delay_us(), 180_000); + }); + } }