zig-ogg #
A reader and writer for the Ogg container format, written in Zig against
RFC 3533, the specification of the format. It is a library first: it
has no filesystem, no threads and no dependencies, reads from a std.Io.Reader
and writes to a std.Io.Writer, and leaves the question of where the bytes come
from to whoever is calling it. A command-line tool comes with it, mostly to
prove the library can be used.
$ zig-ogg info song.ogg
song.ogg: 412 pages, 1 logical bitstream
serial 0x784d00be Vorbis audio 2104 packets, 3015291 bytes, ends at granule 2646000
$ zig-ogg check song.ogg
song.ogg: OK, 412 pages, 2104 packets
The API documentation is generated from the doc comments, where most of the explanation lives, and is published at https://jeff.jcollie.page/zig-ogg/.
What Ogg is, and what it is not #
Ogg is framing and nothing else. It takes packets from a codec, chops them into pages of a few kilobytes with a checksum and a position marker on each, and interleaves the pages of however many logical bitstreams share the file. It knows nothing about what the packets mean — not even how long a second is, since "Ogg does not have a concept of 'time': it only knows about sequentially increasing, unitless position markers" (RFC 3533, Section 4).
So this library moves packets in and out of pages and leaves every question about their contents to whoever wrote them. It does not decode Vorbis, FLAC, Opus or anything else; what it will tell you is which of them a stream claims to be, because that is written in the first page and is the container's business.
Ogg files are application/ogg, audio/ogg or video/ogg depending on what
they carry, per RFC 5334; .ogg, .oga, .ogv and .opus are the
extensions in use.
What it does #
Pages are parsed and written in full: the capture pattern, the version, all
three header flags, the granule position with its -1 sentinel, the serial and
sequence numbers, the checksum, and the segment table with the lacing rules that
make a packet's length unbounded. A page that parsed is written back byte for
byte.
Demultiplexing handles both kinds of multiplexing RFC 3533 defines, and does not need to be told which it is looking at. "Grouping" interleaves the pages of concurrent logical bitstreams; "chaining" concatenates whole streams end to end; both are just pages keyed by serial number, so a demuxer that looks the number up and reassembles per stream handles either, or the two together.
Recovering from damage is what the capture pattern and the per-page checksum
are for, and PageReader.resync uses them: it steps over corrupt bytes until a
page whose checksum is right turns up, and says how many it threw away. A stream
joined in the middle, a page lost from the sequence, and a page that contradicts
the one before it about whether a packet was still open are all reported rather
than quietly papered over — the packet that follows arrives with after_hole
set.
Writing puts packets into pages, choosing page boundaries by a target size, and gets the parts right that are easy to get wrong: the end-of-stream flag lands on a page with content on it, the beginning-of-stream page carries the identification packet alone, a page where no packet finishes carries no granule position, and a packet whose length is a multiple of 255 gets its terminating zero.
Not implemented: seeking. The granule positions and the page index are all
there, and bisecting on them is how seeking in Ogg is done, but nothing here
does it yet — it needs a seekable input, which the std.Io.Reader this takes is
not required to be. Nor is there a muxer that decides for you how to
interleave several streams' pages; writing two streams into one file is done by
pointing two StreamWriters at the same output, which leaves the interleaving
policy where the caller can see it.
Using the library #
Fetch it and import the module it exposes as ogg:
$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-ogg.git
const ogg = b.dependency("ogg", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("ogg", ogg.module("ogg"));
Reading packets #
Demuxer is the whole of demultiplexing. Packets come out in the order the
pages carrying them appear, each tagged with the serial number of the logical
bitstream it belongs to:
const ogg = @import("ogg");
var buffer: [ogg.Page.max_size]u8 = undefined;
var d: ogg.Demuxer = .init(gpa, reader, &buffer, .{});
defer d.deinit();
while (try d.next()) |packet| {
if (packet.first) {
// The first packet of a stream identifies its codec.
const codec = ogg.codec.identify(packet.data) orelse continue;
std.debug.print("0x{x}: {s}\n", .{ packet.serial_number, codec.description() });
}
// packet.data is valid until the next call to next().
}
A Packet carries a little more than its bytes:
| Field | What it says |
|---|---|
serial_number |
Which logical bitstream it came from. |
data |
The packet. Valid until the next call to next. |
granule_position |
Where the codec's position counter stood afterwards, when the stream said — which is only for the last packet to finish on a page, since a page carries one position and that is whose it is. Null otherwise. |
index |
Counts packets within the logical bitstream from zero. |
first, last |
It came off the bos page, or finished on the eos page. |
after_hole |
Something before this packet was lost. The packet itself is whole. |
Writing packets #
One StreamWriter is one logical bitstream:
var body: [ogg.Page.max_body_size]u8 = undefined;
var w: ogg.StreamWriter = .init(writer, &body, .{ .serial_number = serial });
try w.writePacket(identification_header, 0);
try w.writePacket(comment_header, 0);
for (audio[0 .. audio.len - 1]) |frame| try w.writePacket(frame.bytes, frame.samples);
try w.writeLastPacket(audio[audio.len - 1].bytes, total_samples);
writeLastPacket is worth using wherever the last packet is known, so that the
end-of-stream flag goes on the page carrying it rather than on an empty page
after it. flush ends the current page early, which is what a live stream wants
and what the mapping's rule about the bos page needs; finish ends the stream.
Two streams in one file — grouping — is two writers pointed at the same output. The format requires every bos page to come before any other page, which is why the writer does not hold those two back:
var a: ogg.StreamWriter = .init(writer, &body_a, .{ .serial_number = 100 });
var b: ogg.StreamWriter = .init(writer, &body_b, .{ .serial_number = 200 });
try a.writePacket(a_header, 0); // both bos pages go out at once...
try b.writePacket(b_header, 0);
// ...and only then does content follow, in whatever order suits.
One stream after another — chaining — needs nothing at all: finish one and
start the next.
Pages on their own #
Below the demuxer, PageReader hands back one Page at a time for a caller
that wants the framing rather than the contents, and Page.parse is the parser
by itself, over bytes, with no I/O:
var pr: ogg.PageReader = .init(reader, &buffer);
while (try pr.next()) |page| {
// page.lacing, page.body, page.packets() ...
}
Page.packets() walks the packet fragments a page holds, saying of each whether
it is the tail of one that began earlier and whether it ends here. That is the
layer Demuxer is built on, and is what to use to do something else with.
What allocates, and what does not #
A page is bounded and a packet is not, and that one fact decides the shape of everything here.
A segment table holds at most 255 entries of at most 255 bytes, so no page
exceeds 65307 bytes however hostile the stream is. Page, PageReader and
StreamWriter therefore allocate nothing at all: each works out of one buffer
the caller hands it, sized by the Page.max_size and Page.max_body_size
constants, and a page read is handed back as slices into that buffer rather than
copied out of it.
A packet, on the other hand, can be continued from page to page without end. So
Demuxer — the only part that reassembles one — takes an allocator, and takes a
max_packet_size to refuse at, because otherwise a stream whose every page ends
mid-packet is a request for all the memory there is. max_streams is the same
answer to a stream that names a new serial number on every page. Even there,
nothing is copied in the ordinary case: a packet that begins and ends inside one
page points into the page buffer.
The command-line tool #
$ zig-ogg info <file.ogg>... # what logical bitstreams a file holds
$ zig-ogg pages <file.ogg> # one line per page
$ zig-ogg packets <file.ogg> # one line per packet
$ zig-ogg check <file.ogg>... # verify every page's checksum and order
pages is the one to reach for when a file is behaving oddly, since it shows
the framing decisions another muxer made:
$ zig-ogg pages src/tests/data/grouped.ogg
0 serial 0x6a32f4ce page 0 -b- 1 segments, 30 bytes, granule 0
58 serial 0xcf6f1915 page 0 -b- 1 segments, 19 bytes, granule 0
105 serial 0x6a32f4ce page 1 --- 11 segments, 2538 bytes, granule 0
2681 serial 0xcf6f1915 page 1 --- 1 segments, 60 bytes, granule 0
2769 serial 0x6a32f4ce page 2 --e 25 segments, 447 bytes, granule 6000
3268 serial 0xcf6f1915 page 2 --e 38 segments, 3194 bytes, granule 36312
The three flag columns are continued, bos and eos. Two logical bitstreams
are visible here by their serial numbers, both bos pages ahead of any content,
and the two granule positions count in different units because the two codecs
chose differently — 8000 Hz samples for the Vorbis stream and 48000 Hz for the
Opus one.
How it is tested #
Three kinds of test, because each catches what the others cannot.
Against pages somebody else framed. src/tests/data/ holds three small Ogg
files made with ffmpeg, and the tests assert their page tables field by field.
This is what checks the page checksum: its parameters are not any of the
catalogued CRC-32s, and the proof that they are right is that the value computed
here is the one libogg wrote. A test that asserts what this implementation
produced would prove only that it has not changed.
src/tests/data/README.md says what each file is for and how it was made.
Against the specification's own statements. Where the format says something
a test says it back: a nil page carries no packets, a packet whose length is a
multiple of 255 ends in a zero lacing value, a lacing value of 255 means the
packet runs on, a page where no packet finishes carries -1, a page is at most
65307 bytes.
Against itself, over every input. src/tests/fuzz.zig states four
properties that must hold for any bytes at all: demuxing anything terminates,
reports and frees; a page that parsed is written back unchanged; resynchronising
accounts for every byte of the input, either as part of a page handed back or as
one it says it dropped; and a round trip through the writer and the demuxer is
the identity, at every page size and for packets of every length either side of
a segment and a page boundary. Every target runs under an allocator with a
budget, so the allocation-failure paths are exercised at the same time.
$ nix develop --command zig build test # unit tests and the fuzz corpus
$ nix develop --command zig build check # compile everything, run nothing
$ nix develop --command zig build fuzz --fuzz # Zig's fuzzer, until interrupted
$ nix develop --command zig build fuzz-run -- --seconds 300
$ nix develop --command reuse lint
$ nix develop --command zig fmt --check .
zig build fuzz --fuzz runs those targets under Zig's own fuzzer, which follows
coverage; --fuzz=1M makes it a bounded run that ends in a report. The fuzz
test binary is compiled with LLVM even in Debug, because the self-hosted backend
emits no coverage instrumentation and the fuzzer would have nothing to steer by.
A finding prints input saved to '.zig-cache/f/crash' above the report.
zig build fuzz-run drives the same targets from tools/fuzz.zig instead,
mutating a corpus of real streams rather than following coverage. It is the
quicker of the two to reproduce a finding with (--input), and its findings
land in fuzz-findings/.
Getting the source #
The repository has three homes, and they carry the same history.
The everyday one is a Forgejo instance:
git clone https://git.jcollie.dev/jeff/zig-ogg.git
A mirror is kept on Tangled, which builds its forge on the AT Protocol:
https://tangled.org/jcollie.dev/zig-ogg
It is also published on Radicle, a peer-to-peer code forge built on git, where a repository is found by its Repository ID (RID) rather than by a server name:
rad:z4ByfsGeREmwFqFkkBkn1AxsBs1Tj
If you do not have Radicle yet, install it, create an identity, and start your node:
curl -sSf https://radicle.dev/install | sh
rad auth
rad node start
Then clone the repository:
rad clone rad:z4ByfsGeREmwFqFkkBkn1AxsBs1Tj
rad clone consults your node's routing table to find seeds that carry the
repository, and drops the working copy into a zig-ogg directory. Pass a target
path as a second argument to put it somewhere else. If your node has not
discovered a seed yet, name one directly with --seed <NID>.
The result is an ordinary git working copy on the main branch, with a rad
remote wired up, so everyday work is just git. To pull in changes published
since your clone:
rad sync --fetch
git pull rad main
Building #
It builds with Zig 0.17. The last release for Zig 0.16 is tagged v0.1.0, and
the zig-0.16 branch carries it.
Zig is not on PATH; everything runs through the flake's devshell.
$ nix develop
$ zig build # the command-line tool into zig-out/bin
$ zig build docs # the API documentation into zig-out/docs
$ zig build docs-serve # ...and read it at http://127.0.0.1:8000
The documentation has to be served rather than opened: what Zig emits is not a
page but a program — a WebAssembly viewer, its javascript, and a tar of the
sources it reads from — and a browser will not fetch those from a file://
page.
What is where #
| File | What is in it |
|---|---|
src/page.zig |
The page header, the segment table, and the packet fragments a page carries. Pure: bytes in, bytes out, no I/O and no allocation. |
src/crc.zig |
The page checksum, and the note on why it is spelled out rather than taken from the standard library's catalogue. |
src/reader.zig |
Pages off a std.Io.Reader, and resynchronising after damage. |
src/writer.zig |
Packets into pages, one logical bitstream at a time. |
src/demuxer.zig |
Pages back into packets, any number of streams at once. The one part that allocates. |
src/codec.zig |
Which codec a logical bitstream's first packet says it is. |
src/main.zig |
The command-line tool, and the only thing here that opens a file. |
tools/fuzz.zig |
A corpus-mutating fuzzing loop around the same targets as Zig's fuzzer. |
tools/docs_server.zig |
A one-directory, loopback-only HTTP server for reading the docs. |
Licence #
MIT. Every file carries an SPDX header and reuse lint is expected to pass;
LICENSES/MIT.txt is the text. The Ogg streams under src/tests/data/ were
generated for this project and are covered by REUSE.toml.
References cited #
Pfeiffer, Silvia. The Ogg Encapsulation Format Version 0. RFC 3533. RFC Editor, May 2003. https://www.rfc-editor.org/info/rfc3533.
Goncalves, Ivo, Silvia Pfeiffer, and Christopher Montgomery. Ogg Media Types. RFC 5334. RFC Editor, September 2008. https://www.rfc-editor.org/info/rfc5334.
Terriberry, Timothy B., Ron Lee, and Ralph Giles. Ogg Encapsulation for the Opus Audio Codec. RFC 7845. RFC Editor, April 2016. https://www.rfc-editor.org/info/rfc7845.
Xiph.Org Foundation. Ogg Logical Bitstream Framing. https://xiph.org/ogg/doc/framing.html. The statement of the checksum's parameters — "direct algorithm, initial val and final XOR = 0" — that RFC 3533 leaves out.
Xiph.Org Foundation. Ogg Mapping for FLAC. https://xiph.org/flac/ogg_mapping.html.
Xiph.Org Foundation. Vorbis I Specification. https://xiph.org/vorbis/doc/Vorbis_I_spec.html.
Xiph.Org Foundation. MIME Types and File Extensions. XiphWiki.
https://wiki.xiph.org/MIMETypesCodecs. The collected identification packets
of the published Ogg mappings, which is what src/codec.zig compares against.
- Zig 99%
- Nix <1%