352 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 fe627166d5 fix(security): close S3 (arg-injection), S4 (presence-name), S1 (panic-slice)
Three findings from the first security pass:

- S3 (Medium): the peer-supplied screen-share ticket was passed to pixelpass
  as the first positional CLI arg with no end-of-options guard, so a ticket
  starting with `-`/`--` could be reinterpreted as a flag (argument injection).
  New pure `viewer_args()` puts flags first, then a `--` guard, then the ticket
  positionally; spawn_viewer uses it. +2 tests.

- S4 (Medium): peer presence display-names (gossip `Announce`, untrusted and
  spoofable) were rendered unsanitized/unbounded, unlike the chat path. New
  `sanitize::sanitize_name` strips bidi/zero-width format chars + control chars,
  collapses whitespace, and caps at 48 chars; applied at the gossip ingest point
  so every consumer gets a safe value. +4 tests.

- S1 (Low): `&id[..8]` byte-slices could panic on a short/non-ASCII id. New
  panic-free `short_id()` (char-based take) replaces both slices. +1 test.

158 lib tests (was 151), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:16:24 -04:00
molluskandClaude Opus 4.8 420535c5d3 feat(ui): sticky Settings header with Back button
Move the Settings "Back" button out of the scrollable (where it sat as the
last child and scrolled off the bottom of a long page) into a fixed header
bar that stays pinned at the top while content scrolls. The header is a
styled bar with the Back button on the left and a centered "Settings" title;
the old top title and bottom Back button inside the scrollable are removed.

Pure layout change, still dispatches AppMessage::NavigateBack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:15:17 -04:00
molluskandClaude Opus 4.8 c899b1cb94 fix(audio): playback worker re-checks running flag so stop() can't hang (A7)
The playback worker thread looped `while running { rx.recv() }`. A blocking
recv() never re-checks the `running` flag — it only wakes on a new frame or the
sender being dropped. So when stop() set running=false and called
worker_handle.join(), the worker stayed parked in recv() and join() hung until
the frame Sender happened to be dropped. audio_probe reproduced this every run
(it calls backend.stop() while its tx is still in scope), hanging on exit; the
GUI could hang on shutdown on any teardown path that stops audio before dropping
the sender.

Fix: extract a `drain_loop` seam that uses recv_timeout(WORKER_POLL=100ms) so
the loop re-checks `running` at least every 100ms even when idle, and returns
promptly on Disconnected. stop() now joins within one poll interval regardless
of the sender's lifetime. +3 unit tests (151 lib): exits on running-flip with
the sender still alive (the exact hang case, asserted via is_finished), returns
on disconnect, and delivers frames. Verified: audio_probe now self-exits cleanly
(exit 0, "done.", no lingering process). clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:58:16 -04:00
molluskandClaude Opus 4.8 5bd32250a5 fix(audio): honor per-cycle quantum in playback (A1, crackle on non-1024 hw)
The playback RT callback pinned the PipeWire buffer to exactly one 1024-frame
quantum (the 2026-05-31 crackle fix). That is only correct when the machine's
clock.quantum is 1024 — on hardware running quantum 512 or 2048 the pinned
slice mismatches the device's per-cycle demand and the crackle returns. We just
shipped a release to a friend whose quantum is unknown, so this was P1.

