216 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 6541834e0d test(app): pin format_duration hour-boundary cases
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>
2026-06-05 18:49:11 -04:00
mollusk 3d7d0fac11 Merge branch 'feature/adaptive-jitter-buffer': adaptive jitter playout delay 2026-06-05 18:34:55 -04:00
molluskandClaude Opus 4.8 4e8074cb92 feat(jitter): adaptive playout delay driven by buffer feedback
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>
2026-06-05 18:34:52 -04:00
mollusk 6885180b39 Merge branch 'gemini/pw-cli-tests': parse_pw_nodes unit tests 2026-06-02 17:08:44 -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
mollusk c96d6f020f Merge branch 'refactor/pw-cli-parse-seam': pure parse_pw_nodes seam 2026-06-02 17:03:25 -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
mollusk d8cdebbf5a Merge branch 'feature/room-screen': in-room VU meters, local mute, call info bar 2026-06-02 16:57:50 -04:00
molluskandClaude Opus 4.8 3708ca1e15 feat(room): per-peer VU meters, own mic meter, local mute, call info bar
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>
2026-06-02 16:57:50 -04:00
mollusk 88d1268b6e Merge branch 'gemini/jitter-edge-tests': jitter buffer state-transition tests 2026-06-02 16:33:55 -04:00
molluskandClaude Opus 4.8 9d278ded5a test(jitter): state-transition edge cases for JitterBuffer
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>
2026-06-02 16:33:55 -04:00
mollusk 2c17310adc Merge branch 'feature/io-volume': input/output volume sliders 2026-06-02 16:21:40 -04:00
molluskandClaude Opus 4.8 5af25bff5e feat(audio): input/output volume sliders in Settings
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>
2026-06-02 16:21:39 -04:00
mollusk cb2776cc1d Merge branch 'gemini/gate-tests': noise gate edge-case unit tests 2026-06-02 16:00:15 -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
mollusk b36e8f239a Merge branch 'test/mic-meter-regression': mic meter + gate drag unit tests 2026-06-02 15:50:43 -04:00
molluskandClaude Opus 4.8 ca28c56443 test(audio): regression tests for mic meter + gate drag
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>
2026-06-02 15:50:43 -04:00
mollusk c2b8f5a62b Merge branch 'feature/mic-vu-meter': live mic meter with draggable noise gate 2026-06-02 15:48:37 -04:00
molluskandClaude Opus 4.8 b4d0f2db7b feat(audio): live mic meter with draggable noise gate
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>
2026-06-02 15:46:38 -04:00
molluskandClaude Opus 4.8 a15c70623d Merge branch 'gemini/mixer-edge-tests': mixer edge-case unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:57:40 -04:00
molluskandClaude Opus 4.8 915ad8a1ee test(core): mixer edge cases — volume rounding, multi-peer, frame_level
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>
2026-06-02 05:57:19 -04:00
molluskandClaude Opus 4.8 846150659a refactor(app): extract reconnect-chime edge triggers + unit tests
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>
2026-06-02 05:54:13 -04:00
molluskandClaude Opus 4.8 dce9fdfb85 Merge branch 'gemini/jitter-tests': seq_before + buffer overflow unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:50:14 -04:00
molluskandClaude Opus 4.8 1397889369 test(jitter): seq_before wraparound + buffer overflow/resync
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>
2026-06-02 05:49:40 -04:00
molluskandClaude Opus 4.8 fa3d785997 refactor(core): extract pure mixer fns + add saturation unit tests
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>
2026-06-02 05:41:25 -04:00
molluskandClaude Opus 4.8 eb7700dee4 Merge branch 'gemini/ticket-tests': ticket codec + gossip wire-type unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:37:26 -04:00
molluskandClaude Opus 4.8 8f1ecb2a5e test(network): unit tests for ticket codec + gossip wire types
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>
2026-06-02 05:37:08 -04:00
molluskandClaude Opus 4.8 f3e7b4fbca Merge branch 'gemini/codec-tests': Opus codec unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:24:20 -04:00
molluskandClaude Opus 4.8 87fb1595c9 test(codec): unit tests for Opus encode/decode + PLC sizing
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>
2026-06-02 05:23:07 -04:00
molluskandClaude Opus 4.8 131d61e61a Merge branch 'gemini/config-tests': AppConfig serde unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:16:32 -04:00
molluskandClaude Opus 4.8 0f1f7f76a0 test(config): unit tests for AppConfig serde + defaults
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>
2026-06-02 05:15:43 -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 ead3031f3c test: cover graceful-leave instant-evict and rejoin-after-eviction edges
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>
2026-06-01 15:46:15 -04:00
molluskandClaude Opus 4.8 e3b63c0856 fix: make log_msg writes atomic
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>
2026-06-01 15:43:08 -04:00
molluskandClaude Opus 4.8 821102beb6 feat: responsive full-window settings layout
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>
2026-06-01 15:37:56 -04:00
molluskandClaude Opus 4.8 fe4c7f9ee5 docs: add ARCHITECTURE.md overview
Code-accurate architecture guide: async/RT split, module map, audio data
flow, iroh/gossip transport, the dual-armed reconnect-eviction model, core
task layout, and config/privacy defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 05:08:25 -04:00
molluskandClaude Opus 4.8 3b7e6e076e test: regression coverage for transport-driven reconnect eviction
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>
2026-06-01 05:01:03 -04:00
molluskandClaude Opus 4.8 bc1a0a2b23 fix: arm reconnect grace timer from the transport, not just gossip
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>
2026-06-01 04:46:39 -04:00
molluskandClaude Opus 4.8 2c45a6fb3e test: backfill edge cases for notify path helpers
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>
2026-06-01 04:35:50 -04:00
molluskandClaude Opus 4.8 215bb3cf38 docs: point the agent prompt at the team collaboration protocol
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>
2026-06-01 04:05:41 -04:00
mollusk 2aec154f07 feat: sound notification chimes for connection lifecycle events
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.
2026-06-01 03:31:12 -04:00
molluskandClaude Opus 4.8 bddd1a0e6e docs: add operating-principles section to the Antigravity agent prompt
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>
2026-06-01 01:06:29 -04:00
molluskandClaude Opus 4.8 673b72d9ba feat: notification chimes for room join / peer join / peer leave (phase 1)
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>
2026-06-01 00:47:38 -04:00
molluskandClaude Opus 4.8 ffe5b43d8d fix: signal graceful leave at the transport so a disconnect doesn't show "reconnecting"
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>
2026-06-01 00:08:33 -04:00
molluskandClaude Opus 4.8 be818467dd fix: keep reconnecting across a long outage instead of evicting on NeighborDown
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>
2026-05-31 23:29:26 -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 a3263bee03 docs: teach Gemini the codebase via antigravity.toml (project map, invariants, test gotchas)
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>
2026-05-31 15:49:12 -04:00
molluskandClaude Opus 4.8 09acefd2b3 fix: retain peer address so a transient gossip Leave can't strand the dialer
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>
2026-05-31 15:39:51 -04:00
molluskandClaude Opus 4.8 66c912e279 perf: coalesce per-peer audio levels to ~10/sec for the UI
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>
2026-05-29 17:15:06 -04:00