Specification · exact-opus-audio-v1

The audio digest

The byte rule for hashing an Opus stream, written so rewriting the tags cannot change the answer. It is what makes a recording’s identity survive reprocessing.

Policy id
exact-opus-audio-v1
Date
2026-09-02
Status
published

A portable meeting identifies its recording with integrity.matchPolicy = "exact-opus-audio-v1" and a lowercase hex SHA-256 in integrity.opusAudioSha256. This document is the byte rule behind that digest.

It hashes the compressed Opus packets, not decoded PCM and not the whole file. Decoded PCM is not bit-exact across decoders, so a digest over it depends on which library you linked. A whole-file digest cannot live inside the file it covers: writing it changes the tags, which changes the digest. Hashing the packets and excluding the tags makes the digest stable across any rewrite that leaves the audio alone.

The byte stream

Hash these in order:

  1. The ASCII string org.cassini.opus-packets/1, then one zero byte.
  2. The OpusHead packet, with bytes 12–15 (the informational input sample rate) set to zero. Emit H, then the packet length as an unsigned little-endian 64-bit integer, then the packet bytes.
  3. Every audio packet in stream order. The audio packets are every packet of the logical stream after the first two. For each, emit A, then its length as an unsigned little-endian 64-bit integer, then the packet bytes.
  4. A trailer: E, the audio packet count as an unsigned little-endian 64-bit integer, then the playable sample count as another.

Each length counts the packet only, not the marker byte. A zero-length packet has no TOC byte and is malformed.

Excluded: the OpusTags packet, and all Ogg framing. Page boundaries, serial number, sequence numbers, lacing, CRCs and granule positions do not enter the hash. The final granule bounds the sample count, but is not hashed itself, because a metadata-only remux can normalise it while leaving every packet unchanged.

The playable sample count

preSkip is the OpusHead field at bytes 10–11, unsigned little-endian 16-bit.

finalGranule is the granule position of the last page of the stream: a count of 48 kHz samples from the start of decoded output including pre-skip, per RFC 7845 §4.

Each packet's duration comes from its TOC byte as RFC 6716 §3.1 defines, in samples at 48 kHz: frame duration × frame count. Restated here because a wrong row passes silently on a file that only uses 20 ms frames, which is most of them.

config = TOC bits 3–7ModeFrame duration
0–3, 4–7, 8–11SILK NB, MB, WB10, 20, 40, 60 ms
12–13, 14–15Hybrid SWB, FB10, 20 ms
16–19, 20–23, 24–27, 28–31CELT NB, WB, SWB, FB2.5, 5, 10, 20 ms

Within each group the durations run in the order shown, so config 15 is hybrid FB 20 ms and config 31 is CELT FB 20 ms. At 48 kHz, 2.5 ms is 120 samples and 20 ms is 960.

c = TOC bits 0–1Frames
01
1, 22
3bits 0–5 of the second byte

The rules of §3.2.5 apply: a code-3 packet MUST carry a frame count, that count MUST NOT be zero, and a packet MUST NOT exceed 120 ms. A packet that breaks any of those makes the file invalid.

The playable sample count is the sum of packet durations, minus preSkip, clamped to finalGranule − preSkip when that is smaller. A sum below preSkip makes the file invalid. The clamp is what lets a real end trim change the identity while a muxer's granule quirk does not.

Duration in milliseconds is sampleCount × 1000 / 48000 in integer arithmetic, truncated. 11,506,248 samples is 239,713 ms, not 239,714.

What is rejected

A rejection means the digest cannot be computed. To a consumer that is unverified with a reason, per SPEC.md; the audio still plays if the decoder can play it.

The parser accepts one non-chained Ogg logical stream with one or two Opus channels. It rejects a malformed OpusHead (shorter than 19 bytes, wrong magic, version above 15), a malformed packet (empty, or breaking a TOC rule above; framing beyond the TOC is not validated), a CRC failure, a sequence gap, an invalid continuation, a multiplexed or chained stream, a truncated packet, a missing end-of-stream flag, and an invalid final granule.

A final granule is invalid when, read as signed 64-bit, it is negative, or when it is smaller than preSkip. The all-ones value that elsewhere means "no packet ends on this page" is negative and therefore invalid here: a last page carrying audio never needs it.

The Ogg page CRC

RFC 3533 names the polynomial and stops, and the rest is not the common default. Every Cassini file uses:

ParameterValue
polynomial0x04c11db7
initial value0
input reflectionnone
output reflectionnone
final XORnone
coversthe whole page, with the four CRC bytes set to zero

This is not the CRC-32 that zlib.crc32 and most standard libraries give you, which is reflected with initial value 0xFFFFFFFF and a final XOR. To check an implementation: on the first page of the published demo file the stored CRC is 0x530297d0. These parameters reproduce it; zlib.crc32 returns 0x753f3847.

Consequences

A metadata-only remux preserves the digest. Re-encoding changes it, even when the result sounds identical. The reference producer derives meeting.id from this digest, so a tag rewrite keeps a meeting's identity and a re-encode gives it a new one.

This page is rendered from spec/cassini-opus-audio-integrity-v1.md in the specification repository. If the two ever disagree, the file wins.