Codex flagged that routing was channel-blind and the explicit-link
pairer hardcoded `take(2)`. For a 5.1 stream the consequences
depended on the route decision: Route::Processed silently dropped
the centre, LFE, and both surround channels (only FL/FR linked to
the stereo processed sink); Route::Bypass to a 5.1-capable real
sink had the destruction pass kill 4 of 6 links because they
weren't in the 2-pair `want_set`. Either way the user lost
channels.
PLAN §12 already documented the intent ("anything >2ch is routed
directly to the real sink, bypass behaviour, regardless of profile
rule") but the code didn't honour it. This commit makes the
contract load-bearing.
Changes
- `PwNodeInfo` gains `audio_channels: Option<u32>`, populated
in `build_node_info` from the stream's `audio.channels`
property. `None` for clients that don't advertise (older PW,
odd toolkits) — those fall through to normal rule evaluation
on the assumption they're stereo or mono.
- `routing::evaluate` short-circuits to `Route(Bypass)` when
`audio_channels > 2`, ahead of rule matching. The bus filter
is F32 stereo by construction, so this is the only honest
answer: forcing surround into the processed path either drops
channels or invents an unrequested downmix.
- `apply_pending_routes`' link pairing generalised from
`take(2)` to `take(min(src_outs.len(), target_ins.len()))`.
Stereo → stereo is unchanged (`min(2, 2) = 2`); 5.1 → 5.1
real sink now pairs all six channels; 5.1 → stereo real sink
pairs two (PipeWire's source-side adapter does the downmix,
which is its job, not ours). The destruction pass already
only nukes links to *known sinks*, so taps + non-sink
consumers stay untouched as before.
- PLAN §12 updated: the surround bullet now describes enforced
behaviour rather than aspirational documentation.
Tests
- `routing::tests::surround_streams_force_bypass_regardless_of_rule_match`
— a 6-channel stream matching the default profile's "browser
is processed" rule must still bypass.
- `routing::tests::stereo_and_mono_streams_follow_normal_rules`
— confirms the forcer only triggers for `>2ch` (None, Some(1),
Some(2) all flow through to the rule).
188 tests pass; clippy clean at -D warnings --all-targets.
Live regression check (stereo 1 kHz sine into processed): 51
non-floor meter ticks over 3 s, bus DSP path still flowing,
integrated LUFS around -28. Stereo path unaffected by the
generalised pairing.
§11 — annotated Phase 7 and Phase 8 (a–e) inline with what landed
and where, so the section now reads as a commit-log index rather
than a forward-looking todo. The "Tracked follow-ups" subsection
keeps the two trigger-gated dormant items (ephemeral overlay, sub-ms
dispatch primitive) and strikes through the filter-playback BUSY
spike — 8e's ~3 min release-build capture didn't reproduce the
~8×-baseline outlier pattern from the original 6c smoke finding,
so the work to be done collapsed to "instrumentation kept, no
code change."
§11 preamble now notes "all planned phases (0–8) are done as of
2026-05-21"; §12 picks up the same theme by pointing to
team-memory `headroom-project` for current per-risk status.
Memory bumps go to ~/.claude-amaan: `headroom-project` description
+ Phase 7 entry + revised "How to apply" (no more "next planned
work"), `headroom-routing-link-bug` moves both Phase 4k
"still-open follow-ups" to a "Closed (4l)" section, and MEMORY.md's
project hook is updated to reflect "all phases shipped, audio
threads validated alloc-free, packaging modules in place".
Phase 3 — bring up the daemon end-to-end through six checkpoints:
3a Module skeleton (error, profile, routing, runtime, pw/*)
3b Pure routing engine + 13 tests (no PipeWire dep)
3c PwContext: main loop, sigprocmask-block SIGTERM/SIGINT before
add_signal_local so signalfd actually picks them up
3d headroom-processed virtual sink via the adapter factory with
factory.name=support.null-audio-sink
3e Filter: two pw_streams (capture from monitor / playback to real
sink) with an rtrb SPSC ring between them. DSP chain
(Compressor → two-tier Limiter) runs in the playback callback.
Allocation-free; #![forbid(unsafe_code)] preserved via
bytemuck::try_cast_slice for the byte↔f32 reinterpretation.
3f Registry watcher binds the default metadata, evaluates new
Stream/Output/Audio nodes against profile rules, writes
target.object for processed routes. Self-stream guard skips
anything whose node.name starts with 'headroom-filter'.
Workspace deps added: pipewire = { features = ["v0_3_44"] } for the
modern TARGET_OBJECT key, libspa, rtrb, nix (sigprocmask), bytemuck.
Tests: 65 passing (28 dsp, 20 ipc, 4 client, 13 core). Clippy clean
at default level under -D warnings.
PLAN.md §5 renumbered to fix stale subsection labels (was 4.1–4.4
from before the per-app insertion).
Known limitations punted to Phase 4 (documented in commit history
and team memory):
- WirePlumber doesn't always honor late target.object writes once
a stream is already linked (timing race).
- preferred_real_sink dynamic tracking stubbed.
- No auto-promote of headroom-processed to system default.
- application.process.binary occasionally arrives in late metadata
updates after the global registers; routing logs show '?' until
we add a re-read.