0009 — C for hot paths, with the PipeWire node as the language boundary
Status: Accepted (built) — the inventory of what is C has grown; see the 2026-07-29 note Date: 2026-06-29
Context
openmixer is a TypeScript/Node application, which is the right tool for the control plane,
the canonical model, the server, and the UI. But native REAC has a hard real-time core: a
40-channel frame must be encoded and emitted on a raw AF_PACKET socket at a rock-steady
cadence (every 125 µs at 96 kHz), with a free-running counter and the right tail bytes, or
the stagebox loses lock and the PA clicks. A GC'd, event-loop language cannot meet that
deadline reliably.
Decision
Write the hot path in C (libreac / reac-pw): the frame encoder, the cdea
master-role state machine, and the AF_PACKET egress with a SCHED_FIFO cadence pacer.
Keep everything else in TypeScript. The boundary between them is the PipeWire node:
reac:captureis a PipeWire Audio/Source (stagebox in);reac:playbackis a PipeWire Audio/Sink (mix out, re-encoded to REAC).- openmixer (TS) never touches a REAC byte. It only wires the
reac:capture/reac:playbackports into its mix graph viapw-link.
"C for hot paths, TS for the rest." The same rule explains why structural DSP is PipeWire-native nodes and inserts are mod-host (0002): the audio-rate work happens in compiled, RT-scheduled code, and TypeScript orchestrates it from outside the audio thread.
Built today: the REAC receive path (reac:capture) and the frame encoder
(reac_tx_build + emit, verified by a round-trip test) live in the sibling reac-pw
repository. The master-role handshake and the dedicated egress pacer are designed and not
yet built.
Consequences
- The RT deadline is met by code designed for it; the TS side is free to GC, await, and block without ever risking an audio dropout.
- The C/TS boundary is narrow and well-defined (named PipeWire ports), so the two sides develop and test independently. openmixer can be exercised against any PipeWire device while the REAC node is developed separately.
- It requires raw-socket privileges (
CAP_NET_RAW) and RT scheduling on the audio host — a deployment constraint, not a code constraint. - The REAC code lives in a separate repo with its own lifecycle; this monorepo depends on the ports it exposes, not on its internals.
Elaboration (2026-06-30)
The original decision named the boundary (the PipeWire node) and gave one example of what sits below it (REAC). This section settles the general rule, because new work keeps raising the same question: "should this be C or TypeScript?"
The boundary is the PipeWire node. Draw a line at the node and the answer is mechanical.
Below the line — per-sample, per-cycle, real-time — is C:
- PipeWire's own built-in filter-chain nodes wherever they suffice: summing buses, linear faders, biquad EQ/crossover, delay. This is compiled, RT-scheduled DSP we don't write.
mod-hostplus the LV2 plugins for inserts. Also compiled, also on the audio thread.- A small C node or helper — the
reac-pw/libreacpattern — only when openmixer must own custom DSP that no off-the-shelf node provides: REAC encode/decode, theSCHED_FIFOcadence pacer, the rawAF_PACKETegress.
Above the line — routing decisions, the desired-graph reconciler, sessions, adapters, telemetry aggregation, the WS server — is TypeScript. The UI is TypeScript.
Why the control plane is TS, not C
No audio flows through the control plane. It computes the desired graph and then issues
control commands — pw-link, wpctl set-volume, mod-host param_set, pw-cli set-param — and broadcasts state to clients. These fire at event rate (a fader move is
a handful of commands; telemetry is a few hertz), never at sample rate. Every per-sample
operation is already in C: PipeWire, mod-host, libreac.
So rewriting the control plane in C would buy zero audio performance — the audio thread
is already compiled and RT-scheduled — while costing development speed and testability. The
engine's logic and its ~700 fake-backed tests are cheap to write and run in TypeScript and
would be painful in C. And the failure mode people worry about doesn't apply: a Node GC
pause delays a control command by an imperceptible amount; it cannot cause a dropout
because the PipeWire and mod-host RT threads keep moving audio regardless of what the
control plane is doing.
Invariant: no per-sample math in TypeScript
Any DSP openmixer must own becomes a C node at the boundary — the libreac pattern —
never per-sample arithmetic inside the TypeScript engine. The engine today already honours
this: it delegates all DSP to PipeWire, mod-host, and libreac, and never touches an
audio sample itself. This is the line not to cross.
Control-loop latency note
A round-trip from a hardware surface, to the server (TS), to PipeWire and back is tens of
milliseconds. That is imperceptible for fader and routing changes, and what little of it
could be heard is smoothed by PipeWire Props ramps (see
live-edits-click-avoidance.md). It does not
warrant moving the control plane into C.
Note — 2026-07-29: the rule holds, the inventory under it does not
The decision — no per-sample math in TypeScript, C below the PipeWire node boundary, TypeScript above it — is intact and still honoured. Two statements made under it have gone stale in the same direction.
"Below the line" is no longer just three things. This ADR enumerates
PipeWire's built-in filter-chain nodes, mod-host plus LV2, and "a small C node
or helper — the reac-pw / libreac pattern — only when openmixer must own
custom DSP that no off-the-shelf node provides". openmixer now owns a large body
of in-monorepo C that is none of those: the mixer node itself
(packages/pipewire-native/src/mixer.c, mixer_rt.c, mixer_strip.c,
mix_dsp.h), the reverb kernels (mix_reverb.h — Freeverb room and Dattorro
plate), the delay kernel (mix_delay.h), the RTA/FFT tap
(mix_dsp.h, struct omx_rta_tap_snap) and RT load metering
(struct omx_rt_load). That growth is the ADR's own escape clause taken
repeatedly, not a violation of it — but the list reads as exhaustive and is not.
"It delegates all DSP to PipeWire, mod-host, and libreac, and never
touches an audio sample itself" was true of the engine in 2026-06 and is now
false: the engine's own C touches every sample. The boundary the sentence was
defending — TypeScript never touches a sample — is still absolute.
The "~700 fake-backed tests" figure is stale too: packages/audio-engine/src
alone carries 993, and the repo 6280 across 452 test files. The argument the
figure supports is unaffected.
The REAC status in the header ("handshake/pacer designed") described the sibling
reac-pw repo; the master-role handshake is since verified against a real
Roland S-0808 on the development rig: cold-connect through grant to the box's own
ESTABLISHED state, a steady 1/s heartbeat, and zero drops.