Tzopilotl
Docs
GitHub

Audio Engine Architecture

Overview

The audio engine is a real-time system for instantiating, connecting, and processing a dynamic graph of audio processing nodes. It supports:

The engine is designed for integration with a scripting language and a synthdef compiler that generates native plugin code from graph descriptions.

System Diagram

Client Code (any thread)
    |
    begin(engine) / newNode() / connect() / go(silo)
    |
    v
thread-local CmdBundle
    |
    v  push to AtomicFifo
+-------------------------------------------+
|  RT Audio Thread                          |
|  audioCallback()                          |
|    |-- signal worker silos to start       |
|    |-- process silo 0                     |
|    |-- binary-tree mixdown across silos   |
|    |-- apply safety limiter               |
+-------------------------------------------+
    |                         ^
    v  (completed cmds)       |  (dead nodes)
+------------------+    +------------------+
| NRT Cmd Thread   |    | Dead Node Thread |
| doNRT() cleanup  |    | delete nodes     |
| (polls 25ms)     |    | (polls 25ms)     |
+------------------+    +------------------+

1. Engine (tzpl_engine.hpp/cpp)

The Engine is the top-level object. It owns:

hashed names to NodeDef pointers. All registered plugin types live here.

Engine Lifecycle

  1. newEngine(config, streamParams) — Allocates the engine, creates silos, opens the

audio device, defines the built-in "Audio Out" node, and starts worker threads.

  1. startAudio(e) — Starts the RtAudio stream. The audio callback begins firing.
  2. Client code sends commands via begin()/go()/sched().
  3. stopAudio(e) — Stops the audio stream.
  4. freeEngine(e) — Tears everything down.

Safety Limiter

The SafetyLimiter is a lookahead brickwall limiter on the master output:

Applied after all silos are mixed down but before the output reaches the audio device.

2. Silos (tzpl_silo.hpp/cpp)

A Silo is an independent, parallel audio processing context. Each silo has its own:

a dedicated thread running at real-time priority (SCHED_RR, priority 63).

Dual Node Tables

Each silo maintains two parallel hash tables (2048 bins each):

(e.g., looking up a node by ID before creating a connect command).

Nodes are added to the NRT table immediately when a newNode command is created, and added to the RT table when the AddNodeCmd executes on the RT thread. This two-table design avoids any locking on the RT thread.

Audio Processing Pipeline

Per audio callback (one buffer of N frames):

  1. Process RT commands: Pop command lists from from_nrt_ FIFO. Immediate commands

execute directly. Scheduled commands are inserted into the scheduler queue.

  1. Per-sample inner loop (for each frame):

a. processScheduledEvents() — Pop and execute commands due at the current sample time. b. sortNodes() — Re-run topological sort if connections changed. c. runNodes() — Walk the sorted list, calling processAudio() on each node. d. Copy the output node's input data to the silo's output buffer. e. Increment sampleTime_.

  1. Mixdown: Binary-tree reduction — even-indexed silos wait for their odd-indexed

sibling and sum the output buffers. This cascades until silo 0 has the final mix.

Topological Sort

Nodes are sorted via depth-first traversal starting from the outputNode_:

state), the recursion stops. This means cycles introduce a one-sample delay rather than causing an error — the feedback signal is simply one sample old.

The sort only runs when needsSort_ is true (set by connect/disconnect operations).

Inter-Silo Synchronization

3. Nodes (tzpl_node.hpp/cpp)

A Node is an instance of a synth plugin within a silo. It contains:

custom fields (C-style inheritance).

sub-node. User nodes have arbitrary positive IDs.

Ports

**InPort** (input port):

the plugin reads from this buffer.

OutPort's destination list for fan-out tracking.

**OutPort** (output port):

all destinations (fan-out).

Zero-Copy Connection Mechanism

When connect(outPort, inPort) executes:

inPort->srcPort_ = outPort;
inPort->node_->synth->inlets[inPort->index_] = outPort->dataBuffer_;

The inlet pointer is redirected to point directly at the source's output buffer. No data is copied during audio processing — the downstream node reads directly from the upstream node's output buffer.

When disconnected, the inlet pointer reverts to the InPort's own dataBuffer_:

inPort->srcPort_ = nullptr;
inPort->node_->synth->inlets[inPort->index_] = inPort->dataBuffer_;

Node Lifecycle

  1. Creation (NRT): Node::setupSynth() allocates the tzpl_SynthData via

funs.alloc(), sets up the inlet/outlet/control pointer arrays, creates port objects with their own data buffers, calls funs.init(), and inserts the node into the NRT node table.

  1. Activation (RT): AddNodeCmd::doRT() inserts the node into the RT node table and

sets rt_active = true.

  1. Deactivation (RT): RemoveNodeCmd::doRT() removes the node from the RT node table.
  2. Deletion (NRT): The node is either deleted in RemoveNodeCmd::doNRT() or pushed

to the dead_nodes_ FIFO for the dead-node thread to delete.

4. NodeDefs and the Plugin System

NodeDef (tzpl_node.hpp)

A NodeDef describes a type of node. It is stored in the engine's defs_ hash table. Key fields:

