tono · repo guide · v1.10.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, 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.

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 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.

  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 version (schema) and engine (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
  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 at any block size, verified by a fuzzer in CI. Coverage is every node type, and a schema-v2 tracks mixing 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-rendered Player: still the same bytes, just bounced first — StreamGraph::blockers reports 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
  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, 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)
  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, tempo and meter maps, sections, automation, buses) lowers to an ordinary doc of seq tracks — and Song::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
  9. 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)
  10. 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.

in the code: dsl/mod.rs (SCHEMA_VERSION, ENGINE_VERSION) · program.rs::PROGRAM_VERSION · det.rs (the engine-5 kernels)

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

tono (crates/tono-cli) CLI: render + compile → wav/flac/ogg + stems + images + stats; MIDI cargo run -p tono -- render f.json
tono-play code playground: Speaker, play_doc, 11 examples cargo run -p tono-play --example drums
tono-desktop Tauri pattern station: step grid, live audio cargo build -p tono-desktop --release
tono-py PyO3 bindings: typed Song → Program → Performance, numpy maturin develop -m crates/tono-py/Cargo.toml
│ all four depend on ▼ (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 — incl. the tracks mixer
detengine-5 kernels: cross-platform bytes
dspRNG, loudness, limiters
playerbuffer playback
rungs 6 + 9
runtime/AudioSource seam, Engine, SPSC ring, Mixer
runtime/transport + performancesample-accurate clock, command queue, swaps, metrics
rung 7
instrument/notes, voices, gated env
drumkitGM drum map
adaptivegame music: stems, stingers
songtracks / patterns / buses → compile; pattern algebra
musicharmony: pitches, keys, chords
catalogready-made voices
presetsfactory instruments
rung 8
programthe immutable compiled artifact
unitsexact Beat; the one beat→frame crossing
idsstable compile-time handles
diagdiagnostics with stable T-codes
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 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
  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": 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 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.

    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
  5. 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; --inspect shows 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, capabilities

    Then hear rung 9: cargo run -p tono-play --example interactive_music runs a Performance — sections that switch on the bar, an intensity knob, an on-beat stinger.

  6. Read the core in ladder order

    Each layer only uses the ones before it: dslrenderstreamingruntimeinstrument/adaptivesong/catalogprogram. Start at dsl/mod.rs (SoundDoc) and render/mod.rs (render_product), then song/compile.rs (Song::compile → Program) and runtime/performance.rs (a Program running live) — everything else is a consumer of those.

  7. 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 touches streaming/tracks.rs). Changing how an existing node sounds is different: that must go behind a new engine revision so old documents keep their bytes — and engine ≥ 5 math lives in det.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 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; engine ≥ 5 kernels live in det.rs); 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
run a compiled song liveruntime/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 songsong/compile.rs::Song::compileprogram.rs (bundle, hash, estimates); tono compile SONG.json --inspect
build playable instrumentsinstrument/ (design + live voice pool), presets.rs for factory patches
write music in codesong/ (tracks / patterns / buses + the pattern algebra) + music.rs (harmony) + catalog.rs — compiles to a Program
adaptive game musicadaptive/ — intensity stems, beat-quantized transitions, stingers, ducking
cross-platform byte-identitydet.rs — documents stamped engine: 5 share one pin set on every target; dsp.rs dispatches on the doc's engine
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 workflowRULE.md plus .github/workflows/; every command is direct