openmixer technical manual

How openmixer is put together: the canonical model, the software audio engine, the control protocol, and how someone building or extending an installation runs and configures it. Read the architecture manual first for why the layers are drawn where they are; this is the how, grounded in the code as it stands today.

The one idea the whole system turns on: a single device-neutral model sits in the middle, the server owns it as the single source of truth, and everything else — the web surface, the software mix engine, the console adapters, the hardware I/O — plugs into it. A surface never knows which backend it is driving; only the adapter does.

The monorepo

A pnpm workspace (packages/*) on Node 22+, TypeScript with strict + noUncheckedIndexedAccess, ES modules, Vitest for tests, GPL-3.0-or-later on every file.

pnpm install
pnpm -r build      # build every package
pnpm -r test       # vitest across the workspace
pnpm -r lint
Package Role Bin
@freemixer/core The canonical model and pure logic: topology, fader scales, sends, DCA and mute-group coupling, scenes/recall-safe, sessions, console allocation, telemetry types, the in-memory MixerEngine. No I/O. —
@freemixer/audio-engine The software mixer: routing/summing/faders/buses plus mod-host LV2 inserts — SoftwareMixer and its SoftwareMixerAdapter. —
@freemixer/pipewire-native The native libpipewire client and the C DSP engine: event-driven registry (no pw-dump, no subprocesses) and the omx-console pw_filter node — per-strip gain/fader/pan/polarity/summing, EQ biquads, the gate/comp atom, native delay and reverb, the RTA FFT tap, RT load metering. —
@freemixer/catalog The curated LV2 plugin catalog + descriptors, the EQ band model and biquad maths; a Python scanner/benchmark toolchain. —
@freemixer/server Fastify: wires an adapter into a MixerEngine and serves it as the REST entity API (?watch=1 for SSE). openmixer-server, openmixer-console, openmixer-clock-drift
@freemixer/discovery Network device discovery — REAC (L2), Dante (mDNS), AES67 (SAP) probes behind the core DiscoveryProvider. —
@freemixer/graph-layout A pure, zero-dependency layered-DAG auto-arrange for patch graphs (Sugiyama-style). Framework- and language-neutral. —
@freemixer/patchbay A GraphSource abstraction plus a self-contained PipeWire (pw-dump/pw-link) backend for graph-layout. openmixer-patchbay
@freemixer/web-ui The browser mixing surface — a Nuxt 4 / Vue 3 SPA (no SSR), talks to the server over REST, with ?watch=1 SSE for live rows. —
@freemixer/website The explainer site and these manuals, statically generated for GitHub Pages. —
@freemixer/adapter-midas · -x32 · -roland Per-console protocol adapters (Midas PRO over OSC, X32/M32 over OSC, Roland M-5000 over RCS). —
@freemixer/adapter-xtouch The Behringer X-Touch / X-Touch Mini control surface as a bidirectional client — gestures in, motor faders / LEDs / V-Pot rings / scribble strips / meters out. The largest adapter. —
@freemixer/assistant A fully offline command-line assistant that drives the console through the same REST entity door, using a local ollama model for tool-calling. omx-assist
@freemixer/omx-ml The AI-feature layer: classic-DSP analyzers as CPU PipeWire nodes plus a GPU-gated out-of-process ONNX/TensorRT inference service. —

The three console adapters (Midas, X32, Roland) are peer packages: the server never statically imports them. adapter-loader.ts does a guarded dynamic import() by name, and a missing package throws a descriptive error, so the server builds and tests with no adapter present. The default is the built-in mock adapter; the software adapter is the real PipeWire mix engine. adapter-xtouch is the exception — it is a control surface, not a console sink, and the console rig imports it statically (xtouchRegistration from @freemixer/adapter-xtouch, in console-rig.ts).

The canonical model — @freemixer/core

Everything the surface and engine speak, with zero device specifics.

  • ChannelId = { kind, index }, index 1-based. kind ∈ input | fxReturn | aux | mix | matrix | cue | main | fxSend | dca | muteGroup | mixMinus (core/src/model.ts is authoritative). A send is not a special kind — it is a fader at an input × output intersection.
  • ChannelStrip holds the per-channel state: fader, mute, solo, pan, gain (head-amp), phantom, polarity, eq, dynamics, inserts, sends.
  • FaderLevel = { position, db } — an opaque 0..1 surface scalar with dB carried alongside; the device-raw taper stays private to each adapter behind a FaderScale.
  • MixerTopology is what an adapter reports on connect: model name, channel list, stereo pairs, a capability map (has(id, feature), mandatory — surfaces grey out what a device cannot do), and the send matrix spec.
  • MixerEngine holds the canonical state for one bound device, forwards intents to the adapter, and is the adapter's MixerReceiver, so device-pushed and surface-pushed state share one path and one fan-out. Routing one desk to another is engineA.subscribe(apply to engineB) — no special case.

The pure coupling logic also lives here: DCA (dca.ts — effectiveChannelGain = fader × Π active masters, with VCA nesting and a cycle guard), mute groups (mute-group.ts — effectiveChannelMute = individual OR any active group, individual state never overwritten), scenes and recall-safe (scene.ts — SavedScene, the SafeScope set, and filterSnapshotForRecall which strips safed channels/scopes before a recall is applied), sessions (session.ts), and console allocation (console-allocation.ts — the CONSOLE_PRESETS from 16:8 to 96:32, default 16:8 = 16 inputs, aux 8 / group 4 / matrix 2 / DCA 6 / mix-minus 1 / main 2).

The software audio engine — @freemixer/audio-engine

The running console is SoftwareMixer (software-mixer.ts). Its rule (ADR 0002): the structural DSP is PipeWire-native — routing, summing, faders and buses are PipeWire nodes and links — and mod-host is used only for the LV2 plugin inserts. It presents the same north-facing MixerAdapter contract as a console (SoftwareMixerAdapter), so a web fader moves a software channel exactly as it moves a Midas channel.

What is built here today:

  • Declarative routing. A desired-graph reconciler (DeclarativeAudioGraph) diffs the wanted edges against the live graph and applies the difference over pw-link (PipeWireBackend); it only tears down edges whose destination it manages, and it detects feedback loops. Routing persists by node/port name, never by id, because PipeWire ids churn across restart and replug (ADR 0007).
  • Inserts via mod-host. Each strip carries an ordered LV2 chain. planChain is a pure positional diff that keeps the unchanged prefix stable and computes the exact connect/disconnect edges; ChainManager drives it over ModHostClient (a small, unit-tested line-protocol codec). mod-host bring-up is non-fatal: if it is down the desk still runs and reports the degraded state through /health, reconnecting and re-instantiating when it returns.
  • Summing. PipeWire filter-chain bus nodes sum their inputs; the MAIN fader is a real managed node. Buses are single-tier today (an input cap of 64 per bus); a deeper bus tree is explicitly not yet built.
  • The rest of the console. Aux/group sends with position-based tap resolution, reorderable channel and bus-master chains (with an EQ↔dynamics swap for buses), stereo channel linking, N−1 mix-minus with a per-channel exclude list, the source layer and direct paths, the matrix crosspoints, DCA and mute-group coupling (as control-domain overlays, no audio nodes), a quantum lever (with an adaptive mode), live peak meters (a GStreamer meter source), and EBU R128 loudness.
  • Plugin delay compensation. setPdc / applyPdc / pdcReport measure each insert's latency and delay the shorter summing paths to match, so parallel paths stay phase-aligned.

The REAC stagebox audio path is out of this repo by design: it is C in the sibling reac-pw / libreac projects, behind a hard boundary — the PipeWire node (ADR 0009). The TypeScript side never touches a REAC byte; it only wires the named reac:capture / reac:playback ports into the mix graph through the same pw-link reconciler used for everything else. The receive path and the frame encoder exist and are verified upstream; the master-role handshake and cadence pacer that let openmixer drive a real desk are the remaining piece there.

Note the legacy AudioEngineAdapter (audio-engine/src/adapter.ts), a simpler plugin-gain adapter whose sends/solo/pan are still TODO(M3+). The running console uses SoftwareMixerAdapter, not this; don't confuse the two when reading the tree.

The catalog — @freemixer/catalog

tools/scan.py introspects installed LV2 plugins with lilv into PluginDescriptors. Each PluginParam carries what the UI needs — kind (control/patch), symbol, name, min/max/default, integer/toggle/enumeration/logarithmic/trigger flags, unit, scale points. widgetFor(param) is the entire metadata→UI bridge, one decision table that picks a dropdown, toggle, button, stepper, log knob, knob, file path, text, or readout (ADR 0010) — there is no per-plugin UI code. palette() intersects a curated list with what is installed; the curated set is ranked by measured latency within each role, so live-safe choices lead. The EQ band model (eq.ts) and biquad maths (biquad.ts) group a plugin's ports into bands from their symbols/names/units (with a small per-URI override table) and evaluate a magnitude curve — this is what the interactive EQ draws and edits.

The server — @freemixer/server

MixerServer is Fastify. It instantiates the configured adapter, wraps it in a MixerEngine, and puts every engine event on the affected row's ?watch=1 stream (ADR 0003). A watch opens with a full snapshot of the row — the root stream with a snapshot of every instance — so a late client renders a populated board off one response.

Configuration is environment-driven, validated with Zod (config.ts):

Env var Config Default
OPENMIXER_ADAPTER adapter (software / midas / x32 / roland / mock) mock
OPENMIXER_DEVICE_HOST / _PORT device endpoint 127.0.0.1 / per-adapter
OPENMIXER_WEB_HOST / _PORT HTTP bind 0.0.0.0 / 8080

Port note. The web UI does not hardcode a port. It discovers the server endpoint from GET <origin>/config; :8800 survives only as a fourth-precedence fallback applied when the page is itself served from the Nuxt dev port 3000 — the historical dev split (web-ui/app/utils/serverEndpoints.ts). A normal run needs no OPENMIXER_WEB_PORT at all; the default is 8080 (core/src/network-defaults.ts). Failing all of that, the UI falls back to its offline demo.

Structural rows actuate only when the server is built over the live SoftwareMixer surface; the plugin rows need a plugin host; the persistence rows their store directories; the adapter rows an AdapterManager; discovery a provider; telemetry a source. Each is guarded and refuses with an error when its capability is absent — OPTIONS reports what an instance currently allows — which is how the mock and console adapters cleanly refuse what they cannot do.

Running it from a checkout

pnpm --filter @freemixer/server console (or openmixer-server — the same thing) stands up a real software mixer with no hardware. It calls parseConfig directly with adapter: 'software', so it ignores OPENMIXER_ADAPTER / OPENMIXER_DEVICE_* and honours only the web-bind and catalog env. It sizes a 16:8 desk, models its virtual I/O, brings up the native mixer node, loads the catalog and builds the SoftwareMixer over that topology with the allocation seeded. Nodes are torn down on SIGINT/SIGTERM.

There was a second bin, openmixer-demo-lab, which did all of that and then auto-patched the host's detected mics and application streams onto input channels. It was deleted in 2026-07: it was never packaged, it built its console on the loopback topology this engine no longer uses, and — having neither the engine lock nor the single-engine graph guard — a lab instance started next to a running console was exactly the node-name collision those two guards exist to prevent.

OPENMIXER_CATALOG points at the plugin catalog JSON; the built-in default is the @freemixer/catalog package's own data/catalog.json (package-relative), so any checkout or install resolves it — a host that was never scanned just loads an empty catalog. Run the scanner (packages/plugin-qualify/tools/scan.py) to populate it.

openmixer-server boots the console — the only rig. It used to choose by rig.preset between bare (the plain server, no engine) and demo, later console; that axis collapsed in 2026-07 because nothing ever selected bare, which was nonetheless the default. openmixer-console (and its former name openmixer-demo) is now an exact synonym of openmixer-server, kept for muscle memory. A JSON config file is named with --config <file> or OPENMIXER_CONFIG; the bundled packages/server/config/console.config.json is the shipped default install config (the RPM's) and is empty — it has nothing to select. Retired preset names still parse and are ignored, with a startup line saying so. Precedence: config file < env (OPENMIXER_CONSOLE, OPENMIXER_GIG, OPENMIXER_CATALOG, OPENMIXER_DEMO_SOURCES map onto the rig section); the web binding runs its own per-field chain, persisted > CLI > env > config file > default, into which the file's web block enters at the bottom — the same file-under-env order.

The control protocol

REST is the front door. Every entity — a channel's fader, a bus, a DCA, a mute group, a matrix point, a session, a scene, an adapter, a discovery device, a telemetry row, and so on — is declared once as a ConsoleResource and served at /{root}/{kind}/{index}[/{sub}] by the ResourceRegistry (packages/server/src/rest-router.ts, console-resources.ts). GET reads it, PATCH writes it, OPTIONS reports what this instance currently allows, and any GET takes ?watch=1 to become that same body as a live text/event-stream — there is no separate subscribe verb. Around 85 entities serve today, decomposed from an earlier verb-based wire that no longer exists.

This is the whole of the wire — there is no WebSocket, no frames, no verbs. Every write is a REST patch on the fact's own row, and every read rides that row or its ?watch=1 stream. packages/server/src/dispatch-conformance.test.ts holds the property mechanically at the source, and packages/web-ui/app/one-intake-conformance.test.ts holds it on the surface.

The graph layout engine and the patchbay

@freemixer/graph-layout is a pure, zero-dependency library: feed it a Graph (nodes with directional ports, edges between ports) and layout(graph, opts?) returns a deterministic 2-D placement — a column per signal-flow layer, nodes ordered to minimise edge crossings, stereo halves kept adjacent — via a Sugiyama-style pipeline (break cycles, assign layers with a role floor + longest path, minimise crossings with the median heuristic, pair stereo mates, assign coordinates). followSignal(graph, node) returns a node's whole upstream + downstream path for hover-highlighting. Determinism is a hard requirement: equal input yields byte-identical output. It has no DOM, PipeWire, or framework dependency by design, so the same algorithm can be ported to helvum (Rust) or qpwgraph (C++).

@freemixer/patchbay binds that engine to real audio. It defines a GraphSource (snapshot, subscribe, connect, disconnect) with three implementations — a live PipeWireGraphSource (pure pwDumpToGraph over pw-dump, links via pw-link, hot-plug by polling), an HttpGraphSource for the browser, and a MockGraphSource for demos and tests. PatchbayHttp exposes the three operations the graph tab needs over REST — GET /patchbay/graph, POST /patchbay/link, POST /patchbay/unlink — which the mixer server mounts, and which the standalone openmixer-patchbay bin also serves from a bare node:http server with no mixer attached (OPENMIXER_PATCHBAY_HOST / _PORT, default 127.0.0.1:8890). The web-ui Graph tab renders the same shared GraphCanvas over whichever source is reachable, falling back to the demo graph. Serving the graphical canvas statically from the standalone bin is a documented follow-up in the spec — the intended path is a Graph-only web-ui bundle, not a second renderer.

The surface — @freemixer/web-ui

A Nuxt 4 SPA (no SSR — it is a live control surface). It is intentionally thin: components read state from module-scoped composables (useMixer, useStructural, useChain, useSends, useScenes, usePatchbay, useGraph, useTalkback, useCatalog, useTheme, usePersonality, …) and send intents through them — never a second wire, never a second authoritative copy of state (ADR 0015). Every LV2 control renders through the shared param widgets (ParamControl over Encoder/Knob/EnumSelect/NudgeField/ToggleSwitch), and every control has keyboard, wheel, an ARIA role, and a double-click reset (ADR 0006/0014). The surface can run with no server via clearly-marked offline mocks in several composables, which answer reads and writes locally until a real server is attached.

Personalities are pure web-ui data (app/utils/personalities.ts + token blocks in app/assets/tokens.css) — eight of them, openmixer plus classic-analog / modern-digital / vintage / midas / roland / ssl / waves, orthogonal to the brightness theme (dark / hc / light). PERSONALITIES in that file is the list. Adding one is a CSS block plus a registry entry. The plugin editor's parameter grid sizes its column count to the parameter count instead of scrolling, and skins each panel with a texture and accent tinted to the plugin's vendor family, keyed by URI.

Running and deploying

pnpm -r build && pnpm -r test          # the whole workspace

OPENMIXER_WEB_PORT=8800 pnpm --filter @freemixer/server console   # a real software desk
pnpm --filter @freemixer/web-ui dev                               # the surface (dev)
pnpm --filter @freemixer/web-ui generate                          # the surface (static)

NUXT_APP_BASE_URL=/openmixer/ pnpm --filter @freemixer/website generate   # the site, for Pages
pnpm --filter @freemixer/website build                                   # the site, for a console

The website is statically generated, and it has two deployments: GitHub Pages under a project sub-path, and the console itself, which serves it at /help so a desk that is offline at a gig still has its manual. A prerendered page names its assets absolutely (<baseURL>_nuxt/…), so the path prefix is a build input — one config, one artifact shape, NUXT_APP_BASE_URL injected per deployment. Building for / and serving under a prefix 404s every asset and leaves the no-script fallback on screen; that was the rig's dead manual on 2026-08-05.

The console's prefix is MANUAL_MOUNT in @freemixer/core — the server mounts it, the surface deep-links through it, and packages/website's default build script injects it, so pnpm -r build on a console produces a site the console can serve. Fonts are self-hosted by @nuxt/fonts into _fonts/ at build time; nothing on the page reaches the network at runtime, which is the point of shipping it at all.

Where the site is, is derived, not configured. The server looks in /usr/share/openmixer/manual (the openmixer-manual RPM, staged by scripts/build-rpm.sh from build:console) and then in the workspace build root beside its own package, and serves the first that carries the manual. Finding neither it answers 404 and says so in the log. OPENMIXER_MANUAL_DIR exists only to override an unusual layout — a parameter the system can work out must never be one an operator has to remember, which is exactly how a rig came to point at a path one level too deep with nothing to notice.

Design principles

  • Keep the load-bearing logic pure and tested without hardware — the protocol parser, the chain diff (planChain), the graph diff, the mod-host codec, the layout engine, each adapter's address mapping. The stateful drivers are thin shells around them, with the transport behind an injectable seam so the protocol is tested with a fake runner. Things that genuinely need hardware — real REAC framing, live PipeWire latency, live-network discovery — are documented test gates verified on the mixing host, not in CI.
  • GPL-3.0-or-later, SPDX-License-Identifier on every source file. MIT/BSD/GPL code may be copied in; AGPL code is studied and re-implemented clean, never copied (ADR 0016).

Extending

  • A console adapter — a new packages/adapter-<name> implementing core's MixerAdapter, translating { kind, index } to the device's address space and absorbing its quirks there and only there; supply a FaderScale; register it in adapter-loader.ts. Test the protocol against captured bytes, not hardware.
  • A plugin — usable as soon as scan.py finds it installed (addressed by LV2 URI + port symbol, no code); add one { role, uri, label } to catalog/src/curation.ts to curate it. The auto editor renders it from metadata.
  • A view module — a Vue component reading state from a composable and sending intents through it; reuse the param widgets and give every control keyboard/wheel/ARIA/reset.
  • A discovery probe — one protocol per probe emitting DiscoveredDevices, degrading (never crashing) on a missing NIC or route; unit-tested against captured packets.

The decision log records the reasoning behind each choice.