Commit Graph
20 Commits
Author SHA1 Message Date
mollusk 44bad7b70b Fix jitter restart and WAV size overflow 2026-06-16 17:28:41 -04:00
mollusk 20643a24de Add audio controls and focused hotkeys 2026-06-16 17:23:38 -04:00
molluskandClaude Opus 4.8 f6520b79f7 feat(audio): multitrack stem recording — Stage 2 (wire into the mixer)
Wires MultitrackRecorder into the live audio path, behind a recording_mode.

- config: RecordingMode { Mixed, Multitrack, Both } + AppConfig.recording_mode
  (serde-default Mixed, back-compat); CoreCommand::SetRecordingMode, sent at
  app startup from config.
- multitrack.rs: mic now arrives async via push_mic into an internal FIFO,
  drained one frame per end_cycle (mirrors recorder.rs) so the mic track tracks
  the cycle clock; added dir() accessor. mic is a plain WavWriter now.
- core: parallel `multitrack` slot + `is_multitrack` fast-path gate (exactly one
  of the mixed/multitrack recorders is active). SetRecording start branches on
  mode: Mixed → single-file Recorder (unchanged); Multitrack/Both → a per-session
  dir, MultitrackRecorder, and registers everyone already in the room (named,
  silence-aligned from t=0). The mixer taps each peer's RAW frame (pre-volume/
  mute/limiter) into stems and writes peer stems + mix (Both) + end_cycle per
  cycle; the capture thread pushes mic to whichever recorder; PeerJoined adds a
  late joiner's stem track. stop_recording finalizes both.

No UI yet to pick the mode (Stage 3) — defaults to Mixed, so behaviour is
unchanged until then; set recording_mode in config.json to exercise stems.
168 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:04:49 -04:00
molluskandClaude Opus 4.8 fde6f8a680 feat(audio): multitrack stem recording — Stage 1 (pure core + plan)
Scopes the differentiating "record every peer to their own synced track"
feature and lands its pure, isolated core (no live-audio wiring yet).

- docs/multitrack-recording-plan.md: scope contract + locked decisions
  (raw stems pre-volume/mute, stems + a mixed track, silence-pad late joiners).
- src/audio/multitrack.rs: MultitrackRecorder over the existing WavWriter.
  One master clock = the mixer cycle; every end_cycle() appends exactly
  FRAME_SAMPLES to every track (silence where idle) so all stems stay
  sample-aligned. add_peer back-pads a late joiner to cycle 0; track_filename
  gives fs-safe `<slug>-<shortid>.wav` (reuses sanitize_name). Optional mix
  track for "Both" mode.
- +5 unit tests: equal length across tracks, late-joiner leading silence,
  stems-only omits mix, fit() pad/truncate, filename slugging/disambiguation.

Stage 2 (wire into the mixer) is next, behind a checkpoint. 168 lib tests,
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:48:07 -04:00
molluskandClaude Opus 4.8 c899b1cb94 fix(audio): playback worker re-checks running flag so stop() can't hang (A7)
The playback worker thread looped `while running { rx.recv() }`. A blocking
recv() never re-checks the `running` flag — it only wakes on a new frame or the
sender being dropped. So when stop() set running=false and called
worker_handle.join(), the worker stayed parked in recv() and join() hung until
the frame Sender happened to be dropped. audio_probe reproduced this every run
(it calls backend.stop() while its tx is still in scope), hanging on exit; the
GUI could hang on shutdown on any teardown path that stops audio before dropping
the sender.

Fix: extract a `drain_loop` seam that uses recv_timeout(WORKER_POLL=100ms) so
the loop re-checks `running` at least every 100ms even when idle, and returns
promptly on Disconnected. stop() now joins within one poll interval regardless
of the sender's lifetime. +3 unit tests (151 lib): exits on running-flip with
the sender still alive (the exact hang case, asserted via is_finished), returns
on disconnect, and delivers frames. Verified: audio_probe now self-exits cleanly
(exit 0, "done.", no lingering process). clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:58:16 -04:00
molluskandClaude Opus 4.8 5bd32250a5 fix(audio): honor per-cycle quantum in playback (A1, crackle on non-1024 hw)
The playback RT callback pinned the PipeWire buffer to exactly one 1024-frame
quantum (the 2026-05-31 crackle fix). That is only correct when the machine's
clock.quantum is 1024 — on hardware running quantum 512 or 2048 the pinned
slice mismatches the device's per-cycle demand and the crackle returns. We just
shipped a release to a friend whose quantum is unknown, so this was P1.

