07 · Binary parsing: dr-wav RIFF/PCM¶
Objectives: Understand how a WAV file is laid out as a sequence of binary chunks, learn how
encoding/binaryreads little-endian integers directly into Go structs, and see why every size field from an untrusted file is a potential OOM vector that must be capped before allocation. Estimated time: 20 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- RIFF/WAV chunk layout = "a set of shipping boxes stacked inside a bigger shipping box — each box has a label tag on the outside and a size stamp, so you know exactly how far to reach before the next box starts." The outermost RIFF box holds a WAVE stamp, a
fmtmetadata box, and adatabox; the parser walks them left to right by reading the 4-byte tag and the 4-byte size before touching the payload. - Little-endian byte order = "reading a price tag that lists cents before dollars — the least-significant digit comes first." WAV files store every multi-byte integer with the least-significant byte at the lowest address;
encoding/binary.LittleEndianreverses the bytes into the normal Go value so you never do that arithmetic by hand. binary.Read= "a tape measure that advances itself — each time you call it, it reads exactly as many bytes as the target type needs and moves the cursor forward by that amount." Pass it abytes.Reader,binary.LittleEndian, and a pointer to a Go integer or struct field; it fills the value and leaves the reader positioned right after those bytes, with no manual offset tracking.- Untrusted size field as OOM vector = "a forged hotel booking that claims 4 000 rooms are reserved — blindly honouring it collapses the building." A crafted WAV can set the
datachunk size to 4 GB in four bytes; callingmake([]byte, subchunkSize)on that number crashes the process, soreadDataChunkcapsallocSizetor.Len(), the bytes actually present. - Seek to skip = "fast-forwarding a tape instead of listening to the part you don't need."
r.Seek(int64(subchunk1Size-16), io.SeekCurrent)jumps over extrafmtbytes or unknown subchunks without allocating a buffer, so an oversized size field wastes no memory even before the cap check fires. - Separate parse from validate (
ValidateWAV) = "a customs officer who first checks whether a package is sealed correctly, then hands it to an inspector who checks whether the contents are legal."Parseaccepts any byte layout that is structurally sound;ValidateWAVenforces semantic rules likeNumChannels != 0,AudioFormat == 1, and a supportedBitsPerSample, keeping each concern in one place.
Why it matters: audio processing pipelines ingest user-supplied WAV files. A single malformed header field can turn "decode this file" into "allocate all available RAM and crash."
See it — the byte layout. A WAV file is a flat strip of labelled chunks. Each
chunk is a 4-byte ASCII tag, a 4-byte little-endian size, then that many bytes. The
parser walks it left to right with binary.Read. The one field you can never trust
is the highlighted data size — cap it before any make([]byte, n).
The RIFF/WAV byte layout¶
A canonical 44-byte WAV header looks like this. (A byte is the basic unit computers store data in — think of it as one small slot that holds one number from 0 to 255; a text character, or a small piece of a bigger number, typically fits in one byte.)
Offset Size Field
------ ---- -----
0 4 "RIFF" (ASCII magic)
4 4 fileSize - 8 (uint32 LE)
8 4 "WAVE" (format tag)
12 4 "fmt " (subchunk ID)
16 4 16 (subchunk1 size, uint32 LE)
20 2 audioFormat (1 = PCM, uint16 LE)
22 2 numChannels (uint16 LE)
24 4 sampleRate (uint32 LE)
28 4 byteRate (uint32 LE)
32 2 blockAlign (uint16 LE)
34 2 bitsPerSample (uint16 LE)
36 4 "data" (subchunk ID)
40 4 dataSize (uint32 LE)
44 … PCM samples
After the mandatory header, any number of extra subchunks may appear before
data. The parser must scan forward until it finds the "data" tag. ("The parser" is the piece of code whose job is to read the raw bytes and make sense of their layout — the thing that walks through this table field by field.)
Reading fixed fields with encoding/binary¶
encoding/binary is a package — a bundle of ready-made code that someone else wrote, which you can pull into your own program instead of writing that logic yourself. (You bring a package in with an import line at the top of a file; more on that below.)
From dr-wav-go/dr_wav.go, the WAVHeader struct maps directly onto the fixed
fields in the fmt subchunk. (A struct is a labeled bundle of values that belong together — like a form with named blanks. Each named blank inside it, such as AudioFormat or NumChannels below, is called a field.)
// from dr-wav-go/dr_wav.go
type WAVHeader struct {
AudioFormat uint16 // 1 = PCM
NumChannels uint16
SampleRate uint32
ByteRate uint32
BlockAlign uint16
BitsPerSample uint16
}
In plain terms: this defines a form named WAVHeader with six labeled blanks. uint16 and uint32 just say how big a number can fit in that blank (16 bits or 32 bits — a bit is a single yes/no, 0-or-1 switch, and 8 bits make one byte, so uint16 is 2 bytes and uint32 is 4 bytes). Nothing runs here; this only describes the shape of the form so the rest of the code can fill it in.
Parse wraps the input slice in a bytes.Reader and reads each field in order.
In plain terms: Parse is a function — a named, reusable piece of code that does one job when you run ("call" or "invoke") it. A slice is Go's flexible view onto a sequence of bytes: a window over part of a longer list that you can resize without copying the underlying data. bytes.Reader is a small helper that remembers where you've read up to, so each read continues from where the last one left off.
// from dr-wav-go/dr_wav.go
r := bytes.NewReader(data)
var riff [4]byte
binary.Read(r, binary.LittleEndian, &riff) // advances 4 bytes
// … validate "RIFF" …
var chunkSize uint32
binary.Read(r, binary.LittleEndian, &chunkSize) // advances 4 bytes
// … read "WAVE", "fmt ", subchunk1Size …
var header WAVHeader
binary.Read(r, binary.LittleEndian, &header.AudioFormat)
binary.Read(r, binary.LittleEndian, &header.NumChannels)
binary.Read(r, binary.LittleEndian, &header.SampleRate)
// … and so on for ByteRate, BlockAlign, BitsPerSample
In plain terms: each line asks the reader for the next few bytes off the file, converts them from little-endian order into an ordinary number, and stores that number into one named blank of the header form (&header.AudioFormat means "put the result into this specific field" — the & says "here is where in memory that field lives, write directly there"). The reader automatically remembers how far it has gotten, so the next call picks up right after the last one stopped.
Each binary.Read call consumes exactly the number of bytes that the target
type requires. There is no manual offset arithmetic — bytes.Reader tracks the
position internally. (An offset is just a distance, in bytes, from the start of the data — "byte number 40" rather than "the 40th item in a list." "Manual offset arithmetic" would mean the programmer has to calculate and track that distance by hand; here the reader does it for you.)
Why not read the whole struct at once?
binary.Read can decode a whole struct in one call if every field is a
fixed-size type. The code reads field-by-field to make each error message
name the failing field explicitly, which is more useful when debugging
corrupted files. Both styles are correct.
Skipping unknown fmt bytes safely¶
The fmt subchunk is 16 bytes for PCM but can be larger for compressed formats.
The size is declared by subchunk1Size, a uint32 from the file.
// from dr-wav-go/dr_wav.go
// Skip any extra format bytes. Seek rather than allocate: subchunk1Size is an
// untrusted uint32, so make([]byte, subchunk1Size-16) is an OOM vector. If the
// declared size runs past EOF, the next chunk read fails cleanly.
if subchunk1Size > 16 {
if _, err := r.Seek(int64(subchunk1Size-16), io.SeekCurrent); err != nil {
return nil, fmt.Errorf("failed to skip extra format bytes: %w", err)
}
}
In plain terms: if the file says its fmt section is bigger than the normal 16 bytes, jump the reading position forward past the extra bytes without ever copying them into memory. If that jump fails (for example, it would land past the end of the file), the function returns an error instead of the parsed data — meaning it immediately stops and hands that error back to whatever code called it, rather than continuing.
Seek with io.SeekCurrent moves the internal cursor by n bytes without
reading or allocating. (Allocating means asking the computer to reserve a chunk of memory — its working space — for something; the risk in this lesson is reserving far more than the machine actually has.) If the seek lands past EOF, the next read operation
returns an error — a clean, safe failure rather than an OOM crash. (EOF = "end of file," the point where there is no more data left to read. OOM = "out of memory" — the computer tried to reserve more working space than it has available, which crashes the program.)
Finding the data chunk¶
WAV files can have subchunks between fmt and data (e.g. LIST, cue).
readDataChunk loops over them, skipping unknown ones and stopping when it sees
"data":
// from dr-wav-go/dr_wav.go
func readDataChunk(r *bytes.Reader) ([]byte, error) {
for {
var subchunkID [4]byte
binary.Read(r, binary.LittleEndian, &subchunkID)
var subchunkSize uint32
binary.Read(r, binary.LittleEndian, &subchunkSize)
if string(subchunkID[:]) == "data" {
allocSize := int(subchunkSize)
if allocSize > r.Len() {
allocSize = r.Len() // never trust the declared size past EOF
}
pcmData := make([]byte, allocSize)
io.ReadFull(r, pcmData)
return pcmData, nil
}
// Skip this subchunk.
r.Seek(int64(subchunkSize), io.SeekCurrent)
}
}
In plain terms: this function repeats ("loops") the same steps over and over: read a 4-byte tag, read a 4-byte size, and check whether the tag is "data". If it's not, jump past that subchunk and go around again. If it is "data", first shrink the claimed size down to what's actually left in the file (the cap discussed below), then reserve exactly that much memory (make([]byte, allocSize) — this is the allocation step), fill it by reading that many bytes, and hand the result back to whoever called this function.
The critical line is the cap:
r.Len() returns the number of bytes actually remaining in the reader. A
file claiming a 4 GB data chunk but containing only 100 bytes will allocate
100 bytes, not 4 GB. This is the fix for the fuzz-discovered OOM bug (see
Lesson 17 for how go test -fuzz found it).
In plain terms: fuzzing is an automated testing technique that bombards code with huge numbers of randomly mutated, often malformed inputs to try to trigger crashes. A test here is a small piece of code, separate from the main program, whose only job is to check that other code behaves correctly.
Size fields are always untrusted
Any integer read from a file, network packet, or user input that controls
an allocation is an OOM vector. The rule is: cap to bytes present, not
bytes declared. The same principle applies in miniz-go for ZIP entry
sizes and in tinyxml2-go for nesting depth.
Division-by-zero guards in derived calculations¶
After parse, GetSampleCount computes how many samples are in the data. (The (w *WAV) before the function name makes this a method — a function that is attached to a particular kind of struct, here WAV, and is called as someWav.GetSampleCount() rather than standing alone.)
// from dr-wav-go/dr_wav.go
func (w *WAV) GetSampleCount() int {
bytesPerSample := int(w.Header.BitsPerSample) / 8
if bytesPerSample == 0 || w.Header.NumChannels == 0 {
return 0
}
return len(w.Data) / bytesPerSample / int(w.Header.NumChannels)
}
In plain terms: work out how many bytes make up one audio sample; if that comes out to zero, or if there are zero channels, just report a count of 0 instead of doing a division that would blow up the program. Otherwise, divide the total data length by the sample size and by the channel count to get the sample count.
Parse does not reject a zero-channel header — that is left to ValidateWAV.
So GetSampleCount must guard the division independently. The same guard
applies to bytesPerSample: if BitsPerSample is 0, dividing by 8 gives 0,
and dividing by that would panic. (A panic is Go's term for the program hitting an error so severe it stops running immediately, right there — dividing any number by zero is one of the classic ways to trigger one.)
Serialization: the 4 GB guard¶
Serialize writes a WAV file back to bytes. RIFF size fields are uint32, so
a PCM payload larger than 2^32 - 1 - 44 bytes cannot be represented:
// from dr-wav-go/dr_wav.go
const maxWAVDataSize = math.MaxUint32 - 44
func Serialize(wav *WAV) ([]byte, error) {
if len(wav.Data) > maxWAVDataSize {
return nil, fmt.Errorf("WAV data too large to serialize: %d bytes", len(wav.Data))
}
// … write fields …
}
In plain terms: before doing any real work, check whether the audio data is too big to fit in a WAV file's size fields; if it is, immediately hand back an error and nothing else (the nil means "no bytes to give back"), rather than pressing on and writing a broken file.
The check happens before any allocation. Failing fast with a clear error is preferable to writing a truncated file that silently corrupts audio data.
Validation as a second pass¶
ValidateWAV enforces semantic constraints that Parse intentionally skips:
// from dr-wav-go/dr_wav.go
func ValidateWAV(wav *WAV) error {
if wav.Header.AudioFormat != 1 {
return fmt.Errorf("unsupported audio format: %d (only PCM supported)", wav.Header.AudioFormat)
}
if wav.Header.NumChannels == 0 {
return errors.New("invalid number of channels: 0")
}
if wav.Header.SampleRate == 0 {
return errors.New("invalid sample rate: 0")
}
if wav.Header.BitsPerSample != 8 && wav.Header.BitsPerSample != 16 &&
wav.Header.BitsPerSample != 24 && wav.Header.BitsPerSample != 32 {
return fmt.Errorf("unsupported bits per sample: %d", wav.Header.BitsPerSample)
}
return nil
}
This separation keeps Parse focused on structure (does the byte layout make
sense?) and ValidateWAV focused on semantics (do the values make sense?). A
library that silently rejects odd-but-parseable headers surprises callers; one
that parses everything and surfaces validation as a separate step is more
composable.
In plain terms: a library (also called a package) is a bundle of code someone wrote and shared so other programs can reuse it. A caller is whatever other code runs ("calls") a given function — here, whoever uses this WAV library.
Try it
Run the dr-wav tests, including the fuzz regression corpus:
Expected outcome: all tests pass in a few milliseconds. You should see test
names like TestParse, TestValidateWAV, TestWAV_GetSampleCount, and
TestSerialize. None should report a FAIL line.
To replay just the fuzz regression seeds (no new mutation, fast):
If fuzz seeds exist under testdata/fuzz/FuzzParse/, each one is run as a
named sub-test and should pass cleanly — these are the exact byte sequences
that previously caused OOM crashes.
Reading deeper
ParseBatch in dr-wav-go/dr_wav.go uses a worker pool (one goroutine
per CPU — a goroutine is a lightweight, independently-running slice of a
program that Go can run at the same time as others, which is how a
program does several things concurrently, i.e. with multiple parts
genuinely making progress simultaneously instead of one strictly after
another) to parse multiple WAV files concurrently. The channel buffer
sizes (a channel is a pipe that goroutines use to hand values to each
other safely) match len(dataList) exactly so no goroutine blocks on
a send (to "block" means the line of code simply waits right there,
doing nothing else, until some condition is met — here, until there is
room in the channel to accept the value) —
compare this with the deadlock bug in jsmn-go described in
Lesson 14, where a buffer that was one slot too
small caused wg.Wait to hang forever. (A deadlock is when one or
more of these waiting points can never be satisfied, so the program
freezes permanently instead of just pausing briefly.)
Key takeaways¶
encoding/binary.LittleEndian+bytes.Readergives you a streaming, stateful view of raw bytes; eachbinary.Readadvances the cursor by exactly the size of its target type — no offset math needed.- Every size field from an untrusted source is an OOM vector. Cap allocations
to
r.Len()(bytes actually present), never to the declared size. - Seek to skip, never allocate-and-discard.
r.Seek(n, io.SeekCurrent)is free;make([]byte, n)followed by a read is proportional ton. - Separate parse from validate.
Parsechecks byte-level structure;ValidateWAVchecks semantic invariants. Callers that only need to transcode a file can skip validation; callers that need clean data run both. - Guard every division by a field that could be zero.
BitsPerSampleandNumChannelscome from the file and can both be zero;GetSampleCountreturns 0 rather than panicking when either is absent.