Treble is a frontend-agnostic core library for audio synthesis. It provides composable DSP primitives — generators, envelopes, filters, and a node graph — behind a lock-free, real-time-safe audio pipeline. The same engine can be embedded in GUI applications, CLI tools, or test harnesses without modification.
The engine runs three concurrent roles:
| Role | Thread | Responsibility |
|---|---|---|
| App | caller | Creates App, loads instruments, calls start(), sends AudioMessages directly to the render thread, receives BackendEvents |
| Render | audio-render |
Runs system.run() per block, writes stereo samples to the ring buffer, emits BackendEvents |
| CPAL callback | hardware | Pops samples from the ring buffer, writes to the sound card |
flowchart TD
App["App (caller thread)"]
subgraph engine["Audio Engine"]
RT["Render thread\naudio-render"]
CB["CPAL callback\nhardware thread"]
end
App -->|"AudioMessage\n(crossbeam bounded)"| RT
RT -->|"BackendEvent\n(mpsc::channel)"| App
RT -->|"f32 samples\n(ArrayQueue, lock-free)"| CB
CB -->|audio| HW["Sound card"]
sequenceDiagram
participant U as User code
participant RT as Render thread
participant Q as Ring buffer (ArrayQueue<f32>)
participant CB as CPAL callback
U ->> RT : AudioMessage::Instrument(NoteStart { source_index, note, velocity })
Note over RT: system.start_note(source_index, note, velocity)
loop every render_chunk_size frames
RT ->> RT : system.run() — block DSP
RT ->> Q : push stereo-interleaved samples (L,R,L,R,…)
RT ->> U : BackendEvent::AudioChunk(Vec<f32>)
end
CB ->> Q : pop data.len() samples
CB ->> CB : write to hardware output
The ring buffer is a crossbeam::queue::ArrayQueue<f32>. Samples are always stereo-interleaved: [L, R, L, R, …]. The render thread produces two samples per mono frame; the CPAL callback consumes exactly buffer_size × channels samples per callback. On underrun the callback fills with silence and increments a shared counter.
Default capacity: 8 192 samples (~93 ms at 44.1 kHz stereo). The render thread throttles at target_latency_ms (default 50 ms) and never fills the buffer beyond that threshold, keeping command-to-sound latency low.
All audio is processed through a single System graph. Every instrument is compiled into a Source node inside the graph before playback starts.
On App::start(), AudioGraph::compile() assembles all loaded instruments into one System and passes it to the render thread. The render thread calls system.run() every block, draining all pending AudioMessages between blocks.
To swap a new graph at runtime, send AudioMessage::Graph(GraphAudioMessage::Swap(system)). The render thread replaces its current system atomically between blocks.
The render thread sends BackendEvents back to the caller via an mpsc::channel:
| Variant | Description |
|---|---|
AudioStarted { sample_rate } |
Engine is ready |
AudioStopped |
Shutdown complete |
AudioChunk(Vec<f32>) |
Stereo-interleaved samples from the last block — use .step_by(2) to extract L or R. Useful for offline analysis and waveform capture. |
BufferUnderrun { count } |
Ring buffer was empty during a callback |
CommandError { command, error } |
A command failed |
GraphError { description } |
Graph topology error (cycle, missing sink, …) |
Metrics { cpu_usage, latency_ms } |
Periodic diagnostics |
OutputDeviceList { devices } |
Available output devices |
OutputDeviceChanged { device } |
Active output device changed |
A SingleToneGenerator produces one oscillator voice:
- Waveform —
Sine,Square,Sawtooth,Triangle,WhiteNoise, ... - FrequencyRelation —
Ratio,Harmonic,Semitones,Constant,Offsetrelative to a base frequency - Amplitude envelope — any
dyn Envelope - Pitch envelope — optional
dyn Envelopethat scalestime_elapsedper sample
A MultiToneGenerator combines multiple SingleToneGenerators under a shared base frequency, optional global amplitude and pitch envelopes, and a MixMode (Sum, Average, Multiply, Max).
Envelopes implement Envelope::at(time: f32, note_off: f32) -> f32. Built-in segments:
LinearSegment(start, end, duration)BezierSegment(start, end, duration, control_point)ConstantSegment(value, Option<duration>)
ADSREnvelope composes four segments (attack, decay, sustain, release). The sustain level is the end value of the decay segment.
Sources implement the Source trait and feed audio into the graph. Two built-in source types wrap a MultiToneGenerator:
MonophonicSource— single voice, one note at a time. Suitable for percussive instruments (kick, snare). Whentrack_pitchis false, the source always plays at its configured base frequency regardless of what note triggers it.PolyphonicSource— voice pool, multiple simultaneous notes. Each voice is an independent generator instance. Suitable for melodic instruments (keyboard).
Filters implement Filter::process(input: Frame) -> Frame and optionally set_parameter(name, value):
LowPassFilter, HighPassFilter, BandPass, GainFilter, Clipper, Compressor, Tremolo, DelayFilter, MovingAverage, ...
Instruments implement the Instrument trait:
pub trait Instrument: Debug + Send + Sync {
fn start_note(&mut self, note: Note, velocity: f32);
fn stop_note(&mut self, note: Note);
fn into_system(self: Box<Self>) -> System;
}into_system() converts the instrument into a self-contained System sub-graph. AudioGraph::compile() calls this for each loaded instrument and assembles the sub-graphs into one unified System for the render thread.
Built-in instruments: Kick, Snare, HiHat (percussive, fixed pitch), Keyboard (polyphonic, pitch-tracked).
Engine failures carry stable, grep-able codes so a log line or a UI banner links straight back to its source. Codes are never reused or renumbered.
| Code | Meaning |
|---|---|
TRBC-GRAPH-101..109 |
AudioGraphError variants (invalid node/port, cycle, unknown parameter, …) |
TRBC-GRAPH-110 |
An absorbed graph edge had no endpoints; the edge was skipped |
TRBC-RT-001 |
Sink 0 refused master_volume — the master level is not reaching the output |
TRBC-RT-002 |
The audio render thread could not be spawned |
TRBC-INST-001 |
A built-in voice graph failed to compute; the voice plays silence |
The audio path never panics on these: it degrades (silence, a skipped edge, a
held level) and logs the code once. Debug builds additionally trip a
debug_assert where the condition indicates a wiring bug.
treble-meta and treble-derive are separate repositories, pinned in
Cargo.toml by git tag so a bare checkout builds straight from GitHub.
To work against sibling checkouts instead, copy the example override:
cp .cargo/config.toml.example .cargo/config.toml.cargo/config.toml is git-ignored, so CI and fresh clones never see it. The
example assumes the umbrella layout — treble-core, treble-meta and
treble-derive checked out side by side.
Two things to know while that override is active:
- Building rewrites
Cargo.lock, because cargo records a patched crate without itssourceline. The committed lockfile is the unpatched resolution, which is what CI needs — leave that churn out of commits. - A patch is silently ignored when the sibling checkout's version does not
satisfy the pin, and cargo then quietly uses the tag instead, so you can
believe you are testing local changes that never get compiled. Check for
[[patch.unused]]inCargo.lockif a local edit seems to have no effect.
Every repository must spell a shared dependency identically (same URL, .git
suffix included, same tag): cargo keys a git source on URL + ref, so two
spellings build the crate twice and a trait implemented against one instance is
invisible to the other. grep -c 'name = "treble-meta"' Cargo.lock must
print 1.
Verify changes with cargo test and cargo clippy --all-targets -- -D warnings.