Fix: enable the pipewire `v0_3_49` feature (exposes Buffer::requested(), the
graph's per-cycle quantum) and fill exactly that many frames each callback via a
new pure `frames_to_produce()` seam, with a safe ≤1024 fallback when the graph
reports 0 (never the whole slice — over-pulling past the ring depth is the
original crackle). Relax the Buffers size pin from a hard 1024 to a generous
8192-frame max so the mapped slice fits any plausible quantum; requested(), not
the buffer size, now governs per-cycle output.

Verified locally with `pw-metadata clock.force-quantum` + audio_probe at forced
quanta 512/1024/2048: each shows `underrun +0` steady, `quantum=` matching the
forced value, and callbacks/s ≈ rate/quantum — proving requested() is live (the
health line would otherwise read the 1024 fallback). +4 unit tests on
frames_to_produce (148 lib tests, clippy --all-targets clean).

Still pending (field test): one real desktop<->dopedart call through the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:49:53 -04:00
molluskandClaude Opus 4.8 68d78ff411 chore(test): drop useless vec! in recorder wav test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:00:02 -04:00
molluskandClaude Opus 4.8 c3cf00f46f feat: local call recording (your mic + incoming mix) to WAV
Opt-in recording of the full call as you experienced it. New dep-free
src/audio/recorder.rs: a canonical mono S16LE WavWriter (header patched on
finalize) plus a Recorder that buffers your transmitted mic in a bounded FIFO
and sums it, sample-aligned, with each incoming-mix frame the playout mixer
produces. The two independently-clocked streams stay aligned via the FIFO
(capped at ~200ms so drift lag can't grow without bound); silent stretches
record the incoming mix alone. Dep-free UTC timestamp -> sortable filename.

Wiring: CoreCommand::SetRecording toggles an Arc<Mutex<Option<Recorder>>> gated
by an is_recording flag (so the capture/mixer hot paths only lock while actually
recording); capture pushes post-gate mic, the mixer writes the pre-deafen mix.
Recording finalizes on stop, room leave, and room switch. UI: a Record/Stop
button in the controls and a red "● REC m:ss" pill in the room header;
core-confirmed Recording{Started,Stopped} events drive the UI flag so a failed
start can't lie. Files land in ~/peerspeak-recordings/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:48:46 -04:00
molluskandClaude Opus 4.8 90717cda37 test(audio): dense battery for the mix-bus soft limiter
Ten more cases pinning the SoftLimiter contract (Gemini, senior-audited):
sustained-loud ceiling both polarities, out_gain participation (boost + atten),
instant-attack no-overshoot, release direction/monotonicity + gradualness,
cross-call state continuity (split == continuous), empty input, extreme
i32::MIN/MAX magnitudes, and bit-exact transparency just under the ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:31:23 -04:00
molluskandClaude Opus 4.8 38c92ced62 feat(audio): mix-bus soft peak limiter
Replace the mixer's per-sample hard clamp with a lossless i32 bus sum fed
through a feed-forward soft limiter (instant attack, ~120ms release). Below
the ceiling it's transparent and sample-exact; loud multi-peer moments are
ridden down to the ceiling instead of shattering into hard-clip distortion.
State carries across frames so a sustained-loud stretch doesn't re-attack
every 20ms frame. The master output gain now applies inside the limiter so a
boost past the ceiling is limited too.

mix_frames now returns the lossless i32 sum (saturation responsibility moved
to the limiter); its tests assert losslessness, and the new limiter module
carries the saturation/transparency/release guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:27:32 -04:00
molluskandClaude Opus 4.8 8614b26824 test(audio): unit tests for parse_pw_nodes device parser
Covers the pure pw-cli parser seam: multi-node parse sorted by description
(non-audio dropped), Source=>input / Sink=>output, description-falls-back-
to-name, empty/non-audio inputs yield nothing, EOF-flush of the final block,
and incomplete blocks (no media.class) dropped. pw_cli tests 0 -> 6.

Implemented by Gemini per next-task.md; left uncommitted per the operating-
agreement default, reviewed against the real diff and re-verified (build +
clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:08:44 -04:00
molluskandClaude Opus 4.8 ec7d1a85b5 refactor(audio): extract pure parse_pw_nodes from device enumeration
Splits the pw-cli output parsing out of enumerate_audio_devices into a pure
fn parse_pw_nodes(&str) -> Vec<AudioDevice> (with a push_device helper),
leaving only the subprocess call in enumerate_audio_devices. Behavior-
preserving — same id-block boundaries, Audio/* filter, Source=>input,
description-falls-back-to-name, and sort-by-description. Creates a testable
seam (the parsing had zero coverage). Build + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:03:25 -04:00
molluskandClaude Opus 4.8 fa951e570f test(audio): edge-case unit tests for the noise gate
Covers the previously-untested branches of the NoiseGate envelope/timing:
frame_rms known values, empty-frame transmit-follows-state, disabled gate
parks the envelope open (no fade-in on re-enable), hold-window-then-release
ordering, sustained mid-level refreshes the hold, and a loud signal
re-opening a releasing gate. Gate tests 6 -> 12; test-only, no prod change.

Implemented by Gemini per next-task.md; reviewed against the real diff and
re-verified (build + clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:00:15 -04:00
molluskandClaude Opus 4.8 99bd1f0c57 feat: echo cancellation via PipeWire module-echo-cancel
Add an opt-in Echo Cancellation toggle (Settings) that routes the call
through PipeWire's module-echo-cancel (WebRTC AEC + noise suppression + AGC)
instead of running an in-process canceller. PipeWire already sees both the
mic and the speaker monitor, so it handles the echo-reference alignment for
free and we avoid a C++ DSP dependency.

src/audio/echo_cancel.rs (new):
- enable(real_source, real_sink) loads the module via pactl (aec_method=webrtc),
  bound to the chosen devices with source_master/sink_master (defaults if unset),
  waits for the virtual nodes to appear, and returns an RAII guard that unloads
  the module on drop. Best-effort pre-clean of a stale instance from a crashed run.
- EC_SOURCE / EC_SINK are the virtual cleaned-mic source and reference sink.

core: when echo_cancellation is set on Join, load the module and point capture
at EC_SOURCE / playback at EC_SINK; stash the guard in ActiveSession so it
unloads on shutdown (after the audio streams release the nodes). Any failure
logs + warns the UI and falls back to the direct devices — never blocks the call.

config: new echo_cancellation_enabled (serde default false). app: Settings
checkbox under Mic Sensitivity, applied on next room join. An ignored live
smoke test covers the real load/unload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:41:43 -04:00
molluskandClaude Opus 4.8 a7ad07d1f5 feat: smooth noise gate for mic sensitivity
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>
2026-06-01 16:35:41 -04:00
molluskandClaude Opus 4.8 039c34322c fix: eliminate playback crackle by pinning the PipeWire buffer to one quantum
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>
2026-05-31 22:35:52 -04:00
molluskandClaude Opus 4.8 7af0235736 chore: senior-review cleanup pass
- 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>
2026-05-29 15:36:55 -04:00
mollusk dac53fc2ad feat: Add persistent configuration and enumerate PipeWire audio devices 2026-05-27 16:03:07 -04:00
mollusk 5ff0201d94 feat: Add PTT, Volume Sliders, and Device Node Selection 2026-05-27 15:23:07 -04:00
mollusk 1220d94e91 Initialize project and implement decentralized voice chat client (PipeWire, Opus, Iroh, Iced) 2026-05-27 05:18:56 -04:00