Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 5 9f3d0ac2ea feat(probe): give audio_probe a Windows/cpal arm
The probe drove PipeWire directly and stubbed out everywhere else, so the
Windows port had no way to isolate the local output path. win_probe mirrors
unix_probe against CpalBackend: same phase-continuous sine, same fill-paced
production against PLAYBACK_TARGET_SAMPLES, same playout-health log tailer.

NOT COMPILE-VERIFIED. The new module is behind #[cfg(windows)], so a Linux
host never type-checks it; it needs a cargo check on a Windows target (or the
mingw cross target) before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:22:50 -04:00
mollusk 2eae95ede0 Merge Windows chimes + docs + cpal diagnostics (W7/W2/W8)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:21:58 -04:00
molluskandClaude Opus 4.8 fdd532de53 Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2)
Two safe, host-independent hardening steps from the Codex Windows-compat review.

W7 — when a saved input/output device name no longer resolves (WASAPI friendly
names can change across driver/endpoint changes), resolve() now logs the
fallback to the system default instead of switching devices silently — so a
"my audio went to the wrong device" report has a log line explaining why.
(cpal 0.15 exposes only the device name, so a stable hardware id isn't available
to persist; this surfaces the limitation rather than hiding it.)

W2 — the output RT callback now records the largest interleaved buffer length it
is ever asked for (a wait-free fetch_max into an atomic, kept off the log/alloc
path). The once-per-second health-logger reports that size and, if a callback
ever exceeds the prefill target (PLAYBACK_TARGET_SAMPLES), warns explicitly —
that's the exact signature of the WASAPI-shared-mode underrun-every-cycle bug.
This is the diagnostic a real-host test needs before committing to the
structural fix (larger target / fixed buffer); no behavior change.

Windows-only file (cfg(windows)); compile-verified via the windows-gnu
cross-build, not yet exercised on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:21:53 -04:00
molluskandClaude Opus 4.8 46809153d8 Windows: notification chimes via SoundPlayer + docs/WINDOWS.md (W8)
Implemented by Codex (gpt-5.5); reviewed and committed by Claude.

W8 — chimes were played by shelling out to pw-play/paplay/aplay, which don't
exist on Windows, so every chime silently no-op'd there. spawn_player is now
cfg-split: Linux/unix keeps the existing player list; Windows plays the WAV via
PowerShell's System.Media.SoundPlayer (PlaySync on the existing detached thread).
Dependency-free, same fire-and-forget / silent-on-failure contract. Custom chime
paths are single-quote-escaped for the PowerShell command (helper + unit test).

