diff --git a/Cargo.lock b/Cargo.lock index 4711c4b..2dac07a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.14" +version = "0.22.15" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.14" +version = "0.22.15" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.14" +version = "0.22.15" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index d0a4e78..f272243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -484,6 +484,10 @@ path = "tests/manual/artifacts/probe_artifact_contract.rs" name = "hardware_media_smoke_contract" path = "tests/manual/hardware_media/hardware_media_smoke_contract.rs" +[[test]] +name = "tethys_downstream_audio_manual_contract" +path = "tests/manual/audio/tethys_downstream_audio_manual_contract.rs" + [[test]] name = "client_uplink_performance_contract" path = "tests/performance/client/uplink/client_uplink_performance_contract.rs" diff --git a/client/Cargo.toml b/client/Cargo.toml index 4011f7b..118d54a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.14" +version = "0.22.15" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 85711c2..31d88db 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.14" +version = "0.22.15" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 3078975..e7b3734 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -258,6 +258,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UAC_COMPENSATION_US` | server audio sink latency override | | `LESAVKA_UAC_DEV` | server hardware/device override | | `LESAVKA_UAC_HDMI_COMPENSATION_US` | server HDMI audio sink latency override; defaults to `205000` to hold gadget audio back toward the host-observed HDMI video path | +| `LESAVKA_UAC_HOST_MIXER_CONTROLS` | server gadget descriptor override; defaults to `0` so host PipeWire/WirePlumber cannot persist broken UAC hardware-volume channel maps | | `LESAVKA_UAC_LATENCY_TIME_US` | server audio sink latency 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 | diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index 11729cf..2e0b327 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -470,6 +470,20 @@ if [[ -z $DISABLE_UAC ]]; then echo 0x3 >"$U/c_chmask" echo 48000 >"$U/c_srate" echo 2 >"$U/c_ssize" + # Keep host-visible USB mixer controls out of the descriptor by default. + # PipeWire/WirePlumber can persist empty channel maps for gadget hardware + # volume controls, leaving the Lesavka sink routed but stuck at digital zero. + if [[ ${LESAVKA_UAC_HOST_MIXER_CONTROLS:-0} == 1 ]]; then + echo 1 >"$U/p_volume_present" 2>/dev/null || true + echo 1 >"$U/p_mute_present" 2>/dev/null || true + echo 1 >"$U/c_volume_present" 2>/dev/null || true + echo 1 >"$U/c_mute_present" 2>/dev/null || true + else + echo 0 >"$U/p_volume_present" 2>/dev/null || true + echo 0 >"$U/p_mute_present" 2>/dev/null || true + echo 0 >"$U/c_volume_present" 2>/dev/null || true + echo 0 >"$U/c_mute_present" 2>/dev/null || true + fi # The dwc2-backed gadget path behaves more reliably with adaptive capture # than with the default async+feedback mode for the host-facing mic stream. echo adaptive >"$U/c_sync" 2>/dev/null || true diff --git a/scripts/install/server.sh b/scripts/install/server.sh index c9cabb2..ae333f8 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -1473,6 +1473,7 @@ SERVER_ENV_TMP=$(mktemp) 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_HOST_MIXER_CONTROLS=%s\n' "${LESAVKA_UAC_HOST_MIXER_CONTROLS:-0}" printf 'LESAVKA_UAC_HDMI_COMPENSATION_US=%s\n' "${LESAVKA_UAC_HDMI_COMPENSATION_US:-205000}" printf 'LESAVKA_UAC_SESSION_CLOCK_ALIGN=%s\n' "${LESAVKA_UAC_SESSION_CLOCK_ALIGN:-0}" printf 'LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS=%s\n' "${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-350}" diff --git a/scripts/manual/run_tethys_downstream_audio_sanity.sh b/scripts/manual/run_tethys_downstream_audio_sanity.sh new file mode 100755 index 0000000..f41419d --- /dev/null +++ b/scripts/manual/run_tethys_downstream_audio_sanity.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# scripts/manual/run_tethys_downstream_audio_sanity.sh - inspect the Tethys Lesavka speaker sink +# Manual: downstream audio host sanity probe; no sudo or in-cluster changes. +set -euo pipefail + +TETHYS_HOST=${LESAVKA_TETHYS_HOST:-tethys@titan-24} +REPAIR_WIREPLUMBER=${LESAVKA_TETHYS_AUDIO_REPAIR:-0} +RESTART_AUDIO=${LESAVKA_TETHYS_AUDIO_RESTART:-0} + +ssh "$TETHYS_HOST" \ + LESAVKA_TETHYS_AUDIO_REPAIR="$REPAIR_WIREPLUMBER" \ + LESAVKA_TETHYS_AUDIO_RESTART="$RESTART_AUDIO" \ + 'bash -s' <<'REMOTE' +set -euo pipefail + +export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} +export DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus} +PULSE_TIMEOUT=${LESAVKA_TETHYS_AUDIO_TIMEOUT_SECONDS:-5} + +failures=0 +warn() { printf '⚠️ %s\n' "$*" >&2; } +fail() { printf '❌ %s\n' "$*" >&2; failures=$((failures + 1)); } +pass() { printf '✅ %s\n' "$*" >&2; } +run_pactl() { timeout "${PULSE_TIMEOUT}s" pactl "$@"; } +run_wpctl() { timeout "${PULSE_TIMEOUT}s" wpctl "$@"; } + +print_rebind_hint() { + local card iface driver + card=$(aplay -l 2>/dev/null | awk '/Lesavka|Composite|UAC2|USB Audio/ {if (match($0, /card [0-9]+/)) {print substr($0, RSTART + 5, RLENGTH - 5); exit}}' || true) + if [[ -n ${card:-} && -e /sys/class/sound/card${card}/device ]]; then + iface=$(basename "$(readlink -f "/sys/class/sound/card${card}/device")") + driver=$(basename "$(readlink -f "/sys/class/sound/card${card}/device/driver" 2>/dev/null || true)") + if [[ -n $iface && -n $driver ]]; then + echo "Tethys root-level soft rebind, if the user audio graph is wedged:" >&2 + echo " sudo sh -c 'echo -n \"$iface\" > /sys/bus/usb/drivers/$driver/unbind; sleep 2; echo -n \"$iface\" > /sys/bus/usb/drivers/$driver/bind'" >&2 + fi + fi +} + +if ! command -v pactl >/dev/null 2>&1; then + fail "pactl is unavailable in the Tethys desktop session" + exit 1 +fi + +if ! run_pactl info >/dev/null 2>&1; then + fail "Pulse/PipeWire is unreachable for $(id -un); XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" + print_rebind_hint + exit 1 +fi + +sink_line=$(run_pactl list short sinks | awk '/alsa_output\.usb-Lesavka|Lesavka|Composite|UAC2/ {print; exit}') +if [[ -z ${sink_line:-} ]]; then + fail "no Lesavka USB speaker sink is visible to PipeWire/Pulse" + run_pactl list short sinks >&2 || true + exit 1 +fi +sink_name=$(awk '{print $2}' <<<"$sink_line") +pass "found Lesavka sink: $sink_name" + +sink_dump=$(mktemp) +trap 'rm -f "$sink_dump"' EXIT +run_pactl list sinks >"$sink_dump" + +sink_block=$(awk -v sink="$sink_name" ' + /^Sink #[0-9]+/ {printing=0} + $0 ~ "Name: " sink {printing=1} + printing {print} +' "$sink_dump") + +if grep -q 'State: RUNNING' <<<"$sink_block"; then + pass "Lesavka sink is RUNNING" +elif grep -q 'State: SUSPENDED' <<<"$sink_block"; then + warn "Lesavka sink is SUSPENDED; this is okay only when no stream is actively playing" +else + warn "Lesavka sink state is not RUNNING" +fi + +if grep -Eq 'Volume:.* / 0% /' <<<"$sink_block"; then + fail "Lesavka sink volume is persisted at 0%; Tethys can route audio into digital silence" +else + pass "Lesavka sink Pulse volume is not 0%" +fi + +if grep -Eq 'Mute: yes' <<<"$sink_block"; then + fail "Lesavka sink is muted in Pulse/PipeWire" +else + pass "Lesavka sink is not muted" +fi + +if command -v wpctl >/dev/null 2>&1; then + object_id=$(awk -F' = ' '/object.id =/ {gsub(/"/, "", $2); print $2; exit}' <<<"$sink_block") + if [[ -n ${object_id:-} ]]; then + wp_dump=$(run_wpctl inspect "$object_id" 2>/dev/null || true) + if grep -Eq 'audio\.channels = "?0"?' <<<"$wp_dump"; then + fail "WirePlumber reports audio.channels=0 for the Lesavka sink" + elif [[ -n $wp_dump ]]; then + pass "WirePlumber exposes a non-zero channel count for the Lesavka sink" + fi + fi +fi + +state_file="$HOME/.local/state/wireplumber/stream-properties" +if [[ -r $state_file ]] && grep -Eq 'Audio/(Sink|Source):node\.name:.*(usb-Lesavka|Lesavka).*"channelVolumes":\[\]' "$state_file"; then + fail "WirePlumber has persisted empty Lesavka channelVolumes/channelMap in $state_file" + if [[ ${LESAVKA_TETHYS_AUDIO_REPAIR:-0} == 1 ]]; then + backup="$state_file.lesavka-audio.$(date +%Y%m%d%H%M%S).bak" + cp "$state_file" "$backup" + python3 - "$state_file" <<'PY' +import sys +from pathlib import Path +path = Path(sys.argv[1]) +fixed = [] +for line in path.read_text().splitlines(): + if line.startswith("Audio/Sink:node.name:") and "Lesavka" in line and '"channelVolumes":[]' in line: + key = line.split("=", 1)[0] + line = key + '={"mute":false, "volume":1.000000, "channelVolumes":[1.000000, 1.000000], "channelMap":["FL", "FR"]}' + elif line.startswith("Audio/Source:node.name:") and "Lesavka" in line and '"channelVolumes":[]' in line: + key = line.split("=", 1)[0] + line = key + '={"mute":false, "volume":1.000000, "channelVolumes":[1.000000, 1.000000], "channelMap":["FL", "FR"]}' + fixed.append(line) +path.write_text("\n".join(fixed) + "\n") +PY + pass "repaired WirePlumber Lesavka channel state; backup: $backup" + if [[ ${LESAVKA_TETHYS_AUDIO_RESTART:-0} == 1 ]]; then + systemctl --user restart wireplumber pipewire-pulse pipewire || true + else + warn "restart Tethys user audio or replug/rebind the Lesavka USB audio interface before retesting" + fi + fi +else + pass "WirePlumber stream-properties has no empty Lesavka channel map" +fi + +if ps -eo stat,comm,args | awk '$1 ~ /D/ && $2 ~ /wireplumber|pipewire/ {found=1} END {exit !found}'; then + fail "Tethys has PipeWire/WirePlumber stuck in uninterruptible I/O sleep" +fi + +print_rebind_hint + +if [[ $failures -gt 0 ]]; then + exit 1 +fi +pass "Tethys downstream audio host checks passed" +REMOTE diff --git a/server/Cargo.toml b/server/Cargo.toml index 207f38d..2dd6f4a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.14" +version = "0.22.15" edition = "2024" autobins = false diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index 5972a42..461c2bb 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -104,3 +104,22 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { ); } } + +#[test] +fn core_script_hides_uac_hardware_mixer_controls_by_default() { + for expected in [ + "LESAVKA_UAC_HOST_MIXER_CONTROLS:-0", + "echo 0 >\"$U/p_volume_present\"", + "echo 0 >\"$U/p_mute_present\"", + "echo 0 >\"$U/c_volume_present\"", + "echo 0 >\"$U/c_mute_present\"", + "echo 1 >\"$U/p_volume_present\"", + "echo 1 >\"$U/c_volume_present\"", + "PipeWire/WirePlumber can persist empty channel maps", + ] { + assert!( + CORE_SCRIPT.contains(expected), + "lesavka-core UAC mixer guard missing: {expected}" + ); + } +} diff --git a/tests/installer/scripts/install/server_install_script_contract.rs b/tests/installer/scripts/install/server_install_script_contract.rs index 41e37c1..240ae94 100644 --- a/tests/installer/scripts/install/server_install_script_contract.rs +++ b/tests/installer/scripts/install/server_install_script_contract.rs @@ -44,6 +44,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_UVC_FRAME_META=%s", "LESAVKA_UVC_FRAME_META_LOG_PATH=%s", "LESAVKA_UVC_CONTROL_READ_ONLY=", + "LESAVKA_UAC_HOST_MIXER_CONTROLS=%s", "LESAVKA_REQUIRE_TLS=%s", "LESAVKA_TLS_CERT=%s", "LESAVKA_TLS_KEY=%s", @@ -82,6 +83,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_WIDTH:-1920}")); assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_HEIGHT:-1080}")); assert!(SERVER_INSTALL.contains("${LESAVKA_HDMI_SINK:-fbdevsink}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UAC_HOST_MIXER_CONTROLS:-0}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS:-350}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_MAX_LIVE_LAG_MS:-1000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UPSTREAM_STARTUP_TIMEOUT_MS:-60000}")); diff --git a/tests/manual/audio/tethys_downstream_audio_manual_contract.rs b/tests/manual/audio/tethys_downstream_audio_manual_contract.rs new file mode 100644 index 0000000..ddb8f20 --- /dev/null +++ b/tests/manual/audio/tethys_downstream_audio_manual_contract.rs @@ -0,0 +1,50 @@ +// Contract tests for the Tethys downstream audio sanity helper. +// +// Scope: inspect `scripts/manual/run_tethys_downstream_audio_sanity.sh`. +// Targets: downstream UAC host regressions where PipeWire routes audio into a +// broken zero-channel or zero-volume Lesavka sink. + +const SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/run_tethys_downstream_audio_sanity.sh" +)); + +#[test] +fn tethys_audio_probe_detects_zero_volume_and_channel_map_regressions() { + for expected in [ + "LESAVKA_TETHYS_HOST", + "LESAVKA_TETHYS_AUDIO_REPAIR", + "LESAVKA_TETHYS_AUDIO_TIMEOUT_SECONDS", + "run_pactl()", + "pactl list short sinks", + "Volume:.* / 0% /", + "audio.channels=0", + "channelVolumes", + "channelMap", + "WirePlumber has persisted empty Lesavka channelVolumes/channelMap", + ] { + assert!( + SCRIPT.contains(expected), + "Tethys downstream audio probe missing guard: {expected}" + ); + } +} + +#[test] +fn tethys_audio_probe_keeps_repair_explicit_and_non_sudo_by_default() { + for expected in [ + "LESAVKA_TETHYS_AUDIO_REPAIR:-0", + "LESAVKA_TETHYS_AUDIO_RESTART:-0", + "restart Tethys user audio or replug/rebind", + "Tethys root-level soft rebind, if the user audio graph is wedged", + ] { + assert!( + SCRIPT.contains(expected), + "Tethys downstream audio probe should make recovery explicit: {expected}" + ); + } + assert!( + !SCRIPT.contains("sudo sh -c") || SCRIPT.contains("echo \" sudo sh -c"), + "the helper may print a sudo recovery command but must not run sudo itself" + ); +}