Replace the Phase 0 no-op CpalBackend stub with a working cpal backend
(WASAPI on Windows), preserving the exact PipeWire AudioBackend contract
so the mixer/encoder/jitter pipeline is unchanged.
- Capture: input stream -> downmix to mono -> 960-sample (20ms) i16 frames
-> tx, matching the encoder/jitter frame size.
- Playback: 200ms stereo ring prefilled to PLAYBACK_TARGET_SAMPLES; the
output callback drains it (silence on underrun) while the owning thread
feeds it from rx. ring_fill is the exact delta-maintained occupancy
counter (fetch_add on push, fetch_sub on pop), preserving the clock-paced
production design (not ringbuf's stale occupied_len).
- cpal::Stream is !Send, but AudioBackend is Send+Sync and shared via Arc,
so each stream lives on its own owning thread (built/played/dropped
there); the struct holds only the running flag + JoinHandle. stop()
flips the flag and joins.
- Generic over F32/I16/U16 sample formats; device selected by name else
default; requires a native 48kHz config (clear error otherwise, no
resampling yet). Mirrors the PipeWire drain_loop and playout-health line.
- Cargo.toml: add cpal 0.15 under cfg(windows).
Verified by temporarily compiling cpal_impl against real cpal on Linux/ALSA:
build + clippy clean, 6/6 cpal_impl unit tests pass. Reverted to windows-only
gating; shipped Linux state green (316/316). Runtime/WASAPI end-to-end is
unverified and pending a Windows host (plan M2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the tree compile for Windows without touching core logic, by
confining all Linux/PipeWire assumptions behind cfg gates and a single
platform-selected backend alias. No new dependencies — the cpal/WASAPI
backend lands in Phase 1; this ships a no-op stub.
- Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a
cfg(windows) rfd using the Win32 dialog backend.
- audio: gate pipewire_impl to unix, add a cpal_impl stub for windows,
and select between them via the new PlatformAudioBackend alias.
- core: use PlatformAudioBackend instead of the concrete PipeWireBackend.
- lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows
logs inherit the directory ACL.
- audio_probe: gate this PipeWire diagnostic to unix with a stub main.
- app: open URLs via rundll32 on windows, xdg-open on unix (shell-free).
- ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for
x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the
vendored libopus build. Needs a windows act_runner to actually run.
Linux build/clippy/tests green (316/316). The Windows path is verified by
inspection only (no local Windows toolchain); CI is the real gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Gitea Actions workflow that runs `cargo deny --locked check` on
every push to main and every PR, so the deny.toml policy (advisories,
bans, licenses, sources) is enforced automatically rather than by hand.
Runs on a locked tree so the pinned versions in Cargo.lock are what get
audited; a poisoned dependency release can't reach CI until Cargo.lock is
deliberately updated. cargo-deny is pinned to 0.19.9 via a prebuilt binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents the focused security review of the protocol-versioning migration
and the cargo-deny policy addition. Result: no high-confidence vulnerabilities
— the versioned_topic XOR transform is entropy-preserving, signature binding
uses the raw topic_id consistently, and the ALPN/domain changes are
handshake-level compatibility only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supersede bare cargo-audit with an enforceable four-part policy, validated
against the current tree with cargo-deny 0.19.9 (advisories/bans/licenses/
sources all pass):
- advisories: deny vulnerabilities + yanked; ignore the two *unmaintained*
warnings (paste RUSTSEC-2024-0436, audiopus_sys RUSTSEC-2026-0150) with
rationale. Both are transitive and pinned via Cargo.lock, so a future
malicious release can't reach us until a deliberate cargo update.
- sources: trust only crates.io; deny unknown registries and git sources
(core anti-hijack control).
- bans: deny wildcard version reqs; warn on duplicate versions.
- licenses: permissive allow-list covering the current graph.
Mark peerspeak publish = false (it's an application, not a published
library): blocks accidental cargo publish and lets [licenses.private]
skip the missing-license check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the
release version, and per-plane protocol versions enforced on the wire so
incompatible peers fail fast and legibly instead of via silent decode/signature
errors.
⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and
gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced
dopedart) can no longer connect, by design, and now fails at the handshake.
- New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO,
the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN,
and versioned_topic(). Unit tests assert ALPN/domain strings match their
integer versions (no silent drift) + that topic namespacing is deterministic.
- Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in
iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends
re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1).
- Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip
versions never share a swarm; the raw topic_id stays the room identity and
what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs.
- Cargo.toml 0.1.0 -> 0.2.0.
316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump
rules, the "I changed X -> what do I bump" table, and a release checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes S8 (High): inbound audio was authenticated by identity (remote_id) but
NOT by room membership, so a former member who knew a current member's endpoint
could reconnect on the audio ALPN and inject into / eavesdrop on the mix while
invisible in the roster. Now audio is admitted only for live gossip-roster
members (the senior+user-resolved GRACE-AWARE policy).
Transport (src/network/iroh_impl.rs):
- New per-session admitted_audio: HashSet<EndpointId> on Shared (internal state,
no wire/serialization change). Cleared on disconnect_all.
- AudioRouter::accept consults audio_sender_admitted BEFORE ensure_supervisor —
a non-member never gets a supervisor, sender handle, datagram reader, or
outbound mix. Brief StdMutex check, released before the await (no RT lock).
- Pure apply_audio_admission_event(roster, peer, event) with AudioAdmissionEvent
{RosterPresent insert, TransientDropGrace no-op, Remove}. Grace deliberately
cannot ADD membership — it only preserves an already-admitted peer — so an
unknown peer can't sneak in via a grace event. +3 lifecycle tests (on top of
Pass-1's 4 predicate tests).
- admit/keep_for_reconnect_grace/remove/query methods for core to drive.
Core (src/core/mod.rs) — authority is core's VERIFIED gossip-roster events, not
transport connect/disconnect:
- PeerJoined / PeerUpdated: admit_audio_sender before connect_peer.
- PeerConnectionLost: keep_audio_sender_for_reconnect_grace (preserve through the
existing RECONNECT_GRACE window — no audio cut on transient blips).
- gossip PeerLeft, transport ConnEvent::Left, grace-timer expiry: remove_audio_sender
before disconnect_peer + jitter removal (removal-before-teardown bounds the
in-flight-datagram race).
- datagram receiver: audio_sender_admitted gate before any jitter buffer (defense
in depth against a datagram racing a removal). Mixer stays off the hot path.
Mid-join: a peer who dials audio before we've verified their signed Announce is
dropped (no "pending" admission, which would reintroduce the eavesdrop); their
reconnect loop recovers once the Announce admits them.
tests/transport_loopback.rs: admit both ends before connecting, mirroring the
production room-event order.
313 lib / clippy --all-targets / transport_loopback 4 / reconnect_eviction 6 /
release — all re-run green by the senior. Former-member-rejection + mid-join
recovery are verifiable only in a 2-machine call (senior's to run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design-first checkpoint for S8 (authorize inbound audio against live room
membership). Codex's design note (in the handoff task-report.md) establishes
the authoritative roster = gossip IrohGossipState.peers, NOT the audio
transport connection list, and recommends mirroring it into an audio-admission
snapshot consulted at AudioRouter::accept + datagram ingest.
This commit lands ONLY the pure decision seam + tests; wiring is deliberately
paused for a senior decision on the reconnect-grace policy (gossip drops a peer
from the roster on transient NeighborDown, but core keeps the audio supervisor
alive for RECONNECT_GRACE — a strict roster-only gate would cut audio on blips).
- audio_sender_admitted(remote, roster) -> bool (pub(crate), #[allow(dead_code)]).
- 4 tests: member admitted, stranger rejected, former member rejected after
roster removal, mid-join peer rejected until authenticated Announce inserts it.
- No behavior change: accept/datagram/mixer paths untouched. S8 remains OPEN.
310 lib tests / clippy --all-targets / release all green (re-run by senior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SetPresenceMode and the Discoverable time-box auto-revert both committed
the new presence_mode to local state *before* apply_discovery and only
log_msg'd on failure, so a failed off-transition could leave the n0 DNS
PkarrPublisher running while the UI showed not-discoverable (privacy /
reality mismatch — security-open-handoff S11, from the W7 P7 review).
Fix (Codex, senior-reviewed):
- discovery.rs: pure resolve_presence_transition(prev, requested, apply_ok)
-> (mode, Option<error>) seam — on failure keep the previous (truthful)
mode and surface a message. +4 unit tests.
- apply_discovery now builds the replacement resolver/publisher services
BEFORE clearing the service set, so a builder failure leaves the old
posture fully intact (no partial state) — "keep previous mode" is then
provably truthful.
- Both SetPresenceMode and the time-box revert apply discovery first, route
through the seam, commit only the truthful mode, and surface failures via
the existing PresenceModeReverted (corrects the picker) + UiEvent::Error.
No new wire/event variant.
- A failed off-transition stays Discoverable and arms a 60s retry
(DISCOVERY_REVERT_RETRY) so the beacon never stands stuck.
- P3 notes documented: relay-resolve exposes n0 query metadata (by design);
no explicit iroh unpublish API exists, so the bounded ~30s pkarr TTL
linger is documented, not behavior-changed; DirectOnly stays no-n0.
306 lib tests / clippy --all-targets / release all green (re-run by senior).
Runtime publish-stop behavior still wants a 2-machine / packet-capture check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transient implementer handoff note; its content is preserved in the
handoff docs. Not repo content.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fresh/empty Home keeps Create/Join dominant via a tested home_layout_mode
seam (FocusedEmpty / ThreeColumn / Stacked); once Recents or Friends has
content the normal three-card layout returns. Quieter empty-state cards.
Conflict resolution:
- HomeLayoutMode enum/fn coexists with the SettingsCategory enum (separate
derives); both unit tests kept.
- Top bar: the wishlist Hotkeys-info button is always shown; the room-layout
button is hidden on Home (home-empty's intent) and shown in Room. The
auto-merge had wedged the info tooltip into the conditional as a stray
expression — split into separate info_button / layout_button bindings.
291 lib tests pass, clippy --all-targets clean, bin builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings is split into navigable categories (left sidebar ≥820px wide,
pick_list dropdown below) instead of one long scroll. Integrated with the
wishlist branch's hotkey editor by giving it its own "Hotkeys" category
(7 categories total: Audio, Hotkeys, Recording, Profile, Appearance,
Network, Notifications).
Conflict resolution: the wishlist branch had inserted a Hotkeys section
into the old long-scroll between Microphone and Recording; relocated it
into a dedicated SettingsCategory::Hotkeys arm and updated the category
stability test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reject signed Announce(PeerState) whose embedded state.addr.id does not
match the authenticated payload.author, closing the residual S2 gap where
a valid signer could advertise another node's EndpointAddr.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.
- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
-> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
publish only when Discoverable; DirectOnly (the explicit no-server posture)
gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
Toggling publish off drops the publisher (republish task ends; TTL-30s record
expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
persists it. Re-selecting Discoverable restarts the clock.
Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.
266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lay the home screen out as three side-by-side cards — Recents | Connect |
Friends — instead of stacking Recents under Connect. Three 380-460px cards
need ~1280px to fit in a row, so the responsive threshold rises to 1280px;
below that they stack in a column (Connect first). Screenshot-verified at
1920px: Recents left, Connect center, Friends right, top-aligned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The card was hidden entirely when there was no history, so on a fresh
build it appeared to be missing. Always render it (with a "No recent
rooms yet" hint when empty), mirroring the Friends card, so the feature
is discoverable before the first join. Drops the has_recents gating in
the home layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A growing recents history shouldn't reflow the Connect card's Create/Join
controls. Extract the rendering into a `recents_card` free fn (mirroring
`friends_panel`'s self-contained styling) and place it in the left column
beneath the Connect card — both are "get into a room" — with Friends on
the right. The card is omitted entirely (no stray gap) when empty, in both
the narrow (stacked) and wide (row) responsive layouts.
Screenshot-verified at the default width: Connect + Recent Rooms stacked
left, Friends right; the Connect card stays fixed-size as recents grow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a purely-local, most-recent-first recents list so users can hop back
into a room they were just in — meaningful now that rooms carry cosmetic
labels.
- src/recents.rs (new): `Recent {name, ticket, joined_at}`, `push_recent`
(de-dupes by room `topic_id`, refresh-and-move-to-front, caps at
RECENTS_MAX=12), `remove_recent`, `relative_time` ("5m ago"). 6 tests.
- PeerSpeakTicket::topic_of — the stable room identity used as the de-dup
key (host addr + label change between members/sessions; topic doesn't).
- AppConfig.recents (`#[serde(default)]`, back-compat) — local UI state,
never sent over the wire.
- Recorded on RoomJoined (label via label_of); rendered as a "Recent
rooms" block in connect_card (each entry → JoinRecent, ✕ → RemoveRecent),
shown only when non-empty.
Rejoin is best-effort by design: the stored ticket only admits us while
the room is still live and reachable (reliability is P6 discovery + the
member-issued ticket floor, not this list).
263 lib tests green, clippy --all-targets clean. Recents UI
screenshot-verified (seeded config → ages + Untitled-room fallback render).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each participant card gets a star affordance: a clickable outline star (☆) that
adds that peer to your friends list, or a non-interactive gold filled star (★)
once they're already a friend (hidden while the friends store is read-only). The
add pulls the peer's live presence name + address from the room roster and passes
addr: Some(..) to CoreCommand::AddFriend, so the new friend is reachable
immediately — no waiting for a future call to seed last_addr the way a bare
add-by-id does. Name sanitized, short-id fallback; idempotent in core; no-op if
already a friend. New AppMessage::AddFriendFromRoom(EndpointId).
clippy --all-targets clean, 257 lib tests green. UI wiring screenshot-pending: the
star + click need a live 2-machine call (a peer in the room) to verify visually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rooms can now be named. A "Room name (optional)" field on the home Create card
mints a ticket carrying the label; PeerSpeakTicket gains a #[serde(default)]
`name` field (backward/forward compatible — serde ignores unknown fields and
defaults missing ones, so old/new builds still interoperate, just without
labels). restamp preserves the label so member-issued doors keep it; new
label_of helper reads it. Every member (creator or joiner) sets current_room.name
from the ticket, so presence reports a consistent "in <name>" to friends, and the
room-screen header shows the label under the wordmark. Labels are sanitized via
sanitize_name on both mint and display (untrusted peer-supplied ticket).
CoreCommand::Join gains room_name (used only when creating). +2 ticket tests
(label round-trip through restamp/label_of, pre-label backward-compat). clippy
--all-targets clean, 257 lib tests green.
Pure seam unit-tested + home field screenshot-verified; the in-room header label
and friend-side "in HangOut" presence display need a live/2-machine confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the three tooltip'd presence radios in the home Friends card with a
pick_list dropdown + a one-line explainer for the current choice, mirroring the
Settings NetworkMode picker. Add PresenceMode::ALL + a Display impl (descriptive
labels) to back the picker. Tightens the Friends card vertically. clippy
--all-targets clean, 256 lib tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The home-screen Connect + Friends cards were a fixed-width row, so below
~860px the 380px Connect card squeezed the Friends card until its node-ID
field, status, and remove button clipped away. Extract the Connect card into a
free `connect_card(state)` fn (mirroring `friends_panel`) and wrap both in
`responsive`: side-by-side row at >=900px, stacked column below. Verified at
560/760/1000/1912px. clippy clean, 256 lib tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract a self-contained friends_panel() (live list with presence +
one-click Join, add-by-node-ID form, presence-posture radios) and place
it as a side-by-side card next to the connect panel on the home screen,
removing the equivalent sections from Settings. Friends are now visible
without opening Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the friends list live, building on the B1 persistent endpoint.
Friends ownership moves into core (was the GUI's):
- Core loads/owns friends.json behind a shared Mutex<FriendStore>; a malformed
load yields an empty store flagged READ-ONLY so we never overwrite the damaged
file (fixes backlog A16). New commands AddFriend/RemoveFriend/RenameFriend +
UiEvent::FriendsUpdated{friends,read_only}; the GUI is now a read-only mirror
that renders from the event and drives mutations via commands. The friends UI
shows a warning + blocks edits when read-only.
- Presence posture pushed to core via SetPresenceMode (persistence stays in
AppConfig); held in a shared Mutex for the listener/scheduler.
Live listener + outbound scheduler:
- New FriendsProtocol ProtocolHandler on the persistent Router for FRIENDS_ALPN
(the router owns accept(), so the listener can't be presence_net::serve — same
delegation pattern as B1's AudioRouter). Its reply policy reads the shared
friends/mode/current-room and uses presence::should_answer: answer friends only,
never while invisible, and report our current gathering's restamped member
ticket so a friend can one-click Join. handle()'s body is factored into a shared
exchange() used by both serve (tests) and FriendsProtocol.
- Outbound ping scheduler folded into the core loop via tokio::select! on a slow
interval (60s, first pass delayed 3s for endpoint online). FULLY DARK while
Invisible (no probing at all — user's choice). Each pass runs detached so it
never blocks command handling and picks up a rebuilt stack next tick; probes
friends with a saved addr in parallel and emits UiEvent::FriendPresence.
- note_seen auto-heal: a connected peer who is a friend has their last_addr
refreshed (+persisted) so the scheduler can reach them later.
- current_room shared state set on Join (restamped ticket) / cleared on Leave.
P5 UI: each friend shows online / offline / in-room with a one-click Join.
256 lib + 6 reconnect + 4 loopback + 2 ignored real-endpoint tests green, clippy
--all-targets clean, release builds. B2a (ownership/A16) is solo-verifiable; the
live listener + scheduler need the 2-machine field test before this merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found by manual 2-machine exploration: desktop creates room A, dopedart joins;
desktop leaves A, creates room B, leaves B, then rejoins A — and dopedart (still
in A) isn't reconnected until dopedart itself leaves and rejoins.
Root cause: `known_peers` (the A8 rejoin-bootstrap memory) was a single flat set
cleared on ANY join-ticket change. The detour through room B wiped the memory of
room A's peers, so rejoining A — whose ticket names the creator itself as host —
produced an EMPTY bootstrap (`compute_bootstrap` drops self), leaving the desktop
isolated in the gossip topic with no one to dial. dopedart never re-dials a peer
that's already a (now-departed) neighbor, so they never relink.
Fix: key `known_peers` by room ticket (`HashMap<ticket, HashMap<peer, addr>>`)
and stop clearing it. The event loop records peers under its room's ticket; a
join pulls bootstrap targets from that ticket's bucket. Revisiting a room after a
detour now still remembers its peers. Pre-existing bug (logic unchanged by the
B1 refactor); reproduces on pre-B1 main too.
256 lib tests green, clippy --all-targets clean. NOT yet 2-machine field-verified
— the exact A→B→A scenario is the gate (dopedart is up; verifying next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The friends-only presence listener (W7) must answer pings while the app is
open, whether or not we're in a call — but a node id has exactly one live
endpoint instance (proven by the dual-endpoint spike: two endpoints sharing a
SecretKey collide, all inbound connections land on one and the other's ALPN
fails the QUIC handshake). So the listener can't get its own endpoint; the
whole app must share one persistent endpoint. Today the core rebuilds the
endpoint+gossip+router on every Join and tears them down on leave, so there's
nothing alive between calls.
B1 hoists those durable pieces to the app lifetime (no new behavior):
- New persistent `NetStack` (endpoint + gossip + Router) built once at startup
under the RelayNoDiscovery default (relay reachability, no DNS beacon);
`online()` is backgrounded so launch isn't blocked.
- New persistent `AudioRouter` (src/network/iroh_impl.rs) replaces the
per-session `AudioProtocol`: it's registered once on the single Router and
delegates each inbound audio connection to whatever session `Shared` is bound
(`bind` on join, `clear` on leave), dropping links when idle. `IrohTransport::
new` now returns just `Self`.
- Join reuses `net.endpoint`/`net.gossip` and only subscribes its gossip topic +
binds the audio router; Leave clears the router but keeps the endpoint up.
- `SetNetworkMode`/`RegenerateIdentity` rebuild the stack immediately when idle,
else defer to the next Leave/Join (preserves "applies on next join"), and the
existing session is always torn down before any rebuild closes the endpoint.
Tests/loopback updated for the new transport API. 256 lib + 6 reconnect + 4
loopback + 2 ignored real-endpoint tests green, clippy --all-targets clean,
release builds. NOT yet 2-machine field-verified — that regression is the gate
before this merges to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Section 3 claimed playout is pinned to exactly 1024 frames; the code now
follows the graph's Buffer::requested() quantum (pipewire_impl.rs:229-237),
with 1024 only as a fallback -- the doc described the pre-fix behavior that
caused crackle. Flagged by the 2026-06-15 Codex/GPT-5.5 review (backlog A20).
The broader ARCHITECTURE refresh (missing modules, persistent identity, signed
gossip, friends/W7, recording modes) remains under A20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flagged by the 2026-06-15 Codex/GPT-5.5 review (backlog A20): the header
contradicted the per-phase statuses showing P1-P3 done + P4 partial. Now reads
'IN PROGRESS' with the current phase rollup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The I/O edge of the friends-only idle listener: bind/probe/serve a ping->pong
over a dedicated ALPN (peerspeak/friends/0), adapted from pixelpass's proven
control plane. Request/response, one exchange per connection: probe sends a
Ping and reads the Pong; serve accepts, authenticates the remote id, and asks
an injected handler (which wraps presence::should_answer + builds the pong)
what to reply -- None for a stranger/invisible, so the listener reveals
nothing to non-friends.
Verified by a loopback integration test over two real iroh endpoints (ignored
by default): the allowed prober gets a Pong with the room; a fresh stranger id
gets an empty, unusable reply. 256 lib tests + the loopback (run with
--ignored) green, clippy clean (incl --all-targets), release builds.
DEFERRED (next session, needs care + 2 machines): spawning serve on a
persistent endpoint OUTSIDE the per-join room session, and the ping scheduler.
Real fork noted in the module: a second always-on endpoint shares our node id
with the room endpoint (possible relay collision) vs refactoring to one
persistent endpoint -- intentionally not decided at 5am.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Desktop<->dopedart on c5df7a3: persistent identity stable across restarts,
two-way audio regression clean, member-issued ticket lets a room outlive its
creator (desktop rejoins via dopedart's ticket after leaving), friends-add
persists. dopedart resynced to c5df7a3. Clears the P3 field-test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per design feedback: put Remove directly to the right of the name box (was
separated by the id column), and shrink the oversized add-friend inputs to
fixed half-widths (node id 340px, name 170px) instead of stretching across
the panel. A trailing horizontal_space absorbs the rest so rows are
left-aligned and nothing reaches the scrollbar edge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fill-portion inputs pushed the trailing Remove and Add buttons under the
scrollbar gutter, clipping them. Reserve 12px right clearance on both rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Identity section showed only a truncated id and iced text isn't
selectable, so there was no way to share your full node id. Add a Copy
button (mirrors the room-ticket copy) that writes the FULL id to the
clipboard via a new generic CopyText(String) message. Also unblocks the
add-a-friend self-test (copy your id, paste into Add friend).
256 lib tests green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the P2 friends store into the UI. New 'Friends' section in Settings:
loads the store at startup, lists each saved friend as a live-rename text
field + short id + Remove button, and an add row (node-id + optional name +
Add) that validates the id parses as an EndpointId, rejects duplicates, and
falls back to a short-id name when none is given. Every change persists via
friends::save. Empty state prompts adding by node id.
Solo-verifiable (copy your own ID from the Identity section to add a row).
Friends currently live in Settings; they will likely move to a prominent
home-screen panel once P4 presence gives them live status. 256 lib tests
green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'Presence' section to Settings with three radio options wired to the
persisted PresenceMode (W7): Normal (friends only, no beacon; default),
Invisible (appear offline to everyone), Discoverable (also publish so friends
can find you after a network change). Each has a hover tooltip explainer,
mirroring the recording-mode radios.
Selecting one saves config.presence_mode. The live friends listener (P4, not
yet wired) will read this posture when it lands; no core command until then.
256 lib tests green, clippy clean, release builds. Solo screenshot-verifiable
(the live effect needs P4 + 2 machines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The logic half of the friends-only idle listener, built as pure unit-tested
seams so the security-critical decisions are provable without live networking.
src/presence.rs:
- ControlMsg { Ping, Pong { room: Option<RoomPresence> } } — self-describing
tagged JSON; unknown tags rejected (forward-compat).
- should_answer(from, friends, mode): the authorization gate — answer pings
from FRIENDS ONLY and never while invisible. This whitelist is what keeps
the always-on-while-open endpoint from being a stranger-facing spam/DoS
surface; must be the authenticated remote_id, never payload data.
- PresenceMode { Invisible, Normal(default), Discoverable } + helpers; persisted
in AppConfig (backward-compat default = Normal = friends-only, no beacon).
- interpret_pong: defensive reply handling — sanitizes the peer-supplied room
name and only surfaces a joinable room if its ticket actually parses, else
downgrades to plain Online (no dead/hostile Join button). Never auto-joins.
Deferred to a 2-machine session (the I/O edges): binding the live control
endpoint, its accept loop, and the ping scheduler. +8 presence tests, 256 lib
tests green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket a member sees/copies for sharing is now stamped with THEIR OWN
live address + the room's topic, not the (possibly someone else's) ticket they
joined with. So every member — not just the creator — hands out a working door
that bootstraps newcomers off themselves; a room stays reachable as long as
anyone inside can share a ticket, even after the creator leaves.
Pure seam PeerSpeakTicket::restamp(ticket_str, my_addr): re-parse, swap
host_addr to mine, keep topic_id; no-op for an unparseable string or when the
addr is already mine. The core re-stamps only the DISPLAY copy sent in
RoomJoined; the join/bootstrap ticket_str and the A8 retain logic are
untouched, so this is non-breaking (same wire format, different addr).
+3 unit tests (swaps addr/keeps topic, idempotent for same addr, passes
through unparseable). 248 lib tests green, clippy clean, release builds.
Tests-green; the end-to-end 'creator leaves, joiner's ticket still works'
behaviour wants a 2-machine field test. Multi-bootstrap (Vec) + ticket
encoding tightening deferred (both breaking wire changes — want daylight).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The durable anchor of the friends-first model: a local JSON address book at
~/.config/peerspeak/friends.json keyed by stable EndpointId, with a locally
editable display name and a last-known address per friend.
src/friends.rs — FriendStore over Vec<Friend{id,name,last_addr}>. Pure ops:
add (explicit + idempotent; meeting someone in a room never auto-friends
them), remove, rename (local), and note_seen — the auto-heal hook that
refreshes a friend's saved address on connect, friends-only, never clobbering
a local name, and only reporting a change so callers can skip needless writes.
I/O behind a path-injectable seam (load_at/save_at, atomic tempfile+rename,
malformed = error not silent loss), JSON via serde_json (no toml dep).
7 unit tests (idempotent add, remove/contains, rename-existing-only, auto-heal
friends-only + name-preserving, save/load round-trip, missing=empty,
malformed=error). 245 lib tests green, clippy clean, release builds. Store
only — the friends-list UI + core wiring (add-from-room, note_seen on connect)
come with P5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete P1: surface the identity in Settings and let the user manage it.
- New UiEvent::IdentityStatus { node_id, persisted, error }, sent at startup
and after a regenerate, so the app always knows its own node id and whether
the key is persisted.
- CoreCommand::RegenerateIdentity: mints + persists a fresh key (identity::
regenerate), swaps the core's live key for the next join (same 'applies on
next join' semantics as SetNetworkMode), and replies with a fresh status.
- Settings 'Identity' section: shows your permanent ID, a left-aligned
Regenerate button behind a confirm modal (destructive — discards the old id,
warns friends will stop recognising you), and a standing red warning banner
when the key isn't persisted (disk/permission failure -> ephemeral fallback),
explaining the id won't survive the next launch.
238 lib tests green, clippy clean, release builds. Screenshot-verified: the
Identity section, the confirm modal, and the degraded warning (chmod 000 the
key file -> 'Permission denied (os error 13)' banner; Regenerate clears it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the persistent-identity foundation as landed (d157d78) and add the
user's requirement: surface a persistent UI warning (not just a log) when the
key can't be read/written, since running on an ephemeral fallback silently
breaks friend recognition next launch. To build with the regenerate UI slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-launch SecretKey::generate() in the core loop with a stable
key loaded from ~/.config/peerspeak/identity.key, so a peer's node id now
survives restarts. This is the foundation of the friends-first contacts model
(docs/contacts-plan.md): friends are keyed by node id and reachability rests
on a saved address per friend, both of which only mean anything if the id is
stable. iroh never forced rolling ids — the old generate() was an unrevisited
default.
New src/identity.rs: load_or_create / regenerate / save over a 0600 hex key
file (atomic tempfile+rename, perms set before rename), hand-written hex (no
new dep). A malformed file is a hard error, not a silent regenerate, so a bad
hand-edit can't orphan everyone who saved the old id. The fs logic is behind a
path-injectable seam (load_or_create_at/save_at) tested in a temp dir:
create+persist, malformed-errors, regenerate-changes-key, 0600 perms, plus hex
round-trips. Core falls back to an ephemeral key only if the file can't be
read/created, so a bad disk never blocks a call.
regenerate() exists for the Settings 'Regenerate identity' control (next
slice; needs live endpoint rebuild). 238 lib tests green (+8), clippy clean,
release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the plan around the model converged in the 2026-06-15 design
session. Core shift: the friends list (stable node IDs) is the durable
anchor; rooms become ephemeral cosmetic labels, not addressable places.
Locked: persistent identity default-on + Settings regenerate; friends-only
idle listener (answers pings only from friends via remote_id, from a saved
address, no presence beacon); presence axis invisible/normal/discoverable
with discovery opt-in default-off and asymmetric (only the friend who moves
networks publishes); silent gossip-driven address auto-heal; and a universal
floor of hand-shared member-issued tickets that always connect.
Supersedes the heavyweight control-plane cut and the room-centric durable-room
cut; both retained at the bottom with reasons. ~3-4 sessions, build order
P1 identity -> P7 security/field-test. iroh does not force rolling IDs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pivot the contacts plan away from the heavyweight control-plane design
(always-on listener + persistent reachable identity + n0 DNS presence
beacon) toward a serverless, no-beacon spine: member-issued, multi-bootstrap
tickets. Any current member can mint a fresh ticket from their own live
address + the room's existing topic_id (both already in hand; join() already
takes extra_bootstrap: Vec), so rooms outlive their creator for ~no cost.
Layered optional add-ons: one-click invite sharing (peerspeak:// deep links
+ QR + .peerspeak files, delegating delivery to Signal/email/OS), persistent
identity (save-and-return), name-derived rooms, favorite-room bookmarks, and
a privacy-bounded silent occupancy peek. The old heavyweight path is retained
at the bottom of the doc as 'decided against', with the reasoning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bank the W7 investigation as a scope contract. Key finding: pixelpass's
friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0),
but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave)
with a fresh identity each launch — so the real work is a new always-on
control-plane endpoint + persistent identity, not the friends list. Phased
plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI /
4 security+field-test), ~4 sessions, with 4 open decisions that block build
(discovery-vs-privacy being the big one).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-event enable checkbox next to each chime in Settings so a user
can silence individual sounds (e.g. keep 'message'/peer-join but drop
reconnect chimes) while the master 'Enable sound notifications' toggle
stays as the global kill-switch.
The gate lives in one place at the play() seam: a pure should_play(master,
sound) AND that's unit-tested, fed by a per-sound AtomicBool array in
notify keyed by a stable Sound::index/ALL. Flags persist as 8 sound_*_enabled
bools in AppConfig (default true, so upgrades are silent-change-free) with
sound_enabled/set_sound_enabled accessors centralizing the field mapping.
The per-sound checkbox greys out (drops on_toggle) while the master is off.
+3 notify unit tests (should_play truth table, index bijection, flag
set/query independence) + extended config backward-compat test. 230 lib
tests green, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iced's `image::Handle::from_bytes` assigns a fresh *unique* id on every call
(unlike `from_path`, which hashes). `avatar_view` built the handle inline in
`view()`, so every repaint produced a "new" image and iced re-uploaded the
texture each frame. Any redraw triggered it — notably the redraws fired on
mouse movement — so visible avatars flickered constantly while the mouse moved.
Fix: cache handles by a content hash of the PNG bytes (thread-local, UI thread)
and reuse the same `Handle` across redraws, giving a stable texture id. Covers
both presets and custom uploads.
Build + clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes W4: users can upload a custom avatar image.
- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
xdg-portal backend, off-thread through Task::perform) and shows the current
custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
a custom image must be within the byte cap and decode as a PNG within bounds
(image-crate decode limits guard against decompression bombs) or it's
downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
are actually new in the lockfile (rest were already transitive). cargo audit
clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
accept/reject). 227 lib tests green, clippy clean.
Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds bundled preset avatars on top of the monogram foundation.
- New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded;
6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/
— swap for real designs later). +3 unit tests.
- `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`);
`AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join +
new SetAvatar command) → every self-state announce, so it reaches the room incl.
late joiners, and changing it mid-call re-announces live.
- Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted.
- Rendering: `avatar_view` draws the chosen preset image (iced `image` feature,
now enabled) else the monogram, in the self card, peer rows, and chat (chat
looks up the sender's avatar from presence by id).
⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the
signature is recomputed by re-serializing the parsed struct, so a new PeerState
field means old and new builds can't verify each other's presence — ALL peers
must run a build >= this one (same as the S2 change). Redeploy dopedart before
2-machine testing.
Build + clippy clean, 222 lib + integration tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for user avatars (W4). Every participant now shows a circular
monogram avatar: their initial(s) on a colour deterministically derived from a
stable key (node id, or name where no id is available). This is also the
universal fallback for the later preset/upload phases.
- New pure `avatar` module: `initials`, `color_for_key` (FNV-1a → 12-colour
palette), `use_dark_text_on` (contrast). +5 unit tests. Dependency-free.
- `avatar_badge` view helper: a radius-capped coloured container + centred
initials (plain iced widgets, no canvas/image needed).
- Wired into the self card, each peer row (left of the name — pairs with the
A10 fixed-width row), and each chat line.
- Threaded the chat sender's node id end to end (RoomEvent → UiEvent::ChatMessage
gains `from`, ChatEntry gains `from`) so chat avatars are id-keyed and ready
for the Phase 2/3 per-peer avatar lookup.
Build + clippy clean, 219 lib tests green. Visual confirm: join/create a room.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat messages rendered URLs as plain text. Now http/https URLs render as
clickable links that open in the system browser (xdg-open).
- New pure `sanitize::linkify` splits an (already-sanitized) message into
text/URL segments: conservative — only http:// and https:// runs, ending at
whitespace, with trailing prose punctuation peeled back out; reassembling the
segments reproduces the input exactly. +6 unit tests.
- Chat render uses iced `rich_text` with link spans + `on_link_click`.
- `OpenUrl` handler re-validates the http(s) scheme (defence in depth) before
spawning xdg-open with the URL as a single argv entry (no shell, no injection).
Linkify only runs after `sanitize_chat`, so control/format chars are already
gone. 214 lib tests green, clippy clean. Manual check: send a message with a
URL, click it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Screen sharing relies on the optional pixelpass companion CLI. Previously, when
it wasn't installed the Share Screen button was disabled and relabelled "Needs
pixelpass", which read as peerspeak advertising a broken in-app feature.
Now the Share Screen (and a peer's Watch) buttons stay enabled; if pixelpass
isn't on PATH, clicking opens a short explainer popup describing pixelpass as an
optional P2P-video companion and how to enable it (install pixelpass + mpv).
When pixelpass is present, behaviour is unchanged (toggles the share / opens the
viewer). Popup reuses the existing centered-modal + backdrop pattern.
Build + clippy clean. Manual check: with pixelpass off PATH, create a room and
click Share Screen — the explainer appears; backdrop / ✕ / "Got it" dismiss it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controls column (mute/deafen/PTT/echo/record/share/Leave) had no scroll,
so on a short window the bottom of it — including Leave — was clipped with no
way to reach it; the only workaround was enlarging the window. At a small
enough size you couldn't exit the call through the UI at all.
Fix: pin Leave at the bottom of the control panel and wrap the controls above
it in a scrollable (height Fill). The controls now scroll when the window is
too short, and Leave (the exit control) is always visible. Applies to all
three room layouts (control_panel is Fill-height in every arm).
Build + clippy clean. Manual check: create a room, shrink the window — Leave
stays put, controls scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The participant row right-anchors [name | space | share | mute | indicator],
with the status indicator ([Idle]/[Speaking]/[Muted]/[Connecting…]) as the
rightmost element. Those labels differ in width, so when a peer started
speaking the indicator grew and pushed the whole right cluster — including the
mute button — leftward, making the mute icon visibly jump.
Fix: render the indicator in a fixed-width (124px), right-aligned slot sized
for the longest label, so its left edge (and the mute button beside it) stays
put across state changes.
Build + clippy clean. Layout-only; visual confirmation wants a 2-machine call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>