#!/usr/bin/env bash # scripts/manual/run_uac_output_sanity.sh - play a short tone into the Theia UAC sink # Manual: Theia UAC output sanity probe; not part of CI. 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" local log local wav log=$(mktemp) wav=$(mktemp --suffix=.wav) echo "🎧 trying UAC playback device: $candidate" >&2 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(""$log" 2>&1 local rc=$? set -e 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 echo "⚠️ tone failed on $candidate (sink open error)" >&2 sed 's/^/ /' "$log" >&2 || true rm -f "$log" rm -f "$wav" return 1 fi if [[ $rc -eq 0 || $rc -eq 124 ]]; then echo "✅ UAC tone completed on $candidate" >&2 rm -f "$log" rm -f "$wav" return 0 fi echo "⚠️ tone failed on $candidate (rc=$rc)" >&2 sed 's/^/ /' "$log" >&2 || true rm -f "$log" rm -f "$wav" 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