tono · repo guide · v1.8.0

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.

The invariant that makes composition safe
render(graph, seed, sample_rate) → byte-identical audio, forever

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.

  1. 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 f32 bit 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)
  2. 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 modulator
    in the code: dsl/mod.rs::Value + Modulator · evaluated by render (eval_value) and streaming/value.rs — same formulas, same bytes
  3. 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 chain
    in the code: dsl/node.rs (the enum + docs) · dsl/validate.rs (its rules) · render/ (the offline kernel per variant)
  4. 3
    Composition — mix · mul · chain

    Three combinator nodes turn atoms into design. mix layers signals, mul gates one by another (an envelope is just mul by an env node), chain runs 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
  5. 4
    A SoundDoc — the unit the whole project trades in

    A root node plus metadata: duration, sample_rate, seed, and the engine pin 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
  6. 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-rendered Player: 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
  7. 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)
  8. 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 of seq nodes; 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 buildmake 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

tono (root, src/) CLI: render → wav/flac/ogg + images + stats; MIDI export cargo run -- render f.json
tono-play code playground: Speaker, play_doc, 10 examples make play EXAMPLE=drums
tono-desktop Tauri pattern station: step grid, live audio make desktop
tono-py PyO3 bindings: numpy pull + live Engine make python
│ all four depend on ▼ (and tono-desktop/py stream through tono-play's Speaker)
crates/tono-core — the pure engine. No I/O, no transport, no cpal. Deterministic compute only.
rungs 1–4
dsl/Value, Node, SoundDoc, validate
patchdoc + named params
editpath-addressed edits
varymutate / humanize
rung 5
render/offline bounce (osc, seq, kit, effects)
streaming/real-time, byte-identical to offline
dspRNG, loudness, limiters
playerbuffer playback
rung 6
runtime/AudioSource seam, Engine, SPSC ring, Mixer
rung 7
instrument/notes, voices, gated env
drumkitGM drum map
adaptivegame music: stems, stingers
songtracks / patterns → SoundDoc
catalogready-made voices
presetsfactory instruments
feedback
analysisSTFT, spectrogram, LUFS, transients
reviewgrade a sound against its archetype

start here

  1. 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
  2. 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 numbers

    Then change "freq": 880 to "A5", then to a slide modulator, and re-render — you have now used every form of rung 1.

  3. 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.rs open; the doc comments are the source of truth.

  4. 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
  5. Read the core in ladder order

    Each layer only uses the ones before it: dslrenderstreamingruntimeinstrument/adaptivesong/catalog. Start at dsl/mod.rs (SoundDoc) and render/mod.rs (render_product) — everything else is a consumer of those two.

  6. 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 new engine revision 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 typedsl/node.rs + dsl/validate.rs + render/ (+ streaming/ for the live path)
change synthesis mathgate it behind the next engine revision (dsl/mod.rs::ENGINE_VERSION); goldens must not shift for old docs
drive audio from a gameruntime/Engine::load/play, Engine::split for the wait-free audio-thread seam, Mixer for buses
build playable instrumentsinstrument/ (design + live voice pool), presets.rs for factory patches
write music in codesong.rs + catalog.rs — compiles to an ordinary SoundDoc
adaptive game musicadaptive.rs — intensity stems, beat-quantized transitions, stingers, ducking
speaker output in Rusttono-play::Speaker — the one cpal shim; desktop and py stream through it too
analyze / grade a renderanalysis.rs (numbers + images), review.rs (pass/warn/fail against an archetype)
understand any workflowMakefile — every action is a make target; CLAUDE.md is the contributor brief