Cassini portable meeting format — the complete specification, flattened This document: https://cassini-format.codemyriad.io/llms-full.txt Wire version: org.cassini.portable-meeting/1 Spec text CC BY 4.0. Schemas, test vectors and example code CC0. WHAT THIS IS Everything needed to write a reader or a producer, in one file: the errata, the specification, the transcript body format, the audio digest contract, both implementation guides, every JSON Schema, and the actual tag dump of a real file you can download and check against. HOW TO USE IT The specification is the rule. The errata record where the reference implementations still lag it and a few facts about real files. The standards this format inherits, and which sections, are listed at the top of the specification; nothing here restates them. A REAL FILE TO CHECK AGAINST https://cassini-format.codemyriad.io/demo/lantern-festival.opus 1,898,861 bytes, 37 OpusTags comments, 6 speakers, 669 word-timed items. How it was made: https://cassini-format.codemyriad.io/demo/README.md ============================================================================== ERRATA — read this before the specification ============================================================================== # Errata Date: 2026-09-02 Status: every entry checked against the reference producer's source and against a file on disk Rules [`SPEC.md`](SPEC.md) states that the reference implementations do not yet follow, plus a few facts about real files worth knowing before you write a reader. Each entry was checked against the producer's source and against a file on disk. ## The six trust states are not the names the readers print [Trust and integrity](SPEC.md#trust-and-integrity) requires every read to end in one of `plain-audio`, `unknown-cassini-format`, `invalid-cassini-metadata`, `unverified`, `stale-audio` or `ok`, and requires the state to be visible to whoever called the reader. Three gaps: * `cassini inspect` resolves all six and prints the fourth as `integrity-unverified`. Same state, different spelling. * The browser viewer never verifies the audio: it renders the transcript from a byte-range request before the audio has been downloaded. It says **unverified** where it presents the metadata, which is what makes that conforming. * `cassini inspect` called the whole file `invalid-cassini-metadata` when a transcript body was missing a chunk. [Resolving a transcript](SPEC.md#resolving-a-transcript) makes that transcript unavailable and leaves the file's state alone. Fixed in [gocassini#236](https://github.com/codemyriad/gocassini/pull/236), with the `scripted` role and the id grammar; until it merges, vector 006 fails. ## Nothing bounds the decompression [Payload encoding](SPEC.md#payload-encoding) requires a consumer to stop inflating as soon as the output exceeds the declared `RAW_BYTES`. No reader here does: all of them inflate first and check the size afterwards. On a 2,172,012-byte file whose chunks declare 203,932 gzip bytes that inflate to 209,715,277 against `CASSINI_PAYLOAD_RAW_BYTES=42`, Go peaks at 550 MB resident, Python at 430 MB, and `tools/cassini-read.js` at 1.14 GB and returns success. In a browser tab that is a crash on a file that fits in an email. ## Padding: written unpadded, and the Go reader refuses it The producer writes unpadded (`base64.RawURLEncoding`), which is what the document requires. The document also requires a consumer to accept padded input, and `cassini inspect --transcript` fails on conformance vector `004-padded-base64url-v1` with `illegal base64 data at input byte 762`. Two readers also compute the padding from the length before stripping whitespace, so a chunk value containing a newline decodes on some inputs and not on others. Strip whitespace first, then pad. Fixed in the reference implementation by [gocassini#230](https://github.com/codemyriad/gocassini/pull/230), not yet merged. ## Transcript ids: the producer still accepts `_` The schemas match the document: `^[a-z0-9][a-z0-9-]{0,31}$`. The producer's own `transcriptIDRE` does not, and `sanitizeTranscriptID` deliberately preserves `_`. Nothing checks that two ids derive the same tag prefix either, so `raw-asr` and `raw_asr` are accepted together, one chunk set is written, and one transcript's body is gone with no error anywhere. No file uses `_`. `CASSINI_TRANSCRIPT_IDS` across every v2 and v3 file on this machine is `raw-asr`, and only ever `raw-asr`. ## The default transcript is resolved from the tag, not the manifest [Resolving a transcript](SPEC.md#resolving-a-transcript) makes the manifest the list and the manifest the resolver, with `CASSINI_TRANSCRIPT_DEFAULT` as a copy. `ExtractMeeting` preferred the tag; `tools/cassini-read.js` did too and now resolves from the manifest, warning on a disagreement. `tools/cassini-extract.py` already preferred the manifest flag. The browser viewer ignores the tag. On a file where the two disagree, readers still show the user a different transcript. Every shipped file has them agreeing, so nothing is visibly wrong yet. Fixed in the reference implementation by [gocassini#230](https://github.com/codemyriad/gocassini/pull/230), not yet merged. ## The per-transcript chunk count is taken from the tag The same rule, second instance. Conformance vectors `007` and `008` put `payloadRef.chunkCount` and `CASSINI_TX__PAYLOAD_CHUNK_COUNT` in disagreement: the Go reader believes the tag and fails with `unexpected EOF`, the Python and JavaScript readers believe the manifest and decode all 900 words. The manifest is the record. Fixed in the reference implementation by [gocassini#230](https://github.com/codemyriad/gocassini/pull/230), not yet merged. ## `cassini inspect` reports `cassini=ok` on files it cannot decode `words=` is the *declared* `wordCount` out of the manifest index rather than a count of anything decoded. Only `--transcript` discovers the body is unreachable, and even that exits 0. It also sums word counts across alternative transcripts, so a three-word meeting carrying two three-word transcripts reports six. Conformance vector `016` is the minimal reproduction. Fixed in the reference implementation by [gocassini#230](https://github.com/codemyriad/gocassini/pull/230), not yet merged. ## Two things about transcript bodies in real files `language` is written as `""` by the JavaScript repacker in the reference tree, and omitted by the Go producer. The body format says to treat both as "nobody said"; a reader that rejects the empty string fails on real files. No shipped file carries a derived transcript. The reference producer's pack input has no way to set `sourceTranscriptId`, and its validator rejects a derived entry without one, so the `readableTranscripts` path is unreachable today. The shape is specified; nothing exercises it yet. ## Nothing records which program wrote the file The producer sets `ENCODER=Cassini`; ffmpeg's Ogg muxer replaces the value with its own, so a real file carries `encoder=Lavf62.3.100`. The tag survives, the information does not, and that is the fact you need to decide whether a file is worth reprocessing. ## Tag order, and a risk we accept The producer sorts tags with `sort.Strings`. ASCII order puts `CASSINI_PAYLOAD_000` ahead of `CASSINI_PAYLOAD_CHUNK_COUNT`, because `0` sorts before `C`, so the payload is written before the descriptors that explain it. [RFC 7845 §5.2](https://www.rfc-editor.org/rfc/rfc7845#section-5.2) lets an implementation "ignore individual comments that are not fully contained within the first 61,440 octets of the comment header". A long recording exceeds that: | file | OpusTags packet | comments past 61,440 | |---|---|---| | a 4-minute meeting | 14,376 B | none | | a 17-minute recording | 84,527 B | 23 of 48 | | the largest v1 sample | 307,370 B | 77 of 102 | **In practice nothing truncates.** Tested against the 307 KB file: `ffmpeg -c copy`, an `.opus` to `.ogg` remux, and a mutagen round-trip each preserve all 102 comments, `CASSINI_PAYLOAD_CHUNK_COUNT` and `CASSINI_PAYLOAD_SHA256` included. These files are played, not edited, so the exposure is theoretical and it is accepted. Worth knowing rather than worth fixing. A new producer may as well write descriptors before chunk tags, since it costs nothing (the descriptor block is 1,388 bytes, two percent of the window), but no existing file is in danger and nothing needs repacking. ============================================================================== WHAT A REAL FILE ACTUALLY CONTAINS ============================================================================== Every comment in the demo file, in the order ffprobe reports them. Payload chunk values are elided; one is shown in full-ish so the shape is clear. CASSINI_AUDIO_CHANNELS=1 CASSINI_AUDIO_DURATION_MS=239713 CASSINI_AUDIO_MATCH_POLICY=exact-opus-audio-v1 CASSINI_AUDIO_OPUS_SHA256=8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc CASSINI_AUDIO_SAMPLE_COUNT=11506248 CASSINI_AUDIO_SAMPLE_RATE=48000 CASSINI_CREATED_AT=2026-04-15T09:12:00Z CASSINI_DECODE_HINT=Concatenate CASSINI_PAYLOAD_000..N for the manifest; for a transcript body concatenate CASSINI_TX__PAYLOAD_000..N. Each chunk set: base64url decode, gzip decompress, parse UTF-8 JSON. CASSINI_FORMAT=org.cassini.portable-meeting/1 CASSINI_MEETING_ID=mtg_8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc CASSINI_PAYLOAD_CHUNK_COUNT=1 CASSINI_PAYLOAD_ENCODING=base64url+gzip+utf8json CASSINI_PAYLOAD_GZIP_BYTES=1134 CASSINI_PAYLOAD_MIME=application/vnd.cassini.portable-meeting+json CASSINI_PAYLOAD_RAW_BYTES=2133 CASSINI_PAYLOAD_SCHEMA=https://cassini-format.codemyriad.io/schema/cassini-portable-meeting-manifest-v1.schema.json CASSINI_PAYLOAD_SHA256=a4d048386f81bd4b28143d847a2f65497f84c5228c42dd0006f5be3195d6f657 CASSINI_PROCESSED_AT=2026-04-15T09:12:00Z CASSINI_PROFILE=ogg-opus CASSINI_RECORDED_AT_LOCAL=2026-04-15T11:12:00 CASSINI_SPEAKER_COUNT=6 CASSINI_TRANSCRIPT_DEFAULT=script CASSINI_TRANSCRIPT_IDS=script CASSINI_TX_SCRIPT_PAYLOAD_CHUNK_COUNT=3 CASSINI_TX_SCRIPT_PAYLOAD_ENCODING=base64url+gzip+utf8json CASSINI_TX_SCRIPT_PAYLOAD_GZIP_BYTES=8098 CASSINI_TX_SCRIPT_PAYLOAD_MIME=application/vnd.cassini.transcript-words+json CASSINI_TX_SCRIPT_PAYLOAD_RAW_BYTES=45837 CASSINI_TX_SCRIPT_PAYLOAD_SHA256=bcfe6a717171545ef264e1bcf66aba6f014bc25445267c35a206179c12a8c68d comment=Cassini portable meeting file. Decode CASSINI_PAYLOAD_*: base64url -> gzip -> UTF-8 JSON. DATE=2026-04-15T09:12:00Z encoder=Lavf62.3.100 TITLE=Lantern Festival Booth Run-through CASSINI_PAYLOAD_000=H4sIAAAAAAAC_61W227jNhD9lYFe2mItR5Jt-bJPTnYLpI13A9vbKwpjRI0srmVSICkn7iL_… … and 3 more chunk tags. Note the ASCII sort order, and that CASSINI_FORMAT is not the first comment. Note that ffprobe renames DESCRIPTION to "comment", and that Ogg carries comments on the STREAM, so -show_entries format_tags returns nothing. ============================================================================== THE DECODED MANIFEST OF THAT FILE ============================================================================== { "kind": "cassini-portable-meeting", "version": 1, "profile": "ogg-opus", "meeting": { "id": "mtg_8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc", "title": "Lantern Festival Booth Run-through", "createdAtUtc": "2026-04-15T09:12:00Z", "recordedAtLocal": "2026-04-15T11:12:00", "processedAtUtc": "2026-04-15T09:12:00Z", "durationMs": 239713 }, "audio": { "container": "ogg", "codec": "opus", "sampleRate": 48000, "channels": 1, "sampleCount": 11506248, "durationMs": 239713 }, "integrity": { "matchPolicy": "exact-opus-audio-v1", "opusAudioSha256": "8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc", "sampleRate": 48000, "channels": 1, "sampleCount": 11506248, "durationMs": 239713 }, "speakers": [ { "id": "spk_mira", "label": "Mira Chen" }, { "id": "spk_leo", "label": "Leo Rossi" }, { "id": "spk_ben", "label": "Ben Fischer" }, { "id": "spk_ana", "label": "Ana Morales" }, { "id": "spk_noah", "label": "Noah Patel" }, { "id": "spk_jules", "label": "Jules Okafor" } ], "transcripts": [ { "id": "script", "role": "scripted", "default": true, "format": "cassini.words.v1", "wordCount": 669, "createdAtUtc": "2026-04-15T09:12:00Z", "payloadRef": { "prefix": "CASSINI_TX_SCRIPT_PAYLOAD_", "chunkCount": 3, "sha256": "bcfe6a717171545ef264e1bcf66aba6f014bc25445267c35a206179c12a8c68d", "rawBytes": 45837, "gzipBytes": 8098, "mime": "application/vnd.cassini.transcript-words+json", "encoding": "base64url+gzip+utf8json" } } ], "x": { "cassini-format.codemyriad.io": { "fixture": { "backend": "scripted-fixture", "engine": "cassini-format demo builder", "model": "none - no speech-to-text was run", "device": "cpu", "language": "en", "source": "Synthetic fixture, not a recording and not ASR output. The words are the verbatim scenario script (harness/scenarios/showcase-lantern-festival.v1.json in gocassini); the audio is that script read by Kokoro-82M TTS, one voice per speaker, mixed down. MEASURED: each segment's startMs/endMs, taken from the renderer's own schedule for that speaker's track. DERIVED: every word's startMs/endMs, interpolated inside its segment proportionally to token length. Word timings are therefore plausible but not measured; provenance.wordTimings is deliberately absent to say so.", "version": "make_transcript/2" } } } } ============================================================================== THE FIRST ITEMS OF ITS TRANSCRIPT BODY ============================================================================== { "format": "cassini.words.v1", "wordCount": 669, "items": [ { "speaker": "spk_mira", "startMs": 900, "endMs": 1410, "text": "Morning." }, { "speaker": "spk_mira", "startMs": 1410, "endMs": 1774, "text": "Sorry," }, { "speaker": "spk_mira", "startMs": 1774, "endMs": 1992, "text": "two" }, { "speaker": "spk_mira", "startMs": 1992, "endMs": 2356, "text": "goals" }, { "speaker": "spk_mira", "startMs": 2356, "endMs": 2720, "text": "today:" }, { "speaker": "spk_mira", "startMs": 2720, "endMs": 3011, "text": "lock" }, { "speaker": "spk_mira", "startMs": 3011, "endMs": 3229, "text": "the" }, { "speaker": "spk_mira", "startMs": 3229, "endMs": 3593, "text": "booth" }, { "speaker": "spk_mira", "startMs": 3593, "endMs": 4322, "text": "run-through" }, { "speaker": "spk_mira", "startMs": 4322, "endMs": 4540, "text": "for" }, { "speaker": "spk_mira", "startMs": 4540, "endMs": 5123, "text": "Friday's" }, { "speaker": "spk_mira", "startMs": 5123, "endMs": 5633, "text": "lantern" } ] } ============================================================================== SPECIFICATION — SPEC.md ============================================================================== # Cassini portable meeting format Date: 2026-09-02 Status: published, version 1 A Cassini portable meeting is an ordinary Ogg Opus file that also carries its own transcript. Who spoke, what they said word by word with timestamps, and what produced that text all live in the file's OpusTags comment header, next to `TITLE` and `DATE`. A player that knows none of this plays the audio and ignores the rest. The metadata has two layers. The first is plain comments any tool can show. The second is a JSON manifest, gzipped, base64url-encoded and split across numbered comments so the header stays inspectable. Each transcript body is a second such payload under its own prefix. A SHA-256 over the Opus packets ties the transcript to the recording. This is what a file looks like to `ffprobe`, with the payload chunks elided: ```text TITLE=Lantern Festival Booth Run-through DATE=2026-04-15T09:12:00Z CASSINI_FORMAT=org.cassini.portable-meeting/1 CASSINI_PROFILE=ogg-opus CASSINI_PAYLOAD_ENCODING=base64url+gzip+utf8json CASSINI_PAYLOAD_CHUNK_COUNT=1 CASSINI_PAYLOAD_SHA256=a4d048386f81bd4b2814… CASSINI_PAYLOAD_000=H4sIAAAAAAAC_61W227jNhD9lYFe2mIt… CASSINI_TRANSCRIPT_IDS=script CASSINI_TRANSCRIPT_DEFAULT=script CASSINI_TX_SCRIPT_PAYLOAD_CHUNK_COUNT=3 CASSINI_TX_SCRIPT_PAYLOAD_SHA256=bcfe6a717171545ef264… CASSINI_TX_SCRIPT_PAYLOAD_000=H4sIAAAAAAAC_… CASSINI_AUDIO_OPUS_SHA256=8e1f7499c6d5fba88c3b… CASSINI_MEETING_ID=mtg_8e1f7499c6d5fba8… CASSINI_SPEAKER_COUNT=6 CASSINI_DECODE_HINT=Concatenate CASSINI_PAYLOAD_000..N for the manifest; … ``` The words MUST, SHOULD and MAY are used as in RFC 2119. Everything else is explanation. > [`ERRATA.md`](ERRATA.md) lists where the reference implementation does not yet > do what this document says. Read it before implementing. ## Implementing this Read these in order. Each settles something the next assumes. 1. This document: the tags, the chunk transport, the manifest, and what a consumer does in each state it can end in. 2. [`spec/cassini-portable-meeting-manifest-v1.schema.json`](spec/cassini-portable-meeting-manifest-v1.schema.json): the manifest. The schema is normative for the structure it expresses; this prose is normative for everything the schema cannot say, such as rules that span two members. A contradiction between them is a bug in this repository, not a rule; report it. 3. [`spec/cassini-words-v1.md`](spec/cassini-words-v1.md): the transcript body, which is what a `CASSINI_TX__PAYLOAD_` chunk set decodes to. 4. [`spec/cassini-opus-audio-integrity-v1.md`](spec/cassini-opus-audio-integrity-v1.md): the byte stream behind `CASSINI_AUDIO_OPUS_SHA256`. Needed only to compute or verify the audio digest. ### What this format inherits Everything about the container, the codec and the comment header comes from existing specifications. This document does not restate them. | Need | Defined in | |---|---| | Ogg page layout, header flags, lacing and packet reassembly | RFC 3533 §5, §6 | | `OpusHead` alone on the first page; `OpusTags` is the second packet and MAY span pages; audio starts on a fresh page | RFC 7845 §3 | | `OpusHead` fields, including pre-skip at bytes 10–11 | RFC 7845 §5.1 | | `OpusTags` layout: magic, vendor string, comment count, length-prefixed comments | RFC 7845 §5.2 | | A comment is UTF-8 `NAME=value`; names are case-insensitive ASCII; the first `=` separates; a name MAY repeat | Vorbis comment spec, RFC 7845 §5.2.1 | | Opus packet duration from the TOC byte; a packet is at most 120 ms | RFC 6716 §3.1, §3.2.5 | | base64url, the URL-safe alphabet | RFC 4648 §5 | | gzip | RFC 1952 | One thing upstream leaves incomplete: the Ogg page CRC. RFC 3533 gives the polynomial and stops. The full parameters are in [`spec/cassini-opus-audio-integrity-v1.md`](spec/cassini-opus-audio-integrity-v1.md), because that is where a consumer needs them. ## The file ### File identification A file is a Cassini portable meeting when all three hold: 1. it is Ogg Opus; 2. its comment vector carries `CASSINI_FORMAT`; 3. its comment vector carries a complete payload descriptor. The **payload descriptor** is the smallest set of tags that says both "this is a Cassini file" and "here is how to decode it". It is exactly these four: | Tag | Value | |---|---| | `CASSINI_FORMAT` | `org.cassini.portable-meeting/1` | | `CASSINI_PAYLOAD_ENCODING` | `base64url+gzip+utf8json` | | `CASSINI_PAYLOAD_CHUNK_COUNT` | a decimal integer, 1 or more | | `CASSINI_PAYLOAD_000` … `CASSINI_PAYLOAD_` | every index present, none empty | Everything else a producer MUST write (below) is checked after identification, not as part of it. What a consumer does with the answer: | Finding | State | |---|---| | no `CASSINI_FORMAT` | `plain-audio`. Play it. Not an error. | | `CASSINI_FORMAT` names a major version this consumer does not implement | `unknown-cassini-format`. Play it. | | `CASSINI_FORMAT` present, descriptor incomplete | `invalid-cassini-metadata`. Play it. | The states are defined under [Trust and integrity](#trust-and-integrity). This document defines one version, `org.cassini.portable-meeting/1`. Two shapes came before it. Both were used only inside Cassini, neither was ever published, and they are kept in [`spec/drafts/`](spec/drafts/) for anyone who has to read one of those private files. Nothing in this document is version-conditional. ### Audio | | | |---|---| | Container | Ogg (MUST) | | Codec | Opus (MUST) | | Sample rate field | `48000` (MUST) | | Channels | mono for speech; stereo only when the stereo content is deliberate (SHOULD) | | Program | one continuous playable stream (SHOULD) | ### Extensibility The format grows without a version bump. The rule for anything a consumer does not recognise is: ignore it and carry on. - A consumer MUST ignore an unrecognised tag, and an unrecognised manifest member at any depth. Neither is an error. - A tool that rewrites tags or the manifest MUST carry unrecognised ones forward unchanged. Dropping what you did not understand is how a format loses data another tool put there on purpose. - Ignoring the unknown does not loosen the known. `version` is still an integer; `kind` is still the fixed string. Two objects are closed: `integrity`, and every `payloadRef`. Every member of those is an instruction for reassembling bytes or deciding whether audio and transcript belong together, so an unrecognised member there is not a lost hint. Adding a member to either is a new major version, and the schemas reject one. A consumer that meets one in `integrity` cannot know what a match would mean and reports [`unverified`](#unverified); in a `payloadRef` it treats that transcript as unavailable. **Private use.** Tag names beginning `X_` and the manifest member `x` are reserved for private data and will never be defined here. - A private tag SHOULD be `X__`, where `` is a domain the producer controls, upper-cased, with anything outside `A–Z0–9` replaced by `_`: `X_EXAMPLE_COM_TICKET=OPS-14`. - The manifest member `x` is an object keyed by that domain: `"x": {"example.com": {"ticket": "OPS-14"}}`. Private data goes under `x` and nowhere else. A tool publishing a recording outside the organisation strips private data by deleting `x` and every `X_*` tag. - Consumers MUST ignore private data they do not own. A tool that edits `x` MUST recompute `CASSINI_PAYLOAD_SHA256` and `CASSINI_PAYLOAD_RAW_BYTES`. **Reserved.** The manifest member `payloads` and the tag prefix `CASSINI_PL_` are held for a later revision. Producers MUST NOT write them; consumers MUST ignore them. ## Tags ### Reading the comment vector The tags are Vorbis comments in the `OpusTags` packet: the second packet of the stream, which MAY span several pages. A consumer MUST reassemble the whole packet before parsing it. Taking "the second page" instead of "the second packet" truncates any header longer than one page, which is most of them. A consumer MUST read every comment. RFC 7845 lets a generic Opus reader ignore comments past the first 61,440 octets; a Cassini reader MUST NOT, because on a long recording the tags that make the file decodable can sit past that mark. It MUST still bound its own allocation, and MAY reject a header over 120 MB. A consumer fetching over HTTP MAY request only a prefix of the file, since the header is at the front. If the range does not contain a complete `OpusTags` packet, it MUST fetch more rather than parse what it has. Three things the Vorbis comment format leaves open, pinned down here: - **The separator is the first `=`.** A value MAY contain more of them. - **Names are case-insensitive.** Producers MUST write `CASSINI_*` names in upper case. Consumers MUST fold ASCII case on the name before comparing, and MUST compare the value exactly as written. A tool rewriting a tag on a file it did not write MUST replace the spelling already there, not add a second comment under the canonical one. - **A name MAY repeat, and Cassini never repeats one.** Producers MUST NOT write a `CASSINI_*` name twice. A consumer that sees a repeat of a load-bearing tag MUST report `invalid-cassini-metadata` rather than pick one. Load-bearing means `CASSINI_FORMAT` and every `CASSINI_PAYLOAD_*` and `CASSINI_TX_*` tag. For any other `CASSINI_*` tag it SHOULD use the manifest's value and report the repeat. `ENCODER` and the vendor string belong to the muxer, not the producer. ffmpeg writes `encoder=Lavf` whatever it was asked for. ### Cassini descriptor tags Every tag below is REQUIRED, and every one MUST carry a non-empty value. A producer with no value for one of them has a file it cannot write. **Identity** | Tag | Value | |---|---| | `CASSINI_FORMAT` | `org.cassini.portable-meeting/1` | | `CASSINI_PROFILE` | `ogg-opus` | **The manifest chunk set** | Tag | Value | |---|---| | `CASSINI_PAYLOAD_MIME` | `application/vnd.cassini.portable-meeting+json` | | `CASSINI_PAYLOAD_ENCODING` | `base64url+gzip+utf8json` | | `CASSINI_PAYLOAD_SCHEMA` | `https://cassini-format.codemyriad.io/schema/cassini-portable-meeting-manifest-v1.schema.json` | | `CASSINI_PAYLOAD_CHUNK_COUNT` | decimal integer, 1 or more | | `CASSINI_PAYLOAD_SHA256` | lowercase hex SHA-256 of the decompressed JSON bytes | | `CASSINI_PAYLOAD_RAW_BYTES` | decimal, the decompressed length | | `CASSINI_PAYLOAD_GZIP_BYTES` | decimal, the compressed length | | `CASSINI_PAYLOAD_000` … | the chunks; see [Chunk sets](#chunk-sets) | **The transcript index** | Tag | Value | |---|---| | `CASSINI_TRANSCRIPT_IDS` | the ids in `transcripts[]`, comma-separated, no spaces, sorted | | `CASSINI_TRANSCRIPT_DEFAULT` | the id a viewer opens first | | `CASSINI_TX__PAYLOAD_*` | one chunk set per transcript; see [Transcripts and their chunk sets](#transcripts-and-their-chunk-sets) | `CASSINI_TRANSCRIPT_IDS` is sorted; `transcripts[]` is in producer order. A consumer MUST compare them as sets. **Audio shape and identity** | Tag | Value | |---|---| | `CASSINI_AUDIO_SAMPLE_RATE` | `48000`. A constant: Opus always decodes at 48 kHz. | | `CASSINI_AUDIO_CHANNELS` | `1` or `2` | | `CASSINI_AUDIO_SAMPLE_COUNT` | playable samples at 48 kHz, as defined by the digest spec | | `CASSINI_AUDIO_DURATION_MS` | `sampleCount * 1000 / 48000`, integer division, truncated | | `CASSINI_AUDIO_MATCH_POLICY` | `exact-opus-audio-v1` | | `CASSINI_AUDIO_OPUS_SHA256` | lowercase hex SHA-256 of the canonical Opus packet stream | **For a reader who has none of this document** | Tag | Value | |---|---| | `CASSINI_DECODE_HINT` | `Concatenate CASSINI_PAYLOAD_000..N for the manifest; for a transcript body concatenate CASSINI_TX__PAYLOAD_000..N. Each chunk set: base64url decode, gzip decompress, parse UTF-8 JSON.` | ### Summary and mirror tags Three summary tags let a reader show something before decoding anything. Producers SHOULD write them; consumers MUST NOT require them. | Tag | Value | |---|---| | `CASSINI_MEETING_ID` | the manifest's `meeting.id` | | `CASSINI_CREATED_AT` | RFC 3339 UTC | | `CASSINI_SPEAKER_COUNT` | decimal | Four optional tags mirror the origin fields of `meeting`. Each is written only when its value is known: absent, never empty. | Tag | Mirrors | |---|---| | `CASSINI_ROOM_ID` | `meeting.roomId` | | `CASSINI_ROOM_NAME` | `meeting.roomName` (no longer written; still read) | | `CASSINI_JOB_ID` | `meeting.jobId` | | `CASSINI_ATTEMPT_NUMBER` | `meeting.attemptNumber` | | `CASSINI_PROCESSED_AT` | `meeting.processedAtUtc` | | `CASSINI_RECORDED_AT_LOCAL` | `meeting.recordedAtLocal` | Producers SHOULD also write `TITLE`, `DATE` and a one-line `DESCRIPTION` saying how to decode the payload, so that ordinary tools show something useful. All of these are copies. The manifest is the record; see [When a tag and the manifest disagree](#when-a-tag-and-the-manifest-disagree). ### Comment order Nothing in this format depends on the order comments appear in. Chunks are reassembled by the index in their name, never by position. RFC 7845 lets a generic reader ignore comments past the first 61,440 octets of the header, and a long recording's payload passes that mark. In practice nothing truncates: `ffmpeg -c copy`, a remux and a tag-library round-trip all preserve every comment on a 300 KB header, and these files are played, not edited. The format accepts the exposure. A producer MAY write `CASSINI_FORMAT` first, then every other non-chunk tag, then the numbered chunks. That keeps everything load-bearing inside the window at no cost. Sorting the whole list does the opposite, because `CASSINI_PAYLOAD_000` sorts before `CASSINI_PAYLOAD_CHUNK_COUNT`. ## Chunk sets A **chunk set** carries a document of any size in Vorbis comments. It is a prefix `P` and seven kinds of tag: | Tag | Holds | |---|---| | `P` `MIME` | the media type of the decoded document | | `P` `ENCODING` | how to turn the concatenated text back into bytes | | `P` `CHUNK_COUNT` | how many numbered tags follow | | `P` `SHA256` | lowercase hex SHA-256 of the decoded bytes | | `P` `RAW_BYTES` | the decoded length | | `P` `GZIP_BYTES` | the compressed length | | `P` `000` … `P` `` | the document | A file carries at least two: the manifest, whose prefix is `CASSINI_PAYLOAD_`, and one per transcript, whose prefix is that entry's `payloadRef.prefix`. Rules, the same for every chunk set: - Indexing starts at `000`. The index is decimal, zero-padded to at least three digits, and grows past three when it must: `999`, `1000`. Consumers MUST parse it as a number. - Every index from `000` to `CHUNK_COUNT-1` MUST be present. A consumer MUST reassemble by generating those names and looking each up. A missing index makes the set unreadable; a consumer MUST NOT concatenate across a gap. - A chunk name MUST appear once. A consumer that sees a repeat MUST treat the set as unreadable. - Chunk tags numbered past `CHUNK_COUNT-1` are ignored, but a repeat among them is still a repeat. A tool replacing a chunk set MUST delete every numbered tag under that prefix first; a stale chunk is old text left in a file that was meant to lose it. - Values are concatenated in numeric index order with no separator, then decoded once. A single chunk is not decodable on its own. - Producers SHOULD keep each chunk to 4096 characters. Nothing depends on it; it keeps the header readable in tools that print one tag per line. - A chunk tag is the prefix followed by digits only. `CASSINI_PAYLOAD_SCHEMA` starts with the manifest's prefix and is not a chunk. A tool that matches the prefix alone deletes required descriptors. **Which declaration wins.** For the manifest, the `CASSINI_PAYLOAD_*` tags are the record, because there is no manifest yet. For a transcript body, the entry's `payloadRef` is the record for all seven values, and the matching `CASSINI_TX_*` tags are a copy. A disagreement there is a warning, not a failure. ### Payload encoding The one encoding defined is `base64url+gzip+utf8json`: 1. compact UTF-8 JSON: no insignificant whitespace, no trailing newline, no byte-order mark. The set's `SHA256` is over exactly these bytes. 2. gzip, RFC 1952. 3. base64url, RFC 4648 §5, **unpadded**, no line breaks. 4. split across the numbered tags. Producers MUST write unpadded. Consumers MUST accept both padded and unpadded, and MUST strip ASCII whitespace (space, tab, CR, LF) before deciding how much padding to add: a comment value may legally contain a newline. Strictness, so that two readers agree on what is damaged: - A decimal tag value is `0|[1-9][0-9]*`, at most 2^53−1. Anything else is malformed. - base64url is strict: a byte outside the alphabet, padding anywhere but the end, or non-zero trailing bits is a decode failure (RFC 4648 §3.5). - One gzip member, nothing after it. - UTF-8 decoding is fatal on an invalid sequence. A JSON object with a repeated member name is malformed. `RAW_BYTES` is a bound, not decoration. A gzip stream can claim to be small and inflate to gigabytes. Consumers MUST stop inflating once the output exceeds the declared `RAW_BYTES` and treat the result as damaged. The declared value is untrusted: a consumer MUST also apply a ceiling of its own, and MUST bound the comment count, chunk count and JSON depth it will accept. Producers SHOULD keep every chunk set under 64 MiB decompressed; a consumer MAY reject one above that. A consumer MUST check the declared `SHA256` before believing any field of the result. For the manifest, an absent digest is a missing required tag and the file is `invalid-cassini-metadata`. For a body, it makes that transcript unavailable. A consumer MUST NOT decode a chunk set whose `ENCODING` it does not implement. For the manifest that is `invalid-cassini-metadata`; for a body, that transcript is unavailable. ## The manifest ### Embedded manifest The decoded `CASSINI_PAYLOAD_` chunk set is one JSON object. Its schema is [`spec/cassini-portable-meeting-manifest-v1.schema.json`](spec/cassini-portable-meeting-manifest-v1.schema.json). Three members identify the document: | Member | Value | |---|---| | `kind` | `cassini-portable-meeting`. Any other value: not a portable meeting. | | `version` | `1`. MUST match `CASSINI_FORMAT`; a disagreement is `invalid-cassini-metadata`. | | `profile` | `ogg-opus`. Any other value: not a portable meeting. | Required alongside them: `meeting`, `audio`, `integrity`, `speakers`, `transcripts`. Optional: `readableTranscripts`, `provenance`, `chapters`, `summary`, `attachments`, `x`. The manifest is an index. It does not contain the transcript; it points at it. A consumer that wants only the title, date and speaker list never decodes a transcript body. ### `meeting` Required: - `id`: a stable identifier. The reference producer writes `mtg_` plus the 64 hex characters of `integrity.opusAudioSha256`, so a tag rewrite keeps the id and a re-encode changes it. Any producer MAY use any stable non-empty string. Consumers MUST NOT parse it. - `title`: non-empty. - `createdAtUtc`: RFC 3339 UTC. When the pipeline stamped this artifact, which is not when the meeting happened. - `durationMs`: playable duration. Optional: - `recordedAtLocal`: wall-clock time at the recording site, `YYYY-MM-DDTHH:MM:SS`, no offset and no zone. It is a label, not an instant: consumers MUST NOT convert it to one. - `processedAtUtc`: RFC 3339 UTC. - `summary`: a short plain-text summary of the meeting, for display. - `language`: BCP-47. The reference producer never writes it. - `roomId`: a one-way derivation of the room's identity, `rm_<16 lowercase hex>`. Never the identity itself: a Nextcloud Talk conversation token is also the link that joins the conversation, and this file travels. Producers MUST NOT write a raw room token in any tag or manifest member. - `roomName`: no longer written, still read. A display name is editable and a published recording is not. - `jobId`, `attemptNumber`: the producer job, and which attempt, 1-based. A consumer MUST treat a non-positive `attemptNumber` as absent. ### `audio` What is actually stored. All six required: `container` (`ogg`), `codec` (`opus`), `sampleRate` (`48000`), `channels` (`1` or `2`), `sampleCount`, `durationMs`. ### `integrity` Whether this manifest still describes the audio in this file. All six required: - `matchPolicy`: `exact-opus-audio-v1`. - `opusAudioSha256`: 64 lowercase hex, over the stream defined in [`spec/cassini-opus-audio-integrity-v1.md`](spec/cassini-opus-audio-integrity-v1.md). - `sampleRate`, `channels`, `sampleCount`, `durationMs`: MUST equal their counterparts in `audio`. What a consumer does with the answer is under [Trust and integrity](#trust-and-integrity). ### `speakers` The table transcript items point at. Each entry has a non-empty `id` and a `label`, the name a reader displays. The array MAY be empty. Producers MUST write an entry for every speaker their transcripts name. An item whose `speaker` matches nothing is still transcript content; render it under an unknown speaker rather than dropping it. ### `transcripts` The index of word-timed transcripts, at least one entry. Each entry names the chunk set that carries its body. These are the canonical transcript; anything derived from them is optional and regenerable. `readableTranscripts` is the optional index of derived transcripts. Each entry MUST name its source in `sourceTranscriptId`, and that id MUST be one of the transcripts this file declares. ### `provenance` Optional. Which systems produced each layer, so a user can see what made the file. - `speechToText`, `readableCleanup`, `displayTranscript`: maps keyed by transcript id. A transcript with `id: "canary"` resolves to `provenance.speechToText.canary`. - `meetingSummary`: one processing step. - `attribution`: how speaker attribution ran. Required `ran`, `mode`, `wordsMeasured`, `wordsFlagged`, `wordsDropped`; optional `reason`, `thresholdDb`. `mode` is an open string. - `wordTimings`: `{ "endsBoundedByAudio": true }`. A consumer keys off its presence, not its value. Absent means word ends are unvouched for and may run past silence; a repair that clips them is right on such a file and wrong on one that carries the record. A processing step has only optional strings: `backend`, `engine`, `model`, `device`, `language`, `source`, `version`. Producers MUST NOT put a URL or a hostname in any of them. This file is cleartext to everyone who receives it. ### `chapters`, `summary`, `attachments` All optional. `chapters` entries carry `startMs`, `endMs`, `title`. An attachment is `{ "name", "mime", "contentBase64" }`: a file name unique within the array, its media type, and its bytes in **standard base64, not base64url**, the one place the alphabet differs. A consumer MUST ignore an entry it cannot decode. `summary` is metadata about a summary (the reference producer writes `format`, `model`, `backend`); the summary itself is the attachment named `summary.md`. Attachments are for small things: they sit inside the manifest chunk set and count against its bound. ### Transcripts and their chunk sets Each entry in `transcripts[]` and `readableTranscripts[]`: ```jsonc { "id": "canary", // ^[a-z0-9][a-z0-9-]{0,31}$, unique in file "role": "raw-asr", "default": true, // at most one per slot "format": "cassini.words.v1", "language": "en", "wordCount": 9224, "createdAtUtc": "2026-05-12T14:40:00Z", "payloadRef": { "prefix": "CASSINI_TX_CANARY_PAYLOAD_", "chunkCount": 14, "sha256": "…", // of the decompressed body "rawBytes": 718432, "gzipBytes": 221110, "mime": "application/vnd.cassini.transcript-words+json", "encoding": "base64url+gzip+utf8json" } } ``` `role` says how the text came to exist: | Role | What it is | `sourceTranscriptId` | |---|---|---| | `raw-asr` | what a recogniser produced from the audio | MUST NOT carry one | | `human-corrected` | that text after a person fixed it | required | | `translation` | it in another language | required | | `scripted` | authored text the recording was made *from* | MUST NOT carry one | | `readable-cleanup`, `display` | derived views, in `readableTranscripts[]` only | required | `scripted` is the odd one. Some recordings are performances of words that already existed: a song's lyrics, a read script. That text is not a transcription; it is what the audio is a performance of, so it is authoritative rather than derived. A producer that writes both SHOULD flag the `scripted` entry as the default. One is the words; the other is a guess at them. The consumer's resolution rule below does not change. The body of every entry is [`spec/cassini-words-v1.md`](spec/cassini-words-v1.md). The media type is `application/vnd.cassini.transcript-words+json` for a word-timed body and `application/vnd.cassini.transcript-readable+json` for a derived one. **The chunk set.** `payloadRef.prefix` is authoritative; a consumer MUST use it as written and MUST NOT re-derive it. A producer derives it as `CASSINI_TX_` + the id upper-cased with `-` replaced by `_` + `_PAYLOAD_`. Beside the chunks, the six descriptors `_MIME`, `_ENCODING`, `_CHUNK_COUNT`, `_SHA256`, `_RAW_BYTES`, `_GZIP_BYTES` are REQUIRED under that prefix, and each is a copy of the `payloadRef` member of the same name. ### Reserved transcript ids An id MUST match `^[a-z0-9][a-z0-9-]{0,31}$` and MUST be unique in the file. `-` is legal and `_` is not, because the tag prefix replaces `-` with `_`: `raw-asr` and `raw_asr` would share one chunk set and one body would be lost without an error. Producers MUST reject an id containing `_`, and MUST reject two ids whose prefixes collide. These ten are reserved. They are the manifest's own top-level names, held back so an id can never read as one in a log line or a tag dump: ```text payload, format, audio, meeting, integrity, transcript, provenance, summary, attachments, speakers ``` ### Audio integrity The digest covers the playback-relevant `OpusHead` fields, every audio packet in order with its length, and the playable sample count. It excludes `OpusTags` and all Ogg framing. That is what makes it non-circular: the manifest can carry its own audio digest, and rewriting the tags cannot change it. Producers MUST compute it without decoding the audio, and MUST verify it against the file they are about to publish, not the input they encoded. The byte-level rule is [`spec/cassini-opus-audio-integrity-v1.md`](spec/cassini-opus-audio-integrity-v1.md). ## Reading a file The read path, in order. A consumer that performs these steps reads every file this format defines. 1. **Find the tags.** Reassemble `OpusTags` and parse the comment vector. [Reading the comment vector](#reading-the-comment-vector). 2. **Decide what the file is.** [File identification](#file-identification). 3. **Reassemble the manifest.** The `CASSINI_PAYLOAD_` chunk set. [Chunk sets](#chunk-sets). 4. **Decode and verify it.** Bound the inflate, then check the digest and byte counts. [Payload encoding](#payload-encoding). 5. **Read the manifest.** Check `kind`, `version` and `profile`. Ignore what you do not recognise. [Embedded manifest](#embedded-manifest). 6. **Resolve which transcript to show.** Next section. 7. **Decode its body.** Its chunk set, by steps 3 and 4, with `payloadRef` as the record. The body is [`spec/cassini-words-v1.md`](spec/cassini-words-v1.md). 8. **Decide whether to trust it, and say so.** [Trust and integrity](#trust-and-integrity). In every state, a consumer MUST keep playing the audio. A file that played before a Cassini reader touched it MUST play afterwards. Consumers SHOULD show title, date and speaker count before rendering a transcript, since those are one small chunk set away and a body may be many. ### Resolving a transcript There are three slots. A consumer fills each from the entries of one array with one set of roles: | Slot | Array | Roles | Body `format` | |---|---|---|---| | words | `transcripts[]` | `raw-asr`, `human-corrected`, `translation`, `scripted` | `cassini.words.v1` | | readable | `readableTranscripts[]` | `readable-cleanup` | as the entry says | | display | `readableTranscripts[]` | `display` | as the entry says | For each slot, in this order: 1. the first entry whose `default` is `true`; 2. failing that, the first entry. Array order is therefore normative. Zero flagged defaults is a legal file. `CASSINI_TRANSCRIPT_DEFAULT` is the id this rule selects for the words slot. When showing a readable transcript beside the words, a consumer SHOULD prefer the entry whose `sourceTranscriptId` names the words it is showing. `CASSINI_TRANSCRIPT_IDS` and `CASSINI_TRANSCRIPT_DEFAULT` are copies. A consumer MUST ignore an id in the tag that is not in the manifest, and MUST ignore a default that names no entry. Where tag and flag both resolve and disagree, the manifest wins. Beside the file's trust state, each selected transcript is **available** or **unavailable**. A body whose chunk set is missing a chunk, whose count, byte counts or digest disagree, or whose decode fails is unavailable, and the file's state is unaffected: the state describes the manifest and the audio. The other transcripts, the speakers and the meeting are still good. The consumer MUST say which transcript it could not load, and `ok` with no transcript on screen is a legal outcome. A repeated tag is different: it is evidence the file was edited, and [the comment-vector rule](#reading-the-comment-vector) applies. ### Trust and integrity `integrity` answers one question: do this transcript and this audio belong together? It is a **join key, not a seal**. It is not evidence that the audio is authentic or the transcript accurate, and anyone who rewrites the transcript can recompute every digest. Nothing below throws a transcript away. The audio of a real file does not change and the transcript does, because reprocessing with a better model is the normal life of a meeting. A reader that discards the transcript on a failed check is wrong far more often than right. The hard check belongs in the producer, which can refuse to ship. The consumer has a user in front of it. A consumer MUST end every read in exactly one of these states and MUST make it visible to whoever called it. The names are normative. | State | Meaning | The consumer | |---|---|---| | `plain-audio` | no `CASSINI_FORMAT` | plays it; not an error | | `unknown-cassini-format` | a major version it does not implement | plays it; MAY show `meeting`, `audio`, `speakers`; MUST NOT present a transcript it does not understand | | `invalid-cassini-metadata` | present, cannot be reconstructed | plays it; says which step failed; MUST NOT render a partial manifest | | `unverified` | readable, audio not checked | opens it; shows the transcript; marks it unverified wherever shown | | `stale-audio` | readable, checked, does not match | opens it; keeps and shows the transcript; labels it; names the failed check | | `ok` | readable, checked, matches | opens it | Test them in table order; the first that applies is the state. #### `plain-audio` `CASSINI_FORMAT` is absent. The ordinary case for an ordinary `.opus`. #### `unknown-cassini-format` The Opus stream is valid whatever the metadata says. The consumer SHOULD say the file carries metadata in a version it cannot read. #### `invalid-cassini-metadata` The manifest cannot be trusted at all. A required `CASSINI_PAYLOAD_*` tag is missing or malformed; a load-bearing tag is repeated; a manifest chunk is missing; the count, byte counts or digest disagree; the decode, the UTF-8 or the JSON fails; `kind` or `profile` is wrong; `version` disagrees with `CASSINI_FORMAT`; a required member is missing or of the wrong type. A malformed optional member is not on this list: it is ignored as if absent. The consumer MUST NOT show any manifest field. #### `unverified` The consumer did not check the audio, or could not. It has not read the audio bytes (a byte-range fetch, a tag tool); the file carries no digest; the digest in the manifest and the one in the tags disagree, so there is no single claim to check; the digest parser rejected the stream (a page CRC failure, a sequence gap, a chained or multiplexed stream, a missing end-of-stream flag); or `integrity` carries a member this consumer does not recognise, so it cannot know what a match would mean. In each case the consumer SHOULD say why. A consumer that never verifies audio is conforming, and MUST report `unverified` rather than `ok`. Claiming a check you did not run is the one thing this section forbids outright. #### `stale-audio` The digest, or `channels`, `sampleCount` or `durationMs` computed from the stream, disagrees with `integrity`. Any one mismatch is enough. This usually means the file was reprocessed or remuxed, not tampered with. The consumer MUST NOT delete, hide or refuse to render the transcript, MUST NOT make the user re-import the file, and SHOULD offer plain playback as an alternative. It SHOULD show a message equivalent to: ```text The audio in this file does not match the transcript recorded with it. The transcript is shown as it was written; it may describe a different recording. ``` #### `ok` Every manifest check passed, the audio digest and shape were computed from the stream, and every one of them matched. #### Where the hard check lives Producers MUST fail closed. A producer MUST recompute the integrity block from the file it is about to publish and MUST NOT publish one whose digest or shape fields disagree with its own manifest. A `stale-audio` file should be impossible to create, so a reader that meets one is looking at damage done afterwards, and the transcript is still the best thing it has. ### When a tag and the manifest disagree Most `CASSINI_*` tags duplicate a manifest value so that `ffprobe` shows something before anything is decoded. **The manifest is the record and the tags are the copy.** Once a consumer has the manifest it MUST use the manifest's value, and SHOULD report the disagreement. A disagreement is not an error. Three exceptions: - The manifest's own `CASSINI_PAYLOAD_*` descriptors have no manifest counterpart. For the manifest, the tags are the record. - `CASSINI_FORMAT` is checked before any manifest exists. If it disagrees with `version`, the file is `invalid-cassini-metadata`. - If `CASSINI_AUDIO_OPUS_SHA256` and `integrity.opusAudioSha256` disagree, there is no single claim to verify. The file is `unverified`, not `ok`. ### Casual inspection A curious person with `ffprobe` and no documentation should be able to work the format out. The tags they will see include `CASSINI_FORMAT`, `CASSINI_PAYLOAD_ENCODING` and `CASSINI_DECODE_HINT`. ```bash ffprobe -v error -show_entries stream_tags -of json meeting.opus ``` The manifest, decoded in a few lines: ```python import base64, gzip, json, subprocess, sys probe = subprocess.check_output(["ffprobe", "-v", "error", "-show_entries", "stream_tags", "-of", "json", sys.argv[1]], text=True) tags = {} for stream in json.loads(probe).get("streams", []): tags.update(stream.get("tags", {})) count = int(tags["CASSINI_PAYLOAD_CHUNK_COUNT"]) blob = "".join(tags[f"CASSINI_PAYLOAD_{i:03d}"] for i in range(count)) print(gzip.decompress(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))).decode()) ``` That prints the index. A transcript body decodes the same way under its own prefix. Neither this nor `ffprobe` verifies a digest, so a reader built from them is `unverified`. ## Writing a file Producers MUST: - produce valid Ogg Opus; - write every tag under [Cassini descriptor tags](#cassini-descriptor-tags); - embed a manifest that validates against the schema; - compute `CASSINI_AUDIO_OPUS_SHA256` over the canonical packet stream; - keep summary tags and manifest in agreement; - re-read the file they wrote and refuse to ship one whose digest or shape disagrees with its own manifest. Producers SHOULD write compact JSON, keep the raw transcript even when a cleaned one exists, and keep the manifest an index rather than a container. A file SHOULD carry the meeting's identifying fields, the speaker table, the canonical word transcript as its own chunk set, and optionally a readable transcript and chapters. It SHOULD NOT carry search indexes, build products or captions, which are derivable. ## Rationale **Why not one binary blob tag.** The format should be legible. Explicit encoding, integrity and count tags plus a decode hint make the metadata self-describing enough that a curious user can decode it without reading source code. **Why gzip.** Browsers inflate it natively, the payload is small enough for it to work well, and it decodes from a shell. A denser profile would be a new `ENCODING` value, not an undocumented variation. **Rejected alternatives.** A ZIP-like package: not playable in ordinary players. MP4/M4A: Ogg/OpusTags is easier to inspect from the command line. WebM: less obviously "just an audio file". Raw JSON in tags: bloat for no gain at this size. ### Why `.meeting` is not a contract The build pipeline stages a `.meeting` bundle directory with `cassini.json` and `manifest.json`. These are build scratch. The only durable deliverable is the `.opus` file. Do not treat the bundle as a stable interface. How the reference operator guarantees a sealed, verified `.opus` exists before anything downstream uses it is an implementation concern, recorded in [`design/operator-sealing.md`](design/operator-sealing.md). ============================================================================== TRANSCRIPT BODY — spec/cassini-words-v1.md ============================================================================== # The transcript body: `cassini.words.v1` Date: 2026-09-02 Status: published A transcript body is the document the timestamps live in. It is what a `CASSINI_TX__PAYLOAD_` chunk set decodes to: one JSON object, one item per word, each item naming who said it and when. ## The shape ```json { "format": "cassini.words.v1", "language": "en", "wordCount": 3, "items": [ { "speaker": "spk_mira", "startMs": 900, "endMs": 1251, "text": "Morning." }, { "speaker": "spk_mira", "startMs": 1251, "endMs": 1502, "text": "Sorry," }, { "speaker": "spk_leo", "startMs": 5012, "endMs": 5238, "text": "And", "attributionGapDb": 17.25, "lowConfidenceSpeaker": true } ] } ``` That is the whole format. ## The envelope | Member | | Rule | |---|---|---| | `format` | MUST | the literal `cassini.words.v1`. Consumers SHOULD NOT dispatch on it; the manifest entry already said what this is. | | `items` | MUST | an array of items. Empty is `[]`. Consumers SHOULD accept `null` as empty, because a Go encoder emits it for an empty slice. | | `wordCount` | MUST | equal to `items.length`. A convenience; `items` wins on disagreement. | | `language` | MAY | BCP-47 or a bare code. Absent and empty mean the same thing: nobody said. Neither means English. | The envelope MUST be a single UTF-8 JSON object, and a consumer MUST ignore any member it does not recognise. ## An item | Member | | Rule | |---|---|---| | `speaker` | MUST | an `id` from the manifest's `speakers[]`. That entry's `label` is what a reader displays. | | `startMs`, `endMs` | MUST | integers, milliseconds from the start of the audio, `0 ≤ startMs ≤ endMs`. The interval is half-open: the word ends before `endMs`. An end past the audio's duration is allowed; the word ran into silence. | | `text` | MUST | one token, punctuation attached, no surrounding space. In a language written with spaces, joining items with one space reconstructs prose; the format carries no separator. | | `attributionGapDb` | MAY | see below | | `lowConfidenceSpeaker` | MAY | see below | An item whose `speaker` matches no entry is still transcript content. Render it under an unknown speaker; do not drop it. ## Order Items are in **speaker-turn order**, not time order. Within one speaker's run, `startMs` never decreases; two words can share a start. Across a speaker change it can go backwards, because people talk over each other and each speaker's words are kept together. A consumer MUST NOT sort items by `startMs`. Sorting by time interleaves overlapping speakers and destroys every turn in the file. Read them in file order. A **turn** is a maximal run of consecutive items with the same `speaker`. It is the unit a reader renders as a paragraph, and one pass in file order reconstructs every turn. A turn starts at its first item's `startMs` and ends at the greatest `endMs` among its items, which is not always the last item's. Producers SHOULD keep one speaker's continuous speech in one run. ## Attribution Two optional members record how sure the attribution stage was about `speaker`. `attributionGapDb`: at this word, how far the loudest *other* participant's microphone sat above its own noise floor, compared with the attributed speaker's, in dB. Near zero means the attributed speaker was the loudest voice. Large and positive means somebody else was, and this word is a crosstalk candidate. How the producer measured it is its own business, and `provenance.attribution.mode` names the method; the number is evidence, not a defined quantity. The key is present exactly on the words that were measured. A measured `0` is written, so presence is the signal, and a consumer MUST NOT read absence as zero. `lowConfidenceSpeaker`: only ever `true`. A confidently attributed word omits the key. The word is still canonical content: a consumer MAY de-emphasise it or flag it for review, but MUST NOT drop it. A file whose attribution stage never ran carries neither key on any item. A `scripted` transcript carries neither either: nothing was recognised, and its speaker ids are as certain as the person who wrote them. ## Conventions Not required; what the reference producer does. - One item is one word. The format does not forbid a longer span, but nothing produces one. - Blank text is skipped, not written. - Two speakers' items can cover the same milliseconds. Nothing says a moment belongs to one speaker. - Milliseconds throughout, on the same clock as the manifest's `durationMs`. - The media type written into the tags is `application/vnd.cassini.transcript-words+json`. It is a hint. - A derived transcript (`human-corrected`, `translation`, `readable-cleanup`, `display`) uses this same body. What makes it derived is its `role` and `sourceTranscriptId` in the manifest, not a different shape. ## What is deliberately not here No per-item `id`, no confidence score, no alternatives, no word-level language, no link to a source segment. Those exist in the pipeline's working files and are dropped on the way in, because this file is the one that has to survive being mailed to somebody. Adding a member is allowed. Changing what an existing member means is a new format id. ## Schema [`cassini-words-v1.schema.json`](cassini-words-v1.schema.json), CC0. It is open: unknown members validate on both the envelope and the item. A typo in an optional hint should cost you the hint, not the transcript. ============================================================================== AUDIO DIGEST — spec/cassini-opus-audio-integrity-v1.md ============================================================================== # The audio digest: `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](https://www.rfc-editor.org/rfc/rfc7845#section-4). Each packet's duration comes from its TOC byte as [RFC 6716 §3.1](https://www.rfc-editor.org/rfc/rfc6716#section-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–7 | Mode | Frame duration | |---|---|---| | 0–3, 4–7, 8–11 | SILK NB, MB, WB | 10, 20, 40, 60 ms | | 12–13, 14–15 | Hybrid SWB, FB | 10, 20 ms | | 16–19, 20–23, 24–27, 28–31 | CELT NB, WB, SWB, FB | 2.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–1 | Frames | |---|---| | 0 | 1 | | 1, 2 | 2 | | 3 | bits 0–5 of the second byte | The rules of [§3.2.5](https://www.rfc-editor.org/rfc/rfc6716#section-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](../SPEC.md#unverified); 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: | Parameter | Value | |---|---| | polynomial | `0x04c11db7` | | initial value | `0` | | input reflection | none | | output reflection | none | | final XOR | none | | covers | the 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. ============================================================================== GUIDE — writing a file ============================================================================== 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](#a-complete-producer). ## 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 1. Container **MUST** be Ogg, codec **MUST** be Opus, sample rate field `48000`. Mono for speech; stereo only when it is deliberate. 2. **MUST** carry `CASSINI_FORMAT=org.cassini.portable-meeting/1`. That is what makes it a Cassini file. 3. **MUST** carry every descriptor tag, each with a non-empty value: | tag | value | |---|---| | `CASSINI_FORMAT` | `org.cassini.portable-meeting/1` | | `CASSINI_PROFILE` | `ogg-opus` | | `CASSINI_PAYLOAD_MIME` | `application/vnd.cassini.portable-meeting+json` | | `CASSINI_PAYLOAD_ENCODING` | `base64url+gzip+utf8json` | | `CASSINI_PAYLOAD_SCHEMA` | `https://cassini-format.codemyriad.io/schema/cassini-portable-meeting-manifest-v1.schema.json` | | `CASSINI_PAYLOAD_CHUNK_COUNT` | decimal integer, 1 or more | | `CASSINI_PAYLOAD_SHA256` | lowercase hex, over the **decompressed** JSON | | `CASSINI_PAYLOAD_RAW_BYTES` | decimal integer, the decompressed length | | `CASSINI_PAYLOAD_GZIP_BYTES` | decimal integer, the compressed length | | `CASSINI_TRANSCRIPT_IDS` | the ids in `transcripts[]`, comma-separated, no spaces, sorted | | `CASSINI_TRANSCRIPT_DEFAULT` | the id a viewer opens first | | `CASSINI_TX__MIME` | per transcript: `application/vnd.cassini.transcript-words+json` | | `CASSINI_TX__ENCODING` | per transcript: `base64url+gzip+utf8json` | | `CASSINI_TX__CHUNK_COUNT` | per transcript: decimal integer | | `CASSINI_TX__SHA256` | per transcript: lowercase hex, over the decompressed body | | `CASSINI_TX__RAW_BYTES` | per transcript: decimal integer | | `CASSINI_TX__GZIP_BYTES` | per transcript: decimal integer | | `CASSINI_AUDIO_SAMPLE_RATE` | `48000` | | `CASSINI_AUDIO_CHANNELS` | `1` or `2` | | `CASSINI_AUDIO_SAMPLE_COUNT` | playable samples, as the digest spec defines them | | `CASSINI_AUDIO_DURATION_MS` | `sampleCount * 1000 / 48000`, truncating | | `CASSINI_AUDIO_MATCH_POLICY` | `exact-opus-audio-v1` | | `CASSINI_AUDIO_OPUS_SHA256` | lowercase hex, the audio digest | | `CASSINI_DECODE_HINT` | one sentence explaining the chunk sets | The six `CASSINI_TX_*` descriptors are copies of the entry's `payloadRef`. The full list, with the exact `DECODE_HINT` sentence, is in [the specification](/spec/v1/#cassini-descriptor-tags). 4. 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. 5. Chunks **MUST** be `CASSINI_PAYLOAD_NNN`, zero-padded to a *minimum* of three digits, from `000`, joined by index with no separator, exactly `CHUNK_COUNT` of them. Index 1000 is `CASSINI_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. 6. Each chunk-set SHA-256 is over the **decompressed** JSON bytes, not the gzip stream and not the base64 text. `CASSINI_AUDIO_OPUS_SHA256` is different: it is over the packet stream that [`exact-opus-audio-v1`](/spec/audio-integrity/) defines. 7. The manifest **MUST** have `kind`, `version`, `profile`, `meeting`, `audio`, `integrity`, `speakers`, `transcripts`. `kind` is the literal `cassini-portable-meeting` and `profile` the literal `ogg-opus`, in every version of the format. 8. Each transcript body lives in its own chunk set, under a prefix derived from the id by upper-casing and replacing `-` with `_`: `raw-asr` becomes `CASSINI_TX_RAW_ASR_PAYLOAD_`. The entry's `payloadRef` carries that prefix, the chunk count and the body's SHA-256. So ids **MUST NOT** contain `_`: `raw-asr` and `raw_asr` map to the same prefix and one silently wins. In practice `^[a-z0-9][a-z0-9-]{0,31}$`. 9. Ids **MUST NOT** be a reserved descriptor name: `payload`, `format`, `audio`, `meeting`, `integrity`, `transcript`, `provenance`, `summary`, `attachments`, `speakers`. 10. **At most one** entry flagged `default: true` per slot — one across the word-timed roles, one across `readable-cleanup`, one across `display`. Flagging none is legal and readers fall back to array order. `CASSINI_TRANSCRIPT_DEFAULT` mirrors the words slot. A derived transcript carries `sourceTranscriptId`. 11. `CASSINI_AUDIO_OPUS_SHA256` is computed over the canonical compressed Opus stream, **without decoding the audio**. The rule is in [the digest spec](/spec/audio-integrity/): playback-relevant `OpusHead` fields, every audio packet in order with its length, the playable sample count. It excludes `OpusTags` and all Ogg framing, which is what lets the manifest contain its own audio digest without writing it changing it. 12. Descriptor tags **MAY** be written before the chunk tags. [RFC 7845 §5.2](https://www.rfc-editor.org/rfc/rfc7845#section-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 `.ogg` and 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. 13. 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. 14. 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`, `DATE` and a `DESCRIPTION`** saying 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 sets `Cassini`, ffmpeg's Ogg muxer overwrites it with `encoder=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_ID` reads 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>` for `roomId` so 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](/spec/audio-integrity/) alone. Run it against the file the front page links to: ```bash 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 ``` ```console { "sha256": "8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc", "sampleRate": 48000, "channels": 1, "sampleCount": 11506248, "durationMs": 239713, "packetCount": 11986 } 8e1f7499c6d5fba88c3bd9b69ecd3de1b07ae0cff65152c942c5e99062d01cbc ``` Two 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 ```bash # 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.opus ``` The 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. ============================================================================== GUIDE — reading a file ============================================================================== 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? ```bash ffprobe -v error -show_entries stream_tags=CASSINI_FORMAT \ -of default=nw=1:nk=1 meeting.opus ``` ```console org.cassini.portable-meeting/1 ``` Nothing 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. ```bash 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. Absent when the room is unknown." }, "roomName": { "type": "string", "description": "LEGACY, read-only. The room's display name frozen at record time. Producers stopped writing this: a display name is editable and a published recording is not, so the current name lives in the catalog entry instead. The record-time name is still available as the meeting title. Absent in anything packed after that change." }, "jobId": { "type": "string", "description": "Id of the operator job that produced this artifact. Absent when the producer was not an operator job. Discloses nothing new: the operator publishes the artifact under this same id." }, "attemptNumber": { "type": "integer", "minimum": 1, "description": "Which attempt of that job produced this artifact, 1-based. Absent when unknown. There is no separate attempt id: an attempt's identity is the pair (jobId, attemptNumber)." }, "summary": { "type": "string", "description": "A short plain-text summary of the meeting, for display." } } }, "audio": { "type": "object", "description": "Open. Descriptive, not instructional: it restates what the Ogg stream already says, so a consumer that ignores a member it does not recognise loses nothing.", "required": [ "container", "codec", "sampleRate", "channels", "sampleCount", "durationMs" ], "properties": { "container": { "const": "ogg" }, "codec": { "const": "opus" }, "sampleRate": { "const": 48000 }, "channels": { "type": "integer", "enum": [ 1, 2 ] }, "sampleCount": { "type": "integer", "minimum": 0 }, "durationMs": { "type": "integer", "minimum": 0 } } }, "integrity": { "type": "object", "additionalProperties": false, "description": "CLOSED. A member here that a consumer does not recognise is a verification failure and not a hint: a consumer that ignored an unrecognised digest field would report a file as verified while ignoring the field that defines its identity. Adding a member to this object is a new major version, and that is the deliberate price of the open-world rule everywhere else.", "required": [ "matchPolicy", "opusAudioSha256", "sampleRate", "channels", "sampleCount", "durationMs" ], "properties": { "matchPolicy": { "const": "exact-opus-audio-v1" }, "opusAudioSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "sampleRate": { "const": 48000 }, "channels": { "type": "integer", "enum": [ 1, 2 ] }, "sampleCount": { "type": "integer", "minimum": 0 }, "durationMs": { "type": "integer", "minimum": 0 } } }, "speaker": { "type": "object", "description": "Open. The two-string speaker is deliberate, but a colour or an external identity is a hint and not an instruction, so a member a consumer does not recognise is ignored.", "required": [ "id", "label" ], "properties": { "id": { "type": "string", "minLength": 1 }, "label": { "type": "string", "minLength": 1 } } }, "transcriptEntry": { "type": "object", "description": "Open. Everything load-bearing is in required, so a missing or misspelled one is still an error, and anything else is a hint a consumer may ignore.", "required": [ "id", "role", "format", "payloadRef" ], "properties": { "id": { "$ref": "#/$defs/transcriptId" }, "role": { "enum": [ "raw-asr", "human-corrected", "translation", "scripted" ], "description": "How this transcript came to exist. raw-asr is what a recogniser produced; human-corrected is that text after a person fixed it; translation is it in another language; scripted is authored text the recording was made from, which is not a transcription of anything and is authoritative rather than derived." }, "default": { "type": "boolean" }, "format": { "type": "string", "minLength": 1 }, "language": { "type": "string" }, "wordCount": { "type": "integer", "minimum": 0 }, "createdAtUtc": { "type": "string", "format": "date-time" }, "sourceTranscriptId": { "$ref": "#/$defs/transcriptId", "description": "The transcript this one was derived from. Expected on human-corrected and translation entries; a raw-asr entry MUST NOT carry it. The id must name a transcript this file declares." }, "payloadRef": { "$ref": "#/$defs/payloadRef" } } }, "readableTranscriptEntry": { "type": "object", "description": "Open. Same grounds as transcriptEntry: everything load-bearing is in required, so a missing or misspelled one is still an error, and anything else is a hint a consumer may ignore.", "required": [ "id", "role", "format", "sourceTranscriptId", "payloadRef" ], "properties": { "id": { "$ref": "#/$defs/transcriptId" }, "role": { "enum": [ "readable-cleanup", "display" ] }, "default": { "type": "boolean" }, "format": { "type": "string", "minLength": 1 }, "language": { "type": "string" }, "wordCount": { "type": "integer", "minimum": 0 }, "sourceTranscriptId": { "$ref": "#/$defs/transcriptId", "description": "The entry this readable rendering was made from. Required here. The id must name a transcript this file declares, in either array." }, "createdAtUtc": { "type": "string", "format": "date-time" }, "payloadRef": { "$ref": "#/$defs/payloadRef" } } }, "transcriptId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,31}$", "description": "Lowercase, and no underscore. A transcript's tag prefix is its id upper-cased with - replaced by _, so raw-asr and raw_asr would both address CASSINI_TX_RAW_ASR_PAYLOAD_: two entries, one chunk set, one body lost with no error anywhere." }, "payloadRef": { "type": "object", "additionalProperties": false, "description": "CLOSED. A member here that a consumer does not recognise is a decode failure and not a hint: everything in this object is an instruction for reassembling bytes, and a consumer that ignored one would build the wrong bytes and then fail the digest it was given. Adding a member to this object is a new major version.", "required": [ "prefix", "chunkCount", "sha256", "rawBytes", "gzipBytes", "mime", "encoding" ], "properties": { "prefix": { "type": "string", "pattern": "^CASSINI_TX_[A-Z0-9_]+_PAYLOAD_$" }, "chunkCount": { "type": "integer", "minimum": 1 }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "rawBytes": { "type": "integer", "minimum": 0 }, "gzipBytes": { "type": "integer", "minimum": 0 }, "mime": { "type": "string", "minLength": 1 }, "encoding": { "type": "string", "minLength": 1 } } }, "processingStep": { "type": "object", "description": "Open. Every member is optional and every member is a note about how one stage ran, so a consumer ignores what it does not recognise.", "properties": { "backend": { "type": "string" }, "engine": { "type": "string" }, "model": { "type": "string" }, "device": { "type": "string" }, "language": { "type": "string" }, "source": { "type": "string" }, "version": { "type": "string" } } }, "attributionProvenance": { "type": "object", "description": "Open. The five required counters are what a consumer needs to read the record; a sixth statistic is a hint.", "required": [ "ran", "mode", "wordsMeasured", "wordsFlagged", "wordsDropped" ], "properties": { "ran": { "type": "boolean", "description": "Whether the cross-track attribution stage ran against this file's default raw transcript." }, "mode": { "type": "string", "minLength": 1, "description": "How flagged words were handled: annotate (per-word evidence kept on the words), drop (flagged words deleted before publication; this record is then the only trace they existed), or disabled." }, "reason": { "type": "string", "description": "Why the stage did not run. Absent when ran is true." }, "wordsMeasured": { "type": "integer", "minimum": 0 }, "wordsFlagged": { "type": "integer", "minimum": 0 }, "wordsDropped": { "type": "integer", "minimum": 0 }, "thresholdDb": { "type": "number", "description": "The meeting's estimated crosstalk threshold in dB. Absent when the meeting showed no crosstalk population." } } }, "wordTimingProvenance": { "type": "object", "required": [ "endsBoundedByAudio" ], "description": "How the producer decided where each word ends. Absent on files produced before the rule changed, and on files whose speech recognizer makes no such guarantee. Absent means only that the guarantee was not made: the ends may be token-derived, in which case a word's end came from its last token including a trailing punctuation mark — a mark the recognizer stamps at the next acoustic onset, so such a word can run for seconds while its speaker is silent — or they may be sound and simply undeclared. A consumer keys off presence: absent means those ends are unvouched-for and a repair that clips an over-long word is appropriate; present means they were measured against the speaker's own audio and clipping them destroys correct timing. Open: one required boolean, and the object is built around presence rather than exhaustiveness.", "properties": { "endsBoundedByAudio": { "description": "True when each word's end was measured against its own speaker's track rather than taken from its last token's timestamp.", "const": true } } }, "chapter": { "type": "object", "description": "Open.", "required": [ "startMs", "title" ], "properties": { "startMs": { "type": "integer", "minimum": 0 }, "endMs": { "type": "integer", "minimum": 0 }, "title": { "type": "string", "minLength": 1 } } } } } ============================================================================== SCHEMA — cassini-words-v1.schema.json ============================================================================== { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://cassini-format.codemyriad.io/schema/cassini-words-v1.schema.json", "title": "Cassini transcript body (cassini.words.v1)", "description": "The decompressed body of a CASSINI_TX__PAYLOAD_* chunk set, and of a v1 file's inline manifest.transcript. Deliberately open: a consumer that meets an unknown member ignores it.", "type": "object", "required": [ "format", "wordCount", "items" ], "properties": { "format": { "type": "string", "const": "cassini.words.v1", "description": "The body format id. A label, not a discriminator: the manifest entry already said which body this is." }, "language": { "type": "string", "description": "BCP-47 tag or bare language code. The Go producer omits it when unknown; a JavaScript repacker in the reference tree writes an empty string instead, and real v1 files carry that. Treat absent and empty as the same thing: nobody said." }, "wordCount": { "type": "integer", "minimum": 0, "description": "Equal to items.length. A convenience for a reader that wants the count before parsing the array; items wins on disagreement." }, "items": { "description": "One item per word, in speaker-turn order and NOT sorted by startMs: across a speaker change startMs can go backwards, because overlapping speech keeps each speaker's words together. Sorting by time destroys the turns. null is accepted as empty because a Go encoder emits it for a nil slice.", "oneOf": [ { "type": "array", "items": { "$ref": "#/$defs/item" } }, { "type": "null" } ] } }, "$defs": { "item": { "type": "object", "required": [ "speaker", "startMs", "endMs", "text" ], "properties": { "speaker": { "type": "string", "description": "Matches an id in the manifest's speakers[]. An unmatched id is still transcript content." }, "startMs": { "type": "integer", "minimum": 0, "description": "Milliseconds from the start of the audio program, on the same 48 kHz-derived clock as the manifest's durationMs." }, "endMs": { "type": "integer", "minimum": 0 }, "text": { "type": "string", "description": "The token as spoken, punctuation attached, no surrounding whitespace. Joining items with a single space reconstructs the prose." }, "attributionGapDb": { "type": "number", "description": "How far the loudest OTHER participant's microphone sat above its own noise floor compared with the attributed speaker's, in dB, at this word. Present exactly on the words the attribution stage measured. A measured 0 is written, so a missing key means not measured and MUST NOT be read as zero." }, "lowConfidenceSpeaker": { "const": true, "description": "Written only when true; a confidently attributed word omits the key. The word remains canonical transcript content." } }, "additionalProperties": true } }, "additionalProperties": true } ============================================================================== REFERENCE IMPLEMENTATIONS ============================================================================== All CC0. Read them if the prose above left you guessing; report it if it did. https://github.com/codemyriad/cassini-format/blob/main/tools/cassini-read.js browser reader, no dependencies https://github.com/codemyriad/cassini-format/blob/main/tools/cassini-read-pure.py reader with no external tools at all https://github.com/codemyriad/cassini-format/blob/main/tools/cassini-pack.py complete producer, stdlib only https://github.com/codemyriad/cassini-format/blob/main/tools/cassini-opus-digest.py the audio digest alone https://github.com/codemyriad/cassini-format/blob/main/tools/cassini-extract.py decode via ffprobe cassini-pack.py and cassini-opus-digest.py were written from the documents above and nothing else, as a test of whether they are sufficient. The digest one agrees with the reference Go producer byte for byte on the demo file. The reference implementation, which records and transcribes: https://github.com/codemyriad/gocassini (AGPL-3.0)