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.

What was run
Every snippet and solution in this chapter was executed, and the output shown in the comments is what Python printed. Everything is done in memory or in a temporary folder, so nothing is left behind.

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:

OffsetSizeFieldMeaning
04RIFFContainer id
44SizeBytes after this field, so file size minus 8
84WAVEForm type
128 + nChunksEach 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:

import struct, wave, io, math buf = io.BytesIO() with wave.open(buf, "wb") as w: w.setnchannels(1) w.setsampwidth(2) # bytes per sample: 2 = 16 bits w.setframerate(8000) w.writeframes(struct.pack("<4h", 0, 1000, -1000, 32767)) raw = buf.getvalue() print(len(raw)) # 52 print(raw.hex(" ")) # 52 49 46 46 2c 00 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 01 00 40 1f 00 00 80 3e 00 00 02 00 10 00 64 61 74 61 08 00 00 00 00 00 e8 03 18 fc ff 7f

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":

FieldCodeMeaningValue above
Format tagH1 means plain PCM1
ChannelsH1 mono, 2 stereo1
Sample rateIFrames per second8000
Byte rateISample rate times block align16000
Block alignHBytes per frame: channels times bytes per sample2
Bits per sampleHUsually 8, 16 or 2416

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:

def walk(raw): if raw[:4] != b"RIFF" or raw[8:12] != b"WAVE": raise ValueError("not a WAV file") (riff_size,) = struct.unpack_from("<I", raw, 4) if riff_size + 8 != len(raw): raise ValueError("RIFF size does not match file size") pos = 12 while pos < len(raw): if pos + 8 > len(raw): raise ValueError("truncated chunk header") cid, size = struct.unpack_from("<4sI", raw, pos) if pos + 8 + size > len(raw): raise ValueError(f"chunk {cid!r} runs past end of file") yield cid, raw[pos + 8:pos + 8 + size] pos += 8 + size + (size & 1) for cid, data in walk(raw): print(cid, len(data)) # b'fmt ' 16 # b'data' 8 chunks = dict(walk(raw)) fmt, ch, rate, brate, align, bits = struct.unpack("<HHIIHH", chunks[b"fmt "]) print(fmt, ch, rate, brate, align, bits) # 1 1 8000 16000 2 16 print(struct.unpack("<4h", chunks[b"data"])) # (0, 1000, -1000, 32767)

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:

def tone_bytes(freq, secs, rate=8000, amp=0.5): n = int(rate * secs) return b"".join( struct.pack("<h", round(amp * 32767 * math.sin(2 * math.pi * freq * i / rate))) for i in range(n) ) t = tone_bytes(440, 0.01) print(len(t), struct.unpack("<8h", t[:16])) # 160 (0, 5550, 10443, 14102, 16093, 16182, 14357, 10835)

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:

def make_wav(pcm, rate=8000, channels=1): fmt = struct.pack("<HHIIHH", 1, channels, rate, rate * channels * 2, channels * 2, 16) body = b"WAVE" + b"fmt " + struct.pack("<I", 16) + fmt body += b"data" + struct.pack("<I", len(pcm)) + pcm return b"RIFF" + struct.pack("<I", len(body)) + body mine = make_wav(struct.pack("<4h", 0, 1000, -1000, 32767)) print(mine == raw) # True

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:

buf = io.BytesIO() with wave.open(buf, "wb") as w: w.setnchannels(1); w.setsampwidth(2); w.setframerate(8000) w.writeframes(tone_bytes(440, 0.5)) buf.seek(0) with wave.open(buf, "rb") as r: print(r.getnchannels(), r.getsampwidth(), r.getframerate(), r.getnframes(), r.getnframes() / r.getframerate()) # 1 2 8000 4000 0.5 print(len(r.readframes(10))) # 20

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.

Writing to disk and playing it
Pass a file path to 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.
IdeaPNG (Binary File Formats in Practice 5)WAV
Outer container8-byte signature onlyRIFF, size, WAVE (12 bytes)
Byte orderBig-endianLittle-endian
Chunk shapeLength, type, data, CRCId, size, data, pad byte if the size is odd
Damage detectionCRC-32 per chunkNone; check sizes and field agreement instead
Payloadzlib-compressed rowsRaw samples

Challenges

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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.

๐Ÿ“„ View solution

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
Coming next
Binary File Formats in Practice 7 looks inside a single byte: bit fields and flags, using masks and shifts to pack several yes/no values into one number and take them apart again.