diff --git a/docker/decoder/Dockerfile b/docker/decoder/Dockerfile index 0e4d3db2..b3f85389 100644 --- a/docker/decoder/Dockerfile +++ b/docker/decoder/Dockerfile @@ -54,6 +54,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # expect drives Claude Code's one-time interactive gates for an # unattended run — see bin/claude-autonomous. expect \ + # PulseAudio, for capturing audio without a sound card. `module-null-sink` + # is a real device as far as any application is concerned, so the emulator + # and Godot open it normally and `parec` records what they play. Without + # it, "does this actually sound right" is unanswerable in a container -- + # and the cue-to-event bindings stay a name match rather than a + # measurement. See docs/port/AUDIO-VERIFICATION.md. + pulseaudio pulseaudio-utils \ && rm -rf /var/lib/apt/lists/* # Pin the unversioned tool names to 19 so CMake, and anything that shells out to diff --git a/docker/port/Dockerfile b/docker/port/Dockerfile index a828d7aa..e0893494 100644 --- a/docker/port/Dockerfile +++ b/docker/port/Dockerfile @@ -32,6 +32,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 jq ripgrep unzip file less nano tini sudo procps \ # expect drives Claude Code's one-time interactive gates expect \ + # PulseAudio, for capturing audio without a sound card. `module-null-sink` + # is a real device as far as any application is concerned, so the emulator + # and Godot open it normally and `parec` records what they play. Without + # it, "does this actually sound right" is unanswerable in a container -- + # and the cue-to-event bindings stay a name match rather than a + # measurement. See docs/port/AUDIO-VERIFICATION.md. + pulseaudio pulseaudio-utils \ && rm -rf /var/lib/apt/lists/* # ── Godot 4 ────────────────────────────────────────────────────────────────── diff --git a/docs/port/AUDIO-VERIFICATION.md b/docs/port/AUDIO-VERIFICATION.md index 41c80c90..6ca19b4d 100644 --- a/docs/port/AUDIO-VERIFICATION.md +++ b/docs/port/AUDIO-VERIFICATION.md @@ -89,18 +89,25 @@ under a dummy driver" is a weaker claim than "heard", and the difference matters ## 3. A virtual device, when something insists on a real one For anything that opens a device rather than a bus — the emulator, most -obviously — a PulseAudio **null sink** is a real device that records to a file: +obviously — a PulseAudio **null sink** is a real device that records to a file. +`pulseaudio-utils` is in both images, and `audio-capture` wraps it: ```bash -pactl load-module module-null-sink sink_name=cap sink_properties=device.description=cap -PULSE_SINK=cap -parec -d cap.monitor --file-format=wav /tmp/captured.wav +audio-capture run /tmp/menu.wav -- run-canary # start sink, run, record +audio-capture start # or drive it by hand +PULSE_SINK=cap godot --path port +audio-capture record /tmp/out.wav & ``` -This is the route to capturing what the *game* plays — the menu move and confirm -cues behind HANDOFF Q8 — rather than what we think it should play. It needs -`pulseaudio-utils` in the image, so it is a rebuild, not something to reach for -mid-iteration. +This is the route to capturing what the **game** plays — the menu move and +confirm cues behind HANDOFF Q8 — rather than what we believe it should play. +Those bindings are currently a name match against the authors' own identifiers; +a capture turns them into a measurement. + +⚠️ `audio-capture run` reports the peak level and **warns when the result is +silent**, because silence is the failure that looks like success: a WAV of +exactly the right duration, full of zeroes, because the application opened a +different sink. A duration check alone would pass it. ## What none of this establishes diff --git a/tools/audio-capture b/tools/audio-capture new file mode 100755 index 00000000..1db19f04 --- /dev/null +++ b/tools/audio-capture @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Record what an application plays, with no sound card present. +# +# audio-capture start bring up a null sink named `cap` +# audio-capture record OUT.wav & record from it until killed +# audio-capture run OUT.wav -- CMD… start, run CMD, stop, leave OUT.wav +# +# A null sink is a real device as far as the application is concerned: the +# emulator and Godot open it exactly as they would hardware, and its monitor +# source is what `parec` reads. This is the difference between "the file decodes" +# and "the game played this" -- the second is a measurement, the first is not. +# +# Nothing here says the audio SOUNDS right; it says what was emitted. A human +# listening still answers something none of this does. +set -euo pipefail + +SINK="${SYLPH_SINK:-cap}" + +ensure_daemon() { + pulseaudio --check 2>/dev/null || pulseaudio --start --exit-idle-time=-1 2>/dev/null || true + for _ in $(seq 30); do pactl info >/dev/null 2>&1 && return 0; sleep 0.2; done + echo "audio-capture: no PulseAudio daemon" >&2; return 1 +} + +case "${1:-}" in + start) + ensure_daemon + pactl list short sinks | grep -q "^[0-9]*[[:space:]]*$SINK[[:space:]]" \ + || pactl load-module module-null-sink sink_name="$SINK" \ + sink_properties=device.description="$SINK" >/dev/null + pactl set-default-sink "$SINK" + echo "audio-capture: sink '$SINK' ready; point apps at it with PULSE_SINK=$SINK" + ;; + record) + out="${2:?usage: audio-capture record OUT.wav}" + ensure_daemon + exec parec -d "${SINK}.monitor" --file-format=wav "$out" + ;; + run) + out="${2:?usage: audio-capture run OUT.wav -- COMMAND...}"; shift 2 + [ "${1:-}" = "--" ] && shift + "$0" start + parec -d "${SINK}.monitor" --file-format=wav "$out" & rec=$! + # Kill the recorder on any exit path, or a failed run leaves it holding the + # monitor and the next capture silently records nothing. + trap 'kill "$rec" 2>/dev/null || true' EXIT + PULSE_SINK="$SINK" "$@" || true + sleep 1; kill "$rec" 2>/dev/null || true; wait "$rec" 2>/dev/null || true + trap - EXIT + if [ -s "$out" ]; then + dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$out" 2>/dev/null || echo "?") + echo "audio-capture: $out (${dur}s)" + # Silence is the failure that looks like success: a WAV of the right + # length, full of zeroes, because the app opened a different sink. + peak=$(ffmpeg -hide_banner -i "$out" -af astats=measure_perchannel=none -f null - 2>&1 \ + | grep -m1 "Peak level" || true) + echo " ${peak:-no level measured}" + case "$peak" in *"-inf"*) echo " WARNING: silent -- did the app use this sink?" ;; esac + else + echo "audio-capture: nothing recorded" >&2; exit 1 + fi + ;; + *) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' ;; +esac