headroom/crates
atagen df8af6c4d2 4k: routing establishes explicit links, not just target.object
Phase 5 smoke-tested the monitor TUI and surfaced that the bus DSP
never sees signal: bus meters stay at the LUFS floor / -200 dBTP
even when `headroom status` reports a stream as route=processed.
The root cause is in routing, not the TUI.

Why writing target.object alone wasn't enough

  The daemon's routing engine wrote `target.object` on the stream
  node and relied on WirePlumber to (re-)link the stream to the
  declared sink. That works for streams the daemon creates itself
  (`headroom-filter.playback`): the `pw_stream` carries
  target.object at connect time, before WP sees the node global,
  so WP's first linking decision honours it.

  For external clients (pw-cat, Strawberry) the order is reversed:
  WP links the stream the instant the node global appears,
  *before* the daemon's registry callback fires
  `try_route_stream`. The metadata write that follows is a no-op
  for routing — WP doesn't re-link in response to a target.object
  change on an already-linked node. Verified manually: writing
  target.object on a live stream + severing its bad link did NOT
  cause WP to relink to the declared target. WP just left the
  stream unrouted.

What this commit changes

  RoutingState now tracks `Link` registry globals (`links_by_id` +
  `outbound_links_by_node` reverse index) and Audio/Sink globals
  by name (`sinks_by_name` now also carries `headroom-processed`,
  not just the real-hardware sinks). On every routing decision —
  `try_route_stream`, `apply_pw_command(RouteStream)`, and the
  bypass-retarget pass inside `adopt_new_real_sink` — the daemon
  also enqueues a `PendingRoute` for the source node.

  Two enforcement paths:

  - **Fast vigilance** in `try_capture_link`: when WP creates a
    new link out of a managed stream that lands on a different
    Audio/Sink, the daemon calls `registry.destroy_global(link_id)`
    immediately. Links to non-sinks (Layer A taps, other
    downstream consumers) are left alone — Layer A owns those.

  - **50 ms drain loop** in `apply_pending_routes`: for each
    pending route, once the source's output ports and the target
    sink's input ports are visible on the registry, the daemon
    destroys any remaining outbound link landing on the wrong
    sink and creates the desired link via `link-factory` (new
    `create_routing_link` helper — non-passive variant of the
    existing `create_explicit_link` Layer A uses). The owned
    `Link` proxies live in `managed_route_links` keyed by source
    node id; dropping them tears the links down via
    `object.linger = "false"`.

  `target.object` writes are kept (cheap hint that helps fresh
  pw_streams and documents intent) but are no longer the source
  of truth.

Verified

  All 185 tests still pass; clippy clean at -D warnings
  --all-targets.

  Live smoke (pw-cat /dev/zero of a 1 kHz sine at -20 dBFS into
  `--target headroom-processed`):
  - Before: pw-cat:output → Mbox:playback directly; bus meters
    pinned at floor, integrated_lufs = -200, true_peak = -200.
  - After: `routed pw-cat → headroom-processed` followed within
    50 ms by `explicit routing link established`; pw-link confirms
    pw-cat:output → headroom-processed:playback (+ the Layer A
    tap link, preserved). Bus meters show momentary -28 → -16
    LUFS, true_peak around -34 to -19 dBTP, compressor GR -2.6 dB,
    limiter GR -6.7 dB — i.e. the bus DSP chain is processing
    signal end-to-end for the first time.
  - Layer A tap creation logs exactly once (vs. the
    create/destroy fighting loop the first cut had before
    `enforce_link_for_managed_stream` learned to skip non-sink
    destinations).

Known limits not addressed here

  - `default.audio.sink` reassertion by WP. The daemon still
    writes `default.audio.sink = headroom-processed` but WP's
    session policy may rewrite it back. With explicit links, this
    is now mostly cosmetic — new streams whose target.object
    matches headroom-processed will be routed correctly via the
    same enforcement path even if default is something else. The
    metadata side will be tightened later if it turns out to
    matter operationally.

  - A spurious filter.playback → processed:playback feedback link
    still appears in the live graph (the bus filter's own output
    being linked back to its sink). Suspected source: a leftover
    rule on the filter node. To investigate separately; doesn't
    currently affect signal flow because filter capture sees
    signal from the real producer.
2026-05-21 15:36:15 +10:00
..
headroom-cli 5: monitor TUI + wire fill-ins 2026-05-21 13:35:27 +10:00
headroom-client stage 2 2026-05-19 16:33:09 +10:00
headroom-core 4k: routing establishes explicit links, not just target.object 2026-05-21 15:36:15 +10:00
headroom-dsp 4g: bus meters publishing + housekeeping 2026-05-21 10:29:38 +10:00
headroom-ipc 5: monitor TUI + wire fill-ins 2026-05-21 13:35:27 +10:00