Implementation guide · writing
Write a Cassini file
Pack an Opus recording and a word-timed transcript into one file. Start with the Python producer, then explore the format requirements.
On this page
You need an Ogg Opus recording and a transcript with word timestamps. Cassini packages those inputs together; it does not run speech recognition for you.
Write your first file
The CC0 Python producer uses only the standard library. Download it, then provide your audio and transcript:
curl -fLO https://raw.githubusercontent.com/codemyriad/cassini-format/main/tools/cassini-pack.py
python3 cassini-pack.py recording.opus transcript.json meeting.opus \
--title "Weekly sync" --created-at 2026-09-07T09:00:00ZReplace the title and date with your own. recording.opus is your source audio;
meeting.opus is the output. transcript.json supplies speakers and word items
in this shape:
{
"speakers": [{ "id": "sam", "label": "Sam" }],
"items": [
{ "speaker": "sam", "startMs": 900, "endMs": 1300, "text": "Hello." }
]
}Use words and timings that describe your recording. After packing, open the result in the browser reader and follow Checking your work below. If you are implementing your own producer, the requirements that follow define what it must write.
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.
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://format.gocassini.com/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>_PAYLOAD_MIMEper transcript: application/vnd.cassini.transcript-words+jsonCASSINI_TX_<UPPER_ID>_PAYLOAD_ENCODINGper transcript: base64url+gzip+utf8jsonCASSINI_TX_<UPPER_ID>_PAYLOAD_CHUNK_COUNTper transcript: decimal integer CASSINI_TX_<UPPER_ID>_PAYLOAD_SHA256per transcript: lowercase hex, over the decompressed body CASSINI_TX_<UPPER_ID>_PAYLOAD_RAW_BYTESper transcript: decimal integer CASSINI_TX_<UPPER_ID>_PAYLOAD_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.
-
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 acrosstranscripts[], one across thedisplayentries inreadableTranscripts[]. Flagging none is legal and readers fall back to array order.CASSINI_TRANSCRIPT_DEFAULTmirrors the words slot. Word-timed entries carry norole. A display entry carriesrole: "display"andsourceTranscriptIdnaming its word-timed source. -
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. -
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 original word transcript beside derived display text, so its words and timings remain available for comparison and reprocessing.
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. -
Write a summary tag only when you have a value. 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.
The digest excludes OpusTags and all Ogg framing, so tagging cannot change
it: 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 repair-cafe.opus
ffprobe -v error -show_entries stream_tags=CASSINI_AUDIO_OPUS_SHA256 \
-of default=nw=1:nk=1 repair-cafe.opusThe digest tool’s sha256 and the tag agree:
8d28b76a269092cd722ccaee50953dce9de30021c00146f33170c58ab50c0c80The file contains 5429448 playable samples at 48000 Hz, for a duration of 113113 ms. These values and the digest above are read from the downloadable file when the site is generated.
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.