Skip to content

How it works

  • UI thread (Slint). Every edit goes through AppState, the single source of truth. Structural changes (add, remove, move a block, load a preset) produce a ChainSpec; live changes (a knob, a bypass, an A/B or mix change) produce a ChainCommand.
  • 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.

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.

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.

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 for EQPlugin, ParaEQPlugin, FilterPlugin…, a level curve for CompressorPlugin, 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 -- --nocapture prints 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.rs holds 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 (BlockTap in src/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.rs holds 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.slint holds 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.

A Buildroot image with BusyBox init, no systemd and no desktop:

  • a PREEMPT_RT kernel (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 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.

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, theme
native/ C wrapper around NeuralAudio
os/ Buildroot tree: boards, packages, init scripts, RAUC