From 74706aaf32d601937fe65c04b614d84034eb7cd4 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 25 Apr 2026 02:47:29 -0300 Subject: [PATCH] fix(audio): simplify uac sink and prebuild sync harness --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/ci/quality_gate_baseline.json | 2 +- scripts/manual/run_upstream_av_sync.sh | 66 +++++++++++++++++- server/Cargo.toml | 2 +- server/src/audio/voice_input.rs | 95 +++++++++++++++++++------- 7 files changed, 139 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89df807..48b11a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.13.12" +version = "0.13.13" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.13.12" +version = "0.13.13" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.13.12" +version = "0.13.13" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 53a7efe..8e1510e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.12" +version = "0.13.13" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index b951f8a..98ef855 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.12" +version = "0.13.13" edition = "2024" build = "build.rs" diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 15c9e8e..aec6d71 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": 426 + "loc": 469 }, "server/src/bin/lesavka_uvc/control_payloads.rs": { "line_percent": 100.0, diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 1ca6d90..137588a 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -22,8 +22,12 @@ VIDEO_SIZE=${VIDEO_SIZE:-1280x720} VIDEO_FPS=${VIDEO_FPS:-30} VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg} REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto} +REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"} LOCAL_AUDIO_SANITY=${LOCAL_AUDIO_SANITY:-1} +PROBE_PREBUILD=${PROBE_PREBUILD:-1} +PROBE_BIN=${PROBE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-probe"} +ANALYZE_BIN=${ANALYZE_BIN:-"${REPO_ROOT}/target/debug/lesavka-sync-analyze"} mkdir -p "${LOCAL_OUTPUT_DIR}" STAMP="$(date +%Y%m%d-%H%M%S)" @@ -34,6 +38,23 @@ if [[ "${LOCAL_AUDIO_SANITY}" != "0" ]]; then "${SCRIPT_DIR}/run_local_audio_sanity.sh" fi +if [[ "${PROBE_PREBUILD}" != "0" ]]; then + echo "==> prebuilding sync probe/analyzer before opening the capture window" + ( + cd "${REPO_ROOT}" + cargo build -p lesavka_client --bin lesavka-sync-probe --bin lesavka-sync-analyze + ) +fi + +if [[ ! -x "${PROBE_BIN}" ]]; then + echo "sync probe binary not found at ${PROBE_BIN}" >&2 + exit 1 +fi +if [[ ! -x "${ANALYZE_BIN}" ]]; then + echo "sync analyzer binary not found at ${ANALYZE_BIN}" >&2 + exit 1 +fi + echo "==> starting Tethys capture on ${TETHYS_HOST}" ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${REMOTE_CAPTURE}" \ @@ -41,7 +62,8 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${VIDEO_SIZE}" \ "${VIDEO_FPS}" \ "${VIDEO_FORMAT}" \ - "${REMOTE_AUDIO_SOURCE}" <<'REMOTE_CAPTURE_SCRIPT' & + "${REMOTE_AUDIO_SOURCE}" \ + "${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" <<'REMOTE_CAPTURE_SCRIPT' & set -euo pipefail remote_capture=$1 capture_seconds=$2 @@ -49,6 +71,7 @@ video_size=$3 video_fps=$4 video_format=$5 remote_audio_source=$6 +remote_audio_quiesce_user_audio=$7 rm -f "${remote_capture}" video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${video_size}") @@ -56,6 +79,18 @@ if [[ -n "${video_format}" ]]; then video_args+=(-input_format "${video_format}") fi +restore_user_audio() { + systemctl --user start pipewire.socket pipewire-pulse.socket wireplumber.service >/dev/null 2>&1 || true + sleep 1 + systemctl --user start pipewire.service pipewire-pulse.service >/dev/null 2>&1 || true +} + +quiesce_user_audio() { + systemctl --user stop pipewire-pulse.service pipewire.service wireplumber.service \ + pipewire-pulse.socket pipewire.socket >/dev/null 2>&1 || true + sleep 1 +} + resolve_pulse_source() { if ! command -v pactl >/dev/null 2>&1; then return 1 @@ -91,6 +126,31 @@ else exit 64 fi +quiesce_for_alsa=0 +case "${remote_audio_quiesce_user_audio}" in + 1|true|yes) + quiesce_for_alsa=1 + ;; + auto) + if [[ "${audio_mode}" == "alsa" ]]; then + quiesce_for_alsa=1 + fi + ;; + 0|false|no) + quiesce_for_alsa=0 + ;; + *) + printf 'unsupported REMOTE_AUDIO_QUIESCE_USER_AUDIO=%s\n' "${remote_audio_quiesce_user_audio}" >&2 + exit 64 + ;; +esac + +if [[ "${quiesce_for_alsa}" == "1" ]]; then + printf 'quiescing Tethys user audio before raw ALSA capture\n' >&2 + quiesce_user_audio + trap restore_user_audio EXIT +fi + if [[ "${audio_mode}" == "pulse" ]]; then printf 'using Pulse source: %s\n' "${pulse_source}" >&2 ffmpeg -hide_banner -loglevel error -y \ @@ -126,7 +186,7 @@ echo "==> running local Lesavka sync probe against ${LESAVKA_SERVER_ADDR}" probe_status=0 ( cd "${REPO_ROOT}" - cargo run -p lesavka_client --bin lesavka-sync-probe -- \ + "${PROBE_BIN}" \ --server "${LESAVKA_SERVER_ADDR}" \ --duration-seconds "${PROBE_DURATION_SECONDS}" \ --warmup-seconds "${PROBE_WARMUP_SECONDS}" @@ -158,7 +218,7 @@ fi echo "==> analyzing capture" ( cd "${REPO_ROOT}" - cargo run -p lesavka_client --bin lesavka-sync-analyze -- "${LOCAL_CAPTURE}" + "${ANALYZE_BIN}" "${LOCAL_CAPTURE}" ) echo "==> done" diff --git a/server/Cargo.toml b/server/Cargo.toml index 32bb9da..0e35f58 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.12" +version = "0.13.13" edition = "2024" autobins = false diff --git a/server/src/audio/voice_input.rs b/server/src/audio/voice_input.rs index b4b7a92..4dbe5ee 100644 --- a/server/src/audio/voice_input.rs +++ b/server/src/audio/voice_input.rs @@ -119,6 +119,10 @@ fn voice_sink_session_clock_align_enabled() -> bool { .unwrap_or(true) } +fn voice_sink_delay_queue_enabled(compensation_us: i64) -> bool { + compensation_us > 0 +} + impl Voice { #[cfg(coverage)] pub async fn new(_alsa_dev: &str) -> anyhow::Result { @@ -210,24 +214,31 @@ impl Voice { let latency_time_us = voice_sink_latency_time_us(); let compensation_us = voice_sink_compensation_us(); let clock_align_enabled = voice_sink_session_clock_align_enabled(); + let compensation_ns = (compensation_us.max(0) as u64).saturating_mul(1_000); + let delay_queue_enabled = voice_sink_delay_queue_enabled(compensation_us); alsa_sink.set_property("device", alsa_dev); alsa_sink.set_property("sync", clock_align_enabled); - alsa_sink.set_property("async", clock_align_enabled); + // The UAC gadget is a live sink, so async preroll buys us little and + // has repeatedly stranded post-queue buffers behind a sink that never + // fully arms. Keep timestamp sync, but let the sink start immediately. + alsa_sink.set_property("async", false); alsa_sink.set_property("enable-last-sample", false); alsa_sink.set_property("provide-clock", false); alsa_sink.set_property("buffer-time", buffer_time_us); alsa_sink.set_property("latency-time", latency_time_us); - let compensation_ns = (compensation_us.max(0) as u64).saturating_mul(1_000); - delay_queue.set_property("max-size-buffers", 0u32); - delay_queue.set_property("max-size-bytes", 0u32); - delay_queue.set_property("max-size-time", compensation_ns); - delay_queue.set_property("min-threshold-time", compensation_ns); + if delay_queue_enabled { + delay_queue.set_property("max-size-buffers", 0u32); + delay_queue.set_property("max-size-bytes", 0u32); + delay_queue.set_property("max-size-time", compensation_ns); + delay_queue.set_property("min-threshold-time", compensation_ns); + } tracing::info!( %alsa_dev, buffer_time_us, latency_time_us, compensation_us, + delay_queue_enabled, clock_align_enabled, "🎤 UAC sink low-latency timing armed" ); @@ -235,27 +246,51 @@ impl Voice { crate::media_timing::prepare_pipeline_clock_sync(&pipeline); } - pipeline.add_many([ - appsrc.upcast_ref(), - &decodebin, - &convert, - &resample, - &capsfilter, - &level, - &delay_queue, - &post_level, - &alsa_sink, - ])?; + if delay_queue_enabled { + pipeline.add_many([ + appsrc.upcast_ref(), + &decodebin, + &convert, + &resample, + &capsfilter, + &level, + &delay_queue, + &post_level, + &alsa_sink, + ])?; + } else { + pipeline.add_many([ + appsrc.upcast_ref(), + &decodebin, + &convert, + &resample, + &capsfilter, + &level, + &post_level, + &alsa_sink, + ])?; + } appsrc.link(&decodebin)?; - gst::Element::link_many([ - &convert, - &resample, - &capsfilter, - &level, - &delay_queue, - &post_level, - &alsa_sink, - ])?; + if delay_queue_enabled { + gst::Element::link_many([ + &convert, + &resample, + &capsfilter, + &level, + &delay_queue, + &post_level, + &alsa_sink, + ])?; + } else { + gst::Element::link_many([ + &convert, + &resample, + &capsfilter, + &level, + &post_level, + &alsa_sink, + ])?; + } /*------------ decodebin autolink ----------------*/ let convert_sink = convert @@ -423,4 +458,12 @@ mod voice_sink_timing_tests { assert!(super::voice_sink_session_clock_align_enabled()); }); } + + #[test] + fn delay_queue_turns_on_only_for_positive_compensation() { + assert!(!super::voice_sink_delay_queue_enabled(-1)); + assert!(!super::voice_sink_delay_queue_enabled(0)); + assert!(super::voice_sink_delay_queue_enabled(1)); + assert!(super::voice_sink_delay_queue_enabled(90_000)); + } }