Plugin ABI (../shared/tzpl_plugin_abi.h)

The plugin ABI is a pure C interface shared between the engine and the synthdef compiler. It defines:

engine/node pointers, inlet/outlet/control arrays, sample rate, and sample duration. Plugins extend this by placing tzpl_SynthData as the first member of their own struct.

FunctionRequiredPurpose
allocYesAllocate a new instance
freeYesDeallocate an instance
initYesInitialize after allocation (sample rate is set)
uninitNoCleanup before deallocation
resetNoReset state
processAudioYesProcess one sample frame
processEventsNoProcess pending events
eventNoHandle an event
noteOnNoStart a polyphonic note
noteOffNoRelease a note
allNotesOffNoRelease all notes
noteSetParamsNoSet note parameters by index-value pairs
noteSetParamRangeNoSet a range of note parameters

rate (const/init/reset/event/audio), and channel count.

load function.

Plugin Loading

Plugins are .dylib shared libraries. The loading protocol:

  1. Engine scans a directory for files matching *_synth.dylib.
  2. dlopen loads the library.
  3. dlsym looks up a symbol named "load" of type void (*)(Engine*).
  4. The load function calls addNodeDef(engine, info) to register the plugin.

A single plugin can also be loaded by name via loadDef(engine, dirPath, defName).

Signal Types and Type Checking

Connections require type compatibility:

Applies to the direct port-to-port links the engine builds internally.

connect() requires of a user connection -- differing channel counts are adapted rather than rejected (see Channel Adaptation).

Type checking happens before any link is broken, so a rejected connect leaves whatever was already feeding the inlet in place.

5. Commands (tzpl_command.hpp, tzpl_command_subclasses.hpp)

Command Model

All mutations to the audio graph go through the command system. Commands have a two-stage execution model:

(add/remove node, connect/disconnect, set values, trigger notes).

memory allocation/deallocation (e.g., deleting removed nodes). Returns true when the command itself can be deleted.

Command Bundling

Commands are accumulated in a thread-local CmdBundle; the target silo is chosen at submit time:

  1. begin(engine) — Start a new bundle.
  2. Zero or more command calls (newNode(), connect(), etc.) — Each records a

deferred op (BundleOp) into the bundle. No silo is touched yet; the only error a builder can return is errNoActiveBundle.

  1. go(silo) — Dispatch immediately (sugar for sched(silo, 0, 0., schedImmediate)).
  2. sched(silo, clock, beat, policy) — Dispatch bound to a beat on a TempoClock slot.

At submit, the recorded ops are replayed in order against the chosen silo: node IDs and ports are validated against the silo's NRT node-table mirror, connections are type-checked, and the silo-bound objects (nodes, hidden mixers, crossfaders) are allocated. The bundle is atomic: on the first invalid op everything already materialized is freed (never-run Commands free what they own via stage_ == 0 destructor guards), the bundle is discarded, and the submit call returns the error. Submit always closes the bundle.

On success the entire bundle is pushed atomically through the from_nrt_ FIFO, ensuring that a group of related commands (e.g., create a node and connect it) execute together. Beat-scheduled bundles are re-validated on the RT thread when they fire; a command whose target no longer (or does not yet) exist is silently dropped.

Scheduling

The SchedulerQueue is a hash wheel with 1021 bins. Each bin holds a TimeSortedCommandList (doubly-linked, sorted by sample time). At each sample frame, processScheduledEvents() pops commands due at the current sampleTime_.

Command Types

CommandRT Action
AddNodeCmdInsert node into RT node table
RemoveNodeCmdRemove node from RT table; NRT stage deletes it
RemoveAllNodesCmdRemove all nodes except input/output
ConnectCmdConnect ports, optionally with crossfade
ReconnectOutputCmdMove all connections from one output to another
DisconnectInputCmdDisconnect an input, optionally with fade-out
DisconnectOutputCmdDisconnect all destinations from an output
DisconnectNodeCmdDisconnect all ports of a node
SetInputCmd<T>Set input to a constant value, optionally with crossfade
SetControlCmd<T>Set a control parameter on a node
NoteOnCmdTrigger a polyphonic note
NoteOffCmdRelease a note
AllNotesOffCmdRelease all notes
NoteSetParamRangeCmdSet a range of note parameters
NoteSetParamsCmdSet note parameters by index-value pairs

6. Cross-Fading (tzpl_xfader.hpp/cpp)

The crossfader system enables smooth transitions when connections change. It works by creating temporary nodes that are transparently spliced into the signal graph.

How It Works

When a command requests a crossfade (e.g., connect(src, dst, xfadeTime)):

  1. A temporary XFader node is created (nodeID = -1, not in any hash table).
  2. It is spliced into the graph:
  3. The XFader interpolates between old and new over xfadeTime seconds.
  4. When the fade completes:

This entire lifecycle is automatic and happens within the RT thread.

XFader Variants

VariantInput 0Input 1Use Case
XFadeTwoOld source (connected)New source (connected)Replacing one connection with another
XFadeInConstant (normalled value)New source (connected)Fading in from silence/default
XFadeOutOld source (connected)Constant (normalled value)Fading out to silence/default
XFadeSetOld constantNew constantChanging a setInput value smoothly

