✳ OMNIVIEWER Ogg viewer ← back

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.

On this page Pages, not frames The OggS page header Segment lacing Granule positions Logical bitstreams Codec headers & tags The page CRC Re-tagging without re-encoding Opening a huge Ogg Where the spec lives

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 mental model: the codec (Vorbis/Opus) produces a stream of variable-length packets; Ogg chops that stream into pages for transport, and the decoder glues the packets back together from the pages. Editing the metadata means editing one early packet — and re-laying only the pages it touches.

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
FieldBytesMeaning
capture pattern0–3the ASCII OggS — how you find a page (and re-sync after junk)
version4always 0
header type5bit 0x01 continued packet, 0x02 BOS (beginning of stream), 0x04 EOS (end)
granule position6–13a codec-defined timestamp (see below); -1 means "no packet ends here"
bitstream serial14–17which logical bitstream this page belongs to
page sequence18–21increments per page within a bitstream — gaps reveal loss
CRC-3222–25checksum over the whole page, this field taken as zero
segment count26how 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:

CodecFirst-packet magicHeadersTag format
Vorbis0x01 "vorbis"identification, comment, setupVorbisComment
Opus"OpusHead"OpusHead, OpusTagsOpusTags
FLAC0x7F "FLAC"mapping + STREAMINFO, then metadata blocksVorbisComment
Speex"Speex   "header, commentVorbisComment
Theora0x80 "theora"identification, comment, setupVorbisComment

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
Why it stays lossless and instant: the audio packets are copied as opaque bytes — the codec is never invoked — and only a handful of header KB are rebuilt. Re-tagging a multi-gigabyte Opus file costs the same as re-tagging a 3-minute one.

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.

Open an Ogg file →Play it, read the pages, edit the Vorbis/Opus tags — all local. Edit Ogg / Opus tags →Change the tags and download a byte-exact re-tagged copy.

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.