Fix #4: locate the WAV data chunk instead of inferring where it must be - #11
Merged
Merged
Conversation
…t be
`decode_wav_bytes` derived the audio offset by subtraction:
header_size = len(wav_bytes) - n_channels * width_bytes * nframes
which is only correct when nothing follows the `data` chunk. Plenty follows it:
`LIST`/`INFO` tags are routinely appended by ffmpeg, Audacity and iTunes. Those
trailing bytes were counted as header, so the read started too far into the
buffer and returned audio of the right LENGTH and the wrong CONTENT -- with no
exception. For a 10-sample file with a LIST chunk appended:
expected [0, 1, -1, 2, -2, 3, -3, 4, -4, 5]
actual [0, 20041, 20294, 21321, 21574, 6, 0, 24908, 26230, 14389]
Separately, a `data` chunk declaring more bytes than the file carries -- what a
stream writer leaves behind when it never patches the length back into the
header -- tripped `assert header_size >= 44`, which blamed the header for a
problem in the data size.
Both are fixed by walking the RIFF chunk list to find `data` rather than
inferring its position. A differential sweep over 576 well-formed containers
(channels x width x nframes x fmt size x chunks before/after data) shows the new
implementation correct on 576/576 where the old was wrong on 336/576, with no
case where the old was right and the new is wrong -- so no behaviour change on
valid input, which matters: `decode_wav_bytes` has eight consumers in this
ecosystem (hum, know, odat, front among them).
Two subtleties worth naming, both found by adversarial review of the first cut:
- Clamping an over-declared size to end-of-file re-creates the original bug when
a trailing chunk is present -- the metadata comes back as extra samples. So an
overrunning `data` chunk is bounded by the next position from which the rest
of the buffer parses as a chunk list landing exactly on EOF, falling back to
EOF only when there is no such boundary.
- A short `data` chunk now warns (`ShortWavData`) rather than decoding silently.
Clamping is what the issue asked for, but a caller that cannot tell a
half-downloaded file from a complete one is no better off than before.
Also here:
- Sub-frame remainders are dropped rather than raising a struct-size error, and
a payload with no whole frame returns an empty waveform instead of surfacing
an `IndexError` from inside the chunked decoder.
- Malformed input raises `ValueError` saying what is wrong. This unifies three
previous outcomes (`AssertionError`, `wave.Error`, `EOFError`); no consumer in
this ecosystem catches any of them, but it is a contract change.
- `header_size_of_wav_bytes` returns the data offset from the same walk, and
loses its `meta` parameter, which it no longer reads (no callers exist).
- `comptype` in `decode_wav_header_bytes` is assigned unconditionally; it was
bound only inside `if params.comptype == "NONE"`, an unreachable branch that
would have left the name unbound if stdlib `wave` ever widened.
CI: the pytest step ran with `root-dir: recode`, and the isee action builds its
file list with `find <root-dir> -name '*.py'` -- which never reached the
repo-root `test_recode.py`. CI collected 25 doctests and zero tests, so the
existing `test_decode_wav_bytes` had never gated a merge and neither would a new
regression test. Pointed at the repo root it collects 151.
Verified on Python 3.10.13 (the CI interpreter) with the exact CI invocation:
151 passed. Every guard is mutation-checked -- seven single-line reversions,
including restoring the old subtraction, dropping the frame-size channel factor
and clamping an overrun to EOF, each turns the suite red.
Known limitation, unchanged by this commit: WAVE_FORMAT_EXTENSIBLE files decode
on Python 3.12 but not on 3.10, because `decode_wav_header_bytes` delegates to
stdlib `wave`, which only learned that format in 3.12. The chunk walker handles
them fine; only the header read fails.
This was referenced Aug 17, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4.
The bug
decode_wav_bytesnever found the audio — it inferred where the audio must be:That holds only if nothing follows the
datachunk.LIST/INFOtags are routinely appended by ffmpeg, Audacity and iTunes, and those bytes were counted as header — so the read started too far into the buffer:Right number of samples, wrong samples, no exception. Separately, a
datachunk declaring more bytes than the file carries (a stream writer that never patched the length back into the header) trippedassert header_size >= 44— blaming the header for a problem in the data size. That assert is the "choke" in the original report.soundfilereads all of these correctly, which is how the reporter noticed.The fix
Walk the RIFF chunk list to find
data, instead of inferring its position.No behaviour change on valid input. A differential sweep over 576 well-formed containers (channels × width × nframes ×
fmtsize × chunks before/afterdata) has the new code correct on 576/576 where the old was wrong on 336/576, with no case where the old was right and the new is wrong. This matters —decode_wav_byteshas eight consumers in this ecosystem (hum,know,odat,frontamong them).Two subtleties, both surfaced by adversarial review of the first cut and neither obvious:
LISTpresent, "read to the end" hands the metadata back as extra samples. So an overrunningdatachunk is bounded by the next position from which the remainder parses as a chunk list landing exactly on EOF, falling back to EOF only when no such boundary exists.datachunk now warns (ShortWavData) instead of decoding silently. Clamping is what the issue asked for, but a caller who cannot distinguish a half-downloaded file from a complete one is no better off than before.Also included:
IndexErrorfrom inside the chunked decoder.header_size_of_wav_bytesreturns the offset from the same walk, and loses itsmetaparameter, which it no longer reads (it has no callers anywhere).comptypeindecode_wav_header_bytesis assigned unconditionally — it was bound only insideif params.comptype == "NONE", an unreachable branch that would have left the name unbound if stdlibwaveever widened.Malformed input now raises
ValueErrorwith a message saying what is wrong. This unifies three previous outcomes —AssertionError,wave.Error,EOFError— depending on how the input was malformed.wave.Erroris not aValueErrorsubclass, so anexcept wave.Errorhandler would no longer catch it. I checked all eight consumers plus the notebooks: none catches any of the three. Worth a release note.CI was not running the tests
The pytest step passed
root-dir: recode, and theiseeaction builds its file list withfind <root-dir> -name '*.py'— which never reaches the repo-roottest_recode.py. CI collected 25 doctests and zero tests. So the pre-existingtest_decode_wav_byteswas not gating anything, and neither would a new regression test. Pointed at the repo root it collects 151. That change is in this PR.Verification
pytest -v --doctest-modules -o doctest_optionflags="ELLIPSIS IGNORE_EXCEPTION_DETAIL" $files).WAVE_FORMAT_EXTENSIBLE,datasizes of 0 / 0x7FFFFFFF / 0x80000000 / 0xFFFFFFFF, aLISTwhose contents embed the literaldata(mis-sync probe), odd-sized chunks with and without the alignment pad, chunk sizes declaring past EOF, truncation at 10 depths, twodatachunks, andbytearray/memoryviewinputs. No infinite loop, no negative slice, no signed/unsigned confusion, no mis-sync.Known limitations (not addressed here, deliberately)
WAVE_FORMAT_EXTENSIBLEdecodes on 3.12 but raises on 3.10, becausedecode_wav_header_bytesdelegates to stdlibwave, which only learned that format in 3.12. The new chunk walker handles such files fine; only the header read fails. Parsing thefmtchunk directly would fix it and drop the stdlib dependency — out of scope for decode_wav_bytes needs a more robust wav head parsing #4.mk_pcm_audio_codec/num_find_num_type_for, whose blast radius is everyrecodeconsumer rather than just the WAV path. Filed separately.🤖 Generated with Claude Code
https://claude.ai/code/session_01QDDDKZaseXu6NtpzAvGs2i