27 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 99a4a336ad Release 0.6.3 — in-app screen-sharing controls + hwdec fixes
CI / check (push) Failing after 4s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:20:00 -04:00
molluskandClaude Opus 4.8 df45c0bfeb screenshare: log the pixelpass-host and player argv on spawn
The screen-share code only logged pixelpass's high-level JSON events, never
the argv it spawned children with, so a field log couldn't confirm which
encode/viewer settings actually reached the helpers — e.g. the per-call
quality's --bitrate (host) or the hardware-decode --avcodec-hw/--hwdec flag
(player). Log both verbatim at spawn: host args carry no secret, and the
player line omits the local stream URL. Logged per attempt so a player
fallback is visible too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:47:06 -04:00
molluskandClaude Opus 4.8 faad8ce26a screenshare: honor viewer settings for the VLC player too
The viewer playback settings (hardware decode + buffering) only shaped
mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them.
The load-bearing case is hardware decode: mpv defaults to software decode
(the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with
the default hardware_decode=false still got GPU decode and could hit the
frame-1 freeze the default exists to avoid — the toggle did nothing.

vlc_args() now takes the settings and maps the knobs that translate
cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering
posture (network/live caching ms). The genuinely mpv-specific knobs
(cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI
hints are reworded to say which knobs are mpv-only vs universal. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:22:31 -04:00
molluskandClaude Opus 4.8 e378b2e33b screenshare: fix inline per-call quality override being discarded
The Share control's inline quality dropdown sets a session-only
`share_quality_selection`, but ToggleScreenShare (which opens the audio
picker on the only real path to a share) unconditionally reset it back to
the saved config default before ConfirmShareScreen read it. The picker has
no quality control of its own, so the user's per-call pick was silently
dropped 100% of the time and every share used the persisted default.

Drop the reset; add a regression test asserting the override survives
picker-open and reaches the confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:09:22 -04:00
molluskandClaude Opus 4.8 96e41de1b1 screenshare: advanced in-app streaming controls + hwdec toggle
Add a local-only "Screen sharing" section to Settings plus a per-call quality
picker on the Share control: in-app control over how a share is encoded
(quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra
pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering,
cache, + extra mpv args). Settings live in AppConfig.screen_share (all
serde-defaulted, so old configs load unchanged) and become pixelpass host CLI
flags / mpv args at share/view launch.

Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug:
forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio
kept playing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 5c888f8357 context_input: middle-click pastes the X11 PRIMARY selection
Joe (X11) reported that middle-click paste did nothing in the ticket and
node-ID fields. iced's base text_input only binds Ctrl+V to the Standard
(CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so
the "select text, middle-click to paste" workflow was dead.

Add a Button::Middle branch to ContextInput::update that reads
clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing
the already-tested pure paste()). Factor the control-char stripping into a
shared, unit-tested sanitize_clip() helper also used by the menu Paste, so
a trailing newline on the PRIMARY selection is dropped. Respects `locked`
so read-only display fields still reject paste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:25:01 -04:00
molluskandClaude Opus 4.8 074f004227 core: one screen-share player per share — re-watch replaces, not stacks
Every click of Watch (`CoreCommand::ViewShare`) spawned a fresh pixelpass
viewer + mpv and pushed it onto an untracked Vec. A field test hit the
consequence: the first click gave a frozen player (the host's capture was
stalling), so the viewer clicked again to retry — and got a SECOND mpv,
doubling the shared audio.

Track viewers paired with their share ticket. On ViewShare, reap players
whose window already closed (try_wait), then if a live player for the same
ticket exists, kill it before spawning the replacement. Re-watching a
share now swaps its player instead of stacking a second one. Pure
`replace_viewer_index` seam + test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:48:52 -04:00
molluskandClaude Opus 4.8 2d22036930 screenshare: drop mpv --untimed so shared video stays A/V-synced
The viewer launched mpv with `--untimed`, which displays each video
frame the instant it decodes and ignores audio timestamps. Sharing a
desktop (no audio) that just minimizes latency, but sharing a *video*
made its audio drift progressively out of sync — confirmed in a field
test watching a video together. Remove the flag so mpv paces video to
the audio clock; the remaining low-latency flags keep lag negligible for
desktop pointing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:59 -04:00
molluskandClaude Opus 4.8 8014edf91c Release 0.6.2 + AppImage packaging
CI / check (push) Failing after 3m41s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.2 (Cargo + Inno .iss), promote CHANGELOG [Unreleased] -> [0.6.2].
0.6.2 rolls up the licensing (MIT + THIRD_PARTY_LICENSES) and the friends-list
liveness fixes (active offline marking, 15s refresh, manual Rescan) on the
0.6.x wire format (gossip v5, compatible with 0.6.0/0.6.1).

Adds packaging/appimage: a thin AppImage recipe (linuxdeploy) that bundles the
pixelpass screen-share helper in usr/bin so peerspeak's $PATH lookup finds it
with no code change. Assets are include_bytes!-embedded; the graphics stack and
pixelpass's gstreamer/mpv tools are left to the host. Built on Ubuntu 24.04
(glibc 2.39) for reach across Debian 13+/Fedora 40+/rolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:07:41 -04:00
molluskandClaude Opus 4.8 89d5218d25 Housekeeping: Windows doc refresh + scan.rs test-import cleanup
Codex-authored refresh of docs/WINDOWS.md and packaging/windows/{README,INSTALL}.md
from the 2026-07-01 Windows session; scan.rs qualifies super::running_executables()
to drop an unused glob import. PKGBUILD pkgver reflects the last Arch build
(auto-regenerated by makepkg's pkgver()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:59:26 -04:00
mollusk f3c7aa7050 Merge A6 playback handoff fix 2026-07-01 13:50:44 -04:00
mollusk 39b5dafd57 fix(audio): bound playback handoff queue 2026-07-01 13:39:10 -04:00
mollusk a78860db15 Merge W12 FEC/DTX follow-up (Codex, senior-reviewed)
CI / check (push) Failing after 23s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-30 16:56:19 -04:00
5f52aa1506 W12 follow-up: consume in-band FEC, drop redundant DTX
Fixes the two P2 efficacy findings from the Codex audit of the W12
profiles feature.

FEC was enabled on the encoder but never used: the jitter buffer's
loss path did pure PLC, so the redundancy was wasted bitrate. Now the
gap path reconstructs the lost frame from the next buffered packet via
Opus in-band FEC (new `AudioDecoder::decode_fec`, libopus decode with
fec=true into a one-frame buffer), keeping that packet for its own
normal decode and falling back to PLC if FEC decode fails. This is the
documented libopus FEC pattern; receiver-side only, no wire change.

DTX was enabled on BadNetwork but provided no benefit — the capture
noise gate already suppresses silence transmission, and the broadcast
DTX silence packets only created seq gaps that grew the jitter cushion.
All profiles now set dtx=false (plumbing kept for a future revisit).

Adds a jitter-buffer test proving FEC reconstruction beats pure PLC
(RMS error < 0.75x) and that the FEC source packet stays buffered.
500 lib tests, clippy + fmt clean, release build clean.

Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:56:19 -04:00
molluskandClaude Opus 4.8 d92d0f6f6b Add W12 Opus/network quality profiles
CI / check (push) Failing after 25s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Add a small, named codec-policy picker (Low latency / Balanced / Bad
network) instead of exposing raw Opus knobs. The profile->params mapping
is a pure function (`codec::opus_impl::opus_params`) for unit testing;
profiles tune bitrate, in-band FEC, expected packet-loss, and DTX.

- config: `AudioProfile` enum (serde + Display + ALL + u8 round-trip),
  persisted `audio_profile` field (default Balanced).
- codec: `OpusParams` + pure `opus_params()` + `OpusEncoder::apply_params`
  / `apply_profile`.
- core: new `SetAudioProfile` command (Reliable, no coalesce); a shared
  `AtomicU8` lets the capture thread re-tune the live encoder on a
  mid-call switch and read it at each new call's encoder creation.
- app: Settings "Connection quality" picker in the Audio tab, startup
  config-sync send, and a one-line hint per profile.

No wire-format change (GOSSIP/audio planes untouched). 499 lib tests
green (config + codec mapping/apply tests added), clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:26 -04:00
mollusk 551767f9f5 Show build version on launch screen
CI / check (push) Failing after 37s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-29 16:54:26 -04:00
mollusk fa90cd3ce9 Update Arch package version 2026-06-29 16:53:03 -04:00
molluskandClaude Opus 4.8 660261a9a5 deps: bump memmap2 0.9.10 -> 0.9.11 (clears RUSTSEC-2026-0186)
CI / check (push) Successful in 2m6s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
cargo-audit flagged memmap2 0.9.10 as unsound (RUSTSEC-2026-0186, unchecked
pointer offset); 0.9.11 is the patched release. Warning-level only (audit/deny
don't fail on it), but cheap to clear. Audit now down to the two deliberately
-accepted unmaintained warnings (audiopus_sys, paste; ignored in deny.toml).
Lockfile-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:06:19 -04:00
molluskandClaude Opus 4.8 3a74fd0230 ci: drop concurrency block (Gitea 1.26 dropped runs with it set)
CI / check (push) Failing after 12m11s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A push that only changed Cargo.lock failed to create any Actions run while the
concurrency group was present; removing it restores reliable push triggering.
Single-dev CI doesn't need run-cancellation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:02:15 -04:00
molluskandClaude Opus 4.8 2dbb1ea316 deps: bump anyhow 1.0.102 -> 1.0.103 (fixes RUSTSEC-2026-0190)
CI / check (push) Failing after 12m46s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's cargo-deny flagged RUSTSEC-2026-0190: unsoundness in anyhow's
Error::downcast_mut() (UB via borrow-rule violation after Error::context),
reached transitively (n0-error / iroh + the image/rav1e chain). 1.0.103 is the
patched release; lockfile-only, no API change. cargo deny check now fully clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:56:35 -04:00
molluskandClaude Opus 4.8 c902db2e90 style: rustfmt the 0.6.2 additions (A17b + version-in-UI)
CI / check (push) Failing after 2m34s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's fmt --check caught that Codex's hand-written additions in these two files
weren't rustfmt-formatted (the senior gate ran clippy + tests but not
fmt --check). Pure line-wrapping, no logic change. Keeps the crate fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:52:31 -04:00
molluskandClaude Opus 4.8 83e5881768 ci: cancel superseded in-progress runs (concurrency group)
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:51:31 -04:00
molluskandClaude Opus 4.8 8424b44dec ci: add Gitea Actions workflow (self-hosted host-mode runner)
CI / check (push) Failing after 13s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI runs on a self-hosted host-mode gitea-runner on the desktop (label `arch`),
so the cheap gitbutter VPS only queues jobs while all compile/test compute runs
locally. Pipeline on push-to-main / PR / manual dispatch: cargo fmt --check,
clippy --all-targets -D warnings, cargo test --all-targets + doc tests, cargo
deny check, cargo audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:43:35 -04:00
molluskandClaude Opus 4.8 33e49a8ca7 Merge 0.6.2 refinements (Track B): A17b multitrack offload + build-version-in-UI
Track B code body for the 0.6.2 patch release. No wire change (GOSSIP_PROTO stays
5, interoperable with 0.6.0/0.6.1). Two code commits + two investigation closeouts
(A6 root-caused -> deferred to W5; A3 palette audit -> accepted as-is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:28:48 -04:00
molluskandClaude Opus 4.8 393c1c7f09 feat(ui): surface the build version in Settings + at startup
A field build is now self-identifying. `run_gui` logs `PeerSpeak v<version>
starting` (from env!("CARGO_PKG_VERSION")) on launch, and the Settings panel
shows a muted `PeerSpeak v<version>` footer — pinned to the bottom of the
220px category sidebar (wide layout) and appended under the body in the narrow
(<820px) layout. Compile-time string, no new test, no deps, local-only.

Renders the current crate version, so it tracks the Cargo.toml bump at each
release cut (shows v0.6.1 until 0.6.2 is stamped in Track A).

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:58:33 -04:00
molluskandClaude Opus 4.8 1bf14ba08f perf(recording): move multitrack stem disk I/O off the mixer path (A17b)
The multitrack recorder wrote every per-stem WAV frame (and the potentially
large late-joiner back-pad) inline on the caller thread while holding the
recorder mutex, so a slow/contended disk stalled the playout mixer (local
underruns) and the events loop. This is the multitrack counterpart to A17
(e0325d4), which moved the single-file recorder's writes off the mixer path.

Design: the front (MultitrackRecorder) now keeps only cheap in-memory state
(known-peer set, mic FIFO, a pending-cycle builder) and on each end_cycle
assembles ONE whole-cycle batch (new peers + mic frame + optional mix frame +
the map of peer frames written this cycle) and try_sends it over a bounded
sync_channel(256) to a dedicated writer thread. The writer thread owns every
WavWriter, is authoritative for its own cycle count, back-pads a brand-new
peer by cycles_written*frame_samples, fills absent peer/mix frames with
silence, latches the first write/create error then drains, and finalizes all
headers on channel close.

The unit of hand-off is a whole cycle, not a track: the writer appends exactly
frame_samples to every existing track per applied batch, and a full queue
DROPS the entire batch (counted + logged at 1 and every 256). So a dropped
cycle omits the same 20ms from every stem at once and all tracks stay
equal-length and sample-aligned by construction even under disk back-pressure.
On drop the batch's new-peer announcements are rolled back out of the known set
so they re-announce (and correctly re-back-pad) on the next applied cycle.

Public method signatures are unchanged -> zero core/mod.rs edits. The
WAV/file format is unchanged (no wire/on-disk change), no new deps
(std::sync::mpsc + std::thread, as A17). Writer logic is factored behind a
generic SampleWriter seam so the apply-batch alignment invariant is unit-tested
without spawning the thread; new tests cover the back-pad-on-apply invariant,
the dropped-cycle equal-length property, and async create-error surfacing at
finalize. The three existing end-to-end tests pass unchanged (now exercising
the threaded path). 496 lib tests, clippy --all-targets clean, release builds.

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:53:52 -04:00
molluskandClaude Opus 4.8 6f14d2668d docs(protocol): correct GOSSIP_PROTO version mapping (v5 = 0.6.0, not 0.7.0)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The const comment claimed "v5 (0.7.0)" while GOSSIP_PROTO has been 5 since the
v0.6.0 tag (introduced by bca2ccd, "release 0.6.0"). Git confirms the value went
straight 3 -> 5 in that one release and a GOSSIP_PROTO == 4 build never existed.
Merge the two mislabeled v4/v5 bullets into one accurate v4-v5 (0.6.0) entry and
note the 3->5 jump + that this breaking gossip change correctly rode the
0.5.1 -> 0.6.0 MINOR bump per VERSIONING.md (0.6.1 is a wire-compatible PATCH,
still proto 5). Comment-only; no wire/behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:24:53 -04:00
26 changed files with 2280 additions and 215 deletions
+42
View File
@@ -0,0 +1,42 @@
name: CI
# Runs on the self-hosted host-mode runner on the desktop (label `arch`). The
# gitbutter VPS only queues the job; all compile/test compute happens locally.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: arch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain versions
run: |
rustc --version
cargo --version
cargo clippy --version
cargo deny --version
cargo audit --version
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (all targets, warnings as errors)
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test --all-targets
- name: Doc tests
run: cargo test --doc
- name: cargo-deny (advisories, bans, licenses, sources)
run: cargo deny check
- name: cargo-audit
run: cargo audit
+15 -1
View File
@@ -2,7 +2,19 @@
All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.3] — 2026-07-06
### Added
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
### Fixed
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
## [0.6.2] — 2026-07-03
### Fixed
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
@@ -13,6 +25,8 @@ All notable changes to PeerSpeak are documented here.
### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
[0.6.2]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.2
## [0.6.0] — 2026-06-28
### Added
Generated
+5 -5
View File
@@ -200,9 +200,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
@@ -3682,9 +3682,9 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.1"
version = "0.6.3"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
+22
View File
@@ -0,0 +1,22 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
&& let Some(reference) = head.strip_prefix("ref: ")
{
println!("cargo:rerun-if-changed=.git/{}", reference.trim());
}
let short = Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=PEERSPEAK_GIT_SHORT={short}");
}
+65 -39
View File
@@ -1,19 +1,28 @@
# 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.
Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
has passed an older native Windows 11 VM smoke test for launch, GUI render, call
join, and audio flow. The build environment is **not** the Windows VM; current
Windows binaries are built from Linux, normally inside the `peerspeak-win`
distrobox or with the same GNU target environment.
The Windows runtime still trails Linux in a few important areas. See the Claude
handoff file `windows-parity-audit.md` for the full audit and task breakdown.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
| Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Device selection | cpal enumerates input/output devices; see caveat below about stable IDs. |
| Resampling/remap | WASAPI devices can run non-48 kHz formats; PeerSpeak converts at the backend boundary. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Identity/config | Stored through `dirs` under the Windows profile. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
| Game detection | Steam registry `RunningAppID` plus Toolhelp process-scan fallback compile on Windows. |
| File dialogs | `rfd` uses the native Win32 dialog backend. |
Windows paths are resolved through `dirs`:
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
## Building
### Native Windows
### Cross-compile from Linux
Install MSVC Build Tools and CMake, then build normally:
Preferred local path:
```powershell
cargo build --release
```sh
distrobox enter peerspeak-win -- bash -lc '
cd ~/git/butter/peerspeak &&
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
'
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
Equivalent direct command when the host has the GNU target, MinGW, `rust-src`, and
CMake available:
```sh
CMAKE_POLICY_VERSION_MINIMUM=3.5 RUSTC_BOOTSTRAP=1 \
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak \
-Z build-std=std,panic_abort
```
`CMAKE_POLICY_VERSION_MINIMUM=3.5` is required with host CMake 4.x because the
vendored Opus build used by `audiopus_sys` still declares an old minimum CMake
version. Without that env var, the Windows build/check fails during Opus configure.
### Native Windows
A native MSVC build is not the active development path. If used, install MSVC Build
Tools and CMake, then build normally:
```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.
Expect a Windows Firewall prompt the first time the app opens network sockets, or
use the Inno installer option that pre-adds a firewall allow rule. PeerSpeak uses
UDP for QUIC plus relay traffic when direct NAT traversal is unavailable.
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.
The default network mode keeps the n0 relay available for NAT traversal without
publishing presence to n0 DNS. 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 | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
| Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
| Screen share | Blocked by PixelPass, which is currently Linux-only in practice. PeerSpeak can spawn `pixelpass.exe`, but there is no Windows PixelPass host/viewer parity yet. |
| Device persistence | Uses cpal friendly names as keys. These can duplicate or change across Windows driver/profile changes; stable WASAPI endpoint IDs are still needed. |
| Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
| Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
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.
## Current smoke checklist
Before calling a Windows build current, verify on the Windows VM or real Windows
hardware:
- Launch current `peerspeak.exe`; GUI renders and settings open.
- Run `audio_probe.exe 440 30`; listen for glitches and inspect `playout-health`.
- Create/join a Linux <-> Windows room; confirm mic and playback both directions.
- Select input/output devices, restart, and confirm selections persist or fall back clearly.
- Play chimes and custom chime paths.
- Send chat, image/file attachments, and save an attachment through the native dialog.
- Import/play/share/listen to music from Windows file paths.
- Record mixed/stems/both and inspect the WAV output path.
- Exercise friends/presence/recents and the clock-skew banner.
- Test Steam and non-Steam game detection on a real Windows Steam install.
- Install/upgrade/uninstall through the Inno installer, including firewall rule cleanup.
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.5.0.r0.g0000000
pkgver=0.6.2.r319.g8014edf
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
+4
View File
@@ -0,0 +1,4 @@
.tools/
AppDir/
*.AppImage
squashfs-root/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# AppRun for the PeerSpeak AppImage.
#
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/peerspeak" "$@"
+76
View File
@@ -0,0 +1,76 @@
# PeerSpeak AppImage
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
## Why thin, and why pixelpass is bundled
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
change. Joe gets one file, and screen-share works out of the box.
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
runtime and on the AppImage excludelist because it must match the host driver.
So the image carries just the two binaries plus a handful of small libs.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
on AMD/Intel or the NVIDIA driver all work.
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
it prints the exact package names for your distro if any are missing:
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
good/bad/ugly, libav, and the PipeWire plugin),
- **mpv** (or vlc) for the viewer side,
- on X11, `xwininfo` for single-window capture.
On Arch/Artix that is one pacman line, e.g.:
```sh
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
```
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
needs no XDG portal — no systemd required.)
## Building for broad compatibility (glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
covers Debian 13+, Fedora 40+, and current rolling distros.
```sh
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
distrobox enter peerspeak-appimage -- sudo apt-get update
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
curl ca-certificates file patchelf git
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
distrobox enter peerspeak-appimage -- env \
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
./packaging/appimage/build-appimage.sh
```
## Caveats
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
software x264 path always works.
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
driven by peerspeak, never launched standalone from this image.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
# helper.
#
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
# binaries plus their handful of non-excludelisted libs. The custom AppRun
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
# pixelpass, while the host's tools stay reachable.
#
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
#
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
# container / on CI without libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
ps_target="$cache/peerspeak-target"
pp_target="$cache/pixelpass-target"
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
if [ ! -d "$pixelpass_repo" ]; then
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
exit 1
fi
echo ">> building peerspeak (release)"
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
ps_bin="$ps_target/release/peerspeak"
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
pp_bin="$pp_target/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$appdir/usr/bin/peerspeak" \
-e "$appdir/usr/bin/pixelpass" \
-d "$repo/packaging/peerspeak.desktop" \
-i "$repo/assets/icons/peerspeak-256.png" \
--icon-filename peerspeak \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-<version>-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
+3 -2
View File
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
The installer version tracks the release version in `Cargo.toml` — keep
`MyAppVersion` in `peerspeak.iss` in sync when cutting a release. Do not reuse an
old installer filename after a crate-version bump.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.0"
#define MyAppVersion "0.6.3"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+544 -7
View File
@@ -4,7 +4,10 @@ use crate::audio::clip_player::{
};
use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings};
use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::config::{
AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout, ShareBuffering, SharePlayer,
ShareQuality,
};
use crate::core::{
CoreController,
messages::{CoreCommand, UiEvent},
@@ -34,6 +37,17 @@ use tokio::sync::Mutex;
static UI_RX: OnceLock<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_SHORT: &str = env!("PEERSPEAK_GIT_SHORT");
fn app_build_label() -> String {
if GIT_SHORT == "unknown" {
format!("PeerSpeak v{APP_VERSION}")
} else {
format!("PeerSpeak v{APP_VERSION} ({GIT_SHORT})")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Home,
@@ -49,18 +63,20 @@ pub enum SettingsCategory {
Profile,
Appearance,
Network,
Advanced,
Notifications,
Games,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 8] = [
const ALL: [SettingsCategory; 9] = [
SettingsCategory::Audio,
SettingsCategory::Hotkeys,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Advanced,
SettingsCategory::Notifications,
SettingsCategory::Games,
];
@@ -73,6 +89,7 @@ impl SettingsCategory {
SettingsCategory::Profile => "Profile",
SettingsCategory::Appearance => "Appearance",
SettingsCategory::Network => "Network",
SettingsCategory::Advanced => "Advanced",
SettingsCategory::Notifications => "Notifications",
SettingsCategory::Games => "Games",
}
@@ -86,6 +103,7 @@ impl SettingsCategory {
SettingsCategory::Profile => "Avatar and identity",
SettingsCategory::Appearance => "Layout and theme",
SettingsCategory::Network => "Relay and privacy mode",
SettingsCategory::Advanced => "Screen sharing",
SettingsCategory::Notifications => "Chimes and sounds",
SettingsCategory::Games => "Detection, presence, backgrounds",
}
@@ -98,6 +116,176 @@ impl std::fmt::Display for SettingsCategory {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxHeightChoice {
Source,
P720,
P1080,
P1440,
}
impl ShareMaxHeightChoice {
const ALL: [ShareMaxHeightChoice; 4] = [
ShareMaxHeightChoice::Source,
ShareMaxHeightChoice::P720,
ShareMaxHeightChoice::P1080,
ShareMaxHeightChoice::P1440,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(720) => ShareMaxHeightChoice::P720,
Some(1080) => ShareMaxHeightChoice::P1080,
Some(1440) => ShareMaxHeightChoice::P1440,
_ => ShareMaxHeightChoice::Source,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareMaxHeightChoice::Source => None,
ShareMaxHeightChoice::P720 => Some(720),
ShareMaxHeightChoice::P1080 => Some(1080),
ShareMaxHeightChoice::P1440 => Some(1440),
}
}
}
impl std::fmt::Display for ShareMaxHeightChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareMaxHeightChoice::Source => "Source",
ShareMaxHeightChoice::P720 => "720p",
ShareMaxHeightChoice::P1080 => "1080p",
ShareMaxHeightChoice::P1440 => "1440p",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareFramerateChoice {
Preset,
Fps15,
Fps24,
Fps30,
Fps60,
}
impl ShareFramerateChoice {
const ALL: [ShareFramerateChoice; 5] = [
ShareFramerateChoice::Preset,
ShareFramerateChoice::Fps15,
ShareFramerateChoice::Fps24,
ShareFramerateChoice::Fps30,
ShareFramerateChoice::Fps60,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(15) => ShareFramerateChoice::Fps15,
Some(24) => ShareFramerateChoice::Fps24,
Some(30) => ShareFramerateChoice::Fps30,
Some(60) => ShareFramerateChoice::Fps60,
_ => ShareFramerateChoice::Preset,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareFramerateChoice::Preset => None,
ShareFramerateChoice::Fps15 => Some(15),
ShareFramerateChoice::Fps24 => Some(24),
ShareFramerateChoice::Fps30 => Some(30),
ShareFramerateChoice::Fps60 => Some(60),
}
}
}
impl std::fmt::Display for ShareFramerateChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareFramerateChoice::Preset => "Preset default",
ShareFramerateChoice::Fps15 => "15 fps",
ShareFramerateChoice::Fps24 => "24 fps",
ShareFramerateChoice::Fps30 => "30 fps",
ShareFramerateChoice::Fps60 => "60 fps",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxViewersChoice {
Auto,
One,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
}
impl ShareMaxViewersChoice {
const ALL: [ShareMaxViewersChoice; 9] = [
ShareMaxViewersChoice::Auto,
ShareMaxViewersChoice::One,
ShareMaxViewersChoice::Two,
ShareMaxViewersChoice::Three,
ShareMaxViewersChoice::Four,
ShareMaxViewersChoice::Five,
ShareMaxViewersChoice::Six,
ShareMaxViewersChoice::Seven,
ShareMaxViewersChoice::Eight,
];
fn from_config(value: Option<u32>) -> Self {
match value {
Some(1) => ShareMaxViewersChoice::One,
Some(2) => ShareMaxViewersChoice::Two,
Some(3) => ShareMaxViewersChoice::Three,
Some(4) => ShareMaxViewersChoice::Four,
Some(5) => ShareMaxViewersChoice::Five,
Some(6) => ShareMaxViewersChoice::Six,
Some(7) => ShareMaxViewersChoice::Seven,
Some(8) => ShareMaxViewersChoice::Eight,
_ => ShareMaxViewersChoice::Auto,
}
}
fn to_config(self) -> Option<u32> {
match self {
ShareMaxViewersChoice::Auto => None,
ShareMaxViewersChoice::One => Some(1),
ShareMaxViewersChoice::Two => Some(2),
ShareMaxViewersChoice::Three => Some(3),
ShareMaxViewersChoice::Four => Some(4),
ShareMaxViewersChoice::Five => Some(5),
ShareMaxViewersChoice::Six => Some(6),
ShareMaxViewersChoice::Seven => Some(7),
ShareMaxViewersChoice::Eight => Some(8),
}
}
}
impl std::fmt::Display for ShareMaxViewersChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareMaxViewersChoice::Auto => "Auto",
ShareMaxViewersChoice::One => "1",
ShareMaxViewersChoice::Two => "2",
ShareMaxViewersChoice::Three => "3",
ShareMaxViewersChoice::Four => "4",
ShareMaxViewersChoice::Five => "5",
ShareMaxViewersChoice::Six => "6",
ShareMaxViewersChoice::Seven => "7",
ShareMaxViewersChoice::Eight => "8",
})
}
}
const SHARE_CACHE_MB_OPTIONS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HomeLayoutMode {
FocusedEmpty,
@@ -366,7 +554,20 @@ pub enum AppMessage {
/// immediately but does not persist (saved once on release via NoiseGateChanged).
NoiseGateDragging(f32),
NetworkModeSelected(NetworkMode),
AudioProfileSelected(AudioProfile),
RecordingModeSelected(RecordingMode),
ScreenShareQualitySelected(ShareQuality),
ScreenSharePlayerSelected(SharePlayer),
ScreenShareBufferingSelected(ShareBuffering),
ScreenShareMaxHeightSelected(ShareMaxHeightChoice),
ScreenShareFramerateSelected(ShareFramerateChoice),
ScreenShareMaxViewersSelected(ShareMaxViewersChoice),
ScreenShareCacheMbSelected(u32),
ScreenShareBitrateChanged(String),
ToggleScreenShareForceSoftwareEncode(bool),
ToggleScreenShareHardwareDecode(bool),
ScreenShareExtraMpvArgsChanged(String),
ScreenShareExtraHostArgsChanged(String),
/// Choose the friends presence posture (W7): invisible / normal / discoverable.
PresenceModeSelected(PresenceMode),
/// Friends list (W7 P5): add-form edits, add, remove, and local rename.
@@ -525,6 +726,8 @@ pub enum AppMessage {
/// Select which app's audio to share in the picker: `Some(name)` for one app,
/// `None` for the whole desktop ("All system audio").
SelectShareAudioApp(Option<String>),
/// Session-only quality preset for the next share start.
SelectShareQualityOverride(ShareQuality),
/// Confirm the picker: start the share with the currently selected audio app.
ConfirmShareScreen,
/// Watch a peer's screen share, identified by their pixelpass ticket.
@@ -749,6 +952,8 @@ pub struct AppState {
/// The picker's current selection: `Some(name)` = capture that app's audio,
/// `None` = "All system audio" (whole desktop; may echo the call).
share_audio_selection: Option<String>,
/// Session-only quality override for the next screen-share start.
share_quality_selection: ShareQuality,
/// A share start is in flight: `ConfirmShareScreen` was sent but the core
/// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening
/// the picker (and re-confirming) during that startup window. Cleared on
@@ -859,6 +1064,7 @@ impl AppState {
self.share_picker_open = false;
self.share_audio_apps.clear();
self.share_audio_selection = None;
self.share_quality_selection = self.config.screen_share.quality;
self.share_starting = false;
self.share_audio_dropped = false;
self.share_audio_app_active = false;
@@ -908,6 +1114,7 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
let _ = controller.send(CoreCommand::SetAudioProfile(config.audio_profile));
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
@@ -962,6 +1169,7 @@ impl Default for AppState {
let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
let (music_player, music_status) = ClipPlayer::new(config.music_volume);
let music_broadcasting = config.music_broadcast;
let share_quality_selection = config.screen_share.quality;
let music_playlist = config
.music_playlist
.iter()
@@ -1036,6 +1244,7 @@ impl Default for AppState {
share_picker_open: false,
share_audio_apps: Vec::new(),
share_audio_selection: None,
share_quality_selection,
share_starting: false,
share_audio_dropped: false,
share_audio_app_active: false,
@@ -1100,6 +1309,7 @@ fn effective_background_bytes(
}
pub fn run_gui() -> iced::Result {
crate::log_msg(&format!("{} starting", app_build_label()));
// Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
// position, so we center there and leave placement to the compositor.
@@ -1549,6 +1759,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// so the picker can't be reopened during the startup window.
state.share_picker_open = true;
state.share_audio_selection = None;
// NB: do NOT reset `share_quality_selection` here. It is the
// per-call override set by the inline quality dropdown next to
// the Share button, and the picker has no quality control of its
// own — resetting it would silently discard the user's pick
// before `ConfirmShareScreen` reads it.
state.share_audio_apps.clear();
let _ = state.controller.send(CoreCommand::ListAudioApps);
}
@@ -1559,6 +1774,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::SelectShareAudioApp(app) => {
state.share_audio_selection = app;
}
AppMessage::SelectShareQualityOverride(quality) => {
state.share_quality_selection = quality;
}
AppMessage::ConfirmShareScreen => {
// Only a confirm from an open picker starts a share; a stray confirm
// (or one arriving while a start is already in flight) is ignored, so
@@ -1567,14 +1785,21 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_picker_open = false;
state.share_starting = true;
let audio_app = state.share_audio_selection.clone();
let _ = state
.controller
.send(CoreCommand::StartScreenShare { audio_app });
let settings = state.config.screen_share.clone();
let quality = state.share_quality_selection;
let _ = state.controller.send(CoreCommand::StartScreenShare {
audio_app,
settings,
quality,
});
state.status_message = "Starting screen share…".to_string();
}
}
AppMessage::WatchShare(ticket) => {
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
let settings = state.config.screen_share.clone();
let _ = state
.controller
.send(CoreCommand::ViewShare { ticket, settings });
state.status_message = "Opening screen share…".to_string();
}
AppMessage::ToggleMutePressed => {
@@ -2038,12 +2263,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Applied on the next join, since the endpoint is rebuilt then.
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
}
AppMessage::AudioProfileSelected(profile) => {
state.config.audio_profile = profile;
state.config.save();
// Applies live to the running encoder, and to the next call.
let _ = state.controller.send(CoreCommand::SetAudioProfile(profile));
}
AppMessage::RecordingModeSelected(mode) => {
state.config.recording_mode = mode;
state.config.save();
// Takes effect on the next recording start.
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
}
AppMessage::ScreenShareQualitySelected(quality) => {
state.config.screen_share.quality = quality;
state.share_quality_selection = quality;
state.config.save();
}
AppMessage::ScreenSharePlayerSelected(player) => {
state.config.screen_share.player = player;
state.config.save();
}
AppMessage::ScreenShareBufferingSelected(buffering) => {
state.config.screen_share.buffering = buffering;
state.config.save();
}
AppMessage::ScreenShareMaxHeightSelected(choice) => {
state.config.screen_share.max_height = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareFramerateSelected(choice) => {
state.config.screen_share.framerate = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareMaxViewersSelected(choice) => {
state.config.screen_share.max_viewers = choice.to_config();
state.config.save();
}
AppMessage::ScreenShareCacheMbSelected(cache_mb) => {
state.config.screen_share.cache_mb = cache_mb;
state.config.save();
}
AppMessage::ScreenShareBitrateChanged(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
state.config.screen_share.bitrate_mbps = None;
state.config.save();
} else if let Ok(mbps) = trimmed.parse::<u32>() {
state.config.screen_share.bitrate_mbps = Some(mbps);
state.config.save();
}
}
AppMessage::ToggleScreenShareForceSoftwareEncode(enabled) => {
state.config.screen_share.force_software_encode = enabled;
state.config.save();
}
AppMessage::ToggleScreenShareHardwareDecode(enabled) => {
state.config.screen_share.hardware_decode = enabled;
state.config.save();
}
AppMessage::ScreenShareExtraMpvArgsChanged(args) => {
state.config.screen_share.extra_mpv_args = args;
state.config.save();
}
AppMessage::ScreenShareExtraHostArgsChanged(args) => {
state.config.screen_share.extra_host_args = args;
state.config.save();
}
AppMessage::PresenceModeSelected(mode) => {
state.config.presence_mode = mode;
state.config.save();
@@ -3111,6 +3397,19 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str {
}
}
/// One-line explanation of an audio/network profile for the settings picker (W12).
fn audio_profile_hint(profile: AudioProfile) -> &'static str {
match profile {
AudioProfile::LowLatency => {
"Lowest delay, no loss recovery. Best on a clean LAN or wired link."
}
AudioProfile::Balanced => "Default: voice quality with light loss recovery.",
AudioProfile::BadNetwork => {
"Most resilient on a lossy/congested link: heavier loss recovery, lower bitrate."
}
}
}
/// One-line explanation of a recording mode for the settings picker.
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
match mode {
@@ -3133,6 +3432,30 @@ fn presence_mode_hint(mode: PresenceMode) -> &'static str {
}
}
fn share_quality_hint(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "Use pixelpass bandwidth pre-flight; falls back to Medium.",
ShareQuality::Low => "Lower bandwidth: up to 480p, about 1 Mbps.",
ShareQuality::Medium => "Balanced preset: up to 720p, about 2.5 Mbps.",
ShareQuality::High => "Sharper preset: up to 1080p, about 4 Mbps.",
ShareQuality::Source => "Native source resolution, about 6 Mbps.",
}
}
fn share_player_hint(player: SharePlayer) -> &'static str {
match player {
SharePlayer::Mpv => "Try mpv first, then VLC if mpv is unavailable.",
SharePlayer::Vlc => "Try VLC first, then mpv if VLC is unavailable.",
}
}
fn share_buffering_hint(buffering: ShareBuffering) -> &'static str {
match buffering {
ShareBuffering::LowLatency => "Small buffers for interactive screen sharing.",
ShareBuffering::Smooth => "Larger cache/readahead for steadier playback.",
}
}
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
fn format_duration(total_secs: u64) -> String {
let h = total_secs / 3600;
@@ -3806,6 +4129,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let subtitle = text("NAT-traversing full-mesh voice chat")
.size(16)
.color(color_subtext);
let build_label = text(app_build_label()).size(11).color(color_subtext);
let nickname_input = column![
text("Nickname").size(14).color(color_subtext),
@@ -3861,6 +4185,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
column![
logo,
subtitle,
build_label,
vertical_space(20.0),
nickname_input,
vertical_space(16.0),
@@ -4724,6 +5049,135 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Spacing between one category and the next.
let section_gap = 18.0;
let screen_share = &state.config.screen_share;
let screen_share_bitrate = screen_share
.bitrate_mbps
.map(|v| v.to_string())
.unwrap_or_default();
let screen_share_section = column![
column![
text("Host encoding").size(13).color(color_subtext),
row![
column![
text("Quality").size(12).color(color_subtext),
pick_list(
&ShareQuality::ALL[..],
Some(screen_share.quality),
AppMessage::ScreenShareQualitySelected,
).width(iced::Length::Fill),
text(share_quality_hint(screen_share.quality)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Max resolution").size(12).color(color_subtext),
pick_list(
&ShareMaxHeightChoice::ALL[..],
Some(ShareMaxHeightChoice::from_config(screen_share.max_height)),
AppMessage::ScreenShareMaxHeightSelected,
).width(iced::Length::Fill),
text("Source keeps the captured display height.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Framerate").size(12).color(color_subtext),
pick_list(
&ShareFramerateChoice::ALL[..],
Some(ShareFramerateChoice::from_config(screen_share.framerate)),
AppMessage::ScreenShareFramerateSelected,
).width(iced::Length::Fill),
text("Preset default lets pixelpass choose.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Bitrate Mbps").size(12).color(color_subtext),
context_input("preset default", &screen_share_bitrate)
.on_input(AppMessage::ScreenShareBitrateChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("Blank uses the quality preset.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Max viewers").size(12).color(color_subtext),
pick_list(
&ShareMaxViewersChoice::ALL[..],
Some(ShareMaxViewersChoice::from_config(screen_share.max_viewers)),
AppMessage::ScreenShareMaxViewersSelected,
).width(iced::Length::Fill),
text("Auto uses pixelpass' connection-aware recommendation.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
checkbox(screen_share.force_software_encode)
.label("Force software encode")
.on_toggle(AppMessage::ToggleScreenShareForceSoftwareEncode),
text("Passes --no-hwencode to pixelpass.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
].spacing(10).width(iced::Length::Fill),
vertical_space(section_gap),
column![
text("Viewer playback").size(13).color(color_subtext),
row![
column![
text("Player").size(12).color(color_subtext),
pick_list(
&SharePlayer::ALL[..],
Some(screen_share.player),
AppMessage::ScreenSharePlayerSelected,
).width(iced::Length::Fill),
text(share_player_hint(screen_share.player)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
text("Buffering").size(12).color(color_subtext),
pick_list(
&ShareBuffering::ALL[..],
Some(screen_share.buffering),
AppMessage::ScreenShareBufferingSelected,
).width(iced::Length::Fill),
text(share_buffering_hint(screen_share.buffering)).size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
row![
column![
text("Cache MB").size(12).color(color_subtext),
pick_list(
&SHARE_CACHE_MB_OPTIONS[..],
Some(screen_share.cache_mb),
AppMessage::ScreenShareCacheMbSelected,
).width(iced::Length::Fill),
text("mpv demuxer cache size (mpv only).").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
column![
checkbox(screen_share.hardware_decode)
.label("Hardware video decode")
.on_toggle(AppMessage::ToggleScreenShareHardwareDecode),
text("GPU decode (mpv --hwdec=auto / VLC hardware decode). Off avoids the known frame-freeze bug.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
].spacing(16).width(iced::Length::Fill),
].spacing(10).width(iced::Length::Fill),
vertical_space(section_gap),
column![
text("Extra mpv args").size(12).color(color_subtext),
context_input("--no-osc --vd-lavc-threads=2", &screen_share.extra_mpv_args)
.on_input(AppMessage::ScreenShareExtraMpvArgsChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
text("Extra pixelpass args").size(12).color(color_subtext),
context_input("--relay https://relay.example/", &screen_share.extra_host_args)
.on_input(AppMessage::ScreenShareExtraHostArgsChanged)
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
].spacing(6).width(iced::Length::Fill),
text("Applies the next time you start or watch a screen share. These are local preferences only.").size(11).color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
// One recording-mode radio with a hover tooltip explaining it. (iced's
// pick_list can't host per-option tooltips, so the modes are radios.)
let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
@@ -4967,6 +5421,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
control
},
].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
section_header("Connection quality"),
column![
pick_list(
&AudioProfile::ALL[..],
Some(state.config.audio_profile),
AppMessage::AudioProfileSelected,
).width(iced::Length::Fill),
text(audio_profile_hint(state.config.audio_profile)).size(11).color(color_subtext),
text("Applies immediately, even mid-call.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
]
.spacing(10)
.width(iced::Length::Fill)
@@ -5036,6 +5501,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Advanced => column![
section_header("Screen sharing"),
screen_share_section,
]
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Notifications => column![
section_header("Notifications & Sounds"),
column![
@@ -5240,6 +5712,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
for category in SettingsCategory::ALL {
settings_nav = settings_nav.push(category_button(category));
}
settings_nav = settings_nav
.push(iced::widget::Space::new().height(iced::Length::Fill))
.push(text(app_build_label()).size(11).color(color_subtext));
let settings_nav = container(settings_nav)
.padding(12)
.width(iced::Length::Fixed(220.0))
@@ -5258,6 +5733,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.width(iced::Length::Fill),
vertical_space(10.0),
settings_body,
vertical_space(10.0),
text(app_build_label()).size(11).color(color_subtext),
]
.spacing(8)
.width(iced::Length::Fill),
@@ -6428,11 +6905,29 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
} else {
AppMessage::OpenPixelpassHelp
};
button(btn_content(share_kind, share_label, share_fg))
let share_button = button(btn_content(share_kind, share_label, share_fg))
.on_press(share_press)
.style(b_style(share_bg, share_hover, share_fg, 8.0))
.padding(14)
.width(iced::Length::Fill);
let share_control: Element<'_, AppMessage> = if state.self_sharing {
share_button.into()
} else {
row![
share_button,
pick_list(
&ShareQuality::ALL[..],
Some(state.share_quality_selection),
AppMessage::SelectShareQualityOverride,
)
.width(iced::Length::Fixed(112.0)),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.width(iced::Length::Fill)
.into()
};
share_control
},
];
@@ -8652,6 +9147,10 @@ mod tests {
assert!(!state.share_picker_open);
assert!(state.share_audio_apps.is_empty());
assert!(state.share_audio_selection.is_none());
assert_eq!(
state.share_quality_selection,
state.config.screen_share.quality
);
assert!(!state.share_starting);
assert!(!state.share_audio_dropped);
assert!(!state.share_audio_app_active);
@@ -8776,6 +9275,42 @@ mod tests {
assert!(!state.share_picker_open);
}
#[test]
fn inline_quality_override_survives_opening_the_picker() {
// The inline quality dropdown (next to the Share button) sets a
// per-call `share_quality_selection`. Opening the audio picker via
// ToggleScreenShare must NOT reset it back to the saved config default,
// or the override the user just made is silently discarded before
// ConfirmShareScreen reads it into StartScreenShare.
use crate::config::ShareQuality;
let mut state = AppState::default();
// Saved default is Auto; the user picks a different per-call quality.
assert_eq!(state.config.screen_share.quality, ShareQuality::Auto);
let _ = update(
&mut state,
AppMessage::SelectShareQualityOverride(ShareQuality::High),
);
assert_eq!(state.share_quality_selection, ShareQuality::High);
// Clicking Share opens the picker — the override must be preserved.
let _ = update(&mut state, AppMessage::ToggleScreenShare);
assert!(state.share_picker_open);
assert_eq!(
state.share_quality_selection,
ShareQuality::High,
"opening the picker must not clobber the inline per-call override"
);
// Confirming reads that same override into the share start.
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
assert!(state.share_starting);
assert_eq!(
state.share_quality_selection,
ShareQuality::High,
"the override the picker preserved must still be what ConfirmShareScreen sends"
);
}
#[test]
fn share_start_failure_clears_in_flight_flag() {
// A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
@@ -9147,12 +9682,14 @@ mod tests {
"Profile",
"Appearance",
"Network",
"Advanced",
"Notifications",
"Games"
]
);
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
assert_eq!(SettingsCategory::Advanced.hint(), "Screen sharing");
assert_eq!(
SettingsCategory::Games.hint(),
"Detection, presence, backgrounds"
+428 -84
View File
@@ -12,9 +12,11 @@
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
//! networking, no realtime work. The mixer (a non-RT task) drives it.
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
use iroh::EndpointId;
@@ -24,6 +26,8 @@ use crate::core::jitter::FRAME_SAMPLES;
/// Cap on the silence chunk written at once when pre-padding a late joiner, so a
/// long-running call can't trigger a single multi-hundred-MB allocation.
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
const WRITER_QUEUE_CYCLES: usize = 256;
const DROP_LOG_INTERVAL_CYCLES: u64 = 256;
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
@@ -56,41 +60,6 @@ pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf
))
}
/// One output track: its WAV writer plus whether it has been written *this*
/// cycle (so `end_cycle` knows which tracks to pad with silence).
struct Track {
writer: WavWriter,
written_this_cycle: bool,
}
impl Track {
fn create(path: &Path) -> io::Result<Self> {
Ok(Self {
writer: WavWriter::new(path)?,
written_this_cycle: false,
})
}
/// Append `frame` fitted to exactly `frame_samples` (zero-padded if short),
/// and mark the track as written for this cycle.
fn write_frame(&mut self, frame: &[i16], frame_samples: usize) -> io::Result<()> {
self.writer.write_samples(&fit(frame, frame_samples))?;
self.written_this_cycle = true;
Ok(())
}
/// Append `samples` of silence (no cycle-marking — used for padding).
fn write_silence(&mut self, samples: usize) -> io::Result<()> {
let mut remaining = samples;
while remaining > 0 {
let n = remaining.min(SILENCE_CHUNK);
self.writer.write_samples(&vec![0i16; n])?;
remaining -= n;
}
Ok(())
}
}
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
/// happen — Opus frames are uniform), zero-padded if shorter.
fn fit(frame: &[i16], n: usize) -> Vec<i16> {
@@ -126,41 +95,210 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
format!("{slug}-{short}.wav")
}
/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
dir: PathBuf,
frame_samples: usize,
/// Cycles recorded so far = the shared length (in frames) of every track.
cycles: u64,
peers: HashMap<EndpointId, Track>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic: WavWriter,
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
mix: Option<Track>,
#[derive(Default)]
struct PendingCycle {
new_peers: Vec<NewPeer>,
peer_frames: HashMap<EndpointId, Vec<i16>>,
mix_frame: Option<Vec<i16>>,
}
impl MultitrackRecorder {
/// Create a recording in `dir` (which must already exist). `with_mix` adds
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
struct NewPeer {
id: EndpointId,
filename: String,
}
struct CycleBatch {
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: HashMap<EndpointId, Vec<i16>>,
}
trait SampleWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()>;
fn finalize(self) -> io::Result<()>;
}
impl SampleWriter for WavWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
WavWriter::write_samples(self, samples)
}
fn finalize(self) -> io::Result<()> {
WavWriter::finalize(self)
}
}
struct WriterState<W> {
dir: PathBuf,
frame_samples: usize,
peers: HashMap<EndpointId, W>,
mic: W,
mix: Option<W>,
cycles_written: u64,
}
impl WriterState<WavWriter> {
fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let mic = WavWriter::new(&dir.join("me.wav"))?;
let mix = if with_mix {
Some(Track::create(&dir.join("mix.wav"))?)
Some(WavWriter::new(&dir.join("mix.wav"))?)
} else {
None
};
Ok(Self {
dir: dir.to_path_buf(),
frame_samples,
cycles: 0,
peers: HashMap::new(),
mic,
mic_fifo: VecDeque::new(),
mix,
cycles_written: 0,
})
}
}
impl<W: SampleWriter> WriterState<W> {
fn apply_batch<F>(&mut self, batch: &CycleBatch, mut create_peer: F) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<W>,
{
for peer in &batch.new_peers {
if !self.peers.contains_key(&peer.id) {
let writer = create_peer(&self.dir.join(&peer.filename))?;
self.peers.insert(peer.id, writer);
let pad = self.back_pad_samples()?;
let writer = self.peers.get_mut(&peer.id).unwrap();
Self::write_silence(writer, pad)?;
}
}
self.mic.write_samples(&batch.mic_frame)?;
if let Some(mix) = self.mix.as_mut() {
if let Some(frame) = batch.mix_frame.as_deref() {
mix.write_samples(frame)?;
} else {
Self::write_silence(mix, self.frame_samples)?;
}
}
let silence = vec![0i16; self.frame_samples];
for (id, writer) in &mut self.peers {
let frame = batch
.peer_frames
.get(id)
.map(Vec::as_slice)
.unwrap_or(&silence);
writer.write_samples(frame)?;
}
self.cycles_written += 1;
Ok(())
}
fn back_pad_samples(&self) -> io::Result<usize> {
let cycles = usize::try_from(self.cycles_written)
.map_err(|_| io::Error::other("multitrack recording too long"))?;
cycles
.checked_mul(self.frame_samples)
.ok_or_else(|| io::Error::other("multitrack recording too long"))
}
fn write_silence(writer: &mut W, samples: usize) -> io::Result<()> {
let mut remaining = samples;
let silence = vec![0i16; remaining.min(SILENCE_CHUNK)];
while remaining > 0 {
let n = remaining.min(silence.len());
writer.write_samples(&silence[..n])?;
remaining -= n;
}
Ok(())
}
fn finalize(self) -> io::Result<()> {
let mut first_finalize_error = None;
record_first_error(&mut first_finalize_error, self.mic.finalize());
if let Some(mix) = self.mix {
record_first_error(&mut first_finalize_error, mix.finalize());
}
for writer in self.peers.into_values() {
record_first_error(&mut first_finalize_error, writer.finalize());
}
if let Some(e) = first_finalize_error {
Err(e)
} else {
Ok(())
}
}
}
fn record_first_error(slot: &mut Option<io::Error>, result: io::Result<()>) {
if slot.is_none()
&& let Err(e) = result
{
*slot = Some(e);
}
}
/// Applies whole-cycle batches on the writer thread. Each applied batch appends
/// exactly `frame_samples` to every existing track, and a dropped batch never
/// reaches this loop for any track, so stem lengths stay equal even when the
/// bounded queue applies back-pressure.
fn writer_thread_main(
mut state: WriterState<WavWriter>,
batch_rx: mpsc::Receiver<CycleBatch>,
) -> io::Result<()> {
let mut first_write_error = None;
for batch in batch_rx {
if first_write_error.is_none()
&& let Err(e) = state.apply_batch(&batch, WavWriter::new)
{
first_write_error = Some(e);
}
}
let finalize_result = state.finalize();
if let Some(e) = first_write_error {
Err(e)
} else {
finalize_result
}
}
/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
dir: PathBuf,
frame_samples: usize,
known_peers: HashSet<EndpointId>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
with_mix: bool,
batch_tx: SyncSender<CycleBatch>,
writer_thread: JoinHandle<io::Result<()>>,
dropped_cycles: u64,
pending: PendingCycle,
}
impl MultitrackRecorder {
/// Create a recording in `dir` (which must already exist). `with_mix` adds
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let writer_state = WriterState::create(dir, frame_samples, with_mix)?;
let (batch_tx, batch_rx) = mpsc::sync_channel(WRITER_QUEUE_CYCLES);
let writer_thread = thread::spawn(move || writer_thread_main(writer_state, batch_rx));
Ok(Self {
dir: dir.to_path_buf(),
frame_samples,
known_peers: HashSet::new(),
mic_fifo: VecDeque::new(),
with_mix,
batch_tx,
writer_thread,
dropped_cycles: 0,
pending: PendingCycle::default(),
})
}
@@ -173,12 +311,14 @@ impl MultitrackRecorder {
/// so it aligns with the others. Idempotent: a peer already tracked is left
/// as-is (re-announce / name change doesn't restart their file).
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
if self.peers.contains_key(&id) {
if self.known_peers.contains(&id) {
return Ok(());
}
let mut track = Track::create(&self.dir.join(track_filename(name, &id)))?;
track.write_silence(self.cycles as usize * self.frame_samples)?;
self.peers.insert(id, track);
self.known_peers.insert(id);
self.pending.new_peers.push(NewPeer {
id,
filename: track_filename(name, &id),
});
Ok(())
}
@@ -186,11 +326,13 @@ impl MultitrackRecorder {
/// registered yet (write raced ahead of the join event), auto-register it
/// with an id-only name so no audio is dropped.
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
if !self.peers.contains_key(&id) {
if !self.known_peers.contains(&id) {
self.add_peer(id, "")?;
}
let fs = self.frame_samples;
self.peers.get_mut(&id).unwrap().write_frame(frame, fs)
self.pending
.peer_frames
.insert(id, fit(frame, self.frame_samples));
Ok(())
}
/// Buffer a frame of your transmitted mic audio (called from the capture
@@ -215,9 +357,8 @@ impl MultitrackRecorder {
/// Record the finished mixed-bus frame for the current cycle (no-op in
/// stems-only mode).
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
let fs = self.frame_samples;
if let Some(mix) = self.mix.as_mut() {
mix.write_frame(frame, fs)?;
if self.with_mix {
self.pending.mix_frame = Some(fit(frame, self.frame_samples));
}
Ok(())
}
@@ -230,28 +371,63 @@ impl MultitrackRecorder {
// Mic: always one frame per cycle, drained from the FIFO (silence on
// underrun), so it tracks the cycle clock like the peer stems.
let mic_frame = self.drain_mic(fs);
self.mic.write_samples(&mic_frame)?;
// Peers + the optional mix track: pad any not written this cycle.
for track in self.peers.values_mut().chain(self.mix.as_mut()) {
if !track.written_this_cycle {
track.write_silence(fs)?;
let mut pending = std::mem::take(&mut self.pending);
pending.new_peers.sort_by(|a, b| {
a.filename
.cmp(&b.filename)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
});
let batch = CycleBatch {
new_peers: pending.new_peers,
mic_frame,
mix_frame: if self.with_mix {
pending.mix_frame
} else {
None
},
peer_frames: pending.peer_frames,
};
match self.batch_tx.try_send(batch) {
Ok(()) => Ok(()),
Err(TrySendError::Full(batch)) => {
for peer in &batch.new_peers {
self.known_peers.remove(&peer.id);
}
self.dropped_cycles = self.dropped_cycles.saturating_add(1);
if self.dropped_cycles == 1
|| self.dropped_cycles.is_multiple_of(DROP_LOG_INTERVAL_CYCLES)
{
crate::log_msg(&format!(
"multitrack recording: writer queue full; dropped {} cycle(s)",
self.dropped_cycles
));
}
Ok(())
}
track.written_this_cycle = false;
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"multitrack writer thread stopped",
)),
}
self.cycles += 1;
Ok(())
}
/// Finalize every track's WAV header. Consumes the recorder.
pub fn finalize(self) -> io::Result<()> {
self.mic.finalize()?;
if let Some(mix) = self.mix {
mix.writer.finalize()?;
}
for (_, track) in self.peers {
track.writer.finalize()?;
}
Ok(())
let Self {
dir: _,
frame_samples: _,
known_peers: _,
mic_fifo: _,
with_mix: _,
batch_tx,
writer_thread,
dropped_cycles: _,
pending: _,
} = self;
drop(batch_tx);
writer_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
}
}
@@ -277,6 +453,51 @@ mod tests {
d
}
#[derive(Default)]
struct TestWriter {
samples: Vec<i16>,
}
impl SampleWriter for TestWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
self.samples.extend_from_slice(samples);
Ok(())
}
fn finalize(self) -> io::Result<()> {
Ok(())
}
}
fn test_writer_state(frame_samples: usize, with_mix: bool) -> WriterState<TestWriter> {
WriterState {
dir: PathBuf::new(),
frame_samples,
peers: HashMap::new(),
mic: TestWriter::default(),
mix: if with_mix {
Some(TestWriter::default())
} else {
None
},
cycles_written: 0,
}
}
fn test_batch(
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: Vec<(EndpointId, Vec<i16>)>,
) -> CycleBatch {
CycleBatch {
new_peers,
mic_frame,
mix_frame,
peer_frames: peer_frames.into_iter().collect(),
}
}
#[test]
fn fit_pads_and_truncates() {
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
@@ -311,6 +532,110 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn apply_batch_advances_existing_tracks_and_back_pads_late_peer() {
let frame = 3;
let early = an_id();
let late = an_id();
let mut state = test_writer_state(frame, true);
state.cycles_written = 2;
state.mic.samples = vec![8; 2 * frame];
state.mix.as_mut().unwrap().samples = vec![6; 2 * frame];
state.peers.insert(
early,
TestWriter {
samples: vec![1; 2 * frame],
},
);
let batch = test_batch(
vec![NewPeer {
id: late,
filename: "late.wav".to_string(),
}],
vec![9; frame],
None,
vec![(early, vec![2; frame]), (late, vec![7; frame])],
);
state
.apply_batch(&batch, |_| Ok(TestWriter::default()))
.unwrap();
assert_eq!(state.cycles_written, 3);
assert_eq!(state.mic.samples.len(), 3 * frame);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.mix.as_ref().unwrap().samples[2 * frame..],
&[0, 0, 0]
);
assert_eq!(state.peers.get(&early).unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.peers.get(&early).unwrap().samples[2 * frame..],
&[2, 2, 2]
);
assert_eq!(
state.peers.get(&late).unwrap().samples,
vec![0, 0, 0, 0, 0, 0, 7, 7, 7],
"late peer is back-padded by completed cycles before this batch"
);
}
#[test]
fn skipped_batches_keep_all_tracks_equal_length() {
let frame = 2;
let p1 = an_id();
let p2 = an_id();
let mut state = test_writer_state(frame, true);
let first = test_batch(
vec![
NewPeer {
id: p1,
filename: "p1.wav".to_string(),
},
NewPeer {
id: p2,
filename: "p2.wav".to_string(),
},
],
vec![1; frame],
Some(vec![5; frame]),
vec![(p1, vec![10; frame]), (p2, vec![20; frame])],
);
state
.apply_batch(&first, |_| Ok(TestWriter::default()))
.unwrap();
let _dropped_cycle = test_batch(
Vec::new(),
vec![2; frame],
Some(vec![6; frame]),
vec![(p1, vec![11; frame])],
);
let after_drop = test_batch(
Vec::new(),
vec![3; frame],
None,
vec![(p1, vec![12; frame])],
);
state
.apply_batch(&after_drop, |_| Ok(TestWriter::default()))
.unwrap();
let expected = 2 * frame;
assert_eq!(state.cycles_written, 2);
assert_eq!(state.mic.samples.len(), expected);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p1).unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p2).unwrap().samples.len(), expected);
assert_eq!(
&state.peers.get(&p2).unwrap().samples[frame..],
&[0, 0],
"peer absent from an applied batch gets silence for that cycle"
);
}
#[test]
fn all_tracks_equal_length_after_n_cycles() {
let dir = tmpdir("equal");
@@ -406,4 +731,23 @@ mod tests {
"no mix track in stems-only mode"
);
}
#[cfg(unix)]
#[test]
fn async_peer_create_error_surfaces_at_finalize() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir("asyncerr");
let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
rec.add_peer(an_id(), "blocked").unwrap();
rec.end_cycle().unwrap();
let result = rec.finalize();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let err = result.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
let _ = std::fs::remove_dir_all(&dir);
}
}
+3
View File
@@ -20,6 +20,9 @@ pub trait AudioDecoder: Send {
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
/// enabling the decoder to perform packet loss concealment (PLC).
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
/// Reconstructs the previous lost frame from the next packet's in-band FEC.
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError>;
}
pub mod opus_impl;
+120 -1
View File
@@ -1,5 +1,49 @@
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use opus::{Application, Channels, Decoder, Encoder};
use crate::config::AudioProfile;
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
/// unit-testable function (W12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpusParams {
/// Target bitrate in bits/sec.
pub bitrate: i32,
/// Enable in-band forward error correction (loss redundancy in the bitstream).
pub inband_fec: bool,
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
pub packet_loss_perc: i32,
/// Discontinuous transmission: stop sending during silence to save bandwidth.
pub dtx: bool,
}
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
///
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
/// leaves headroom for the redundancy on a congested link.
pub fn opus_params(profile: AudioProfile) -> OpusParams {
match profile {
AudioProfile::LowLatency => OpusParams {
bitrate: 24_000,
inband_fec: false,
packet_loss_perc: 0,
dtx: false,
},
AudioProfile::Balanced => OpusParams {
bitrate: 32_000,
inband_fec: true,
packet_loss_perc: 10,
dtx: false,
},
AudioProfile::BadNetwork => OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 25,
dtx: false,
},
}
}
pub struct OpusEncoder {
encoder: Encoder,
@@ -17,6 +61,29 @@ impl OpusEncoder {
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
Ok(Self { encoder })
}
/// Apply concrete codec parameters to the live encoder. Safe to call between
/// frames, so the user can switch profile mid-call.
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
self.encoder
.set_bitrate(Bitrate::Bits(params.bitrate))
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
self.encoder
.set_inband_fec(params.inband_fec)
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
self.encoder
.set_packet_loss_perc(params.packet_loss_perc)
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
self.encoder
.set_dtx(params.dtx)
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
Ok(())
}
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
self.apply_params(&opus_params(profile))
}
}
impl AudioEncoder for OpusEncoder {
@@ -95,12 +162,64 @@ impl AudioDecoder for OpusDecoder {
pcm.truncate(decoded_per_channel * channels_count);
Ok(pcm)
}
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError> {
let channels_count = self.channels_count();
let mut pcm = vec![0i16; self.frame_samples * channels_count];
let decoded_per_channel = self
.decoder
.decode(next_payload, &mut pcm, true)
.map_err(|e| CodecError::Decode(format!("Opus FEC decoding failed: {}", e)))?;
pcm.truncate(decoded_per_channel * channels_count);
Ok(pcm)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opus_params_mapping() {
let low = opus_params(AudioProfile::LowLatency);
let bal = opus_params(AudioProfile::Balanced);
let bad = opus_params(AudioProfile::BadNetwork);
// LowLatency has no loss redundancy; the other two do.
assert!(!low.inband_fec);
assert_eq!(low.packet_loss_perc, 0);
assert!(bal.inband_fec);
assert!(bad.inband_fec);
// Capture-side gating suppresses silence; no profile adds Opus DTX.
assert!(!low.dtx && !bal.dtx && !bad.dtx);
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
// BadNetwork trims base bitrate to make room for FEC redundancy.
assert!(bad.bitrate < bal.bitrate);
// All bitrates are sane positive voice rates.
for p in [low, bal, bad] {
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
assert!((0..=100).contains(&p.packet_loss_perc));
}
}
#[test]
fn test_apply_profile_sets_bitrate() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
// Every profile applies cleanly to a real encoder...
for profile in AudioProfile::ALL {
encoder.apply_profile(profile).unwrap();
}
// ...and the last-applied bitrate is reflected by the encoder.
encoder.apply_profile(AudioProfile::Balanced).unwrap();
let want = opus_params(AudioProfile::Balanced).bitrate;
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
}
#[test]
fn test_round_trip() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+236
View File
@@ -93,6 +93,60 @@ impl std::fmt::Display for RecordingMode {
}
}
/// Named Opus encoder / network-resilience policy (W12). The user picks a
/// profile instead of raw codec knobs; the concrete libopus parameters live in
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioProfile {
/// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
/// clean LAN / low-loss link where added latency matters more than loss.
LowLatency,
/// Sensible default: voice bitrate with in-band FEC for light packet loss.
#[default]
Balanced,
/// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
/// loss, at a lower bitrate to leave headroom for the redundancy.
BadNetwork,
}
impl AudioProfile {
/// All variants, in picker display order.
pub const ALL: [AudioProfile; 3] = [
AudioProfile::LowLatency,
AudioProfile::Balanced,
AudioProfile::BadNetwork,
];
/// Compact discriminant for handing the profile to the capture thread via an
/// atomic. Pairs with [`AudioProfile::from_u8`].
pub fn as_u8(self) -> u8 {
match self {
AudioProfile::LowLatency => 0,
AudioProfile::Balanced => 1,
AudioProfile::BadNetwork => 2,
}
}
/// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
pub fn from_u8(v: u8) -> AudioProfile {
match v {
0 => AudioProfile::LowLatency,
2 => AudioProfile::BadNetwork,
_ => AudioProfile::Balanced,
}
}
}
impl std::fmt::Display for AudioProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AudioProfile::LowLatency => "Low latency",
AudioProfile::Balanced => "Balanced",
AudioProfile::BadNetwork => "Bad network",
})
}
}
impl std::fmt::Display for RoomLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
@@ -114,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
}
}
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareQuality {
#[default]
Auto,
Low,
Medium,
High,
Source,
}
impl ShareQuality {
pub const ALL: [ShareQuality; 5] = [
ShareQuality::Auto,
ShareQuality::Low,
ShareQuality::Medium,
ShareQuality::High,
ShareQuality::Source,
];
}
impl std::fmt::Display for ShareQuality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareQuality::Auto => "Auto",
ShareQuality::Low => "Low",
ShareQuality::Medium => "Medium",
ShareQuality::High => "High",
ShareQuality::Source => "Source",
})
}
}
/// Preferred local player for watching a peer's screen share.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SharePlayer {
#[default]
Mpv,
Vlc,
}
impl SharePlayer {
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
}
impl std::fmt::Display for SharePlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SharePlayer::Mpv => "mpv",
SharePlayer::Vlc => "VLC",
})
}
}
/// Local player buffering posture for screen-share playback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareBuffering {
#[default]
LowLatency,
Smooth,
}
impl ShareBuffering {
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
}
impl std::fmt::Display for ShareBuffering {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareBuffering::LowLatency => "Low latency",
ShareBuffering::Smooth => "Smooth",
})
}
}
fn default_screen_share_cache_mb() -> u32 {
2
}
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
/// deliberately let pixelpass/player defaults stand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenShareSettings {
#[serde(default)]
pub quality: ShareQuality,
#[serde(default)]
pub bitrate_mbps: Option<u32>,
#[serde(default)]
pub framerate: Option<u32>,
#[serde(default)]
pub max_height: Option<u32>,
#[serde(default)]
pub max_viewers: Option<u32>,
#[serde(default)]
pub force_software_encode: bool,
#[serde(default)]
pub extra_host_args: String,
#[serde(default)]
pub player: SharePlayer,
#[serde(default)]
pub hardware_decode: bool,
#[serde(default)]
pub buffering: ShareBuffering,
#[serde(default = "default_screen_share_cache_mb")]
pub cache_mb: u32,
#[serde(default)]
pub extra_mpv_args: String,
}
impl Default for ScreenShareSettings {
fn default() -> Self {
Self {
quality: ShareQuality::default(),
bitrate_mbps: None,
framerate: None,
max_height: None,
max_viewers: None,
force_software_encode: false,
extra_host_args: String::new(),
player: SharePlayer::default(),
hardware_decode: false,
buffering: ShareBuffering::default(),
cache_mb: default_screen_share_cache_mb(),
extra_mpv_args: String::new(),
}
}
}
fn default_true() -> bool {
true
}
@@ -199,6 +386,10 @@ pub struct AppConfig {
pub clip_volume_universal: bool,
#[serde(default)]
pub network_mode: NetworkMode,
/// Opus encoder / network-resilience profile (W12). Applies live to the
/// running encoder; default `Balanced`.
#[serde(default)]
pub audio_profile: AudioProfile,
/// Presence posture for the friends idle listener (W7): invisible / normal /
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
#[serde(default)]
@@ -311,6 +502,9 @@ pub struct AppConfig {
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
pub pixelpass_path: Option<String>,
/// Local-only host/player controls for screen sharing.
#[serde(default)]
pub screen_share: ScreenShareSettings,
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
/// one-click rejoin; never sent over the wire. De-duped by room topic and
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
@@ -380,6 +574,7 @@ impl Default for AppConfig {
show_player_bar: true,
clip_volume_universal: true,
network_mode: NetworkMode::default(),
audio_profile: AudioProfile::default(),
presence_mode: crate::presence::PresenceMode::default(),
echo_cancellation_enabled: false,
notifications_enabled: true,
@@ -415,6 +610,7 @@ impl Default for AppConfig {
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
@@ -744,6 +940,14 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
assert_eq!(
deserialized.screen_share.buffering,
ShareBuffering::LowLatency
);
assert_eq!(deserialized.screen_share.cache_mb, 2);
// Configs predating the per-sound flags (W6) enable every chime, so an
// upgrade is silent-change-free.
for sound in Sound::ALL {
@@ -1019,6 +1223,38 @@ mod tests {
assert_ne!(display_0, display_2);
}
#[test]
fn test_audio_profile() {
// Default is Balanced.
assert_eq!(AudioProfile::default(), AudioProfile::Balanced);
// ALL holds the three variants.
assert_eq!(AudioProfile::ALL.len(), 3);
assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));
// as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
// to the default rather than panicking.
for p in AudioProfile::ALL {
assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
}
assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);
// serde round-trips, and Display strings are non-empty + distinct.
let mut labels = Vec::new();
for p in AudioProfile::ALL {
let s = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
let label = p.to_string();
assert!(!label.is_empty());
labels.push(label);
}
labels.sort();
labels.dedup();
assert_eq!(labels.len(), 3);
}
#[test]
fn test_unknown_field_tolerance() {
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
+95 -5
View File
@@ -202,11 +202,15 @@ impl JitterBuffer {
None
} else {
// Gap with later packets already buffered: a packet was lost
// or reordered out of window. Conceal this frame via Opus PLC
// and grow the cushion — the jitter beat our current delay.
// or reordered out of window. First try Opus in-band FEC from
// the next packet; if unavailable, fall back to plain PLC.
self.next_seq = Some(next.wrapping_add(1));
self.note_disruption();
self.decoder.decode(None).ok()
let next_payload = self.packets.values().next().expect("non-empty");
self.decoder
.decode_fec(next_payload)
.or_else(|_| self.decoder.decode(None))
.ok()
}
}
}
@@ -221,8 +225,8 @@ impl JitterBuffer {
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::AudioEncoder;
use crate::codec::opus_impl::OpusEncoder;
use crate::codec::opus_impl::{OpusDecoder, OpusEncoder, OpusParams};
use crate::codec::{AudioDecoder, AudioEncoder};
use opus::{Application, Channels};
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
@@ -233,6 +237,32 @@ mod tests {
enc.encode(&pcm).unwrap()
}
fn tone_frame(enc: &mut OpusEncoder, amp: i16, frame_index: usize) -> Vec<u8> {
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
.map(|i| {
let sample_index = frame_index * FRAME_SAMPLES + i;
let t = sample_index as f32 / 48_000.0;
let fundamental = (t * 220.0 * 2.0 * std::f32::consts::PI).sin();
let harmonic = (t * 440.0 * 2.0 * std::f32::consts::PI).sin();
((fundamental * 0.7 + harmonic * 0.3) * amp as f32) as i16
})
.collect();
enc.encode(&pcm).unwrap()
}
fn rms_error(a: &[i16], b: &[i16]) -> f64 {
assert_eq!(a.len(), b.len());
let sum_sq: f64 = a
.iter()
.zip(b)
.map(|(&left, &right)| {
let diff = left as f64 - right as f64;
diff * diff
})
.sum();
(sum_sq / a.len() as f64).sqrt()
}
#[test]
fn buffers_then_plays_in_order() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
@@ -289,6 +319,66 @@ mod tests {
assert!(jb.pop_frame().is_none());
}
#[test]
fn uses_in_band_fec_from_next_packet_for_gap() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
enc.apply_params(&OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 60,
dtx: false,
})
.unwrap();
let dropped_seq = 5usize;
let amps = [1800, 1800, 1800, 1800, 1800, 12_000, 12_000, 12_000];
let packets: Vec<Vec<u8>> = amps
.into_iter()
.enumerate()
.map(|(seq, amp)| tone_frame(&mut enc, amp, seq))
.collect();
let mut expected_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
expected_decoder.decode(Some(packet)).unwrap();
}
let expected_lost = expected_decoder
.decode(Some(&packets[dropped_seq]))
.unwrap();
let mut plc_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
plc_decoder.decode(Some(packet)).unwrap();
}
let pure_plc = plc_decoder.decode(None).unwrap();
let mut jb = JitterBuffer::new().unwrap();
for (seq, packet) in packets.iter().enumerate() {
if seq != dropped_seq {
jb.insert(seq as u32, packet.clone());
}
}
for _ in 0..dropped_seq {
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
}
let recovered = jb.pop_frame().expect("gap should be reconstructed");
assert_eq!(recovered.len(), FRAME_SAMPLES);
assert!(
jb.packets.contains_key(&(dropped_seq as u32 + 1)),
"FEC source packet must remain buffered for normal decode"
);
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
let fec_error = rms_error(&recovered, &expected_lost);
let plc_error = rms_error(&pure_plc, &expected_lost);
assert!(
fec_error < plc_error * 0.75,
"FEC reconstruction should be materially closer than PLC (fec_error={fec_error}, plc_error={plc_error})"
);
}
#[test]
fn drops_packets_already_played() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+32 -6
View File
@@ -1,4 +1,4 @@
use crate::config::{NetworkMode, RecordingMode};
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
use crate::friends::Friend;
use crate::network::PeerState;
use crate::presence::{FriendPresence, PresenceMode};
@@ -56,6 +56,10 @@ pub enum CoreCommand {
/// Set the relay/discovery posture. Takes effect on the next room join,
/// since the endpoint is (re)built then.
SetNetworkMode(NetworkMode),
/// Set the Opus encoder / network-resilience profile (W12). Applies live to
/// the running capture encoder, and to the next call's encoder. Sent at
/// startup from config and whenever the user changes it.
SetAudioProfile(AudioProfile),
/// Start/stop recording the call to a local WAV (your mic + the incoming
/// mix). No-op start if already recording / not in a call.
SetRecording(bool),
@@ -115,13 +119,18 @@ pub enum CoreCommand {
/// whole desktop audio (the legacy behavior).
StartScreenShare {
audio_app: Option<String>,
settings: ScreenShareSettings,
quality: ShareQuality,
},
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
StopScreenShare,
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
/// open it in a local player.
ViewShare(String),
ViewShare {
ticket: String,
settings: ScreenShareSettings,
},
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
/// on the next room join (the endpoint is rebuilt then). The core replies with
/// an updated [`UiEvent::IdentityStatus`].
@@ -212,6 +221,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
@@ -239,9 +249,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
@@ -292,6 +309,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
@@ -319,9 +337,16 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
@@ -574,6 +599,7 @@ mod tests {
},
CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat("hello".to_string()),
];
+150 -16
View File
@@ -17,7 +17,7 @@ use crate::network::{
};
use crate::audio::multitrack::MultitrackRecorder;
use crate::config::{NetworkMode, RecordingMode};
use crate::config::{AudioProfile, NetworkMode, RecordingMode};
use crate::presence::PresenceMode;
use iroh::{
Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router,
@@ -31,6 +31,12 @@ use tokio::sync::{Mutex, mpsc};
type CoalesceStore = Arc<StdMutex<HashMap<CoalesceKey, CoreCommand>>>;
// Mixer -> playback-worker handoff. The playback ring itself targets three
// 20ms frames; allow at most two more in flight so worker lag applies
// backpressure before the ring can overshoot to its 200ms cap (A6).
const PLAYBACK_HANDOFF_QUEUE_FRAMES: usize = 2;
const PLAYBACK_HANDOFF_RETRY: Duration = Duration::from_millis(1);
pub struct CoreController {
reliable_tx: mpsc::UnboundedSender<CoreCommand>,
coalesce: CoalesceStore,
@@ -156,6 +162,22 @@ fn audio_datagram_len_ok(len: usize) -> bool {
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
}
async fn send_playback_frame(
tx: &std::sync::mpsc::SyncSender<Vec<i16>>,
mut frame: Vec<i16>,
) -> bool {
loop {
match tx.try_send(frame) {
Ok(()) => return true,
Err(std::sync::mpsc::TrySendError::Full(returned)) => {
frame = returned;
tokio::time::sleep(PLAYBACK_HANDOFF_RETRY).await;
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => return false,
}
}
}
/// The presence label to broadcast for a detected game: its display name,
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
@@ -640,9 +662,11 @@ struct ActiveSession {
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop).
screenshare_host: Option<tokio::process::Child>,
/// pixelpass viewer children we spawned to watch peers' shares; killed on
/// session teardown (each also self-exits when its player window closes).
screenshare_viewers: Vec<tokio::process::Child>,
/// pixelpass viewer children we spawned to watch peers' shares, each paired
/// with the share ticket it's viewing so a re-watch of the same share can
/// replace (not stack) its player. Killed on session teardown (each also
/// self-exits when its player window closes).
screenshare_viewers: Vec<(String, tokio::process::Child)>,
}
impl ActiveSession {
@@ -654,7 +678,7 @@ impl ActiveSession {
if let Some(mut host) = self.screenshare_host.take() {
let _ = host.kill().await;
}
for mut viewer in self.screenshare_viewers.drain(..) {
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
let _ = viewer.kill().await;
}
self.datagram_task.abort();
@@ -1162,6 +1186,12 @@ async fn run_core_loop(
// App-internal capture/playback gains (f32 bits), live-read by the audio loops.
let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
// Opus encoder profile (W12) as a discriminant, live-read by the capture
// thread so a mid-call profile switch re-tunes the running encoder. Set from
// config via the GUI's startup `SetAudioProfile`; defaults to Balanced.
let audio_profile = Arc::new(std::sync::atomic::AtomicU8::new(
AudioProfile::default().as_u8(),
));
// Call recording: an optional live recorder (mic FIFO + WAV writer), shared
// by the capture thread (pushes mic) and the mixer task (writes mix frames).
// `is_recording` is a fast-path gate so the audio loops only take the lock
@@ -1648,7 +1678,8 @@ async fn run_core_loop(
// Setup raw audio channels
let (capture_tx, capture_rx) = std::sync::mpsc::channel();
let (playback_tx, playback_rx) = std::sync::mpsc::channel();
let (playback_tx, playback_rx) =
std::sync::mpsc::sync_channel(PLAYBACK_HANDOFF_QUEUE_FRAMES);
// Echo cancellation: if enabled, load PipeWire's echo-cancel module
// bound to the chosen real devices and route capture/playback
@@ -1740,6 +1771,7 @@ async fn run_core_loop(
let is_recording_capture = is_recording.clone();
let multitrack_capture = multitrack.clone();
let is_multitrack_capture = is_multitrack.clone();
let audio_profile_capture = audio_profile.clone();
let capture_thread = std::thread::spawn(move || {
use opus::{Application, Channels};
@@ -1751,6 +1783,13 @@ async fn run_core_loop(
return;
}
};
// Tune the encoder to the configured profile (W12), then track
// the live discriminant so a mid-call switch re-applies it.
let mut current_profile =
AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed));
if let Err(e) = encoder.apply_profile(current_profile) {
crate::log_msg(&format!("Opus profile apply failed: {:?}", e));
}
// Per-sender packet sequence number, prepended to every frame so
// receivers can reorder and conceal loss. Wraps after ~years.
let mut seq: u32 = 0;
@@ -1763,6 +1802,14 @@ async fn run_core_loop(
let mut mic_meter = MicLevelMeter::new();
while let Ok(mut pcm) = capture_rx.recv() {
// Re-tune the encoder if the user switched profile mid-call.
// Cheap atomic load per frame; only reconfigures on change.
let want =
AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed));
if want != current_profile && encoder.apply_profile(want).is_ok() {
current_profile = want;
}
// Apply the input gain first so the meter, gate, and what we
// transmit all reflect the same (gained) signal.
apply_volume(
@@ -2092,7 +2139,7 @@ async fn run_core_loop(
mixed
};
if playback_tx.send(frame_to_send).is_err() {
if !send_playback_frame(&playback_tx, frame_to_send).await {
break;
}
@@ -2452,7 +2499,7 @@ async fn run_core_loop(
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::new(),
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
};
let self_id = endpoint.id().to_string();
@@ -2682,6 +2729,13 @@ async fn run_core_loop(
}
}
CoreCommand::SetAudioProfile(profile) => {
// Publish the new profile to the capture thread (W12). It picks up
// the change on its next frame and re-tunes the live encoder; a
// call that starts later reads the same atomic at encoder creation.
audio_profile.store(profile.as_u8(), Ordering::Relaxed);
}
CoreCommand::RegenerateIdentity => {
// Mint + persist a fresh identity, discarding the old one. The
// persistent endpoint is rebuilt with the new key (now if idle, else
@@ -3069,7 +3123,11 @@ async fn run_core_loop(
.await;
}
CoreCommand::StartScreenShare { audio_app } => {
CoreCommand::StartScreenShare {
audio_app,
settings,
quality,
} => {
let Some(session) = &mut active_session else {
let _ = ui_tx
.send(UiEvent::Error(
@@ -3119,7 +3177,15 @@ async fn run_core_loop(
});
tx
});
match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await {
match crate::screenshare::spawn_host(
&bin,
audio_app.as_deref(),
&settings,
quality,
notices,
)
.await
{
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
@@ -3157,7 +3223,7 @@ async fn run_core_loop(
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
}
CoreCommand::ViewShare(ticket) => {
CoreCommand::ViewShare { ticket, settings } => {
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
Some(b) => b,
None => {
@@ -3169,11 +3235,27 @@ async fn run_core_loop(
continue;
}
};
match crate::screenshare::spawn_viewer(&bin, &ticket).await {
if let Some(session) = &mut active_session {
// Drop viewers whose player window has already closed so the
// list only tracks live players.
session
.screenshare_viewers
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
// One player per share: a second Watch click on a share we're
// already viewing is a retry (usually because the first window
// froze), so replace the existing player rather than stacking a
// second mpv — two players would double the shared audio.
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
let (_, mut old) = session.screenshare_viewers.remove(pos);
let _ = old.kill().await;
crate::log_msg("Screen share viewer replaced (re-watch)");
}
}
match crate::screenshare::spawn_viewer(&bin, &ticket, &settings).await {
Ok(child) => {
crate::log_msg("Screen share viewer started");
if let Some(session) = &mut active_session {
session.screenshare_viewers.push(child);
session.screenshare_viewers.push((ticket, child));
}
}
Err(e) => {
@@ -3189,21 +3271,46 @@ async fn run_core_loop(
Ok(())
}
/// Index of an existing viewer for `ticket` in the live-viewers list, if any.
/// A re-watch of the same share replaces that player instead of stacking a
/// second one — two players decoding the same stream would double the shared
/// audio. Generic over the child value so the dedup rule is unit-testable
/// without spawning real player processes.
fn replace_viewer_index<T>(viewers: &[(String, T)], ticket: &str) -> Option<usize> {
viewers.iter().position(|(t, _)| t == ticket)
}
#[cfg(test)]
mod tests {
use super::{
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
PeerSpeakTicket, admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok,
coalesce_insert, coalesce_pop, frame_level, mix_frames, mix_stereo_frames,
next_game_change, should_auto_fetch, stereo_to_mono,
PLAYBACK_HANDOFF_QUEUE_FRAMES, PeerSpeakTicket, admit_retained, apply_peer_volume,
apply_volume, audio_datagram_len_ok, coalesce_insert, coalesce_pop, frame_level,
mix_frames, mix_stereo_frames, next_game_change, replace_viewer_index, send_playback_frame,
should_auto_fetch, stereo_to_mono,
};
use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::sync_channel;
use std::time::Duration;
fn endpoint_id() -> iroh::EndpointId {
iroh::SecretKey::generate().public()
}
#[test]
fn re_watch_replaces_existing_viewer_for_same_ticket() {
// The value type stands in for a viewer Child; only the ticket matters.
let viewers = vec![("ticket-A".to_string(), 0u8), ("ticket-B".to_string(), 1u8)];
// Re-watching an already-open share finds the existing player to replace.
assert_eq!(replace_viewer_index(&viewers, "ticket-A"), Some(0));
assert_eq!(replace_viewer_index(&viewers, "ticket-B"), Some(1));
// A different (new) share has nothing to replace — it opens fresh.
assert_eq!(replace_viewer_index(&viewers, "ticket-C"), None);
// Empty list: first watch of anything opens fresh.
assert_eq!(replace_viewer_index::<u8>(&[], "ticket-A"), None);
}
#[test]
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
@@ -3432,6 +3539,33 @@ mod tests {
assert!(!audio_datagram_len_ok(5 + MAX_OPUS_PAYLOAD));
}
#[tokio::test]
async fn playback_handoff_waits_for_bounded_queue_space() {
let (tx, rx) = sync_channel::<Vec<i16>>(PLAYBACK_HANDOFF_QUEUE_FRAMES);
for n in 0..PLAYBACK_HANDOFF_QUEUE_FRAMES {
tx.try_send(vec![n as i16]).unwrap();
}
let worker = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
for n in 0..PLAYBACK_HANDOFF_QUEUE_FRAMES {
assert_eq!(rx.recv().unwrap(), vec![n as i16]);
}
assert_eq!(rx.recv().unwrap(), vec![99, 100]);
});
let sent = tokio::time::timeout(
Duration::from_secs(1),
send_playback_frame(&tx, vec![99, 100]),
)
.await
.expect("bounded handoff should unblock after the worker drains a frame");
assert!(sent);
drop(tx);
worker.join().unwrap();
}
#[test]
fn mic_meter_holds_the_peak_across_the_window() {
let mut m = MicLevelMeter::new();
+1 -3
View File
@@ -97,14 +97,12 @@ fn windows_toolhelp_executables() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn enumerates_at_least_this_process() {
// The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename.
let exes = running_executables();
let exes = super::running_executables();
assert!(
!exes.is_empty(),
"expected to see running processes via /proc"
+10 -6
View File
@@ -33,12 +33,16 @@ pub const FRIENDS_PROTO: u32 = 1;
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
///
/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a
/// current shared-listening track descriptor and playback timeline. Bytes still
/// ride the files plane by id; gossip carries only the descriptor/timeline.
///
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next
/// track so tuned-in listeners can fetch it before the DJ advances.
/// v4v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
/// gained an optional `music` presence field (a current shared-listening track
/// descriptor + playback timeline; the audio bytes still ride the files plane by
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
/// gained optional prefetch hints for the next track so tuned-in listeners can
/// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
pub const GOSSIP_PROTO: u32 = 5;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
+265 -30
View File
@@ -21,6 +21,8 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
@@ -139,7 +141,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
/// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (our own enumeration / the user's pick), not peer-supplied, but is still
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
pub fn host_args(
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
@@ -149,9 +155,44 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
args.push(format!("--app={name}"));
args.push("--strict-audio".to_string());
}
if quality != ShareQuality::Auto {
args.push(format!("--quality={}", pixelpass_quality(quality)));
}
if let Some(height) = settings.max_height {
args.push(format!("--max-height={height}"));
}
if let Some(mbps) = settings.bitrate_mbps {
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
}
if let Some(fps) = settings.framerate {
args.push(format!("--framerate={fps}"));
}
if settings.force_software_encode {
args.push("--no-hwencode".to_string());
}
if let Some(max) = settings.max_viewers {
args.push(format!("--max-viewers={max}"));
}
args.extend(split_extra_args(&settings.extra_host_args));
args
}
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "auto",
ShareQuality::Low => "low",
ShareQuality::Medium => "medium",
ShareQuality::High => "high",
ShareQuality::Source => "source",
}
}
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
/// not depend on a shell lexer, so quoted values are not interpreted here.
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
raw.split_whitespace().map(str::to_string)
}
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters
/// (newlines etc.) that have no place in a real `application.name`. `None` means
@@ -320,15 +361,22 @@ pub fn is_available(config_override: Option<&str>) -> bool {
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. We do
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
/// not pass encode/viewer overrides unless the local settings explicitly ask for
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host(
bin: &Path,
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> std::io::Result<(Child, String)> {
let args = host_args(audio_app, settings, quality);
// Log the exact argv we hand pixelpass so a field log can confirm which
// encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe.
crate::log_msg(&format!("pixelpass host spawn: {} {}", bin.display(), args.join(" ")));
let mut child = Command::new(bin)
.args(host_args(audio_app))
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
// Capture stderr (not null): pixelpass prints its startup precondition
@@ -428,10 +476,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String {
}
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
/// child so the caller can kill it on room-leave; it also self-exits when the
/// player window closes (its tunnel ends).
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
/// stream in a local player (mpv/VLC in the configured order, then fallback).
/// Returns the live viewer child so the caller can kill it on room-leave; it also
/// self-exits when the player window closes (its tunnel ends).
pub async fn spawn_viewer(
bin: &Path,
ticket: &str,
settings: &ScreenShareSettings,
) -> std::io::Result<Child> {
let mut child = Command::new(bin)
.args(viewer_args(ticket))
.stdin(Stdio::null())
@@ -465,7 +517,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
}
};
if let Err(e) = launch_player(&url) {
if let Err(e) = launch_player(&url, settings) {
let _ = child.kill().await;
return Err(e);
}
@@ -547,23 +599,33 @@ fn event_for_log(ev: &PixelpassEvent) -> 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.
fn launch_player(url: &str) -> std::io::Result<()> {
const MPV_ARGS: &[&str] = &[
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
];
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
/// Open the viewer stream URL in a media player, then fall back to vlc. The
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
let vlc_args = vlc_args(settings);
let first = match settings.player {
SharePlayer::Mpv => ("mpv", &mpv_args),
SharePlayer::Vlc => ("vlc", &vlc_args),
};
let second = match settings.player {
SharePlayer::Mpv => ("vlc", &vlc_args),
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let child = match spawn_player("mpv", MPV_ARGS, url) {
let child = match spawn_player(first.0, first.1, url) {
Ok(c) => c,
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
@@ -577,7 +639,64 @@ fn launch_player(url: &str) -> std::io::Result<()> {
Ok(())
}
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
ShareBuffering::Smooth => {
args.push("--cache=yes".to_string());
args.push("--demuxer-readahead-secs=2".to_string());
}
}
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
/// that map cleanly onto its option set: the buffering posture (network/live
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
/// them as mpv-only. Pure: no I/O.
///
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
/// `--hwdec=auto`.
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
let caching_ms = match settings.buffering {
ShareBuffering::LowLatency => 200,
ShareBuffering::Smooth => 1500,
};
let hw = if settings.hardware_decode {
"--avcodec-hw=any"
} else {
"--avcodec-hw=none"
};
vec![
format!("--network-caching={caching_ms}"),
format!("--live-caching={caching_ms}"),
hw.to_string(),
]
}
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
// --avcodec-hw) so a field log can confirm the viewer settings reached the
// player. The `url` is omitted deliberately — it is the local stream address
// and is not needed to verify the flags. Logged on each attempt, so a
// fallback from the preferred player to the other one is visible too.
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
Command::new(bin)
.args(args)
.arg(url)
@@ -621,7 +740,11 @@ mod tests {
fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture.
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(None, &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
@@ -629,8 +752,9 @@ mod tests {
// The chosen app rides in the `--app=<name>` single-token form so a
// name beginning with `-` can never be reparsed as a flag (A23), plus
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some("Firefox")),
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
vec![
"--host",
"--output",
@@ -641,7 +765,7 @@ mod tests {
);
// The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag.
let args = host_args(Some("-rm -rf"));
let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
@@ -650,11 +774,122 @@ mod tests {
fn host_args_blank_or_control_app_is_dropped() {
// An empty / whitespace / control-laden selection is sanitized away,
// falling back to whole-desktop capture rather than a broken flag.
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some("bad\nname")),
host_args(Some(" "), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
assert_eq!(
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
fn host_args_apply_screen_share_settings_and_extra_args_last() {
let settings = ScreenShareSettings {
bitrate_mbps: Some(5),
framerate: Some(60),
max_height: Some(1080),
max_viewers: Some(4),
force_software_encode: true,
extra_host_args: "--relay https://relay.example --verbose".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
host_args(Some("Firefox"), &settings, ShareQuality::High),
vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
"--quality=high",
"--max-height=1080",
"--bitrate=5000",
"--framerate=60",
"--no-hwencode",
"--max-viewers=4",
"--relay",
"https://relay.example",
"--verbose",
]
);
}
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default()),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",
]
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
mpv_args(&settings),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",
"--demuxer-max-bytes=16M",
"--hwdec=auto",
"--no-osc",
"--vd-lavc-threads=2",
]
);
}
#[test]
fn vlc_args_default_disables_hardware_decode() {
// The A-bug fix default (hardware_decode = false) must reach VLC too:
// VLC hardware-decodes by default, so without an explicit
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
assert_eq!(
vlc_args(&ScreenShareSettings::default()),
vec![
"--network-caching=200",
"--live-caching=200",
"--avcodec-hw=none",
]
);
}
#[test]
fn vlc_args_smooth_buffering_and_hwdecode() {
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
// mpv-only and must NOT leak into the VLC argv.
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
vlc_args(&settings),
vec![
"--network-caching=1500",
"--live-caching=1500",
"--avcodec-hw=any",
]
);
}
#[test]
+60 -6
View File
@@ -70,6 +70,13 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
}
}
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
/// selection) from clipboard text before it is pasted. Shared by the
/// right-click menu Paste and the middle-click PRIMARY paste.
pub fn sanitize_clip(raw: &str) -> String {
raw.chars().filter(|c| !c.is_control()).collect()
}
pub fn select_all_range(value: &str) -> (usize, usize) {
let value = text_input::Value::new(value);
@@ -362,6 +369,48 @@ where
return;
}
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
// selection, so without this the common "select text, middle-click to
// paste" workflow does nothing on X11.
let middle_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
) && cursor.is_over(layout.bounds());
if middle_click_on_input && !self.locked {
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
if !clip.is_empty() {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
let (start, end) = match input_state.cursor().state(&value) {
text_input::cursor::State::Index(index) => {
let index = index.min(value.len());
(index, index)
}
text_input::cursor::State::Selection { start, end } => {
normalized_range(&value, start, end)
}
};
let edit = paste(&self.value, start, end, &clip);
input_state.move_cursor_to(edit.cursor);
if let Some(on_paste) = &self.on_paste {
shell.publish(on_paste.as_ref()(edit.value));
} else if let Some(on_input) = &self.on_input {
shell.publish(on_input.as_ref()(edit.value));
}
}
shell.capture_event();
shell.request_redraw();
return;
}
Widget::update(
&mut self.input,
&mut tree.children[0],
@@ -717,12 +766,7 @@ where
}
}
MenuAction::Paste => {
let clip = clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default()
.chars()
.filter(|c| !c.is_control())
.collect::<String>();
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Standard).unwrap_or_default());
let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell);
@@ -842,6 +886,16 @@ mod tests {
assert_eq!(clip, None);
}
#[test]
fn sanitize_clip_strips_control_chars_keeps_text() {
// An X11 PRIMARY selection commonly carries a trailing newline.
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
// Non-control unicode is preserved.
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
assert_eq!(sanitize_clip(""), "");
}
#[test]
fn paste_replaces_selection_or_inserts_at_cursor() {
assert_eq!(