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:capture is a PipeWire Audio/Source (stagebox in); reac:playback is a PipeWire Audio/Sink (mix out, re-encoded to REAC).
  • openmixer (TS) never touches a REAC byte. It only wires the reac:capture / reac:playback ports into its mix graph via pw-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-host plus the LV2 plugins for inserts. Also compiled, also on the audio thread.
  • 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: REAC encode/decode, the SCHED_FIFO cadence pacer, the raw AF_PACKET egress.

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.