Commit graph

8 commits

Author SHA1 Message Date
atagen
28aa099e80 profiles: ship an extended set of default profiles 2026-05-24 19:20:29 +10:00
atagen
7797f60128 fix: further layer A (per-app) glitches 2026-05-24 18:12:31 +10:00
atagen
5143c07c99 F5: document the limiter's rate-leakage caveat in PLAN §3.1
Codex's review of the v0 design correctly pointed out that PLAN's
"hard −0.1 dBTP ceiling" claim only holds at the filter's output,
not at the speaker, when the real sink runs at a non-48 kHz native
rate. PipeWire inserts a resampler at the
`filter.playback → real-sink` edge, and any polynomial /
windowed-sinc reconstruction can elevate inter-sample peaks
slightly through its own math. The elevation is small in practice
(a few tenths of a dB for a clean band-limited resampler) and the
contract still holds where headroom is in control of the graph,
but the README and §3 had been silent on the leak.

This commit only edits docs:

  - §3.1 grows a "Contract scope (caveat)" paragraph next to the
    "Never bypassed, never disabled" hard-tier description, naming
    the leak, its magnitude, and the fix-for-real (filter rate
    matching).
  - §11 picks up a new tracked follow-up entry alongside the
    other dormant items. Scope: dynamic `FILTER_SAMPLE_RATE`,
    kernel rebuild on real-sink change, Layer A's
    block-period constant goes dynamic too. Gated on a multi-rate
    hardware test bench — no point shipping the refactor without
    something to validate it against. **v1 scope.**

No code changes; no tests; clippy and tests are unaffected.
2026-05-21 18:43:58 +10:00
atagen
3427ec56fc 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.
2026-05-21 18:24:01 +10:00
atagen
244367ccb9 plan: mark phases 7 + 8 done, close BUSY-spike follow-up
§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".
2026-05-21 17:06:11 +10:00
atagen
fcf421b94c stage 6: per-app 2026-05-20 23:49:58 +10:00
atagen
ae83310772 stage 3: daemon core
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.
2026-05-19 22:15:49 +10:00
atagen
ca1910de60 stage 2 2026-05-19 16:33:09 +10:00