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, adaptive music, the desktop studio, the Python bindings) 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.
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 4) so old sounds never silently change. 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 theenginepin that freezes which kernel revision renders it. This JSON document is the currency of the repo — the CLI renders it, the studio edits it, Python receives it as a string, songs compile to it, and the golden tests hash its render. If you save, send, or version anything in tono, it is one of these.{ "name": "blip", "duration": 0.3, "engine": 4, "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, verified by a fuzzer in CI. Docs outside the streamable subset fall back to a pre-renderedPlayer: still the same bytes, just bounced first.in the code: render/mod.rs::render_product · streaming/{value,source,proc}.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, the streaming renderer — every live object implements it, every output adapter (cpal via
tono-play::Speaker, a future AudioWorklet) 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) compiles via
to_doc()into an ordinary doc ofseqnodes; AdaptiveMusic loops rendered docs as intensity stems; the catalog and presets are just curated constructors. 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.rs (to_doc) · adaptive.rs · catalog.rs · presets.rs
the full flow, on the ladder
The authoring loop is rungs 2→5 plus your eyes; the live loop is rungs 5→6 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 ──► 🔊
the map
Cargo workspace: the root crate is the CLI; the engine and the native faces live under
crates/. The three cpal-based crates are off the default build —
make verify never compiles them; make verify-native is their gate.
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 250+ tests including the golden corpus and the offline/streaming byte-identity fuzz.
make verify # the default-build gate make hooks # install pre-commit / pre-push gates once per clone -
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": 4, "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 -- 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.
make play # the playground walkthrough make play EXAMPLE=band # catalog + fluent song builder make play EXAMPLE=adaptive # intensity-driven stems + stinger ls crates/tono-play/examples/ # all ten -
Read the core in ladder order
Each layer only uses the ones before it:
dsl→render→streaming→runtime→instrument/adaptive→song/catalog. Start atdsl/mod.rs(SoundDoc) andrender/mod.rs(render_product) — everything else is a consumer of those two. -
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). Changing how an existing node sounds is different: that must go behind a newenginerevision so old documents keep their bytes. The golden tests arbitrate.make verify # default crates (CI: ubuntu + macos) make verify-native # tono-desktop / tono-play / tono-py + all examples
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); 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 |
| build playable instruments | instrument/ (design + live voice pool), presets.rs for factory patches |
| write music in code | song.rs + catalog.rs — compiles to an ordinary SoundDoc |
| adaptive game music | adaptive.rs — intensity stems, beat-quantized transitions, stingers, ducking |
| 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 | Makefile — every action is a make target; CLAUDE.md is the contributor brief |