Guide · producers
Writing a Cassini file
What a producer has to get right, as requirements you can check off, then the conventions that are merely a good idea.
The audio is ordinary Ogg Opus, so the only real work is building the tags. What follows is what a real file contains, measured rather than quoted.
If you would rather read code, there is a complete producer in standard-library Python.
What you are making
An Ogg Opus file at 48 kHz, mono for speech, with two things in its OpusTags comment header: plain comments a human can read, and a JSON manifest gzipped, base64url-encoded and cut into numbered pieces. Nothing else.
Requirements
-
Container MUST be Ogg, codec MUST be Opus, sample rate field
48000. Mono for speech; stereo only when it is deliberate. -
MUST carry
CASSINI_FORMAT=org.cassini.portable-meeting/1. That is what makes it a Cassini file. -
MUST carry every descriptor tag, each with a non-empty value:
tag value CASSINI_FORMATorg.cassini.portable-meeting/1CASSINI_PROFILEogg-opusCASSINI_PAYLOAD_MIMEapplication/vnd.cassini.portable-meeting+jsonCASSINI_PAYLOAD_ENCODINGbase64url+gzip+utf8jsonCASSINI_PAYLOAD_SCHEMAhttps://cassini-format.codemyriad.io/schema/cassini-portable-meeting-manifest-v1.schema.jsonCASSINI_PAYLOAD_CHUNK_COUNTdecimal integer, 1 or more CASSINI_PAYLOAD_SHA256lowercase hex, over the decompressed JSON CASSINI_PAYLOAD_RAW_BYTESdecimal integer, the decompressed length CASSINI_PAYLOAD_GZIP_BYTESdecimal integer, the compressed length CASSINI_TRANSCRIPT_IDSthe ids in transcripts[], comma-separated, no spaces, sortedCASSINI_TRANSCRIPT_DEFAULTthe id a viewer opens first CASSINI_TX_<UPPER_ID>_MIMEper transcript: application/vnd.cassini.transcript-words+jsonCASSINI_TX_<UPPER_ID>_ENCODINGper transcript: base64url+gzip+utf8jsonCASSINI_TX_<UPPER_ID>_CHUNK_COUNTper transcript: decimal integer CASSINI_TX_<UPPER_ID>_SHA256per transcript: lowercase hex, over the decompressed body CASSINI_TX_<UPPER_ID>_RAW_BYTESper transcript: decimal integer CASSINI_TX_<UPPER_ID>_GZIP_BYTESper transcript: decimal integer CASSINI_AUDIO_SAMPLE_RATE48000CASSINI_AUDIO_CHANNELS1or2CASSINI_AUDIO_SAMPLE_COUNTplayable samples, as the digest spec defines them CASSINI_AUDIO_DURATION_MSsampleCount * 1000 / 48000, truncatingCASSINI_AUDIO_MATCH_POLICYexact-opus-audio-v1CASSINI_AUDIO_OPUS_SHA256lowercase hex, the audio digest CASSINI_DECODE_HINTone sentence explaining the chunk sets The six
CASSINI_TX_*descriptors are copies of the entry'spayloadRef. The full list, with the exactDECODE_HINTsentence, is in the specification. -
Build the payload in this order: compact UTF-8 JSON, gzip, base64url without padding, split. Getting the order wrong is the commonest way to produce a file nothing can read.
-
Chunks MUST be
CASSINI_PAYLOAD_NNN, zero-padded to a minimum of three digits, from000, joined by index with no separator, exactlyCHUNK_COUNTof them. Index 1000 isCASSINI_PAYLOAD_1000, not truncated and not four-padded from the start. Keep each value at or under 4096 characters. Not an Ogg requirement; it keeps the header readable in ordinary tools. -
Each chunk-set SHA-256 is over the decompressed JSON bytes, not the gzip stream and not the base64 text.
CASSINI_AUDIO_OPUS_SHA256is different: it is over the packet stream thatexact-opus-audio-v1defines. -
The manifest MUST have
kind,version,profile,meeting,audio,integrity,speakers,transcripts.kindis the literalcassini-portable-meetingandprofilethe literalogg-opus, in every version of the format. -
Each transcript body lives in its own chunk set, under a prefix derived from the id by upper-casing and replacing
-with_:raw-asrbecomesCASSINI_TX_RAW_ASR_PAYLOAD_. The entry'spayloadRefcarries that prefix, the chunk count and the body's SHA-256.So ids MUST NOT contain
_:raw-asrandraw_asrmap to the same prefix and one silently wins. In practice^[a-z0-9][a-z0-9-]{0,31}$. -
Ids MUST NOT be a reserved descriptor name:
payload,format,audio,meeting,integrity,transcript,provenance,summary,attachments,speakers. -
At most one entry flagged
default: trueper slot — one across the word-timed roles, one acrossreadable-cleanup, one acrossdisplay. Flagging none is legal and readers fall back to array order.CASSINI_TRANSCRIPT_DEFAULTmirrors the words slot. A derived transcript carriessourceTranscriptId. -
CASSINI_AUDIO_OPUS_SHA256is computed over the canonical compressed Opus stream, without decoding the audio. The rule is in the digest spec: playback-relevantOpusHeadfields, every audio packet in order with its length, the playable sample count. It excludesOpusTagsand all Ogg framing, which is what lets the manifest contain its own audio digest without writing it changing it. -
Descriptor tags MAY be written before the chunk tags.
RFC 7845 §5.2 lets a reader ignore comments past the first 61,440 octets, and the payload is easily larger than that, so on a long recording the descriptors can fall outside the window. Nothing truncates in practice:
ffmpeg -c copy, a remux to.oggand a mutagen round-trip all preserve every comment on a 307 KB header. The descriptor block is about 1,388 bytes, so writing it first costs nothing and a new producer may as well. -
Verify your own output before shipping it: read it back, recompute both digests, refuse to publish a mismatch. The producer is the right place for the strict check, because it can fix the problem and a reader cannot.
-
Keep the raw ASR transcript even when a cleaned version exists. A better cleanup model is coming and it will want the original.
Conventions
Not required. All of it is what the reference producer does.
-
Write
TITLE,DATEand aDESCRIPTIONsaying in one line how to decode the payload. They cost nothing and they are what somebody sees on right-click. -
Do not bother with
ENCODER. The producer setsCassini, ffmpeg's Ogg muxer overwrites it withencoder=Lavf…. So nothing in a Cassini file records which program wrote it, which is the omission I would most like back. -
Write a summary tag only when you have a value. An empty
CASSINI_ROOM_IDreads as "this meeting has a room whose id is the empty string". Absent, never empty. -
Never put a room token, join link or internal service URL in a tag. These files get mailed to people. The reference producer derives a one-way
rm_<16 hex>forroomIdso that publishing a recording does not also hand out the credential that joins the live conversation. -
The manifest is the record; summary tags are the copy. Edit one, edit the other. A consumer finding them disagreeing believes the manifest.
A complete producer
tools/cassini-pack.py builds a valid file with nothing but the Python
standard library. No ffmpeg, no Go. It walks the Ogg pages, computes the digest,
builds the manifest, and rewrites only the OpusTags packet, copying every audio
page across untouched and patching the page sequence numbers and CRCs.
That is easier than what the reference implementation does. Since the digest
excludes OpusTags and all Ogg framing, tagging provably cannot change it, so
there is no hash-tag-rehash loop. Compute it once.
tools/cassini-opus-digest.py computes exact-opus-audio-v1 from
the digest spec alone. Run it against the file the
front page links to:
python3 tools/cassini-opus-digest.py lantern-festival.opus
ffprobe -v error -show_entries stream_tags=CASSINI_AUDIO_OPUS_SHA256 \
-of default=nw=1:nk=1 lantern-festival.opus{
"sha256": "8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc",
"sampleRate": 48000,
"channels": 1,
"sampleCount": 11506248,
"durationMs": 239713,
"packetCount": 11986
}
8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbcTwo implementations that never saw each other's code, two languages, the same 64 characters over 11,986 packets. That is the only evidence I have that the digest rule is written down properly, and it is worth more than my opinion.
Checking your work
# still a playable Opus file. If this fails, nothing else matters.
ffmpeg -v error -i meeting.opus -f null -
# the manifest and the transcript body decode and validate
python3 tools/cassini-extract.py meeting.opus > manifest.json
python3 tools/cassini-extract.py meeting.opus --transcript > body.json
python3 -c "import json,jsonschema
v = lambda d, s: jsonschema.validate(json.load(open(d)), json.load(open(s)),
format_checker=jsonschema.FormatChecker())
v('manifest.json', 'spec/cassini-portable-meeting-manifest-v1.schema.json')
v('body.json', 'spec/cassini-words-v1.schema.json')"
# the tags mirror the manifest, and the audio digest is the one in the file
python3 tools/cassini-extract.py meeting.opus --check
python3 tools/cassini-opus-digest.py meeting.opus
# an independent reader agrees with you
python3 tools/cassini-read-pure.py meeting.opusThe schemas check shape. The cross-field rules, one default per slot, a
sourceTranscriptId that names a declared transcript, integrity equal to
audio, are prose, and --check is where they are tested. With gocassini
built, cassini inspect meeting.opus is the last check, and the one that
catches a disagreement between your digest and the reference one.