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 / extraRouteTo carry the new shape; normalizeOutputRoute reads 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 same omx_fill_gain / omx_apply_master discipline (no zipper noise). Route 0 (the primary) reuses the node's own out_L/out_R and is trimmed in place, skipped at unity so the common single-route path stays byte-identical; extras get their own out_<idx> ports. A mono destination folds L+R with omx_fold_gain at the −6 dB coefficient.
  • Server. SoftwareMixer.setBusOutputTrim / busOutputTrimOf store a per-(bus, sink) trim; the session capture writes a bare array at the unity default (zero churn) and the object shape once trimmed; a patchbay.outputTrim WS 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 native out_<idx> port pair — never the shared out_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 live patchbay.outputTrim change (not only at creation), each extra on its own route. Route add/remove drives mixerAddOutputRoute/mixerRemoveOutputRoute idempotently, 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 (a pw-link carries no gain), so it was recorded here as a native-only feature. Moot since 2026-07: the loopback topology and its OPENMIXER_NATIVE_MIXER gate 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 OutputRoute model 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.