DEV Community

Prabhash
Prabhash

Posted on Edited on

The WAV header is not 44 bytes

Most WAV bugs I have seen come from this line:

samples = data[44:]
Enter fullscreen mode Exit fullscreen mode

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:

  • 1 is integer PCM
  • 3 is IEEE float
  • 0xFFFE is 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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
pm25coder profile image
pm25coder •

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. wave gets 4 of the 5 right -- it resolves 0xFFFE and reports sampwidth 3 for the 24-bit file -- and raises wave.Error: unknown format: 3 on 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.

Collapse
 
prabhash_me profile image
Prabhash •

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:

  1. All 264,600 samples match after the 25 shift, not 264,575. The naive slice is 25 samples longer than the real data, so nothing drops off the end.
  2. I get 10,324 of the first 20,000 outside [-1, 1], not 10,214. Probably a different window. Doesn't change anything.

The stdlib one is version dependent. On 3.9 and 3.11, wave raises on both files, unknown format: 65534 and unknown format: 3, so 3 of 5. Current CPython accepts PCM, IEEE float and extensible, and for extensible it checks the SubFormat GUID against KSDATAFORMAT_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!

Collapse
 
prabhash_me profile image
Prabhash •

You are right, I was wrong to say your result was neither.

Checked Lib/wave.py on each branch. 3.11 and earlier have no WAVE_FORMAT_EXTENSIBLE and no WAVE_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 raises unknown 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 new getformat().

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.

Thread Thread
 
pm25coder profile image
pm25coder •

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 tests self._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 than unknown 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.

Collapse
 
pm25coder profile image
pm25coder •

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: 3 on the float file. inspect.getsource(wave) contains no WAVE_FORMAT_IEEE_FLOAT at all; the only non-PCM branch is extensible, where it compares SubFormat against KSDATAFORMAT_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.