Developer
Guide.
A map of the codebase, DSP kit, scripting surface, CM5 deployment path, and contribution notes 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 guide moves through architecture, DSP, scripting, controller integration, build and test flow, CM5 deployment, and contribution notes. Each section 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 two binaries. The audio engine runs inside a single cpal callback; the UI is the iced 0.13 runtime in the same process; 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.5 host via mlua (memory + per-tick time budget enforced), the brume API table, script loader, Lua FX slot. |
| brume-patch-store | JSON patch + perf library — per-engine patches and whole-instrument snapshots, versioned schema, path-traversal checks, atomic writes. |
| brume-settings | Per-user settings persistence. |
| brume-ui-native | The full UI as an iced 0.13 application: window, view tree, message handlers for engine echoes, the per-engine sub-tab tables in tabs.rs that drive parameter rendering, debounced settings persistence, and the worker that hosts the Lua scripting thread. |
| 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, builds the iced application with the engine’s IPC channels and the loaded patch library, and hands control to iced’s main-thread event loop. The companion binary brumectl sits under apps/ as the host-side CLI for status checks, install, and SSH deploys.
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 at SCHED_FIFO when the binary carries cap_sys_nice. Takes the engine mutex with try_lock per buffer; on a miss it zero-fills. Never allocates. |
| iced main | Runs the iced application: a ~62 Hz subscription tick drains the engine’s EngineToUi channel into param_values, advances slider smoothers, and triggers iced repaints. Owns the audio 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.
Patches & presets
crates/patch-store owns serialization, on-disk storage, and recall for both Patch (one engine’s parameter state, or an FX chain) and Perf (a whole-instrument snapshot). Both are ordinary serde JSON. The schema is deliberately boring so patches can be authored, generated, and version-controlled outside the app — the factory library itself is just a tree of these files.
Schema
A Patch is a metadata envelope around a flat BTreeMap<String, f64> of parameter values. The keys are ParameterId names — the same enum that crosses the IPC boundary — so a patch is just a serialized snapshot of parameters the UI already addresses by id. version, name, mode, and params are required; the metadata fields (tags, created_at, modified_at, brume_version, author, notes) and the reserved modulation / effects slots all carry #[serde(default)] with skip_serializing_if, so older files deserialize cleanly and empty fields never bloat the JSON.
pub struct Patch {
pub version: u32, // SCHEMA_VERSION, currently 1
pub name: String,
pub mode: String, // "fm" | "harmonic" | "timbral" | "granular" | "fx"
pub params: BTreeMap<String, f64>, // ParameterId name -> value
pub tags: Vec<String>, // mode auto-inserted on save
pub created_at: String, // ISO 8601 UTC
pub modified_at: String,
pub brume_version: String, // provenance only, never drives migration
pub author: Option<String>,
pub modulation: Option<Value>, // reserved
pub effects: Option<Value>, // reserved
pub notes: Option<String>,
}
A Perf shares that envelope but swaps the flat map for a composite body: parts: [Option<PartSnapshot>; 4] (FM / Harmonic / Timbral / Granular, each a { mode, params } snapshot, None when unused), an optional FxSnapshot, and a MixerSnapshot (per-part levels, mutes, delay/reverb sends, and master volume). Perfs embed copies of each part’s params rather than referencing patch files by name, so renaming or deleting a source patch can’t break a perf; an advisory source_patch breadcrumb is kept for UI context but never consulted at load.
Storage & forward-compat
A library has one writable user root (~/.brume/library/, where save lands) and zero or more read-only factory roots (default /usr/share/brume/factory/, overridable with the colon-separated BRUME_FACTORY_DIR for dev hosts without the system path provisioned). list and load walk both layers and tag each entry User or Factory; the runtime never writes to a factory root. Saves are atomic write-then-rename, so an interrupted save can’t corrupt an existing file.
SCHEMA_VERSION is 1 today; bump it only for a genuinely breaking change — additive fields ride the serde(default) path instead, and unknown fields are tolerated on read. The factory presets ship in-tree at crates/patch-store/factory/<mode>/*.json and are extracted to the system factory root by brumectl install. Because they are ordinary JSON, adding or revising a factory preset is a source edit, not a code change.
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 (FM, 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 once the per-engine tab tables in crates/ui-native/src/tabs.rs are extended with its ParamSpecs. Each ParamSpec carries the binding range, the linear-or-log scale, and the default value; the same definitions feed the on-screen sliders, the nanoKONTROL knob mappings, and the MIDI Learn binding system, so a parameter declared once gets correct behaviour everywhere. 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.5 interpreter exposed as the brume global table, hosted via mlua with a memory budget and a per-tick time budget so a runaway script can’t exhaust the device. 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.
Controllers
ControlSurface is the trait Brume's first-class controller drivers implement; the source lives at crates/midi-io/src/control_surface.rs, and the shipped set holds NanoKontrol2. Generic CC controllers route through MIDI Learn at runtime and do not need a driver.
Trait surface
| Method | Role |
|---|---|
| id() | Stable string used as the kind tag on EngineToUi::ControllerCc. |
| matches_port(name) | True when the driver claims a given MIDI port. |
| rt_knob_slot(cc) | Audio-priority hook. Returning Some(0..7) routes the CC straight to the engine, bypassing the UI thread. |
| handle_cc(api, cc, value) | Called for every CC the driver does not claim as a knob slot. |
| handle_note(api, note, vel, on) | Optional. Default ignores. |
ControlSurfaceApi is the action vocabulary the native UI implements: set_part_level, toggle_mix_mute, toggle_mix_solo, cycle_engine, cycle_sub_tab, apply_fx_knob, apply_mod_knob, is_on_mix_page, is_on_mod_page. Drivers see the trait only.
Adding a driver
Two edits:
- A struct under
crates/ui-native/src/controllers/implementingControlSurface.nanokontrol2.rsis the smallest example. - One line in
shipped_registry()incontrollers/mod.rs.
Drivers are unit-testable through a mock ControlSurfaceApi; nanokontrol2.rs ships the canonical shape.
Profiles & scripts
profiles/<id>.json: serde-serialised ControlMatrix for CC-to-parameter bindings the driver does not claim. Loading replaces the active matrix in one step.
scripts/controllers/<id>.lua: LED feedback, scene switching, anything user-customisable. Runs after the driver via the existing on_cc hook.
Build & test
Brume is a Rust workspace. The shipped target is Linux aarch64 (CM5), and contributors can build, run, and test the full instrument on any modern Linux machine.
Setup
Rust 1.87 or newer. The Linux development libraries Brume needs at minimum are ALSA (libasound2-dev on Debian/Ubuntu) and libxkbcommon-dev for the iced UI. A cargo build error on a missing system library names what to install next.
Build
cargo check --workspace for fast compile-only verification. cargo build --release -p brume-main for the runnable binary.
Run
cargo run -p brume-main launches the iced UI in a window. No CM5 needed: the on-screen keyboard plays through your default audio device, a USB MIDI controller routes through the engine if connected, and the full DSP runs. This is the loop for iterating on patches, scripts, signal flow, or UI changes.
Test
cargo test --workspace runs the DSP, protocol, modulation, transport, and controller-driver suites. Per-crate runs work too: cargo test -p brume-engine-runtime for the engine, cargo test -p brume-midi-io for MIDI and the control-surface registry.
CM5 deployment
Two paths land Brume onto a Compute Module 5: brumectl install for end users with a release artifact, and scripts/deploy-cm5.sh for maintainers iterating on a feature branch. Both paths cross-build aarch64 on the workstation and push the binary over SSH; the CM5 itself never compiles.
For end-user installs from a release tag:
cargo run -p brumectl --release -- install <device-host>
brumectl install fetches the matching brume binary and factory-preset tree from the GitHub Releases artifact for the version of brumectl you’re running, then runs through the staged install on the device: stop the service, place the binary at /usr/bin/brume with cap_sys_nice set so the audio thread can elevate to SCHED_FIFO, lay out the factory presets under /usr/share/brume/factory/, and restart.
For dev iteration on a CM5 you’re developing against:
scripts/deploy-cm5.sh
The script cross-compiles via cargo-zigbuild against an aarch64 sysroot synced from the device, scp’s the resulting binary to /tmp/brume.new, then runs the same atomic chain brumectl install uses on the device side: stop the service, install the new binary, apply cap_sys_nice, swap it into place, and restart. The first run takes about 60 seconds; subsequent edits land in roughly 30 seconds. The factory preset tree pushes alongside the binary so the dev path mirrors what the release path delivers.
All DSP code is in-tree under crates/dsp-core/, so the deploy flow is a plain cross-build + push + service restart with no out-of-tree sibling directories to keep synchronised.
Contributing
Each commit addresses one concern. Changes touching 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 is to build clean and run the change end-to-end before pushing.
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 hands off to the iced runtime. The rest of the workspace is reachable from those two files.
Agents
Brume is built to be worked on with AI coding agents, and the repository ships the metadata they need. If you drive development through a harness — Claude Code, Codex, Cursor, OpenCode — point it at this material before it edits anything.
AGENTS.md at the repository root is the always-loaded floor for any agent. It carries the load-bearing invariants (unsafe_code = "forbid" workspace-wide, no allocations or syscalls on the audio thread, atomic commits, schema-versioned persistence), a crate-by-crate map of the workspace, and an index of the procedural skills. It defers to CONTRIBUTING.md for house style and PR flow rather than duplicating them. CLAUDE.md is a symlink to AGENTS.md, so Claude Code — which looks for CLAUDE.md — reads the same content with no second copy to drift; harnesses that follow the AGENTS.md convention read it directly.
Procedural playbooks live under .agents/skills/, one directory per skill with a SKILL.md whose description an agent matches against intent, so a playbook loads only when a task calls for it: brume-getting-started (orientation), brume-audio-thread-review (the allocation/syscall/panic/denormal checklist for the audio path), brume-add-parameter, brume-schema-version-bump, brume-lua-script, brume-cm5-deploy, and atomic-commits.
Confirm your harness has actually loaded this before it starts editing — an agent working blind produces changes that violate the invariants and fail CI (the unsafe ban and the build/test matrix are enforced; the rest is house style a reviewer will hold you to). If your harness auto-discovers AGENTS.md or CLAUDE.md you’re covered; if it doesn’t, have it read AGENTS.md first, and name the relevant scenario when you want a specific skill loaded.
AGENTS.md, symlinked to CLAUDE.md), on-intent skills under .agents/skills/, and CONTRIBUTING.md for the human-facing rules. Make sure your agent has the floor loaded before it writes code.