A sans-I/O reader and writer for RIFF, the container underneath WAV, AVI and WebP.
  • Zig 88.6%
  • Nix 11.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie b8e61dc1f9
All checks were successful
test / nix (push) Successful in 2m22s
test / test (push) Successful in 2m14s
test / docs (push) Successful in 2m28s
Say what a Radicle peer needs, and correct the payload example
The README and the module comment both still showed `payload` handing back
a bounded reader by value, which it no longer does --- it keeps the reader
so that what a decoder took goes back into the walk without the caller
saying anything, and returns the interface.

And the repository is on Radicle, where it is findable by its identifier
and by nothing else, so a README that leaves the identifier out has left
out the one thing a peer needs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01MdXttGjXJxdbBPZQQNyR6h
2026-09-19 14:35:26 -05:00
.forgejo/workflows Read and write RIFF 2026-09-19 13:41:13 -05:00
LICENSES Read and write RIFF 2026-09-19 13:41:13 -05:00
src Say what a Radicle peer needs, and correct the payload example 2026-09-19 14:35:26 -05:00
tools Read and write RIFF 2026-09-19 13:41:13 -05:00
.gitignore Read and write RIFF 2026-09-19 13:41:13 -05:00
build.zig Read and write RIFF 2026-09-19 13:41:13 -05:00
build.zig.zon Read and write RIFF 2026-09-19 13:41:13 -05:00
flake.lock Read and write RIFF 2026-09-19 13:41:13 -05:00
flake.nix Read and write RIFF 2026-09-19 13:41:13 -05:00
package.nix Read and write RIFF 2026-09-19 13:41:13 -05:00
README.md Say what a Radicle peer needs, and correct the payload example 2026-09-19 14:35:26 -05:00
REUSE.toml Read and write RIFF 2026-09-19 13:41:13 -05:00

zig-riff

A sans-I/O reader and writer for RIFF, the container underneath WAV, AVI, WebP, DLS, RMID and a good deal else.

RIFF is a container and not a format. A file is twelve bytes of header — RIFF, a length, and a four-character form type saying what kind of file this is — and then a sequence of chunks, each a four-character identifier, a length, and that many bytes. Everything that makes a WAV a WAV is in the chunks; everything in this library is the walk between them.

The API documentation is generated from the doc comments in the source, which is where the reasoning lives.

Using it

var reader: riff.Reader = try .init(&stream);
if (!reader.form.is("WEBP")) return error.NotWebp;

while (try reader.next()) |chunk| {
    if (chunk.is("VP8L")) {
        var buf: [64]u8 = undefined;
        try decodeLossless(reader.payload(&buf));
    }
    // Anything left unread is stepped over by the next `next`.
}

Sans-I/O: everything reads through a caller-supplied std.Io.Reader and writes through a std.Io.Writer. Nothing here opens a file, so a walk over a network stream is the same code as one over memory, and the whole library is testable without touching a disk.

Add it with:

zig fetch --save git+https://git.jcollie.dev/jeff/zig-riff.git

and depend on the module named riff.

What it handles, and why each is worth saying

The pad byte

A chunk is padded to an even length, and the pad byte is not counted in the chunk's length. This is the single most common way to write a RIFF parser that works on most files and desynchronises on some, because a file whose chunks all happen to have even lengths never exercises it. Every length here goes through Chunk.padded, and the walk steps over the pad byte without the caller having to know it is there.

Nesting

A LIST chunk holds a four-character list type and then more chunks, which is how AVI stores almost everything. enter descends into one, and next climbs back out when it runs out — so a caller writes one loop rather than a recursive walk:

while (try reader.next()) |chunk| {
    if (chunk.is("LIST")) {
        const kind = try reader.enter();
        if (kind.is("movi")) { ... }
        continue;
    }
}

Bytes consumed come out of every open level at once, since the levels nest, and a chunk claiming more than the container holding it has left is refused rather than believed.

Both byte orders

Almost every RIFF file is little-endian and begins RIFF. The big-endian variant begins RIFX, is rare and real, and costs one branch. The identifiers are characters and are never byte-swapped — which is the thing most likely to be got wrong when adding the second order to a parser that only had the first.

Truncation

A file that stops on a chunk boundary is read up to where it stops rather than refused: a recorder killed mid-write leaves a header promising more than arrived, and what did arrive is still worth having. A length that runs past its container is a different thing and is an error.

RF64, by name

RF64 is the WAV extension for files past four gigabytes: it replaces the RIFF tag, writes 0xffffffff where the length goes, and puts the real 64-bit sizes in a ds64 chunk. It is not handled, and it is recognised and refused as error.Rf64 rather than read as a corrupt RIFF, because a caller that meets one should be told what it is.

Writing

var writer: riff.Writer = .init("WAVE");
defer writer.deinit(gpa);

try writer.chunk(gpa, "fmt ", header_bytes);
try writer.openList(gpa, "INFO");
try writer.chunk(gpa, "INAM", "a name");
try writer.close();
try writer.chunk(gpa, "data", samples);

try writer.finish(w);

Everything is held until finish, and it has to be: the header carries the length of everything after it and a LIST carries the length of everything inside it, and neither is known until the thing is complete. A forward-only writer either buffers or seeks backwards, and this one is sans-I/O and cannot seek.

Where this lives

git clone https://git.jcollie.dev/jeff/zig-riff.git

It is on Radicle as rad:z3QNJRPpssgFMdfjV8Pgsopix3qE4, which is the only thing a peer needs to find it — a Radicle repository is discoverable by its identifier and by nothing else:

rad clone rad:z3QNJRPpssgFMdfjV8Pgsopix3qE4

Testing

nix develop -c zig build test --summary all
nix develop -c zig fmt --check .
nix develop -c reuse lint

The tests are the awkward cases rather than the happy path: a chunk of odd length followed by one a parser that forgot the pad byte would read a byte early, a LIST walked by the same loop as its siblings, a big-endian file whose identifiers must not be swapped, a chunk longer than the file holding it, and a truncated recording. There is also a property test over arbitrary bytes — that the walk terminates, stays inside the input, and never reports more content than arrived — which is the claim that matters for a parser whose every length is chosen by whoever wrote the file.

References cited

Licence

MIT, and REUSE compliant: every file carries its own copyright and licence, or is covered by REUSE.toml.