perf(bot): stream recording audio to disk instead of buffering in RAM (#82) #167

Merged
claude-bot merged 1 commit from feat/82-stream-audio-to-disk into main 2026-07-16 00:07:44 +00:00
Contributor

Summary

PerUserPCMSink accumulated each speaker's silence-padded 48kHz stereo PCM in a BytesIO (≈675 MB/hour/speaker) and only flushed at stop via a full-buffer getvalue() copy — a 5-person/6-hour session ≈ 20 GB resident, OOMing long before the max_recording_hours cap, and a crash lost all audio. This streams each speaker's PCM to an on-disk WAV as it arrives.

Changes

  • cogs/recording.py: the sink opens a wave writer per speaker on their first packet, streams leading silence (in bounded 1s chunks — no multi-GB zero allocation for late joiners), writeframesraws each packet (one 20ms silence frame for DTX), and finalizes headers at close() (idempotent, _closed-guarded). _process closes writers defensively and cleans up raw files on error.
  • services/recording_status.py, tests repointed off the removed in-memory audio_data accessor.
  • Output contract byte-identical: still produces the same intermediate 48kHz/2ch raw WAV at the same path, then runs the unchanged convert_to_whisper_wav FFmpeg step (-ar 16000 -ac 1) → same {user_id}.wav, same speakers.json, same upload. utils/audio.py untouched. Timeline/silence arithmetic reproduced exactly.

Memory is now O(speakers), independent of duration; no getvalue()/full-buffer copy remains; partial audio survives a crash.

Verification

Full bot suite green on the dev server: 186 passed (+7 new sink write-path tests covering first-packet formatting, late-joiner leading silence, DTX frame, a combined timeline, absence of any in-memory buffer, idempotent close, and empty-session). A live before/after RSS + end-to-end transcription runbook (needs a real Discord session) is documented and will be posted as a PR comment.

Follow-ups noted (out of scope): optional in-flight 16kHz-mono downsample (would let us drop the FFmpeg step) and crash-resume of orphaned raw WAVs.

Closes #82

🤖 Generated with Claude Code

## Summary `PerUserPCMSink` accumulated each speaker's silence-padded 48kHz stereo PCM in a `BytesIO` (≈675 MB/hour/speaker) and only flushed at stop via a full-buffer `getvalue()` copy — a 5-person/6-hour session ≈ 20 GB resident, OOMing long before the `max_recording_hours` cap, and a crash lost all audio. This streams each speaker's PCM to an on-disk WAV as it arrives. ## Changes - `cogs/recording.py`: the sink opens a `wave` writer per speaker on their first packet, streams leading silence (in bounded 1s chunks — no multi-GB zero allocation for late joiners), `writeframesraw`s each packet (one 20ms silence frame for DTX), and finalizes headers at `close()` (idempotent, `_closed`-guarded). `_process` closes writers defensively and cleans up raw files on error. - `services/recording_status.py`, tests repointed off the removed in-memory `audio_data` accessor. - **Output contract byte-identical**: still produces the same intermediate 48kHz/2ch raw WAV at the same path, then runs the **unchanged** `convert_to_whisper_wav` FFmpeg step (`-ar 16000 -ac 1`) → same `{user_id}.wav`, same `speakers.json`, same upload. `utils/audio.py` untouched. Timeline/silence arithmetic reproduced exactly. Memory is now O(speakers), independent of duration; no `getvalue()`/full-buffer copy remains; partial audio survives a crash. ## Verification Full bot suite green on the dev server: **186 passed** (+7 new sink write-path tests covering first-packet formatting, late-joiner leading silence, DTX frame, a combined timeline, absence of any in-memory buffer, idempotent close, and empty-session). A live before/after RSS + end-to-end transcription runbook (needs a real Discord session) is documented and will be posted as a PR comment. Follow-ups noted (out of scope): optional in-flight 16kHz-mono downsample (would let us drop the FFmpeg step) and crash-resume of orphaned raw WAVs. Closes #82 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(bot): stream recording audio to disk instead of buffering in RAM
All checks were successful
CI / Docker image build (pull_request) Successful in 46s
CI / Backend lint (ruff) (pull_request) Successful in 57s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m12s
CI / Backend migration, tests, and audit (pull_request) Successful in 3m7s
CI / Bot tests and audit (pull_request) Successful in 6m55s
faf123cf08
PerUserPCMSink accumulated each speaker's decoded 48 kHz stereo PCM in an
io.BytesIO that grew toward full session length and was copied wholesale at
stop (getvalue()), doubling peak memory — ~675 MB/hour/speaker, OOMing long
before max_recording_hours. A crash also lost all captured audio.

The sink now opens a wave.Wave_write per speaker on their first packet and
writeframesraw()'s each Opus frame straight to the on-disk raw WAV in the
audio_temp volume (leading-silence padding for late joiners is streamed in
bounded 1 s chunks; DTX packets still write one 20 ms silence frame; the
cross-speaker timeline arithmetic is unchanged). Resident memory is now
bounded to the OS file buffer plus one frame regardless of session length,
and whatever was flushed survives a crash.

Output contract is byte-identical: the streamed file is the same 48 kHz
stereo raw WAV (same path {session_id}_{user_id}_raw.wav, same format) the
finalize path used to build in RAM, so _process still resamples each to
16 kHz mono via the unchanged FFmpeg step (utils/audio.py) into
{session_dir}/{user_id}.wav. The stop-path getvalue()/full-buffer copy is
gone; writers are closed in cleanup() (and defensively in _process) with
headers finalised on close.

audio_data (BytesIO accessor) is replaced by audio_files (user_id -> Path)
for the finalize path and bytes_written() (user_id -> bytes) for live status;
recording_status.py updated to the new accessor.

Tests: added sink write-path coverage (first-packet leading silence,
late-joiner gap padding, DTX silence frame, combined timeline, live
bytes_written, idempotent close, correctly-formatted on-disk WAV) and
repointed the _process/status tests at the on-disk files. 186 bot tests pass.

The optional in-flight 48k-stereo->16k-mono downsample and crash-resume are
left as follow-ups (out of scope to keep output byte-identical).

Closes #82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-07-16 00:01:02 +00:00
claude-bot deleted branch feat/82-stream-audio-to-disk 2026-07-16 00:07:45 +00:00
Sign in to join this conversation.
No description provided.