Completes W4: users can upload a custom avatar image.
- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
xdg-portal backend, off-thread through Task::perform) and shows the current
custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
a custom image must be within the byte cap and decode as a PNG within bounds
(image-crate decode limits guard against decompression bombs) or it's
downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
are actually new in the lockfile (rest were already transitive). cargo audit
clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
accept/reject). 227 lib tests green, clippy clean.
Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds bundled preset avatars on top of the monogram foundation.
- New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded;
6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/
— swap for real designs later). +3 unit tests.
- `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`);
`AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join +
new SetAvatar command) → every self-state announce, so it reaches the room incl.
late joiners, and changing it mid-call re-announces live.
- Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted.
- Rendering: `avatar_view` draws the chosen preset image (iced `image` feature,
now enabled) else the monogram, in the self card, peer rows, and chat (chat
looks up the sender's avatar from presence by id).
⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the
signature is recomputed by re-serializing the parsed struct, so a new PeerState
field means old and new builds can't verify each other's presence — ALL peers
must run a build >= this one (same as the S2 change). Redeploy dopedart before
2-machine testing.
Build + clippy clean, 222 lib + integration tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a dependency-free signal-analysis toolkit and a `specview` dev CLI to
evaluate audio (especially echo cancellation) with objective numbers and a
terminal spectrogram instead of ear alone.
- src/dsp/: hand-written radix-2 FFT, Hann window, STFT, seeded test-signal
generators (sine/log-sweep/white/pink/impulse), metrics (RMS/dBFS/peak/ERLE/
per-band energy), minimal WAV read+write, and a 24-bit-ANSI half-block
spectrogram renderer (magma colormap, freq/time axes, dB legend, ASCII
fallback). Pure layers have no I/O; only `wav` touches the filesystem.
- src/bin/specview.rs: `gen` (conjure a test signal -> WAV), `show` (spectrogram
+ per-band energy summary), `erle` (broadband + per-band echo-return-loss
between a before/after pair).
- 27 unit tests (FFT correctness, ERLE landmarks, WAV round-trip, render shape).
Verified end-to-end: log sweep renders as the expected exponential curve;
a 20 dB-quieter copy reads +20.0 dB ERLE broadband and per band.
Measurement substrate for upcoming AEC refinement (no shipped-path changes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Adds a mic input meter to Settings for gate calibration, replacing the
blind noise-gate slider with a unified Discord/OBS-style control: the bar
shows the live mic level and a draggable handle sets the gate threshold on
the same axis. Fill is green above the gate (transmitting), dim below it
(muted), with a live status word; the handle is bright red with a dark
edge so it stays legible when the green level sweeps past it.
Two level sources:
- In-call: the capture thread peak-holds the raw (pre-gate, pre-mute)
frame level and emits UiEvent::MicLevel ~10/sec.
- Off-call: a "Test mic" toggle runs CoreCommand::SetMicMonitor, spinning
up a standalone capture-only stream feeding run_mic_monitor. It shares
the backend's single capture stream, so Join tears it down first and
leaving Settings releases it; ignored while a session is active.
The gate handle drags live via NoiseGateDragging (no disk write per pixel)
and persists once on release via NoiseGateChanged. Meter axis is 0..0.3 so
a normal voice doesn't peg. Enables the iced "canvas" feature for the
custom GateMeter widget.
Build + clippy clean, tests pass. Field-verified on desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
redundant pattern matching, missing Default)
Builds clean with zero warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fix tokio runtime panic by spawning a dedicated Tokio runtime thread in CoreController.
- Add central log_msg utility in src/lib.rs for debugging.
- Add instrumentation/logs to join, leave, and gossip events in src/network/gossip.rs.
- Add test_net.rs bin for testing gossip loopback sync.
- Use std::sync::Mutex in IrohGossipState to resolve Tokio block-in-async panics.