#!/usr/bin/env bash # scripts/manual/run_local_audio_sanity.sh # Manual: local speaker-to-mic sanity probe; not part of CI. # # 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(" 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}"