BRUME
vdev
Brume · Developer Guide · rev dev

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

MIDI IN CHANNEL MAP FM HARMONIC TIMBRAL GRANULAR MOD MATRIX LFO · ENV · VEL FILTER MIXER LEVEL · PAN · MUTE SEND BUSES SATURATOR CHORUS DELAY REVERB LUA FX SCRIPT FX + MASTER LIMITER · STEREO AUDIO OUT

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.

CrateRole
brume-commonCanonical types: ParameterId, OscillatorMode, BrumeError. FromStr impls at the IPC boundary.
brume-app-protocolUiToEngine / EngineToUi message enums shared between engine and UI. Designed so the audio thread never allocates to publish a message.
brume-modulationLFOs, step sequencers, transition shapes, routing table.
brume-control-modelMIDI CC bindings, channel-to-part routing, MIDI Learn state. Persisted to disk.
brume-audio-iocpal-backed output stream, device enumeration. CoreAudio on macOS, ALSA on Linux.
brume-midi-iomidir input, activity tracking, control-surface recognition, MIDI Learn capture path.
brume-engine-runtimeBrumeEngine, Part, BrumeVoice, the four oscillators, SVF filter, amp envelope, transport. Owned exclusively by the audio thread after construction.
brume-fx-chainFxSlot trait and FxChain container. Ships four built-in effects (Saturator, Chorus, Stereo Delay, Dattorro Reverb).
brume-scriptingLua 5.5 host via mlua (memory + per-tick time budget enforced), the brume API table, script loader, Lua FX slot.
brume-patch-storeJSON patch + perf library — per-engine patches and whole-instrument snapshots, versioned schema, path-traversal checks, atomic writes.
brume-settingsPer-user settings persistence.
brume-ui-nativeThe 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-rpiPi-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 over-USB 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.

ThreadResponsibility
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 mainRuns 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 inputOne or more MIDI input connections. Parses bytes, routes notes and CCs through ControlMatrix, forwards recognised control surfaces to the UI.
brume-scriptDedicated 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 (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:

  1. A new module under crates/engine-runtime/src/ implementing the oscillator.
  2. A new variant on OscillatorMode in brume-common.
  3. A new field on BrumeVoice plus dispatch inside set_oscillator_mode and process.
  4. New ParameterId variants and their routing in Part::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.

CallbackCalled 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:

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

MethodRole
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:

  1. A struct under crates/ui-native/src/controllers/ implementing ControlSurface. nanokontrol2.rs is the smallest example.
  2. One line in shipped_registry() in controllers/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.

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.

CM5 deploy

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.

Where to start 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.