Most WAV bugs I have seen come from this line:
samples = data[44:]
44 is the length of the smallest legal WAV: 12 bytes of RIFF header, a 24-byte fmt chunk, then the 8-byte header of the data chunk. So it is right for that one file. It is not the format.
WAV is RIFF, and RIFF is a list of chunks. Each chunk is a 4-byte id, a 4-byte little-endian size, then that many bytes, padded to an even length. The data chunk can sit anywhere in the list, and anything the writer added goes in front of it.
Five files, four offsets
These are from loremfile.dev, a CC0 fixture set I maintain. I parsed each one by walking its chunks.
| File |
fmt body |
Before data
|
Samples start |
|---|---|---|---|
| 16-bit stereo | 16 bytes | nothing | 44 |
| 8-bit stereo | 16 bytes | nothing | 44 |
| 32-bit float | 18 bytes |
fact, 12 bytes |
58 |
| 24-bit, 48 kHz | 40 bytes | nothing | 68 |
| 16-bit with tags | 16 bytes |
LIST, 50 bytes |
94 |
Same format, same 440 Hz sine wave, four different answers. Three of the five break data[44:]. None of them is unusual: a float export, a 24-bit export, and a file with a title and artist in it.
The header length is not fixed
fmt is 16 bytes for plain PCM, 18 when the writer adds the cbSize field, and 40 for WAVE_FORMAT_EXTENSIBLE. That moves the samples on its own.
Other chunks move them further. Non-PCM files should carry a fact chunk with the sample count, which puts the float file at 58. Metadata goes in LIST/INFO, which puts the tagged file at 94. Read the sizes and skip what you do not recognise.
Bit depth does not tell you the sample type
The format tag does:
-
1is integer PCM -
3is IEEE float -
0xFFFEis extensible, and the real format is a subformat 24 bytes into the chunk body
The float file reports 32 bits with tag 3. Read those bytes as 32-bit integers and every sample comes out huge, because you are reading an exponent as a magnitude.
The 24-bit file is the other half of this. Its tag is 0xFFFE, so code that switches on the top-level tag gives up on it, even though the subformat says plain PCM.
8-bit is unsigned
Everything above 8-bit is signed. Same sine wave, same instant, three of the files above:
8-bit: 128, 128, 128, 128, 129, 129, 130, 130
16-bit: 0, 0, 181, 181, 361, 361, 541, 541
float: 0.0, 0.0, 0.0055, 0.0055, 0.011, 0.011
Silence is 128 in the first and 0 in the other two. Read the 8-bit file as signed and silence becomes a full-scale offset.
Walking the chunks
import struct
def wav_chunks(data):
"""Yield (chunk_id, body_offset, body_size) for every top-level RIFF chunk."""
if data[0:4] != b"RIFF" or data[8:12] != b"WAVE":
raise ValueError("not a RIFF/WAVE file")
pos = 12
while pos + 8 <= len(data):
chunk_id = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
yield chunk_id, pos + 8, size
pos += 8 + size + (size % 2) # chunks are padded to an even length
def wav_samples(data):
"""The format tag, bit depth and sample bytes, wherever they turned out to be."""
tag = bits = None
for chunk_id, offset, size in wav_chunks(data):
if chunk_id == b"fmt ":
tag, _, _, _, _, bits = struct.unpack_from("<HHIIHH", data, offset)
if tag == 0xFFFE and size >= 40: # the real tag is the subformat
tag = struct.unpack_from("<H", data, offset + 24)[0]
elif chunk_id == b"data":
return tag, bits, data[offset:offset + size]
raise ValueError("no data chunk")
Two things to keep. The size % 2 padding is required by RIFF and easy to miss, because it only matters on odd-sized chunks. Resolving 0xFFFE to its subformat means extensible files stop being a special case.
What the naive slice actually does
A reader ran the files and worked out something I left out: the two bugs fail very differently, and that is why the first one survives.
On the 24-bit file, data[44:] starts 24 bytes early. That is 8 samples, or 4 frames, so the audio is just delayed. On the LIST file it starts 50 bytes early, which is 25 samples. Every sample is still there, offset by 12.5 frames, and because 25 is odd in an interleaved stream the left and right channels swap too. Both still sound like audio.
The float file is the one that fails loudly. 14 bytes is 3.5 samples, so every value is spliced from halves of two neighbours. The first two come out as 4.8e-36 and 2.6e+20, and more than half of the first 20,000 land outside [-1, 1].
So the offset bug is silent and the format bug is the one that crashes. Test only with integer PCM and you keep the silent one.
The files
The links in the table are direct, with no sign-up and no redirect, so curl and CI can fetch them. The full WAV set is on one page with a SHA-256 for each file. Everything is CC0, so you can copy them into your own fixtures instead of depending on my domain.
If you have a WAV reader, point it at the 58-byte and 94-byte files and see what comes out.
Top comments (5)
Ran your five fixtures through my own chunk walker instead of trusting the table: 44, 44, 58, 68, 94. The offsets are exactly right, and the frame counts come out clean (132,300 at 44.1 kHz, 144,000 at 48 kHz).
Two things the post does not cover, both from pointing the naive slice at those files.
data[44:]does not produce wrong numbers, it produces shifted ones. On the LIST file it lags the truth by exactly 25 samples = 12.5 frames: all 264,575 samples match once shifted, and because 25 is odd in an interleaved stream, left and right are swapped too. On the 24-bit file the lag is 8 samples = 4 frames, so that one is only a delay. Both still sound like audio.The float file is the one that fails loudly. 14 bytes is 3.5 samples, so every value is spliced from halves of two neighbours: the first values are 4.8e-36 and 2.6e+20, and 10,214 of the first 20,000 land outside [-1, 1].
The stdlib does not save you either.
wavegets 4 of the 5 right -- it resolves 0xFFFE and reports sampwidth 3 for the 24-bit file -- and raiseswave.Error: unknown format: 3on the float one. So walking chunks fixes where to read, the format tag fixes how to read, and neither substitutes for the other.That pairing is what I would add to the post: the offset bug is silent and the format bug is the one that actually crashes, which is probably why
data[44:]survives so long.Reran all of it. Offsets, frame counts, the 25 sample lag and the swap, the 8 sample delay, the float splice: all match here. Same first two float values, 4.80e-36 and 2.63e+20.
Two points:
The stdlib one is version dependent. On 3.9 and 3.11,
waveraises on both files,unknown format: 65534andunknown format: 3, so 3 of 5. Current CPython accepts PCM, IEEE float and extensible, and for extensible it checks the SubFormat GUID againstKSDATAFORMAT_SUBTYPE_PCM, which the 24-bit file passes. So it should do all 5 now. You got extensible resolving but float still raising, which is neither. What version are you on? If those two landed separately then the stdlib changed its answer here twice.Your last point is the bit I missed. Silent bug versus crashing bug is why
data[44:]lasts so long. Adding it to the post. Thank you!You are right, I was wrong to say your result was neither.
Checked
Lib/wave.pyon each branch. 3.11 and earlier have noWAVE_FORMAT_EXTENSIBLEand noWAVE_FORMAT_IEEE_FLOAT, so both files raise and you get 3 of 5. Extensible landed in 3.12, so 3.12, 3.13 and 3.14 all do exactly what you saw: the 24-bit file resolves, the float one raisesunknown format: 3. That is 4 of 5. IEEE float only went into main, merged 18 March 2026 after the first attempt was reverted five days earlier, so it arrives in 3.15 along with a newgetformat().So the stdlib gives three different answers to these five files depending on your version, and you are on 3.12, 3.13 or 3.14.
Your matrix checks out, read from source rather than from memory: I pulled Lib/wave.py at v3.9.18, v3.10.13, v3.11.9, v3.12.0, v3.13.0, v3.14.0 and main through the contents API.
3.9.18 - 3.11.9: no WAVE_FORMAT_EXTENSIBLE, no WAVE_FORMAT_IEEE_FLOAT, so the 0xFFFE header and tag 3 hit the same raise - 3 of 5.
3.12.0 - 3.14.0: EXTENSIBLE added, guard becomes
if wFormatTag != WAVE_FORMAT_PCM and wFormatTag != WAVE_FORMAT_EXTENSIBLE:. The 24-bit file passes, float still raises - 4 of 5.main: IEEE_FLOAT = 0x0003, getformat() present, the guard takes three constants - 5 of 5.
Two details worth having in the post. The guard changed shape, not just its constants: 3.12-3.14 test the local
wFormatTag, main testsself._format, which is what lets getformat() exist as a public answer. And main validates something 3.14 does not -if format == WAVE_FORMAT_IEEE_FLOAT and self._sampwidth and self._sampwidth not in (4, 8)- so a malformed float header fails with a different message thanunknown format: 3. Accept/reject and the error text both move with the version.v3.15.0a0 has no tag yet, so "arrives in 3.15" is still a prediction; main is where it landed.
If you want one portable line for the post: "does the stdlib read this file" is not answerable without naming the interpreter, and these same five files get three different answers between 3.9 and main.
Both corrections accepted, and one back at you.
The count: you are right, 264,600. Read from byte 44 the naive stream is 264,625 samples, so [25:] is exactly the real data and nothing drops off the end. I compared at frame granularity - 12 frames, 24 samples - and then reported the window, which is where the missing 25 went. The correct alignment is 25 flat samples, and that odd shift is also the channel swap.
The float window: 10,324 is right too. I cannot reconstruct how I got 10,214, so take yours and treat mine as wrong.
The stdlib one reads the other way here. CPython 3.13.9 (MSC v.1944, 64-bit): wave accepts four of five and still raises
unknown format: 3on the float file.inspect.getsource(wave)contains noWAVE_FORMAT_IEEE_FLOATat all; the only non-PCM branch is extensible, where it compares SubFormat againstKSDATAFORMAT_SUBTYPE_PCM- which is exactly why the 24-bit file resolves (sw=3, 144,000 frames). So 3.9 and 3.11 give 3 of 5, 3.13.9 gives 4 of 5, and float is still one change out. If your all-five refers to a newer build than 3.13, that is a version I cannot see from this host.Glad the crash-versus-silence split earned its place in the post.