Inside an Ogg file: pages, packets & codecs
An .ogg (or .opus, or .oga) is not a codec — it is a container, and a rather elegant one. The same wrapper carries Vorbis, Opus, FLAC, Speex or Theora video, framed as a stream of self-describing, individually check-summed pages. This is a tour of that structure: the OggS page header, how segment lacing reassembles a codec's packets, what a granule position means, how several logical bitstreams can share one file, where Vorbis and Opus keep their VorbisComment/OpusTags metadata — and the JavaScript to re-tag a multi-gigabyte file byte-for-byte without re-encoding a single audio frame.
Pages, not frames
Where MP3 is a bare concatenation of frames and MP4 is a tree of boxes, Ogg is a sequence of pages. A page is a small (typically 4–8 KB, at most ~64 KB) chunk with a fixed header and a body, and its whole job is to be a robust transport: each page is independently framed by a capture pattern, carries a sequence number so a decoder can spot a dropped page, and is protected by its own CRC. The codec inside never sees pages — it sees packets, which the container laces across page boundaries.
The OggS page header
Every page begins with a 27-byte header, then a variable segment table, then the body:
4f 67 67 53 00 02 00 00 00 00 00 00 00 00 53 67 6f 4f 00 00 00 00 9e 4f 1b b1 01 1e ...
└─ "OggS" │ │ └─ granule position (i64 LE) │ │ │ │ └ segment table
capture │ └ header type (0x02 = BOS) └ serial# └ page seq# └ CRC-32 └ segment count
| Field | Bytes | Meaning |
|---|---|---|
| capture pattern | 0–3 | the ASCII OggS — how you find a page (and re-sync after junk) |
| version | 4 | always 0 |
| header type | 5 | bit 0x01 continued packet, 0x02 BOS (beginning of stream), 0x04 EOS (end) |
| granule position | 6–13 | a codec-defined timestamp (see below); -1 means "no packet ends here" |
| bitstream serial | 14–17 | which logical bitstream this page belongs to |
| page sequence | 18–21 | increments per page within a bitstream — gaps reveal loss |
| CRC-32 | 22–25 | checksum over the whole page, this field taken as zero |
| segment count | 26 | how many lacing values follow (0–255) |
Reading a page is then just arithmetic — the body length is the sum of the lacing values, and the page's total size is 27 + segmentCount + bodyLength:
function readPage(b, i) {
if (b[i] !== 0x4f || b[i+1] !== 0x67 || b[i+2] !== 0x67 || b[i+3] !== 0x53) return null; // "OggS"
const segCount = b[i + 26];
let bodyLen = 0;
for (let s = 0; s < segCount; s++) bodyLen += b[i + 27 + s]; // sum the lacing table
return {
headerType: b[i + 5], // BOS / EOS / continued
serial: u32le(b, i + 14),
seq: u32le(b, i + 18),
lacing: b.subarray(i + 27, i + 27 + segCount),
body: b.subarray(i + 27 + segCount, i + 27 + segCount + bodyLen),
size: 27 + segCount + bodyLen,
};
}
Segment lacing: packets from pages
The segment table is how Ogg encodes variable-length packets in a byte-aligned way. Each entry is 0–255. A packet of length L is written as floor(L / 255) segments of 255 followed by one final segment of L % 255. A value < 255 ends a packet; a value of exactly 255 means "the packet continues" in the next segment (and, if it is the last segment on the page, onto the next page, whose header type has the continued bit set). A packet whose length is an exact multiple of 255 therefore ends with a zero-length terminating segment — that is not a quirk, it is how the reader knows the packet is done.
// Reassemble packets from a bitstream's pages.
function packets(pages) {
const out = []; let cur = [];
for (const pg of pages) {
let off = 0;
for (const lv of pg.lacing) {
cur.push(pg.body.subarray(off, off + lv)); off += lv;
if (lv < 255) { out.push(concat(cur)); cur = []; } // < 255 terminates
}
}
return out;
}
Granule positions: Ogg's clock
The granule position is the one field only the codec can interpret. For Vorbis it is the number of PCM samples decodable by the end of the page, so the duration is simply lastGranule / sampleRate. For Opus it is always counted at 48 kHz regardless of the original rate, and you subtract the pre-skip (the encoder delay declared in the header): (lastGranule - preSkip) / 48000. This is why you can get an exact duration from a 20 GB file without decoding it — you read the granule position off the very last page.
const durationSec = codec === 'opus'
? (lastGranule - preSkip) / 48000
: lastGranule / sampleRate;
Logical bitstreams
One physical Ogg file can interleave several logical bitstreams, each identified by its 32-bit serial number — that is how an Ogg Theora video multiplexes a Vorbis audio track. Every bitstream opens with a BOS page (header type 0x02) and ends with an EOS page (0x04); grouping pages by serial and looking at each bitstream's first packet tells you what codecs are present. A plain .ogg/.opus music file is a single bitstream, which is what makes safe in-place tag editing tractable.
Codec headers & where the tags live
The first packets of a bitstream are its headers. You identify the codec from the first bytes of the very first packet:
| Codec | First-packet magic | Headers | Tag format |
|---|---|---|---|
| Vorbis | 0x01 "vorbis" | identification, comment, setup | VorbisComment |
| Opus | "OpusHead" | OpusHead, OpusTags | OpusTags |
| FLAC | 0x7F "FLAC" | mapping + STREAMINFO, then metadata blocks | VorbisComment |
| Speex | "Speex " | header, comment | VorbisComment |
| Theora | 0x80 "theora" | identification, comment, setup | VorbisComment |
The second packet holds the metadata, and it is the same VorbisComment body everywhere (Opus just prefixes it with OpusTags instead of a Vorbis marker): a vendor string, then a count, then a list of UTF-8 KEY=value lines. Parsing it is a walk of little-endian lengths:
function parseComments(pkt, headerLen) {
let o = headerLen; // 7 for "\x03vorbis", 8 for "OpusTags"
const vendorLen = u32le(pkt, o); o += 4;
const vendor = utf8(pkt.subarray(o, o += vendorLen));
const count = u32le(pkt, o); o += 4;
const tags = [];
for (let i = 0; i < count; i++) {
const len = u32le(pkt, o); o += 4;
const s = utf8(pkt.subarray(o, o += len)); // "ARTIST=Aphex Twin"
const eq = s.indexOf('=');
tags.push({ key: s.slice(0, eq), value: s.slice(eq + 1) });
}
return { vendor, tags };
}
The page CRC
Each page carries a CRC-32 over its own bytes — with the four CRC bytes taken as zero during the computation. Ogg uses an unusual variant: polynomial 0x04c11db7, initial value 0, and no input/output bit reflection (unlike the reflected CRC-32 in zlib/PNG). Rewrite a page and you must recompute this, or every decoder rejects it:
function oggCrc(bytes, start, end, crcPos) {
let crc = 0;
for (let i = start; i < end; i++) {
const b = (i >= crcPos && i < crcPos + 4) ? 0 : bytes[i]; // CRC field reads as 0
crc = ((crc << 8) ^ TABLE[((crc >>> 24) ^ b) & 0xff]) >>> 0; // non-reflected
}
return crc >>> 0;
}
Re-tagging without re-encoding — the whole trick
Here is the problem. To change the artist you must rewrite the comment packet, which changes the length of the header pages. Naïvely, that shifts every subsequent page and — because each page's sequence number is baked into its header and its CRC — forces you to rewrite the entire file. On a 20 GB recording that is a non-starter.
The fix is to keep the header region at a constant page count. Both formats let you pad the comment packet: OpusTags explicitly allows trailing padding, and Vorbis stops reading at its framing bit, so trailing bytes are ignored. Adding exactly 65025 padding bytes (255 × 255) adds exactly one full page of segments — so you can rebuild the header to occupy the same number of pages it did before. Every downstream audio page then keeps its sequence number and is copied byte-for-byte:
// Rebuild only the header pages; splice them in front of the untouched audio.
const header = repaginateHeader({ codec, idPacket, setupPacket, vendor, comments,
idPageCount, restPageCount }); // pads to match page count
const retagged = new Blob([ header.bytes, file.slice(headerRegionEnd) ]);
// ─ header.bytes: a few KB rebuilt in memory
// ─ file.slice(...): the entire audio body, never read, never re-encoded
Opening a huge Ogg
Everything you need to inspect an Ogg sits at the two ends of the file. The codec, channels, sample rate and all the tags are in the first pages; the duration is one granule position on the last page. So OmniViewer opens even a multi-gigabyte Ogg with two small reads — a bounded prefix for the headers and the page sample, and one tail read for the final granule — and never pulls the audio body into memory. The PAGES tab then shows the page structure directly off the prefix, and the METADATA tab's re-tag uses the constant-page-count trick above, so it too is size-independent.
Where the spec lives
The container is RFC 3533 (the Ogg encapsulation format). The codecs and their headers: the Vorbis I specification (including the VorbisComment field), and RFC 7845 (Ogg Opus) for OpusHead/OpusTags and the pre-skip. OmniViewer implements all of the above in a dependency-free reader/writer; drop a file on the Ogg viewer to see your own bytes.