F3: force-bypass surround streams; generalise N-channel pairing

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.
This commit is contained in:
atagen 2026-05-21 18:24:01 +10:00
parent 03edb17180
commit 3427ec56fc
3 changed files with 78 additions and 6 deletions

13
PLAN.md
View file

@ -1114,8 +1114,17 @@ for current status per risk.
filter should source its rate from the real sink and convert on the
capture side only.
- **Surround content downmix vs. passthrough.** v0 punts: anything
>2ch is routed directly to the real sink (bypass behaviour)
regardless of profile rule. Documented behaviour.
`>2ch` is force-bypassed regardless of profile rule. The bus
filter is F32 stereo by construction and pulling a 5.1+ stream
into it would either drop the centre/LFE/surround channels (with
explicit links pairing only the first two ports) or run our DSP
on a downmix that wasn't asked for. The check fires in
`routing::evaluate` based on `PwNodeInfo.audio_channels` (parsed
from the stream's `audio.channels` property). The explicit-link
pairing in `apply_pending_routes` was generalised from `take(2)`
to `take(min(src, dst))` so wide bypass to a wide real sink links
all channels; narrower sinks let PipeWire's source-side adapter
handle downmix as usual.
---