headroom/crates
atagen efb0c0f746 fix: kill bus-filter tremolo via larger ring + scheduling hints
Soak testing surfaced a continuous per-quantum tremolo on the
processed audio path. `pw-cli info` on the filter streams showed
`clock.quantum-limit = "8192"` (frames) — exactly matching the
ring's 16 384-sample capacity. With max buffer == ring capacity
there is zero headroom: each callback the capture pushed a full
buffer's worth into a half-empty ring (dropping the overflow) and
the very next playback callback found the ring under-filled
(zero-filling the deficit). At steady state ~32 k samples/sec
were each dropped on capture and zero-filled on playback —
audible as ~23 Hz amplitude modulation on whatever was playing.

Confirmed by plumbing `samples_starved` / `samples_dropped` (which
existed in `PlaybackState` / `CaptureState` but were never read)
through a shared `PlaybackTiming` so the AGC tick can log per-tick
deltas. Both counters climbed in lockstep, both idle and active —
the lockstep being the signature of buffer-size > ring-size.

Mitigation:
  * Bump `RING_CAPACITY` 16 384 → 65 536 (4× the documented
    max buffer × CHANNELS). Adds ~340 ms average ring latency,
    which is bad for competitive gaming but acceptable as a
    hold-the-line measure.
  * Set `node.latency = "256/48000"` on both filter halves so
    PipeWire targets a small buffer. The hint is advisory and
    the system's quantum-limit ceiling still wins, but it costs
    nothing and helps on systems with less aggressive defaults.
  * Set `node.link-group` on both halves to the same value —
    standard for `module-loopback`'s paired-stream pattern.
  * Set `audio.rate` and `node.passive = true` on the processed
    sink so it runs at the real sink's rate and follows the same
    driver. Eliminates a redundant resampler at the monitor →
    filter boundary and lands every headroom node under one
    driver (confirmed via pw-top: all show as `+` followers of
    Mbox).
  * Re-order runtime startup so the initial sample rate is
    computed before either the processed sink or the filter is
    built; both now boot at the same rate.

What this does not do: fix the underlying architectural issue.
The two `pw_stream`s communicate via an rtrb ring with no
PipeWire-graph dependency edge between them, so producer/consumer
ordering within a quantum isn't enforced — the fix is to switch
to a single `pw_filter` node (input + output ports on the same
node, ordering by construction). That rewrite is the next move;
this commit just gets the soak unblocked.
2026-05-22 10:04:45 +10:00
..
headroom-cli 8e: playback callback timing instrumentation + spike investigation 2026-05-21 16:42:46 +10:00
headroom-client stage 2 2026-05-19 16:33:09 +10:00
headroom-core fix: kill bus-filter tremolo via larger ring + scheduling hints 2026-05-22 10:04:45 +10:00
headroom-dsp filter rate matching A+B: runtime-parameterised rate at boot 2026-05-21 20:43:55 +10:00
headroom-ipc filter rate matching A+B: runtime-parameterised rate at boot 2026-05-21 20:43:55 +10:00