From one node to a game soundtrack
tono has exactly one primitive — a node that evaluates to a signal — and one design rule: every layer above it (patches, instruments, songs, compiled programs, adaptive music, the desktop studio, and the Python face) must compile down to nodes rather than invent its own audio path. Understand the smallest unit and how it composes, and the whole codebase reads as variations on one idea. Four durable objects carry it: a Song composes, a SoundDoc renders, a Program ships, an Engine runs.
Because every layer reduces to the same nodes, one guarantee covers everything: the same
document renders the same bytes on every run, and the real-time path matches the offline
bounce bit-for-bit. A golden corpus (crates/tono-core/tests/golden.rs) pins the
hashes in CI; kernel improvements ship behind a document engine revision
(currently 5) so old sounds never silently change. Revision 5 renders through the
deterministic det kernels — pinned-coefficient f64 polynomials and a fixed-order
FFT — so byte-identity holds across platforms, one shared pin set on every target;
older revisions keep their historical renders forever. If your change shifts a golden hash,
you changed synthesis math — not organisation.
the smallest unit, and the ladder up
Read this ladder once and the module tree stops being a list of names. Each rung is built only from the rungs below it.
-
0
A sample — f32 in [-1, 1]
The atom of audio. “Deterministic” in tono means literally these bits: two renders of the same document produce identical
f32bit patterns, which is why sounds are testable, diffable, and cacheable like any other build artifact.in the code: type Signal = Vec<f32> · dsp.rs (RNG, loudness, limiting) -
1
A Value — a parameter over time
Every knob a node exposes is a
Value: a constant, a note name that resolves to Hz, or a modulator that produces a value per sample. This is why there is no separate “automation system” — modulation is just what parameters are."freq": 880 // constant "freq": "A5" // note name → Hz "freq": { "slide": { "from": 880, "to": 180, "secs": 0.18, "curve": "exp" } } // per-sample modulatorin the code: dsl/mod.rs::Value + Modulator · evaluated by render (eval_value) and streaming/value.rs — same formulas, same bytes -
2
A Node — the smallest sound
One enum variant = one DSP unit that evaluates to a mono signal. Two kinds: sources make signal (sine, square, noise, fm, impact, seq…); processors transform an incoming one (lowpass, reverb, drive, compress…). That’s the entire vocabulary — there is nothing else to learn at the bottom.
{ "type": "sine", "freq": 880 } // a source: the smallest complete sound { "type": "lowpass", "cutoff": 1200 } // a processor: only meaningful inside a chainin the code: dsl/node.rs (the enum + docs) · dsl/validate.rs (its rules) · render/ (the offline kernel per variant) -
3
Composition — mix · mul · chain
Three combinator nodes turn atoms into design.
mixlayers signals,mulgates one by another (an envelope is justmulby anenvnode),chainruns processors in series. Everything you will ever author is these three shapes nested.{ "type": "mul", "inputs": [ // a “bleep”: { "type": "sine", "freq": 880 }, // tone … { "type": "env", "a": 0.002, "d": 0.08, // … shaped by an envelope "s": 0.0, "r": 0.05 } ] }in the code: Node::Mix / Mul / Chain in dsl/node.rs · recursion in render/mod.rs::render_node -
4
A SoundDoc — the unit the whole project trades in
A root node plus metadata:
duration,sample_rate,seed, and theversion(schema) andengine(kernel) pins that freeze what the document means and which DSP revision renders it. This JSON document is the currency of the repo — the CLI renders it, the studio edits it, Python receives it, songs compile through it into Programs, and the golden tests hash its render. If you save, send, or version anything in tono, it is one of these (or the Program one compiles to).{ "name": "blip", "duration": 0.3, "version": 2, "engine": 5, "root": { …the mul above… } }in the code: dsl/mod.rs::SoundDoc · validate() is pure (no filesystem) — loaders check sf2_paths() themselves -
5
Two evaluators, one answer — render vs streaming
The same node tree has two interpreters.
render/evaluates whole buffers offline (the bounce).streaming/carries per-node state (oscillator phase, filter memory) and evaluates sample-by-sample in real time — reusing the offline kernels so the output is byte-identical at any block size, verified by a fuzzer in CI. Coverage is every node type, and a schema-v2tracksmixing console now streams natively too: the streaming mixer runs every track's graph, the pan/gain automation lanes, the sidechain duck envelopes, and the bus/master insert chains as one per-sample loop — byte-identical to the offline mixer. The remaining whole-buffer cases (a schema-v1 tracks root, the sampler,normalize, loop playback, stereo treatments,convolve/granular) fall back to a pre-renderedPlayer: still the same bytes, just bounced first —StreamGraph::blockersreports exactly which a document trips, with the fix.in the code: render/mod.rs::render_product · streaming/{value,source,proc,tracks}.rs · streaming/tests.rs::fuzz_streamed_matches_offline_byte_for_byte -
6
An AudioSource — the run-time atom
One trait, one method: fill this interleaved-stereo buffer. It is to playback what Node is to authoring. Engine, Mixer, Instrument, AdaptiveMusic, Performance, the streaming renderer — every live object implements it, every output adapter (cpal via
tono-play::Speaker) consumes it. This seam is why the pure core never links a sound card.pub trait AudioSource { fn fill(&mut self, out: &mut [f32]) -> usize; // L,R,L,R… whole buffer, every call }in the code: runtime/source.rs · runtime/ring.rs (the wait-free Engine::split seam for game audio threads) -
7
The layers — compilers down to rungs 4–6
Everything above the seam is sugar that reduces to what you already know: a Patch is a doc plus named parameters (edits by path); an Instrument renders its patch per note through the streaming evaluator; a Song (tracks / patterns / arrangement, tempo and meter maps, sections, automation, buses) lowers to an ordinary doc of
seqtracks — andSong::compile()wraps that lowering in validation to return the Program of rung 8; the pattern algebra (transpose, euclidean, humanize…) mints new patterns, never a new audio path; AdaptiveMusic loops rendered docs as intensity stems; the catalog and presets are just curated constructors; music spells the harmony (pitches, keys, chords, voicings). Nothing up here adds a new audio path — which is why the byte-identity guarantee at the bottom covers all of it.in the code: patch.rs · instrument/ · song/ (to_doc, compile, pattern) · music.rs · adaptive/ · catalog.rs · presets.rs -
8
A Program — the immutable compiled artifact
Compile a song and the loose authoring structure becomes a Program: the resolved SoundDoc, the musical metadata a transport needs (tempo/meter maps, pickup, sections, markers, durations in bars/seconds/frames, a track roster with stable declaration-order ids), bounded resource estimates (frames, events, peak voices, memory), streaming-coverage warnings, and a canonical content hash — FNV-1a over sorted-key JSON, so an equivalent song compiles to the same hash from Rust or Python. Validation collects every problem in one pass, each a structured diagnostic with a stable T-code and a fix. A Program loads without recompiling: the loader checks the bundle revision and re-verifies the hash. This is what applications render, ship, and run.
in the code: song/compile.rs::Song::compile → program.rs (Program, ProgramMeta, ResourceEstimates, content_hash) · ids.rs (the stable handles) · diag.rs (the T-coded diagnostics) · units.rs (exact Beat; the one beat→frame crossing) -
9
A Performance — a running Program
The runtime half of the artifact. A Performance wraps a Program in a sample-accurate Transport (exact frame ↔ beat/bar conversions through the tempo/meter maps — the same walks the compiler used) and a bounded command queue: the host schedules play, seeks, loops, gain rides, stingers, and crossfaded program swaps at frames, beats, bars, markers, or sections, and the audio callback executes them at their exact frames in submission order — no game loop, Python thread, or OS timer ever wakes on a musical boundary. The callback performs no allocation (scratch is pre-sized; a counting- allocator gate demands zero heap calls across
fill), a full queue rejects and counts, a swap target that fails validation never displaces the last valid Program, metrics read off the audio path, and a captured command stream replays bit-for-bit.in the code: runtime/transport.rs (the clock) · runtime/performance.rs (the queue, swaps, metrics) · tests/rt_alloc.rs (the zero-allocation gate)
the big picture: Song → Program → Engine
Four durable objects carry the invariant from composition to speaker. A Song is the composition: tracks, patterns, and an arrangement, plus tempo/meter maps, pickup, sections and markers, automation lanes, and mix buses. A SoundDoc is the low-level graph the song lowers to — still the renderable truth. A Program is the immutable compiled artifact: the resolved doc plus metadata, estimates, and hash under three version pins. An Engine is whatever runs one — the offline renderers, the streaming renderer, or the live runtime. Everything a host ships is one of these four.
you compose the compiler you get back
────────────────────── ───────────────────────────────── ────────────────────────────
Song + Patches + assets ──► validate: every problem in one ──► diagnostics (stable T-codes,
(song.json, .patch.json, pass, each naming its fix each with a remediation)
sf2, convolver wav) lower: the Song → a SoundDoc ──► estimates (frames, events,
stamp: the schema / engine / peak voices, memory)
program pins + canonical hash ──► offline render ──► wav, stems,
│ images, stats
▼
Program — the immutable artifact (rung 8)
▼
Engine — the same bundle runs on every face:
native runtime · Python
three version pins, three clocks
A Program records three revisions that evolve independently, so no upgrade is ever silent.
schema (SCHEMA_VERSION, currently 2) freezes what the document
means — a v2 tracks root gains id-keyed per-track streams and buses
without touching v1 semantics. engine (ENGINE_VERSION, currently
5) freezes the DSP math — revision 5 routes every transcendental through the
det kernels (pinned fdlibm-grade f64 polynomials, a fixed-order FFT), so bytes
match across platforms, not just across runs; older revisions keep their historical renders.
program (PROGRAM_VERSION, currently 2) freezes the bundle
format and semantic hash boundary — a loader rejects a Program newer than itself (T3001) and re-verifies the content
hash on load (T3002), never recompiles. A document pins the first two; compilation stamps all
three into the artifact.
the two loops, on the ladder
The authoring loop is rungs 2→5 plus your eyes; the live loop is rungs 5→6 (or 8→9 for a compiled song) plus a speaker. Same document either way.
you write the engine you look at
───────────── ────────────────────────── ─────────────────
blip.json ──► dsl: parse + validate ──► render ──► blip.wav
(rungs 1–4) (loud errors, NaN-proof) │ blip.png ◄─ spectrogram
│ blip_wave.png
└──► analysis ──► blip.stats.json
▲ │
└────────────── refine the JSON, re-render ◄─────────┘
game loop ──► runtime: Engine / Mixer / Instrument (rung 7)
└── fill() ──► streaming (rung 5) ──► Speaker / ring ──► 🔊
song host ──► runtime: Performance (rung 9) — scheduled at bars, executed at frames
└── fill() ──► streaming (rung 5) ──► Speaker / worklet ──► 🔊
the map
Cargo workspace: a virtual root manifest; the CLI, the engine and the faces
live under crates/. Everything but the CLI and tono-core is off the default
build; the workspace-wide Cargo gate still compiles and tests every
member explicitly. Row labels below are the ladder rungs each module group implements.
Faces — thin shells, each one entry point
start here
-
Build and prove the invariant to yourself
One command runs exactly what CI runs — fmt, clippy, all 500+ tests including the golden corpus, the offline/streaming byte-identity fuzz, and the zero-allocation audio-callback gate.
cargo fmt --all -- --check cargo clippy --locked --all-targets -- -D warnings cargo test --locked git config core.hooksPath .githooks # enable the same local gates -
Climb rungs 1–4 by hand: render your first document
Write the bleep from the ladder, render it, open the PNGs. This is the authoring loop every face wraps.
cat > blip.json <<'EOF' { "name": "blip", "duration": 0.3, "engine": 5, "root": { "type": "mul", "inputs": [ { "type": "sine", "freq": 880 }, { "type": "env", "a": 0.002, "d": 0.08, "s": 0.0, "r": 0.05 } ] } } EOF cargo run -p tono -- render blip.json -o out/ open out/blip.png # the spectrogram; out/blip.stats.json has the numbersThen change
"freq": 880to"A5", then to aslidemodulator, and re-render — you have now used every form of rung 1. -
Learn the node vocabulary
docs/cookbook.md is the DSL reference — every node type, the engine revisions, recipes for SFX, loops, and full songs. Skim it with
crates/tono-core/src/dsl/node.rsopen; the doc comments are the source of truth. -
Hear rungs 6–7 live from code
The tono-play examples are the guided tour of the layers — sounds, instruments, voice management, buses, songs, adaptive game music.
cargo run -p tono-play --example playground # walkthrough cargo run -p tono-play --example band # catalog + fluent song builder cargo run -p tono-play --example adaptive # intensity-driven stems + stinger ls crates/tono-play/examples/ # all eleven -
Compile a song into a Program — rungs 8–9
A song is JSON too. Compiling it validates every reference in one pass and writes the immutable bundle;
--inspectshows the pins, hash, estimates, and capabilities instead.cat > groove.song.json <<'EOF' { "name": "groove", "bpm": 120.0, "version": 2, "tracks": [ { "name": "bass", "wave": "bass", "env": { "a": 0.005, "d": 0.1, "s": 0.8, "r": 0.2, "punch": 0.0 } } ], "patterns": [ { "name": "riff", "bars": 1, "notes": [ { "step": 0, "len": 4, "pitch": "C2" }, { "step": 8, "len": 4, "pitch": "G2" } ] } ], "arrangement": [ { "track": "bass", "pattern": "riff", "bar": 0 } ] } EOF cargo run -p tono -- compile groove.song.json # writes groove.program.json cargo run -p tono -- compile groove.song.json --inspect # pins, hash, estimates, capabilitiesThen hear rung 9:
cargo run -p tono-play --example interactive_musicruns a Performance — sections that switch on the bar, an intensity knob, an on-beat stinger. -
Read the core in ladder order
Each layer only uses the ones before it:
dsl→render→streaming→runtime→instrument/adaptive→song/catalog→program. Start atdsl/mod.rs(SoundDoc) andrender/mod.rs(render_product), thensong/compile.rs(Song::compile→ Program) andruntime/performance.rs(a Program running live) — everything else is a consumer of those. -
Make a change safely
Adding a node type touches the four places the ladder predicts:
dsl/node.rs(the variant, rung 2),dsl/validate.rs(its rules),render/(the offline kernel, rung 5),streaming/(the real-time mirror — or let it fall back to the Player; a mixer-visible change also touchesstreaming/tracks.rs). Changing how an existing node sounds is different: that must go behind a newenginerevision so old documents keep their bytes — and engine ≥ 5 math lives indet.rs, pinned across platforms. The golden tests arbitrate.cargo fmt --all -- --check cargo clippy --locked --all-targets -- -D warnings cargo test --locked
where things live
| I want to… | Go to |
|---|---|
| add / change a node type | dsl/node.rs + dsl/validate.rs + render/ (+ streaming/ for the live path) |
| change synthesis math | gate it behind the next engine revision (dsl/mod.rs::ENGINE_VERSION; engine ≥ 5 kernels live in det.rs); goldens must not shift for old docs |
| drive audio from a game | runtime/ — Engine::load/play, Engine::split for the wait-free audio-thread seam, Mixer for buses |
| run a compiled song live | runtime/performance.rs (schedule at frames/beats/bars/sections, swaps, metrics) + runtime/transport.rs; the zero-allocation gate is tests/rt_alloc.rs |
| compile / ship a song | song/compile.rs::Song::compile → program.rs (bundle, hash, estimates); tono compile SONG.json --inspect |
| build playable instruments | instrument/ (design + live voice pool), presets.rs for factory patches |
| write music in code | song/ (tracks / patterns / buses + the pattern algebra) + music.rs (harmony) + catalog.rs — compiles to a Program |
| adaptive game music | adaptive/ — intensity stems, beat-quantized transitions, stingers, ducking |
| cross-platform byte-identity | det.rs — documents stamped engine: 5 share one pin set on every target; dsp.rs dispatches on the doc's engine |
| speaker output in Rust | tono-play::Speaker — the one cpal shim; desktop and py stream through it too |
| analyze / grade a render | analysis.rs (numbers + images), review.rs (pass/warn/fail against an archetype) |
| understand any workflow | RULE.md plus .github/workflows/; every command is direct |