diff --git a/Cargo.lock b/Cargo.lock index 9b152cf..ea49221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index 336f4e8..6d8b315 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.13.6" +version = "0.13.7" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 6b45a3c..a901e20 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.13.6" +version = "0.13.7" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_local_audio_sanity.sh b/scripts/manual/run_local_audio_sanity.sh new file mode 100755 index 0000000..1da693a --- /dev/null +++ b/scripts/manual/run_local_audio_sanity.sh @@ -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(" 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}" diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 3cac9b9..1ca6d90 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -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}" \ diff --git a/server/Cargo.toml b/server/Cargo.toml index 4c827eb..2f4b86f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.13.6" +version = "0.13.7" edition = "2024" autobins = false