tono · repo guide

From one node to a game soundtrack

tono builds sound from nodes that generate and process signals. Songs lower to those graphs; instruments and live hosts reuse the same DSP and playback interfaces. Follow those shared layers to understand the CLI, the web Sound Studio, and Python bindings. A Song composes, a SoundDoc renders, a Program ships, and an Engine runs sounds.

The invariant that makes composition safe
render(current document) → deterministic audio

The current renderer supports schema 2 and engine 5. The same validated document renders the same bytes, and streaming playback matches the offline bounce where supported. Golden PCM hashes in crates/tono-core/tests/golden.rs and block-size sweeps guard that agreement. det.rs provides deterministic f64 kernels and a fixed-order FFT. CI verifies Linux with pinned and latest Rust; Windows/macOS builds are no longer funded. Explicit unsupported format revisions fail validation. Investigate a changed golden hash before updating it: a code reorganisation must preserve sound.

the smallest unit, and the ladder up

Read the ladder to follow the main authoring and playback interfaces. The module map below shows their consumers.

  1. 0
    A sample — f32

    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

    Many source parameters accept a Value: a constant, a note name resolving to Hz, or a modulator evaluated per sample. Other fields are fixed configuration. Track gain and pan automation use beat-positioned lanes in the mixer.

    "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 · streaming/value.rs shares value evaluation with the offline renderer
  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 · tracks

    Combinators turn atoms into designs. mix layers signals, mul gates one by another (an envelope multiplies a source), and chain runs processors in series. tracks adds a mixing console with named layers, pan/gain lanes, routing, buses, and a master chain.

    { "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 / Tracks 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: name, duration, sample_rate, seed, and schema/engine pins. The CLI renders SoundDocs, Python accepts them, and songs compile through them into Programs. The web Sound Studio edits a Song draft with grid state, then builds SoundDocs from it for playback. These are distinct authoring and runtime formats.

    { "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
    One evaluator, two block sizes — streaming/ and render/

    The node tree has one evaluator, streaming/, which carries oscillator, filter, and mixer state between blocks. render/ is the bounce: it runs that evaluator as one block as long as the document and adds the output stage, so a stream at any block size is byte-identical to the offline output by construction. The tracks mixer handles lanes, sidechain duck envelopes, buses, and insert chains. Whole-buffer or unsupported combinations — normalization, loop playback, wide stereo treatments and the SoundFont sampler — use a pre-rendered Player fallback. StreamGraph::blockers reports the exact blockers for a document.

    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

    The playback seam fills interleaved stereo buffers. Engine, Mixer, Instrument, AdaptiveMusic, Performance, and the streaming source implement AudioSource. Output adapters consume that trait. It also has a default reset method for sources that can rewind. The core does not open an audio device.

    pub trait AudioSource {  // primary method; reset omitted here
        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 — authoring and playback over rungs 4–6

    A Patch is a document plus named parameters edited by path. An Instrument turns notes into voices with the shared render/streaming machinery. A Song contains tracks, patterns, arrangement, tempo/meter maps, sections, automation, and buses; it lowers to a SoundDoc and compiles into a Program. Pattern algebra transforms notes without introducing another DSP engine. AdaptiveMusic combines rendered stems and stingers; the catalog and presets supply voices; music supplies pitches, keys, chords, and voicings.

    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

    A Program packages a resolved SoundDoc, compile target, musical metadata, resource estimates, and an integrity hash. The document owns name, sample rate, duration, and schema/engine pins. Metadata owns tempo/meter maps, pickup, sections, markers, length in bars, and named tracks in declaration order. Inspection indices are roster positions, not persistent identities. Warnings and streaming capabilities are derived. The hash covers every serialized field except itself using canonical sorted-key JSON and FNV-1a. Loading verifies the exact bundle revision, integrity, document validation, and target constraints without recompiling.

    in the code: song/compile.rs::Song::compile → program.rs (Program, ProgramMeta, ResourceEstimates) · diag.rs (T-coded diagnostics) · units.rs (exact Beat; beat→frame conversion)
  10. 9
    A Performance — a running Program

    A Performance combines a Program, Transport, and bounded command queue. It resolves frames, beats, bars, markers, or sections at scheduling time and executes commands during fill, in submission order for timestamp ties. Gain changes ramp and swaps crossfade; invalid replacements leave the current source running. Stingers and replacements are prepared when scheduling. The queue holds 4096 commands and rejects/counts overflow. Normal blocks use pre-sized scratch; larger blocks can grow it once. tests/rt_alloc.rs checks steady-state fills after warm-up. Python live output pumps samples on a control thread and drains the sample ring through Speaker; the wrapper uses a nonblocking source lock and grows scratch on first use. Recorded commands replay deterministically.

    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

A Song holds the composition. A SoundDoc is its resolved render graph. A Program adds musical metadata, estimates, and a hash to that document. Engine provides reusable sound resources and live instances; Performance runs a compiled song. Offline renderers and live sources share the underlying DSP, while the output adapter handles the device.

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)
 optional SoundFonts)        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)
                                    ▼
                            Renderers / Performance — the same Program on each face:
                            native runtime · Python

