The gossip bootstrap list was derived solely from the ticket: a client
dialed the host, but "we are the host" produced an EMPTY list. So when the
room CREATOR rejoined their own room (their ticket names themselves as host)
they dialed nobody and never re-entered the swarm — the remaining peer stayed
stuck until it too left and rejoined. (First 2-human field test, 2026-06-14;
user-confirmed call-breaking, P1.)
Fix: retain the peers seen in the current room across leave (core
`known_peers`, updated by the event task; reset only when the joined ticket
changes) and pass them to `RoomState::join` as extra bootstrap targets. The
new pure `compute_bootstrap` seam unions the ticket host + retained peers,
drops self, and de-dups; address resolution rides the persistent lookup.
- `RoomState::join` gains `extra_bootstrap: Vec<EndpointAddr>` (test_net
callers pass vec![]).
- +4 unit tests on `compute_bootstrap`, incl. the host-rejoin regression case.
Tests-green (208 lib + 6 + 4 integration), clippy clean. NOT yet 2-machine
field-verified — needs a live host leave→rejoin call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "Echo cancellation" checkbox to the in-call control column (beside
Push-to-Talk). It binds to the same config.echo_cancellation_enabled flag and
ToggleEchoCancellation message as the Settings checkbox, so the two stay in
sync automatically (single source of truth; iced re-renders from it). A tooltip
is explicit that it applies on the NEXT room join — the current PipeWire-module
AEC is wired at join time and isn't hot-swappable mid-call. (A true live in-call
toggle falls out for free once the in-process EchoCanceller lands — AEC Stage E.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The top-bar room-layout button was an icon-only canvas with no label or
tooltip, while the Settings button beside it is labeled — a discoverability
and consistency gap. Wrap it in the existing tooltip pattern ("Room layout",
Position::Bottom) so its purpose is discoverable on hover.
- The Audio Devices input/output columns used spacing(4) while every other
Settings section uses spacing(8); bump both to 8 for an even vertical rhythm.
Pure presentational changes. Build + clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sticky Settings header centered its title with two horizontal_space()
flanks plus a hardcoded 90px right spacer guessing the Back button's width —
off-center if the button's rendered width drifted (documented follow-up).
Replace with equal-width Fill flanks: the Back button sits in a left Fill
segment (left-aligned), an empty Fill segment balances the right, so the
center title is geometrically centered regardless of button width. No magic
constant. Pure layout change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Geigel double-talk detection so the adaptive filter stops diverging when
the near-end talks over the far-end (the failure the Stage B demo exposed:
ERLE went negative as the filter mistook near-end voice for echo).
- src/dsp/aec.rs: DoubleTalkDetector — Geigel test (mic level vs a sliding-
window peak of the far-end, via an O(1) monotonic-deque max) with a hangover
latch. EchoCanceller — wraps Nlms + the detector, freezing adaptation while
double-talk is declared; reports the double-talk rate; DTD can be toggled off
for A/B. 5 aec tests (detector fires on near-end not echo; DTD protects a
converged filter through double-talk, +15 dB over raw NLMS).
- specview aec: now uses EchoCanceller with tunable --dtd-threshold / --hangover
/ --near-onset / --no-dtd, and prints the double-talk %.
Tuning found with the harness (white far-end, -12 dB echo, near-end onset
mid-call): DTD threshold 0.5 is the sweet spot — late-call ERLE +29.6 dB with
DTD vs -4.0 dB without (a 33 dB swing); 0.35 over-triggers (never learns), 0.7
under-triggers (drifts). Default set to 0.5. Also documents the false-positive
/miss tradeoff and that correlated (pink/speech) far-ends converge slower.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage A+B of the in-process AEC: a Normalized LMS adaptive-filter echo
canceller and a synthetic echo-path simulator, driven end-to-end by a new
`specview aec` command so cancellation can be measured fully headless on
conjured signals (no speakers/mic needed).
- src/dsp/echo_path.rs: EchoPath — synthetic acoustic echo (bulk delay +
exponentially-decaying diffuse RIR, energy-normalized to a target
attenuation) convolved over a far-end signal. Gives ground-truth echo.
- src/dsp/aec.rs: Nlms — sample-at-a-time NLMS adaptive FIR (ring-buffered
reference history, energy-normalized update, freezable for double-talk).
Cleaned output = mic minus the learned echo estimate.
- specview aec: far -> sim echo -> (+ optional near-end) -> cancel -> measure.
ERLE via oracle residual (cleaned - near), broadband + early/late
(convergence) + per voice band, optional before/after spectrograms.
- 6 new tests (path delay/attenuation, NLMS convergence >20 dB on a known
path, frozen-filter no-op, near-end passthrough). 33 dsp tests total.
Verified: single-talk pink-noise echo cancels +14.5 -> +32.0 dB ERLE as the
filter converges; double-talk (no DTD yet) drives ERLE negative as the filter
diverges onto the near-end tone — the motivating result for Stage C (DTD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a dependency-free signal-analysis toolkit and a `specview` dev CLI to
evaluate audio (especially echo cancellation) with objective numbers and a
terminal spectrogram instead of ear alone.
- src/dsp/: hand-written radix-2 FFT, Hann window, STFT, seeded test-signal
generators (sine/log-sweep/white/pink/impulse), metrics (RMS/dBFS/peak/ERLE/
per-band energy), minimal WAV read+write, and a 24-bit-ANSI half-block
spectrogram renderer (magma colormap, freq/time axes, dB legend, ASCII
fallback). Pure layers have no I/O; only `wav` touches the filesystem.
- src/bin/specview.rs: `gen` (conjure a test signal -> WAV), `show` (spectrogram
+ per-band energy summary), `erle` (broadband + per-band echo-return-loss
between a before/after pair).
- 27 unit tests (FFT correctness, ERLE landmarks, WAV round-trip, render shape).
Verified end-to-end: log sweep renders as the expected exponential curve;
a 20 dB-quieter copy reads +20.0 dB ERLE broadband and per band.
Measurement substrate for upcoming AEC refinement (no shipped-path changes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the "Recording" category to the Settings page so the recording mode is
selectable from the UI (config + core wiring already existed from Stage 2).
- AppMessage::RecordingModeSelected → persists config + sends SetRecordingMode
(takes effect on the next recording start). recording_mode_hint copy.
- iced 0.14 pick_list can't host per-option tooltips, so the three modes are
RADIO BUTTONS each wrapped in a `tooltip` (hover explains that mode) — chosen
over a dropdown so each option is self-documenting. Placed after Microphone,
using the shared section_header; output-dir note below.
- +1 config test (recording_mode round-trip + is_multitrack classification).
User-verified live (Both mode writes the expected WAVs; radios + tooltips
approved). Senior-written — Gemini's unsanctioned Stage 3 attempt was discarded.
179 tests (169 lib +1 ign, 6 reconnect, 4 transport), clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Reorganize Settings into six clearly-headed categories with a reusable
section_header (color_blue size-16 title + thin full-width divider): Audio
Devices, Microphone, Network & Privacy, Room Layout, Theme, Notifications &
Sounds. Splits the old shared Mic|Network row, drops the inconsistent inline
size-14 sublabels, and removes the hardcoded "Theme" title from theme_section
so it's rendered by the same header helper as every other section. Left-aligns
the content (was centered) so headers, dividers, and hints line up.
Presentational only; no message/logic changes. User-verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gossip `author` field was self-asserted, so an in-room member could forge
it to impersonate (chat), force-evict (Leave), or poison presence + the address
book (Announce). Now every GossipPayload is signed with the node's ed25519
secret key and verified on receipt; a forged author can't validate because the
attacker lacks the victim's key.
Done as the complete fix (forgery + replay):
- GossipPayload gains `ts` (sender-stamped) + `sig` (iroh::Signature, serde-64B);
Debug is hand-written since Signature has none.
- Signature covers domain tag + room topic_id + author + ts + msg
(`signable_bytes`): topic binding blocks cross-room replay, ts + a 2-min
freshness window block temporal replay (within-window replays are byte-
identical and de-duped by the swarm), author binding makes spoofing fail.
- New pure seams `sign_gossip` / `verify_gossip` (+ `GossipReject`); all four
outgoing broadcasts sign, the receive loop verifies-then-trusts (drops
unauthenticated/stale before any peer-map / event / address-book action).
- IrohGossipState now holds the node SecretKey + active topic bytes; callers
(core, test_net) updated.
NOTE: breaking gossip wire change — all peers must run this build (the staged
friend release + dopedart need rebuild). Node identity is ephemeral, so no
migration concern beyond rebuild.
+5 unit tests (genuine accept; forged author, tampered msg, cross-room, stale/
future all rejected). 163 lib tests (was 158), clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three findings from the first security pass:
- S3 (Medium): the peer-supplied screen-share ticket was passed to pixelpass
as the first positional CLI arg with no end-of-options guard, so a ticket
starting with `-`/`--` could be reinterpreted as a flag (argument injection).
New pure `viewer_args()` puts flags first, then a `--` guard, then the ticket
positionally; spawn_viewer uses it. +2 tests.
- S4 (Medium): peer presence display-names (gossip `Announce`, untrusted and
spoofable) were rendered unsanitized/unbounded, unlike the chat path. New
`sanitize::sanitize_name` strips bidi/zero-width format chars + control chars,
collapses whitespace, and caps at 48 chars; applied at the gossip ingest point
so every consumer gets a safe value. +4 tests.
- S1 (Low): `&id[..8]` byte-slices could panic on a short/non-ASCII id. New
panic-free `short_id()` (char-based take) replaces both slices. +1 test.
158 lib tests (was 151), clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Settings "Back" button out of the scrollable (where it sat as the
last child and scrolled off the bottom of a long page) into a fixed header
bar that stays pinned at the top while content scrolls. The header is a
styled bar with the Back button on the left and a centered "Settings" title;
the old top title and bottom Back button inside the scrollable are removed.
Pure layout change, still dispatches AppMessage::NavigateBack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The UI was hardcoded to one Catppuccin Mocha palette (color literals in
view() + theme() pinned to Dark). It now sources colours from a chosen
theme's palette, selectable in Settings.
- theme.rs (NEW): Palette (13 semantic colour roles) + AppTheme enum
(Catppuccin Mocha/Macchiato/Frappe/Latte, Dracula, Nord, Tokyo Night,
Gruvbox Dark, Solarized Light, Gruvbox Light). Pure palette()/label()/
ALL/base_theme()/is_dark() + WCAG relative_luminance()/contrast_ratio().
- config: theme: AppTheme field (serde-default Mocha) + backward-compat.
- app: theme() returns config.theme.base_theme() (iced widget chrome);
view() + with_layout_picker() source colours from the palette; new
ThemeSwatch canvas widget; a Theme section of clickable swatches in
Settings; SelectTheme applies live + persists. Canvas widgets already
take colours as data, so they re-theme for free.
Tests written alongside (+7 theme, +2 config; 135 -> 144 lib): every
palette clears WCAG AA text-on-base contrast (4.5:1) with subtext/accent
>= 3:1, is_dark matches luminance direction, variants distinct/labeled,
serde round-trips, default = Mocha.
Verified live: the Settings swatch grid renders all 10 palettes and the
whole UI re-themes (screenshot-checked Latte light + Dracula dark).
clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
X11 sessions now persist and restore the window position (window_x/y) in
addition to size. Gated to X11: Wayland's xdg-shell gives clients no way
to self-position, so we center there (and iced never emits Moved on
Wayland, so window_x/y stay None). No drift across save/restore — iced's
Moved event and Position::Specific both use the window's outer position.
Also confirmed peerspeak already runs on X11 out of the box (winit
compiles both backends and auto-selects via WAYLAND_DISPLAY/DISPLAY) and
documented X11/Wayland support in FEATURES.md.
- config: window_x/window_y: Option<i32> (serde-default None).
- app: is_wayland() + pure initial_window_position() helper; a Moved
handler records position; the close path persists it.
- tests: +3 initial_window_position (X11 restore / Wayland centers /
partial-or-missing centers), +2 config (round-trip incl. negative
coords; backward-compat load without the new fields). 130 -> 135 lib.
Verified live on X11/XWayland: saved an off-center position, the window
reopened there (not centered). clippy clean incl. --all-targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Building packaging/PKGBUILD in place leaves scratch dirs (src/, pkg/,
peerspeak/ clone), the built *.pkg.tar.* and makepkg *.log behind, and
also rewrites pkgver in PKGBUILD. Ignore the scratch outputs so an
in-tree `makepkg` no longer pollutes git status. (Cleanest is still to
build from a copy outside the repo.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a desktop/taskbar identity and a system package for PeerSpeak.
- assets/icons/peerspeak.svg: master app icon — the in-app mic glyph
over a P2P mesh of peer nodes, Catppuccin Mocha palette. Rendered to
the hicolor raster sizes (16..512) committed alongside it.
- Window/taskbar icon: embed a 128x128 straight-RGBA blob and load it
via iced from_rgba (keeps us off iced's heavy `image` feature). Set
the Wayland/X11 app_id to "peerspeak" so compositors match the window
to the .desktop launcher and show the icon natively.
- packaging/peerspeak.desktop: launcher (StartupWMClass=peerspeak).
- packaging/PKGBUILD: peerspeak-git VCS package — cargo --frozen build,
--lib check, installs the binary, .desktop, and hicolor icons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The window always opened at the hardcoded 900x760 because the size was never
persisted: run_gui hardcoded it, AppConfig had no size fields, and the Resized
handler only kept the size in memory (for divider clamping) while
exit_on_close_request:true quit before anything could save.
- AppConfig gains window_width/window_height (serde-default 900/760).
- run_gui restores them as the initial window size.
- The Resized handler mirrors the live size into config (guarded against bogus
tiny sizes); divider positions on load now clamp against the restored size
rather than a hardcoded default.
- exit_on_close_request:false + a CloseRequested handler writes the final size
once, then iced::exit() — no per-resize disk thrash.
Verified empirically that KWin/Wayland honors a client-requested initial size
(requested 1150x680 -> window reported 1150x680). Window *position* is not
restored: xdg-shell gives Wayland clients no way to set their own position.
+1 config test (default + round-trip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an 18-icon set drawn on iced canvas (no image/font dep, recolors with the
theme) — consistent with the existing LayoutThumb/GateMeter/Divider widgets.
Icons: mic, mic-off, headphones, deafen, speaker, speaker-off, monitor (share),
eye (watch), record, stop, chat, people, clock, settings, copy, leave, create,
live. Each authored in a 24x24 space, scaled to the widget, stroked with round
caps/joins.
Wired throughout the UI in place of emoji + added to the icon-less primary
buttons (mute/deafen/leave/copy/settings/create): self + peer cards (live badge,
watch, per-peer speaker mute), header (participants/timer/REC), controls
(mute/deafen/record/share/leave), launch (create/settings), settings (test mic),
drawer chat toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.
Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.
- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
(→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
so the existing gossip re-announce delivers the offer to late joiners for free
and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
+ Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).
Tests-green; the 2-machine gossip/remote path is not yet field-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Approach B: peerspeak spawns pixelpass --host --output json, scrapes the
ticket from its JSON stdout, and distributes it over the existing gossip
plane as a ScreenShareOffer; peers get a one-click pixelpass viewer.
Mutually optional, runtime-only coupling -- neither tool is a Cargo
dependency of the other; the contract is pixelpass's CLI + JSON protocol.
Video-only, separate viewer window, PATH binary discovery. Not yet built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capability list of what PeerSpeak already does, companion to
ARCHITECTURE.md, so the feature surface doesn't have to be re-derived
from the code each session. Marks each row verified / tests-green /
plumbing, and collects the outstanding 2-machine field-test debt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three in-call room layouts — 3-Column (Participants | Chat | Controls),
Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer
(Participants | Controls with a collapsible Chat panel) — chosen via one
persisted RoomLayout config setting and applied live.
Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the
launch and in-call screens opens a popup gallery (dimmed click-to-dismiss
backdrop + centered panel) of clickable schematic thumbnails; the Settings screen
shows the same thumbnails inline (no button). Thumbnails are drawn with the
canvas widget (new LayoutThumb program — colored panel boxes, blue border on the
selected one), so no image-decoding dependency is added.
Each layout's panel boundaries are draggable (DividerKind gains Controls +
ChatDrawer for the 3-column right divider and the drawer's left edge; new
clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize).
Participants width is shared across layouts but capped per layout at render time
so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set
in the dock layout won't collapse Chat in 3-column or Controls in the drawer).
The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remember last nickname: a new serde-default AppConfig.username field is
pre-filled into the launch-screen nickname field, and saved when a room is
joined or created (i.e. when the name is actually used), so it carries across
launches.
Sanitize chat: a pure sanitize_chat() drops control characters (ANSI escapes,
NUL, stray CR/LF/TAB), collapses whitespace runs to single spaces, trims, and
caps length (2000 chars). Applied to our outgoing text on submit AND to incoming
peer messages on receive — peer content is untrusted, so the sender's name and
text are both sanitized before display; empty-after-sanitize messages are
dropped. Unit tests for sanitize_chat (control/whitespace/unicode/empty + length
cap) and a config backward-compat assertion for username.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a reusable Divider canvas widget and place two in the room screen: a
vertical divider between the Participants and Controls panels (drag to resize
the Participants width) and a horizontal divider between the main row and the
Chat dock (drag to resize the dock height). The Participants panel and Chat dock
size from persisted config values; the Controls panel and main row fill the rest.
The widget reports drag motion as a pixel delta along its axis (mirroring the
GateMeter drag handling, so a drag continues past the thin strip). update()
applies the delta and clamps it: clamp_participants_width / clamp_chat_height
keep both sides of each divider above a minimum. Sizes are re-clamped on window
resize (window size tracked from window::Event::Resized) and clamped again on
load (a size saved under a different window could be out of range).
Persistence: participants_width / chat_height are new serde-default AppConfig
fields; the divider publishes PersistConfig on drag release so the final
position is written once (not per pixel). 3 clamp unit tests (incl. a tiny-window
degenerate case) + config backward-compat assertions for the new fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add room text chat riding the existing iroh-gossip topic (same layer as the
presence roster). New GossipMessage::Chat { name, text, ts }; the gossip loop
forwards it as RoomEvent::ChatMessage, core relays it to the UI as
UiEvent::ChatMessage, and RoomState::send_chat broadcasts an authored line
(display name from self-state, ms timestamp). CoreCommand::SendChat sends; our
own author is suppressed by the existing self-echo guard, so the UI echoes our
sent line locally instead.
UI: a full-width chat dock along the bottom of the room (the chosen layout) —
bottom-anchored scrollback with per-sender name colouring (green = you), an
input with Enter-to-send + a Send button, history capped at 300 lines. The room
window default grows to 900x760 so the dock doesn't squeeze the controls column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Add ARCHITECTURE.md Section 4 covering the jitter buffer's adaptive
playout delay: controller state/params, the grow/shrink/silence/overflow/
prime-timeout transitions, and a state diagram; note it in the Section 2
module map. Sections renumbered 4-7 -> 5-8 (no internal cross-refs).
Gemini-authored (junior) via the headless agy loop. Senior review caught +
fixed an inaccuracy: the original called the strategy "AIMD (multiplicative
decrease)" but the shrink is additive (-1, rate-limited by CLEAN_RUN_TO_SHRINK),
not multiplicative; reworded accordingly. Numbers fact-checked against
src/core/jitter.rs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two edge tests for the adaptive playout-delay controller:
- grown_target_requires_deeper_reprime: a disruption-grown target actually
gates the next re-prime (3 frames no longer enough once target is 4).
- overflow_resync_resets_clean_run: the MAX_BUFFERED overflow resync path
restarts the clean run.
Gemini-authored (junior), senior-reviewed against the real diff and
independently re-verified (cargo test --lib + clippy clean). Driven via the
headless agy --print --sandbox loop (resumed with --continue past the
orientation-tax timeout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two boundary assertions to format_duration_renders_mss_and_hmmss:
59s -> "0:59" (last second of m:ss form) and 3599s -> "59:59" (final
second before the output switches to h:mm:ss at 3600).
Gemini-authored (junior), senior-reviewed against the real diff and
independently re-verified (cargo test --lib + clippy clean). First task
driven through the headless `agy --print --sandbox` loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the fixed 3-frame (~60ms) playout delay with a feedback
controller that tunes depth to real network behavior, no wall clock
needed:
- Grow (+1 frame) on a late-arriving packet (one for a sequence already
played past) or a gap that forces Opus PLC — jitter beat the cushion.
- Shrink (-1 frame) after a long unbroken run of real frames — the link
is comfortably ahead. Fast grow, slow shrink (AIMD-style).
- Bounded to [2, 12] frames (40-240ms), well under MAX_BUFFERED_FRAMES.
- Benign silence (a talker pausing) emits none of these signals, so the
delay is untouched across quiet stretches — avoids the classic
"inflate delay because someone went quiet" bug.
- Prime-timeout safety net: since the mixer polls every ~20ms, prime
after ~500ms even under a grown target so a short utterance isn't held
forever and startup latency stays bounded.
No public API change; all logic stays in jitter.rs. Adds 8 unit tests
(grow/shrink, both bounds, silence-neutrality, prime timeout).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Enriches the in-room screen:
- Per-peer VU meters: a live level bar per peer card (reuses the per-peer
audio_levels stream), green while speaking, dim when idle/locally-muted.
- Your own mic meter on the self-card (reuses the in-call MicLevel), green
when transmitting, grey when muted or PTT-inactive.
- Per-peer local mute (🔊/🔇): silences a peer for you only — decoded so
their VU still moves, but not mixed. New CoreCommand::SetPeerMuted + a
locally_muted set in the core/mixer, distinct from per-peer volume.
- Header call-info: participant count + a live m:ss / h:mm:ss call timer
(dependency-free — rides the in-call event stream rather than a tick sub).
format_duration unit-tested. Build + clippy clean, 70 lib tests.
Field-verified on a real desktop<->dopedart call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the remaining state-transition edges: re-prime after an underrun goes
idle (must re-accumulate TARGET_DELAY_FRAMES, not resume on one packet),
duplicate-insert overwrite (no buffer growth), is_idle across fresh/buffering/
underrun, and overflow-resync when next_seq is already Some (playout head
snaps to the new front). Jitter tests 6 -> 10; test-only, no prod change.
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>
Adds Discord-style app-internal gain controls under each device picker:
input volume scales the captured mic (applied before the meter/gate/encode,
so it also moves the mic meter), output volume scales the mixed playback
(on top of per-peer volumes). PeerSpeak-only — no system/other-app effect.
Both persist in config (input_volume/output_volume, serde default 1.0 for
backward compat) and read live by the audio loops via f32-bit atomics, so
they take effect mid-call. Sliders apply live on drag and save on release.
The standalone mic-test monitor applies the same input gain so the test
meter reflects it. Reuses the existing apply_volume helper (unity fast-path
+ i16 saturation).
Tests: config backward-compat + round-trip for the new fields (gain math
itself is covered by the existing apply_volume tests). 65 lib tests, clippy
clean. Field-verified: input slider moves the mic-test meter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Extracts the peak-hold/throttle logic shared by the in-call capture thread
and run_mic_monitor into MicLevelMeter, and adds unit coverage:
- core: MicLevelMeter reports only after a full window, holds the window
peak, resets between windows, and reports zero for silence.
- app: GateMeter::x_to_threshold maps edges/midpoint correctly, clamps
out-of-bounds drags, and stays finite for a zero-width (pre-layout) bar.
9 new tests, all green; clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a mic input meter to Settings for gate calibration, replacing the
blind noise-gate slider with a unified Discord/OBS-style control: the bar
shows the live mic level and a draggable handle sets the gate threshold on
the same axis. Fill is green above the gate (transmitting), dim below it
(muted), with a live status word; the handle is bright red with a dark
edge so it stays legible when the green level sweeps past it.
Two level sources:
- In-call: the capture thread peak-holds the raw (pre-gate, pre-mute)
frame level and emits UiEvent::MicLevel ~10/sec.
- Off-call: a "Test mic" toggle runs CoreCommand::SetMicMonitor, spinning
up a standalone capture-only stream feeding run_mic_monitor. It shares
the backend's single capture stream, so Join tears it down first and
leaving Settings releases it; ignored while a session is active.
The gate handle drags live via NoiseGateDragging (no disk write per pixel)
and persists once on release via NoiseGateChanged. Meter axis is 0..0.3 so
a normal voice doesn't peg. Enables the iced "canvas" feature for the
custom GateMeter widget.
Build + clippy clean, tests pass. Field-verified on desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the mixer unit tests: pin apply_volume's as-i16 truncation-toward-zero
semantics (3*0.5 -> 1, -3*0.5 -> -1, not round-to-nearest), in-range amplify
without clamp, three-peer sum, mix_frames zero-padding when the output is
longer than every peer frame, and frame_level mid-range (constant 16384 ->
~0.5). Tests-only; no production change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the PeerConnecting/PeerConnected chime logic out of update()'s match
arms into two pure functions — reconnect_attempt_chime and reconnected_chime
— that own the connecting/ever_connected set transitions and return the
Option<Sound> to play, leaving the notify::play side effect in update.
Behavior-preserving; the full suite still passes.
Adds 4 #[cfg(test)] tests pinning the edge-trigger contract: a first dial is
silent, a reconnect attempt chimes exactly once and stays silent across the
supervisor's repeated redials, "reconnected" only fires after a prior link,
and a two-outage cycle chimes attempt->reconnected each time (per-outage, not
once-ever). Closes the last catalogued Tier B test gap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add unit tests for the jitter buffer's untested pure logic: seq_before's
wrapping u32 comparison — basic ordering, the u32::MAX→0 forward wrap, and
the exact < (1<<31) half-range tipping point (0x7FFF_FFFF before, 0x8000_0000
not) — and insert's MAX_BUFFERED_FRAMES overflow path (caps depth, drops the
oldest frame, resyncs the playout head next_seq to the new front). Tests-only;
no production change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the audio mixer's inline math out of the async mixer-task closure into
three pure, testable functions — mix_frames (sample-by-sample sum with i16
saturation), apply_volume (per-peer scale + clamp, unity-skip fast path), and
frame_level (normalized RMS for the UI meter). Behavior-preserving: the loop
now calls them and the full suite still passes.
Adds 12 #[cfg(test)] unit tests, notably the saturation guards: a loud mix or
a volume boost clamps to i16::MAX/MIN rather than wrapping (a plain cast would
wrap a 2x-full-scale sum to a large negative value). Also covers sum, ragged
peer-frame lengths, no-peers silence, volume unity/zero/half, and RMS bounds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add #[cfg(test)] coverage for the join-link codec and gossip wire format:
PeerSpeakTicket Display/FromStr round-trip and — the real gap —
malformed-input rejection (empty / non-base64 / valid-base64-non-JSON all
return NetError::InvalidTicket, never panic, since users paste untrusted
text here); PeerState serde round-trip; and GossipMessage::Leave /
Announce + GossipPayload round-trips. Tests-only; no production change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add #[cfg(test)] coverage for src/codec/opus_impl.rs: encode→decode
round-trip shape + signal-energy survival, decoded duration follows the
packet, and the key regression guard — decode(None)/decode(Some(&[]))
conceals exactly frame_samples per channel (960 mono / 1920 stereo),
pinning the previously-fixed 120ms-burst PLC sizing bug. Tests-only; no
production behavior change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add #[cfg(test)] coverage for src/config.rs: default round-trip,
backward-compat default-fill (old configs missing newer #[serde(default)]
fields still load), notifications_enabled default_true wiring, NetworkMode
default/ALL/Display/round-trip, and unknown-field tolerance. Derives
PartialEq on AppConfig to support equality asserts. Tests-only; no
production behavior change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Two reconnect-eviction edges were logically handled but had no explicit
assertion:
- A graceful ConnEvent::Left must emit PeerLeft *immediately* (not wait on
the grace timer). New graceful_leave_evicts_immediately asserts the leave
lands well inside the grace window.
- After a grace-timer eviction scrubs the peer from seen_connected, a later
rejoin's initial dial must be a fresh first-dial, not a reconnect that
arms a new eviction. New rejoin_after_grace_eviction_dials_cleanly covers
this (distinct from the existing graceful-leave scrub path).
Also reorder the eviction task to scrub timers/seen_connected before
emitting PeerConnectionFailed, so an observer (or a redial racing the
eviction) always sees clean internal state — this also makes the new
rejoin-after-eviction test deterministic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each log_msg opened the file in O_APPEND mode then used writeln!, which
issues a separate write() syscall per formatting fragment. O_APPEND only
guarantees atomicity per write() call, so concurrent log_msg calls from
the app's many threads/tasks could interleave their fragments mid-line.
Format the full line (timestamp + msg + newline) into one String, then
emit it with a single write_all so each log line lands as one atomic
append. No behavior change beyond non-interleaved output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings screen now follows the full window width/height instead of a
fixed 460x500 card. Device/privacy pickers and the noise-gate slider fill
the available width, and controls are laid out in 2 columns so a single
dropdown never stretches across the whole window:
- Input Device | Output Device
- Mic Sensitivity | Network Privacy (top-aligned)
- The 8 custom chime path fields remain a 2-column grid
Section spacing tightened so everything (incl. Back) fits on 1080p without
scrolling. The styled card now fills the page (max_width cap dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the conn-event handling into a testable ConnEventHandler (grace window
as a field) and add tests/reconnect_eviction.rs covering:
- second outage after a reconnect still evicts (the bug fixed in bc1a0a2;
proven to fail when the transport-arming is disabled)
- a reconnect within grace is not evicted
- a first-ever dial is not given an eviction timer
- a graceful leave scrubs seen_connected so a later rejoin dials cleanly
Behavior-preserving refactor: the conn-event task now builds a ConnEventHandler
and forwards each event to it; arm_grace_timer takes the grace Duration as a
param (production passes RECONNECT_GRACE). All existing transport/reconnect
integration tests still pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A second sustained outage after a reconnect never evicted the peer: the 45s
RECONNECT_GRACE timer was armed only by the gossip PeerConnectionLost path, but
a transport-only retained-addr reconnect leaves gossip's neighbor state stale,
so the second drop produced no new PeerConnectionLost and no timer.
Arm the grace timer from the transport ConnEvent::Connecting too (the supervisor
reliably re-emits it on every outage), gated on a new seen_connected set so a
first-ever dial isn't given an eviction clock. Route both arming sites through a
shared arm_grace_timer helper that is a no-op if a timer is already pending
(earliest drop notice sets one hard deadline; a flapping link can't reset it),
and scrub seen_connected on eviction/leave so a later rejoin starts clean.
Compiles, clippy-clean, all tests green incl. transport reconnect suite.
NOT yet field-verified — pending the two-outage laptop test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover expand_tilde (bare ~, ~/sub, absolute, relative, non-leading tilde,
~user, whitespace) and validate_custom_path (empty/whitespace, missing,
existing file vs dir via CARGO_MANIFEST_DIR, tilde-prefixed missing).
Test-only; no production code or dependency changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Section 0 bullet directing the agent to read operating-agreement.md
(team roles, task handoff via next-task.md, report via task-report.md)
before starting any task.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audible chimes for reconnect/reconnected/reconnect-failed, self-leave,
peer join/leave, and mic/deafen toggle, completing the 4-phase
notification plan (phase 1 room/peer join+leave shipped in 673b72d).
- Per-event custom WAV overrides in Settings, with ~ expansion and live
file-found/not-found validation; persisted on settings exit. Each event
falls back to its embedded default chime when no custom path is set.
- Reconnect-attempt chime is edge-triggered: fires once per disconnect,
not once per redial attempt.
- Global "enable sound notifications" toggle.
Prepends a "Section 0 — How You Work" to the system_instruction distilling the
engineering approach the agent should take: understand-before-acting, measure
before theorizing, root-cause over symptom-patching, treating compiles/tests-
pass as distinct from field-verified, surfacing design forks, honest reporting,
dependency caution, when to decline/push back, and working in durable
checkpoints. Each principle cross-references the existing project rules/sections
(Rule 1 ground-truth, Rule 3 safe-Rust, Rule 4 commit policy, Section 6 privacy
default, Sections 7-8) so it stays one coherent, non-duplicative doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds audible notifications for the basic membership events: a bright ascending
triad when you join a room, a soft two-note rise when another participant
joins, and a two-note fall when one leaves. Hooked into the app's UiEvent
handling (RoomJoined / PeerJoined / PeerLeft) so each is played locally by
whoever observes the event.
The chimes are short mono 16-bit WAVs generated by a committed stdlib-only
Python script (assets/sounds/generate_chimes.py) and embedded in the binary
with include_bytes!, so a deployed single binary is self-contained. The new
notify module materializes each to a temp file once, then plays it
fire-and-forget via pw-play (PipeWire-native; falls back to paplay/aplay) on a
detached thread that waits on the child -- never blocks the UI, never leaves a
zombie, and silently no-ops if no player is available.
Phase 1 of a larger plan; always-on for now. Later phases: reconnect-attempt /
reconnected sounds, a settings enable/disable toggle, per-event custom sounds,
and extra events (self-leave, mute/unmute, connection-lost).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field-testing the reconnect fix surfaced a follow-on bug: clicking Disconnect
(or quitting) showed the peer as yellow "Reconnecting" on the other end instead
of leaving cleanly. Logs showed the remote saw NeighborDown first and the gossip
`Leave` arrived ~15s later (broadcast then router torn down ~450ms after, so
NeighborDown — now meaning "reconnecting" — beats the slow, swarm-routed Leave).
Fix: make graceful leave a prompt, reliable TRANSPORT signal instead of relying
on gossip. On leave/quit, IrohTransport::leave() explicitly closes each live
connection with a distinguished goodbye code (GOODBYE_CODE) and aborts all
supervisors (so none linger redialing the about-to-close endpoint). The remote's
supervisor inspects the close reason: an application close with our goodbye code
=> emit the new ConnEvent::Left (evict now); a timeout/reset/other code =>
transient drop, reconnect as before. core handles ConnEvent::Left exactly like a
PeerLeft (cancel grace timer, disconnect, drop jitter, UI remove). ActiveSession
now holds the transport and calls leave() before shutting the router down.
Tracks the live connection per peer in Shared.live_conns (inserted on link up,
removed on drop) so leave() can close them. The gossip Leave path stays as a
harmless backup.
New test dialer_reports_left_on_graceful_close: a close with the goodbye code
reports Left and does NOT reconnect (mirror of the code-0 transient-drop test).
clippy clean, 4 transport tests pass. NOT yet field-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field test of the long-outage path failed: ~31s into a Wi-Fi drop the peer
vanished with no "Reconnecting..." indicator and never came back. Logs showed
the chain `Gossip NeighborDown -> Removed peer -> Transport: stopped
supervising peer`.
Root cause: a transient gossip NeighborDown was routed to RoomEvent::PeerLeft,
same as a graceful leave, so core called disconnect_peer -> supervisor.abort().
That aborted the very reconnect supervisor that was meant to redial -- before
its own reconnect loop (which re-emits Connecting and re-dials the retained
09acefd address with backoff) ever ran. The supervisor + retained-address fix
were effectively dead code in the field, which is also why the loopback tests
(they drive the supervisor directly) never caught it.
Fix: decouple a transient drop from a graceful leave.
- gossip.rs: NeighborDown now emits the new RoomEvent::PeerConnectionLost
instead of PeerLeft. A graceful GossipMessage::Leave still emits PeerLeft.
- core: on PeerConnectionLost, do NOT disconnect the peer. Keep its supervisor
alive (it redials the retained address and drives the yellow indicator) and
arm a per-peer reconnect grace timer (RECONNECT_GRACE = 45s, comfortably past
the ~30s QUIC idle timeout). The peer is evicted only if the link hasn't
recovered when the timer fires. A gossip rejoin (PeerJoined/PeerUpdated) or a
transport reconnect (ConnEvent::Connected) cancels the timer first; session
shutdown aborts all pending timers so none fire a stray eviction.
Builds clean, clippy clean, 3 transport tests pass. NOT yet field-verified --
re-test the long-outage path on a real call.
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>
The system prompt was generic Rust guidance with no project-specific knowledge,
so Gemini kept re-deriving (and re-breaking) the audio-networking design. Adds
four sections distilled from the senior-audit sessions:
- Section 5: project map — module-by-module, with the trait seams to honor.
- Section 6: audio-networking invariants — each maps to a real fixed bug
(single deterministic-initiator connection per pair; per-peer supervisor owns
the lifecycle; AbortOnDrop for Connection-holding tasks; silent-drop != close;
retain the full EndpointAddr and dial it directly; presence layer != transport
layer; seq header + jitter/PLC; non-blocking broadcast; RT-path rules; UI-event
throttling; intentional privacy posture).
- Section 7: testing gotchas — stable-address loopback misses eviction bugs,
iroh's path cache makes lookup-removal a false test, same-host instances are
invalid for outage tests, explicit close() for prompt drops, clippy/test gates.
- Section 8: offline dep docs at ~/Documents/peerspeak_docs/ + the handoff log.
Also fixes the closing line, which told Gemini to ask for "details of our new
project" on what is now a mature codebase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dialer-side supervisor dialed peers by bare EndpointId, forcing iroh to
resolve the address through the gossip-fed MemoryLookup. A transient drop that
also triggered a gossip Leave/NeighborDown purged the peer from that lookup, so
the supervisor redialed forever with "no address" (observed in the 2026-05-30
field test: ~4.5 min of "No address lookup configured; retrying in 5s").
The transport now retains each peer's full EndpointAddr (relay + direct addrs),
refreshed on join and on every re-announce (so a rejoin on a new address updates
the dial target), and the dialer dials that retained address directly — which
bypasses the lookup entirely. connect_peer now takes an EndpointAddr; core hands
over state.addr on PeerJoined and PeerUpdated.
New regression test dials and reconnects with the dialer's lookup deliberately
empty and the relay disabled, so only the retained address can carry the dial:
it passes with the fix and times out under the old bare-id behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mixer ran at the fixed 20ms playback cadence and pushed an
AudioLevels event every tick — ~50/sec — each waking the iced runtime
for a full re-render. Levels are now peak-held per peer across a 100ms
window and emitted once per window (~10/sec), cutting UI-bound events
5x. Peak-hold (rather than last-sample) means a brief speech transient
inside a window still registers, so the speaking indicator stays
responsive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio links now survive a dropped QUIC connection instead of silently
dying until the peer leaves and rejoins the room.
Transport: each peer is owned by a single supervisor task running a
connect → run → reconnect loop. The deterministic-initiator rule (lower
id dials, higher accepts) holds on every reconnect, so one shared
connection re-forms each time; the dialer redials with capped backoff
and the acceptor awaits the inbound link, switching to a replacement
immediately if one arrives before its own close fires. Inbound
connections are routed to the supervisor via a per-peer channel.
Fixes a latent bug from the prior design: aborting a peer's tasks left
the detached send/read loops running, holding Connection clones so the
link never actually closed. The loops now live in abort-on-drop guards
scoped to the supervisor, so cancelling it releases the connection.
UX: a new ConnEvent stream surfaces per-peer link state to the UI, which
shows "Connecting…"/"Reconnecting…" with a yellow indicator and border
while a peer's audio link is down, returning to normal when it recovers.
Tests: the loopback test now runs through the supervisor path, plus a
new test drives a real drop (explicit close of a controlled peer
endpoint) and asserts the dialer re-dials the stable address and audio
resumes over the rebuilt connection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>