0017 — A per-destination output trim, not multiple mains, is the output-gain floor
Status: Accepted. Layer 1 is built and audibly driven; layer 2 (native matrix summing) is built and live; layer 3 is sequenced. Date: 2026-07-13
Context
A bus's physical routing is bare, full-scale links: pw-link (or the native node's output
port) straight to a sink, at whatever level the bus master happens to sit. That is fine when a
bus feeds ONE destination. It is not fine when a bus fans out to several — the exact situation
that bit the operator.
The 16 dB incident (2026-07-13). A bus was fanned to two physical destinations at once: a stagebox monitor feed and the operator's RME feed. The RME feed carried a −10 dB desktop softvol of its own; the box path had no gain control at all, plus an unchosen stereo→mono fold that summed both legs at unity (+6 dB). The monitor feed therefore ran ~16 dB hotter than the RME feed, and nothing on the console could pull it down — the level lived entirely outside the mix (in a desktop mixer on one path, and nowhere on the other).
The hard constraint this exposes: it must NEVER be impossible to send a controlled gain to a physical destination. A destination with no console-side level is a trap — the operator reaches for a fader that does not exist.
The tempting "fix" is multiple mains: give the console N master strips, one per destination, each a full fader/mute/pan. It is the wrong shape. N mains means N×(fader + mute + pan) per channel, an N-fold scene explosion, and a control-surface layout that has to bank across masters that are almost always moved together. The console already has the right primitives for "the same mix at N controlled levels": DCAs (0008) couple gain across members without a sub-bus, and matrices sum buses/mains into independent outputs. Multiplying the master is not the canon answer; a per-destination trim plus matrices is.
Decision
Answer the output-gain floor in three layers, weakest-coupling first.
Layer 1 — a per-destination output trim (this ADR, built)
Every output route — the primary AND each fan-out extra — grows from a bare port list to an
OutputRoute: { ports, trimDb?, mute?, monoFoldDb? }. Absent fields read as
unity / un-muted / the default fold, so every existing session (which persisted bare port
arrays) loads losslessly and, until an operator sets a trim, nothing changes on disk or in the
signal path.
- Model (
@freemixer/core).OutputSnapshotJson.routeTo/extraRouteTocarry the new shape;normalizeOutputRoutereads the legacy bare-array form as a unity route. The stereo→mono fold carries a −6 dB-per-leg default sum law (monoFoldDb), so a correlated L≈R signal folds to ~unity instead of the +6 dB that bit the operator — kept as a route-model coefficient so the per-leg-choice UI can override it per destination. - Native DSP (
packages/pipewire-native,mixer.c+mix_dsp.h). The native mixer node's MAIN output fans to per-destination output routes, each a gain+mute+fold stage applied POST bus-master at the fan point — a word-atomic parameter like the existing master gain, ramped click-free by the sameomx_fill_gain/omx_apply_masterdiscipline (no zipper noise). Route 0 (the primary) reuses the node's ownout_L/out_Rand is trimmed in place, skipped at unity so the common single-route path stays byte-identical; extras get their ownout_<idx>ports. A mono destination folds L+R withomx_fold_gainat the −6 dB coefficient. - Server.
SoftwareMixer.setBusOutputTrim/busOutputTrimOfstore a per-(bus, sink) trim; the session capture writes a bare array at the unity default (zero churn) and the object shape once trimmed; apatchbay.outputTrimWS command sets one destination's trim and broadcasts the crosspoint state with each cell's trim. - Web UI. Each active output crosspoint cell carries a compact dB + mute affordance — the crosspoint stays a patch tick, the trim edits in a small inline editor. Scope is trim + mute; no EQ/delay.
- Reconcile drive (phase 2, built). The model half above is now made AUDIBLE: the native
reconcile (
rebuildDesiredNative, and every path that lays output links, including the sink-reappearance re-route) lays each extra destination on its OWN nativeout_<idx>port pair — never the sharedout_L/out_R— and pushes every route's trim/mute/fold into the C route table (mixerSetOutputRoute): the primary (route 0) trimmed in place, on session load AND on every livepatchbay.outputTrimchange (not only at creation), each extra on its own route. Route add/remove drivesmixerAddOutputRoute/mixerRemoveOutputRouteidempotently, so a session load re-creates the same ports deterministically across engine restarts. Unity semantics hold: a show with no trims pushes unity/un-muted on every route and keeps the primary path byte-identical (an extra MAY move to its own port at unity gain — a stereo extra is a bit-identical copy; a mono destination now folds at the −6 dB-per-leg default instead of the pre-trim +6 dB sum, which is the incident fix, not a regression). Loopback path: a per-destination output trim there would have needed a gain node per fan-out edge (apw-linkcarries no gain), so it was recorded here as a native-only feature. Moot since 2026-07: the loopback topology and itsOPENMIXER_NATIVE_MIXERgate are deleted, the native mixer always owns the graph, and the trim is always applied.
Layer 2 — matrix buses as first-class output strips (sequenced next; SUPERSEDES the fan-out)
The structural "outputs like main": a matrix is a mix of buses/mains feeding its own output,
with its own fader/EQ/inserts — the model half already exists
(MatrixSnapshotJson / MatrixSpec). Promoting matrices to full first-class strips gives the
operator N independent outputs with full processing, which is the right home for a
destination that needs more than a trim (a delayed fill, a broadcast feed with its own
limiter). Layer 1's per-destination trim is the floor under this; matrices are the ceiling.
Supersession plan. The layer-1 extraRouteTo
fan-out — one MAIN output fanning to several destinations, each with a patch-edge trim — is
TRANSITIONAL. Layer 2 REPLACES it: each destination becomes a MATRIX strip fed from MAIN
at unity, and the route's trimDb migrates to that matrix's fader (the proper, recallable,
control-surface-bankable level, with EQ/delay/limiter available on the same strip). Once a
destination is a matrix, the patch-edge trim is no longer the destination's level — it remains
only as the final safety on the matrix → physical-port patch (the "controlled gain must
always exist" floor, now a backstop under the matrix fader rather than the primary control). So
the phase-2 out_<idx> fan-out is the mechanism that keeps every destination controllable
today, deliberately built to be retired into matrix strips — not a parallel long-term routing
model. Multiple mains stays explicitly rejected (below): the answer to "N controlled
destinations" is N matrices fed from the one MAIN, never N master strips.
Migration mechanism — retired. A promotion surface
(promoteOutputToMatrix / the output.promoteToMatrix verb) existed to convert a MAIN
extraRouteTo destination into a matrix. Its only input was the extras list, and the
one-destination-per-bus rule made that data impossible: a bus goes to ONE place, and
fanning to several IS a matrix (usePatchbay.ts, operator ruling 2026-08-06). The surface, its
row, its broadcast and its debt entries were deleted; allocation now follows the one-summing-bus
one-summing-bus model. A pre-matrix session still loads —
extraRouteTo survives only as fixture history.
Layer 2 native summing, built. The matrix strip
is now a real native DSP stage, not just a model. On the MAIN mixer node
(packages/pipewire-native/mixer.c) each allocated matrix is a struct omx_matrix: a
fixed-size per-source send coefficient table, its own fader/mute/balance stage (the
master stage reused — omx_strip_eff × omx_balance_law → omx_apply_master), and its own
output ports mtx_<i>_L/_R. In on_process the matrix stage taps the post-master MAIN mix
(busL/busR, read before the primary route trims it in place, exactly like the extraRouteTo
extras), sums it via omx_mix_strip at the matrix's MAIN send coefficient, applies the matrix
fader/mute/balance, meters it, and writes its ports. What flows natively today: a matrix
fed from MAIN (send slot 0 — the loader default sources: [MAIN_BUS_ID]). Higher send slots
store + round-trip a level but carry no native audio yet, because the aux/mix buses that would
feed them live in other nodes / on the loopback path — they become native taps when those
buses are native (no table resize needed; the slots already exist). Mono matrices are deferred
(stereo only this phase). The matrix output patches through the existing crosspoint and
stays trim-guarded by the layer-1 floor on the final matrix → physical-port edge — the
supersession plan's "backstop under the matrix fader" made concrete. N-API verbs:
mixerAddMatrix / mixerSetMatrix / mixerSetMatrixPoint / mixerRemoveMatrix /
mixerMatrixMeters; mixerInfo reports each matrix's ports; the engine wraps them as
NativeConsoleMixer.{addMatrix,setMatrix,setMatrixPoint,removeMatrix,matrixMeters} +
syncMatrices(count) / matrixOutputNames() (declarative reconcile, mirroring
syncExtraRoutes).
Consistency with DCAs + mute groups. A matrix send is the
membership-with-level idiom the console already uses: a DCA is membership + a fader, a mute
group is membership + an active flag, and a matrix is its sources as members each at a
per-send level — same schema shape in @freemixer/core, same X.point / X.set WS verb
family, same SEL-driven sends-on-faders assignment flow in the UI. A matrix is NOT a third
pattern; it is the DCA/mute-group membership shape applied to audio sends into an output
strip.
Send-groups seam (follow-on, NOT built). A send group — grouped adjustment of several
channels'/sources' sends, a DCA-like overlay on send levels — attaches cleanly here: the
level pushed to NativeConsoleMixer.setMatrixPoint (and the native coefficient table) is
already the resolved per-send coefficient, so a group offset layers above the per-send
value at the TS resolve step (effective = memberSend + groupOffset, the DCA pattern applied
to sends) without changing the native table or the persisted per-send levels. That is where a
future send.group overlay would compute its contribution.
Layer 3 — reac-pw sink volume Props (transport defense in depth)
The reac-pw transport sink also exposes a PipeWire volume via node Props, so a controlled
level survives even below the console model — a last-resort floor at the wire, independent of
whether the mix model is loaded. Belt-and-braces for the "controlled gain must always exist"
constraint, not the primary control surface.
Rejected — multiple mains
N mains = N×(fader/mute/pan) per channel and a scene / control-surface explosion, for a need that DCAs + matrices + a per-destination trim already cover. Not built, not planned.
Consequences
- The hard constraint holds: a physical destination is never uncontrollable — its trim is on the crosspoint, persisted, and applied in the engine at the fan point.
- Lossless back-compat: a pre-trim session (bare port arrays) loads as unity/un-muted, and an untrimmed route re-captures as a bare array, so shows written before this feature are byte-stable.
- The native fan point is the one place per-destination gain lives, so metering, RTA and clip detection stay on the shared post-master mix (the console's actual output), while each destination is scaled downstream of them — the meter reads the mix, the trim shapes the feed.
- The −6 dB mono-fold default fixes the specific +6 dB the incident carried, while leaving the per-leg choice a clean override point.
- Layers 2–3 are additive: matrices and the transport-sink volume slot under the same
OutputRoutemodel without re-litigating the floor.
Note — 2026-07-29: the back-compat consequence did not survive; two symbols moved
The decision — one per-destination trim/mute/fold on the route, rather than multiple mains — stands and is built. Three specifics in this ADR no longer match the code.
"Lossless back-compat" is the opposite of what shipped. This ADR promises "a
pre-trim session (bare port arrays) loads as unity/un-muted … so shows written
before this feature are byte-stable", and describes normalizeOutputRoute reading
"the legacy bare-array form as a unity route". The code took a deliberate clean
break instead — the module doc comment atop packages/core/src/output-route.ts reads:
FORMAT (greenfield, clean break). … The earlier POSITIONAL shape (a bare
`[l]`/`[l, r]` array or `{ ports }`) is **NOT read back** — a session written
by an older build **drops its output routes on load** (authorized: no migration).
An operator reading this ADR would believe an older show file still loads its routing. It does not.
normalizeOutputRoute no longer exists anywhere in packages/*/src. The
accessors are outputRouteTrimDb / outputRouteMuted / outputRouteDelayMs
(all in packages/core/src/output-route.ts).
The route shape is role-keyed, not positional. { ports, trimDb?, mute?, monoFoldDb? } is now { L?, R?, trimDb?, mute?, delayMs? }, the OutputRoute
type in output-route.ts — note delayMs, which post-dates this ADR.
THE FOLD IS NO LONGER A NUMBER ON THE ROUTE (operator ruling 2026-09-07, spec
docs/design/specs/2026-07-16-per-leg-output-routing.md amendment 2026-09-07b).
monoFoldDb and its outputRouteMonoFoldDb accessor are GONE, and with them this
ADR's "kept as a route-model coefficient so the per-leg-choice UI can override it
per destination" — that override is exactly what the ruling withdraws. The figure
is MONO_FOLD_COEF = 0.5 in output-route.ts, declared once: correlated content
sums to 2 L, so the coefficient that lands it at unity is the reciprocal of that
sum and is not a per-destination choice. WHETHER a destination folds is its
endpoint's shape — both roles on one physical port — published as the boolean
monoFold on the leg row and derived from the profile-RESOLVED sink.
Two smaller drifts: mono matrices are no longer deferred — width is per matrix, via
syncMatrices(lanes: readonly NativeMatrixLane[]) (each lane carries its own
channels: 1 | 2) in packages/audio-engine/src/native-mixer.ts, a signature that has
moved twice since this ADR and will likely move again; and the native files live under
packages/pipewire-native/**src**/, with the route table since split out into
mixer_route.c / mixer_route.h.