perf(recording): convert to 16 kHz mono during capture (#176 item 1) #474
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/176-in-capture-downsample"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Item 1 of #176. Item 3 landed in #473; item 2 was already done by #399.
What it does
The sink streamed Discord's native 48 kHz stereo to disk and handed the result to FFmpeg after the recording stopped: 192 kB/s per speaker, 4.15 GB for a six-hour session. That size is what pushed the raw tracks past WAV's 4 GB field and forced them headerless in #321. Converting as the audio arrives cuts it 6× — to 0.69 GB — and removes the post-stop pass entirely.
It is not two lines of
audioop, and that is the whole story#176 proposed resampling each frame with
audioop.ratecv. That is quietly wrong:ratecvinterpolates with no anti-aliasing filter, so everything above the output's 8 kHz Nyquist limit folds back into the speech band instead of being removed.Measured on a 12 kHz tone:
audioopSpeech has real energy above 8 kHz in sibilants and fricatives, so the naive version would lay phantom tones under every voice and feed them to Whisper — with the files the right size and format, and every existing test still green. Nothing would have reported it.
utils/resample.pyfilters properly first: a Hamming-windowed sinc low-pass, odd tap count so the group delay is an exact integer sample rather than a half (a half-sample delay smears the cross-speaker alignment that #320 was about), then takes every third sample. Filter state and decimation phase carry across frames — without that, every 20 ms boundary is a click and the tracks drift apart over hours.Why numpy
A 159-tap FIR at 50 frames/sec/speaker is ~15M multiply-accumulates per second. Pure Python cannot do that on the event loop. With numpy it is 78 µs per 20 ms frame — 0.39% of one core per speaker, 3.9% at ten.
The dependency is added to
requirements.inand the lockfile regenerated withpip-compile, per the pip-compile manager note inrenovate.json. The diff torequirements.txtis two lines.The raw tracks are now self-describing, and they had to be
This is the part that was not in the issue, and it is the reason the work is bigger than "resample in
write()".These files are headerless — nothing in one says what rate it is. The format lived entirely in the constants this PR changes. And since #399, the bot automatically recovers leftover raw tracks at startup. So a bot upgraded between a crash and a restart would have read 48 kHz stereo bytes using 16 kHz mono constants and reported six times the real duration — silently, and straight into the backend's duration invariant (#324).
So:
.16k-mono.s16le), andRAW_TRACK_FORMATSmaps suffix → rate/channelsMIN_SPEECH_BYTESthreshold (16 000 bytes is half a second of 16 kHz mono but a twelfth of a second of 48 kHz stereo), and the finalise step, which declares the track's own rate to FFmpegFinalising no longer shells out
The sink's output is already Whisper's format, so
_processwraps it in a WAV header rather than re-encoding a file into the format it is already in. FFmpeg stays for the MP3 mixdown and for legacy tracks.Test changes worth a look
_FRAME_BYTESwas doing double duty as "what the fake decoder returns" and "what lands on disk". Those are now different numbers, so they are separate constants and the on-disk one derives fromBYTES_PER_SECOND. Four sink tests asserted byte-equality against the pre-resample pattern, which cannot hold once the bytes are filtered — they assert length and not-silence instead.test_recording_recovery's autouse FFmpeg stand-in became inert when the main path stopped calling FFmpeg: an autouse fixture patching a function nothing calls any more, with assertions inside it that could never fire — silently asserting nothing on every test in the file. It is inverted now, and fails if anything re-encodes a current-format track.Verification
274 bot tests pass. 13 mutations, all caught:
audioopfailure mode)reset()that does not clear historyThe
bot-prodimage builds with numpy, anddocs/plus bothCLAUDE.mdfiles are updated to describe the new pipeline.🤖 Generated with Claude Code