Architecture manual

This manual describes how openmixer is put together: the layers, why they are drawn where they are, and how data moves through them. It is paired with a decision log that records each significant choice as a standalone ADR.

Throughout, built marks something that exists in packages/ today and designed marks something documented as a plan but not yet implemented. The two are kept apart deliberately — the architecture is stable, but only part of it is wired.

The one idea

Every digital mixer fuses three things that do not have to be fused: the control surface you touch, the mix engine that does the DSP, and the audio I/O that gets sound in and out. openmixer separates them behind a single device-neutral model and lets you mix and match:

   control surfaces                 canonical model                  I/O + engines
   (north, thin clients)            (single source of truth)         (south, sinks)

   web UI (Nuxt/Vue)  ┐                                         ┌─ software engine
   touch / mouse / kbd ┼─ intents ─▶  @freemixer/core  ──fan──▶ ┤    (native C DSP
   MCU / X-Touch ──────┤              model + engine    out     │     + mod-host / LV2)
   another desk ───────┘              capability map            ├─ REAC stagebox I/O
                                      event fan-out             ├─ console adapter
                                                                │    (Midas / X32 / Roland)
                                                                └─ …

A control source emits a canonical intent ("move input/3's fader to 0.7"). The engine holds the canonical state, applies it, and fans the resulting change out to every connected listener. A sink is either a device adapter (drives a real console over its own protocol) or the software audio engine (hosts LV2 plugins and routes audio). The surface and the engine never know which sink is behind the model — only the adapter does.

This is the part worth owning: the neutral model in the middle, and the adapters around it. Add a console, a stagebox, or a surface by adding an adapter, not by touching the core.

The layers

1. The canonical model — @freemixer/core (built)

core is the vocabulary everything else speaks. It has zero device specifics.

  • ChannelId = { kind, index }. kind is one of input, fxReturn, aux, mix, matrix, cue, main, fxSend, dca, muteGroup, mixMinus (packages/core/src/model.ts is authoritative); index is 1-based. A send is not a special type — it is a fader at an input × output intersection.
  • ChannelStrip carries the per-channel state: fader, mute, solo, pan, gain, phantom, polarity, eq, dynamics, inserts, and sends.
  • FaderLevel = { position, db }. Position is an opaque 0..1 surface scalar; dB is carried alongside as a derived read. The device-raw taper stays private to each adapter behind a FaderScale (ADR 0011).
  • MixerTopology is what an adapter reports on connect: the model name, the channel list, stereo pairs, a capability map (has(id, feature)), and a send matrix spec. Capabilities are mandatory — no desk has every feature, so surfaces grey out what a given device cannot do rather than erroring.
  • MixerAdapter / MixerReceiver are the device-facing contracts. Writes are promises (setFader, setMute, …); reads are a push observer (onFader, onMeters, …) because real desks stream state unsolicited after you subscribe.
  • MixerEngine is the hub's heart. It holds the canonical state for one bound device, forwards intents to the adapter, and is the adapter's MixerReceiver, so device-pushed state and surface-pushed state share one path and one fan-out.

Routing one source to one sink — "the Midas surface drives the X32" — is just subscribing one engine's events and calling another engine's control methods. No special case.

2. The sinks (south)

There are two kinds of sink, both behind the same MixerAdapter contract.

Console adapters (built, varying maturity) translate the canonical model to a real desk's remote protocol. They are the only code that knows a protocol:

  • adapter-midas — Midas PRO Series over OSC/UDP (port 10002). The most complete; ported from a working Python proof-of-concept. Fader/mute/solo/gain/name work both directions; cross-point sends and read-side EQ/dynamics/meters are still open.
  • adapter-x32 — Behringer X32 / Midas M32 over OSC/UDP (port 10023). Substantial: fader/mute/pan/gain/send/name plus /xremote keep-alive; setSolo and read-side EQ/dynamics/meters are open. It absorbs the X32's quirks (e.g. /mix/on is ON=unmuted, inverted to the canonical "muted").
  • adapter-roland — Roland M-5000 over the RCS control plane. A documented stub: fully wired to the interface, but every place the real RCS wire format plugs in is marked TODO(hardware) and isolated behind a transport seam. Note: this is the Roland control plane, not REAC audio transport — those are separate.

The software audio engine (built) — @freemixer/audio-engine plus @freemixer/pipewire-native — is the mixer rather than controlling one. It presents the same north-facing MixerAdapter contract, so the engine drives it identically to a console: a web fader moves a software channel exactly as it moves a Midas channel.

The control face is SoftwareMixerAdapter (software-adapter.ts), reached through adapter-loader.ts under the adapter name software: gain/mute are PipeWire node volume, never a mod-host gain plugin. Fader, mute, solo (setSolo) and pan (setPan) are all live on it.

  • The structural DSP — per-strip gain/fader/pan/polarity, summing, the EQ bank, the gate/comp atom, native delay and reverb — is openmixer's own C pw_filter node in @freemixer/pipewire-native (mixer.c, mixer_rt.c, mix_dsp.h). The older path that realised buses as PipeWire filter-chain nodes (bus-node.ts, eq-node.ts) still exists as a fallback; the live rig runs the native node (ADR 0002, ADR 0009).
  • LV2 plugin inserts are a separate layer: an ordered chain hosted by mod-host (ChainManager / planChain), reconciled declaratively (ADR 0006). Which of the two layers owns what, and why they must not be conflated, is native DSP vs mod-host inserts.
  • Audio is patched by a declarative desired-graph reconciler (DeclarativeAudioGraph) over PipeWire's pw-link (PipeWireBackend), in the style of Zynthian's zynautoconnect (ADR 0005).

3. The I/O (the southern edge)

