Replace the per-frame hard-cut noise gate with a stateful envelope gate
(src/audio/gate.rs):
- Hysteresis: opens at the slider threshold, closes only below 0.6x that,
so speech near the threshold doesn't flap the gate.
- Attack/release: per-sample gain ramp (5ms open, 80ms close) instead of a
click — the gate fades rather than dropping frames outright.
- Hangover: holds the gate open 200ms after the level drops, so word tails
and brief pauses aren't chopped.
A fully-closed frame still reports don't-transmit, preserving the original
bandwidth win of not sending pure silence (receiver jitter buffer conceals
the gap). The live slider value is read per frame so changes apply
immediately. Settings slider gains a one-line hint. 6 unit tests cover
attack shape, hysteresis, hangover-then-release, disabled pass-through, and
the closed-gate no-transmit path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The playback stream was negotiated with a ~256ms (12288-frame) maxsize
buffer. The sink drains the graph quantum (1024 frames) per cycle, so one
of our buffers lasted ~12 cycles and `process` was called only ~4x/sec,
each time asking us to fill all 12288 frames -- far more than the 200ms
(9600-sample) playout ring could ever hold. So ~half of every buffer was
silence-fill: a steady ~46% underrun, audible as constant crackle. This
is a consumer-side buffer-size bug, upstream of production pacing, which
is why earlier mixer-pacing attempts never moved the numbers.
Fix: pass an explicit SPA_TYPE_OBJECT_ParamBuffers param on connect,
pinning buffer size to one 1024-frame quantum (2048 bytes mono S16LE).
PipeWire now hands us a quantum-sized buffer ~47x/sec, the ring satisfies
every callback, and slice.len()/stride equals the quantum so we never
over-pull. A node.latency hint is added too (not load-bearing on its own
-- the hint alone changed nothing; the Buffers param is the fix). Note:
pipewire 0.9.2 only exposes feature v0_3_32, so Buffer::requested() is
unreachable -- pinning the buffer size is the available lever.
Verified with the probe (quantum=1024, 47 cb/s, underrun +0 steady) and
by ear: clean 440Hz tone, no clicks. Local playout path only -- not yet
verified on a live two-peer call.
Also in this commit (the investigation scaffolding that proved it out):
- Fill-paced mixer: production tracks the hardware clock via a shared
exact ring-occupancy gauge (Arc<AtomicUsize>) kept near
PLAYBACK_TARGET_SAMPLES, replacing the fixed 20ms timer that beat
against the 1024 quantum.
- src/bin/audio_probe.rs: drives a sine through the real start_playback
path with no network/mic, for isolating the local output stage.
- playout-health logging: quiet in normal use (logs only on underrun/
dropped > 0); set PEERSPEAK_AUDIO_VERBOSE=1 for the per-second
heartbeat (audio_probe sets it automatically).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
redundant pattern matching, missing Default)
Builds clean with zero warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>