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 },index1-based.kind ∈ input | fxReturn | aux | mix | matrix | cue | main | fxSend | dca | muteGroup | mixMinus(core/src/model.tsis authoritative). A send is not a special kind — it is a fader at an input × output intersection.ChannelStripholds 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 aFaderScale.MixerTopologyis 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.MixerEngineholds the canonical state for one bound device, forwards intents to the adapter, and is the adapter'sMixerReceiver, so device-pushed and surface-pushed state share one path and one fan-out. Routing one desk to another isengineA.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 overpw-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.
planChainis a pure positional diff that keeps the unchanged prefix stable and computes the exact connect/disconnect edges;ChainManagerdrives it overModHostClient(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/pdcReportmeasure 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 stillTODO(M3+). The running console usesSoftwareMixerAdapter, 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;:8800survives 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 noOPENMIXER_WEB_PORTat all; the default is8080(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-Identifieron 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'sMixerAdapter, translating{ kind, index }to the device's address space and absorbing its quirks there and only there; supply aFaderScale; register it inadapter-loader.ts. Test the protocol against captured bytes, not hardware. - A plugin — usable as soon as
scan.pyfinds it installed (addressed by LV2 URI + port symbol, no code); add one{ role, uri, label }tocatalog/src/curation.tsto 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.