Also adds docs/WINDOWS.md: a build/run/status guide (native MSVC + cross-compile
to -gnu, first-run firewall/UDP note, %APPDATA% paths, and the honest known-gaps
table — echo-cancel/screenshare/resampling/device-id/buffer-pacing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:21:40 -04:00
mollusk 6ccad0d37a Merge Windows-compat quick wins (W5/W6/W9) into windows port
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:03:55 -04:00
molluskandClaude Opus 4.8 ddb3d2aabc Windows compat quick wins: echo-cancel UI gate, pixelpass .exe, cfg tighten (W5/W6/W9)
Three Windows-compatibility fixes from the Codex review. Implemented by Codex
(gpt-5.5); reviewed and committed by Claude.

W5 — echo cancellation is a Linux/PipeWire feature, but the toggle was shown and
live on Windows, so a Windows join tried `pactl` and errored before falling back.
Now `#[cfg(target_os = "linux")]` gates the core enable path (and the
ActiveSession guard field); on other targets the Settings + in-call controls
render as a disabled checkbox with a "not available on Windows yet" note.

W6 — pixelpass PATH lookup only tried `pixelpass`; on Windows it now also tries
`pixelpass.exe` via a cfg-selected candidate list (+ unit test).

W9 — the Linux audio stack (pipewire/pw_cli/echo_cancel/audio_probe + the
`PlatformAudioBackend` alias and device-enum re-export) was gated `cfg(unix)`;
tightened to `cfg(target_os = "linux")` so a hypothetical macOS build won't try
to compile PipeWire. cpal stays `cfg(windows)`. Genuinely-Unix file/key
permission code in lib.rs/identity.rs left as `cfg(unix)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:03:49 -04:00
molluskandClaude Opus 4.8 bbbe2d8f17 Windows audio (cpal): startup handshake + RT-safe capture ring (W1, W3)
Two correctness fixes for the cpal/WASAPI backend from the Codex Windows-compat
review, plus device logging.

W1 — start_capture/start_playback no longer return Ok before the stream exists.
The owning thread did device resolution, config selection, build_stream, and
play() and only *logged* failures, so a missing 48 kHz config / unsupported
format / WASAPI error left the UI in a joined-but-silent room. The worker now
reports readiness over a channel and start_* blocks on it via finish_start(),
returning the real AudioError on failure (and joining the dead worker).

W3 — the RT capture callback no longer allocates or sends on a channel. It now
only downmixes and wait-free-pushes mono samples into a preallocated lock-free
HeapRb; the owning thread drains that ring, frames it (the Vec allocation lives
off the RT path), and sends completed frames. A full ring increments an overrun
counter instead of blocking. Restores the no-alloc/no-block-in-callback contract
the PipeWire backend already honors.

Also logs the selected device name / sample format / channels / rate on stream
start (a review nice-to-have) and logs capture overruns when they occur.

Windows-only file (cfg(windows)); Linux build unaffected. Compile-verified via
the windows-gnu cross-build; not yet run on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:57:41 -04:00
molluskandClaude Opus 4.8 63b45e03ab Windows port: cfg-gate iced window application_id (Linux-only field)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
iced's `window::settings::PlatformSpecific::application_id` only exists on
Linux (X11/Wayland use it to match the .desktop launcher icon); on Windows
the struct exposes a different field set, so the unconditional assignment
failed to compile for `*-pc-windows-*`. This was the first real Windows
compile blocker surfaced now that the port actually cross-compiles.

Move the field behind a `platform_specific_settings()` helper gated on
`target_os = "linux"`, with a defaults-only variant elsewhere. Linux build
unchanged (verified `cargo check`); the windows-gnu target now builds a
runnable .exe (verified launching under Wine: GUI renders, iroh network
stack + ring identity init, config/identity land in %APPDATA%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:03:59 -04:00
molluskandClaude Opus 4.8 2937e5191a Windows port Phase 2: cpal device enumeration
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Give the Windows device pickers a real device list (Phase 0/1 left pw_cli
returning nothing off-Linux) and generalize enumeration into a
platform-neutral interface.

- audio/mod.rs: move AudioDevice here (neutral home), gate pw_cli to
  cfg(unix), and re-export enumerate_audio_devices per-platform (pw_cli on
  unix, cpal_impl on windows). Also drop a now-stale "no-op stub" doc note.
- cpal_impl.rs: add enumerate_audio_devices() — iterate the cpal host's
  input + output devices into AudioDevice (name == description == the cpal
  friendly name, which is what resolve() matches target_node against, so a
  saved selection round-trips), sorted by description.
- pw_cli.rs: use super::AudioDevice instead of a local copy; parsing +
  tests unchanged.
- app/mod.rs: one-line import change; the device-picker logic is untouched.

Verified: shipped Linux state green (build --locked, clippy, 316/316,
pw_cli parse tests 6/6); the cpal enumerator compiles against real cpal via
the Linux/ALSA toggle. Runtime device listing on Windows is pending a real
host (M2/M3). WASAPI names are less stable than PipeWire node names, so a
saved device may not always round-trip (falls back to default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:28:12 -04:00
molluskandClaude Opus 4.8 47c58047ce Windows port Phase 1: real cpal/WASAPI audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
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>
2026-06-18 17:15:56 -04:00
molluskandClaude Opus 4.8 85b12a26c9 Windows port Phase 0: platform-select the audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
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>
2026-06-18 16:47:13 -04:00
molluskandClaude Opus 4.8 e4767be210 ci: enforce cargo-deny supply-chain policy on push and PRs
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>
2026-06-18 16:00:36 -04:00
mollusk 10ee765ffd Merge pull request 'Security scan' (#1) from security-scan into main
Reviewed-on: #1
2026-06-18 19:57:56 +00:00
molluskandClaude Opus 4.8 3034c42f71 Add security review report for security-scan branch
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>
2026-06-18 15:54:35 -04:00
molluskandClaude Opus 4.8 465c7ba2b0 Add cargo-deny supply-chain policy (deny.toml)
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>
2026-06-18 15:46:52 -04:00
molluskandClaude Opus 4.8 3ec09de87e Adopt versioning standard + migrate to versioned protocol planes (0.2.0)
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>
2026-06-18 05:20:27 -04:00
mollusk 4b8fb92dc5 Merge codex-security-s8-audio-membership: gate inbound audio by live room membership (S8) 2026-06-18 04:41:41 -04:00
molluskandClaude Opus 4.8 1adf8a97bb S8 (Pass 2): wire grace-aware audio-membership admission
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>
2026-06-18 04:41:37 -04:00
molluskandClaude Opus 4.8 10707152a3 S8 (design-first): pure audio_sender_admitted membership seam (unwired)
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>
2026-06-18 04:24:01 -04:00
mollusk f2e72624f7 Merge codex-security-s11-presence-discovery: honest presence/discovery state on apply failure (S11) 2026-06-18 03:32:42 -04:00
molluskandClaude Opus 4.8 319d0c5e29 S11: make presence/discovery state honest on apply failure
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>
2026-06-18 03:32:38 -04:00
mollusk d56c2c90b2 Merge codex-security-hardening: S10 log redaction + T1/T2/T5/T6/T7 trust-boundary fixes 2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 5086e86bd2 Security hardening: log redaction + 5 trust-boundary fixes (S10, T1/T2/T5/T6/T7)
Codex (gpt-5.5) implementer branch, senior-reviewed.

- S10 (High): redact capabilities/chat from logs; create log 0600 + chmod
  existing; rotate at 5 MiB. New short_id/short_bytes_hex/redact_for_log seams.
- T1 (P2): friends-ALPN authorizes (handler(from)) before reading any peer
  bytes; unauthorized conns closed pre-read (DoS relief).
- T2 (P2): per-(author,kind) replay gate on state-changing gossip (Announce/
  Leave) only; Chat bypasses it, preserving the S2 no-monotonic-ts decision.
- T5 (P2): cap inbound Opus datagrams at 4 + MAX_OPUS_PAYLOAD (4000).
- T6 (P2): bind friend-Pong room ticket host to the authenticated responder
  (interpret_pong/probe now thread the remote id) — blocks Join-button
  redirect/phishing. Non-regressive given the W7 P3 restamp design.
- T7 (P3): sanitize_ticket caps/validates PeerState.sharing at gossip ingest
  so invalid offers never render a Watch button.

302 lib tests pass (was 291), clippy --all-targets clean, release builds.
Tests-green only; DoS relief + 2-machine replay/redirect behavior want a
field test. W7 P7 (n0 DNS privacy) reviewed read-only — findings to triage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 54780fa73b Remove Codex task-report.md from repo root
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>
2026-06-18 01:51:26 -04:00
molluskandClaude Opus 4.8 b1aa751a84 Merge codex-home-empty-state-ui: focused empty-state Home layout
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>
2026-06-18 01:36:19 -04:00
molluskandClaude Opus 4.8 9efab491c7 Merge codex-settings-category-nav: category-navigated Settings
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>
2026-06-18 01:32:17 -04:00
molluskandClaude Opus 4.8 f3f399a748 Merge codex-wishlist-audio-hotkeys: per-peer EQ (W2), spatial pan + stereo bus (W1), focused hotkeys (W5), + A21/A22/A14 fixes
W2: src/audio/eq.rs 3-band RBJ biquad EQ, per-peer, flat=bypass.
W1: src/audio/pan.rs constant-power pan; mixer/playback/recorder converted
    to a stereo bus, bit-for-bit dual-mono at pan=0.
W5: src/hotkeys.rs config-backed focused hotkey map + Settings editor + info popup.
A21: jitter resets on large seq discontinuities (sender restart / far jump).
A22: WAV writer guards RIFF/data size overflow.
A14: orderly window-close shutdown (finalize recordings, leave room, close net).
W3 (PipeWire routing) intentionally left as a design note.

No new deps; no wire/serialization changes. Tests-green only; audio + 2-machine
field verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:29:45 -04:00
molluskandClaude Opus 4.8 1afdccbefe Merge codex-security-s9: bind gossip Announce addr to authenticated author (S9)
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>
2026-06-18 01:29:34 -04:00
mollusk 7724da73b8 Refine home empty-state layout 2026-06-17 17:06:45 -04:00
mollusk 8982df364e Fix gossip announce address binding 2026-06-17 16:17:03 -04:00
mollusk 33e3998e7c Add orderly shutdown on window close 2026-06-16 17:37:00 -04:00
mollusk 44bad7b70b Fix jitter restart and WAV size overflow 2026-06-16 17:28:41 -04:00
mollusk 20643a24de Add audio controls and focused hotkeys 2026-06-16 17:23:38 -04:00
32 changed files with 4638 additions and 475 deletions
+34
View File
@@ -0,0 +1,34 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
+85
View File
@@ -0,0 +1,85 @@
name: windows-build
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
# must provide both.
on:
push:
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
# which CMake 4.x rejects unless this is set. See the opus spike report.
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
jobs:
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (MSVC, pinned to repo toolchain if present)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Show toolchain + build prerequisites
shell: bash
run: |
set -euo pipefail
rustc --version
cargo --version
# libopus is built from source via cmake; fail early with a clear
# message if the runner lacks it rather than deep in the opus build.
if ! command -v cmake >/dev/null 2>&1; then
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
exit 1
fi
cmake --version
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
# get compiled — same supply-chain stance as the cargo-deny job.
- name: Build (all targets, msvc)
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
# real iroh/QUIC endpoints and need working loopback networking, which isn't
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
# runner is confirmed.
- name: Unit tests (lib, msvc)
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
# Informational for now (not `-D warnings`): the Windows tree may surface
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
# it's clean.
- name: Clippy (msvc)
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
Generated
+269 -25
View File
@@ -105,6 +105,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -114,12 +136,12 @@ dependencies = [
"android-properties",
"bitflags 2.11.1",
"cc",
"jni",
"jni 0.22.4",
"libc",
"log",
"ndk",
"ndk 0.9.0",
"ndk-context",
"ndk-sys",
"ndk-sys 0.6.0+11769913",
"num_enum",
"thiserror 2.0.18",
]
@@ -712,6 +734,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -1002,6 +1030,26 @@ dependencies = [
"libm",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen",
]
[[package]]
name = "cosmic-text"
version = "0.15.0"
@@ -1026,6 +1074,29 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1228,6 +1299,12 @@ dependencies = [
"syn",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -2227,7 +2304,7 @@ dependencies = [
"http",
"idna",
"ipnet",
"jni",
"jni 0.22.4",
"rand 0.10.1",
"rustls",
"thiserror 2.0.18",
@@ -2247,7 +2324,7 @@ dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni",
"jni 0.22.4",
"once_cell",
"prefix-trie",
"rand 0.10.1",
@@ -2270,7 +2347,7 @@ dependencies = [
"hickory-proto",
"ipconfig",
"ipnet",
"jni",
"jni 0.22.4",
"moka",
"ndk-context",
"once_cell",
@@ -3102,6 +3179,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -3463,6 +3556,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -3596,7 +3698,7 @@ dependencies = [
"dispatch",
"futures-channel",
"futures-lite",
"jni",
"jni 0.22.4",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
@@ -3695,6 +3797,20 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3704,7 +3820,7 @@ dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
"ndk-sys 0.6.0+11769913",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
@@ -3716,6 +3832,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -4466,6 +4591,29 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -4594,12 +4742,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"bytes",
"cpal",
"dirs",
"iced",
"image",
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
@@ -5877,7 +6026,7 @@ dependencies = [
"fastrand",
"js-sys",
"memmap2",
"ndk",
"ndk 0.9.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -7162,7 +7311,7 @@ dependencies = [
"log",
"metal",
"naga",
"ndk-sys",
"ndk-sys 0.6.0+11769913",
"objc",
"once_cell",
"ordered-float",
@@ -7247,6 +7396,16 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -7278,6 +7437,16 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -7288,7 +7457,7 @@ dependencies = [
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -7386,13 +7555,22 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -7423,13 +7601,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -7441,20 +7628,35 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -7466,18 +7668,36 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7490,24 +7710,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -7536,7 +7780,7 @@ dependencies = [
"js-sys",
"libc",
"memmap2",
"ndk",
"ndk 0.9.0",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
+27 -8
View File
@@ -1,7 +1,10 @@
[package]
name = "peerspeak"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
publish = false
[lib]
name = "peerspeak"
@@ -27,17 +30,12 @@ bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
# the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
iroh = "1.0.0-rc.0"
iroh-gossip = "0.99.0"
opus = "0.3.1"
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
# the playback RT callback to fill exactly what the device asks for instead of
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
rand = "0.10.1"
ringbuf = "0.5.0"
serde = { version = "1.0.228", features = ["derive"] }
@@ -45,3 +43,24 @@ serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18"
# --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
[target.'cfg(target_os = "linux")'.dependencies]
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
# quantum), used by the playback RT callback to fill exactly what the device asks
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
# The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
# Native file picker via the XDG desktop portal (no GTK) on Linux.
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
[target.'cfg(windows)'.dependencies]
# Native file picker using the built-in Win32 dialog backend on Windows.
rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15"
+51
View File
@@ -0,0 +1,51 @@
# Security Review: `security-scan` branch (PeerSpeak)
_Date: 2026-06-18_
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
supply-chain policy addition.
## Result: No high-confidence security vulnerabilities found.
Each plausible attack surface introduced by this branch was investigated and
confirmed safe:
### 1. `versioned_topic` XOR transform — topic secrecy preserved
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
entropy-preserving** — the result is still uniformly random; no byte becomes
predictable and no entropy is lost. The room secret is no more recoverable by an
observer than before the change (previously the raw `topic_id` was the on-wire
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
distinctness, so isolation is not weakened. **Not a vulnerability.**
### 2. Signature topic-binding — no raw/versioned confusion
`src/network/gossip.rs`
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
per join and every peer signs/verifies against the same raw topic, so no second
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
VERSIONING.md's claim. **Not a vulnerability.**
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
cryptographic domain separation preserved. **Not a vulnerability.**
### 4. ALPN changes — handshake compatibility only
Audio `peerspeak-audio``peerspeak/audio/1`, friends `/0``/1`. No security
check keys off the old ALPN strings (audio admission is gated by live room
membership per S8, not the ALPN literal); no residual references to old strings
in non-test code. **Not a vulnerability.**
### 5. `deny.toml`
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
advisories are out of scope. **Not a vulnerability.**
The versioning migration is a clean, security-preserving change.
+139
View File
@@ -0,0 +1,139 @@
# PeerSpeak Versioning Standard
PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library
surface — it is the **wire protocol** two nodes use to talk. So versioning here
tracks one question above all others:
> **Can a node on build X talk to a node on build Y?**
There are two distinct version layers. Keep them straight.
---
## Layer 1 — Release version (`Cargo.toml`)
The human-facing label you put on a build ("install this one").
**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).**
While we are pre-1.0 (friends-only, no stability promise yet):
| Change | Bump | Example |
| --- | --- | --- |
| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` |
| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` |
- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends
circle (a "public" release), and we are willing to commit to wire stability.
After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal
SemVer).
- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the
same commit. The number in `Cargo.toml` is the source of truth; surface it in
the UI (e.g. an About/Settings line) so a user can read their build.
**Rule of thumb:** if you find yourself writing "all peers must rebuild" or
"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a
**MINOR** bump, and it must also bump the relevant protocol version in Layer 2.
---
## Layer 2 — Protocol compatibility (the one that actually breaks calls)
Wire incompatibility must **fail fast and legibly** — never as a silent
signature/decode error that looks like a bug or an attack. We achieve this by
embedding a protocol version into each transport plane, so incompatible peers
are rejected at connect/subscribe time instead of mid-conversation.
PeerSpeak has **three independent planes**, each versioned **separately** — bump
only the plane whose wire format actually changed (audio rarely changes; gossip
changes often; they must not be forced to bump together).
### ALPN naming convention
All peerspeak ALPNs use the form **`peerspeak/<plane>/<N>`** where `<N>` is that
plane's protocol version (an integer, starts at `1`). iroh refuses a connection
whose ALPN does not match exactly, so two peers on different `<N>` for a plane
simply cannot open that connection → we map that to a clean "peer is running an
incompatible version" instead of garbage.
| Plane | ALPN / mechanism | Bump when… |
| --- | --- | --- |
| **Audio** | ALPN `peerspeak/audio/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | the `ControlMsg` / presence ping-pong shape changes |
| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change |
### Gossip is special
The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not
control, so we cannot version it via the ALPN. Instead, the gossip protocol
version is bound in **two** places:
1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random
32 bytes carried in the ticket, but the topic we actually *subscribe* to is
`protocol::versioned_topic(topic_id)` — a deterministic, dependency-free
transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip
versions therefore derive **different subscription topics from the same ticket**
and never share a swarm — the same isolation a versioned ALPN gives the other
planes. The ticket format and the room identity (`topic_id`) are unchanged; only
the subscribed topic is namespaced. (The transform is for *isolation*, not
security — cryptographic separation is the signature domain below.)
2. **Signature domain (cryptographic separation):** the signing domain string
(`peerspeak-gossip-v<N>`, bound into every signed payload) carries the version,
so two versions that somehow met on a topic would fail each other's verification
rather than misread it.
Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives
`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in
`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift).
### Single source of truth for protocol versions
All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic`
live in **`src/protocol.rs`**. Every call site derives from there (e.g.
`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A
unit test asserts each ALPN/domain string matches its integer version so a bump
can't half-apply.
---
## "I changed X — what do I bump?" (quick reference)
| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) |
| --- | --- | --- |
| Opus framing / audio datagram layout | `peerspeak/audio/N``N+1` | MINOR |
| `ControlMsg` / presence shape | `peerspeak/friends/N``N+1` | MINOR |
| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR |
| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH |
| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR |
When in doubt about "is this additive-safe?", assume **breaking** and bump. A
false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken
calls in the field.
---
## Release checklist (per build handed to anyone)
1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`.
2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were
bumped in the same change.
3. Note the version + "breaking?" in the commit / handoff.
4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit.
5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend
releases) when the bump was a MINOR/wire break.
---
## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`)
- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking)
migration to this standard. **All peers must run ≥ `0.2.0` to interoperate**
(the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build
(e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it
now fails cleanly at the handshake instead of silently.
- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`,
gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from
`src/protocol.rs`.
- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the
UI (an About/Settings line) and/or log it at startup, so a running build is
self-identifying in the field. Small follow-up.
+88
View File
@@ -0,0 +1,88 @@
# cargo-deny policy for peerspeak
#
# Supersedes a bare `cargo audit` run. Enforce with:
# cargo install cargo-deny --locked
# cargo deny check
#
# In CI, run `cargo deny check` on a locked tree so the pinned, vetted
# versions in Cargo.lock are what actually get audited.
# ---------------------------------------------------------------------------
# Advisories: RustSec database. Vulnerabilities and yanked crates are denied
# by default. The two `ignore` entries below are *unmaintained* warnings only
# (no known exploit); they are deep transitive deps we cannot remove. Pinning
# them via Cargo.lock is our real protection — a future malicious release does
# not reach us until we deliberately `cargo update`, so each update is a review
# checkpoint. Revisit these if either advisory is upgraded to a vulnerability.
# ---------------------------------------------------------------------------
[advisories]
ignore = [
# paste: unmaintained, compile-time proc-macro only (zero runtime surface),
# transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork
# `pastey` is already in the tree; stragglers will follow upstream.
"RUSTSEC-2024-0436",
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
"RUSTSEC-2026-0150",
]
# ---------------------------------------------------------------------------
# Bans: shape of the dependency graph.
# ---------------------------------------------------------------------------
[bans]
# Multiple versions of the same crate bloat the build; warn rather than fail
# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix.
multiple-versions = "warn"
# Wildcard ("*") version requirements are a supply-chain footgun: they accept
# any future release, defeating the lockfile-as-review-checkpoint model.
wildcards = "deny"
# ...but our own intra-repo path deps may use "*"; don't penalize those.
allow-wildcard-paths = true
# Crates that may never appear in the graph. Add a maintained replacement's
# predecessor here once you've migrated off it, to prevent regressions.
deny = []
# ---------------------------------------------------------------------------
# Sources: where crates are allowed to come from. This is the core anti-hijack
# control — only the official crates.io registry is trusted; arbitrary git
# sources (a common vector for slipping in unaudited code) are rejected.
# ---------------------------------------------------------------------------
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# allow-git = [] # add a specific, pinned git repo here only if ever needed
# ---------------------------------------------------------------------------
# Licenses: permissive set covering the current graph. If `cargo deny check`
# reports an unmatched license, vet it and add the SPDX id here (or add a
# per-crate entry under [licenses.exceptions]) rather than widening blindly.
# ---------------------------------------------------------------------------
[licenses]
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"MPL-2.0",
"Unicode-3.0",
"Unicode-DFS-2016",
"CC0-1.0",
"0BSD",
"Unlicense",
"BSL-1.0",
"NCSA", # University of Illinois/NCSA — BSD-like permissive
"CDLA-Permissive-2.0", # Community Data License Agreement, permissive
]
confidence-threshold = 0.8
exceptions = []
# peerspeak itself has no `license` field and is not published, so skip the
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
# if/when this is ever published.
[licenses.private]
ignore = true
+77
View File
@@ -0,0 +1,77 @@
# PeerSpeak on Windows
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
checks listed below.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
Windows paths are resolved through `dirs`:
- Config: `%APPDATA%\peerspeak\config.json`
- Identity: `%APPDATA%\peerspeak\identity.key`
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
## Building
### Native Windows
Install MSVC Build Tools and CMake, then build normally:
```powershell
cargo build --release
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
```powershell
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
cargo build --release
```
### Cross-compile from Linux
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
```sh
rustup target add x86_64-pc-windows-gnu
sudo pacman -S mingw-w64-gcc cmake
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
```
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
spike) lives in the maintainer's handoff docs, outside the repo.
## First run and networking
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
The default network mode keeps the n0 relay available for NAT traversal without publishing
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
connections are expected and valid.
## Known gaps
| Item | Status |
|---|---|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
Before calling Windows support done, verify a real Windows machine can create/join a room,
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
play notification chimes.
+720 -156
View File
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
//! Windows audio backend — cpal / WASAPI (Phase 1).
//!
//! Implements [`AudioBackend`] on top of [`cpal`], which wraps WASAPI on Windows.
//! It is the Windows counterpart to `pipewire_impl.rs` and deliberately preserves
//! the exact same contract so the rest of the app (mixer, encoder, jitter buffer)
//! is unchanged:
//!
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
//! The RT capture callback only downmixes and pushes samples into a lock-free
//! ring; the owning thread drains that ring, frames it, and sends — so the
//! callback never allocates, locks, or touches an mpsc channel.
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
//! drained from a ring buffer that is paced to the device's hardware clock via
//! `ring_fill` exactly as the PipeWire backend does.
//!
//! ## Threading and the `!Send` stream
//!
//! `cpal::Stream` is `!Send` (some backends require it to be created and dropped
//! on the same thread), but [`AudioBackend`] is `Send + Sync` and the backend is
//! shared through an `Arc`. So the stream never lives in the struct: each of
//! `start_capture`/`start_playback` spawns one owning thread that builds the
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
//! handle). The stream's RT callback does the actual audio work; the owning
//! thread additionally feeds the playback ring (or drains the capture ring).
//!
//! `start_*` does not return until the owning thread reports back over a readiness
//! channel that the device resolved and the stream is built and playing — so a
//! device/format/WASAPI failure surfaces as a real `Err` to the caller instead of
//! leaving the UI in a joined-but-silent room.
//!
//! ## Sample rate
//!
//! The whole pipeline assumes 48 kHz (Opus + the 960-sample frame). Phase 1 only
//! selects a native-48 kHz device config; if the device can't do 48 kHz we return
//! a clear error rather than silently producing pitch-shifted audio. Arbitrary
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, FromSample, Sample, SampleFormat, SampleRate, SizedSample, Stream, StreamConfig};
use ringbuf::{
traits::{Consumer, Producer, Split},
HeapRb,
};
use super::{AudioBackend, AudioDevice, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
const SAMPLE_RATE: u32 = 48_000;
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
/// and `core::jitter::FRAME_SAMPLES`.
const CAPTURE_FRAME: usize = 960;
/// Lock-free capture ring capacity (mono samples) between the RT callback and the
/// owning drain thread: 8 frames = 160 ms of headroom, so a scheduling hiccup on
/// the drain thread doesn't immediately overrun the RT producer.
const CAPTURE_RING_CAPACITY: usize = CAPTURE_FRAME * 8;
/// How long the capture drain thread sleeps when the ring is momentarily empty,
/// before polling again. Small enough to stay well under the 20 ms frame cadence.
const CAPTURE_POLL: Duration = Duration::from_millis(5);
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
/// headroom and never has to drop frames in steady state.
const RING_CAPACITY: usize = 9600 * PLAYBACK_CHANNELS;
/// How often a blocked playback worker re-checks its `running` flag, bounding how
/// long `stop()` can take to join it (mirrors the PipeWire backend's `WORKER_POLL`).
const WORKER_POLL: Duration = Duration::from_millis(100);
/// Windows audio backend. See module docs.
pub struct CpalBackend {
capture: Mutex<Option<StreamWorker>>,
playback: Mutex<Option<StreamWorker>>,
}
/// A spawned owning thread plus the flag that tells it to drop its stream and exit.
struct StreamWorker {
running: Arc<AtomicBool>,
thread: JoinHandle<()>,
}
impl CpalBackend {
pub fn new() -> Self {
Self {
capture: Mutex::new(None),
playback: Mutex::new(None),
}
}
}
impl Default for CpalBackend {
fn default() -> Self {
Self::new()
}
}
impl AudioBackend for CpalBackend {
fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let guard = self.capture.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-capture".to_string())
.spawn(move || {
run_capture(tx, target_node, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "capture")
}
fn start_playback(
&self,
rx: Receiver<Vec<i16>>,
target_node: Option<String>,
ring_fill: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
let guard = self.playback.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Playback already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-playback".to_string())
.spawn(move || {
run_playback(rx, target_node, ring_fill, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "playback")
}
fn stop(&self) -> Result<(), AudioError> {
for slot in [&self.capture, &self.playback] {
if let Some(worker) = slot.lock().unwrap().take() {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
}
}
Ok(())
}
}
/// Block until the just-spawned worker reports (over `ready_rx`) that its stream
/// is built and playing, then either install it (`Ok`) or join it and surface the
/// real error. This is what makes `start_capture`/`start_playback` fail loudly
/// instead of returning `Ok` into a joined-but-silent room (Codex review W1).
fn finish_start(
mut guard: std::sync::MutexGuard<'_, Option<StreamWorker>>,
worker: StreamWorker,
ready_rx: Receiver<Result<(), AudioError>>,
what: &str,
) -> Result<(), AudioError> {
match ready_rx.recv() {
Ok(Ok(())) => {
*guard = Some(worker);
Ok(())
}
// Setup failed (Err) or the worker exited before reporting (recv Err):
// either way it has stopped, so reap it and surface the error.
Ok(Err(e)) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(e)
}
Err(_) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(AudioError::Init(format!(
"cpal {what} worker exited before reporting readiness"
)))
}
}
}
// ---------------------------------------------------------------------------
// Device enumeration (for the settings device pickers)
// ---------------------------------------------------------------------------
/// Enumerate WASAPI input/output devices via cpal, sorted by description to match
/// the PipeWire backend's stable UI ordering.
///
/// cpal exposes a single friendly name per device, which is also what [`resolve`]
/// matches `target_node` against — so `name` and `description` are the same string
/// and a saved selection round-trips. Note: WASAPI device names are less stable
/// across driver/endpoint changes than PipeWire node names, so a saved device may
/// not always be found again; selection then falls back to the system default.
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let host = cpal::default_host();
let mut devices = Vec::new();
if let Ok(inputs) = host.input_devices() {
for device in inputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: true,
});
}
}
}
if let Ok(outputs) = host.output_devices() {
for device in outputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: false,
});
}
}
}
devices.sort_by(|a, b| a.description.cmp(&b.description));
devices
}
// ---------------------------------------------------------------------------
// Device / config selection
// ---------------------------------------------------------------------------
/// Resolve a device (by `target` name, else the system default) and a stream
/// config running natively at [`SAMPLE_RATE`].
///
/// For output we require [`PLAYBACK_CHANNELS`] (stereo) so the interleaved ring
/// maps 1:1 to the device buffer; for input we prefer mono but accept any channel
/// count and downmix. A device with no 48 kHz config is a hard error (no
/// resampling yet — see module docs).
fn resolve(
output: bool,
target: Option<String>,
) -> Result<(Device, StreamConfig, SampleFormat), AudioError> {
let host = cpal::default_host();
let default = || {
if output {
host.default_output_device()
} else {
host.default_input_device()
}
};
let device = match target {
// A saved device name that no longer resolves falls back to the system
// default — but log it, because WASAPI friendly names can change across
// driver/endpoint changes, so a silent fallback otherwise looks like
// "audio went to the wrong device for no reason" (review W7).
Some(ref name) => match find_device_by_name(&host, output, name) {
Some(dev) => Some(dev),
None => {
crate::log_msg(&format!(
"cpal: saved {} device '{name}' not found; using system default",
if output { "output" } else { "input" },
));
default()
}
},
None => default(),
}
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
let supported = choose_config(&device, output)?;
let sample_format = supported.sample_format();
let config = supported.config();
Ok((device, config, sample_format))
}
fn find_device_by_name(host: &cpal::Host, output: bool, name: &str) -> Option<Device> {
let devices = if output {
host.output_devices().ok()?
} else {
host.input_devices().ok()?
};
devices.into_iter().find(|d| d.name().is_ok_and(|n| n == name))
}
/// Pick a supported config at exactly [`SAMPLE_RATE`]. Output must be stereo;
/// input prefers mono, then any channel count (downmixed later).
fn choose_config(
device: &Device,
output: bool,
) -> Result<cpal::SupportedStreamConfig, AudioError> {
let ranges: Vec<cpal::SupportedStreamConfigRange> = if output {
device
.supported_output_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
} else {
device
.supported_input_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
};
// A range covers a sample-rate span and a fixed channel count.
let supports_48k = |r: &cpal::SupportedStreamConfigRange| {
r.min_sample_rate().0 <= SAMPLE_RATE && SAMPLE_RATE <= r.max_sample_rate().0
};
let pick = |channels: Option<u16>| {
ranges
.iter()
.find(|r| supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
.cloned()
};
let chosen = if output {
pick(Some(PLAYBACK_CHANNELS as u16))
} else {
pick(Some(1)).or_else(|| pick(None))
};
chosen
.map(|r| r.with_sample_rate(SampleRate(SAMPLE_RATE)))
.ok_or_else(|| {
AudioError::Device(format!(
"device '{}' has no {SAMPLE_RATE} Hz {} config; resampling not yet implemented (Phase 1.1)",
device.name().unwrap_or_else(|_| "<unknown>".to_string()),
if output { "stereo output" } else { "input" },
))
})
}
// ---------------------------------------------------------------------------
// Capture
// ---------------------------------------------------------------------------
fn run_capture(
tx: Sender<Vec<i16>>,
target: Option<String>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
// The RT callback pushes mono samples into this lock-free ring; we drain it on
// this (non-RT) thread, so the callback never allocates or sends on a channel.
let rb = HeapRb::<i16>::new(CAPTURE_RING_CAPACITY);
let (producer, mut consumer) = rb.split();
let overrun = Arc::new(AtomicU64::new(0));
// Fallible device/stream setup. We report the real error to `start_capture`
// before doing any work, so a join never lands in a silent room.
let setup = || -> Result<(Stream, String, SampleFormat, usize), AudioError> {
let (device, config, sample_format) = resolve(false, target)?;
let channels = config.channels as usize;
let stream = match sample_format {
SampleFormat::F32 => {
build_input::<f32, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::I16 => {
build_input::<i16, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::U16 => {
build_input::<u16, _>(&device, &config, producer, channels, overrun.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported capture sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format, channels))
};
let (stream, dev_name, sample_format, channels) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal capture started: device='{dev_name}' format={sample_format:?} channels={channels} rate={SAMPLE_RATE} Hz"
));
// Drain the RT ring on this thread: pop mono samples, frame them (the `Vec`
// allocation lives here, off the RT path), and send completed frames. Keep
// `stream` alive until `stop()` flips the flag.
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
let mut last_overrun = 0u64;
while running.load(Ordering::Relaxed) {
let mut drained = false;
while let Some(sample) = consumer.try_pop() {
drained = true;
if let Some(frame) = acc.push(sample) {
// Consumer gone (call ended) → stop feeding; the stream is
// dropped below on the way out.
if tx.send(frame).is_err() {
drop(stream);
return;
}
}
}
let o = overrun.load(Ordering::Relaxed);
if o != last_overrun {
crate::log_msg(&format!(
"cpal capture overrun: dropped {} samples (drain thread fell behind)",
o - last_overrun
));
last_overrun = o;
}
if !drained {
thread::sleep(CAPTURE_POLL);
}
}
drop(stream);
}
fn build_input<T, P>(
device: &Device,
config: &StreamConfig,
mut producer: P,
channels: usize,
overrun: Arc<AtomicU64>,
) -> Result<Stream, AudioError>
where
T: SizedSample + Send + 'static,
i16: FromSample<T>,
P: Producer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
device
.build_input_stream::<T, _, _>(
config,
move |data: &[T], _| {
// RT-safe: downmix + wait-free push only. A full ring means the
// drain thread stalled; count the drop and keep going.
for frame in data.chunks_exact(channels) {
let mono = downmix_to_mono(frame);
if producer.try_push(mono).is_err() {
overrun.fetch_add(1, Ordering::Relaxed);
}
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Average a device frame's channels down to a single mono i16. For a 1-channel
/// device this is just the converted sample.
fn downmix_to_mono<T>(frame: &[T]) -> i16
where
T: Copy,
i16: FromSample<T>,
{
if frame.is_empty() {
return 0;
}
let sum: i32 = frame.iter().map(|&s| i16::from_sample(s) as i32).sum();
(sum / frame.len() as i32) as i16
}
/// Accumulates mono samples into fixed-size [`CAPTURE_FRAME`] frames. Pulled out
/// of the RT callback so the framing is unit-testable.
struct FrameAccumulator {
buf: Vec<i16>,
frame_len: usize,
}
impl FrameAccumulator {
fn new(frame_len: usize) -> Self {
Self {
buf: Vec::with_capacity(frame_len),
frame_len,
}
}
/// Push one sample; returns a completed frame when the buffer fills.
fn push(&mut self, sample: i16) -> Option<Vec<i16>> {
self.buf.push(sample);
if self.buf.len() == self.frame_len {
Some(std::mem::replace(
&mut self.buf,
Vec::with_capacity(self.frame_len),
))
} else {
None
}
}
}
// ---------------------------------------------------------------------------
// Playback
// ---------------------------------------------------------------------------
fn run_playback(
rx: Receiver<Vec<i16>>,
target: Option<String>,
ring_fill: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
let rb = HeapRb::<i16>::new(RING_CAPACITY);
let (mut producer, consumer) = rb.split();
// Prefill to the steady-state depth so playout starts at target. `ring_fill`
// is an EXACT occupancy counter maintained by deltas (worker fetch_add on
// push, RT callback fetch_sub on pop) — not ringbuf's cached `occupied_len`,
// which is stale across the split halves and would lie high and starve the
// ring. See pipewire_impl.rs for the full rationale.
for _ in 0..PLAYBACK_TARGET_SAMPLES {
let _ = producer.try_push(0);
}
ring_fill.store(PLAYBACK_TARGET_SAMPLES, Ordering::Relaxed);
// Diagnostics (mirrors the PipeWire backend's playout-health line).
let underrun = Arc::new(AtomicU64::new(0));
let dropped = Arc::new(AtomicU64::new(0));
// Largest single output-callback length seen (interleaved samples). WASAPI
// shared-mode picks its own period, so this can exceed the prefill target —
// which would force an underrun every cycle (review W2). The callback only
// does a wait-free fetch_max; the health logger reports/warns off the RT path.
let max_cb = Arc::new(AtomicUsize::new(0));
// Fallible device/stream setup; report the real error to `start_playback`
// before any work so a failure surfaces instead of a silent room. `consumer`
// is moved into the output callback here.
let setup = || -> Result<(Stream, String, SampleFormat), AudioError> {
let (device, config, sample_format) = resolve(true, target)?;
let stream = match sample_format {
SampleFormat::F32 => {
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::I16 => {
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::U16 => {
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format))
};
let (stream, dev_name, sample_format) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal playback started: device='{dev_name}' format={sample_format:?} channels={PLAYBACK_CHANNELS} rate={SAMPLE_RATE} Hz"
));
let logger = spawn_health_logger(
running.clone(),
ring_fill.clone(),
underrun.clone(),
dropped.clone(),
max_cb.clone(),
);
// Feed the ring from the network mixer until `stop()` flips `running` or the
// sender disconnects (call ended). Clock-paced production keeps the ring near
// target, so the drop path below should never fire in steady state.
drain_loop(&rx, &running, |frame| {
if ring_fill.load(Ordering::Relaxed) + frame.len() > RING_CAPACITY {
dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
ring_fill.fetch_add(frame.len(), Ordering::Relaxed);
});
// We're shutting down (either stop() or disconnect). Ensure the logger sees it
// even on the disconnect path, then drop the stream.
running.store(false, Ordering::Relaxed);
let _ = logger.join();
drop(stream);
}
fn build_output<T, C>(
device: &Device,
config: &StreamConfig,
mut consumer: C,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> Result<Stream, AudioError>
where
T: SizedSample + FromSample<i16> + Send + 'static,
C: Consumer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal playback stream error: {e}"));
device
.build_output_stream::<T, _, _>(
config,
move |data: &mut [T], _| {
// Wait-free; the logger thread reads this off the RT path.
max_cb.fetch_max(data.len(), Ordering::Relaxed);
let (popped, starved) = fill_output(&mut consumer, data);
if starved > 0 {
underrun.fetch_add(starved, Ordering::Relaxed);
}
if popped > 0 {
// Decrement the exact occupancy by what we actually pulled
// (underruns removed nothing) so the mixer paces against the
// true ring depth.
ring_fill.fetch_sub(popped, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Drain the ring into the device buffer, substituting silence on underrun.
/// Returns `(samples_popped, samples_starved)`. RT-safe (wait-free `try_pop`).
fn fill_output<T, C>(consumer: &mut C, out: &mut [T]) -> (usize, u64)
where
T: Sample + FromSample<i16>,
C: Consumer<Item = i16>,
{
let mut popped = 0usize;
let mut starved = 0u64;
for slot in out.iter_mut() {
match consumer.try_pop() {
Some(v) => {
*slot = T::from_sample(v);
popped += 1;
}
None => {
*slot = T::from_sample(0i16);
starved += 1;
}
}
}
(popped, starved)
}
/// Once-per-second playout-health line (mirrors the PipeWire backend). Quiet
/// unless a second actually glitched, or `PEERSPEAK_AUDIO_VERBOSE` is set.
fn spawn_health_logger(
running: Arc<AtomicBool>,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
dropped: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> JoinHandle<()> {
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
thread::spawn(move || {
let (mut last_u, mut last_d) = (0u64, 0u64);
let mut reported_cb = 0usize;
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(1));
let u = underrun.load(Ordering::Relaxed);
let d = dropped.load(Ordering::Relaxed);
let fill = ring_fill.load(Ordering::Relaxed);
let (du, dd) = (u - last_u, d - last_d);
last_u = u;
last_d = d;
if verbose || du > 0 || dd > 0 {
crate::log_msg(&format!(
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d})",
fill / (48 * PLAYBACK_CHANNELS),
));
}
// Report the device's callback size the first time it's seen (and on
// any new high). If a callback asks for more than the prefill target,
// the ring can't satisfy it and underruns every cycle — the W2 bug
// signature; warn so a real-host log shows whether it's biting.
let cb = max_cb.load(Ordering::Relaxed);
if cb > reported_cb {
reported_cb = cb;
let ms = cb / (48 * PLAYBACK_CHANNELS);
if cb > PLAYBACK_TARGET_SAMPLES {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)",
));
} else if verbose {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}",
));
}
}
}
})
}
/// Pump frames from `rx` to `on_frame` until `running` goes false or the sender
/// disconnects. The timed receive re-checks `running` at least every
/// [`WORKER_POLL`], so `stop()` can join the worker promptly instead of hanging
/// on a parked blocking `recv()` (same A7 fix as the PipeWire backend). Pure
/// w.r.t. its inputs, so it's unit-testable.
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn downmix_averages_channels() {
assert_eq!(downmix_to_mono::<i16>(&[100, 100]), 100);
assert_eq!(downmix_to_mono::<i16>(&[100, -100]), 0);
assert_eq!(downmix_to_mono::<i16>(&[50]), 50);
assert_eq!(downmix_to_mono::<i16>(&[]), 0);
// 4-channel average rounds toward zero (integer division).
assert_eq!(downmix_to_mono::<i16>(&[10, 20, 30, 41]), 25);
}
#[test]
fn frame_accumulator_emits_full_frames() {
let mut acc = FrameAccumulator::new(3);
assert_eq!(acc.push(1), None);
assert_eq!(acc.push(2), None);
assert_eq!(acc.push(3), Some(vec![1, 2, 3]));
// Resets for the next frame.
assert_eq!(acc.push(4), None);
assert_eq!(acc.push(5), None);
assert_eq!(acc.push(6), Some(vec![4, 5, 6]));
}
#[test]
fn fill_output_pops_then_substitutes_silence() {
let rb = HeapRb::<i16>::new(8);
let (mut prod, mut cons) = rb.split();
for v in [1, 2, 3] {
prod.try_push(v).unwrap();
}
let mut out = [0i16; 5];
let (popped, starved) = fill_output(&mut cons, &mut out);
assert_eq!(popped, 3);
assert_eq!(starved, 2);
assert_eq!(out, [1, 2, 3, 0, 0]);
}
#[test]
fn drain_loop_exits_when_running_flips_even_with_sender_alive() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |_| {}));
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
thread::sleep(WORKER_POLL + Duration::from_millis(150));
assert!(
h.is_finished(),
"drain_loop must exit after running=false even while the sender is alive"
);
drop(tx);
h.join().unwrap();
}
#[test]
fn drain_loop_returns_on_disconnect() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
drop(tx);
drain_loop(&rx, &running, |_| panic!("no frame should arrive"));
}
#[test]
fn drain_loop_delivers_frames() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let got = Arc::new(Mutex::new(Vec::new()));
let g2 = got.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |f| g2.lock().unwrap().push(f)));
tx.send(vec![1, 2, 3]).unwrap();
tx.send(vec![4, 5]).unwrap();
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
drop(tx);
h.join().unwrap();
assert_eq!(*got.lock().unwrap(), vec![vec![1, 2, 3], vec![4, 5]]);
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Per-peer listener-side voice EQ.
//!
//! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed
//! voice-oriented frequencies, with only gain exposed to the UI. State lives per
//! peer in the playout mixer so filter delay registers are continuous across 20ms
//! Opus frames; flat settings are treated as bypass so the default path is cheap
//! and sample-exact.
use serde::{Deserialize, Serialize};
const DEFAULT_SAMPLE_RATE: f32 = 48_000.0;
const LOW_SHELF_HZ: f32 = 160.0;
const MID_PEAK_HZ: f32 = 2_400.0;
const HIGH_SHELF_HZ: f32 = 6_500.0;
const MID_Q: f32 = 1.0;
const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const FLAT_EPSILON_DB: f32 = 0.001;
/// UI and config clamp for each band. Wide enough to be useful for voice, narrow
/// enough that a peer cannot accidentally make the listener-side limiter do all
/// the work.
pub const EQ_GAIN_DB_MIN: f32 = -12.0;
pub const EQ_GAIN_DB_MAX: f32 = 12.0;
/// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct EqSettings {
#[serde(default)]
pub low_gain_db: f32,
#[serde(default)]
pub mid_gain_db: f32,
#[serde(default)]
pub high_gain_db: f32,
}
impl Default for EqSettings {
fn default() -> Self {
Self {
low_gain_db: 0.0,
mid_gain_db: 0.0,
high_gain_db: 0.0,
}
}
}
impl EqSettings {
pub fn flat() -> Self {
Self::default()
}
/// Clamp all public gains to the supported UI/DSP range.
pub fn clamped(self) -> Self {
Self {
low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
}
}
/// True when the EQ should be bypassed entirely.
pub fn is_flat(self) -> bool {
self.low_gain_db.abs() <= FLAT_EPSILON_DB
&& self.mid_gain_db.abs() <= FLAT_EPSILON_DB
&& self.high_gain_db.abs() <= FLAT_EPSILON_DB
}
}
/// A stateful three-band EQ. One instance belongs to one decoded peer stream.
pub struct Eq {
settings: EqSettings,
low: Biquad,
mid: Biquad,
high: Biquad,
}
impl Eq {
/// Build an EQ at the application's audio rate (48 kHz).
pub fn new(settings: EqSettings) -> Self {
Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE)
}
fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self {
let settings = settings.clamped();
Self {
settings,
low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q),
mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q),
high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q),
}
}
pub fn settings(&self) -> EqSettings {
self.settings
}
/// Process one mono PCM frame in place. Flat settings are sample-exact bypass.
pub fn process_frame(&mut self, frame: &mut [i16]) {
if self.settings.is_flat() {
return;
}
for sample in frame {
let x = *sample as f32;
let y = self.high.process(self.mid.process(self.low.process(x)));
*sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
}
}
}
#[derive(Debug, Clone, Copy)]
struct Coeffs {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
}
impl Coeffs {
fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
let inv_a0 = 1.0 / a0;
Self {
b0: b0 * inv_a0,
b1: b1 * inv_a0,
b2: b2 * inv_a0,
a1: a1 * inv_a0,
a2: a2 * inv_a0,
}
}
fn all_finite(self) -> bool {
self.b0.is_finite()
&& self.b1.is_finite()
&& self.b2.is_finite()
&& self.a1.is_finite()
&& self.a2.is_finite()
}
}
/// Direct Form II transposed biquad. The two delay registers are the state that
/// must survive across frames.
struct Biquad {
coeffs: Coeffs,
z1: f32,
z2: f32,
}
impl Biquad {
fn new(coeffs: Coeffs) -> Self {
debug_assert!(coeffs.all_finite());
Self {
coeffs,
z1: 0.0,
z2: 0.0,
}
}
fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
let sqrt_a = a.sqrt();
let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
}
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
let b0 = 1.0 + alpha * a;
let b1 = -2.0 * cos_w0;
let b2 = 1.0 - alpha * a;
let a0 = 1.0 + alpha / a;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha / a;
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
}
fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
let sqrt_a = a.sqrt();
let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
}
fn process(&mut self, x: f32) -> f32 {
let y = self.coeffs.b0 * x + self.z1;
self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2;
self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y;
// Avoid carrying denormal-sized state forever on long quiet tails.
if self.z1.abs() < 1.0e-20 {
self.z1 = 0.0;
}
if self.z2.abs() < 1.0e-20 {
self.z2 = 0.0;
}
y
}
}
fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) {
let sr = sample_rate.max(1.0);
let f = freq.clamp(1.0, sr * 0.49);
let w0 = 2.0 * std::f32::consts::PI * f / sr;
let a = 10.0f32.powf(gain_db / 40.0);
let alpha = w0.sin() / (2.0 * q.max(0.001));
(a, w0.cos(), alpha)
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(freq: f32, len: usize, amp: f32) -> Vec<i16> {
(0..len)
.map(|n| {
let t = n as f32 / DEFAULT_SAMPLE_RATE;
(amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16
})
.collect()
}
fn rms(frame: &[i16]) -> f32 {
let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum();
(sum / frame.len().max(1) as f32).sqrt()
}
#[test]
fn flat_eq_is_sample_exact_identity() {
let mut eq = Eq::new(EqSettings::flat());
let mut frame: Vec<i16> = (-480..480).map(|n| (n * 31) as i16).collect();
let original = frame.clone();
eq.process_frame(&mut frame);
assert_eq!(frame, original);
}
#[test]
fn low_shelf_boost_raises_low_frequency_energy() {
let mut eq = Eq::new(EqSettings {
low_gain_db: 9.0,
..EqSettings::flat()
});
let mut low = sine(100.0, 48_000, 3_000.0);
let before = rms(&low);
eq.process_frame(&mut low);
let after = rms(&low);
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
}
#[test]
fn high_shelf_boost_raises_high_frequency_energy() {
let mut eq = Eq::new(EqSettings {
high_gain_db: 9.0,
..EqSettings::flat()
});
let mut high = sine(8_000.0, 48_000, 3_000.0);
let before = rms(&high);
eq.process_frame(&mut high);
let after = rms(&high);
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
}
#[test]
fn coefficients_are_finite_across_supported_gain_range() {
for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] {
for b in [
Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q),
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
] {
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
}
}
}
#[test]
fn hot_signal_does_not_nan_or_wrap() {
let mut eq = Eq::new(EqSettings {
low_gain_db: 12.0,
mid_gain_db: 12.0,
high_gain_db: 12.0,
});
let mut frame = sine(1_000.0, 48_000, 30_000.0);
eq.process_frame(&mut frame);
let peak = frame
.iter()
.map(|&s| i32::from(s).abs())
.max()
.unwrap_or(0);
assert!(peak > 1_000, "processed signal should retain audible energy");
assert!(
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
"a boosted sine should retain both polarities"
);
}
#[test]
fn settings_are_clamped() {
let s = EqSettings {
low_gain_db: -99.0,
mid_gain_db: 2.0,
high_gain_db: 99.0,
}
.clamped();
assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN);
assert_eq!(s.mid_gain_db, 2.0);
assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX);
}
}
+56 -5
View File
@@ -1,17 +1,22 @@
use std::sync::mpsc::{Sender, Receiver};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use thiserror::Error;
/// Target depth of the playback ring buffer, in samples (48kHz mono).
/// Playback output channel count. Capture/encode/network remain mono; only the
/// listener-side playout bus is stereo.
pub const PLAYBACK_CHANNELS: usize = 2;
/// Target depth of the playback ring buffer, in interleaved samples (48kHz
/// stereo).
///
/// The playout chain is paced to keep the ring near this level: production is
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
/// not by a fixed software timer — which is what eliminates the producer/
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum
/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum
/// so a single hardware pull can never empty the ring before the mixer refills.
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880;
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS;
#[derive(Error, Debug)]
pub enum AudioError {
@@ -51,10 +56,56 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>;
}
pub mod echo_cancel;
pub mod eq;
pub mod gate;
pub mod limiter;
pub mod multitrack;
pub mod pan;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli;
pub mod recorder;
/// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`);
/// `description` is the human-facing label shown in the UI. The two may be equal
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
// Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices;
/// The audio backend implementation for the current platform.
///
/// The whole app constructs and threads this alias (via
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
/// platform selection lives entirely here. Both implementations satisfy the
/// [`AudioBackend`] trait, which is the only interface the core talks to.
///
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
#[cfg(target_os = "linux")]
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
#[cfg(windows)]
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
+77
View File
@@ -0,0 +1,77 @@
//! Listener-side stereo pan law.
//!
//! Capture, Opus, and the network stay mono. These helpers are used only after a
//! peer has been decoded locally, just before the playout mix is written to the
//! stereo playback bus.
/// Clamp and compute constant-power pan gains for `pan` in `[-1.0, 1.0]`.
///
/// - `-1.0` is hard left `(1, 0)`
/// - `0.0` is center `(sqrt(1/2), sqrt(1/2))`
/// - `1.0` is hard right `(0, 1)`
pub fn pan_gains(pan: f32) -> (f32, f32) {
let pan = pan.clamp(-1.0, 1.0);
let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
(theta.cos(), theta.sin())
}
/// Gains used by the legacy-compatible playback mixer.
///
/// The pure law above is constant-power. The existing application, however, was
/// mono and users heard the full old mono signal in both ears. Scaling by sqrt(2)
/// makes `pan = 0` exactly dual-mono `(1, 1)`, preserving the default sound while
/// still following the same equal-power curve as a peer is moved away from center.
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
let (left, right) = pan_gains(pan);
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f32 = 1.0e-6;
#[test]
fn hard_left_and_right_are_endpoints() {
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
let (l, r) = pan_gains(1.0);
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
}
#[test]
fn center_is_equal_and_power_preserving() {
let (l, r) = pan_gains(0.0);
assert!((l - r).abs() < EPS);
assert!((l - std::f32::consts::FRAC_1_SQRT_2).abs() < EPS);
assert!(((l * l + r * r) - 1.0).abs() < EPS);
}
#[test]
fn gains_move_monotonically() {
let pans = [-1.0, -0.5, 0.0, 0.5, 1.0];
let mut prev_l = f32::INFINITY;
let mut prev_r = f32::NEG_INFINITY;
for pan in pans {
let (l, r) = pan_gains(pan);
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
prev_l = l;
prev_r = r;
}
}
#[test]
fn playback_center_preserves_legacy_dual_mono() {
let (l, r) = playback_pan_gains(0.0);
assert!((l - 1.0).abs() < EPS);
assert!((r - 1.0).abs() < EPS);
}
#[test]
fn input_is_clamped() {
assert_eq!(pan_gains(-9.0), pan_gains(-1.0));
assert_eq!(pan_gains(9.0), pan_gains(1.0));
}
}
+23 -18
View File
@@ -283,8 +283,9 @@ fn run_playback(
let core = context.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz).
const RING_CAPACITY: usize = 9600;
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
// 48kHz).
const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS;
let rb = HeapRb::<i16>::new(RING_CAPACITY);
let (mut producer, consumer) = rb.split();
@@ -371,7 +372,7 @@ fn run_playback(
let data = &mut datas[0];
let mut total_size = 0;
if let Some(slice) = data.data() {
let stride = 2; // S16LE Mono = 2 bytes per frame
let stride = 2 * crate::audio::PLAYBACK_CHANNELS; // S16LE stereo
// Fill exactly what the graph asked for this cycle (with
// a safe fallback), never the whole mapped slice — that
// over-pull past the ring depth was the original crackle.
@@ -383,17 +384,20 @@ fn run_playback(
user_data.callback_count.fetch_add(1, Ordering::Relaxed);
let mut starved = 0u64;
for i in 0..n_frames {
let val = match user_data.consumer.try_pop() {
Some(v) => v,
None => {
starved += 1;
0
}
};
let bytes = val.to_le_bytes();
let start = i * stride;
slice[start] = bytes[0];
slice[start + 1] = bytes[1];
for ch in 0..crate::audio::PLAYBACK_CHANNELS {
let val = match user_data.consumer.try_pop() {
Some(v) => v,
None => {
starved += 1;
0
}
};
let bytes = val.to_le_bytes();
let offset = start + ch * 2;
slice[offset] = bytes[0];
slice[offset + 1] = bytes[1];
}
}
if starved > 0 {
// One wait-free atomic add per quantum — RT-safe.
@@ -403,7 +407,8 @@ fn run_playback(
// actually pulled (excluding underruns, which removed
// nothing) so the mixer paces against true ring depth.
// Wait-free fetch_sub, RT-safe.
let popped = n_frames - starved as usize;
let requested_samples = n_frames * crate::audio::PLAYBACK_CHANNELS;
let popped = requested_samples - starved as usize;
if popped > 0 {
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
}
@@ -411,7 +416,7 @@ fn run_playback(
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = 2;
*chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _;
*chunk.size_mut() = total_size as _;
}
}
@@ -422,7 +427,7 @@ fn run_playback(
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
audio_info.set_rate(48000);
audio_info.set_channels(1); // Mono
audio_info.set_channels(crate::audio::PLAYBACK_CHANNELS as u32); // Stereo playback
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
@@ -450,7 +455,7 @@ fn run_playback(
// `frames_to_produce`). `requested()`, not the buffer size, now governs
// per-cycle output, so this is a generous max rather than a hard pin.
const MAX_QUANTUM_FRAMES: i32 = 8192;
const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame
const STRIDE: i32 = 2 * crate::audio::PLAYBACK_CHANNELS as i32; // S16LE stereo
let buffers_obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
id: pw::spa::param::ParamType::Buffers.as_raw(),
@@ -555,7 +560,7 @@ fn run_playback(
if verbose || du > 0 || dd > 0 {
crate::log_msg(&format!(
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s",
fill / 48,
fill / (48 * crate::audio::PLAYBACK_CHANNELS),
));
}
}
+1 -13
View File
@@ -1,18 +1,6 @@
use super::AudioDevice;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let output = Command::new("pw-cli")
.arg("list-objects")
+55 -8
View File
@@ -22,6 +22,8 @@ use std::path::{Path, PathBuf};
const SAMPLE_RATE: u32 = 48_000;
const BITS_PER_SAMPLE: u16 = 16;
const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
/// if the capture clock runs persistently faster than playout — past this we drop
@@ -34,7 +36,7 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
pub struct WavWriter {
file: File,
/// Bytes of PCM data written so far (for the size fields).
data_bytes: u32,
data_bytes: u64,
}
impl WavWriter {
@@ -42,7 +44,10 @@ impl WavWriter {
pub fn new(path: &Path) -> io::Result<Self> {
let mut file = File::create(path)?;
file.write_all(&Self::header(0))?;
Ok(Self { file, data_bytes: 0 })
Ok(Self {
file,
data_bytes: 0,
})
}
/// The 44-byte canonical WAV/PCM header for the given data length in bytes.
@@ -68,21 +73,40 @@ impl WavWriter {
/// Append PCM samples to the data chunk.
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
let added_bytes = u64::try_from(samples.len())
.ok()
.and_then(|len| len.checked_mul(2))
.ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
let new_data_bytes = self
.data_bytes
.checked_add(added_bytes)
.ok_or_else(|| io::Error::other("WAV data size overflow"))?;
if new_data_bytes > MAX_RIFF_DATA_BYTES {
return Err(io::Error::other("WAV too large for RIFF"));
}
let mut buf = Vec::with_capacity(samples.len() * 2);
for &s in samples {
buf.extend_from_slice(&s.to_le_bytes());
}
self.file.write_all(&buf)?;
self.data_bytes += (samples.len() * 2) as u32;
self.data_bytes = new_data_bytes;
Ok(())
}
/// Patch the RIFF + data size fields and flush. Consumes the writer.
pub fn finalize(mut self) -> io::Result<()> {
let data_bytes = u32::try_from(self.data_bytes)
.map_err(|_| io::Error::other("WAV too large for RIFF"))?;
let riff_size = self
.data_bytes
.checked_add(RIFF_DATA_OVERHEAD)
.and_then(|size| u32::try_from(size).ok())
.ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
self.file.seek(SeekFrom::Start(4))?;
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?;
self.file.write_all(&riff_size.to_le_bytes())?;
self.file.seek(SeekFrom::Start(40))?;
self.file.write_all(&self.data_bytes.to_le_bytes())?;
self.file.write_all(&data_bytes.to_le_bytes())?;
self.file.flush()?;
Ok(())
}
@@ -209,11 +233,29 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
#[test]
fn wav_writer_rejects_data_that_would_overflow_riff_header() {
let dir = std::env::temp_dir();
let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
let mut w = WavWriter::new(&path).unwrap();
w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
let before_len = std::fs::metadata(&path).unwrap().len();
let err = w.write_samples(&[0]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Other);
assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
drop(w);
let _ = std::fs::remove_file(&path);
}
#[test]
fn mic_is_summed_with_mix_when_present() {
let dir = std::env::temp_dir();
let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))).unwrap(),
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(),
path: PathBuf::new(),
};
@@ -223,7 +265,11 @@ mod tests {
r.write_frame(&[10, 20]).unwrap();
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
r.write_frame(&[0, 0]).unwrap();
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence");
assert_eq!(
r.mic_fifo.len(),
0,
"remaining mic sample consumed; rest is silence"
);
let _ = r.finalize();
}
@@ -231,7 +277,8 @@ mod tests {
fn mic_fifo_is_capped() {
let dir = std::env::temp_dir();
let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))).unwrap(),
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(),
path: PathBuf::new(),
};
+225 -97
View File
@@ -1,11 +1,11 @@
//! Audio playout diagnostic probe.
//!
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
//! Drives a phase-continuous sine tone through the *real* playback path
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
//! PipeWire hardware clock. No network, no microphone — this isolates the local
//! output path so we can confirm the clock-paced playout is glitch-free.
//! hardware clock. No network, no microphone — this isolates the local output
//! path so we can confirm the clock-paced playout is glitch-free.
//!
//! Use your ears on the tone (any click/pop is a glitch) together with the
//! `playout-health:` lines tailed to stdout:
@@ -17,103 +17,231 @@
//!
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30
//!
//! This probe exercises the platform playback backend directly: PipeWire on Linux
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 samples = 20ms @ 48kHz mono
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
#[cfg(target_os = "linux")]
fn main() {
unix_probe::run();
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
#[cfg(windows)]
fn main() {
win_probe::run();
}
#[cfg(not(any(target_os = "linux", windows)))]
fn main() {
eprintln!("audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly).");
}
#[cfg(target_os = "linux")]
mod unix_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
}
#[cfg(windows)]
mod win_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::cpal_impl::CpalBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = CpalBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the cpal/WASAPI hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
});
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
}
+27 -1
View File
@@ -1,6 +1,7 @@
use crate::notify::Sound;
use crate::theme::AppTheme;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
@@ -232,6 +233,17 @@ pub struct AppConfig {
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
#[serde(default)]
pub recents: Vec<crate::recents::Recent>,
/// Per-peer listener-side EQ settings, keyed by peer node id string. Local
/// preference only; never sent to peers.
#[serde(default)]
pub peer_eq: HashMap<String, crate::audio::eq::EqSettings>,
/// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right),
/// keyed by peer node id string. Local preference only.
#[serde(default)]
pub peer_pan: HashMap<String, f32>,
/// Focused app-local keyboard shortcuts.
#[serde(default)]
pub hotkeys: crate::hotkeys::HotkeyMap,
/// Last window size (px), restored as the initial size on next launch.
/// Saved on close.
#[serde(default = "default_window_width")]
@@ -287,6 +299,9 @@ impl Default for AppConfig {
sound_reconnect_failed_enabled: true,
pixelpass_path: None,
recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
hotkeys: crate::hotkeys::HotkeyMap::default(),
window_width: default_window_width(),
window_height: default_window_height(),
window_x: None,
@@ -411,6 +426,18 @@ mod tests {
assert_eq!(deserialized.window_height, 760.0);
// Configs predating the recents list load an empty list.
assert!(deserialized.recents.is_empty());
// Configs predating per-peer listener shaping load flat/center/default
// shortcut settings.
assert!(deserialized.peer_eq.is_empty());
assert!(deserialized.peer_pan.is_empty());
assert_eq!(
crate::hotkeys::format_binding(
deserialized
.hotkeys
.binding(crate::hotkeys::HotkeyAction::PushToTalk)
),
"Space"
);
}
#[test]
@@ -596,4 +623,3 @@ mod tests {
assert_eq!(config.noise_gate_threshold, 0.01);
}
}
+62 -3
View File
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
/// badly behind, so we drop the oldest and resync rather than grow unbounded.
const MAX_BUFFERED_FRAMES: usize = 32;
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
/// as a restarted/new stream, not ordinary packet loss or reordering.
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
pub struct JitterBuffer {
decoder: OpusDecoder,
/// Reorder window: sequence number -> encoded Opus payload.
@@ -116,6 +120,14 @@ impl JitterBuffer {
}
}
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
self.packets.clear();
self.packets.insert(seq, payload);
self.next_seq = None;
self.clean_run = 0;
self.buffering_ticks = 0;
}
/// Store a received packet, dropping ones we've already played past and
/// bounding total depth.
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
@@ -124,9 +136,19 @@ impl JitterBuffer {
if let Some(next) = self.next_seq
&& seq_before(seq, next)
{
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
self.reset_to_stream(seq, payload);
return;
}
self.note_disruption();
return;
}
if let Some(next) = self.next_seq
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
{
self.reset_to_stream(seq, payload);
return;
}
self.packets.insert(seq, payload);
while self.packets.len() > MAX_BUFFERED_FRAMES {
@@ -283,6 +305,44 @@ mod tests {
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
}
#[test]
fn far_behind_sequence_resets_as_restarted_stream() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(5_000);
jb.packets.insert(5_000, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
jb.insert(0, vec![1]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test]
fn far_ahead_sequence_resets_to_bound_plc_run() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(10);
jb.packets.insert(10, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
jb.insert(jumped_seq, vec![2]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(
jb.packets.get(&jumped_seq).map(Vec::as_slice),
Some(&[2][..])
);
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test]
fn test_seq_before_ordering() {
// Basic ordering
@@ -358,7 +418,7 @@ mod tests {
fn is_idle_reflects_buffer_state() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
let mut jb = JitterBuffer::new().unwrap();
// Fresh buffer
assert!(jb.is_idle());
@@ -369,7 +429,7 @@ mod tests {
// Prime (3 frames)
jb.insert(1, frame(&mut enc, 1000));
jb.insert(2, frame(&mut enc, 1000));
// Drain past the end so it underruns
assert!(jb.pop_frame().is_some());
assert!(jb.pop_frame().is_some());
@@ -635,4 +695,3 @@ mod tests {
assert_eq!(jb.clean_run, 0);
}
}
+14 -5
View File
@@ -11,6 +11,10 @@ pub enum CoreCommand {
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
Leave,
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
/// audio/screen-share work, close the persistent network stack, then ack with
/// [`UiEvent::ShutdownComplete`].
Shutdown,
ToggleMute,
/// Change our avatar (W4) and re-announce it to the room over presence.
SetAvatar(crate::avatar::Avatar),
@@ -18,6 +22,10 @@ pub enum CoreCommand {
SetPttMode(bool),
SetPttActive(bool),
SetPeerVolume(EndpointId, f32),
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
/// Listener-side per-peer pan. Local only; never leaves this app instance.
SetPeerPan(EndpointId, f32),
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
/// still show) but not mixed into our output.
SetPeerMuted(EndpointId, bool),
@@ -116,11 +124,12 @@ pub enum UiEvent {
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence },
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
/// GUI must mirror + persist this so its presence picker stops showing
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
/// without having issued the command itself.
/// Core corrected the committed presence posture. Usually the Discoverable
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// failure, this carries the previous truthful mode. The GUI must mirror +
/// persist this so its presence picker matches the endpoint's discovery state.
PresenceModeReverted { mode: PresenceMode },
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String),
}
+370 -56
View File
@@ -1,7 +1,8 @@
pub mod messages;
pub mod jitter;
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{
@@ -12,6 +13,7 @@ use crate::network::{
use crate::core::messages::{CoreCommand, UiEvent};
use crate::config::{NetworkMode, RecordingMode};
use crate::presence::PresenceMode;
use crate::audio::multitrack::MultitrackRecorder;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
use iroh_gossip::net::Gossip;
@@ -49,6 +51,12 @@ impl CoreController {
pub fn send(&self, cmd: CoreCommand) -> bool {
self.cmd_tx.try_send(cmd).is_ok()
}
/// Clone the command sender for asynchronous one-shot sends that should wait
/// for channel capacity instead of failing immediately on a full queue.
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
self.cmd_tx.clone()
}
}
/// How long a peer may stay "reconnecting" after a transient drop before we give
@@ -57,6 +65,32 @@ impl CoreController {
/// clears from the room promptly.
const RECONNECT_GRACE: Duration = Duration::from_secs(45);
/// Opus frames sent by our encoder are one 20 ms mono frame, normally far below
/// this. 4000 bytes still leaves room for large valid Opus packets (well above a
/// 48 kHz / 60 ms frame) while bounding malicious datagram copy/decode churn.
const MAX_OPUS_PAYLOAD: usize = 4000;
/// If the Discoverable time-box tries to revert but discovery service reconfiguration
/// fails, retry soon while keeping the UI in the still-possible publishing state.
const DISCOVERY_REVERT_RETRY: Duration = Duration::from_secs(60);
fn audio_datagram_len_ok(len: usize) -> bool {
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
}
fn arm_discovery_retry(
discovery_deadline: &mut Option<tokio::time::Instant>,
now: tokio::time::Instant,
) {
let retry_deadline = now + DISCOVERY_REVERT_RETRY;
if discovery_deadline
.map(|current| current > retry_deadline)
.unwrap_or(true)
{
*discovery_deadline = Some(retry_deadline);
}
}
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
/// room-event task (which arms one on a transient drop and cancels it on a
/// gossip rejoin) and the conn-event task (which cancels it when the audio link
@@ -103,6 +137,7 @@ fn arm_grace_timer(
let handle = tokio::spawn(async move {
tokio::time::sleep(grace).await;
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
transport_evict.remove_audio_sender(peer_id);
transport_evict.disconnect_peer(peer_id).await;
jitter_evict.lock().await.remove(&peer_id);
// Scrub our internal state *before* announcing the eviction, so anything
@@ -202,7 +237,7 @@ fn run_mic_monitor(
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a
/// room session is active — `backend.stop()` would also tear down the call's
/// capture/playback. Monitor and session are mutually exclusive by construction.
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
if let Some(m) = monitor {
let _ = backend.stop();
let _ = m.thread.join();
@@ -214,6 +249,7 @@ fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
/// limiter (see [`crate::audio::limiter`]) can ride it down to the ceiling instead
/// of the old hard clip shattering loud moments. Peers shorter than `frame_len`
/// contribute 0 past their end; an empty peer set yields a silent bus.
#[cfg(test)]
fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> {
let mut mixed = vec![0i32; frame_len];
for frame in peer_frames {
@@ -224,6 +260,44 @@ fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> {
mixed
}
/// Sum per-peer mono frames into one interleaved stereo `i32` bus. Center pan is
/// a special exact dual-mono path so the default listener mix is bit-for-bit the
/// old mono sum duplicated to both ears.
fn mix_stereo_frames(peer_frames: &[(Vec<i16>, f32)], frame_len: usize) -> Vec<i32> {
let mut mixed = vec![0i32; frame_len * crate::audio::PLAYBACK_CHANNELS];
for (frame, pan) in peer_frames {
if pan.abs() <= f32::EPSILON {
for (i, &sample) in frame.iter().take(frame_len).enumerate() {
let idx = i * crate::audio::PLAYBACK_CHANNELS;
let s = sample as i32;
mixed[idx] += s;
mixed[idx + 1] += s;
}
continue;
}
let (left_gain, right_gain) = crate::audio::pan::playback_pan_gains(*pan);
for (i, &sample) in frame.iter().take(frame_len).enumerate() {
let idx = i * crate::audio::PLAYBACK_CHANNELS;
let x = sample as f32;
mixed[idx] += (x * left_gain).round() as i32;
mixed[idx + 1] += (x * right_gain).round() as i32;
}
}
mixed
}
/// Fold an interleaved stereo frame to mono for the existing mixed WAV writers.
/// Center/default pan folds back to the exact old mono mix.
fn stereo_to_mono(stereo: &[i16]) -> Vec<i16> {
let mut mono = Vec::with_capacity(stereo.len() / crate::audio::PLAYBACK_CHANNELS);
for pair in stereo.chunks_exact(crate::audio::PLAYBACK_CHANNELS) {
let sum = pair[0] as i32 + pair[1] as i32;
mono.push((sum / 2).clamp(i16::MIN as i32, i16::MAX as i32) as i16);
}
mono
}
/// Handles the transport's per-peer link-state stream (`ConnEvent`): arms/cancels
/// reconnect grace timers, tracks which peers we've linked with, and forwards
/// link state to the UI. Pulled out of the conn-event task as a unit so the
@@ -297,6 +371,7 @@ impl ConnEventHandler {
// until the grace timer or the slow gossip Leave.
cancel_grace_timer(&self.grace_timers, &id);
self.seen_connected.lock().unwrap().remove(&id);
self.transport.remove_audio_sender(id);
self.transport.disconnect_peer(id).await;
self.jitter.lock().await.remove(&id);
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
@@ -315,6 +390,7 @@ struct ActiveSession {
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop).
@@ -325,7 +401,7 @@ struct ActiveSession {
}
impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) {
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
@@ -356,6 +432,7 @@ impl ActiveSession {
// Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel);
crate::log_msg("Leaving room...");
@@ -411,12 +488,11 @@ impl NetStack {
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
///
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its
/// republish task ends when the last clone is dropped, and the already-published
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing
/// resolution. The brief clear→re-add window is a few synchronous calls; presence
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
/// Idempotent and reversible: it builds the replacement services first, then clears
/// the service set and reinstalls exactly what the plan wants. Flipping `publisher`
/// off drops the publisher (its republish task ends when the last clone is dropped,
/// and the already-published record TTL-expires within ~30s) without an endpoint
/// rebuild and without disturbing resolution.
fn apply_discovery(
endpoint: &Endpoint,
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
@@ -427,16 +503,34 @@ fn apply_discovery(
pkarr::{PkarrPublisher, PkarrResolver},
};
let services = endpoint.address_lookup()?;
let pkarr_resolver = if plan.resolver {
Some(PkarrResolver::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
let dns_resolver = if plan.resolver {
Some(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
let publisher = if plan.publisher {
Some(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
services.clear();
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
// depends on — it must survive every posture, including DirectOnly).
services.add(memory_lookup.clone());
if plan.resolver {
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?);
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
if let Some(pkarr_resolver) = pkarr_resolver {
services.add(pkarr_resolver);
}
if plan.publisher {
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?);
if let Some(dns_resolver) = dns_resolver {
services.add(dns_resolver);
}
if let Some(publisher) = publisher {
services.add(publisher);
}
Ok(())
}
@@ -492,7 +586,7 @@ async fn build_net_stack(
// report) is injected via `friends_handler`.
let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
.accept(b"peerspeak-audio", audio_router.clone())
.accept(crate::protocol::AUDIO_ALPN, audio_router.clone())
.accept(
crate::presence_net::FRIENDS_ALPN,
crate::presence_net::FriendsProtocol::new(friends_handler),
@@ -588,7 +682,7 @@ async fn probe_friends_once(
let ep = endpoint.clone();
set.spawn(async move {
match crate::presence_net::probe(&ep, addr).await {
Ok(reply) => crate::presence::interpret_pong(&reply).map(|p| (id, p)),
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
Err(_) => None,
}
});
@@ -639,7 +733,7 @@ async fn run_core_loop(
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PipeWireBackend::new());
let audio_backend = Arc::new(PlatformAudioBackend::new());
let is_muted = Arc::new(AtomicBool::new(false));
let is_deafened = Arc::new(AtomicBool::new(false));
@@ -665,6 +759,8 @@ async fn run_core_loop(
let is_multitrack = Arc::new(AtomicBool::new(false));
let mut recording_mode = RecordingMode::default();
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string();
@@ -794,26 +890,85 @@ async fn run_core_loop(
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
// so a publish beacon never stands indefinitely. The branch is disabled
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI.
// belt-and-braces. On fire: stop publishing first, then commit Normal only
// if the endpoint's discovery services accepted the non-publishing plan.
_ = tokio::time::sleep_until(
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
), if discovery_deadline.is_some() => {
discovery_deadline = None;
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal;
let plan = crate::discovery::lookup_plan(network_mode, false);
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
let previous_mode = *presence_mode.lock().unwrap();
if previous_mode != PresenceMode::Discoverable {
discovery_deadline = None;
continue;
}
let requested_mode = PresenceMode::Normal;
let now = tokio::time::Instant::now();
let plan = crate::discovery::lookup_plan(
network_mode,
requested_mode.publishes_to_discovery(),
);
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
let (committed_mode, transition_error) =
crate::discovery::resolve_presence_transition(
previous_mode,
requested_mode,
apply_result.is_ok(),
);
*presence_mode.lock().unwrap() = committed_mode;
discovery_deadline = if committed_mode == PresenceMode::Discoverable {
Some(now + DISCOVERY_REVERT_RETRY)
} else {
None
};
match apply_result {
Ok(()) => {
crate::log_msg(
"discovery: Discoverable time-box elapsed → reverting to Normal",
);
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: PresenceMode::Normal,
})
.await;
}
Err(e) => {
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
if committed_mode != requested_mode {
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: committed_mode,
})
.await;
}
if let Some(message) = transition_error {
let _ = ui_tx
.send(UiEvent::Error(format!("{message} ({e:#})")))
.await;
}
}
}
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: crate::presence::PresenceMode::Normal,
})
.await;
continue;
}
};
match cmd {
CoreCommand::Shutdown => {
crate::log_msg("Core shutdown requested");
// Finalize recordings while capture/mixer feeders are still alive.
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await;
stop_mic_monitor(&audio_backend, mic_monitor.take());
if let Some(session) = active_session.take() {
session.shutdown(audio_backend.clone()).await;
net.audio_router.clear();
}
*current_room.lock().unwrap() = None;
net.shutdown().await;
let _ = ui_tx.send(UiEvent::ShutdownComplete).await;
break;
}
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone();
current_avatar = avatar;
@@ -858,7 +1013,12 @@ async fn run_core_loop(
let ticket_str = if ticket.trim().is_empty() || ticket == "create" {
let topic_id: [u8; 32] = rand::random();
let host_addr = endpoint.addr();
crate::log_msg(&format!("Creating room. host_addr={:?}, topic_id={:?}", host_addr, topic_id));
crate::log_msg(&format!(
"Creating room. host_id={}, host_addrs={}, topic={}",
crate::short_id(&host_addr.id.to_string()),
host_addr.addrs.len(),
crate::short_bytes_hex(&topic_id)
));
// The creator's chosen cosmetic label rides in the ticket so
// every joiner inherits it; sanitize it before it leaves here.
let label = crate::sanitize::sanitize_name(&room_name);
@@ -866,7 +1026,10 @@ async fn run_core_loop(
ticket.to_string()
} else {
let ticket_str = ticket.trim().to_string();
crate::log_msg(&format!("Joining room with existing ticket={}", ticket_str));
crate::log_msg(&format!(
"Joining room with existing ticket={}",
crate::redact_for_log(&ticket_str)
));
ticket_str
};
@@ -905,7 +1068,17 @@ async fn run_core_loop(
.map(|peers| peers.values().cloned().collect())
.unwrap_or_default();
crate::log_msg(&format!("Attempting room_state.join with self_state={:?}, extra_bootstrap={:?}", self_state, extra_bootstrap.iter().map(|a| a.id).collect::<Vec<_>>()));
let extra_bootstrap_ids = extra_bootstrap
.iter()
.map(|a| crate::short_id(&a.id.to_string()))
.collect::<Vec<_>>();
crate::log_msg(&format!(
"Attempting room_state.join self_id={}, self_name={:?}, sharing={}, extra_bootstrap={:?}",
crate::short_id(&self_state.addr.id.to_string()),
self_state.name,
self_state.sharing.is_some(),
extra_bootstrap_ids
));
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
@@ -924,7 +1097,9 @@ async fn run_core_loop(
// The guard unloads the module on drop — including the early-return
// paths below, since it's a local until moved into the session. On
// any failure, warn and fall back to the direct devices.
#[cfg(target_os = "linux")]
let mut echo_cancel_guard = None;
#[cfg(target_os = "linux")]
let (capture_target, playback_target) = if echo_cancellation {
match crate::audio::echo_cancel::enable(
input_device.as_deref(),
@@ -951,6 +1126,10 @@ async fn run_core_loop(
} else {
(input_device.clone(), output_device.clone())
};
#[cfg(not(target_os = "linux"))]
let _ = echo_cancellation;
#[cfg(not(target_os = "linux"))]
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
@@ -1070,8 +1249,12 @@ async fn run_core_loop(
};
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
if bytes.len() < 4 {
continue; // malformed: missing sequence header
if !transport_recv.audio_sender_admitted(from_peer) {
continue;
}
if !audio_datagram_len_ok(bytes.len()) {
// Malformed (< sequence header) or oversized Opus payload.
continue;
}
let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let payload = bytes[4..].to_vec();
@@ -1104,6 +1287,8 @@ async fn run_core_loop(
let jitter_mixer = jitter.clone();
let is_deafened_clone = is_deafened.clone();
let peer_volumes_mixer = peer_volumes.clone();
let peer_eq_mixer = peer_eq.clone();
let peer_pan_mixer = peer_pan.clone();
let locally_muted_mixer = locally_muted.clone();
let output_gain_mixer = output_gain.clone();
let ui_tx_mixer = ui_tx.clone();
@@ -1117,6 +1302,9 @@ async fn run_core_loop(
// the ceiling instead of hard-clipping. State carries across
// frames (see audio::limiter).
let mut limiter = crate::audio::limiter::SoftLimiter::new(48_000);
// Per-peer EQ filter state. Settings are live-cloned each
// cycle; state is rebuilt only when a peer's EQ changes.
let mut peer_eqs: HashMap<EndpointId, Eq> = HashMap::new();
// When the ring is at/above target we have nothing to do; nap
// briefly and re-check. Short enough (relative to the ~60ms
// target and ~21ms device quantum) that we always refill well
@@ -1140,8 +1328,11 @@ async fn run_core_loop(
}
let current_volumes = peer_volumes_mixer.lock().await.clone();
let current_eq = peer_eq_mixer.lock().await.clone();
let current_pans = peer_pan_mixer.lock().await.clone();
let muted_peers = locally_muted_mixer.lock().await.clone();
let mut peer_frames = Vec::new();
let mut peer_frames: Vec<(Vec<i16>, f32)> = Vec::new();
let mut peers_seen = HashSet::new();
// Multitrack stem capture: tap each peer's RAW decoded frame
// (pre-volume, pre-mute, pre-limiter) so the stems are clean
@@ -1167,10 +1358,31 @@ async fn run_core_loop(
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(&mut frame, vol);
let eq_settings = current_eq
.get(&peer_id)
.copied()
.unwrap_or_default()
.clamped();
if eq_settings.is_flat() {
peer_eqs.remove(&peer_id);
} else {
let needs_rebuild = peer_eqs
.get(&peer_id)
.map(|eq| eq.settings() != eq_settings)
.unwrap_or(true);
if needs_rebuild {
peer_eqs.insert(peer_id, Eq::new(eq_settings));
}
if let Some(eq) = peer_eqs.get_mut(&peer_id) {
eq.process_frame(&mut frame);
}
}
// Level is recorded even for locally-muted peers so
// the UI still shows that they're speaking.
let peak = level_peaks.entry(peer_id).or_insert(0.0);
*peak = peak.max(frame_level(&frame));
peers_seen.insert(peer_id);
// Locally muted: decoded above (jitter buffer advances,
// level shown) but not mixed into our output.
@@ -1178,16 +1390,23 @@ async fn run_core_loop(
continue;
}
peer_frames.push(frame);
let pan = current_pans
.get(&peer_id)
.copied()
.unwrap_or(0.0)
.clamp(-1.0, 1.0);
peer_frames.push((frame, pan));
}
}
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
// Lossless i32 sum, then the limiter applies the master
// output gain (in f32, so a boost past the ceiling is
// limited too) and rides peaks down to the ceiling.
let mixed_sum = mix_frames(&peer_frames, FRAME_SAMPLES);
let mixed_sum = mix_stereo_frames(&peer_frames, FRAME_SAMPLES);
let out_gain = f32::from_bits(output_gain_mixer.load(Ordering::Relaxed));
let mixed = limiter.process(&mixed_sum, out_gain);
let record_mix = stereo_to_mono(&mixed);
// Record the true call audio, independent of local deafen —
// deafen only silences our own monitor, not what the call
@@ -1200,7 +1419,7 @@ async fn run_core_loop(
for (id, f) in &stems {
mt.write_peer(*id, f)?;
}
mt.write_mix(&mixed)?;
mt.write_mix(&record_mix)?;
mt.end_cycle()
})();
if let Err(e) = res {
@@ -1209,13 +1428,13 @@ async fn run_core_loop(
}
} else if is_recording_mixer.load(Ordering::Relaxed)
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut()
&& let Err(e) = rec.write_frame(&mixed)
&& let Err(e) = rec.write_frame(&record_mix)
{
crate::log_msg(&format!("Recording write failed: {e}"));
}
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
vec![0i16; FRAME_SAMPLES]
vec![0i16; mixed.len()]
} else {
mixed
};
@@ -1267,6 +1486,7 @@ async fn run_core_loop(
// A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it.
cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
// Establish the audio connection as soon as the peer
// is known (the transport dedupes the full-mesh race).
// Hand over the full address so reconnects can dial
@@ -1318,6 +1538,7 @@ async fn run_core_loop(
{
peers.remove(&peer_id);
}
transport_events.remove_audio_sender(peer_id);
transport_events.disconnect_peer(peer_id).await;
jitter_events.lock().await.remove(&peer_id);
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
@@ -1330,6 +1551,7 @@ async fn run_core_loop(
// it. Idempotent: an ordinary mute/unmute update just
// re-records the same address.
cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
transport_events.connect_peer(state.addr.clone()).await;
// Auto-heal a friend's saved address (W7) on the
// re-announce too — this is the path that catches a
@@ -1371,6 +1593,7 @@ async fn run_core_loop(
// hasn't recovered within RECONNECT_GRACE. A gossip
// rejoin (PeerJoined/PeerUpdated) or a transport
// reconnect (ConnEvent::Connected) cancels it first.
transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
arm_grace_timer(
&grace_timers_events,
@@ -1417,6 +1640,7 @@ async fn run_core_loop(
conn_event_task,
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::new(),
@@ -1522,6 +1746,26 @@ async fn run_core_loop(
guard.insert(peer_id, vol);
}
CoreCommand::SetPeerEq(peer_id, settings) => {
let settings = settings.clamped();
let mut guard = peer_eq.lock().await;
if settings.is_flat() {
guard.remove(&peer_id);
} else {
guard.insert(peer_id, settings);
}
}
CoreCommand::SetPeerPan(peer_id, pan) => {
let pan = pan.clamp(-1.0, 1.0);
let mut guard = peer_pan.lock().await;
if pan.abs() <= 0.001 {
guard.remove(&peer_id);
} else {
guard.insert(peer_id, pan);
}
}
CoreCommand::SetPeerMuted(peer_id, muted) => {
let mut guard = locally_muted.lock().await;
if muted {
@@ -1649,22 +1893,60 @@ async fn run_core_loop(
}
CoreCommand::SetPresenceMode(mode) => {
*presence_mode.lock().unwrap() = mode;
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
// address-lookup services. The resolver stays on regardless so we can
// still look up moved friends.
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
let previous_mode = *presence_mode.lock().unwrap();
let now = tokio::time::Instant::now();
if previous_mode == mode {
// Same-mode requests are no-ops for discovery wiring, but keep the
// existing UX: re-selecting Discoverable restarts the clock.
discovery_deadline = if mode == PresenceMode::Discoverable {
Some(now + crate::discovery::DISCOVERY_TIMEBOX)
} else {
None
};
continue;
}
// Arm (Discoverable) or cancel (any other posture) the auto-revert
// time-box. Re-selecting Discoverable restarts the clock.
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable {
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX)
// W7 P6/S11: re-apply n0 DNS discovery for the requested posture
// first, then commit the presence mode only if the endpoint accepted
// that discovery plan. This keeps the UI truthful when dropping the
// publisher fails.
let plan =
crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
let (committed_mode, transition_error) =
crate::discovery::resolve_presence_transition(
previous_mode,
mode,
apply_result.is_ok(),
);
*presence_mode.lock().unwrap() = committed_mode;
if committed_mode == PresenceMode::Discoverable {
if apply_result.is_ok() && mode == PresenceMode::Discoverable {
discovery_deadline = Some(now + crate::discovery::DISCOVERY_TIMEBOX);
} else {
arm_discovery_retry(&mut discovery_deadline, now);
}
} else {
None
};
discovery_deadline = None;
}
if let Err(e) = apply_result {
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
if committed_mode != mode {
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: committed_mode,
})
.await;
}
if let Some(message) = transition_error {
let _ = ui_tx
.send(UiEvent::Error(format!("{message} ({e:#})")))
.await;
}
}
}
CoreCommand::SetRecordingMode(mode) => {
@@ -1865,7 +2147,10 @@ async fn run_core_loop(
#[cfg(test)]
mod tests {
use super::{apply_volume, frame_level, mix_frames, MicLevelMeter, MIC_LEVEL_REPORT_SAMPLES};
use super::{
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
};
/// A frame of constant amplitude with the given sample count.
fn frame(amp: i16, len: usize) -> Vec<i16> {
@@ -1881,6 +2166,15 @@ mod tests {
assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
}
#[test]
fn audio_datagram_length_gate_preserves_header_and_caps_payload() {
assert!(!audio_datagram_len_ok(0));
assert!(!audio_datagram_len_ok(3));
assert!(audio_datagram_len_ok(4));
assert!(audio_datagram_len_ok(4 + MAX_OPUS_PAYLOAD));
assert!(!audio_datagram_len_ok(5 + MAX_OPUS_PAYLOAD));
}
#[test]
fn mic_meter_holds_the_peak_across_the_window() {
let mut m = MicLevelMeter::new();
@@ -1924,6 +2218,27 @@ mod tests {
assert_eq!(mixed, vec![100i32, -200, 300, -400]);
}
#[test]
fn centered_stereo_mix_is_exact_dual_mono() {
let a = vec![100, -200, 300, -400];
let b = vec![50, 200, -100, 400];
let mixed = mix_stereo_frames(&[(a, 0.0), (b, 0.0)], 4);
assert_eq!(mixed, vec![150, 150, 0, 0, 200, 200, 0, 0]);
}
#[test]
fn hard_left_pan_only_contributes_left_channel() {
let frame = vec![100, 200];
let mixed = mix_stereo_frames(&[(frame, -1.0)], 2);
assert_eq!(mixed, vec![141, 0, 283, 0]);
}
#[test]
fn stereo_fold_down_averages_pairs() {
let mono = stereo_to_mono(&[100, 100, 200, 0, i16::MAX, i16::MAX]);
assert_eq!(mono, vec![100, 100, i16::MAX]);
}
#[test]
fn two_peers_sum_sample_by_sample() {
let a = vec![100, -200, 300, -400];
@@ -2038,4 +2353,3 @@ mod tests {
assert!((level - 0.5).abs() < 1e-3, "mid-range level was {level}");
}
}
+96 -10
View File
@@ -8,14 +8,19 @@
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
//! address is stale and the dial falls through to discovery.
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
//! timing/source metadata to n0; it only fires when a saved address is stale and
//! the dial falls through to discovery.
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
//! publishes their address to n0 DNS; everyone else just looks it up.
//! - **Stopping publishing removes the local publisher service**; iroh does not
//! expose an explicit unpublish call here, so already-published pkarr records can
//! linger until their default ~30s TTL expires.
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
//! ever touches n0 there, regardless of the Discoverable toggle.
use crate::config::NetworkMode;
use crate::presence::PresenceMode;
use std::time::Duration;
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
match network_mode {
// The explicit serverless posture: no n0 contact at all, even to resolve.
// A Discoverable toggle here is intentionally inert.
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
NetworkMode::DirectOnly => LookupPlan {
resolver: false,
publisher: false,
},
// Relay-capable: always resolve (so a stationary friend can find a mover);
// publish only when the user opted into Discoverable.
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
LookupPlan { resolver: true, publisher: want_publish }
}
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
resolver: true,
publisher: want_publish,
},
}
}
/// Decide which presence mode may be committed after attempting to apply discovery
/// services for `requested`.
///
/// On failure, keep the previous mode: it is the only locally truthful state because
/// the endpoint's discovery services may still reflect the old posture. Same-mode
/// requests are no-ops from a presence-truth perspective and do not surface an error.
pub fn resolve_presence_transition(
previous: PresenceMode,
requested: PresenceMode,
apply_ok: bool,
) -> (PresenceMode, Option<String>) {
if previous == requested {
return (previous, None);
}
if apply_ok {
(requested, None)
} else {
(
previous,
Some(format!(
"Couldn't update discovery mode; keeping {previous}."
)),
)
}
}
@@ -64,12 +100,18 @@ mod tests {
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
assert_eq!(
lookup_plan(mode, false),
LookupPlan { resolver: true, publisher: false },
LookupPlan {
resolver: true,
publisher: false
},
"{mode:?}: resolve always on, no publish when not Discoverable"
);
assert_eq!(
lookup_plan(mode, true),
LookupPlan { resolver: true, publisher: true },
LookupPlan {
resolver: true,
publisher: true
},
"{mode:?}: Discoverable adds publish on top of resolve"
);
}
@@ -79,12 +121,18 @@ mod tests {
fn direct_only_never_touches_n0_even_when_discoverable() {
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, false),
LookupPlan { resolver: false, publisher: false }
LookupPlan {
resolver: false,
publisher: false
}
);
// The serverless posture overrides the Discoverable request entirely.
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, true),
LookupPlan { resolver: false, publisher: false }
LookupPlan {
resolver: false,
publisher: false
}
);
}
@@ -92,4 +140,42 @@ mod tests {
fn timebox_is_thirty_minutes() {
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
}
#[test]
fn presence_transition_commits_requested_mode_after_successful_apply() {
assert_eq!(
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
(PresenceMode::Discoverable, None)
);
}
#[test]
fn presence_transition_keeps_previous_mode_when_apply_fails() {
let (mode, err) =
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
assert_eq!(mode, PresenceMode::Normal);
assert!(err.unwrap().contains("keeping Normal"));
}
#[test]
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
let (mode, err) =
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
assert_eq!(mode, PresenceMode::Discoverable);
assert!(err.unwrap().contains("keeping Discoverable"));
}
#[test]
fn presence_transition_same_mode_is_noop_without_error() {
assert_eq!(
resolve_presence_transition(
PresenceMode::Discoverable,
PresenceMode::Discoverable,
false
),
(PresenceMode::Discoverable, None)
);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Focused, app-local keyboard shortcuts.
//!
//! These helpers are intentionally pure: key serialization, formatting, lookup,
//! and conflict detection live here, while iced event handling stays at the app
//! edge. There are no OS-global shortcuts.
use iced::keyboard;
use serde::{Deserialize, Serialize};
/// A serializable key identity. Modifiers are deliberately out of scope for this
/// first pass; iced delivers the focused app key and we compare that exact key.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum KeyBinding {
Named(String),
Character(String),
}
impl KeyBinding {
pub fn from_key(key: &keyboard::Key) -> Option<Self> {
match key {
keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))),
keyboard::Key::Character(ch) => {
let s = ch.to_string();
if s.is_empty() {
None
} else {
Some(Self::Character(s.to_lowercase()))
}
}
keyboard::Key::Unidentified => None,
}
}
pub fn label(&self) -> String {
match self {
KeyBinding::Named(name) => name.clone(),
KeyBinding::Character(ch) => ch.to_uppercase(),
}
}
}
/// Parse a hand-editable binding string from config/docs/tests. Empty and
/// `"unset"` are unbound.
pub fn parse_binding(input: &str) -> Option<KeyBinding> {
let trimmed = input.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
return None;
}
if trimmed.chars().count() == 1 {
Some(KeyBinding::Character(trimmed.to_lowercase()))
} else {
Some(KeyBinding::Named(trimmed.to_string()))
}
}
pub fn format_binding(binding: Option<&KeyBinding>) -> String {
binding
.map(KeyBinding::label)
.unwrap_or_else(|| "unset".to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HotkeyAction {
ToggleMute,
ToggleDeafen,
OpenSettings,
PushToTalk,
LeaveRoom,
}
impl HotkeyAction {
pub const ALL: [HotkeyAction; 5] = [
HotkeyAction::ToggleMute,
HotkeyAction::ToggleDeafen,
HotkeyAction::OpenSettings,
HotkeyAction::PushToTalk,
HotkeyAction::LeaveRoom,
];
pub fn label(self) -> &'static str {
match self {
HotkeyAction::ToggleMute => "Toggle mute",
HotkeyAction::ToggleDeafen => "Toggle deafen",
HotkeyAction::OpenSettings => "Open Settings",
HotkeyAction::PushToTalk => "Push-to-talk",
HotkeyAction::LeaveRoom => "Leave room",
}
}
pub fn tier(self) -> HotkeyTier {
match self {
HotkeyAction::ToggleMute
| HotkeyAction::ToggleDeafen
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HotkeyTier {
AppWide,
RoomOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HotkeyContext {
pub in_call: bool,
}
impl HotkeyContext {
fn allows(self, action: HotkeyAction) -> bool {
matches!(action.tier(), HotkeyTier::AppWide) || self.in_call
}
}
/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding
/// and add a few function-key app shortcuts that do not collide with typing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HotkeyMap {
#[serde(default = "default_mute")]
pub toggle_mute: Option<KeyBinding>,
#[serde(default = "default_deafen")]
pub toggle_deafen: Option<KeyBinding>,
#[serde(default = "default_settings")]
pub open_settings: Option<KeyBinding>,
#[serde(default = "default_ptt")]
pub push_to_talk: Option<KeyBinding>,
#[serde(default)]
pub leave_room: Option<KeyBinding>,
}
impl Default for HotkeyMap {
fn default() -> Self {
Self {
toggle_mute: default_mute(),
toggle_deafen: default_deafen(),
open_settings: default_settings(),
push_to_talk: default_ptt(),
leave_room: None,
}
}
}
fn named(name: &str) -> Option<KeyBinding> {
Some(KeyBinding::Named(name.to_string()))
}
fn default_mute() -> Option<KeyBinding> {
named("F9")
}
fn default_deafen() -> Option<KeyBinding> {
named("F10")
}
fn default_settings() -> Option<KeyBinding> {
named("F2")
}
fn default_ptt() -> Option<KeyBinding> {
named("Space")
}
impl HotkeyMap {
pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> {
match action {
HotkeyAction::ToggleMute => self.toggle_mute.as_ref(),
HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(),
HotkeyAction::OpenSettings => self.open_settings.as_ref(),
HotkeyAction::PushToTalk => self.push_to_talk.as_ref(),
HotkeyAction::LeaveRoom => self.leave_room.as_ref(),
}
}
pub fn set_binding(&mut self, action: HotkeyAction, binding: Option<KeyBinding>) {
match action {
HotkeyAction::ToggleMute => self.toggle_mute = binding,
HotkeyAction::ToggleDeafen => self.toggle_deafen = binding,
HotkeyAction::OpenSettings => self.open_settings = binding,
HotkeyAction::PushToTalk => self.push_to_talk = binding,
HotkeyAction::LeaveRoom => self.leave_room = binding,
}
}
pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option<HotkeyAction> {
let pressed = KeyBinding::from_key(key)?;
HotkeyAction::ALL
.into_iter()
.find(|&action| context.allows(action) && self.binding(action) == Some(&pressed))
}
pub fn lookup_binding(
&self,
binding: &KeyBinding,
context: HotkeyContext,
) -> Option<HotkeyAction> {
HotkeyAction::ALL
.into_iter()
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
}
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
let mut conflicts = Vec::new();
let actions = HotkeyAction::ALL;
for i in 0..actions.len() {
for j in (i + 1)..actions.len() {
let a = actions[i];
let b = actions[j];
if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b))
&& ab == bb
{
conflicts.push(HotkeyConflict {
binding: ab.clone(),
first: a,
second: b,
});
}
}
}
conflicts
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HotkeyConflict {
pub binding: KeyBinding,
pub first: HotkeyAction,
pub second: HotkeyAction,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_actions_format_as_unset() {
assert_eq!(format_binding(None), "unset");
assert_eq!(parse_binding("unset"), None);
assert_eq!(parse_binding(""), None);
}
#[test]
fn duplicate_binding_is_detected() {
let mut map = HotkeyMap::default();
map.set_binding(HotkeyAction::ToggleMute, parse_binding("M"));
map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m"));
let conflicts = map.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute);
assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen);
}
#[test]
fn lookup_respects_room_tier() {
let mut map = HotkeyMap::default();
map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape"));
let binding = parse_binding("Escape").unwrap();
assert_eq!(
map.lookup_binding(&binding, HotkeyContext { in_call: false }),
None,
"room-only shortcuts should not fire outside a call"
);
assert_eq!(
map.lookup_binding(&binding, HotkeyContext { in_call: true }),
Some(HotkeyAction::LeaveRoom)
);
}
#[test]
fn default_ptt_is_space() {
let map = HotkeyMap::default();
assert_eq!(
format_binding(map.binding(HotkeyAction::PushToTalk)),
"Space"
);
}
#[test]
fn parse_single_character_case_folds() {
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
}
}
+135 -6
View File
@@ -2,6 +2,7 @@ pub mod audio;
pub mod codec;
pub mod dsp;
pub mod network;
pub mod protocol;
pub mod core;
pub mod app;
pub mod config;
@@ -16,10 +17,18 @@ pub mod sanitize;
pub mod avatar;
pub mod recents;
pub mod discovery;
pub mod hotkeys;
use std::path::PathBuf;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
#[cfg(unix)]
const LOG_MODE: u32 = 0o600;
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
/// so we never hardcode a per-user path.
@@ -42,6 +51,74 @@ pub fn log_file_path() -> PathBuf {
log_path().clone()
}
/// Short, human-matchable id prefix for diagnostics. Never use this where the
/// full value is needed for protocol behavior.
pub fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
/// Redact a capability-bearing value for logs while keeping a tiny prefix for
/// support correlation. Tickets and endpoint addresses are bearer capabilities:
/// logging the full string is equivalent to leaking the room/share.
pub fn redact_for_log(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
"<redacted:empty>".to_string()
} else {
format!("<redacted:{}...>", short_id(value))
}
}
pub fn short_bytes_hex(bytes: &[u8]) -> String {
bytes.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join("")
}
fn rotated_log_path(path: &Path) -> PathBuf {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
path.with_file_name(format!("{file_name}.1"))
}
fn prepare_log_file(path: &Path) -> std::io::Result<File> {
prepare_log_file_with_limit(path, LOG_MAX_BYTES)
}
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::metadata(path).is_ok_and(|m| m.len() > max_bytes) {
let rotated = rotated_log_path(path);
let _ = std::fs::remove_file(&rotated);
if std::fs::rename(path, &rotated).is_err() {
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
}
}
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
// The log can carry capability-bearing values (redacted, but still): keep it
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
// the directory ACL, so this hardening is Unix-only.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(LOG_MODE);
}
let file = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Re-assert the mode in case the file pre-existed with looser perms.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
}
Ok(file)
}
pub fn log_msg(msg: &str) {
// Format the whole line into one buffer first, then emit it with a single
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
@@ -51,13 +128,65 @@ pub fn log_msg(msg: &str) {
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
Err(_) => format!("{}\n", msg),
};
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path())
{
if let Ok(mut file) = prepare_log_file(log_path()) {
use std::io::Write;
let _ = file.write_all(line.as_bytes());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn temp_log_dir() -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("peerspeak-log-test-{}-{stamp}", std::process::id()))
}
#[test]
fn redaction_keeps_only_a_short_prefix() {
let secret = "abcdefghijklmnopqrstuvwxyz";
let redacted = redact_for_log(secret);
assert!(redacted.contains("abcdefgh"));
assert!(!redacted.contains("ijklmnopqrstuvwxyz"));
assert_eq!(redact_for_log(" "), "<redacted:empty>");
}
// Owner-only log perms are a Unix concept; on Windows the file inherits the
// directory ACL and there's no mode to assert.
#[cfg(unix)]
#[test]
fn log_file_is_created_private() {
let dir = temp_log_dir();
let path = dir.join("peerspeak.log");
let _file = prepare_log_file(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, LOG_MODE);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn oversized_log_is_rotated_on_open() {
let dir = temp_log_dir();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("peerspeak.log");
{
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"oversized").unwrap();
}
let _file = prepare_log_file_with_limit(&path, 4).unwrap();
let rotated = rotated_log_path(&path);
assert_eq!(std::fs::read_to_string(rotated).unwrap(), "oversized");
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
let _ = std::fs::remove_dir_all(dir);
}
}
+174 -15
View File
@@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize};
/// Domain-separation tag mixed into every signed gossip payload so a signature
/// can never be lifted out of this protocol/version into another context.
const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
use crate::protocol::GOSSIP_SIG_DOMAIN;
/// How far a payload's sender-stamped timestamp may differ from local time
/// before it's rejected as stale (replayed) or implausibly future. Bounds the
@@ -43,7 +43,7 @@ impl std::fmt::Debug for GossipPayload {
f.debug_struct("GossipPayload")
.field("author", &self.author)
.field("ts", &self.ts)
.field("msg", &self.msg)
.field("msg_kind", &gossip_message_kind(&self.msg))
.finish_non_exhaustive()
}
}
@@ -79,6 +79,58 @@ enum GossipReject {
BadSignature,
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
OutOfWindow,
/// A signed Announce advertised an address for a different node id.
AnnounceAddressMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum StateMutationKind {
Announce,
Leave,
}
fn gossip_message_kind(msg: &GossipMessage) -> &'static str {
match msg {
GossipMessage::Announce(_) => "Announce",
GossipMessage::Leave => "Leave",
GossipMessage::Chat { .. } => "Chat",
}
}
fn state_mutation_kind(msg: &GossipMessage) -> Option<StateMutationKind> {
match msg {
GossipMessage::Announce(_) => Some(StateMutationKind::Announce),
GossipMessage::Leave => Some(StateMutationKind::Leave),
GossipMessage::Chat { .. } => None,
}
}
fn admit_state_mutation(
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
author: EndpointId,
msg: &GossipMessage,
ts: u64,
) -> bool {
let Some(kind) = state_mutation_kind(msg) else {
return true;
};
let key = (author, kind);
if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) {
return false;
}
seen.insert(key, ts);
true
}
fn peer_state_for_log(state: &PeerState) -> String {
format!(
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
state.name,
state.is_muted,
crate::short_id(&state.addr.id.to_string()),
state.addr.addrs.len(),
state.sharing.is_some()
)
}
/// Authenticate a received payload against the room topic and local clock. The
@@ -99,6 +151,10 @@ fn verify_gossip(
if now_ms.abs_diff(payload.ts) > window_ms {
return Err(GossipReject::OutOfWindow);
}
if let GossipMessage::Announce(state) = &payload.msg
&& state.addr.id != payload.author {
return Err(GossipReject::AnnounceAddressMismatch);
}
Ok(())
}
@@ -185,11 +241,25 @@ impl RoomState for IrohGossipState {
self_state: PeerState,
extra_bootstrap: Vec<EndpointAddr>,
) -> Result<(), NetError> {
crate::log_msg(&format!("RoomState::join: self_id={:?}, self_name={:?}, ticket={}", self_state.addr.id, self_state.name, ticket_str));
crate::log_msg(&format!(
"RoomState::join: self_id={}, self_name={:?}, ticket={}",
crate::short_id(&self_state.addr.id.to_string()),
self_state.name,
crate::redact_for_log(ticket_str)
));
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
let topic_id = TopicId::from_bytes(ticket.topic_id);
// Version-namespace the subscribed topic (VERSIONING.md): peers on a
// different gossip protocol version derive a different topic from the same
// ticket and never share a swarm. The raw ticket.topic_id stays the room
// identity (and what signatures bind, below).
let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id));
crate::log_msg(&format!("Parsed ticket. host_id={:?}, host_addrs={:?}, topic={:?}", ticket.host_addr.id, ticket.host_addr.addrs, topic_id));
crate::log_msg(&format!(
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
crate::short_id(&ticket.host_addr.id.to_string()),
ticket.host_addr.addrs.len(),
crate::short_bytes_hex(&ticket.topic_id)
));
// Stop any currently running topic
let _ = self.leave().await;
@@ -236,6 +306,7 @@ impl RoomState for IrohGossipState {
let handle = tokio::spawn(async move {
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
let mut state_mutations_seen = HashMap::new();
// Broadcast initial state
let initial_payload = {
@@ -285,7 +356,26 @@ impl RoomState for IrohGossipState {
continue;
}
crate::log_msg(&format!("Gossip Event::Received from author={:?}, payload={:?}", payload.author, payload.msg));
if !admit_state_mutation(
&mut state_mutations_seen,
payload.author,
&payload.msg,
payload.ts,
) {
crate::log_msg(&format!(
"Gossip dropped replayed state mutation author={}, kind={}, ts={}",
crate::short_id(&payload.author.to_string()),
gossip_message_kind(&payload.msg),
payload.ts
));
continue;
}
crate::log_msg(&format!(
"Gossip Event::Received author={}, kind={}",
crate::short_id(&payload.author.to_string()),
gossip_message_kind(&payload.msg)
));
match payload.msg {
GossipMessage::Announce(mut state) => {
@@ -299,6 +389,10 @@ impl RoomState for IrohGossipState {
// monogram, so a malformed/oversized/bomb
// image can't crash or exhaust us (W4).
state.avatar = state.avatar.sanitize_incoming();
// Screen-share tickets are capabilities and
// peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
let (is_new, state_changed) = {
let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author);
@@ -310,11 +404,19 @@ impl RoomState for IrohGossipState {
};
if is_new {
crate::log_msg(&format!("Gossip new peer joined: {:?}, state: {:?}", payload.author, state));
crate::log_msg(&format!(
"Gossip new peer joined: {}, state: {}",
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
address_lookup.add_endpoint_info(state.addr.clone());
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
} else if state_changed {
crate::log_msg(&format!("Gossip peer state updated: {:?}, state: {:?}", payload.author, state));
crate::log_msg(&format!(
"Gossip peer state updated: {}, state: {}",
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
}
}
@@ -390,7 +492,10 @@ impl RoomState for IrohGossipState {
}
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
crate::log_msg(&format!("RoomState::update_self_state: state={:?}", self_state));
crate::log_msg(&format!(
"RoomState::update_self_state: state: {}",
peer_state_for_log(&self_state)
));
*self.self_state.lock().unwrap() = Some(self_state.clone());
let sender_opt = self.active_sender.lock().unwrap().clone();
@@ -489,11 +594,10 @@ mod tests {
use super::*;
use crate::network::PeerState;
use iroh::SecretKey;
use std::collections::HashMap;
fn sample_peer_state() -> PeerState {
let secret = SecretKey::generate();
let public = secret.public();
let addr = iroh::EndpointAddr::from(public);
fn sample_peer_state_for(id: EndpointId) -> PeerState {
let addr = iroh::EndpointAddr::from(id);
PeerState {
name: "TestPeerGossip".to_string(),
is_muted: true,
@@ -563,7 +667,7 @@ mod tests {
fn test_gossip_payload_announce_round_trip() {
let secret = SecretKey::generate();
let topic = [9u8; 32];
let peer_state = sample_peer_state();
let peer_state = sample_peer_state_for(secret.public());
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
let serialized = serde_json::to_string(&payload).unwrap();
@@ -732,5 +836,60 @@ mod tests {
// Within the window (clock skew tolerance) → accepted.
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
}
}
#[test]
fn verify_rejects_announce_with_address_for_another_identity() {
let signer = SecretKey::generate();
let advertised = SecretKey::generate();
let topic = [6u8; 32];
let state = sample_peer_state_for(advertised.public());
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::AnnounceAddressMismatch)
);
}
#[test]
fn state_mutation_replay_gate_drops_replayed_leave_and_announce() {
let author = fresh_id();
let mut seen = HashMap::new();
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
assert!(!admit_state_mutation(&mut seen, author, &announce, 10));
assert!(!admit_state_mutation(&mut seen, author, &announce, 9));
assert!(admit_state_mutation(&mut seen, author, &announce, 12));
}
#[test]
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
let author = fresh_id();
let mut seen = HashMap::new();
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
}
#[test]
fn state_mutation_replay_gate_is_per_author_and_kind() {
let author = fresh_id();
let other = fresh_id();
let mut seen = HashMap::new();
let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
}
}
+168 -5
View File
@@ -5,11 +5,11 @@ use bytes::Bytes;
use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex as StdMutex};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use async_trait::async_trait;
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
use crate::protocol::AUDIO_ALPN;
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
/// useless latency — keep it shallow and drop the oldest frame when full.
@@ -56,6 +56,10 @@ struct Shared {
/// supervisor inserts its connection when the link comes up and removes it
/// when the link dies.
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
/// Core-owned audio admission snapshot for this room session. It mirrors the
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -112,6 +116,16 @@ impl Shared {
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
}
}
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
let roster = self.admitted_audio.lock().unwrap();
audio_sender_admitted(peer_id, &roster)
}
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
let mut roster = self.admitted_audio.lock().unwrap();
apply_audio_admission_event(&mut roster, peer_id, event);
}
}
/// Why a peer's live-link wait woke up.
@@ -135,6 +149,41 @@ fn is_graceful_leave(err: &ConnectionError) -> bool {
matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE))
}
/// Pure S8 membership decision: iroh already authenticated `remote` as the
/// connection's endpoint id, so audio admission is exactly live roster membership.
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
roster.contains(&remote)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AudioAdmissionEvent {
/// A signed gossip Announce/Update says the peer is in the live room roster.
RosterPresent,
/// Gossip reported a transient drop; keep admission during reconnect grace.
TransientDropGrace,
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
Remove,
}
pub(crate) fn apply_audio_admission_event(
roster: &mut HashSet<EndpointId>,
peer_id: EndpointId,
event: AudioAdmissionEvent,
) {
match event {
AudioAdmissionEvent::RosterPresent => {
roster.insert(peer_id);
}
AudioAdmissionEvent::TransientDropGrace => {
// Grace is not an authority to add membership; it only preserves an
// already-admitted peer until either rejoin or grace expiry.
}
AudioAdmissionEvent::Remove => {
roster.remove(&peer_id);
}
}
}
/// Owns a single peer's connection lifecycle for as long as the peer is in the
/// room: obtain a link, run the send/read loops, and on loss obtain a new one —
/// with capped backoff on the dialing side. The deterministic-initiator rule
@@ -333,10 +382,17 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
if shared.self_id.to_string() < peer_id.to_string() {
return Ok(());
}
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected inbound audio from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Route the connection to this peer's supervisor (creating it if the
// inbound link beat the gossip join event). try_send keeps the
// protocol handler from ever blocking; a full queue only happens if
// links are churning, and the supervisor will get the next one.
// inbound link arrives after the signed gossip Announce admitted it).
// try_send keeps the protocol handler from ever blocking; a full queue
// only happens if links are churning, and the supervisor gets the next one.
let inbound_tx = shared.ensure_supervisor(peer_id).await;
if inbound_tx.try_send(connection).is_err() {
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
@@ -369,6 +425,7 @@ impl IrohTransport {
addrs: StdMutex::new(HashMap::new()),
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
incoming_tx,
conn_events_tx,
});
@@ -397,11 +454,35 @@ impl IrohTransport {
}
self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them).
tokio::time::sleep(Duration::from_millis(150)).await;
}
/// Admit a peer to this session's audio plane. Core calls this from verified
/// gossip roster events; the transport never derives membership on its own.
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
}
/// Preserve an already-admitted peer through the reconnect grace window.
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
}
/// Remove a peer from audio admission before tearing down transport/jitter state.
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
}
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
}
#[async_trait]
@@ -443,3 +524,85 @@ impl NetworkTransport for IrohTransport {
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
fn endpoint_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn audio_sender_admission_accepts_roster_member() {
let member = endpoint_id();
let roster = HashSet::from([member]);
assert!(audio_sender_admitted(member, &roster));
}
#[test]
fn audio_sender_admission_rejects_unknown_sender() {
let member = endpoint_id();
let stranger = endpoint_id();
let roster = HashSet::from([member]);
assert!(!audio_sender_admitted(stranger, &roster));
}
#[test]
fn audio_sender_admission_rejects_former_member_after_roster_removal() {
let former = endpoint_id();
let mut roster = HashSet::from([former]);
assert!(audio_sender_admitted(former, &roster));
roster.remove(&former);
assert!(!audio_sender_admitted(former, &roster));
}
#[test]
fn audio_sender_admission_waits_for_mid_join_announce() {
let joining_peer = endpoint_id();
let mut roster = HashSet::new();
assert!(!audio_sender_admitted(joining_peer, &roster));
roster.insert(joining_peer);
assert!(audio_sender_admitted(joining_peer, &roster));
}
#[test]
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
assert!(audio_sender_admitted(peer, &roster));
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(!audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
let peer = endpoint_id();
let mut roster = HashSet::from([peer]);
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
assert!(!audio_sender_admitted(peer, &roster));
}
}
+41 -5
View File
@@ -3,11 +3,12 @@
//!
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
//! binary is self-contained — no asset directory to ship alongside it. On first
//! use each sound is written once to a temp file, then played fire-and-forget
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
//! on a detached thread that waits on the child, so it never blocks the UI and
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
//! a missing chime should never disrupt a call.
//! use each sound is written once to a temp file, then played fire-and-forget.
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
//! detached thread that waits on the child, so it never blocks the UI and never
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
//! missing chime should never disrupt a call.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
Some(path)
}
#[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String {
s.replace('\'', "''")
}
/// Try each available player in turn, waiting on the first that starts (which
/// reaps the child). Runs on a detached thread, so the wait is harmless.
#[cfg(not(windows))]
fn spawn_player(path: &Path) {
for player in ["pw-play", "paplay", "aplay"] {
let started = Command::new(player)
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
}
}
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
/// `PlaySync()` blocking for the sound duration is fine.
#[cfg(windows)]
fn spawn_player(path: &Path) {
let path = escape_powershell_single_quoted(&path.display().to_string());
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
let _ = Command::new("powershell")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -234,6 +258,18 @@ mod tests {
assert!(!should_play(false, false));
}
#[test]
fn test_powershell_single_quote_escape() {
assert_eq!(
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav"
);
assert_eq!(
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
}
#[test]
fn test_sound_indices_unique_and_match_all() {
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
+40 -21
View File
@@ -110,26 +110,32 @@ pub enum FriendPresence {
InRoom { name: String, ticket: String },
}
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
/// peer-supplied name** and **only surface it as joinable if the ticket actually
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
/// dangerous Join button. (We still never auto-join; the user clicks.)
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
/// Interpret a peer's reply defensively. `from` must be the connection's
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
/// points back at the replying friend. A garbage/redirect ticket downgrades the
/// friend to plain `Online` rather than offering a dead or attacker-controlled
/// Join button. (We still never auto-join; the user clicks.)
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
match msg {
ControlMsg::Ping => None,
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
ControlMsg::Pong { room: Some(r) } => {
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
Some(FriendPresence::InRoom {
name: crate::sanitize::sanitize_name(&r.name),
ticket: r.ticket.clone(),
})
} else {
let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
// Online, but the advertised room is unusable — don't offer Join.
Some(FriendPresence::Online)
return Some(FriendPresence::Online);
};
if ticket.host_addr.id != from {
// Online, but the advertised room redirects away from the friend
// who authenticated this Pong — don't offer a phishing Join.
return Some(FriendPresence::Online);
}
Some(FriendPresence::InRoom {
name: crate::sanitize::sanitize_name(&r.name),
ticket: r.ticket.clone(),
})
}
}
}
@@ -206,21 +212,22 @@ mod tests {
#[test]
fn interpret_ping_is_not_a_reply() {
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
}
#[test]
fn interpret_pong_online_and_inroom() {
let friend = id();
// No room -> Online.
assert_eq!(
interpret_pong(&ControlMsg::Pong { room: None }),
interpret_pong(&ControlMsg::Pong { room: None }, friend),
Some(FriendPresence::Online)
);
// Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(id());
let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
});
}, friend);
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
}
@@ -230,17 +237,29 @@ mod tests {
// Online — no dead/hostile Join button is surfaced.
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
});
}, id());
assert_eq!(got, Some(FriendPresence::Online));
}
#[test]
fn interpret_pong_rejects_ticket_for_a_different_host() {
let friend = id();
let attacker = id();
let t = valid_ticket(attacker);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
}, friend);
assert_eq!(got, Some(FriendPresence::Online));
}
#[test]
fn interpret_pong_sanitizes_a_hostile_room_name() {
// Control/bidi characters in a peer-supplied name are stripped.
let t = valid_ticket(id());
let friend = id();
let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
});
}, friend);
match got {
Some(FriendPresence::InRoom { name, .. }) => {
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
+18 -16
View File
@@ -31,7 +31,7 @@ use std::time::Duration;
/// ALPN for the friends presence/control plane. Separate from the audio/gossip
/// ALPNs so a control dial never lands on a bare room endpoint and vice versa.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/0";
pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN;
/// Upper bound on a single control message — generous for a Pong carrying a
/// member ticket (~300 chars), but rejects a peer trying to make us buffer a
@@ -49,16 +49,17 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
serde_json::from_slice(bytes).context("failed to decode control message")
}
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means
/// no usable reply (offline / unreachable / refused / malformed) — the caller
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`]
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and
/// used by hermetic tests).
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<ControlMsg> {
/// Probe `peer` for presence: send a `Ping`, return their authenticated id and
/// `Pong`. An error means no usable reply (offline / unreachable / refused /
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
/// also accepted (and used by hermetic tests).
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
.await
.context("timed out connecting to peer")?
.context("failed to connect to peer")?;
let from = conn.remote_id();
let io = async {
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
@@ -77,7 +78,7 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
.await
.context("timed out awaiting pong")?;
conn.close(VarInt::from_u32(0), b"done");
result
result.map(|msg| (from, msg))
}
/// A reply policy: given the *authenticated* remote id, decide whether and how to
@@ -110,6 +111,10 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
// The authenticated remote id — NOT anything the peer puts in the payload.
let from = conn.remote_id();
let Some(reply) = handler(from) else {
conn.close(VarInt::from_u32(0), b"not authorized");
return Ok(());
};
let io = async {
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
@@ -118,13 +123,9 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
ControlMsg::Ping => {}
other => bail!("expected a ping, got {other:?}"),
}
// Ask the policy what to send. None -> answer nothing (stranger / invisible):
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
if let Some(reply) = handler(from) {
send.write_all(&encode(&reply)?)
.await
.context("failed to write pong")?;
}
send.write_all(&encode(&reply)?)
.await
.context("failed to write pong")?;
send.finish().context("failed to finish reply stream")?;
Ok::<_, anyhow::Error>(())
};
@@ -220,10 +221,11 @@ mod tests {
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
// The allowed prober gets a Pong with the room.
let pong = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
.await
.expect("probe timed out")
.expect("probe failed");
assert_eq!(from, server_addr.id);
match pong {
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
other => panic!("expected Pong with a room, got {other:?}"),
+82
View File
@@ -0,0 +1,82 @@
//! Single source of truth for PeerSpeak's on-wire protocol versions and the
//! per-plane ALPNs / gossip constants derived from them.
//!
//! See `VERSIONING.md`. The rule: each transport plane is versioned independently
//! (audio rarely changes, gossip changes often), and incompatible peers must fail
//! fast — never as a silent decode/signature error. iroh refuses a mismatched
//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating;
//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its
//! version is bound into the subscribed topic ([`versioned_topic`]) and the
//! signature domain ([`GOSSIP_SIG_DOMAIN`]).
//!
//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a
//! plane's protocol version is a breaking wire change → also bump `Cargo.toml`
//! MINOR (see `VERSIONING.md`).
/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio
/// wire change. Mirrored in [`AUDIO_ALPN`].
pub const AUDIO_PROTO: u32 = 1;
/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any
/// change. Mirrored in [`FRIENDS_ALPN`].
pub const FRIENDS_PROTO: u32 = 1;
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
/// into [`versioned_topic`].
pub const GOSSIP_PROTO: u32 = 1;
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw
/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and
/// is unchanged; only the *subscribed* topic is namespaced.
///
/// Deterministic and dependency-free; bijective for a fixed version, so distinct
/// rooms stay distinct after namespacing. This transform is for *isolation*, not
/// security — cryptographic separation between versions comes from
/// [`GOSSIP_SIG_DOMAIN`].
pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] {
let v = GOSSIP_PROTO.to_le_bytes();
let mut out = topic_id;
for (i, b) in out.iter_mut().enumerate() {
*b ^= v[i % v.len()];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The ALPN/domain strings must stay in lock-step with the integer versions
/// so a version bump can't silently forget to update the wire string.
#[test]
fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
}
#[test]
fn versioned_topic_is_deterministic_and_room_distinct() {
let a = [9u8; 32];
let mut b = a;
b[5] = 10;
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
}
#[test]
fn versioned_topic_actually_namespaces_for_current_version() {
// Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic.
assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]);
}
}
+77 -2
View File
@@ -25,6 +25,20 @@ use tokio::process::{Child, Command};
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
#[cfg(windows)]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
}
#[cfg(not(windows))]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
[dir.join(PIXELPASS_BIN)]
}
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -108,6 +122,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
]
}
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
pub fn sanitize_ticket(ticket: String) -> Option<String> {
let ticket = ticket.trim();
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
let valid_shape = ticket.starts_with("endpoint")
&& ticket.len() > "endpoint".len()
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
(valid_len && valid_shape).then(|| ticket.to_string())
}
/// Resolve the pixelpass binary: an explicit config override (used only if it
/// points at an existing file), otherwise the first `pixelpass` found on
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
@@ -126,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
}
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(PIXELPASS_BIN))
.flat_map(|dir| pixelpass_path_candidates(&dir))
.find(|c| c.is_file())
}
@@ -267,12 +294,29 @@ where
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {ev:?}"));
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
}
}
});
}
fn event_for_log(ev: &PixelpassEvent) -> String {
match ev {
PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
PixelpassEvent::Connected(_) => "connected".to_string(),
PixelpassEvent::ViewerJoined { active, max } => {
format!("viewer_joined active={active} max={max}")
}
PixelpassEvent::ViewerLeft { active, max } => {
format!("viewer_left active={active} max={max}")
}
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::Other => "other".to_string(),
}
}
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
/// background task so it doesn't linger as a zombie when its window closes.
@@ -340,6 +384,27 @@ mod tests {
);
}
#[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
}
#[test]
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
}
#[test]
fn event_log_redacts_ticket_values() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
assert!(log.contains("endpoint"));
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
}
#[test]
fn parses_ticket() {
assert_eq!(
@@ -458,4 +523,14 @@ mod tests {
// only assert it doesn't return the empty path as a match.
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
}
#[test]
fn pixelpass_path_candidates_are_platform_specific() {
let dir = Path::new("bin");
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[cfg(windows)]
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
#[cfg(not(windows))]
assert_eq!(candidates, vec![dir.join("pixelpass")]);
}
}
+3
View File
@@ -169,6 +169,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
b.lookup.add_endpoint_info(a.endpoint.addr());
let a_id = a.endpoint.id();
let b_id = b.endpoint.id();
a.transport.admit_audio_sender(b_id);
b.transport.admit_audio_sender(a_id);
// Subscribe to incoming datagrams on B before any are sent.
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");