{
"id": "com.jakesimonds.streetlight.jam.session",
"defs": {
"seg": {
"type": "object",
"required": [
"t",
"text"
],
"properties": {
"t": {
"type": "integer",
"description": "Milliseconds from the start of the recording to when the segment was finalized — which trails the words themselves by however long the recognizer took to commit them."
},
"text": {
"type": "string",
"description": "The recognized words, trimmed. Interim results are never stored; only what the recognizer marked final."
}
},
"description": "One finalized run of recognized speech."
},
"main": {
"key": "any",
"type": "record",
"record": {
"type": "object",
"required": [
"id",
"title",
"createdAt",
"durationMs",
"audio"
],
"properties": {
"id": {
"type": "string",
"maxLength": 64,
"description": "The jam's id and its directory name under sessions/: \"jam-\" + the save time in base 36 + a short random tail, so ids sort chronologically as plain strings. Minted server-side; the browser never picks one."
},
"audio": {
"type": "string",
"maxLength": 300,
"description": "Repo-relative path to the recording, always nodes/jam-capture/sessions/<id>/audio.webm. The file is GITIGNORED, so this path can point at nothing on a fresh clone — the page renders such a row with its text intact and no player rather than hiding it."
},
"cheat": {
"type": "array",
"items": {
"ref": "#cheatRow",
"type": "ref"
},
"description": "The cheat sheet: each distinct chord name once, in order of first appearance, with how many times it recurred. The shape a person actually wants when reopening a jam — the chord changes, without the timeline."
},
"title": {
"type": "string",
"maxLength": 200,
"description": "What Jake typed into the name box when he saved. Free text, and \"untitled jam\" when he saved without typing — which is what all three of the original 2026-08-12 jams did, so an untitled row is normal and not a defect."
},
"chords": {
"type": "array",
"items": {
"ref": "#chord",
"type": "ref"
},
"description": "Notes struck within 180ms of each other, clustered and named. Derived at STOP by the page, then frozen into the row: re-deriving on read would let a change to the naming table silently rewrite what Jake played last month."
},
"createdAt": {
"type": "string",
"format": "datetime",
"maxLength": 64,
"description": "When the jam was SAVED, not when recording started — the two differ by however long the name box sat open. ISO-8601. The three migrated rows carry the save-time epoch the old server.mjs stored as a number, converted; nothing else was reconstructible."
},
"durationMs": {
"type": "integer",
"minimum": 0,
"description": "How long the recording ran, measured by the page's own clock (performance.now deltas), not read back off the audio file. A jam whose audio failed to flush would still carry a duration here."
},
"noteEvents": {
"type": "array",
"items": {
"ref": "#noteEvent",
"type": "ref"
},
"description": "Every MIDI note struck during the jam, in the order they were released. Raw capture — `chords` and `cheat` are both derived from this array and nothing else."
},
"transcript": {
"type": "string",
"description": "The rough lyrics: every final Web Speech segment joined with spaces. Machine transcription of singing, so it is wrong often and interestingly — it is kept as a SEARCH INDEX for finding a jam again (\"where was that one about the tractor\"), never as a record of what was sung. Empty when the browser had no speech recognition or the mic caught nothing."
},
"transcriptSegs": {
"type": "array",
"items": {
"ref": "#seg",
"type": "ref"
},
"description": "The same transcript, still cut into the segments the recognizer emitted, each stamped with its offset into the jam. This is what makes the words seekable against the audio; the joined `transcript` above is the flattening of it."
}
}
},
"description": "One saved jam. Immutable once written — there is no edit path, only save and delete."
},
"chord": {
"type": "object",
"required": [
"t",
"name",
"notes"
],
"properties": {
"t": {
"type": "integer",
"description": "Milliseconds from the start of the recording to the first note of the cluster."
},
"name": {
"type": "string",
"maxLength": 64,
"description": "The named chord — \"F\", \"Gm7\", \"Bm/D\". When no template matches, this falls back to the bare pitch classes joined by spaces (\"C E F G\"), which is the honest answer for a cluster that is not a chord; those appear in real rows and are not errors."
},
"notes": {
"type": "array",
"items": {
"type": "integer"
},
"description": "The MIDI note numbers in the cluster, as struck — inversions preserved, so the voicing survives even though the name flattens it."
}
},
"description": "A cluster of notes struck together, with a best-effort name."
},
"cheatRow": {
"type": "object",
"required": [
"name",
"count",
"firstT"
],
"properties": {
"name": {
"type": "string",
"maxLength": 64,
"description": "The chord name, matching a `chords[].name`."
},
"count": {
"type": "integer",
"minimum": 1,
"description": "How many clusters in the jam carried this name."
},
"firstT": {
"type": "integer",
"description": "Milliseconds to its first appearance — the sort key for the sheet, so the list reads in playing order rather than by frequency."
}
},
"description": "One distinct chord in the jam, counted."
},
"noteEvent": {
"type": "object",
"required": [
"note",
"on",
"off"
],
"properties": {
"on": {
"type": "integer",
"description": "Milliseconds from the start of the recording to key-down, rounded. Notes still held when STOP is pressed are flushed with `off` set to the stop time, so a held final chord is captured rather than dropped."
},
"off": {
"type": "integer",
"description": "Milliseconds from the start of the recording to key-up, rounded."
},
"note": {
"type": "integer",
"maximum": 127,
"minimum": 0,
"description": "MIDI note number, 0-127. Middle C is 60."
}
},
"description": "One key, from press to release."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1,
"description": "One jam captured at the piano: the room audio, the MIDI Jake actually played, and a rough machine transcript of whatever he sang over it. Written by the viewer's POST /api/jam-session (local-viewer/server.mjs) through mintRecord, one row appended per save to nodes/jam-capture/sessions.jsonl, read back by GET /api/jam-sessions and rendered by the jam-capture node page. THE SPLIT BETWEEN ROW AND BLOB: everything in this record is text and numbers and lives in git; the audio itself is a webm blob under nodes/jam-capture/sessions/<id>/audio.webm, which is gitignored and referenced by the `audio` path field. That split is the whole reason this store is browsable — before 2026-08-15 a jam was a per-session session.json sitting beside its audio inside an ignored directory, carrying no $type, so the lexicon scan read three separate orphan stores and could show a count and nothing else. SCALING CAVEAT, stated rather than discovered: `noteEvents` is the raw MIDI stream and runs ~1000 entries for a four-minute jam, so a row is tens of KB and a browse page fetches them 60 at a time. At a few dozen jams that is fine; if the library ever reaches hundreds, the fix is to move noteEvents beside the audio as a sibling blob and leave the derived `chords`/`cheat` in the row, NOT to start truncating a record that says it holds everything. Kept whole for now because chords and cheat are both derived from noteEvents and dropping it would make them unre-derivable."
}