Scene Protocol
A scene file is not a configuration file. It is the wire format between a composing agent and the compiler — the input half of scorekit’s machine contract, exactly as the Machine Interface is the invocation half. An agent that emits a valid scene document is speaking a protocol: every field has one deterministic, observable compile semantic, the format is versioned under the same semver as the binary, and violations are rejected loudly with machine-readable positions, never silently ignored.
This chapter is the normative specification of that protocol. The live, machine-readable source of truth is always scorekit schema (JSON Schema); when this prose and the binary disagree, the binary wins.
Why a protocol, not just a format
Five properties separate a protocol from a mere file format, and the scene DSL commits to all of them:
- Strictness. Unknown fields are rejected (
deny_unknown_fieldsat every level). A typo, or a scene written for a newer scorekit, fails with exit 2 and a line/column — it is never silently dropped. Silent tolerance is how formats rot; loud rejection is how protocols stay honest. - Deterministic semantics. The same scene + the same scorekit version compiles to byte-identical MIDI, always. There is no “interpretation”, no randomness outside the seeded
performance.humanize, and no field whose effect depends on the machine it runs on. - No dead fields. Every field must have a deterministic compile semantic. Mood tags, game-state hints, and creative intent (
avoid: too_bright) are by design not part of the protocol — they belong in the agent’s prompt space. A field that “doesn’t do anything” is a protocol violation waiting to be invented twice. - Machine discoverability.
scorekit schemaexports the full JSON Schema, including field docs, ranges, and defaults. An agent can learn to write valid scenes from the schema plus one error message, without reading source code. - Text-native. UTF-8 YAML, line-oriented, diff-friendly. Version control, review, merge, and rollback are git’s job; the protocol’s job is to make diffs mean something (see
scorekit difffor semantic comparison that ignores formatting).
Envelope
- One UTF-8 YAML document per file; one document = one scene (or one suite, when
sectionsis present). - Field names are
snake_case. Unknown fields anywhere are an error. - Validation is two-layered, and both layers are part of the contract:
- Structural — shape and types, as exported by
scorekit schema. Parse errors reportlocation: {line, column}. - Semantic — ranges, cross-field rules (e.g.
motifrequired iffpattern: melody), reference checks. Violations report afieldpath liketracks[2].glide.
- Structural — shape and types, as exported by
Versioning and stability
The scene protocol is versioned by the scorekit binary’s semantic version. There is deliberately no version field inside the scene file: strict unknown-field rejection already makes version skew detectable at the only moment it matters (compile time), and a self-declared version number that the compiler must trust would be weaker than one it derives from its own schema.
Within a major version:
- Additive only. New fields are optional with defaults that preserve prior output — a scene that does not use a new field compiles to byte-identical MIDI before and after the addition. (Precedent: when
pan/reverb/glidelanded in 0.2.0, the golden SMF byte-comparison test did not change.) Additional compile targets such asscorekit makecodemust not add schema fields that only that target can fulfill. - No repurposing. An existing field’s name, type, range, default, or compile semantic does not change. The normative transform order (below) is part of the semantic and is equally frozen.
- Old scene, new binary: always valid, same MIDI bytes.
- New scene, old binary: rejected with exit 2 and the offending field path — a readable upgrade signal, not a corrupted asset.
Anything that breaks these rules is a major-version change. Determinism across tool versions (FluidSynth/FFmpeg) is a separate boundary owned by the Machine Interface: pin your toolchain for identical audio; MIDI needs only the scorekit version pinned.
Rules for extending the protocol
A proposed field must clear all of these gates (this is the governance encoded in the project’s iron rules):
- It has exactly one deterministic compile semantic, statable in one sentence (“compiles to X”).
- It is achievable with free/open sound sources — the schema must not contain fields only commercial libraries can honor.
- Its absence compiles byte-identically to the world before the field existed.
- It ships in the same change as at least one happy-path E2E test, one failure-path test if validation is involved, and an acceptance-matrix row.
Scene document
| Field | Type / range | Default | Compile semantic |
|---|---|---|---|
title | string | — | informational only; never affects output |
story | string | — | informational only; never affects output — freeform narrative brief (theme, mood, dramatic intent) carried into meta.json so review agents can audit the music against its story |
tempo | int, 20..=300 | required | BPM; sets the SMF tempo meta event |
key | <Note>_<major|minor> (C_major, F#_minor, Eb_major, …) | C_major | root + scale for all degree/numeral resolution |
time_signature | N/D, N 1..=12, D ∈ {2,4,8,16} | 4/4 | bar length in ticks; drives pattern shapes |
bars | int, 1..=256 | required | scene length; total ticks = bars × N × (PPQ·4/D) |
loop | bool | false | true = seamless-loop asset (sample-exact length, sealed seam); false = one-shot with --tail decay |
harmony | [numeral, …], diatonic i..vii (case per quality) | major I-V-vi-IV, minor i-VI-III-VII | one triad per bar, cycled to fill; sustain/arpeggio/bass derive from it |
motifs | {name: [note, …]} | {} | melodies referenced by pattern: melody tracks; map is order-insensitive (sorted for determinism) |
clips | {stable_id: clip, …}, ≤128 | {} | exact pitched/percussion event sequences and step/linear automation referenced by pattern: clip; every nested map key is stable identity, so declaration reordering changes neither MIDI nor semantic diff |
performance | object | absent | deterministic humanization (below); absent = exact mechanical rendering |
textures | [texture, …], ≤16 | [] | deterministic ambience/SFX layers; source names bind through --texture-profile at build time and never affect MIDI |
tracks | [track, …], 1..=16 | required | ≤15 melodic; any percussion tracks share channel 10 but remain independent MIDI tracks/stems |
sections | [section, …] | [] | turns the scene into a suite; one output asset per section |
Track
| Field | Type / range | Default | Compile semantic |
|---|---|---|---|
id | [a-z][a-z0-9_-]{0,63}, unique per scene | required | stable scene-local identity referenced by sections[].mute, midi --solo, stem file names (NN-<id>.ext), and every meta.json/inspect-instruments report; itself never affects compiled MIDI |
palette | [a-z][a-z0-9_-]{0,63} | orchestration’s default_palette | logical orchestration-palette selector (--orchestration, --renderer sfizz only); pure routing metadata — never changes compiled MIDI and has no effect on SF2/TiMidity backends |
instrument | portable name from scorekit schema | required | exact GM program when one exists; standalone midi rejects profile-only melodic identities, while sfizz’s internal MIDI emits no false program; drums/tabla use channel 10 |
pattern | sustain arpeggio bass drums melody tabla clip | required | note-generation algorithm (below), or exact authored events for clip; drums and tabla pair only with their namesake instruments |
motif | motif name | — | required iff pattern: melody; must exist in motifs |
clip | clip name | — | required iff pattern: clip; must exist in clips and match the track’s pitched/percussion kind |
intensity | 0.0..=1.0 | 0.6 | scales note velocities |
articulation | sustain staccato spiccato pizzicato tremolo mute | sustain | render-time SFZ sample selector only; never changes the compiled MIDI |
pan | 0.0..=1.0 | absent | CC10 = round(v·127) once at tick 0; absent = no CC10 emitted |
reverb | 0.0..=1.0 | absent | CC91 = round(v·127) once at tick 0; absent = no CC91 emitted |
glide | 0.0..=1.0, melody-only | absent | tail portamento via pitch bend, clamped ±2 semitones (GM default bend range); loops glide last note → first note, seam-continuous |
Because all percussion tracks share one MIDI channel, their optional pan and
reverb values must compile to the same CC10/CC91 state. Conflicting values are
rejected with the later track’s exact field path rather than silently letting
one track overwrite another.
Event clip
Clips are the exact-event escape hatch for arrangements whose identity lives in syncopation, fills, bass switches, or controller gestures rather than a generative pattern. They remain source-neutral MIDI structure: a clip never names a synth, plugin parameter, sample, or local path.
| Field | Type / range | Semantic |
|---|---|---|
| map key | [a-z][a-z0-9_-]{0,63} | stable clip identity used by tracks, section overrides, and semantic diff |
kind | pitched | percussion | selects the legal event shape |
length_beats | quarter-note beats, 1 PPQ-480 tick..=1024 | quantized with round(beats × 480) |
mode | once | loop | emit once from track start, or repeat exactly to fill the scene/section |
events | {stable_id: event, …}, 1..=2048 | order-insensitive authored events |
automation | {stable_id: lane, …}, ≤4 | pitched clips only; at most one lane per target |
A looping clip’s quantized length must divide every scene/section timeline in which it is active. Events cannot cross the clip boundary. Pitched events with the same MIDI key cannot overlap; percussion events cannot repeat the same voice at the same quantized tick. After loop expansion, each active track may produce at most 65,536 note/control events per scene or section, including samples generated by linear automation; validation rejects larger expansions before composition allocates them.
Clip event
| Field | Pitched clip | Percussion clip |
|---|---|---|
| map key | stable event identity | stable event identity |
at | required quarter-note beat ≥0 | required quarter-note beat ≥0 |
duration | required; ≥1 quantized tick | optional; defaults to 0.125 beat |
pitch | required scientific pitch (C-1=0, C4=60, A4=69) | forbidden |
voice | forbidden | required: kick, snare, clap, closed_hat, pedal_hat, open_hat, low_tom, mid_tom, high_tom, crash, ride, tambourine, cowbell, high_bongo, low_bongo, mute_high_conga, open_high_conga, low_conga, high_timbale, low_timbale, high_agogo, low_agogo, cabasa, or maracas |
velocity | required 1..=127 | required 1..=127 |
Clip velocity is still scaled by track/section intensity and
performance.dynamics. Authored onset, duration, and velocity relationships
are otherwise exact: swing, legato, and humanize do not rewrite clip
events.
Automation
Each lane chooses one portable MIDI target: cc1, cc11, cc74, or
pitch_bend, plus interpolation: step | linear (default step). Its
points map uses stable IDs and exact {at, value} entries. CC values are
0..=127; pitch bend is -8192..=8191 around center 0. Every lane starts at beat
0. A loop lane’s final authored value must equal its initial value, making the
repeated controller state explicit and seam-safe. Step mode emits only authored
points. Linear mode emits each authored point plus deterministic rounded linear
samples every 60 ticks (PPQ/8, a 32nd note), anchored at each segment’s left
point.
SF2/TiMidity receive these standard MIDI events directly. For sfizz, the
effective leaf renderer-profile mapping must explicitly declare every target
under controls; otherwise orchestration resolution fails before build
staging. This prevents a talking-bass scene from silently rendering through a
patch that ignores its authored motion.
Texture track
Texture tracks are score-timeline material: field recordings, ambience, and sound effects used as part of the composition. They do not model runtime world audio such as distance attenuation, weather state, or engine RPM.
| Field | Type / range | Default | Compile semantic |
|---|---|---|---|
source | [a-z][a-z0-9_-]{0,63} | required | portable key resolved by --texture-profile; scene files never contain audio paths |
mode | loop | one_shot | required | continuous source repetition or one full source playback at each trigger |
start_beat | quarter-note beat ≥0 | 0 | loop-only start position; must be 0 when the scene or any section loops |
at | [beat, …], 1..=64 entries | [] | one-shot-only trigger positions; the same schedule repeats in every scene-loop pass |
gain | 0.0..=1.0 | 1.0 | linear gain applied to the arranged texture stem before summation |
Beat positions are quantized to the nearest PPQ-480 tick, then converted to sample frames using the same quantized MIDI tempo as musical tracks. Loop textures run continuously across the two render passes; the normal loop seal therefore joins adjacent source frames even when the source duration does not divide the scene duration. The recording itself should be prepared as a loop-ready asset because scorekit does not conceal clicks inside the source. For a looping scene, a one-shot source must not be longer than one complete scene pass; this guarantees that one prior pass contains all tail material needed at the loop boundary.
Motif note
| Field | Type / range | Semantic |
|---|---|---|
degree | int, -21..=21 | scale step in the scene’s key; 0 = rest; 8 = tonic an octave up; negatives descend |
beats | 0.125..=16 | duration in quarter-note beats |
Section
| Field | Type / range | Default | Semantic |
|---|---|---|---|
name | [A-Za-z0-9_-]+, unique | required | output asset suffix (out-<name>.ogg) |
bars | 1..=256 | required | section length |
tempo | 20..=300 | scene tempo | per-section override |
loop | bool | false | per-section loop treatment |
mute | [track id, …] | [] | stable track ids silenced this section; muting every track is rejected |
clips | {track_id: clip_id, …} | {} | replace a pattern: clip track’s clip for this section; replacement kind must match the track |
intensity | 0.0..=2.0 | 1.0 | multiplier on each track’s intensity |
Sections inherit the scene’s key, tracks (including spatial fields), textures, motifs, harmony, and performance. A shared texture schedule must be valid for every section: each trigger is checked against the shortest derived section timeline, preventing a trigger from silently wrapping into a later pass of a shorter cue.
Performance
All optional, all deterministic:
| Field | Type / range | Effect |
|---|---|---|
humanize | {timing_ms: 0..=50, velocity: 0..=30, seed: u64} | seeded jitter — same seed, same bytes |
swing | 0.0..=0.5 | delays off-beat eighths |
legato | bool | extends non-drum durations ~12% |
dynamics | {start, peak} of pp p mp mf f ff | loop-safe arch start→peak→start |
Normative compile semantics
These are observable guarantees an integration may rely on; changing any of them is a breaking protocol change.
Time and encoding. Output is SMF format 1, PPQ 480. A bar is N × (480·4/D) ticks. Clip positions always use quarter-note beats regardless of the time-signature denominator and quantize with round(beats × 480). Events are encoded in the canonical order (tick, kind rank: note-off < automation CC < pitch-bend < note-on, key/value) — this ordering is the byte-determinism guarantee.
Channels and programs. Melodic tracks take channels in declaration order
(0, 1, 2, …), skipping channel 10 (index 9), which is shared by every
percussion track (drums or tabla). Those tracks remain separate SMF tracks
and stems. An exact GM identity opens with its
program change. Internal MIDI rendered against an exact sfizz patch leaves a
profile-only melodic channel programless; standalone scorekit midi rejects
that case before writing a file rather than publish MIDI that generic players
interpret as program 0 (piano). Tabla remains programless on channel 10.
pan/reverb CCs follow immediately at tick 0 and precede clip automation at
that tick. Channel state persists across loop passes, so static CCs are emitted
once while authored clip automation repeats with its clip.
Patterns.
sustain— the bar’s full triad held for the whole bar.arpeggio— eighth notes cycling chord tones in the fixed order root, third, fifth, third.bass— chord root two octaves down; two half-bar notes when the meter’s numerator is even and ≥4, else one whole-bar note.drums— kick on beat 1 (plus the midpoint beat when N ≥ 4), snare on off-beats, closed hi-hat on every beat and half-beat.tabla— one channel-10 stroke per notated beat, cycling the fixed 16-beat sequencedha dhin dhin dha | dha dhin dhin dha | dha tin tin ta | ta dhin dhin dhaacross bar boundaries.melody— the named motif, looped or truncated to exactly fill the length; degrees resolve in the scene key.clip— the named exact event sequence, emitted once or repeated without generative timing transforms; pitched clips pair with melodic instruments and percussion clips with percussion tracks.
Transform order. Generated patterns use swing → dynamics → legato → humanize, then glide bend computation (it must observe final onsets), then loop-pass duplication. Clips apply only intensity/dynamics velocity scaling before exact repetition; their authored timing is never humanized, swung, or made legato. Loop math is applied last so looped scenes stay sample-exact under every transform.
Harmony. One numeral per bar, cycled. Numerals resolve to diatonic triads of the scene key; sustain/arpeggio/bass all read the same per-bar chord, which is what keeps multi-track scenes harmonically coherent by construction.
Texture assembly. FFmpeg first normalizes every referenced recording to stereo signed 16-bit PCM at the requested build sample rate. scorekit then performs only deterministic cutting, repetition, zero-padding, placement, linear gain, and summation. Texture stems use the same exact output window and loop seal as instrument stems, so every stem stays sample-aligned and sums back to the full mix within the existing rounding tolerance.
Error contract
Protocol violations follow the machine-interface error shape: exit 2, and with --json a single structured object on stderr carrying location (parse) or field (semantic). The field path grammar is stable: tracks[i].pan, sections[i].mute[j], harmony[i], performance.swing. An agent is expected to repair a scene from field + message alone; that expectation is part of the protocol’s design, not a nicety.
What the protocol will not carry
- No mood/state/intent fields (
emotion,danger,avoid) — no deterministic compile semantic exists for them; they live in the agent’s brief. - No renderer or recording paths (
.sf2/.sfz/audio locations) — sound sources are bound at invocation time (--soundfont,--orchestration,--texture-profile), so a scene never bakes in one machine’s disk layout. A track’sid/paletteare portable routing metadata, not paths — the orchestration profile they route through resolves to real files, never the scene. - No embedded version negotiation, includes, or macros — one file, one scene, strict fields. Composition happens in the agent, not in a template engine.