WAV Audio Files
Binary File Formats in Practice
Chapter 6 ยท WAV Audio Files
The PNG in the last chapter was a signature followed by chunks. A WAV file is built the same way, with two differences worth noticing: it wraps everything in an outer RIFF container, and its integers are little-endian again. Sound is also a gentler subject for practice than images, because "the pixel data" is just a list of numbers over time. This chapter pulls apart a tiny WAV, reads its format fields, generates a tone, and then lets Python's wave module do the same job. It builds on Binary File Formats in Practice 2, Binary File Formats in Practice 3 and Binary File Formats in Practice 5.
Sound as Numbers
A microphone signal is continuous, so digital audio samples it: it measures the level many times per second and stores each measurement as an integer. Three numbers describe the result:
- Sample rate: measurements per second (8000 for crude telephone quality, 44100 for CD audio).
- Bits per sample: how precisely each level is stored. 16 bits per sample means a signed integer from -32768 to 32767.
- Channels: 1 for mono, 2 for stereo. One frame holds one sample for every channel.
Storing the samples as they are, with no compression, is called PCM (pulse-code modulation). That is the plain kind of WAV this chapter handles; WAV can also hold compressed audio, which we refuse politely rather than decode.
The RIFF Layout
RIFF is a general container: a 4-byte id, a 4-byte size, then contents. A WAV is a RIFF whose form type is WAVE, followed by chunks in the same id-size-data shape as PNG, but with a little-endian size and no CRC:
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | RIFF | Container id |
| 4 | 4 | Size | Bytes after this field, so file size minus 8 |
| 8 | 4 | WAVE | Form type |
| 12 | 8 + n | Chunks | Each is id (4), size (4, little-endian), data |
There is one rule PNG did not have: if a chunk's size is odd, a single pad byte follows its data so the next chunk starts on an even offset. The pad byte is not counted in the size. A reader steps to the next chunk with pos += 8 + size + (size & 1).
Writing a Tiny WAV
Let wave write a file of four samples into a BytesIO (an in-memory file, so nothing touches the disk), then look at the raw bytes:
Read it in pieces. 52 49 46 46 is RIFF; 2c 00 00 00 is 44, which is 52 minus 8; 57 41 56 45 is WAVE. Then 66 6d 74 20 is fmt (note the trailing space, because ids are exactly four bytes) with size 10 00 00 00, 16. After its 16 bytes comes 64 61 74 61, data, with size 8: four samples of two bytes each. The last eight bytes are the samples themselves, 00 00, e8 03 (1000), 18 fc (-1000) and ff 7f (32767), each little-endian.
The fmt Chunk
The 16 bytes of the fmt chunk unpack with one format string, "<HHIIHH":
| Field | Code | Meaning | Value above |
|---|---|---|---|
| Format tag | H | 1 means plain PCM | 1 |
| Channels | H | 1 mono, 2 stereo | 1 |
| Sample rate | I | Frames per second | 8000 |
| Byte rate | I | Sample rate times block align | 16000 |
| Block align | H | Bytes per frame: channels times bytes per sample | 2 |
| Bits per sample | H | Usually 8, 16 or 24 | 16 |
The byte rate and block align can be worked out from the other fields. They are stored anyway, and a careful reader checks that they agree, because a file that contradicts itself is damaged or hostile.
Walking the Chunks by Hand
This is the PNG walker again with three changes: little-endian sizes, the form type check, and the pad byte. The length is checked against the data really present before it is used:
Files written by other programs often contain extra chunks, such as LIST (metadata like a title or the encoder name) (check). Because the walker skips by size, it steps over chunks it does not understand, which is how RIFF readers stay compatible. Be careful with the size check on the outer container, though: some real-world writers leave a wrong size in a streamed file (check), so a tolerant reader might only warn. This course's reader is strict.
Generating a Tone
A pure tone is a sine wave. For a frequency f in hertz, sample number i at rate r is at time i / r seconds, and the level there is sin(2 * pi * f * i / r). Scale that from the range -1 to 1 up to 16-bit integers, keeping the amplitude below the maximum so the peaks do not clip:
A hundredth of a second at 8000 samples per second is 80 samples, so 160 bytes. The values rise to a peak and begin to fall: the first part of a wave. The exact digits depend on floating-point rounding, so a different platform could differ in the last place (check).
Writing the Container Yourself
Writing is easier than reading, because you control every field. This function builds the whole file from PCM bytes, and the test compares it with what the wave module produced earlier:
The two files are byte-for-byte identical. The only value computed late is the RIFF size, which needs the length of everything after it, so the body is built first and measured.
Reading Back With wave
For ordinary files the standard library does the parsing. Write half a second of 440 Hz (the musical note A above middle C) and ask the module about it:
readframes(10) returns 10 frames, which is 20 bytes here because each mono 16-bit frame is 2 bytes. Frames, not bytes, is the unit the module counts in. The module reads plain PCM only and raises an error for other format tags, which is why a hand-written reader is still worth knowing: you can see exactly what it accepts.
wave.open instead of a BytesIO to save a real .wav file, which any audio player can open. The exercises use a temporary folder and remove it afterwards, so you can listen by keeping the file if you want to.
| Idea | PNG (Binary File Formats in Practice 5) | WAV |
|---|---|---|
| Outer container | 8-byte signature only | RIFF, size, WAVE (12 bytes) |
| Byte order | Big-endian | Little-endian |
| Chunk shape | Length, type, data, CRC | Id, size, data, pad byte if the size is odd |
| Damage detection | CRC-32 per chunk | None; check sizes and field agreement instead |
| Payload | zlib-compressed rows | Raw samples |
Challenges
Write list_chunks(raw) that walks a WAV file and returns a list of (id, size) pairs. It must reject a wrong signature, a chunk whose size runs past the end of the data, and a file with too few bytes left to hold a chunk header, and it must step over the pad byte after an odd-sized chunk. Build a WAV by hand that includes an extra odd-sized chunk before the data chunk, show the list, and show all three failures.
Write describe(raw) that returns a dictionary with channels, sample rate, bits per sample, number of frames and duration in seconds. It must refuse a format tag other than 1, a file with no fmt or data chunk, and a header whose byte rate or block align does not agree with the other fields. Show a one-second mono 8000 Hz file, a one-second stereo 44100 Hz file, and the two header failures.
Write write_tone(path, freq, secs, rate, amp) using the wave module and read_by_hand(path) that parses the same file with struct and returns the rate and the list of samples. In a temporary folder, write a quarter second of 440 Hz, read the samples with wave and by hand, and show that the results are equal, that the peak is close to half of full scale, and that the file size is 44 bytes plus 2 bytes per frame. Remove the folder and confirm it is gone.
Quick Reference
- Header:
RIFF, little-endian size (file size minus 8),WAVE; chunks start at offset 12 - Chunk: id (4) + size (4, little-endian) + data, plus one pad byte if the size is odd
- fmt chunk:
struct.unpack("<HHIIHH", data)gives tag, channels, rate, byte rate, block align, bits - PCM: tag 1; frame size = channels times bytes per sample = block align
- Samples: 16-bit PCM is signed little-endian,
"<h"each - Duration: frames divided by sample rate
- wave module:
setnchannels,setsampwidth,setframerate,writeframes; counts in frames, not bytes - Always: check each size against the data you really have, and check that redundant fields agree