audio: actually install the capture path I kept deferring
The audio work was three parts and I shipped two. The transcode-fidelity method and the pinned 5.1 downmix landed; the null sink -- the only one that answers "what does the GAME play" -- I deferred to "the next natural rebuild window" and then rebuilt both images four times without doing it. pulseaudio-utils is now in both, with tools/audio-capture wrapping it: a null sink is a real device as far as an application is concerned, so Canary and Godot open it normally and parec records what they emit. This unblocks the decoder's Q8. The cue-to-event bindings are currently a name match against the authors' own identifiers -- a plausible guess, not a measurement -- and capturing what the game plays on a menu move converts them. `audio-capture run` reports the peak level and warns when the capture 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 passes it, which is how a confident wrong number gets made.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 <the application>
|
||||
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
|
||||
|
||||
|
||||
64
tools/audio-capture
Executable file
64
tools/audio-capture
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user