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 thanMAX_BLOCKare processed in sub-blocks. - not lock or block. Communication is through wait-free
rtrbsingle-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 becrossbeam_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).rtrbnever waits.Session::senddrops 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:
- The session builds whatever the engine needs (a
TrackDsp, anArc<Vec<RegionDsp>>, anInsertDsp, aDelayLine) on its own thread and sends it in aCmd. - At the start of the next callback,
Engine::applyswaps the new object in. - The object it replaced is sent back as a
Garbagevalue.Session::idledrains the garbage channel on the main thread, where dropping (and freeing) is safe. - If the garbage ring were ever full,
Engine::discardleaks 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::processholds aNoDenormalsguard (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: forSHED_SECS(250 ms)processoutputs silence without processing (sheddingcounts the remaining callbacks), so the thread can sleep again. Left alone, a real-time thread that never sleeps hitsRLIMIT_RTTIME(about 200 ms) and the kernel kills the app with SIGXCPU.EngineStatus::overloadscounts 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_outputrunsEngine::processinsidecatch_unwind, because a panic unwinding into the driver’s C callback would abort the process. On the worker pool each strip has acatch_unwindof its own (pool.rs), whichever thread runs it. Either way a panicking block is silenced and counted inEngineStatus::engine_errors;Session::engine_error_warningturns new errors into a tickwarning(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()indsp/track.rssilences 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_outputrecords the plug-in’s uid inEngineStatus::bad_audio_uid, countsbad_audio, and resets a built-in effect so it recovers.Session::bad_audio_warningnames the plug-in and track in a toast, at most every 5 s. The final mix bus goes throughcontain()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 (
smoothinprocess_block), so fader moves, mutes and send changes don’t click.snap_gainsjumps 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::feedusesfetch_maxon the float bits (non-negative floats order like their bit patterns), andtakeswaps 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::renderempties the meters afterwards, so a bounce doesn’t light them.