Skip to content

Real-time rules

Engine::process and everything it calls run on the audio thread. They must:

  • not allocate or free memory. Buffers are allocated up front (MAX_BLOCK = 1024 frames, MAX_INPUTS = 32, MAX_MIDI = 512 events per track per block). Device buffers larger than MAX_BLOCK are processed in sub-blocks.
  • not lock or block. Communication is through wait-free rtrb single-producer, single-consumer rings (commands in and garbage out, 4096 entries each; live MIDI, recorded MIDI, recorded audio, the analyzer feed) and atomics (EngineStatus, Meter, FxShared). The command and garbage queues used to be crossbeam_channels. Those can make a receiver spin while a preempted sender finishes a write; with real-time priority the audio thread then starved the sender and the kernel killed the app with SIGXCPU (RLIMIT_RTTIME). rtrb never waits. Session::send drops a command rather than block if the ring is ever full.
  • not do I/O or call into plug-in controllers. Only the processor half of a plug-in (PluginProcessor) is moved into the engine.

The pattern that makes this work is Cmd in, Garbage out:

  1. The session builds whatever the engine needs (a TrackDsp, an Arc<Vec<RegionDsp>>, an InsertDsp, a DelayLine) on its own thread and sends it in a Cmd.
  2. At the start of the next callback, Engine::apply swaps the new object in.
  3. The object it replaced is sent back as a Garbage value. Session::idle drains the garbage channel on the main thread, where dropping (and freeing) is safe.
  4. If the garbage ring were ever full, Engine::discard leaks the object rather than block.

Session::send counts commands; wait_for_engine spins (up to 1 s) until EngineStatus::cmds_done catches up, for the few places that need the engine to have applied something (stopping a recording). With no audio device running, send drains the queue by calling process with no buffers, so the graph stays current and headless tests work.

Failures on the audio thread are contained rather than fatal:

  • Denormals. Engine::process holds a NoDenormals guard (denormals.rs) for its whole run: flush-to-zero, plus denormals-are-zero on x86_64 (FPCR.FZ on aarch64), restored when the guard drops, since the callback thread belongs to the driver. The pool’s workers set it for their whole life. Decaying tails in filters, reverbs and the tuner would otherwise fall into the denormal range, where arithmetic is about a hundred times slower.

  • Overload. When callbacks take longer than the audio they produce, back to back for more than OVERLOAD_SECS (60 ms), the engine flushes notes and sheds load: for SHED_SECS (250 ms) process outputs silence without processing (shedding counts the remaining callbacks), so the thread can sleep again. Left alone, a real-time thread that never sleeps hits RLIMIT_RTTIME (about 200 ms) and the kernel kills the app with SIGXCPU. EngineStatus::overloads counts these; Session::check_overload (run with each tick) stops playback or recording and shows “System overload: …”. Offline renders (offline) are exempt, since they are meant to run flat out.

  • Panics. audio_io::build_output runs Engine::process inside catch_unwind, because a panic unwinding into the driver’s C callback would abort the process. On the worker pool each strip has a catch_unwind of its own (pool.rs), whichever thread runs it. Either way a panicking block is silenced and counted in EngineStatus::engine_errors; Session::engine_error_warning turns new errors into a tick warning (a toast), at most every 5 s. The panic message goes to the log.

  • Bad plug-in output. After every instrument, insert and Stereo Out plug-in, contain() in dsp/track.rs silences NaNs and clamps anything beyond ±1000 (+60 dBFS), so one unstable plug-in can’t poison the filters after it or reach the device. check_output records the plug-in’s uid in EngineStatus::bad_audio_uid, counts bad_audio, and resets a built-in effect so it recovers. Session::bad_audio_warning names the plug-in and track in a toast, at most every 5 s. The final mix bus goes through contain() once more before the output buffer, and the callback clamps to ±4 when converting.

Other conventions inside the engine:

  • Gains are smoothed with a one-pole filter of about 5 ms (smooth in process_block), so fader moves, mutes and send changes don’t click. snap_gains jumps straight to the target at the start of a render.
  • A muted send keeps its slot with gain 0, so it fades out instead of clicking.
  • Meter::feed uses fetch_max on the float bits (non-negative floats order like their bit patterns), and take swaps in zero, so the UI reads the peak since its last tick. The session folds those peaks into the clip indicators’ holds (session/strip_meters.rs), so none is missed between ticks; Engine::render empties the meters afterwards, so a bounce doesn’t light them.