2026-05-12 14:12:57 -03:00
#!/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( ) {
2026-05-12 14:31:23 -03:00
local card iface driver device_root audio_ifaces
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}}'
awk '/Lesavka|Composite|UAC2|USB Device 0x1d6b:0x104|1d6b:0x104/ {print $1; exit}' /proc/asound/cards 2>/dev/null
} | awk 'NF {print; exit}' || true )
2026-05-12 14:12:57 -03:00
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 ) " )
2026-05-12 14:31:23 -03:00
device_root = ${ iface % : * }
if [ [ -n $iface && -n $driver && -n $device_root ] ] ; then
audio_ifaces = $( for candidate in " /sys/bus/usb/devices/ ${ device_root } " :*; do
[ [ -e $candidate /bInterfaceClass && -e $candidate /bInterfaceSubClass ] ] || continue
[ [ $( cat " $candidate /bInterfaceClass " 2>/dev/null) = = "01" ] ] || continue
case " $( cat " $candidate /bInterfaceSubClass " 2>/dev/null) " in
01| 02) basename " $candidate " ; ;
esac
done | sort -V | xargs echo )
[ [ -n $audio_ifaces ] ] || audio_ifaces = $iface
echo "Tethys root-level audio-interface rebind, if the UAC graph is wedged:" >& 2
echo " sudo sh -c 'for i in $audio_ifaces ; do echo -n \"\$i\" > /sys/bus/usb/drivers/ $driver /unbind 2>/dev/null || true; done; sleep 2; for i in $audio_ifaces ; do echo -n \"\$i\" > /sys/bus/usb/drivers/ $driver /bind 2>/dev/null || true; done' " >& 2
echo "Tethys full composite re-enumeration, if audio interfaces bind but ALSA creates no PCM:" >& 2
echo " sudo sh -c 'echo 0 > /sys/bus/usb/devices/ $device_root /authorized; sleep 3; echo 1 > /sys/bus/usb/devices/ $device_root /authorized' " >& 2
2026-05-12 14:12:57 -03:00
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
2026-05-12 14:31:23 -03:00
print_rebind_hint
2026-05-12 14:12:57 -03:00
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