The target deployment feeds the software engine from a Roland REAC stagebox over native REAC — not AES67, not an M-5000 console (ADR 0001). The stagebox's inputs arrive as a 40-channel PipeWire source; the mixed buses go back out as a PipeWire sink that re-encodes REAC to the stagebox outputs, which feed the PA.

The REAC work lives in a sibling repository, reac-pw (with libreac), behind a hard boundary: the PipeWire node. openmixer (TypeScript) never touches a REAC byte; it only wires the reac:capture / reac:playback ports into its mix graph. The REAC receive path and the frame encoder exist and are verified, and so does the master-role handshake that lets openmixer drive a real box: cold-connect → grant → ESTABLISHED → steady 1/s heartbeat, zero drops, verified against a Roland S-0808. openmixer packages that master as a unit (packaging/systemd/reac-pw.service) and probes the box through it (packages/server/src/reac-pw-prober.ts). This is the C-for-hot-paths boundary (ADR 0009).

4. The server — @freemixer/server (built)

The server wires one chosen adapter into a MixerEngine and exposes it to browsers:

  • REST is the front door. Every entity is GET/PATCH/OPTIONS-able at /{root}/{kind}/{index}[/{sub}], one ConsoleResource declaration per entity, served by the ResourceRegistry (packages/server/src/rest-router.ts). Around 85 entities serve today, decomposed from an earlier verb-based wire that no longer exists. Any GET also takes ?watch=1 to become a live text/event-stream of that same body — no separate subscribe verb (packages/server/src/resource-stream.ts). It is the only wire: there is no WebSocket.

The server is the single source of truth (ADR 0003). Every engine event lands on the affected row's ?watch=1 stream for every watcher, including the client that caused it — so a finger on glass, a motor fader, or a device-side change all converge to the same values on every client. There is no peer-to-peer surface chatter; surfaces talk only to the server.

5. The surfaces (north, thin clients) — @freemixer/web-ui (built)

The web UI is a Nuxt 4 single-page app — a live control surface, no SSR. It renders the fader wall (level, meter, mute, solo, pan per strip), an insert rack per strip, and an auto-generated plugin editor: every control widget is chosen from LV2 metadata by one widgetFor() decision table, so there is no per-plugin UI code (ADR 0010). It is built touch-first with full keyboard navigation, pointer/wheel affordances, and ARIA roles on every control (ADR 0014).

Because the server is authoritative, a surface is intentionally thin: it sends intents and re-renders from broadcasts. Opening the surface on several devices at once gives you several synchronized views for free — that is the substrate the MCU/X-Touch surfaces (a physical fader bank with motorized, touch-sensing faders that mirrors and drives the desk over MIDI) and the multi-window model (ADR 0015) build on: each surface, window, and physical fader bank is one more client of the one server.

How a fader move flows (built path)

  1. The operator drags a fader in the web UI. The client updates its local state optimistically and sends PATCH /api/channel/{kind}/{index}/fader.
  2. The server validates the patch, derives dB from the adapter's FaderScale, and calls engine.setFader(...).
  3. The engine records the new value, emits a fader event, and calls adapter.setFader(...), which writes to the device (a console, or mod-host).
  4. The engine's event lands on the fader row's ?watch=1 stream for every watcher. Other clients update; the originating client reconciles its optimistic value against the authoritative one.

A device-originated change (someone moves a real fader) enters at the adapter's MixerReceiver, takes the same engine → broadcast path, and lands on every surface. One path, both directions.

Cross-cutting principles

  • Configuration over hard-coding. Endpoints, ports, timeouts, channel lists, strip plugin URIs, and fader tapers are all configuration with sensible defaults — nothing about a device is baked into the wiring.
  • Pure cores, injected I/O. The load-bearing logic — the chain reconciler (planChain), the graph diff (DeclarativeAudioGraph.plan), the mod-host wire codec — is written as pure functions and unit-tested with no socket and no hardware. The stateful drivers are thin shells around them.
  • Persist by name, never by id. PipeWire/JACK object ids churn across restart and replug, so routing and scenes must persist by node-name + port-name and re-resolve (ADR 0007).
  • Degrade, don't crash. A missing stagebox, an absent adapter package, a dead mod-host, an unreachable discovery NIC — each is reported and worked around, never fatal.
  • GPL-3, no AGPL copied. openmixer is GPL-3.0-or-later. MIT/BSD/GPL-2-or-later/GPL-3 code may be copied; AGPL code (MOD-UI, Eyevinn, Zrythm) is studied and re-implemented clean, never copied (ADR 0016).

What is built vs designed

Layer Built Designed (not yet built)
Canonical model full core model + engine —
Software engine native C DSP node (gain/fader/pan/sum, EQ, gate/comp, delay, reverb, RTA), mod-host insert chains, pw-link routing, summing/buses/matrix/DCA/sends a deeper bus tree (buses are single-tier — enforced in createManagedBus)
I/O REAC discovery probe, head-amp control, packaged reac-pw master unit native REAC audio transport itself stays in the sibling reac-pw repo, not here
Server REST only (~85 ConsoleResource entities, each GET/PATCH/OPTIONS-able and ?watch=1-streamable), /health, /telemetry —
Adapters Midas (mature), X32 (substantial), X-Touch MCU (largest), Roland (stub), AdapterManager registry + adapters.yaml Roland RCS wire format (TODO(hardware))
Surfaces web UI (strips, inserts, auto-editor), MCU/X-Touch, in-app multi-window layout manager, EBU-R128 metering —

Read the decision log next for the reasoning behind each of these choices, or the technical manual for the concrete interfaces, the full control protocol, and how to add an adapter, plugin, probe, or view module.