three current format revisions

Three constants describe the current formats. Schema (SCHEMA_VERSION = 2) defines SoundDoc/Song structure. Engine (ENGINE_VERSION = 5) defines the current DSP math. Program (PROGRAM_VERSION = 3) defines the bundle and its semantic hash boundary. A document stores the first two; its Program stores the bundle revision. Loaders accept these exact revisions, reject unsupported bundles (T3001), and verify the hash (T3002) plus document validity. Historical render paths and migrations have been removed.

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 / ring ──► 🔊

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-core (feature cpal) code playground: the speaker module (Speaker, play_doc) + 5 examples cargo run -p tono-core --features cpal --example live_band
tono-web WebAssembly adapter: the docs site's Sound Studio and library previews npm run docs:test
tono-py PyO3 bindings: typed Song → Program → Performance, numpy maturin develop -m crates/tono-py/Cargo.toml
│ all four depend on ▼ (tono play and tono-py stream through core's cpal Speaker)
crates/tono-core — DSP, compiler, transport, and runtime. Optional SoundFont loading; the audio device only behind the off-by-default cpal feature.
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
detcurrent deterministic kernels
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
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

    Start with the default core/CLI gate below. The root README lists full-workspace Linux checks and native prerequisites; .github/workflows/ci-rust.yml additionally checks all features, soaks, release builds, Rust docs, and installed wheels.

    cargo fmt --all -- --check
    cargo clippy --locked --all-targets -- -D warnings
    cargo test --locked
  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, "version": 2, "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/
    xdg-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

    The SoundDoc reference covers node types; sound-effect design and song composition show their use. Read them alongside crates/tono-core/src/dsl/node.rs; Rust API docs describe the public types.

  4. Hear rungs 6–7 live from code

    The tono-core speaker examples (feature cpal) are the guided tour of the layers — sounds, instruments, a live band, buses, songs, adaptive game music.

    cargo run -p tono-core --features cpal --example playground  # walkthrough
    cargo run -p tono-core --features cpal --example lofi        # catalog + fluent song builder
    cargo run -p tono-core --features cpal --example adaptive    # intensity-driven stems + stinger
    ls crates/tono-core/examples/        # all five, plus the headless ones
  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 run rung 9: cargo run -p tono-core --example compose composes a song, compiles it and runs it through a Performance, headless and deterministic.

  6. Read the core in ladder order

    Start at dsl/mod.rs (SoundDoc) and render/mod.rs (render_product), then follow streaming/ and the runtime/ audio seam. song/compile.rs builds Programs; runtime/performance.rs runs them. The decision records explain determinism, time conversion, bundle integrity, Python, and queues.

  7. Make a change safely

    A node change touches dsl/node.rs, dsl/validate.rs, and its render/ kernel. Add streaming support or an explicit fallback blocker; mixer-visible behavior also touches streaming/tracks.rs. Run golden, streaming, validation, and applicable runtime proofs. Changing sound deliberately requires a revision decision and updated fixtures; code cleanup must preserve the current PCM pins. Deterministic math lives in det.rs.

    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 mathcurrent kernels live in det.rs; investigate changed PCM pins and make an explicit revision decision for intentional sound changes
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::compile → program.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
deterministic PCMdet.rs — current engine-5 kernels; Linux golden tests pin their output
speaker output in Rustspeaker.rs (tono_core::speaker::Speaker, feature cpal) — the one cpal shim; tono play, desktop and py stream through it too
analyze / grade a renderanalysis.rs (numbers + images), review.rs (pass/warn/fail against an archetype)
understand any workflowthe root README, CONTRIBUTING.md, and .github/workflows/