Fade Curves

Seven interpolation curves are available:

CurveDescription
fadeLinearStraight line: a + x*(b-a)
fadeExponentialExponential: a * pow(b/a, x)
fadeSmoothstepHermite S-curve: x*x*(3-2*x)
fadeEqualPowerConstant-power crossfade (cubic approximation)
fadeOutInFade out first, then fade in (V-shaped dip)
fadeEaseInCubicSlow start, fast end
fadeEaseOutCubicFast start, slow end

6a. Hidden Helper Nodes

Besides crossfaders, two other node kinds are created by the engine itself. All of them have nodeID == -1, live in no hash table, and never appear in the topology shadow -- the graph view shows the user's connection, not the machinery behind it.

Fan-In Mixers (tzpl_mixer.hpp/cpp)

Connecting a second source to an occupied inlet splices in a mixer that sums its inputs and drives the destination; InPort::mixerNode_ points at it. A mixer has a fixed number of input slots (4). When they are all taken, a further connect chains another mixer in front as the new head, holding the old head in one of its slots -- fan-in is unbounded. Chaining never moves an existing link, which is what makes it safe while crossfaders are running into slots. When one source is left the chain collapses back to a direct connection (deferred while a fade is still in flight, since the fader reads a slot's buffer).

Channel Adaptation (tzpl_chanadapt.hpp/cpp)

Any outlet can drive any inlet of the same element type and rate. If the channel counts differ, an adapter node is spliced in immediately after the source, so everything downstream of it -- crossfader, mixer, destination -- sees matching types:

destination channel; stereo into a quad inlet lands as (L, R, L, R).

8-channel source into a stereo inlet mixes down to (a0+a2+a4+a6, a1+a3+a5+a7).

Channel counts are required to be powers of two, so the index wrap is a mask -- the same assumption the synthdef compiler's codegen makes when it wraps channel indices.

An adapter belongs to exactly one connection and retires itself (disconnects and goes to the dead-node queue) as soon as either end is unlinked. Silo::unlink is the single funnel where that is detected, which imposes one rule on the rest of the engine: when re-routing a source, connect the new link before breaking the old one, or an adapter in that source's path will retire out from under the rewire.

7. Polyphonic Voice Management (main.cpp — Voicer template)

The Voicer<MaxVoices, NumParams, RowsOrCols> template class provides polyphonic voice allocation for plugins that support noteOn/noteOff:

are released, steals the oldest active voice.

voices) or VoicesInRows (scalar-friendly, voices are contiguous).

The VoicerTest in main.cpp demonstrates a 32-voice FM/waveshaping synthesizer using this system.

8. Threading Model

Threads

ThreadRolePriority
Audio callbackSignals workers, processes silo 0, mixdown, safety limiterReal-time (system)
Worker threads (1..N-1)Each processes one siloSCHED_RR, priority 63
NRT command threadRuns doNRT() on completed commandsNormal (polls 25ms)
Dead node threadDeletes nodes pushed from the RT threadNormal (polls 25ms)
Client threadsSend commands via begin()/go()/sched()Normal

Inter-Thread Communication

Client thread  --[AtomicFifo: from_nrt_]--> RT thread
RT thread      --[AtomicFifo: to_nrt_]----> NRT cmd thread
RT thread      --[AtomicFifo: dead_nodes_]-> Dead node thread

All three FIFOs are lock-free SPSC (single producer, single consumer) queues based on the Le, Guatto, Cohen, Pop algorithm (SBAC-PAD 2013).

Real-Time Safety Guarantees

NRT threads and passed to the RT thread via FIFOs.

background thread.

exceptions from crashing the audio thread.

floating-point values from the output buffer.

9. S-Expression Parser (tzpl_sexpr.hpp/cpp)

The engine includes an s-expression parser for text-based command input. An s-expression is parsed into sexpr::Item values — a variant of bool, int64_t, double, Symbol, std::string, or std::vector<Item>.

Example command format:

(sched 100
  (newNode sinosc 101)
  (setInput (101 0) (300))
  (connect (101 0) (0 0)))

A binary serialization format (Builder/ListBuilder) is partially implemented for compact encoding of s-expressions.

10. Built-In Plugins (main.cpp)

Several plugins are defined directly in main.cpp for testing:

PluginDescriptionInputsOutputs
VoicerTest32-voice FM/waveshaping synth with pan, envelope, driveNone2ch (stereo) f32 audio
SinOscSine oscillator2: freq (f32), amp (f32)1: out (2ch f32 audio)
AddOpAddition operator2: a (2ch f32 audio), b (2ch f32 audio)1: out (2ch f32 audio)
MulOpMultiplication operator2: a (2ch f32 audio), b (2ch f32 audio)1: out (2ch f32 audio)

11. Client API Summary (tzpl_client_interface.hpp)

Engine Lifecycle

Command Bundling

Graph Mutation

Parameter Control

Polyphonic Notes

Buffers (declared, not yet implemented)