Developer
Guide.
A map of the codebase, DSP kit, scripting surface, and contribution workflow for developers working on Brume.
Overview
Audience
The manual describes Brume from the operator's perspective: which knob does what, which tab reveals which engine, how MIDI Learn behaves. This guide takes the inverse view, documenting the code underneath those controls for developers curious enough to read, extend, or port the work.
Most of what matters lives in the Cargo workspace at the root of the repository. The four headline areas — the audio engine, the DSP kit it builds on, the Lua scripting surface, and the contribution workflow — each get a section below. Each one starts with the shape of the thing, then points into specific files when specifics matter.
Pointers into code
The running codebase is the source of truth. Function signatures, parameter ranges, and error types change; paragraphs describing them go stale without a visible diff. This guide keeps the narrative thin and leans on pointers into source so that a paragraph naming crates/engine-runtime/src/engine.rs reads as an invitation to open that file rather than as a claim about its contents.
Three hot-path invariants matter more than any paragraph naming them: the audio thread does not allocate, it does not block on the UI, and IPC messages are enum-typed where practical. The Architecture section below names the specific rules; anyone touching the audio thread, the IPC channels, or the UI timer should read the relevant crate's tests and in-line comments before changing behaviour.
Architecture
Brume is a Cargo workspace of fourteen library crates and four binaries. The audio engine runs inside a single cpal callback, the UI is a wry webview hosted on GTK, and IPC between the two rides on bounded crossbeam channels. The overall signal path:
Workspace layout
The workspace groups code by responsibility. The boundaries are load-bearing — crossing them without a reason is usually a sign the design has drifted.
| Crate | Role |
|---|---|
| brume-common | Canonical types: ParameterId, OscillatorMode, BrumeError. FromStr impls at the IPC boundary. |
| brume-app-protocol | UiToEngine / EngineToUi message enums shared between engine and UI. Designed so the audio thread never allocates to publish a message. |
| brume-modulation | LFOs, step sequencers, transition shapes, routing table. |
| brume-control-model | MIDI CC bindings, channel-to-part routing, MIDI Learn state. Persisted to disk. |
| brume-audio-io | cpal-backed output stream, device enumeration. CoreAudio on macOS, ALSA on Linux. |
| brume-midi-io | midir input, activity tracking, control-surface recognition, MIDI Learn capture path. |
| brume-engine-runtime | BrumeEngine, Part, BrumeVoice, the four oscillators, SVF filter, amp envelope, transport. Owned exclusively by the audio thread after construction. |
| brume-fx-chain | FxSlot trait and FxChain container. Ships four built-in effects (Saturator, Chorus, Stereo Delay, Dattorro Reverb). |
| brume-scripting | Lua 5.4 host via mlua, the brume API table, script loader, Lua FX slot. |
| brume-preset-store | JSON preset library with path-traversal checks and atomic writes. |
| brume-settings | Per-user settings persistence. |
| brume-ui-app | GTK window, wry webview, IPC bridge, debounced persistence, script worker thread. Bundles the UI HTML / JS / CSS into the binary via include_str!. |
| brume-platform-rpi | Pi-specific helpers. Currently thin; reserved for hardware plumbing that doesn't belong in the engine. |
The binary brume-main wires everything together: it constructs the engine behind an Arc<parking_lot::Mutex<BrumeEngine>>, opens the cpal output stream, starts the MIDI input thread, constructs the UI with a BrumeUiConfig, and hands control to the GTK main loop. Two companion binaries sit under apps/: render-wav drives headless audio renders for the landing-page demos, and brumectl is the host-side companion CLI for status checks and over-USB deploys (status, shell, watch are portable to any Unix host; the flash wizard currently shells out to macOS’s diskutil + ioreg, with a Linux port planned).
Signal path
Audio flows through each voice identically regardless of which synthesis engine is active, following the diagram above. MIDI fans out through the channel map to one of four parts, each voice passes through the mod matrix and SVF filter, the mixer sums parts with per-part send levels, the send buses feed the four built-in effects plus any Lua FX, and the wet returns mix back into the master limiter before the DAC.
Threads and channels
Brume's concurrency model is deliberately narrow. Four threads matter at runtime.
| Thread | Responsibility |
|---|---|
| audio (cpal) | Runs the cpal output callback. Takes the engine mutex with try_lock per buffer; on a miss it zero-fills. Never allocates. |
| GTK main | Webview IPC bridge, 50 ms timer for engine-to-UI drain and evaluate_script calls. Owns the stream handle for device switches. |
| midir input | One or more MIDI input connections. Parses bytes, routes notes and CCs through ControlMatrix, forwards recognised control surfaces to the UI. |
| brume-script | Dedicated thread for the Lua tick loop. A slow eval or on_tick freezes only this thread; script output crosses to the UI via a bounded channel. |
Communication uses bounded crossbeam_channels. UI to engine messages travel on a 256-slot channel; engine to UI on a 1024-slot channel sized to absorb bursts of ~200 Hz × 8 CCs of DAW automation without dropping echoes. try_send drops rather than blocks so the audio callback can never be made to wait on the UI.
Invariants
Three contracts are load-bearing enough to call out explicitly.
The audio thread must not allocate. That covers the obvious (no Box::new, no vec![]) as well as the subtle (no format!, no String::from, no growing a Vec past its capacity, no HashMap::insert on a new key). When the engine needs to publish structured data — modulation assignments, transport state — it packs the data into a stack-allocated [Option<T>; N] and sends that; the UI thread stringifies for display.
The audio thread must not block on the UI. The engine mutex is parking_lot, so it cannot poison; the audio callback takes it with try_lock so contention triggers a zero-fill instead of a wait. Engine-to-UI pushes go through try_send, so a saturated queue drops messages rather than blocking the callback. The same rule applies to any future mutex the audio thread touches.
IPC shapes are enum-typed where possible. ParameterId has a single FromStr impl in brume-common that delegates to serde's Deserialize, so adding a new variant extends the parser automatically. A handful of values still travel as strings across the boundary (clock modes, FX slot names) for legitimate reasons, and the pattern for converting any of them to typed enums is the same one ParameterId already uses.
DSP Kit
brume-dsp-core is Brume's in-tree DSP crate under GPL-3.0-only. It is intentionally free of application-level types and of heap allocation on the audio path. The wavefolder, the SVF filter, the phase oscillator, the Dattorro reverb topology, and the smoother primitive that every engine relies on all live there; Brume composes them into higher-level oscillators and effects.
Zero-alloc contract
A DSP slot's process method runs inside the audio callback. It cannot allocate, cannot block on a mutex, and cannot take a lock the UI also takes. The workspace lint unsafe_code = "forbid" keeps one class of hazard out of safe code at compile time; the other two rules are enforced by review and by the tests around each hot path.
When a DSP primitive needs storage — a delay line, a scope ring, a per-voice scratch buffer — allocate it at construction time behind a boxed array or fixed-size stack array, then reuse it. The convention across the engine runtime is Box<[f32; N]> when the array is too large for the cpal thread stack and a plain [f32; N] otherwise.
Adding an oscillator
The four existing engines (Complex, Harmonic, Timbral, Granular) share a uniform shape: new(sample_rate), typed parameter setters, a per-sample or per-block process method, and a reset. crates/engine-runtime/src/granular_oscillator.rs is the most self-contained example — it owns its grain pool, its shared FM oscillator, its drift smoother, and its RNG. Read it end-to-end before designing a new engine.
A new engine needs four edits:
- A new module under
crates/engine-runtime/src/implementing the oscillator. - A new variant on
OscillatorModeinbrume-common. - A new field on
BrumeVoiceplus dispatch insideset_oscillator_modeandprocess. - New
ParameterIdvariants and their routing inPart::set_parameter.
The UI side gains the new mode automatically if the SYNTH_TABS table in ui.js is updated. Testing happens at the oscillator-crate level (property tests on output finiteness, reset state, parameter ranges) and at the engine level.
Adding an effect
The FxSlot trait in crates/fx-chain/src/lib.rs is the interface:
pub trait FxSlot: Send {
fn name(&self) -> &str;
fn params(&self) -> &[FxParamDef];
fn set_param(&mut self, name: &str, value: f32);
fn get_param(&self, name: &str) -> f32;
fn process_stereo(&mut self, left: &mut [f32], right: &mut [f32]);
fn reset(&mut self);
}
Built-in effects register through FxChain::new and are looked up by name from the audio callback. The four reserved names (SATURATOR_SLOT, CHORUS_SLOT, DELAY_SLOT, REVERB_SLOT) map to dedicated positions in process_block; anything else — including Lua FX loaded at runtime — is iterated through custom_slots_mut. A new built-in effect adds a constant name next to the existing four, a construction in FxChain::new, and a call site in engine.rs::process_block if it needs a dedicated position; otherwise it flows through the custom-slot loop unchanged.
Scripting
Brume's scripting surface is a single Lua 5.4 interpreter exposed as the brume global table. Scripts run on a dedicated thread named brume-script so a slow eval or on_tick cannot freeze the UI drain.
Lifecycle
A script is loaded by calling ScriptEngine::load_script(name), which reads the file from the scripts directory, evaluates it in a fresh Lua state, and registers any callbacks the module defined. Every callback is opt-in.
| Callback | Called from |
|---|---|
| on_init() | Once, when the script loads. Good for setting parameters or wiring a clock coroutine. |
| on_tick(beat) | Every 50 ms on the script thread, with the current beat position when transport is playing. |
| on_beat(beat) | On integer-beat boundaries. |
| on_note(part, note, vel, on) | For incoming MIDI notes, before the engine's own note allocation. |
| on_cc(part, cc, value) | For incoming MIDI CCs after the ControlMatrix lookup. |
| on_cleanup() | Once, when the script is unloaded or hot-reloaded. |
Hot-reload is an mtime poll every two seconds. When the file changes, the script is re-loaded in place and on_cleanup fires on the previous version before on_init runs on the new one.
API surface
The reference scripts under scripts/ are the canonical source for the brume table; the manual's scripting section catalogues the call surface in full. In outline:
brume.set_param(part, id, value)/brume.get_param(part, id)for every parameter the UI exposes.brume.note_on(part, note, velocity)andbrume.note_off(part, note)for direct note output.brume.set_fx(slot, param, value)for FX chain parameters.brume.set_tempo(bpm),brume.bpm()for transport.brume.add_param(def)to declare a script-owned parameter that appears as a slider in the UI.clock.run,clock.sync,clock.sleepfor norns-style coroutine scheduling.screen.clear / color / line / rect / circle / text / updatefor drawing to the script viewport.
DSP primitives — dsp.delay, dsp.allpass, dsp.lowpass, dsp.saturator, dsp.wavefolder — are thin Lua bindings over the same DSP kit the engine uses. They exist primarily so an FX script can build a non-trivial effect without dropping to Rust.
FX scripts
Audio-effect scripts live under scripts/fx/ and take a different shape from control scripts. They define a global fx table with init, process, and params. LuaFxSlot::from_file loads one at runtime and inserts it into the master FX chain. scripts/fx/diffuser.lua is the smallest working example.
The process function runs on the audio thread, which means the same zero-alloc rule applies. mlua is configured with GC paused during process calls, but allocating new Lua tables or strings inside the hot loop still costs enough to glitch. The DSP primitives are pre-allocated in init and reused.
Contributing
Local build
Brume builds on macOS (for UI preview and compilation sanity) and on Linux aarch64 (the shipped target). On macOS, the GTK and wry surfaces are gated behind #[cfg(target_os = "linux")], so cargo build --workspace will compile but will not produce a runnable UI. cargo check --workspace is the fastest way to verify a change compiles on the platform you are on; cargo test --workspace runs the DSP and protocol test suites.
CM5 deploy
The development loop for anyone running Brume on their own Compute Module 5 is host-machine to device over SSH. The shape of the four steps, with your own values substituted for the placeholders:
rsync -avz --exclude target --exclude .git \
brume/ <user>@<device-host>:<device-path>/brume/
ssh <user>@<device-host> \
"cd <device-path>/brume && cargo build --release -p brume-main"
ssh <user>@<device-host> \
"systemctl --user restart brume.service"
All DSP code is in-tree under crates/dsp-core/, so the deploy flow is a plain sync + build + service restart with no workspace-Cargo.toml rewriting or out-of-tree sibling directories to keep synchronised.
Tests
DSP leaves have test coverage in-crate. granular_oscillator.rs, harmonic_oscillator.rs, the transport module, and the modulation router all exercise property-style checks on output finiteness, reset state, and parameter ranges. The engine crate has top-level tests that verify voice allocation, parameter routing, and output production end-to-end.
The IPC boundary has targeted tests too. ParameterId::FromStr round-trips every variant against serde's Deserialize, which is the single source of truth for the wire format. Persistence is tested against its debounce window and its shutdown-flush behaviour on Drop.
Changes that touch the audio thread or the IPC channels should come with either a test or a note in the PR explaining why one is not warranted. The working pattern in this repository: each commit addresses one concern, builds clean on both macOS and the CM5, and runs on the device before being pushed.
crates/engine-runtime/src/engine.rs is the top-level audio loop, and apps/brume-main/src/main.rs is the wiring harness that constructs everything and starts the GTK main loop. The rest of the workspace is reachable from those two files.