diff --git a/Cargo.lock b/Cargo.lock index 48b11a5..fae6b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.13.13" +version = "0.13.14" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.13.13" +version = "0.13.14" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.13.13" +version = "0.13.14" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8e1510e..ec7b53f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.13" +version = "0.13.14" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 98ef855..ba2e962 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.13" +version = "0.13.14" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 19e83a5..6d78c90 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -187,7 +187,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_UAC_DEV` | server hardware/device override | | `LESAVKA_UAC_HDMI_COMPENSATION_US` | server HDMI audio sink latency override | | `LESAVKA_UAC_LATENCY_TIME_US` | server audio sink latency override | -| `LESAVKA_UAC_SESSION_CLOCK_ALIGN` | server audio sink clock-alignment override | +| `LESAVKA_UAC_SESSION_CLOCK_ALIGN` | server audio sink clock-alignment override; defaults to `0` | | `LESAVKA_TEST_CAM_U32` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_CAP_CAMERA` | test/build contract variable; not runtime operator config | | `LESAVKA_TEST_CAP_MIC` | test/build contract variable; not runtime operator config | @@ -212,7 +212,7 @@ Hardware-facing assumptions belong near the code that uses them; this file is th | `LESAVKA_TEST_VIDEO_SOURCE` | test/build contract variable; not runtime operator config | | `LESAVKA_TOUCHPAD_SCALE` | input routing/clipboard override | | `LESAVKA_UAC_DEV` | server hardware/device override | -| `LESAVKA_UAC_SESSION_CLOCK_ALIGN` | disable only for A/B diagnosing UAC sink timing vs silence | +| `LESAVKA_UAC_SESSION_CLOCK_ALIGN` | server audio sink clock-alignment override; `0` is the host-validated default | | `LESAVKA_UPLINK_CAMERA_PREVIEW` | client media capture/playback override | | `LESAVKA_UPLINK_MIC_LEVEL` | client media capture/playback override | | `LESAVKA_USB_RECOVERY_` | USB recovery timing override | diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 904f9ca..9cc3566 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -443,7 +443,7 @@ fi 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}" - printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' "${LESAVKA_UAC_SESSION_CLOCK_ALIGN:-1}" + printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' "${LESAVKA_UAC_SESSION_CLOCK_ALIGN:-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 137588a..edace6c 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -21,8 +21,10 @@ 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_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-auto} REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto} REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto} +ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-1} SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"} LOCAL_AUDIO_SANITY=${LOCAL_AUDIO_SANITY:-1} PROBE_PREBUILD=${PROBE_PREBUILD:-1} @@ -62,6 +64,7 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ "${VIDEO_SIZE}" \ "${VIDEO_FPS}" \ "${VIDEO_FORMAT}" \ + "${REMOTE_CAPTURE_STACK}" \ "${REMOTE_AUDIO_SOURCE}" \ "${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" <<'REMOTE_CAPTURE_SCRIPT' & set -euo pipefail @@ -70,8 +73,9 @@ capture_seconds=$2 video_size=$3 video_fps=$4 video_format=$5 -remote_audio_source=$6 -remote_audio_quiesce_user_audio=$7 +remote_capture_stack=$6 +remote_audio_source=$7 +remote_audio_quiesce_user_audio=$8 rm -f "${remote_capture}" video_args=(-f video4linux2 -framerate "${video_fps}" -video_size "${video_size}") @@ -107,24 +111,102 @@ resolve_pulse_source() { ' } -audio_mode="alsa" +resolve_pw_audio_target() { + if ! command -v pw-dump >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then + return 1 + fi + pw-dump | python3 - <<'PY' +import json +import sys + +try: + objs = json.load(sys.stdin) +except Exception: + raise SystemExit(1) + +for obj in objs: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = (obj.get("info") or {}).get("props") or {} + if props.get("media.class") != "Audio/Source": + continue + serial = props.get("object.serial") + name = props.get("node.name", "") + desc = props.get("node.description", "") + if serial is None: + continue + if "Lesavka_Composite" in name or "Lesavka Composite" in desc: + print(serial) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +capture_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="pulse" - else - printf 'PipeWire Lesavka source not found; falling back to hw:3,0\n' >&2 - fi -elif [[ "${remote_audio_source}" == pulse:* ]]; then - audio_mode="pulse" - 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 +pw_audio_target="" + +case "${remote_capture_stack}" in + auto) + if command -v pw-record >/dev/null 2>&1 \ + && command -v pw-v4l2 >/dev/null 2>&1 \ + && pw_audio_target="$(resolve_pw_audio_target)"; then + capture_mode="pwpipe" + elif [[ "${remote_audio_source}" == "auto" ]]; then + if pulse_source="$(resolve_pulse_source)"; then + capture_mode="pulse" + else + printf 'PipeWire Lesavka source not found; falling back to hw:3,0\n' >&2 + fi + elif [[ "${remote_audio_source}" == pulse:* ]]; then + capture_mode="pulse" + 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 + ;; + pwpipe) + if ! command -v pw-record >/dev/null 2>&1 || ! command -v pw-v4l2 >/dev/null 2>&1; then + printf 'REMOTE_CAPTURE_STACK=pwpipe requires pw-record and pw-v4l2\n' >&2 + exit 64 + fi + pw_audio_target="$(resolve_pw_audio_target)" || { + printf 'PipeWire Lesavka capture target not found for REMOTE_CAPTURE_STACK=pwpipe\n' >&2 + exit 64 + } + capture_mode="pwpipe" + ;; + pulse) + if [[ "${remote_audio_source}" == pulse:* ]]; then + pulse_source="${remote_audio_source#pulse:}" + elif [[ "${remote_audio_source}" == "auto" ]]; then + pulse_source="$(resolve_pulse_source)" || { + printf 'PipeWire Lesavka source not found for REMOTE_CAPTURE_STACK=pulse\n' >&2 + exit 64 + } + else + pulse_source="${remote_audio_source}" + fi + capture_mode="pulse" + ;; + alsa) + if [[ "${remote_audio_source}" == alsa:* ]]; then + alsa_audio_dev="${remote_audio_source#alsa:}" + elif [[ "${remote_audio_source}" != "auto" ]]; then + alsa_audio_dev="${remote_audio_source}" + fi + capture_mode="alsa" + ;; + *) + printf 'unsupported REMOTE_CAPTURE_STACK=%s\n' "${remote_capture_stack}" >&2 + exit 64 + ;; +esac quiesce_for_alsa=0 case "${remote_audio_quiesce_user_audio}" in @@ -145,13 +227,32 @@ case "${remote_audio_quiesce_user_audio}" in ;; esac -if [[ "${quiesce_for_alsa}" == "1" ]]; then +if [[ "${capture_mode}" == "alsa" && "${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 [[ "${capture_mode}" == "pwpipe" ]]; then + printf 'using PipeWire-native mux capture target serial: %s\n' "${pw_audio_target}" >&2 + timeout "${capture_seconds}" pw-record \ + --target "${pw_audio_target}" \ + --rate 48000 \ + --channels 2 \ + --format s16 \ + --raw - \ + | pw-v4l2 ffmpeg -hide_banner -loglevel error -y \ + -thread_queue_size 1024 \ + "${video_args[@]}" \ + -i /dev/video0 \ + -thread_queue_size 1024 \ + -f s16le -ar 48000 -ac 2 \ + -i pipe:0 \ + -t "${capture_seconds}" \ + -c:v copy \ + -c:a pcm_s16le \ + "${remote_capture}" +elif [[ "${capture_mode}" == "pulse" ]]; then printf 'using Pulse source: %s\n' "${pulse_source}" >&2 ffmpeg -hide_banner -loglevel error -y \ -thread_queue_size 1024 \ @@ -196,8 +297,33 @@ capture_status=0 wait "${capture_pid}" || capture_status=$? if ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_CAPTURE}'"; then + remote_fetch_capture="${REMOTE_CAPTURE}" + if [[ "${ANALYSIS_NORMALIZE}" != "0" ]]; then + remote_fetch_capture="${REMOTE_CAPTURE%.mkv}-analysis.mkv" + echo "==> normalizing remote capture to CFR for analysis" + normalize_status=0 + ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \ + "${REMOTE_CAPTURE}" \ + "${remote_fetch_capture}" \ + "${VIDEO_FPS}" <<'REMOTE_NORMALIZE_SCRIPT' || normalize_status=$? +set -euo pipefail +src=$1 +dst=$2 +fps=$3 +ffmpeg -hide_banner -loglevel error -y \ + -i "${src}" \ + -vf "fps=${fps}" \ + -c:v libx264 -preset ultrafast -crf 12 -g 1 -pix_fmt yuv420p \ + -c:a pcm_s16le \ + "${dst}" +REMOTE_NORMALIZE_SCRIPT + if [[ "${normalize_status}" -ne 0 ]]; then + echo "remote CFR normalization failed; falling back to raw capture" >&2 + remote_fetch_capture="${REMOTE_CAPTURE}" + fi + fi echo "==> fetching capture back to ${LOCAL_CAPTURE}" - scp ${SSH_OPTS} "${TETHYS_HOST}:${REMOTE_CAPTURE}" "${LOCAL_CAPTURE}" + scp ${SSH_OPTS} "${TETHYS_HOST}:${remote_fetch_capture}" "${LOCAL_CAPTURE}" fi if [[ "${probe_status}" -ne 0 ]]; then diff --git a/server/Cargo.toml b/server/Cargo.toml index 0e35f58..f7b0e57 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.13" +version = "0.13.14" edition = "2024" autobins = false