0005 — Declarative desired-graph reconcile for audio routing
Status: Accepted (built) Date: 2026-06-29
Context
The audio routing — which capture port feeds which plugin, which plugin feeds which bus, which bus feeds which output — changes constantly as the operator adds inserts, re-routes sends, recalls scenes, and as I/O appears and disappears. Patching PipeWire/JACK ports imperatively ("connect A→B now") is fragile: it is order-dependent, it double-applies on retry, and it has no notion of "what should be true" to converge back to after a glitch or a replug.
Decision
Route declaratively: the engine holds the desired graph and a reconciler converges
the live graph onto it. This is the Zynthian zynautoconnect pattern, and the same shape
as a Kubernetes controller — edit state, then reconcile.
DeclarativeAudioGraphholds a{ destination: [sources] }desired map.plan()reads the live edges (viapw-link -l, parsed byparsePwLink), diffs them against desired, and returns only theconnect/disconnectoperations needed.- It only ever tears down edges whose destination it manages, so it never rips out routing set up outside openmixer (mics→monitors patched by hand).
- The same idea drives the per-strip insert chain:
planChainis a pure positional diff of desired-vs-loaded slots producing exact mod-hostadd/remove/connect/disconnectcalls, keeping unchanged prefixes stable.
The diff logic is a pure function, unit-tested against a fake backend with no socket.
Consequences
- Idempotent and self-healing: re-running reconcile is safe, and after a graph change the engine can re-assert the desired state. Reading the live graph is what makes the diff possible — the mod-host protocol alone cannot list connections.
- Scene recall becomes "set the desired graph, then reconcile" rather than a script of imperative patches.
- Two known sharp edges, recorded as TODOs in the code:
reconcile()currently disconnects-then-connects (it should connect-new-then-drop-stale, ideally suspending the node, to avoid audible glitches during re-patching); and the live-graph read needs to be robust across PipeWire id churn, which is why routing persists by name, not id (0007). - The reconciler is structural plumbing; it deliberately does not sum audio — summing is a PipeWire-native mixer node (0002).
Note — 2026-07-29: the reconcile-ordering sharp edge is fixed
Consequences records two known sharp edges as TODOs in the code, the first being
that reconcile() "currently disconnects-then-connects (it should
connect-new-then-drop-stale … to avoid audible glitches during re-patching)".
That is done. graph.ts now wires the new route live before dropping the stale one, so
the signal is never broken and no silence-gap click reaches the operator; the code
comment next to the change records the same reasoning.
The rest of the ADR — the desired-graph model, plan()/reconcile(), only
tearing down destinations it manages, parsePwLink — is current.