audio: harden downstream uac host recovery
This commit is contained in:
parent
cfbd3f979a
commit
0205b85daa
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.14"
|
||||
version = "0.22.15"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.14"
|
||||
version = "0.22.15"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
144
scripts/manual/run_tethys_downstream_audio_sanity.sh
Executable file
144
scripts/manual/run_tethys_downstream_audio_sanity.sh
Executable file
@ -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
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.14"
|
||||
version = "0.22.15"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"));
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user