fix(audio): simplify uac sink and prebuild sync harness

This commit is contained in:
Brad Stein 2026-04-25 02:47:29 -03:00
parent caca212e71
commit 74706aaf32
7 changed files with 139 additions and 36 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.13.12" version = "0.13.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.13.12" version = "0.13.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.13.12" version = "0.13.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.13.12" version = "0.13.13"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.13.12" version = "0.13.13"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -278,7 +278,7 @@
}, },
"server/src/audio/voice_input.rs": { "server/src/audio/voice_input.rs": {
"line_percent": 100.0, "line_percent": 100.0,
"loc": 426 "loc": 469
}, },
"server/src/bin/lesavka_uvc/control_payloads.rs": { "server/src/bin/lesavka_uvc/control_payloads.rs": {
"line_percent": 100.0, "line_percent": 100.0,

View File

@ -22,8 +22,12 @@ VIDEO_SIZE=${VIDEO_SIZE:-1280x720}
VIDEO_FPS=${VIDEO_FPS:-30} VIDEO_FPS=${VIDEO_FPS:-30}
VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg} VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg}
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto} 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"} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
LOCAL_AUDIO_SANITY=${LOCAL_AUDIO_SANITY:-1} 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}" mkdir -p "${LOCAL_OUTPUT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)" 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" "${SCRIPT_DIR}/run_local_audio_sanity.sh"
fi 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}" echo "==> starting Tethys capture on ${TETHYS_HOST}"
ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
"${REMOTE_CAPTURE}" \ "${REMOTE_CAPTURE}" \
@ -41,7 +62,8 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
"${VIDEO_SIZE}" \ "${VIDEO_SIZE}" \
"${VIDEO_FPS}" \ "${VIDEO_FPS}" \
"${VIDEO_FORMAT}" \ "${VIDEO_FORMAT}" \
"${REMOTE_AUDIO_SOURCE}" <<'REMOTE_CAPTURE_SCRIPT' & "${REMOTE_AUDIO_SOURCE}" \
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" <<'REMOTE_CAPTURE_SCRIPT' &
set -euo pipefail set -euo pipefail
remote_capture=$1 remote_capture=$1
capture_seconds=$2 capture_seconds=$2
@ -49,6 +71,7 @@ video_size=$3
video_fps=$4 video_fps=$4
video_format=$5 video_format=$5
remote_audio_source=$6 remote_audio_source=$6
remote_audio_quiesce_user_audio=$7
rm -f "${remote_capture}" rm -f "${remote_capture}"
video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${video_size}") 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}") video_args+=(-input_format "${video_format}")
fi 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() { resolve_pulse_source() {
if ! command -v pactl >/dev/null 2>&1; then if ! command -v pactl >/dev/null 2>&1; then
return 1 return 1
@ -91,6 +126,31 @@ else
exit 64 exit 64
fi 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 if [[ "${audio_mode}" == "pulse" ]]; then
printf 'using Pulse source: %s\n' "${pulse_source}" >&2 printf 'using Pulse source: %s\n' "${pulse_source}" >&2
ffmpeg -hide_banner -loglevel error -y \ ffmpeg -hide_banner -loglevel error -y \
@ -126,7 +186,7 @@ echo "==> running local Lesavka sync probe against ${LESAVKA_SERVER_ADDR}"
probe_status=0 probe_status=0
( (
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
cargo run -p lesavka_client --bin lesavka-sync-probe -- \ "${PROBE_BIN}" \
--server "${LESAVKA_SERVER_ADDR}" \ --server "${LESAVKA_SERVER_ADDR}" \
--duration-seconds "${PROBE_DURATION_SECONDS}" \ --duration-seconds "${PROBE_DURATION_SECONDS}" \
--warmup-seconds "${PROBE_WARMUP_SECONDS}" --warmup-seconds "${PROBE_WARMUP_SECONDS}"
@ -158,7 +218,7 @@ fi
echo "==> analyzing capture" echo "==> analyzing capture"
( (
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
cargo run -p lesavka_client --bin lesavka-sync-analyze -- "${LOCAL_CAPTURE}" "${ANALYZE_BIN}" "${LOCAL_CAPTURE}"
) )
echo "==> done" echo "==> done"

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.13.12" version = "0.13.13"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -119,6 +119,10 @@ fn voice_sink_session_clock_align_enabled() -> bool {
.unwrap_or(true) .unwrap_or(true)
} }
fn voice_sink_delay_queue_enabled(compensation_us: i64) -> bool {
compensation_us > 0
}
impl Voice { impl Voice {
#[cfg(coverage)] #[cfg(coverage)]
pub async fn new(_alsa_dev: &str) -> anyhow::Result<Self> { pub async fn new(_alsa_dev: &str) -> anyhow::Result<Self> {
@ -210,24 +214,31 @@ impl Voice {
let latency_time_us = voice_sink_latency_time_us(); let latency_time_us = voice_sink_latency_time_us();
let compensation_us = voice_sink_compensation_us(); let compensation_us = voice_sink_compensation_us();
let clock_align_enabled = voice_sink_session_clock_align_enabled(); 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("device", alsa_dev);
alsa_sink.set_property("sync", clock_align_enabled); 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("enable-last-sample", false);
alsa_sink.set_property("provide-clock", false); alsa_sink.set_property("provide-clock", false);
alsa_sink.set_property("buffer-time", buffer_time_us); alsa_sink.set_property("buffer-time", buffer_time_us);
alsa_sink.set_property("latency-time", latency_time_us); alsa_sink.set_property("latency-time", latency_time_us);
let compensation_ns = (compensation_us.max(0) as u64).saturating_mul(1_000); if delay_queue_enabled {
delay_queue.set_property("max-size-buffers", 0u32); delay_queue.set_property("max-size-buffers", 0u32);
delay_queue.set_property("max-size-bytes", 0u32); delay_queue.set_property("max-size-bytes", 0u32);
delay_queue.set_property("max-size-time", compensation_ns); delay_queue.set_property("max-size-time", compensation_ns);
delay_queue.set_property("min-threshold-time", compensation_ns); delay_queue.set_property("min-threshold-time", compensation_ns);
}
tracing::info!( tracing::info!(
%alsa_dev, %alsa_dev,
buffer_time_us, buffer_time_us,
latency_time_us, latency_time_us,
compensation_us, compensation_us,
delay_queue_enabled,
clock_align_enabled, clock_align_enabled,
"🎤 UAC sink low-latency timing armed" "🎤 UAC sink low-latency timing armed"
); );
@ -235,27 +246,51 @@ impl Voice {
crate::media_timing::prepare_pipeline_clock_sync(&pipeline); crate::media_timing::prepare_pipeline_clock_sync(&pipeline);
} }
pipeline.add_many([ if delay_queue_enabled {
appsrc.upcast_ref(), pipeline.add_many([
&decodebin, appsrc.upcast_ref(),
&convert, &decodebin,
&resample, &convert,
&capsfilter, &resample,
&level, &capsfilter,
&delay_queue, &level,
&post_level, &delay_queue,
&alsa_sink, &post_level,
])?; &alsa_sink,
])?;
} else {
pipeline.add_many([
appsrc.upcast_ref(),
&decodebin,
&convert,
&resample,
&capsfilter,
&level,
&post_level,
&alsa_sink,
])?;
}
appsrc.link(&decodebin)?; appsrc.link(&decodebin)?;
gst::Element::link_many([ if delay_queue_enabled {
&convert, gst::Element::link_many([
&resample, &convert,
&capsfilter, &resample,
&level, &capsfilter,
&delay_queue, &level,
&post_level, &delay_queue,
&alsa_sink, &post_level,
])?; &alsa_sink,
])?;
} else {
gst::Element::link_many([
&convert,
&resample,
&capsfilter,
&level,
&post_level,
&alsa_sink,
])?;
}
/*------------ decodebin autolink ----------------*/ /*------------ decodebin autolink ----------------*/
let convert_sink = convert let convert_sink = convert
@ -423,4 +458,12 @@ mod voice_sink_timing_tests {
assert!(super::voice_sink_session_clock_align_enabled()); 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));
}
} }