183 lines
5.8 KiB
Bash
Executable File
183 lines
5.8 KiB
Bash
Executable File
#!/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}"
|