Guide · consumers
Reading a Cassini file
From a path to a transcript. Eight steps, the same in every language. Every command below was run against the file the front page links to.
You have a .opus file and you want the transcript out of it. Everything you
need is in the file.
Is this one of those files?
ffprobe -v error -show_entries stream_tags=CASSINI_FORMAT \
-of default=nw=1:nk=1 meeting.opusorg.cassini.portable-meeting/1Nothing printed means ordinary audio. That is an answer, not an error.
Two traps. Ogg carries comments on the stream, so -show_entries format_tags
returns nothing. And ffprobe renames DESCRIPTION to comment.
The payload, in one pipeline
No script, no library. This is for looking, not for a reader: it takes the
chunks it can see, three digits only, and checks nothing. A reader joins exactly
CHUNK_COUNT chunks by index and verifies the digest.
ffprobe -v error -show_entries stream_tags -of json meeting.opus \
| jq -r '[.streams[0].tags | to_entries[]
| select(.key | test("^CASSINI_PAYLOAD_[0-9]{3}$"))]
| sort_by(.key) | map(.value) | join("")' \
| awk '{ p=(4-length($0)%4)%4; printf "%s",$0; for(i=0;i<p;i++) printf "=" }' \
| basenc --base64url -d | gunzip | jq .The awk line re-pads: the producer writes unpadded base64url, GNU basenc
demands padding.
A transcript body decodes the same way with its own prefix. This reconstructs the meeting as speaker turns:
ffprobe -v error -show_entries stream_tags -of json meeting.opus \
| jq -r '.streams[0].tags
| [to_entries[] | select(.key | test("^CASSINI_TX_RAW_ASR_PAYLOAD_[0-9]{3}$"))]
| sort_by(.key) | map(.value) | join("")' \
| awk '{ p=(4-length($0)%4)%4; printf "%s",$0; for(i=0;i<p;i++) printf "=" }' \
| basenc --base64url -d | gunzip \
| jq -r 'reduce .items[] as $w ([];
if (.[-1].speaker == $w.speaker)
then (.[:-1] + [.[-1] + {text: (.[-1].text + " " + $w.text)}])
else (. + [$w]) end)
| .[] | "[\(.startMs/1000)s] \(.speaker): \(.text)"'[0.9s] spk_mira: Morning. Sorry, two goals today: lock the booth run-through for Friday's lantern festival and make sure nobody is still demoing last week's build.
[7.7s] spk_leo: And please, this time, nobody says beta out loud to the mayor.
[12.23s] spk_ben: I fixed the Q R code bug from issue two one four. The iPad was caching the old redirect and making me look cursed.raw-asr is a transcript id, not a constant. Read CASSINI_TRANSCRIPT_IDS and
CASSINI_TRANSCRIPT_DEFAULT, or read the manifest, which is the record.
Getting a model to write the reader
Better than waiting for me to write one in your language.
/llms-full.txt is this whole specification in one 100 KB
document: errata first, then the spec, the body format, the digest contract, both
guides, every schema, and the real tag dump of the file the front page links to.
Hand it over, then check the result against
that file.
The errata come first on purpose. They are short, and they are where the reference readers still disagree with the spec — padding, the inflate bound, the state names — so a model reading them knows which side to implement. If something written from the bundle still gets it wrong, that is a bug in the bundle and I want to hear about it.
Then check it against the conformance vectors: 22 files covering the edges, with a harness that takes a reader in any language. The three readers in this repository pass with no hard failures. The reference Go implementation fails one: a transcript body missing a chunk, which it treats as a broken file rather than a missing transcript. That is what a suite is for.
The algorithm
Eight steps, the same in every language.
-
Find the OpusTags packet. Second packet of the logical bitstream. A 60 KB comment header spans several Ogg pages, so reassembling across page boundaries is the normal path, not an edge case.
-
Read the comment vector. Vorbis field names are case-insensitive, so upper-case them before comparing, and never touch the value. They are also not required to be unique. Cassini never writes a name twice, so a repeat means something edited the file: if it is load-bearing — a chunk, a digest, a count,
CASSINI_FORMAT— that isinvalid-cassini-metadata, and if it is a mirror tag, believe the manifest and say you saw it. Both current readers assume names are unique and upper-case, and have got away with it because the producer writes them that way. -
Check
CASSINI_FORMAT. Absent means plain audio. Accept/1. A major version you do not implement means play the audio, never error on valid Opus. -
Reassemble the manifest. Join
CASSINI_PAYLOAD_000throughCHUNK_COUNT - 1by index, not by the order tags appear. Re-pad, base64url-decode, gunzip, parse UTF-8 JSON. -
Verify. SHA-256 the decompressed bytes against
CASSINI_PAYLOAD_SHA256. Bound the decompression while inflating, not after. -
Resolve the transcript.
manifest.transcripts[]indexes them, each entry'spayloadRefnaming its own chunk set and its own SHA-256.The manifest resolves the default, not the tag. For each slot — words, readable, display — take the first entry for that slot flagged
default: true, and failing that the first entry for that slot in array order.CASSINI_TRANSCRIPT_DEFAULTis a copy; ignore it when it names nothing the manifest has, and say so when it disagrees with the flag. -
Read the items in file order. They are in speaker-turn order, not sorted by time: across a speaker change
startMsgoes backwards, because people talk over each other. Sorting bystartMsdestroys every turn. A turn is a maximal run of consecutive items with the samespeaker, which is what thejqabove reconstructs in one pass. -
Ignore what you do not recognise. A tag name or a manifest member you have never heard of is not an error, at any depth. The two exceptions are
integrityand everypayloadRef, where every member is an instruction and an unknown one is a verification failure. The schemas now say exactly that.
In the browser, with no dependencies
fetch gets the bytes, DecompressionStream inflates, crypto.subtle checks
the digests. The player on the front page is this module reading the file you can
download.
tools/cassini-read.js is that module, CC0, about 300 lines. It is generated
from the site's own reader and the build fails if they drift, so the file you
download is the one this page ran. tools/cassini-read-pure.py does the same in
Python with no external tools, in about 110 lines, and only ever reads the front
of the file — the same code works over an HTTP range request.
import { readCassini, toTurns } from './cassini-read.js';
const buf = await (await fetch('meeting.opus')).arrayBuffer();
const file = await readCassini(buf);
if (file.cassini) {
const turns = toTurns(file.transcript.words, file.manifest.speakers);
console.log(file.manifest.meeting.title, turns.length, 'turns');
}state comes back alongside the data, one of the six below. verified: { manifest, transcript } says what was checked: true matched, null nothing
claimed one, false disagreed. A manifest that disagrees is not shown at all;
a transcript that disagrees comes back as unavailable with the reason.
Say which of the six states you ended in
Keep the transcript. Label it. Let the person decide.
Every read ends in exactly one of six states, and the names are part of the spec so that two readers describe one file the same way:
| state | what you found | what you do |
|---|---|---|
plain-audio | no CASSINI_FORMAT | play it, silently, not an error |
unknown-cassini-format | a major version you do not implement | play it, say the metadata is newer than you |
invalid-cassini-metadata | tags present, payload won't reassemble, decode or hash | play it, say which step failed |
unverified | readable, audio not checked | show the transcript, marked unverified |
stale-audio | readable, checked, does not match | show the transcript, labelled, offer plain audio |
ok | readable, checked, matches | open it as a meeting |
Three digests, three different consequences:
- The manifest digest fails: the manifest is not used, not even a field of
it.
invalid-cassini-metadata; the audio plays. - A transcript body digest fails: that transcript is unavailable. The meeting, the speakers and the other transcripts are still good, and the file's state does not change.
- The audio digest fails: keep the transcript and label it.
stale-audiois what happens when a file is reprocessed or remuxed, which is the normal life of one. Discarding the transcript here is the one thing a reader must not do.
If you never verify the audio, you are still conforming — a reader that pulls
only the front of the file over a range request cannot. Report unverified and
mean it. The one thing the spec forbids outright is claiming a check you did not
run.
The digest catches accidents: a transcript reattached to the wrong audio, a corrupted payload, a truncated download. Not someone who wants to lie to you.
Things that will trip you up
- Padding. Producer writes unpadded, the Go reader rejects padded, the spec's own example re-pads. Accept both.
- Chunk order. Reassemble by index. The producer writes tags ASCII-sorted,
which puts
CASSINI_PAYLOAD_000nowhere near where you would expect. CASSINI_FORMATis comment nine, not comment one. No magic bytes at a fixed offset.- Transcript ids map to prefixes by upper-casing and replacing
-with_. Soraw-asrandraw_asrcollide. Do not use_. CASSINI_TRANSCRIPT_IDSis sorted;manifest.transcripts[]is not. Same ids, different order. Neither is wrong.- A comment header can be large.
RFC 7845 §5.2 lets a
generic reader ignore comments past the first 61,440 octets. A Cassini reader
may not: on a long recording the tags that make the file decodable sit past
that mark. Read the whole
OpusTagspacket, and over HTTP fetch more until you have it. - The summary tags are an incomplete copy. A file carries no
CASSINI_WORD_COUNT, noCASSINI_TRANSCRIPT_LANGUAGE, noCASSINI_STT_*, whatever the spec says. Read the manifest.