test(audio): add real speaker to mic sanity preflight

This commit is contained in:
Brad Stein 2026-04-24 21:01:17 -03:00
parent cb408ebd9a
commit 9e00e7ea89
6 changed files with 194 additions and 6 deletions

6
Cargo.lock generated
View File

@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.13.6"
version = "0.13.7"
dependencies = [
"anyhow",
"async-stream",
@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.13.6"
version = "0.13.7"
dependencies = [
"anyhow",
"base64",
@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.13.6"
version = "0.13.7"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.13.6"
version = "0.13.7"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.13.6"
version = "0.13.7"
edition = "2024"
build = "build.rs"

View File

@ -0,0 +1,182 @@
#!/usr/bin/env bash
# scripts/manual/run_local_audio_sanity.sh
#
# Play a real Lesavka-style speaker tone, record the real local microphone
# path, and fail if the recorded signal does not show strong energy at the
# expected tone frequency.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)"
LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"}
LOCAL_AUDIO_SOURCE=${LOCAL_AUDIO_SOURCE:-}
LOCAL_AUDIO_SINK=${LOCAL_AUDIO_SINK:-}
LOCAL_AUDIO_SANITY_SECONDS=${LOCAL_AUDIO_SANITY_SECONDS:-6}
LOCAL_AUDIO_TONE_DELAY_SECONDS=${LOCAL_AUDIO_TONE_DELAY_SECONDS:-1}
LOCAL_AUDIO_TONE_SECONDS=${LOCAL_AUDIO_TONE_SECONDS:-3}
LOCAL_AUDIO_TONE_FREQUENCY_HZ=${LOCAL_AUDIO_TONE_FREQUENCY_HZ:-880}
LOCAL_AUDIO_TONE_VOLUME=${LOCAL_AUDIO_TONE_VOLUME:-0.25}
resolve_default_source() {
pactl get-default-source 2>/dev/null
}
resolve_source_mute_state() {
local source=$1
pactl list sources 2>/dev/null | awk -v source="${source}" '
$1 == "Name:" && $2 == source { seen=1; next }
seen && $1 == "Mute:" { print $2; exit }
seen && /^$/ { exit }
'
}
mkdir -p "${LOCAL_OUTPUT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)"
CAPTURE_WAV="${LOCAL_OUTPUT_DIR}/lesavka-local-audio-sanity-${STAMP}.wav"
if [[ -z "${LOCAL_AUDIO_SOURCE}" ]]; then
if ! LOCAL_AUDIO_SOURCE="$(resolve_default_source)"; then
echo "could not determine a default Pulse/PipeWire source" >&2
exit 64
fi
fi
SOURCE_MUTE_STATE="$(resolve_source_mute_state "${LOCAL_AUDIO_SOURCE}")"
if [[ "${SOURCE_MUTE_STATE}" == "yes" ]]; then
echo "warning: source ${LOCAL_AUDIO_SOURCE} is currently muted; unmuting for sanity test" >&2
pactl set-source-mute "${LOCAL_AUDIO_SOURCE}" 0
fi
capture_pid=""
cleanup() {
if [[ -n "${capture_pid}" ]] && kill -0 "${capture_pid}" >/dev/null 2>&1; then
kill "${capture_pid}" >/dev/null 2>&1 || true
wait "${capture_pid}" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
echo "==> capturing local microphone source: ${LOCAL_AUDIO_SOURCE}"
ffmpeg -y -hide_banner -loglevel error \
-f pulse \
-i "${LOCAL_AUDIO_SOURCE}" \
-ac 1 \
-ar 16000 \
-t "${LOCAL_AUDIO_SANITY_SECONDS}" \
"${CAPTURE_WAV}" &
capture_pid=$!
sleep "${LOCAL_AUDIO_TONE_DELAY_SECONDS}"
echo "==> playing local speaker tone at ${LOCAL_AUDIO_TONE_FREQUENCY_HZ} Hz"
tone_sink_args=()
if [[ -n "${LOCAL_AUDIO_SINK}" ]]; then
tone_sink_args=(device="${LOCAL_AUDIO_SINK}")
fi
tone_status=0
timeout "${LOCAL_AUDIO_TONE_SECONDS}" \
gst-launch-1.0 -q \
audiotestsrc is-live=true wave=sine \
freq="${LOCAL_AUDIO_TONE_FREQUENCY_HZ}" \
volume="${LOCAL_AUDIO_TONE_VOLUME}" \
! audioconvert ! audioresample ! queue ! pulsesink "${tone_sink_args[@]}" \
>/dev/null 2>&1 || tone_status=$?
if [[ "${tone_status}" -ne 0 && "${tone_status}" -ne 124 ]]; then
echo "local speaker tone failed with status ${tone_status}" >&2
exit "${tone_status}"
fi
wait "${capture_pid}"
capture_pid=""
python - "${CAPTURE_WAV}" \
"${LOCAL_AUDIO_TONE_FREQUENCY_HZ}" \
"${LOCAL_AUDIO_TONE_DELAY_SECONDS}" \
"${LOCAL_AUDIO_TONE_SECONDS}" <<'PY'
import math
import statistics
import struct
import sys
import wave
path = sys.argv[1]
target_hz = float(sys.argv[2])
tone_start_s = float(sys.argv[3])
tone_duration_s = float(sys.argv[4])
tone_end_s = tone_start_s + tone_duration_s
window_s = 0.1
with wave.open(path, "rb") as wav:
frame_count = wav.getnframes()
sample_rate = wav.getframerate()
sample_width = wav.getsampwidth()
channel_count = wav.getnchannels()
raw = wav.readframes(frame_count)
if sample_width != 2:
raise SystemExit(f"unsupported sample width {sample_width * 8} bits in {path}")
samples = [sample[0] for sample in struct.iter_unpack("<h", raw)]
window_frames = max(1, int(sample_rate * window_s))
omega = 2.0 * math.pi * target_hz / sample_rate
coeff = 2.0 * math.cos(omega)
def goertzel_power(values: list[int]) -> tuple[float, float]:
s0 = 0.0
s1 = 0.0
s2 = 0.0
for value in values:
s0 = value + coeff * s1 - s2
s2 = s1
s1 = s0
power = s1 * s1 + s2 * s2 - coeff * s1 * s2
rms = math.sqrt(sum(value * value for value in values) / len(values)) if values else 0.0
return power, rms
rows: list[tuple[float, float, float]] = []
for offset in range(0, len(samples), window_frames):
window = samples[offset:offset + window_frames]
if len(window) < window_frames:
break
power, rms = goertzel_power(window)
rows.append((offset / sample_rate, power, rms))
baseline_rows = [row for row in rows if row[0] < max(0.0, tone_start_s - 0.2)]
tone_rows = [
row for row in rows
if row[0] >= tone_start_s + 0.2 and row[0] <= max(tone_start_s + 0.2, tone_end_s - 0.2)
]
if not baseline_rows or not tone_rows:
raise SystemExit("sanity analysis did not get enough baseline/tone windows")
baseline_power = statistics.median(row[1] for row in baseline_rows)
baseline_rms = statistics.median(row[2] for row in baseline_rows)
tone_power = statistics.median(row[1] for row in tone_rows)
tone_rms = statistics.median(row[2] for row in tone_rows)
power_ratio = tone_power / max(baseline_power, 1.0)
rms_ratio = tone_rms / max(baseline_rms, 1.0)
print(
{
"capture": path,
"tone_hz": target_hz,
"baseline_power": round(baseline_power, 1),
"tone_power": round(tone_power, 1),
"power_ratio": round(power_ratio, 2),
"baseline_rms": round(baseline_rms, 1),
"tone_rms": round(tone_rms, 1),
"rms_ratio": round(rms_ratio, 2),
}
)
if tone_power < max(1.0e9, baseline_power * 50.0):
raise SystemExit(
f"tone energy at {target_hz:.0f} Hz was not strong enough "
f"(baseline={baseline_power:.1f}, tone={tone_power:.1f})"
)
print("local speaker-to-mic sanity passed")
PY
echo "==> local audio sanity capture: ${CAPTURE_WAV}"

View File

@ -23,11 +23,17 @@ VIDEO_FPS=${VIDEO_FPS:-30}
VIDEO_FORMAT=${VIDEO_FORMAT:-mjpeg}
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=5"}
LOCAL_AUDIO_SANITY=${LOCAL_AUDIO_SANITY:-1}
mkdir -p "${LOCAL_OUTPUT_DIR}"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOCAL_CAPTURE="${LOCAL_OUTPUT_DIR}/lesavka-upstream-av-sync-${STAMP}.mkv"
if [[ "${LOCAL_AUDIO_SANITY}" != "0" ]]; then
echo "==> verifying local speaker-to-mic sanity before upstream sync run"
"${SCRIPT_DIR}/run_local_audio_sanity.sh"
fi
echo "==> starting Tethys capture on ${TETHYS_HOST}"
ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
"${REMOTE_CAPTURE}" \

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.13.6"
version = "0.13.7"
edition = "2024"
autobins = false