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>
Drives two real iroh endpoints on localhost (relay disabled, addresses
exchanged directly) through the full production path: reactive connect_peer
with lower-id dialing, sequenced broadcast, receive_datagrams, and
JitterBuffer decode at a realistic mixer cadence. Verifies datagrams arrive
attributed to the sender, the sequence header round-trips, and Opus decodes
to full 20ms frames -- without needing a mic, speakers, or the GUI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endpoint previously used presets::N0, which both relays through n0's
servers and publishes a signed EndpointId->addresses record to n0's public
DNS every time you go online. Since PeerSpeak already exchanges full peer
addresses via the join ticket and gossip, that DNS presence beacon is
redundant here.
Adds a NetworkMode config option (persisted, switchable from Settings):
- RelayNoDiscovery (new default): presets::Minimal + RelayMode::Default +
the in-memory address lookup. Keeps n0 relay for NAT traversal but drops
the DNS publish/resolve, so n0 only ever sees relayed-call metadata, never
a standing online beacon.
- N0Full: previous behavior (relay + DNS) for maximum reliability.
- DirectOnly: RelayMode::Disabled, fully serverless.
The mode is applied when the endpoint is built on room join. Existing
config.json files load unchanged via #[serde(default)].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the fire-and-forget datagram path with sequenced packets, a
per-peer jitter buffer, and persistent per-peer send tasks. Together these
fix three intertwined weaknesses that only showed up off localhost.
Packet format: every audio frame now carries a 4-byte little-endian
sequence number header ([seq][opus payload]), the basis for reordering and
loss detection.
Jitter buffer (core/jitter.rs): incoming packets are reordered by sequence
behind a fixed ~60ms playout delay. Missing sequences with later packets
already buffered are concealed via Opus PLC (decode(None)) -- a path the
decoder supported but nothing ever invoked. Underruns go idle and re-buffer
rather than concealing indefinitely. Covered by unit tests using real
encoded frames (reorder, gap-conceal, prime, late-drop).
Transport (network/iroh_impl.rs): each peer gets one long-lived send task
fed by a shallow bounded channel (drop-oldest on backpressure), instead of
spawning a throwaway task per peer per 20ms frame. Connections are now
established reactively on peer-join and torn down on peer-leave; the
lexicographically-lower EndpointId dials so a full-mesh pair forms exactly
one shared bidirectional connection instead of two racing ones. This also
removes the previous lock-held-across-connect().await serialization.
Opus decoder: PLC output is now sized to one 20ms frame, so concealment
synthesizes 20ms instead of a 120ms burst from the oversized max buffer.
Known follow-up (Tier 2): no reconnect on transient connection loss; a
send error currently retires the peer until they rejoin.
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>
- Fix tokio runtime panic by spawning a dedicated Tokio runtime thread in CoreController.
- Add central log_msg utility in src/lib.rs for debugging.
- Add instrumentation/logs to join, leave, and gossip events in src/network/gossip.rs.
- Add test_net.rs bin for testing gossip loopback sync.
- Use std::sync::Mutex in IrohGossipState to resolve Tokio block-in-async panics.