Fix: enable the pipewire `v0_3_49` feature (exposes Buffer::requested(), the
graph's per-cycle quantum) and fill exactly that many frames each callback via a
new pure `frames_to_produce()` seam, with a safe ≤1024 fallback when the graph
reports 0 (never the whole slice — over-pulling past the ring depth is the
original crackle). Relax the Buffers size pin from a hard 1024 to a generous
8192-frame max so the mapped slice fits any plausible quantum; requested(), not
the buffer size, now governs per-cycle output.

Verified locally with `pw-metadata clock.force-quantum` + audio_probe at forced
quanta 512/1024/2048: each shows `underrun +0` steady, `quantum=` matching the
forced value, and callbacks/s ≈ rate/quantum — proving requested() is live (the
health line would otherwise read the 1024 fallback). +4 unit tests on
frames_to_produce (148 lib tests, clippy --all-targets clean).

Still pending (field test): one real desktop<->dopedart call through the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:49:53 -04:00
mollusk 7d86c9be7f Merge feat/ui-themes: selectable UI themes (10 palettes + swatch picker) 2026-06-13 17:43:13 -04:00
molluskandClaude Opus 4.8 c87fa3d45f feat(ui): selectable UI themes (10 palettes + swatch picker)
The UI was hardcoded to one Catppuccin Mocha palette (color literals in
view() + theme() pinned to Dark). It now sources colours from a chosen
theme's palette, selectable in Settings.

- theme.rs (NEW): Palette (13 semantic colour roles) + AppTheme enum
  (Catppuccin Mocha/Macchiato/Frappe/Latte, Dracula, Nord, Tokyo Night,
  Gruvbox Dark, Solarized Light, Gruvbox Light). Pure palette()/label()/
  ALL/base_theme()/is_dark() + WCAG relative_luminance()/contrast_ratio().
- config: theme: AppTheme field (serde-default Mocha) + backward-compat.
- app: theme() returns config.theme.base_theme() (iced widget chrome);
  view() + with_layout_picker() source colours from the palette; new
  ThemeSwatch canvas widget; a Theme section of clickable swatches in
  Settings; SelectTheme applies live + persists. Canvas widgets already
  take colours as data, so they re-theme for free.

Tests written alongside (+7 theme, +2 config; 135 -> 144 lib): every
palette clears WCAG AA text-on-base contrast (4.5:1) with subtext/accent
>= 3:1, is_dark matches luminance direction, variants distinct/labeled,
serde round-trips, default = Mocha.

Verified live: the Settings swatch grid renders all 10 palettes and the
whole UI re-themes (screenshot-checked Latte light + Dracula dark).
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:43:13 -04:00
mollusk 53c1c112e6 Merge feat/x11-position-restore: restore window position on X11 2026-06-13 16:51:22 -04:00
molluskandClaude Opus 4.8 c888c30c08 feat(window): restore window position on X11
X11 sessions now persist and restore the window position (window_x/y) in
addition to size. Gated to X11: Wayland's xdg-shell gives clients no way
to self-position, so we center there (and iced never emits Moved on
Wayland, so window_x/y stay None). No drift across save/restore — iced's
Moved event and Position::Specific both use the window's outer position.

Also confirmed peerspeak already runs on X11 out of the box (winit
compiles both backends and auto-selects via WAYLAND_DISPLAY/DISPLAY) and
documented X11/Wayland support in FEATURES.md.

- config: window_x/window_y: Option<i32> (serde-default None).
- app: is_wayland() + pure initial_window_position() helper; a Moved
  handler records position; the close path persists it.
- tests: +3 initial_window_position (X11 restore / Wayland centers /
  partial-or-missing centers), +2 config (round-trip incl. negative
  coords; backward-compat load without the new fields). 130 -> 135 lib.

Verified live on X11/XWayland: saved an off-center position, the window
reopened there (not centered). clippy clean incl. --all-targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:51:22 -04:00
molluskandClaude Opus 4.8 45cea799ec chore: gitignore makepkg build artifacts
Building packaging/PKGBUILD in place leaves scratch dirs (src/, pkg/,
peerspeak/ clone), the built *.pkg.tar.* and makepkg *.log behind, and
also rewrites pkgver in PKGBUILD. Ignore the scratch outputs so an
in-tree `makepkg` no longer pollutes git status. (Cleanest is still to
build from a copy outside the repo.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:07:39 -04:00
molluskandClaude Opus 4.8 02065d23bc feat: app icon + Arch package (PKGBUILD + .desktop)
Add a desktop/taskbar identity and a system package for PeerSpeak.

- assets/icons/peerspeak.svg: master app icon — the in-app mic glyph
  over a P2P mesh of peer nodes, Catppuccin Mocha palette. Rendered to
  the hicolor raster sizes (16..512) committed alongside it.
- Window/taskbar icon: embed a 128x128 straight-RGBA blob and load it
  via iced from_rgba (keeps us off iced's heavy `image` feature). Set
  the Wayland/X11 app_id to "peerspeak" so compositors match the window
  to the .desktop launcher and show the icon natively.
- packaging/peerspeak.desktop: launcher (StartupWMClass=peerspeak).
- packaging/PKGBUILD: peerspeak-git VCS package — cargo --frozen build,
  --lib check, installs the binary, .desktop, and hicolor icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 20:01:26 -04:00
mollusk a757353c69 Merge fix/remember-window-size: persist + restore window size 2026-06-06 17:44:11 -04:00
molluskandClaude Opus 4.8 88905e5173 fix(ui): remember window size across launches
The window always opened at the hardcoded 900x760 because the size was never
persisted: run_gui hardcoded it, AppConfig had no size fields, and the Resized
handler only kept the size in memory (for divider clamping) while
exit_on_close_request:true quit before anything could save.

- AppConfig gains window_width/window_height (serde-default 900/760).
- run_gui restores them as the initial window size.
- The Resized handler mirrors the live size into config (guarded against bogus
  tiny sizes); divider positions on load now clamp against the restored size
  rather than a hardcoded default.
- exit_on_close_request:false + a CloseRequested handler writes the final size
  once, then iced::exit() — no per-resize disk thrash.

Verified empirically that KWin/Wayland honors a client-requested initial size
(requested 1150x680 -> window reported 1150x680). Window *position* is not
restored: xdg-shell gives Wayland clients no way to set their own position.

+1 config test (default + round-trip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:42:59 -04:00
molluskandClaude Opus 4.8 1a377a2323 docs: note the canvas icon set in FEATURES
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:20:47 -04:00
mollusk 08f173440a Merge feat/icon-set: canvas-drawn icon set replacing emoji 2026-06-06 17:17:56 -04:00
molluskandClaude Opus 4.8 f4d3ad9d7f feat(ui): canvas-drawn icon set replacing emoji
Add an 18-icon set drawn on iced canvas (no image/font dep, recolors with the
theme) — consistent with the existing LayoutThumb/GateMeter/Divider widgets.

Icons: mic, mic-off, headphones, deafen, speaker, speaker-off, monitor (share),
eye (watch), record, stop, chat, people, clock, settings, copy, leave, create,
live. Each authored in a 24x24 space, scaled to the widget, stroked with round
caps/joins.

Wired throughout the UI in place of emoji + added to the icon-less primary
buttons (mute/deafen/leave/copy/settings/create): self + peer cards (live badge,
watch, per-peer speaker mute), header (participants/timer/REC), controls
(mute/deafen/record/share/leave), launch (create/settings), settings (test mic),
drawer chat toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:14:25 -04:00
molluskandClaude Opus 4.8 375d9ad261 docs: screen share single-sharer 2-machine path field-verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:30:31 -04:00
mollusk 4ee31cc889 Merge feat/screenshare: screen sharing via pixelpass 2026-06-06 16:14:59 -04:00
molluskandClaude Opus 4.8 d9e544607a feat: screen sharing via pixelpass (Discord-style, presence-borne ticket)
Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.

Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.

- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
  discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
  (→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
  so the existing gossip re-announce delivers the offer to late joiners for free
  and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
  session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
  pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
  + Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).

Tests-green; the 2-machine gossip/remote path is not yet field-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:46:34 -04:00
molluskandClaude Opus 4.8 5dad86db57 docs: lock screen-share integration design (peerspeak <-> pixelpass)
Approach B: peerspeak spawns pixelpass --host --output json, scrapes the
ticket from its JSON stdout, and distributes it over the existing gossip
plane as a ScreenShareOffer; peers get a one-click pixelpass viewer.
Mutually optional, runtime-only coupling -- neither tool is a Cargo
dependency of the other; the contract is pixelpass's CLI + JSON protocol.
Video-only, separate viewer window, PATH binary discovery. Not yet built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:14:14 -04:00
molluskandClaude Opus 4.8 be2ee334b4 docs: add user-facing feature inventory with field-test status
Capability list of what PeerSpeak already does, companion to
ARCHITECTURE.md, so the feature surface doesn't have to be re-derived
from the code each session. Marks each row verified / tests-green /
plumbing, and collects the outstanding 2-machine field-test debt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 03:48:43 -04:00
molluskandClaude Opus 4.8 2f12d54a80 feat(ui): selectable room layouts with a thumbnail picker
Add three in-call room layouts — 3-Column (Participants | Chat | Controls),
Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer
(Participants | Controls with a collapsible Chat panel) — chosen via one
persisted RoomLayout config setting and applied live.

Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the
launch and in-call screens opens a popup gallery (dimmed click-to-dismiss
backdrop + centered panel) of clickable schematic thumbnails; the Settings screen
shows the same thumbnails inline (no button). Thumbnails are drawn with the
canvas widget (new LayoutThumb program — colored panel boxes, blue border on the
selected one), so no image-decoding dependency is added.

Each layout's panel boundaries are draggable (DividerKind gains Controls +
ChatDrawer for the 3-column right divider and the drawer's left edge; new
clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize).
Participants width is shared across layouts but capped per layout at render time
so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set
in the dock layout won't collapse Chat in 3-column or Controls in the drawer).
The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:23:27 -04:00
molluskandClaude Opus 4.8 b436b57f13 feat: remember last nickname + sanitize chat input
Remember last nickname: a new serde-default AppConfig.username field is
pre-filled into the launch-screen nickname field, and saved when a room is
joined or created (i.e. when the name is actually used), so it carries across
launches.

Sanitize chat: a pure sanitize_chat() drops control characters (ANSI escapes,
NUL, stray CR/LF/TAB), collapses whitespace runs to single spaces, trims, and
caps length (2000 chars). Applied to our outgoing text on submit AND to incoming
peer messages on receive — peer content is untrusted, so the sender's name and
text are both sanitized before display; empty-after-sanitize messages are
dropped. Unit tests for sanitize_chat (control/whitespace/unicode/empty + length
cap) and a config backward-compat assertion for username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:02:08 -04:00
molluskandClaude Opus 4.8 5279a53400 feat(ui): draggable, persisted dividers between room panels
Add a reusable Divider canvas widget and place two in the room screen: a
vertical divider between the Participants and Controls panels (drag to resize
the Participants width) and a horizontal divider between the main row and the
Chat dock (drag to resize the dock height). The Participants panel and Chat dock
size from persisted config values; the Controls panel and main row fill the rest.

The widget reports drag motion as a pixel delta along its axis (mirroring the
GateMeter drag handling, so a drag continues past the thin strip). update()
applies the delta and clamps it: clamp_participants_width / clamp_chat_height
keep both sides of each divider above a minimum. Sizes are re-clamped on window
resize (window size tracked from window::Event::Resized) and clamped again on
load (a size saved under a different window could be out of range).

Persistence: participants_width / chat_height are new serde-default AppConfig
fields; the divider publishes PersistConfig on drag release so the final
position is written once (not per pixel). 3 clamp unit tests (incl. a tiny-window
degenerate case) + config backward-compat assertions for the new fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:44:53 -04:00
molluskandClaude Opus 4.8 68d78ff411 chore(test): drop useless vec! in recorder wav test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:00:02 -04:00
mollusk 0ea91eb026 Merge gemini/chat-tests: chat wire + history-cap unit tests 2026-06-05 21:58:51 -04:00
molluskandClaude Opus 4.8 7bbe4f3af6 test: cover chat wire type + history cap
GossipMessage::Chat serde round-trips (normal, empty strings, u64::MAX ts,
unicode/emoji), GossipPayload{Chat} round-trip, and push_chat history-cap
behaviour (single, below cap order-preserved, above cap drops oldest keeping
the newest CHAT_HISTORY_MAX in order). Gemini, senior-audited.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:58:51 -04:00
molluskandClaude Opus 4.8 a6aca73c67 feat: in-room text chat over the gossip plane
Add room text chat riding the existing iroh-gossip topic (same layer as the
presence roster). New GossipMessage::Chat { name, text, ts }; the gossip loop
forwards it as RoomEvent::ChatMessage, core relays it to the UI as
UiEvent::ChatMessage, and RoomState::send_chat broadcasts an authored line
(display name from self-state, ms timestamp). CoreCommand::SendChat sends; our
own author is suppressed by the existing self-echo guard, so the UI echoes our
sent line locally instead.

UI: a full-width chat dock along the bottom of the room (the chosen layout) —
bottom-anchored scrollback with per-sender name colouring (green = you), an
input with Enter-to-send + a Send button, history capped at 300 lines. The room
window default grows to 900x760 so the dock doesn't squeeze the controls column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:55:49 -04:00
molluskandClaude Opus 4.8 c3cf00f46f feat: local call recording (your mic + incoming mix) to WAV
Opt-in recording of the full call as you experienced it. New dep-free
src/audio/recorder.rs: a canonical mono S16LE WavWriter (header patched on
finalize) plus a Recorder that buffers your transmitted mic in a bounded FIFO
and sums it, sample-aligned, with each incoming-mix frame the playout mixer
produces. The two independently-clocked streams stay aligned via the FIFO
(capped at ~200ms so drift lag can't grow without bound); silent stretches
record the incoming mix alone. Dep-free UTC timestamp -> sortable filename.

Wiring: CoreCommand::SetRecording toggles an Arc<Mutex<Option<Recorder>>> gated
by an is_recording flag (so the capture/mixer hot paths only lock while actually
recording); capture pushes post-gate mic, the mixer writes the pre-deafen mix.
Recording finalizes on stop, room leave, and room switch. UI: a Record/Stop
button in the controls and a red "● REC m:ss" pill in the room header;
core-confirmed Recording{Started,Stopped} events drive the UI flag so a failed
start can't lie. Files land in ~/peerspeak-recordings/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:48:46 -04:00
mollusk 674c9b6950 Merge gemini/limiter-tests: dense soft-limiter test battery 2026-06-05 21:31:23 -04:00
molluskandClaude Opus 4.8 90717cda37 test(audio): dense battery for the mix-bus soft limiter
Ten more cases pinning the SoftLimiter contract (Gemini, senior-audited):
sustained-loud ceiling both polarities, out_gain participation (boost + atten),
instant-attack no-overshoot, release direction/monotonicity + gradualness,
cross-call state continuity (split == continuous), empty input, extreme
i32::MIN/MAX magnitudes, and bit-exact transparency just under the ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:31:23 -04:00
molluskandClaude Opus 4.8 38c92ced62 feat(audio): mix-bus soft peak limiter
Replace the mixer's per-sample hard clamp with a lossless i32 bus sum fed
through a feed-forward soft limiter (instant attack, ~120ms release). Below
the ceiling it's transparent and sample-exact; loud multi-peer moments are
ridden down to the ceiling instead of shattering into hard-clip distortion.
State carries across frames so a sustained-loud stretch doesn't re-attack
every 20ms frame. The master output gain now applies inside the limiter so a
boost past the ceiling is limited too.

mix_frames now returns the lossless i32 sum (saturation responsibility moved
to the limiter); its tests assert losslessness, and the new limiter module
carries the saturation/transparency/release guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:27:32 -04:00
mollusk c5962f2703 Merge branch 'gemini/doc-adaptive-jitter': document adaptive playout-delay controller 2026-06-05 18:58:28 -04:00
molluskandClaude Opus 4.8 1bf79be0e1 docs(architecture): document the adaptive playout-delay controller
Add ARCHITECTURE.md Section 4 covering the jitter buffer's adaptive
playout delay: controller state/params, the grow/shrink/silence/overflow/
prime-timeout transitions, and a state diagram; note it in the Section 2
module map. Sections renumbered 4-7 -> 5-8 (no internal cross-refs).

Gemini-authored (junior) via the headless agy loop. Senior review caught +
fixed an inaccuracy: the original called the strategy "AIMD (multiplicative
decrease)" but the shrink is additive (-1, rate-limited by CLEAN_RUN_TO_SHRINK),
not multiplicative; reworded accordingly. Numbers fact-checked against
src/core/jitter.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:58:28 -04:00
mollusk c935c15e92 Merge branch 'gemini/jitter-adaptive-edge-tests': adaptive-controller edge tests 2026-06-05 18:55:42 -04:00
molluskandClaude Opus 4.8 9f1b276f36 test(jitter): cover grown-target re-prime and overflow clean_run reset
Two edge tests for the adaptive playout-delay controller:
- grown_target_requires_deeper_reprime: a disruption-grown target actually
  gates the next re-prime (3 frames no longer enough once target is 4).
- overflow_resync_resets_clean_run: the MAX_BUFFERED overflow resync path
  restarts the clean run.

Gemini-authored (junior), senior-reviewed against the real diff and
independently re-verified (cargo test --lib + clippy clean). Driven via the
headless agy --print --sandbox loop (resumed with --continue past the
orientation-tax timeout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:55:42 -04:00
mollusk 43b03ba784 Merge branch 'gemini/trial-format-duration': format_duration hour-boundary tests 2026-06-05 18:49:15 -04:00
molluskandClaude Opus 4.8 6541834e0d test(app): pin format_duration hour-boundary cases
Add two boundary assertions to format_duration_renders_mss_and_hmmss:
59s -> "0:59" (last second of m:ss form) and 3599s -> "59:59" (final
second before the output switches to h:mm:ss at 3600).

Gemini-authored (junior), senior-reviewed against the real diff and
independently re-verified (cargo test --lib + clippy clean). First task
driven through the headless `agy --print --sandbox` loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:49:11 -04:00
mollusk 3d7d0fac11 Merge branch 'feature/adaptive-jitter-buffer': adaptive jitter playout delay 2026-06-05 18:34:55 -04:00
molluskandClaude Opus 4.8 4e8074cb92 feat(jitter): adaptive playout delay driven by buffer feedback
Replace the fixed 3-frame (~60ms) playout delay with a feedback
controller that tunes depth to real network behavior, no wall clock
needed:

- Grow (+1 frame) on a late-arriving packet (one for a sequence already
  played past) or a gap that forces Opus PLC — jitter beat the cushion.
- Shrink (-1 frame) after a long unbroken run of real frames — the link
  is comfortably ahead. Fast grow, slow shrink (AIMD-style).
- Bounded to [2, 12] frames (40-240ms), well under MAX_BUFFERED_FRAMES.
- Benign silence (a talker pausing) emits none of these signals, so the
  delay is untouched across quiet stretches — avoids the classic
  "inflate delay because someone went quiet" bug.
- Prime-timeout safety net: since the mixer polls every ~20ms, prime
  after ~500ms even under a grown target so a short utterance isn't held
  forever and startup latency stays bounded.

No public API change; all logic stays in jitter.rs. Adds 8 unit tests
(grow/shrink, both bounds, silence-neutrality, prime timeout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:34:52 -04:00
mollusk 6885180b39 Merge branch 'gemini/pw-cli-tests': parse_pw_nodes unit tests 2026-06-02 17:08:44 -04:00
molluskandClaude Opus 4.8 8614b26824 test(audio): unit tests for parse_pw_nodes device parser
Covers the pure pw-cli parser seam: multi-node parse sorted by description
(non-audio dropped), Source=>input / Sink=>output, description-falls-back-
to-name, empty/non-audio inputs yield nothing, EOF-flush of the final block,
and incomplete blocks (no media.class) dropped. pw_cli tests 0 -> 6.

Implemented by Gemini per next-task.md; left uncommitted per the operating-
agreement default, reviewed against the real diff and re-verified (build +
clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:08:44 -04:00
mollusk c96d6f020f Merge branch 'refactor/pw-cli-parse-seam': pure parse_pw_nodes seam 2026-06-02 17:03:25 -04:00
molluskandClaude Opus 4.8 ec7d1a85b5 refactor(audio): extract pure parse_pw_nodes from device enumeration
Splits the pw-cli output parsing out of enumerate_audio_devices into a pure
fn parse_pw_nodes(&str) -> Vec<AudioDevice> (with a push_device helper),
leaving only the subprocess call in enumerate_audio_devices. Behavior-
preserving — same id-block boundaries, Audio/* filter, Source=>input,
description-falls-back-to-name, and sort-by-description. Creates a testable
seam (the parsing had zero coverage). Build + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:03:25 -04:00
mollusk d8cdebbf5a Merge branch 'feature/room-screen': in-room VU meters, local mute, call info bar 2026-06-02 16:57:50 -04:00
molluskandClaude Opus 4.8 3708ca1e15 feat(room): per-peer VU meters, own mic meter, local mute, call info bar
Enriches the in-room screen:
- Per-peer VU meters: a live level bar per peer card (reuses the per-peer
  audio_levels stream), green while speaking, dim when idle/locally-muted.
- Your own mic meter on the self-card (reuses the in-call MicLevel), green
  when transmitting, grey when muted or PTT-inactive.
- Per-peer local mute (🔊/🔇): silences a peer for you only — decoded so
  their VU still moves, but not mixed. New CoreCommand::SetPeerMuted + a
  locally_muted set in the core/mixer, distinct from per-peer volume.
- Header call-info: participant count + a live m:ss / h:mm:ss call timer
  (dependency-free — rides the in-call event stream rather than a tick sub).

format_duration unit-tested. Build + clippy clean, 70 lib tests.
Field-verified on a real desktop<->dopedart call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:57:50 -04:00
mollusk 88d1268b6e Merge branch 'gemini/jitter-edge-tests': jitter buffer state-transition tests 2026-06-02 16:33:55 -04:00
molluskandClaude Opus 4.8 9d278ded5a test(jitter): state-transition edge cases for JitterBuffer
Covers the remaining state-transition edges: re-prime after an underrun goes
idle (must re-accumulate TARGET_DELAY_FRAMES, not resume on one packet),
duplicate-insert overwrite (no buffer growth), is_idle across fresh/buffering/
underrun, and overflow-resync when next_seq is already Some (playout head
snaps to the new front). Jitter tests 6 -> 10; test-only, no prod change.

Implemented by Gemini per next-task.md; left uncommitted per the operating-
agreement default, reviewed against the real diff and re-verified (build +
clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:33:55 -04:00
mollusk 2c17310adc Merge branch 'feature/io-volume': input/output volume sliders 2026-06-02 16:21:40 -04:00
molluskandClaude Opus 4.8 5af25bff5e feat(audio): input/output volume sliders in Settings
Adds Discord-style app-internal gain controls under each device picker:
input volume scales the captured mic (applied before the meter/gate/encode,
so it also moves the mic meter), output volume scales the mixed playback
(on top of per-peer volumes). PeerSpeak-only — no system/other-app effect.

Both persist in config (input_volume/output_volume, serde default 1.0 for
backward compat) and read live by the audio loops via f32-bit atomics, so
they take effect mid-call. Sliders apply live on drag and save on release.
The standalone mic-test monitor applies the same input gain so the test
meter reflects it. Reuses the existing apply_volume helper (unity fast-path
+ i16 saturation).

Tests: config backward-compat + round-trip for the new fields (gain math
itself is covered by the existing apply_volume tests). 65 lib tests, clippy
clean. Field-verified: input slider moves the mic-test meter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:21:39 -04:00
mollusk cb2776cc1d Merge branch 'gemini/gate-tests': noise gate edge-case unit tests 2026-06-02 16:00:15 -04:00