How it works
Three threads around one chain
Section titled “Three threads around one chain”- UI thread (Slint). Every edit goes through
AppState, the single source of truth. Structural changes (add, remove, move a block, load a preset) produce aChainSpec; live changes (a knob, a bypass, an A/B or mix change) produce aChainCommand. - Chain builder thread. It instantiates the plugins and models of a
ChainSpec, off the audio thread, and hands the finished chain over, replaying the live commands received meanwhile. - JACK callback. Tuner, then effects, then looper, then the output router, with preallocated buffers. Return blocks read the inputs and Send blocks fill four send buses; the router plays the main mix, a send or an input on each output. A new chain is crossfaded in over 20 ms, its sends with it; the old one is dropped off the audio thread.
The chain on several cores
Section titled “The chain on several cores”src/audio/dsp_pool.rs keeps one DSP worker thread per core but the audio thread’s (up to 7),
started when first needed, with the scheduling class and priority of the JACK thread. The audio
thread hands them jobs through lock-free slots and waits for them within the same period, so
nothing is delayed by a period:
- Splits: the two branches of a split run at once, one of them on a worker.
- Pipeline: a long chain is cut into stages of about the same cost (from each block’s measured load). Each period is passed along in 8 slices: a stage starts a slice as soon as the stage before has finished it. Every block still sees its samples in order, so the output is the same, sample for sample, as on one core; a test checks it.
- Crossfade: during a preset change, the old and the new chain run on two cores.
Light chains stay on the audio thread, where handing work over would cost more than it saves; a
block feeding a send bus stays there too. Settings → Processing sets the workers (Auto: all of
them). pedalboard-bench (src/bin/bench.rs) times a rig of NAM blocks offline on 1 to 8 cores.
Real-time rules
Section titled “Real-time rules”The audio callback never allocates memory and never waits on a lock: it only uses try_lock and
atomics. Model loading, file reading and plugin instantiation all happen on the builder thread.
Long impulse responses are split: the first 170 ms are convolved in the callback, the rest on a
worker thread with a deadline, so the callback’s cost stays flat.
A panic in the callback (a plugin gone wrong) is caught: the chain is dropped off the audio
thread, the dry signal plays, and the builder makes the chain again. Outside tests, the code has
no unwrap or expect (Clippy enforces it): presets, impulse responses, plugin data, the store
index and archives are parsed without trusting them, and what cannot be read is skipped and
logged. Logs go through tracing, to syslog on the image.
Scenes, knobs and bypasses are commands applied to the running chain: no rebuild, so delays and reverbs ring on. Bypass uses a 10 ms ramp; delays and reverbs fade their input and let their output ring out.
Plugin views
Section titled “Plugin views”The views above a block’s controls (see Plugin views) are inferred, never configured per plugin.
- Inference (
src/plugins/view_infer.rs). The LV2 class picks the kind: an EQ forEQPlugin,ParaEQPlugin,FilterPlugin…, a level curve forCompressorPlugin,LimiterPlugin,GatePlugin, a scope for delays, reverbs, distortions, modulations and simulators. Then the controls’ names, units and named values say what each one is and which ones form a band. Anything ambiguous gives no view: the knobs alone are better than a wrong curve.cargo test views_of_the_catalog -- --nocaptureprints the view each catalog plugin gets. - Models (
src/plugins/eq_curve.rs,dynamics.rs,scope.rs). Plain functions of the control values: the EQ response from RBJ biquads, a soft-knee compression curve, a delay’s echoes. They also go back, from a point dragged to the control value (ratio_value,time_value,Level::value);control_map.rsholds the shared interpolation. - Measuring. The audio thread copies the input and output of one block, the one whose view is
shown, into a lock-free ring (
BlockTapinsrc/audio/spectrum.rs), and only while a view is shown. A 40 ms UI timer (src/block_sound.rs) reads it: spectrum (Analyzer, real FFT), peak levels and their history (src/level_history.rs), the waves in and out (waves, aligned on the latency and a rising zero crossing). - Drawing. Rust draws, Slint shows. Each view (
eq_ui.rs,dynamics_ui.rs,scope_ui.rs) turns its model into path commands and handle positions;src/plugin_view.rsholds what they share: the editor’s control values (Controls), the per-plugin cache of inferred views, path helpers and the log frequency axis. In Slint,ui/components/plot.slintholds the shared pieces (Plot,PlotPath,InOut,Handle,Caption…). Handles only report where they are dragged, as fractions of their plot (PlotEvents); Rust turns that into control values and sets them exactly as a knob would.
The OS image
Section titled “The OS image”A Buildroot image with BusyBox init, no systemd and no desktop:
- a
PREEMPT_RTkernel (Raspberry Pi 6.18, or mainline 6.18 LTS on x86_64, built for x86-64-v3); - jackd at real-time priority 80 on the audio interface, waiting for one if none is plugged;
- the UI full screen on DRM/KMS, without X11 or Wayland, restarted with the audio;
- NetworkManager, chrony and dropbear, and nothing else.
| Partition | Mounted | |
|---|---|---|
FAT PEDALBOARD |
/boot, read-only |
pedalboard.txt, authorized_keys |
| System A and B | /, read-only |
Never written while running, so a power cut cannot damage it |
| Data | /data |
Presets, models, plugins, networks; created on first boot, checked at every boot |
Updates
Section titled “Updates”Updates are RAUC bundles signed with the release key.
Each image records OS_ID, a fingerprint of everything in the system but the app. A release
whose OS_ID matches the running system is installed as an app update: the app alone,
extracted to /data/app, tried at its next start and kept once it has run 30 seconds. Otherwise
it is a system update: written to the other slot and tried once with the firmware’s tryboot
flag on the Pi, or GRUB’s environment on x86_64, then kept once the UI has run 30 seconds.
The app runs as an unprivileged user. It downloads and checks the bundle, then asks a small root service to install it; RAUC checks the signature.
Code layout
Section titled “Code layout”src/├── audio/ JACK callback, chain switcher, DSP worker pool, tuner, looper, output routing├── plugins/ lilv host, LV2 features, NAM, impulse responses, small DSP blocks,│ view inference and models (view_infer, eq_curve, dynamics, scope)├── state/ AppState: chain, presets, scenes, setlists, session├── presets/ Preset and setlist storage├── tone3000/ TONE3000 account, library, pictures├── midi.rs MIDI parsing, the Helix-style map, clock├── update.rs Release check, download, request to the OS updater├── plugin_view.rs, block_sound.rs, eq_ui.rs, dynamics_ui.rs, scope_ui.rs│ Plugin views: shared drawing, the sound of the block shown, each view└── …_ui.rs Glue between each Settings card or page and the UI├── bin/ pedalboard-splash (boot logo), pedalboard-bench (multi-core timing)ui/ Slint UI: pages, components, themenative/ C wrapper around NeuralAudioos/ Buildroot tree: boards, packages, init scripts, RAUC