2026-04-24 21:27:19 -03:00
#!/usr/bin/env bash
# scripts/manual/run_uac_output_sanity.sh - play a short tone into the Theia UAC sink
2026-04-29 01:25:06 -03:00
# Manual: Theia UAC output sanity probe; not part of CI.
2026-04-24 21:27:19 -03:00
set -euo pipefail
if [ [ ${ EUID :- $( id -u) } -ne 0 ] ] ; then
echo "❌ run this as root so it uses the same UAC leg as lesavka-server." >& 2
exit 1
fi
SERVER_ENV = ${ LESAVKA_SERVER_ENV :- /etc/lesavka/server.env }
if [ [ -r $SERVER_ENV ] ] ; then
set -a
# shellcheck disable=SC1090
source " $SERVER_ENV "
set +a
fi
DURATION_SECONDS = ${ LESAVKA_UAC_SANITY_SECONDS :- 4 }
TONE_FREQ = ${ LESAVKA_UAC_SANITY_FREQ :- 880 }
TONE_VOLUME = ${ LESAVKA_UAC_SANITY_VOLUME :- 0 .45 }
REQUESTED_DEV = ${ LESAVKA_UAC_SANITY_DEV :- ${ LESAVKA_UAC_DEV :- hw : UAC2Gadget ,0 } }
declare -A SEEN_CANDIDATES = ( )
CANDIDATES = ( )
append_candidate( ) {
local candidate = " ${ 1 :- } "
candidate = ${ candidate //[ $'\r\n' ]/ }
candidate = ${ candidate # " ${ candidate %%[![ : space : ]]* } " }
candidate = ${ candidate % " ${ candidate ##*[![ : space : ]] } " }
[ [ -n $candidate ] ] || return 0
if [ [ -z ${ SEEN_CANDIDATES [ $candidate ]+x } ] ] ; then
CANDIDATES += ( " $candidate " )
SEEN_CANDIDATES[ " $candidate " ] = 1
fi
}
append_candidate_family( ) {
local candidate = " ${ 1 :- } "
[ [ -n ${ candidate //[[ : space : ]]/ } ] ] || return 0
append_candidate " $candidate "
if [ [ $candidate = = hw:* ] ] ; then
append_candidate " plughw: ${ candidate #hw : } "
elif [ [ $candidate = = plughw:* ] ] ; then
append_candidate " hw: ${ candidate #plughw : } "
fi
}
discover_uac_numeric_candidates( ) {
python3 - <<'PY'
import re
import subprocess
try:
output = subprocess.check_output( [ "aplay" , "-l" ] , text = True, stderr = subprocess.DEVNULL)
except Exception:
raise SystemExit( 0)
seen = set( )
for line in output.splitlines( ) :
match = re.search( r"^card\s+(\d+):.*device\s+(\d+):" , line)
if not match:
continue
lowered = line.lower( )
if not any( token in lowered for token in ( "uac" , "lesavka" , "composite" , "usb audio" ) ) :
continue
candidate = f"hw:{match.group(1)},{match.group(2)}"
if candidate not in seen:
print( candidate)
seen.add( candidate)
PY
}
build_candidates( ) {
while IFS = read -r candidate; do
append_candidate_family " $candidate "
done < <( discover_uac_numeric_candidates)
append_candidate_family " $REQUESTED_DEV "
for alias in \
"hw:UAC2Gadget,0" \
"hw:UAC2_Gadget,0" \
"hw:Composite,0" \
"hw:Lesavka,0"
do
append_candidate_family " $alias "
done
}
run_tone( ) {
local candidate = " $1 "
2026-04-24 21:49:29 -03:00
local log
2026-04-24 22:14:43 -03:00
local wav
2026-04-24 21:49:29 -03:00
log = $( mktemp)
2026-04-24 22:14:43 -03:00
wav = $( mktemp --suffix= .wav)
2026-04-24 21:27:19 -03:00
echo " 🎧 trying UAC playback device: $candidate " >& 2
2026-04-24 22:14:43 -03:00
python3 - " $wav " " $DURATION_SECONDS " " $TONE_FREQ " " $TONE_VOLUME " <<'PY'
import math, struct, sys, wave
path, duration_s, freq_hz, volume = sys.argv[ 1] , float( sys.argv[ 2] ) , float( sys.argv[ 3] ) , float( sys.argv[ 4] )
rate = 48_000
frames = max( 1, int( rate * duration_s) )
amp = max( 0.0, min( volume, 1.0) ) * 32767.0
with wave.open( path, "wb" ) as wf:
wf.setnchannels( 2)
wf.setsampwidth( 2)
wf.setframerate( rate)
for index in range( frames) :
sample = int( math.sin( ( 2.0 * math.pi * freq_hz * index) / rate) * amp)
frame = struct.pack( "<hh" , sample, sample)
wf.writeframesraw( frame)
PY
2026-04-24 21:27:19 -03:00
set +e
2026-04-24 22:14:43 -03:00
timeout --signal= INT " $(( DURATION_SECONDS + 2 )) s " \
aplay -q -D " $candidate " " $wav " \
>" $log " 2>& 1
2026-04-24 21:27:19 -03:00
local rc = $?
set -e
2026-04-24 22:14:43 -03:00
if grep -Eiq 'unknown pcm|invalid argument|no such (device|file)|audio open error|device or resource busy|unable to open slave|unable to install hw params|unable to set hw params|audio open error|no such file or directory' " $log " ; then
2026-04-24 21:49:29 -03:00
echo " ⚠️ tone failed on $candidate (sink open error) " >& 2
sed 's/^/ /' " $log " >& 2 || true
rm -f " $log "
2026-04-24 22:14:43 -03:00
rm -f " $wav "
2026-04-24 21:49:29 -03:00
return 1
fi
2026-04-24 21:27:19 -03:00
if [ [ $rc -eq 0 || $rc -eq 124 ] ] ; then
echo " ✅ UAC tone completed on $candidate " >& 2
2026-04-24 21:49:29 -03:00
rm -f " $log "
2026-04-24 22:14:43 -03:00
rm -f " $wav "
2026-04-24 21:27:19 -03:00
return 0
fi
echo " ⚠️ tone failed on $candidate (rc= $rc ) " >& 2
2026-04-24 21:49:29 -03:00
sed 's/^/ /' " $log " >& 2 || true
rm -f " $log "
2026-04-24 22:14:43 -03:00
rm -f " $wav "
2026-04-24 21:27:19 -03:00
return " $rc "
}
build_candidates
if [ [ ${# CANDIDATES [@] } -eq 0 ] ] ; then
echo "❌ no UAC playback candidates found. Check 'aplay -l' and /etc/lesavka/server.env." >& 2
exit 1
fi
echo " 🎵 running Theia UAC sanity tone for ${ DURATION_SECONDS } s at ${ TONE_FREQ } Hz " >& 2
printf ' candidates: %s\n' " ${ CANDIDATES [*] } " >& 2
for candidate in " ${ CANDIDATES [@] } " ; do
if run_tone " $candidate " ; then
exit 0
fi
done
echo "❌ none of the UAC playback candidates accepted the sanity tone." >& 2
echo " requested device: $REQUESTED_DEV " >& 2
echo " current aplay -l:" >& 2
aplay -l >& 2 || true
exit 1