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.

  • DeclarativeAudioGraph holds a { destination: [sources] } desired map.
  • plan() reads the live edges (via pw-link -l, parsed by parsePwLink), diffs them against desired, and returns only the connect/disconnect operations 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: planChain is a pure positional diff of desired-vs-loaded slots producing exact mod-host add/remove/connect/disconnect calls, 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.