Skip to content

feat(gfx): audio capture builtins — audio_capture_open/read/close (#579)#588

Merged
InauguralPhysicist merged 1 commit into
mainfrom
feat/audio-capture-579
Jul 13, 2026
Merged

feat(gfx): audio capture builtins — audio_capture_open/read/close (#579)#588
InauguralPhysicist merged 1 commit into
mainfrom
feat/audio-capture-579

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #579

What

Live recording input for the gfx extension — the missing device source for DeslanStudio's transport recording (F-DS-17). Pull-model capture through the already-dlopen'd SDL2: SDL_OpenAudioDevice(iscapture=1) in queue mode + SDL_DequeueAudio. No callback, no C→eigs re-entry, no new threading surface — fits the existing polling clients (gfx_poll loops, transport_tick_recording).

  • audio_capture_open of [freq, channels] → device id or 0 (defaults [44100, 1]; allowed_changes=0 so SDL converts to exactly the requested format — no rate-negotiation surface needed; re-open closes the previous device; graceful 0 without SDL or a capture device)
  • audio_capture_read of nullbuffer (VAL_BUFFER, the gfx audio: audio_play / audio_play_loop reject VAL_BUFFER samples (list-only) #578 fast type) of new samples since the last read, floats in [-1, 1] (interleaved for channels > 1); at most 2048 samples per call — loop until the returned buffer is empty to drain fully. Empty buffer = nothing new yet; null = no device open.
  • audio_capture_close of null — double-close safe; gfx_close also reaps the capture device before dlclosing SDL.

vs the issue's proposed shape

Matches the issue's poll/drain model exactly (queue-mode capture, no callback, CI-testable under SDL_AUDIODRIVER=dummy — verified empirically: dummy capture opens, accumulates silence monotonically, partial dequeue and double-close both behave). Three deliberate deviations:

  1. Names: audio_capture_open/read/close instead of audio_open_capture/audio_read/audio_close_capture — one greppable family prefix, and bare audio_read collides with the read_* file-builtin namespace.
  2. Buffer, not list: the issue asked for "drained samples … (empty list when none)". gfx audio: audio_play / audio_play_loop reject VAL_BUFFER samples (list-only) #578 just made VAL_BUFFER the bulk-samples type on the output side; capture returns the same type, so a record→overdub round trip (audio_capture_readtransport_feed_recordingaudio_play) never converts.
  3. Per-call cap (2048 samples): tape-driven, see below. Callers drain with a read-until-empty loop; the loop replays faithfully (one N record per call, empties included).

Tape design (the load-bearing part)

Captured audio is the textbook nondeterministic input, so audio_capture_open and audio_capture_read are TRACE_NONDET_TAKE/RECORD builtins — under EIGS_REPLAY the tape is taken before any SDL call: replay never opens or reads a real microphone; the recorded device id and sample buffers are served off the tape.

  • Buffers ride the tape natively — the b[…] N-record encoding (write_value_ptr_full / parse_value_p) already round-trips VAL_BUFFER; no format change needed.
  • The 2048-sample cap is the honest minimal path for the 64 KiB N-record budget: worst-case %.17g over all 65536 possible int16-derived samples is 23 chars → a full read records at ~51 KB < 65536. An over-budget record is written …<truncated> and replay silently falls back to the live source — for capture that would mean a live mic read mid-replay, the exact divergence the tape exists to prevent. The cap makes truncation impossible by construction. (Documented in docs/TRACE.md and at the definition site.)
  • audio_open inconsistency found and documented, not changed: the output-side audio_open is untraced today — its env-dependent return (0 on a machine with no audio) can steer a branch differently on replay, and g_audio_freq (which the untraced generators read) is hidden nondet state. Tracing it now would change how existing tapes replay and silence playback under replay; capture (an input) must be traced regardless. The asymmetry + residual gap is spelled out in docs/TRACE.md so it's a deliberate line, not an accident.
  • audio_capture_close is deterministic (always null) and untraced; under replay it's a no-op because no device was ever opened.

Tests

  • Suite [62] (+13, device-gated with constant counts like the mixer checks): read-before-open → null; buffer type; per-call cap on first and full chunks; [-1, 1] range scan; monotonic accumulation (bounded poll); drain-until-empty terminates; read-after-close → null; double-close; re-open with default args. Runs against dummy and real drivers (content assertions are range-based, not silence-based). Fixed the [62] label drift while there: it said 24 but the file already ran 25; now 38 and enforced.
  • test_replay.sh: records a capture session under SDL_AUDIODRIVER=dummy + EIGS_TRACE, then replays with SDL_AUDIODRIVER=doesnotexist — a live open would fail, so byte-identical output proves the id and samples came off the tape. Gated (skips on non-gfx builds and where no capture device opens, e.g. the CI dev image, which has no libSDL2).

Gates (all local, X540NA)

  • make test (release, default build): 2862/2862
  • make asan + ASAN_OPTIONS=detect_leaks=1 full suite: 2866/2866, leak tally 0, [87] clean
  • gfx+ASan+UBSan variant (hand-built — make asan excludes ext_gfx.c): test_audio 38/38 + test_replay 20/20, leak-clean
  • make jit-smoke, make freestanding-check: pass (freestanding surface untouched)
  • make gfx + full suite: 2901/2901 (= 2862 + 38 audio + 1 replay)

🤖 Generated with Claude Code

The gfx extension's audio surface was output-only; DeslanStudio's
transport recording (F-DS-17) could never record a real microphone.
Pull-model capture via the already-dlopen'd SDL2:
SDL_OpenAudioDevice(iscapture=1) in queue mode + SDL_DequeueAudio —
no callback, no C->eigs re-entry, no new threading surface.

- audio_capture_open of [freq, channels] -> dev id or 0 (defaults
  44100/1; allowed_changes=0 so SDL converts to the requested format;
  re-open closes the previous device; graceful 0 without SDL/capture)
- audio_capture_read of null -> VAL_BUFFER (the #578 fast type) of
  new samples in [-1, 1], at most 2048 per call — loop until empty.
  Empty buffer = nothing yet; null = no device open.
- audio_capture_close of null — double-close safe; gfx_close also
  reaps the capture device before dlclosing SDL.

Tape-first: open and read are TRACE_NONDET_TAKE/RECORD builtins —
under EIGS_REPLAY the tape is taken before any SDL call, so replay
never opens or reads a real microphone; the b[...] buffer encoding
rides N records natively. The 2048-sample per-read cap exists so the
worst-case record (~51 KB) always fits the 64 KiB N-record budget —
a truncated record would silently fall back to the live mic on
replay. audio_open (output) stays deliberately untraced (playback is
a re-performed side effect, like print); the residual gap of its
env-dependent return is documented in docs/TRACE.md.

Tests: gfx-suite section [62] +13 checks (buffer shape, per-call cap,
[-1,1] range, monotonic accumulation, drain-until-empty, close/read-
after-close/double-close, re-open; device-gated with constant counts
— SDL_AUDIODRIVER=dummy provides a silent capture device), and a
test_replay.sh case that records a capture session then replays it
with the SDL driver deliberately broken. Fixed the [62] label drift
(said 24, file ran 25; now 38).

Closes #579

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@InauguralPhysicist InauguralPhysicist merged commit aa8638a into main Jul 13, 2026
17 checks passed
@InauguralPhysicist InauguralPhysicist deleted the feat/audio-capture-579 branch July 13, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gfx audio: no audio INPUT/capture builtin (SDL recording unexposed)

1 participant