Compare commits
39
Commits
0aaf6be529
...
v0.6.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a4a336ad | ||
|
|
df45c0bfeb | ||
|
|
faad8ce26a | ||
|
|
e378b2e33b | ||
|
|
96e41de1b1 | ||
|
|
5c888f8357 | ||
|
|
074f004227 | ||
|
|
2d22036930 | ||
|
|
8014edf91c | ||
|
|
89d5218d25 | ||
|
|
f3c7aa7050 | ||
|
|
39b5dafd57 | ||
|
|
a78860db15 | ||
|
|
5f52aa1506 | ||
|
|
d92d0f6f6b | ||
|
|
551767f9f5 | ||
|
|
fa90cd3ce9 | ||
|
|
660261a9a5 | ||
|
|
3a74fd0230 | ||
|
|
2dbb1ea316 | ||
|
|
c902db2e90 | ||
|
|
83e5881768 | ||
|
|
8424b44dec | ||
|
|
33e49a8ca7 | ||
|
|
393c1c7f09 | ||
|
|
1bf14ba08f | ||
|
|
6f14d2668d | ||
|
|
49c3ce8c0a | ||
|
|
aec21a48d5 | ||
|
|
d0a16cb8b9 | ||
|
|
e0325d4590 | ||
|
|
e8a894be49 | ||
|
|
8c33b5c70f | ||
|
|
10bd15aeaa | ||
|
|
8ad0bea19d | ||
|
|
abb53af559 | ||
|
|
c0c1969332 | ||
|
|
d155091eed | ||
|
|
b553a94875 |
@@ -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
@@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to PeerSpeak are documented here.
|
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
|
### 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.
|
- **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
|
### 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.
|
- **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
|
## [0.6.0] — 2026-06-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+5
-5
@@ -200,9 +200,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
@@ -3682,9 +3682,9 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.10"
|
version = "0.9.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.0"
|
version = "0.6.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.0"
|
version = "0.6.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -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
@@ -1,19 +1,28 @@
|
|||||||
# PeerSpeak on Windows
|
# PeerSpeak on Windows
|
||||||
|
|
||||||
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
|
||||||
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
has passed an older native Windows 11 VM smoke test for launch, GUI render, call
|
||||||
checks listed below.
|
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
|
## What works today
|
||||||
|
|
||||||
| Area | Status |
|
| Area | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GUI | Iced/wgpu builds and renders under Wine. |
|
| GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
|
||||||
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
| Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
|
||||||
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
| 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. |
|
| 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. |
|
| 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`:
|
Windows paths are resolved through `dirs`:
|
||||||
|
|
||||||
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
### Native Windows
|
### Cross-compile from Linux
|
||||||
|
|
||||||
Install MSVC Build Tools and CMake, then build normally:
|
Preferred local path:
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
cargo build --release
|
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
|
```powershell
|
||||||
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
cargo build --release
|
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
|
## First run and networking
|
||||||
|
|
||||||
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
Expect a Windows Firewall prompt the first time the app opens network sockets, or
|
||||||
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
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
|
The default network mode keeps the n0 relay available for NAT traversal without
|
||||||
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
publishing presence to n0 DNS. Relayed connections are expected and valid.
|
||||||
connections are expected and valid.
|
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
| Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
|
||||||
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
| 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. |
|
||||||
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
| 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. |
|
||||||
| 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. |
|
| Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
|
||||||
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
| Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
|
||||||
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
|
||||||
|
|
||||||
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
## Current smoke checklist
|
||||||
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
|
||||||
play notification chimes.
|
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
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.5.0.r0.g0000000
|
pkgver=0.6.2.r319.g8014edf
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.tools/
|
||||||
|
AppDir/
|
||||||
|
*.AppImage
|
||||||
|
squashfs-root/
|
||||||
Executable
+11
@@ -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" "$@"
|
||||||
@@ -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.
|
||||||
Executable
+89
@@ -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"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Debian / Ubuntu `.deb` build
|
||||||
|
|
||||||
|
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
|
||||||
|
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
|
||||||
|
|
||||||
|
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
|
||||||
|
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
|
||||||
|
documents only the **build environment**, which is otherwise undiscoverable from
|
||||||
|
a fresh clone.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time: create + provision the build box (see "Build environment" below)
|
||||||
|
distrobox enter peerspeak-bookworm -- bash -lc '
|
||||||
|
source ~/.cargo/env
|
||||||
|
cd ~/git/butter/peerspeak
|
||||||
|
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
|
||||||
|
cargo deb
|
||||||
|
'
|
||||||
|
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build environment
|
||||||
|
|
||||||
|
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
|
||||||
|
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
|
||||||
|
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
|
||||||
|
floor (see "glibc floor" below).
|
||||||
|
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
|
||||||
|
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
|
||||||
|
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
|
||||||
|
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
|
||||||
|
so a host build links Arch-compiled C objects into the "Debian" binary.
|
||||||
|
|
||||||
|
### One-time provisioning inside the box
|
||||||
|
|
||||||
|
```sh
|
||||||
|
distrobox enter peerspeak-bookworm
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y build-essential pkg-config clang libclang-dev \
|
||||||
|
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
|
||||||
|
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
source ~/.cargo/env
|
||||||
|
cargo install cargo-deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### The mandatory separate `CARGO_TARGET_DIR`
|
||||||
|
|
||||||
|
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
|
||||||
|
directory** the Arch host builds into. If you run `cargo deb` without overriding
|
||||||
|
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
|
||||||
|
them into the Debian binary, producing a `.deb` that crashes or demands the
|
||||||
|
host's glibc.
|
||||||
|
|
||||||
|
Always point the build at a box-local cache:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
|
||||||
|
```
|
||||||
|
|
||||||
|
(Run `cargo clean` first if you ever suspect a polluted target dir.)
|
||||||
|
|
||||||
|
## glibc floor
|
||||||
|
|
||||||
|
The `.deb` is built against the build box's glibc, which becomes the install
|
||||||
|
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
|
||||||
|
|
||||||
|
| Build box | glibc | Runs on |
|
||||||
|
|----------------------|-------|--------------------------------------|
|
||||||
|
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
|
||||||
|
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
|
||||||
|
|
||||||
|
If a friend is on something even older than Debian 12, drop the floor further by
|
||||||
|
recreating the box from an older base image and rebuilding.
|
||||||
|
|
||||||
|
## Runtime `Depends` / `Recommends`
|
||||||
|
|
||||||
|
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
|
||||||
|
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
|
||||||
|
- `Recommends = "pixelpass, mpv"` — `pixelpass` provides in-room screen sharing
|
||||||
|
and `mpv` is the screen-share viewer (these are companion programs invoked as
|
||||||
|
subprocesses, not linked libraries, so they are Recommends not Depends).
|
||||||
|
|
||||||
|
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
|
||||||
|
lists the whole GStreamer stack explicitly.
|
||||||
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
|
|||||||
|
|
||||||
## 1. Install it
|
## 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.**
|
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
|
This is normal — it shows up for any app that isn't from a big company with a
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
|
|||||||
|
|
||||||
## Version compatibility
|
## Version compatibility
|
||||||
|
|
||||||
The installer version tracks the crate version in `Cargo.toml` (currently
|
The installer version tracks the release version in `Cargo.toml` — keep
|
||||||
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
|
`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**:
|
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
|
peers on different MINOR versions can't connect (they fail fast at the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.6.0"
|
#define MyAppVersion "0.6.3"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
+2438
-919
File diff suppressed because it is too large
Load Diff
+53
-12
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
|
|||||||
let pick = |channels: Option<u16>| {
|
let pick = |channels: Option<u16>| {
|
||||||
ranges
|
ranges
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
|
.find(|r| {
|
||||||
|
usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -666,15 +668,30 @@ fn run_capture(
|
|||||||
let device_rate = config.sample_rate.0;
|
let device_rate = config.sample_rate.0;
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => build_input::<f32, _>(
|
SampleFormat::F32 => build_input::<f32, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::I16 => build_input::<i16, _>(
|
SampleFormat::I16 => build_input::<i16, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::U16 => build_input::<u16, _>(
|
SampleFormat::U16 => build_input::<u16, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
other => Err(AudioError::Stream(format!(
|
other => Err(AudioError::Stream(format!(
|
||||||
@@ -764,7 +781,10 @@ fn run_capture(
|
|||||||
// Surface a stream error the RT callback flagged (it can't log itself).
|
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||||
let ec = err_code.load(Ordering::Relaxed);
|
let ec = err_code.load(Ordering::Relaxed);
|
||||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
if ec != STREAM_ERR_NONE && ec != last_err {
|
||||||
crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec)));
|
crate::log_msg(&format!(
|
||||||
|
"cpal capture stream error: {}",
|
||||||
|
stream_err_text(ec)
|
||||||
|
));
|
||||||
last_err = ec;
|
last_err = ec;
|
||||||
}
|
}
|
||||||
if !drained {
|
if !drained {
|
||||||
@@ -914,16 +934,34 @@ fn run_playback(
|
|||||||
let device_rate = config.sample_rate.0;
|
let device_rate = config.sample_rate.0;
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => build_output::<f32, _>(
|
SampleFormat::F32 => build_output::<f32, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::I16 => build_output::<i16, _>(
|
SampleFormat::I16 => build_output::<i16, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::U16 => build_output::<u16, _>(
|
SampleFormat::U16 => build_output::<u16, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
other => Err(AudioError::Stream(format!(
|
other => Err(AudioError::Stream(format!(
|
||||||
"unsupported playback sample format: {other:?}"
|
"unsupported playback sample format: {other:?}"
|
||||||
@@ -1199,7 +1237,10 @@ fn spawn_health_logger(
|
|||||||
// Surface a stream error the RT callback flagged (it can't log itself).
|
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||||
let ec = err_code.load(Ordering::Relaxed);
|
let ec = err_code.load(Ordering::Relaxed);
|
||||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
if ec != STREAM_ERR_NONE && ec != last_err {
|
||||||
crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec)));
|
crate::log_msg(&format!(
|
||||||
|
"cpal playback stream error: {}",
|
||||||
|
stream_err_text(ec)
|
||||||
|
));
|
||||||
last_err = ec;
|
last_err = ec;
|
||||||
}
|
}
|
||||||
// Report the device's per-cycle demand (in internal 48 kHz-stereo
|
// Report the device's per-cycle demand (in internal 48 kHz-stereo
|
||||||
|
|||||||
+49
-11
@@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard {
|
|||||||
.arg("unload-module")
|
.arg("unload-module")
|
||||||
.arg(&self.module_index)
|
.arg(&self.module_index)
|
||||||
.output();
|
.output();
|
||||||
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index));
|
crate::log_msg(&format!(
|
||||||
|
"Echo cancel: unloaded module {}",
|
||||||
|
self.module_index
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard {
|
|||||||
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
|
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
|
||||||
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes
|
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes
|
||||||
/// don't appear — the caller should fall back to the direct devices.
|
/// don't appear — the caller should fall back to the direct devices.
|
||||||
pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<EchoCancelGuard, String> {
|
pub fn enable(
|
||||||
|
real_source: Option<&str>,
|
||||||
|
real_sink: Option<&str>,
|
||||||
|
) -> Result<EchoCancelGuard, String> {
|
||||||
// Best-effort: clear any stale instance left by a crashed prior run so we
|
// Best-effort: clear any stale instance left by a crashed prior run so we
|
||||||
// don't stack duplicate modules / fight over the virtual node names.
|
// don't stack duplicate modules / fight over the virtual node names.
|
||||||
unload_stale();
|
unload_stale();
|
||||||
@@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
if module_index.parse::<u64>().is_err() {
|
if module_index.parse::<u64>().is_err() {
|
||||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||||
}
|
}
|
||||||
let guard = EchoCancelGuard { module_index, source_name, sink_name };
|
let guard = EchoCancelGuard {
|
||||||
|
module_index,
|
||||||
|
source_name,
|
||||||
|
sink_name,
|
||||||
|
};
|
||||||
|
|
||||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||||
// the subsequent capture/playback streams can actually target them. If they
|
// the subsequent capture/playback streams can actually target them. If they
|
||||||
@@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
|||||||
/// Whether `pactl list <kind> short` lists a node named `name`.
|
/// Whether `pactl list <kind> short` lists a node named `name`.
|
||||||
/// `kind` is "sources" or "sinks".
|
/// `kind` is "sources" or "sinks".
|
||||||
fn node_present(kind: &str, name: &str) -> bool {
|
fn node_present(kind: &str, name: &str) -> bool {
|
||||||
let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else {
|
let Ok(out) = Command::new("pactl")
|
||||||
|
.arg("list")
|
||||||
|
.arg(kind)
|
||||||
|
.arg("short")
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
String::from_utf8_lossy(&out.stdout)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
@@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool {
|
|||||||
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||||
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||||
fn unload_stale() {
|
fn unload_stale() {
|
||||||
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
let Ok(out) = Command::new("pactl")
|
||||||
|
.arg("list")
|
||||||
|
.arg("modules")
|
||||||
|
.arg("short")
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
@@ -179,7 +199,10 @@ fn unload_stale() {
|
|||||||
&& ec_module_is_stale(args, process_is_alive)
|
&& ec_module_is_stale(args, process_is_alive)
|
||||||
&& index.parse::<u64>().is_ok()
|
&& index.parse::<u64>().is_ok()
|
||||||
{
|
{
|
||||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
let _ = Command::new("pactl")
|
||||||
|
.arg("unload-module")
|
||||||
|
.arg(index)
|
||||||
|
.output();
|
||||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,13 +221,25 @@ mod tests {
|
|||||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||||
let source_name = guard.source_name().to_string();
|
let source_name = guard.source_name().to_string();
|
||||||
let sink_name = guard.sink_name().to_string();
|
let sink_name = guard.sink_name().to_string();
|
||||||
assert!(node_present("sources", &source_name), "cleaned source must exist");
|
assert!(
|
||||||
assert!(node_present("sinks", &sink_name), "reference sink must exist");
|
node_present("sources", &source_name),
|
||||||
|
"cleaned source must exist"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
node_present("sinks", &sink_name),
|
||||||
|
"reference sink must exist"
|
||||||
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
// Give pactl a moment to tear the nodes down.
|
// Give pactl a moment to tear the nodes down.
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
assert!(!node_present("sources", &source_name), "source must be gone after unload");
|
assert!(
|
||||||
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
|
!node_present("sources", &source_name),
|
||||||
|
"source must be gone after unload"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!node_present("sinks", &sink_name),
|
||||||
|
"sink must be gone after unload"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -220,7 +255,10 @@ mod tests {
|
|||||||
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
|
assert_eq!(
|
||||||
|
pid_from_ec_args("source_name=someone_elses_source.4242"),
|
||||||
|
None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+17
-9
@@ -251,7 +251,10 @@ mod tests {
|
|||||||
let before = rms(&low);
|
let before = rms(&low);
|
||||||
eq.process_frame(&mut low);
|
eq.process_frame(&mut low);
|
||||||
let after = rms(&low);
|
let after = rms(&low);
|
||||||
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
|
assert!(
|
||||||
|
after > before * 1.6,
|
||||||
|
"low shelf should boost low RMS: {before} -> {after}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -264,7 +267,10 @@ mod tests {
|
|||||||
let before = rms(&high);
|
let before = rms(&high);
|
||||||
eq.process_frame(&mut high);
|
eq.process_frame(&mut high);
|
||||||
let after = rms(&high);
|
let after = rms(&high);
|
||||||
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
|
assert!(
|
||||||
|
after > before * 1.6,
|
||||||
|
"high shelf should boost high RMS: {before} -> {after}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -275,7 +281,10 @@ mod tests {
|
|||||||
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
||||||
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
|
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
|
||||||
] {
|
] {
|
||||||
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
|
assert!(
|
||||||
|
b.coeffs.all_finite(),
|
||||||
|
"coefficients must be finite at {gain} dB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,12 +298,11 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
||||||
eq.process_frame(&mut frame);
|
eq.process_frame(&mut frame);
|
||||||
let peak = frame
|
let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
|
||||||
.iter()
|
assert!(
|
||||||
.map(|&s| i32::from(s).abs())
|
peak > 1_000,
|
||||||
.max()
|
"processed signal should retain audible energy"
|
||||||
.unwrap_or(0);
|
);
|
||||||
assert!(peak > 1_000, "processed signal should retain audible energy");
|
|
||||||
assert!(
|
assert!(
|
||||||
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||||
"a boosted sine should retain both polarities"
|
"a boosted sine should retain both polarities"
|
||||||
|
|||||||
+51
-12
@@ -169,7 +169,10 @@ mod tests {
|
|||||||
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
|
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
|
||||||
last = peak(&f);
|
last = peak(&f);
|
||||||
}
|
}
|
||||||
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}");
|
assert!(
|
||||||
|
last >= 9900,
|
||||||
|
"gain should reach ~1.0 on sustained loud input, got peak {last}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -179,8 +182,15 @@ mod tests {
|
|||||||
g.process(&mut f, 0.05);
|
g.process(&mut f, 0.05);
|
||||||
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
|
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
|
||||||
// 0->1, so the early samples are well below full scale (no instant click).
|
// 0->1, so the early samples are well below full scale (no instant click).
|
||||||
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]);
|
assert!(
|
||||||
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
|
f[0].abs() < 5000,
|
||||||
|
"attack should start near zero, got {}",
|
||||||
|
f[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
f[FRAME - 1].abs() > 9000,
|
||||||
|
"attack should complete within the frame"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -193,8 +203,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
// First quiet frame right after speech: hold keeps it open (not chopped).
|
// First quiet frame right after speech: hold keeps it open (not chopped).
|
||||||
let mut q = frame(50); // rms ~0.0015, below close (0.03)
|
let mut q = frame(50); // rms ~0.0015, below close (0.03)
|
||||||
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)");
|
assert!(
|
||||||
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately");
|
g.process(&mut q, 0.05),
|
||||||
|
"first quiet frame must stay open (hangover)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
peak(&q) > 0,
|
||||||
|
"held-open frame must not be silenced immediately"
|
||||||
|
);
|
||||||
|
|
||||||
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
|
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
|
||||||
let mut closed = false;
|
let mut closed = false;
|
||||||
@@ -205,7 +221,10 @@ mod tests {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(closed, "gate must eventually close and stop transmitting after sustained silence");
|
assert!(
|
||||||
|
closed,
|
||||||
|
"gate must eventually close and stop transmitting after sustained silence"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -216,8 +235,14 @@ mod tests {
|
|||||||
g.process(&mut f, 0.05); // open=0.05, close=0.03
|
g.process(&mut f, 0.05); // open=0.05, close=0.03
|
||||||
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
|
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
|
||||||
let mut mid = frame(1310);
|
let mut mid = frame(1310);
|
||||||
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open");
|
assert!(
|
||||||
assert!(g.open, "hysteresis: gate stays open above the close threshold");
|
g.process(&mut mid, 0.05),
|
||||||
|
"between-threshold frame must keep an open gate open"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
g.open,
|
||||||
|
"hysteresis: gate stays open above the close threshold"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -225,7 +250,10 @@ mod tests {
|
|||||||
let mut g = NoiseGate::new(SR);
|
let mut g = NoiseGate::new(SR);
|
||||||
// Never opened; feed silence — should report don't-transmit promptly.
|
// Never opened; feed silence — should report don't-transmit promptly.
|
||||||
let mut f = frame(0);
|
let mut f = frame(0);
|
||||||
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit");
|
assert!(
|
||||||
|
!g.process(&mut f, 0.05),
|
||||||
|
"an unopened gate on silence must not transmit"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -266,7 +294,11 @@ mod tests {
|
|||||||
|
|
||||||
let mut f2 = frame(10000);
|
let mut f2 = frame(10000);
|
||||||
assert!(g.process(&mut f2, 0.05)); // enabled
|
assert!(g.process(&mut f2, 0.05)); // enabled
|
||||||
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]);
|
assert!(
|
||||||
|
f2[0].abs() > 9000,
|
||||||
|
"expected first sample of enabled frame to have no fade-in, got {}",
|
||||||
|
f2[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -302,7 +334,10 @@ mod tests {
|
|||||||
let mut f = frame(1310);
|
let mut f = frame(1310);
|
||||||
assert!(g.process(&mut f, 0.05));
|
assert!(g.process(&mut f, 0.05));
|
||||||
}
|
}
|
||||||
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)");
|
assert!(
|
||||||
|
g.open,
|
||||||
|
"gate must stay open (hold refreshed by mid-level input)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -333,6 +368,10 @@ mod tests {
|
|||||||
last_peak = peak(&f);
|
last_peak = peak(&f);
|
||||||
}
|
}
|
||||||
assert!(g.open);
|
assert!(g.open);
|
||||||
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak);
|
assert!(
|
||||||
|
last_peak >= 9900,
|
||||||
|
"peak of the 3rd reopened frame must be >= 9900, got {}",
|
||||||
|
last_peak
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-15
@@ -123,7 +123,10 @@ mod tests {
|
|||||||
let out = lim.process(&loud, 1.0);
|
let out = lim.process(&loud, 1.0);
|
||||||
let ceiling = lim.ceiling().ceil() as i16;
|
let ceiling = lim.ceiling().ceil() as i16;
|
||||||
for &s in &out {
|
for &s in &out {
|
||||||
assert!(s > 0, "positive loud input stays positive (no wrap), got {s}");
|
assert!(
|
||||||
|
s > 0,
|
||||||
|
"positive loud input stays positive (no wrap), got {s}"
|
||||||
|
);
|
||||||
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
|
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +178,10 @@ mod tests {
|
|||||||
let out_pos = lim.process(&pos_loud, 1.0);
|
let out_pos = lim.process(&pos_loud, 1.0);
|
||||||
for &s in &out_pos {
|
for &s in &out_pos {
|
||||||
assert!(s > 0, "positive input stays positive, got {s}");
|
assert!(s > 0, "positive input stays positive, got {s}");
|
||||||
assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}");
|
assert!(
|
||||||
|
s <= ceiling_ceil,
|
||||||
|
"positive sample {s} exceeded ceiling {ceiling_ceil}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sustained negative loud sum
|
// Sustained negative loud sum
|
||||||
@@ -185,7 +191,10 @@ mod tests {
|
|||||||
let neg_ceiling = -ceiling_ceil;
|
let neg_ceiling = -ceiling_ceil;
|
||||||
for &s in &out_neg {
|
for &s in &out_neg {
|
||||||
assert!(s < 0, "negative input stays negative, got {s}");
|
assert!(s < 0, "negative input stays negative, got {s}");
|
||||||
assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}");
|
assert!(
|
||||||
|
s >= neg_ceiling,
|
||||||
|
"negative sample {s} exceeded negative ceiling {neg_ceiling}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +209,14 @@ mod tests {
|
|||||||
let out = lim.process(&input, 8.0);
|
let out = lim.process(&input, 8.0);
|
||||||
for &s in &out {
|
for &s in &out {
|
||||||
assert!(s > 0, "positive stays positive");
|
assert!(s > 0, "positive stays positive");
|
||||||
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}");
|
assert!(
|
||||||
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}");
|
s <= ceiling_ceil,
|
||||||
|
"sample {s} must be limited to ceiling {ceiling_ceil}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(s - ceiling_ceil).abs() <= 2,
|
||||||
|
"sample {s} should ride the ceiling {ceiling_ceil}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +228,10 @@ mod tests {
|
|||||||
let out = lim.process(&input, 0.5);
|
let out = lim.process(&input, 0.5);
|
||||||
for (i, &s) in out.iter().enumerate() {
|
for (i, &s) in out.iter().enumerate() {
|
||||||
let expected = (input[i] as f32 * 0.5).round() as i16;
|
let expected = (input[i] as f32 * 0.5).round() as i16;
|
||||||
assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}");
|
assert!(
|
||||||
|
(s - expected).abs() <= 1,
|
||||||
|
"sample {s} should be close to expected {expected}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequently feed a new sample at unity gain. It must be transparent,
|
// Subsequently feed a new sample at unity gain. It must be transparent,
|
||||||
@@ -230,7 +248,12 @@ mod tests {
|
|||||||
|
|
||||||
let loud = vec![200_000i32; 10];
|
let loud = vec![200_000i32; 10];
|
||||||
let out = lim.process(&loud, 1.0);
|
let out = lim.process(&loud, 1.0);
|
||||||
assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil);
|
assert!(
|
||||||
|
out[0] <= ceiling_ceil,
|
||||||
|
"first sample {} must not overshoot ceiling {}",
|
||||||
|
out[0],
|
||||||
|
ceiling_ceil
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5. Release direction & monotonicity.
|
/// 5. Release direction & monotonicity.
|
||||||
@@ -247,13 +270,23 @@ mod tests {
|
|||||||
|
|
||||||
// Output should be monotonic (non-decreasing)
|
// Output should be monotonic (non-decreasing)
|
||||||
for i in 1..out.len() {
|
for i in 1..out.len() {
|
||||||
assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]);
|
assert!(
|
||||||
|
out[i] >= out[i - 1],
|
||||||
|
"output must be monotonic; index {} was {}, index {} was {}",
|
||||||
|
i - 1,
|
||||||
|
out[i - 1],
|
||||||
|
i,
|
||||||
|
out[i]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The end sample should be closer to the original input than the start sample
|
// The end sample should be closer to the original input than the start sample
|
||||||
let start_diff = (mid_val as i16 - out[0]).abs();
|
let start_diff = (mid_val as i16 - out[0]).abs();
|
||||||
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
|
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
|
||||||
assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}");
|
assert!(
|
||||||
|
end_diff < start_diff,
|
||||||
|
"end diff {end_diff} should be smaller than start diff {start_diff}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 6. Release is gradual, not instantaneous.
|
/// 6. Release is gradual, not instantaneous.
|
||||||
@@ -265,7 +298,11 @@ mod tests {
|
|||||||
|
|
||||||
// Immediately follow with a sub-ceiling sample
|
// Immediately follow with a sub-ceiling sample
|
||||||
let out = lim.process(&[10_000i32], 1.0);
|
let out = lim.process(&[10_000i32], 1.0);
|
||||||
assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]);
|
assert!(
|
||||||
|
out[0] < 10_000,
|
||||||
|
"first quiet sample should still be attenuated (got {})",
|
||||||
|
out[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 7. State carries across process calls.
|
/// 7. State carries across process calls.
|
||||||
@@ -287,7 +324,10 @@ mod tests {
|
|||||||
let mut out_split = out_split1;
|
let mut out_split = out_split1;
|
||||||
out_split.extend(&out_split2);
|
out_split.extend(&out_split2);
|
||||||
|
|
||||||
assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call");
|
assert_eq!(
|
||||||
|
out_single, out_split,
|
||||||
|
"splitting process calls must produce identical output to a single call"
|
||||||
|
);
|
||||||
|
|
||||||
// Test 2: Pre-loaded limiter vs fresh limiter on the same input
|
// Test 2: Pre-loaded limiter vs fresh limiter on the same input
|
||||||
let mut lim_preloaded = SoftLimiter::new(SR);
|
let mut lim_preloaded = SoftLimiter::new(SR);
|
||||||
@@ -299,8 +339,16 @@ mod tests {
|
|||||||
let out_preloaded = lim_preloaded.process(&test_input, 1.0);
|
let out_preloaded = lim_preloaded.process(&test_input, 1.0);
|
||||||
let out_fresh = lim_fresh.process(&test_input, 1.0);
|
let out_fresh = lim_fresh.process(&test_input, 1.0);
|
||||||
|
|
||||||
assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ");
|
assert_ne!(
|
||||||
assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]);
|
out_preloaded, out_fresh,
|
||||||
|
"pre-loaded and fresh limiter outputs should differ"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out_preloaded[0] < out_fresh[0],
|
||||||
|
"pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}",
|
||||||
|
out_preloaded[0],
|
||||||
|
out_fresh[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 8. Empty input.
|
/// 8. Empty input.
|
||||||
@@ -320,7 +368,10 @@ mod tests {
|
|||||||
// Gain 0.0
|
// Gain 0.0
|
||||||
let out_zero = lim.process(&input, 0.0);
|
let out_zero = lim.process(&input, 0.0);
|
||||||
assert_eq!(out_zero.len(), input.len());
|
assert_eq!(out_zero.len(), input.len());
|
||||||
assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros");
|
assert!(
|
||||||
|
out_zero.iter().all(|&s| s == 0),
|
||||||
|
"0.0 gain should result in all zeros"
|
||||||
|
);
|
||||||
|
|
||||||
// Gain 1.0
|
// Gain 1.0
|
||||||
let out_unity = lim.process(&input, 1.0);
|
let out_unity = lim.process(&input, 1.0);
|
||||||
@@ -354,6 +405,9 @@ mod tests {
|
|||||||
|
|
||||||
let out = lim.process(&input, 1.0);
|
let out = lim.process(&input, 1.0);
|
||||||
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
|
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
|
||||||
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain");
|
assert_eq!(
|
||||||
|
out, expected,
|
||||||
|
"below ceiling input must be bit-exact at unity gain"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::mpsc::{Receiver, Sender};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||||
@@ -35,7 +35,11 @@ pub enum AudioError {
|
|||||||
pub trait AudioBackend: Send + Sync {
|
pub trait AudioBackend: Send + Sync {
|
||||||
/// Starts capturing raw PCM audio from the input device (microphone),
|
/// Starts capturing raw PCM audio from the input device (microphone),
|
||||||
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
|
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
|
||||||
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>;
|
fn start_capture(
|
||||||
|
&self,
|
||||||
|
tx: Sender<Vec<i16>>,
|
||||||
|
target_node: Option<String>,
|
||||||
|
) -> Result<(), AudioError>;
|
||||||
|
|
||||||
/// Starts playing back raw PCM audio to the output device (speaker),
|
/// Starts playing back raw PCM audio to the output device (speaker),
|
||||||
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
||||||
@@ -65,16 +69,16 @@ pub mod pan;
|
|||||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||||
// backend wires it in.
|
// backend wires it in.
|
||||||
pub mod resample;
|
#[cfg(windows)]
|
||||||
|
pub mod cpal_impl;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod echo_cancel;
|
pub mod echo_cancel;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
#[cfg(windows)]
|
|
||||||
pub mod cpal_impl;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pw_cli;
|
pub mod pw_cli;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
pub mod resample;
|
||||||
|
|
||||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||||
/// identifier the backend uses to request the device (`target_node`);
|
/// identifier the backend uses to request the device (`target_node`);
|
||||||
@@ -96,10 +100,10 @@ impl std::fmt::Display for AudioDevice {
|
|||||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub use pw_cli::enumerate_audio_devices;
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use cpal_impl::enumerate_audio_devices;
|
pub use cpal_impl::enumerate_audio_devices;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use pw_cli::enumerate_audio_devices;
|
||||||
|
|
||||||
/// The audio backend implementation for the current platform.
|
/// The audio backend implementation for the current platform.
|
||||||
///
|
///
|
||||||
|
|||||||
+461
-91
@@ -12,9 +12,11 @@
|
|||||||
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
|
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
|
||||||
//! networking, no realtime work. The mixer (a non-RT task) drives it.
|
//! 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::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use iroh::EndpointId;
|
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
|
/// 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.
|
/// long-running call can't trigger a single multi-hundred-MB allocation.
|
||||||
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
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
|
/// 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
|
/// 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
|
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
|
||||||
/// happen — Opus frames are uniform), zero-padded if shorter.
|
/// happen — Opus frames are uniform), zero-padded if shorter.
|
||||||
fn fit(frame: &[i16], n: usize) -> Vec<i16> {
|
fn fit(frame: &[i16], n: usize) -> Vec<i16> {
|
||||||
@@ -108,7 +77,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
|
|||||||
let clean = crate::sanitize::sanitize_name(name);
|
let clean = crate::sanitize::sanitize_name(name);
|
||||||
let mut slug: String = clean
|
let mut slug: String = clean
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
c.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// Collapse runs of '-' and trim them off the ends.
|
// Collapse runs of '-' and trim them off the ends.
|
||||||
while slug.contains("--") {
|
while slug.contains("--") {
|
||||||
@@ -120,41 +95,210 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
|
|||||||
format!("{slug}-{short}.wav")
|
format!("{slug}-{short}.wav")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live multitrack recording: per-peer stems + your mic, plus an optional
|
#[derive(Default)]
|
||||||
/// mixed track, all under one session directory and clocked together.
|
struct PendingCycle {
|
||||||
pub struct MultitrackRecorder {
|
new_peers: Vec<NewPeer>,
|
||||||
dir: PathBuf,
|
peer_frames: HashMap<EndpointId, Vec<i16>>,
|
||||||
frame_samples: usize,
|
mix_frame: Option<Vec<i16>>,
|
||||||
/// 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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultitrackRecorder {
|
struct NewPeer {
|
||||||
/// Create a recording in `dir` (which must already exist). `with_mix` adds
|
id: EndpointId,
|
||||||
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
|
filename: String,
|
||||||
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
|
}
|
||||||
|
|
||||||
|
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 mic = WavWriter::new(&dir.join("me.wav"))?;
|
||||||
let mix = if with_mix {
|
let mix = if with_mix {
|
||||||
Some(Track::create(&dir.join("mix.wav"))?)
|
Some(WavWriter::new(&dir.join("mix.wav"))?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
dir: dir.to_path_buf(),
|
dir: dir.to_path_buf(),
|
||||||
frame_samples,
|
frame_samples,
|
||||||
cycles: 0,
|
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
mic,
|
mic,
|
||||||
mic_fifo: VecDeque::new(),
|
|
||||||
mix,
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +311,14 @@ impl MultitrackRecorder {
|
|||||||
/// so it aligns with the others. Idempotent: a peer already tracked is left
|
/// so it aligns with the others. Idempotent: a peer already tracked is left
|
||||||
/// as-is (re-announce / name change doesn't restart their file).
|
/// as-is (re-announce / name change doesn't restart their file).
|
||||||
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut track = Track::create(&self.dir.join(track_filename(name, &id)))?;
|
self.known_peers.insert(id);
|
||||||
track.write_silence(self.cycles as usize * self.frame_samples)?;
|
self.pending.new_peers.push(NewPeer {
|
||||||
self.peers.insert(id, track);
|
id,
|
||||||
|
filename: track_filename(name, &id),
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +326,13 @@ impl MultitrackRecorder {
|
|||||||
/// registered yet (write raced ahead of the join event), auto-register it
|
/// registered yet (write raced ahead of the join event), auto-register it
|
||||||
/// with an id-only name so no audio is dropped.
|
/// with an id-only name so no audio is dropped.
|
||||||
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
|
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, "")?;
|
self.add_peer(id, "")?;
|
||||||
}
|
}
|
||||||
let fs = self.frame_samples;
|
self.pending
|
||||||
self.peers.get_mut(&id).unwrap().write_frame(frame, fs)
|
.peer_frames
|
||||||
|
.insert(id, fit(frame, self.frame_samples));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buffer a frame of your transmitted mic audio (called from the capture
|
/// Buffer a frame of your transmitted mic audio (called from the capture
|
||||||
@@ -209,9 +357,8 @@ impl MultitrackRecorder {
|
|||||||
/// Record the finished mixed-bus frame for the current cycle (no-op in
|
/// Record the finished mixed-bus frame for the current cycle (no-op in
|
||||||
/// stems-only mode).
|
/// stems-only mode).
|
||||||
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
|
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
|
||||||
let fs = self.frame_samples;
|
if self.with_mix {
|
||||||
if let Some(mix) = self.mix.as_mut() {
|
self.pending.mix_frame = Some(fit(frame, self.frame_samples));
|
||||||
mix.write_frame(frame, fs)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -224,28 +371,63 @@ impl MultitrackRecorder {
|
|||||||
// Mic: always one frame per cycle, drained from the FIFO (silence on
|
// Mic: always one frame per cycle, drained from the FIFO (silence on
|
||||||
// underrun), so it tracks the cycle clock like the peer stems.
|
// underrun), so it tracks the cycle clock like the peer stems.
|
||||||
let mic_frame = self.drain_mic(fs);
|
let mic_frame = self.drain_mic(fs);
|
||||||
self.mic.write_samples(&mic_frame)?;
|
let mut pending = std::mem::take(&mut self.pending);
|
||||||
// Peers + the optional mix track: pad any not written this cycle.
|
pending.new_peers.sort_by(|a, b| {
|
||||||
for track in self.peers.values_mut().chain(self.mix.as_mut()) {
|
a.filename
|
||||||
if !track.written_this_cycle {
|
.cmp(&b.filename)
|
||||||
track.write_silence(fs)?;
|
.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.
|
/// Finalize every track's WAV header. Consumes the recorder.
|
||||||
pub fn finalize(self) -> io::Result<()> {
|
pub fn finalize(self) -> io::Result<()> {
|
||||||
self.mic.finalize()?;
|
let Self {
|
||||||
if let Some(mix) = self.mix {
|
dir: _,
|
||||||
mix.writer.finalize()?;
|
frame_samples: _,
|
||||||
}
|
known_peers: _,
|
||||||
for (_, track) in self.peers {
|
mic_fifo: _,
|
||||||
track.writer.finalize()?;
|
with_mix: _,
|
||||||
}
|
batch_tx,
|
||||||
Ok(())
|
writer_thread,
|
||||||
|
dropped_cycles: _,
|
||||||
|
pending: _,
|
||||||
|
} = self;
|
||||||
|
drop(batch_tx);
|
||||||
|
writer_thread
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +453,51 @@ mod tests {
|
|||||||
d
|
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]
|
#[test]
|
||||||
fn fit_pads_and_truncates() {
|
fn fit_pads_and_truncates() {
|
||||||
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
|
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
|
||||||
@@ -284,7 +511,10 @@ mod tests {
|
|||||||
let short: String = id.to_string().chars().take(8).collect();
|
let short: String = id.to_string().chars().take(8).collect();
|
||||||
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
|
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
|
||||||
// Spaces / punctuation collapse to single dashes, trimmed.
|
// Spaces / punctuation collapse to single dashes, trimmed.
|
||||||
assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav"));
|
assert_eq!(
|
||||||
|
track_filename(" Bob the Builder! ", &id),
|
||||||
|
format!("bob-the-builder-{short}.wav")
|
||||||
|
);
|
||||||
// A name that sanitizes/slugs to nothing falls back to "peer".
|
// A name that sanitizes/slugs to nothing falls back to "peer".
|
||||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||||
}
|
}
|
||||||
@@ -302,6 +532,110 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn all_tracks_equal_length_after_n_cycles() {
|
fn all_tracks_equal_length_after_n_cycles() {
|
||||||
let dir = tmpdir("equal");
|
let dir = tmpdir("equal");
|
||||||
@@ -327,10 +661,18 @@ mod tests {
|
|||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
|
|
||||||
let expected = 3 * frame;
|
let expected = 3 * frame;
|
||||||
assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length");
|
assert_eq!(
|
||||||
|
wav_samples(&dir.join("me.wav")),
|
||||||
|
expected,
|
||||||
|
"mic padded to full length"
|
||||||
|
);
|
||||||
assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
|
assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
|
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length");
|
assert_eq!(
|
||||||
|
wav_samples(&dir.join(track_filename("p2", &p2))),
|
||||||
|
expected,
|
||||||
|
"silent peer still full length"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -357,8 +699,14 @@ mod tests {
|
|||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
|
|
||||||
// Both tracks are the full 5 cycles long (late one was back-padded).
|
// Both tracks are the full 5 cycles long (late one was back-padded).
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame);
|
assert_eq!(
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame);
|
wav_samples(&dir.join(track_filename("early", &early))),
|
||||||
|
5 * frame
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wav_samples(&dir.join(track_filename("late", &late))),
|
||||||
|
5 * frame
|
||||||
|
);
|
||||||
|
|
||||||
// The late track's first 2 cycles are silence, then the real audio.
|
// The late track's first 2 cycles are silence, then the real audio.
|
||||||
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
|
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
|
||||||
@@ -378,6 +726,28 @@ mod tests {
|
|||||||
rec.end_cycle().unwrap();
|
rec.end_cycle().unwrap();
|
||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
assert!(dir.join("me.wav").exists());
|
assert!(dir.join("me.wav").exists());
|
||||||
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode");
|
assert!(
|
||||||
|
!dir.join("mix.wav").exists(),
|
||||||
|
"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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
|
|||||||
/// still following the same equal-power curve as a peer is moved away from center.
|
/// still following the same equal-power curve as a peer is moved away from center.
|
||||||
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
||||||
let (left, right) = pan_gains(pan);
|
let (left, right) = pan_gains(pan);
|
||||||
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
|
(
|
||||||
|
left * std::f32::consts::SQRT_2,
|
||||||
|
right * std::f32::consts::SQRT_2,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -36,8 +39,14 @@ mod tests {
|
|||||||
fn hard_left_and_right_are_endpoints() {
|
fn hard_left_and_right_are_endpoints() {
|
||||||
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
||||||
let (l, r) = pan_gains(1.0);
|
let (l, r) = pan_gains(1.0);
|
||||||
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
|
assert!(
|
||||||
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
|
l.abs() < EPS,
|
||||||
|
"left at hard-right should be zero-ish, got {l}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(r - 1.0).abs() < EPS,
|
||||||
|
"right at hard-right should be one, got {r}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -55,8 +64,14 @@ mod tests {
|
|||||||
let mut prev_r = f32::NEG_INFINITY;
|
let mut prev_r = f32::NEG_INFINITY;
|
||||||
for pan in pans {
|
for pan in pans {
|
||||||
let (l, r) = pan_gains(pan);
|
let (l, r) = pan_gains(pan);
|
||||||
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
|
assert!(
|
||||||
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
|
l <= prev_l + EPS,
|
||||||
|
"left gain must not rise as pan moves right"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r >= prev_r - EPS,
|
||||||
|
"right gain must not fall as pan moves right"
|
||||||
|
);
|
||||||
prev_l = l;
|
prev_l = l;
|
||||||
prev_r = r;
|
prev_r = r;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-44
@@ -1,13 +1,16 @@
|
|||||||
use crate::audio::{AudioBackend, AudioError};
|
use crate::audio::{AudioBackend, AudioError};
|
||||||
use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
|
||||||
use std::thread::{self, JoinHandle};
|
|
||||||
use std::time::Duration;
|
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
|
use ringbuf::{
|
||||||
|
HeapRb,
|
||||||
|
traits::{Consumer, Producer, Split},
|
||||||
|
};
|
||||||
use spa::pod::Pod;
|
use spa::pod::Pod;
|
||||||
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct PipeWireBackend {
|
pub struct PipeWireBackend {
|
||||||
capture_state: Mutex<Option<CaptureState>>,
|
capture_state: Mutex<Option<CaptureState>>,
|
||||||
@@ -41,7 +44,11 @@ impl PipeWireBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioBackend for PipeWireBackend {
|
impl AudioBackend for PipeWireBackend {
|
||||||
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
|
fn start_capture(
|
||||||
|
&self,
|
||||||
|
tx: Sender<Vec<i16>>,
|
||||||
|
target_node: Option<String>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
let mut capture_guard = self.capture_state.lock().unwrap();
|
let mut capture_guard = self.capture_state.lock().unwrap();
|
||||||
if capture_guard.is_some() {
|
if capture_guard.is_some() {
|
||||||
return Err(AudioError::Stream("Capture already started".to_string()));
|
return Err(AudioError::Stream("Capture already started".to_string()));
|
||||||
@@ -108,12 +115,17 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
|
fn run_capture(
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
cmd_rx: pw::channel::Receiver<()>,
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
tx: Sender<Vec<i16>>,
|
||||||
|
target_node: Option<String>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let mainloop =
|
||||||
|
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
let context = pw::context::ContextRc::new(&mainloop, None)
|
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
let core = context.connect_rc(None)
|
let core = context
|
||||||
|
.connect_rc(None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
|
||||||
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
|
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
|
||||||
@@ -181,15 +193,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
|
|
||||||
let mut params = [Pod::from_bytes(&values).unwrap()];
|
let mut params = [Pod::from_bytes(&values).unwrap()];
|
||||||
|
|
||||||
stream.connect(
|
stream
|
||||||
spa::utils::Direction::Input,
|
.connect(
|
||||||
None,
|
spa::utils::Direction::Input,
|
||||||
pw::stream::StreamFlags::AUTOCONNECT
|
None,
|
||||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
| pw::stream::StreamFlags::RT_PROCESS,
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
&mut params,
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
)
|
&mut params,
|
||||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
)
|
||||||
|
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
|
||||||
// Spawn the worker thread to pop from consumer and send Vec<i16> frames
|
// Spawn the worker thread to pop from consumer and send Vec<i16> frames
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
@@ -257,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
|
|||||||
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
|
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
|
||||||
/// join the worker promptly instead of hanging on a parked blocking `recv()`
|
/// join the worker promptly instead of hanging on a parked blocking `recv()`
|
||||||
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
|
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
|
||||||
fn drain_loop(
|
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
|
||||||
rx: &Receiver<Vec<i16>>,
|
|
||||||
running: &AtomicBool,
|
|
||||||
mut on_frame: impl FnMut(Vec<i16>),
|
|
||||||
) {
|
|
||||||
while running.load(Ordering::Relaxed) {
|
while running.load(Ordering::Relaxed) {
|
||||||
match rx.recv_timeout(WORKER_POLL) {
|
match rx.recv_timeout(WORKER_POLL) {
|
||||||
Ok(frame) => on_frame(frame),
|
Ok(frame) => on_frame(frame),
|
||||||
@@ -293,7 +302,11 @@ fn publish_frame<P: Producer<Item = i16>>(
|
|||||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||||
const FALLBACK_FRAMES: usize = 1024;
|
const FALLBACK_FRAMES: usize = 1024;
|
||||||
let want = if requested > 0 { requested } else { FALLBACK_FRAMES };
|
let want = if requested > 0 {
|
||||||
|
requested
|
||||||
|
} else {
|
||||||
|
FALLBACK_FRAMES
|
||||||
|
};
|
||||||
want.min(mapped_frames)
|
want.min(mapped_frames)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,11 +316,12 @@ fn run_playback(
|
|||||||
target_node: Option<String>,
|
target_node: Option<String>,
|
||||||
fill_gauge: Arc<AtomicUsize>,
|
fill_gauge: Arc<AtomicUsize>,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
let mainloop =
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
let context = pw::context::ContextRc::new(&mainloop, None)
|
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
let core = context.connect_rc(None)
|
let core = context
|
||||||
|
.connect_rc(None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
|
||||||
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||||
@@ -428,7 +442,9 @@ fn run_playback(
|
|||||||
}
|
}
|
||||||
if starved > 0 {
|
if starved > 0 {
|
||||||
// One wait-free atomic add per quantum — RT-safe.
|
// One wait-free atomic add per quantum — RT-safe.
|
||||||
user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed);
|
user_data
|
||||||
|
.underrun_samples
|
||||||
|
.fetch_add(starved, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
// Decrement the exact occupancy counter by the samples we
|
// Decrement the exact occupancy counter by the samples we
|
||||||
// actually pulled (excluding underruns, which removed
|
// actually pulled (excluding underruns, which removed
|
||||||
@@ -493,7 +509,11 @@ fn run_playback(
|
|||||||
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||||
pw::spa::utils::Choice(
|
pw::spa::utils::Choice(
|
||||||
pw::spa::utils::ChoiceFlags::empty(),
|
pw::spa::utils::ChoiceFlags::empty(),
|
||||||
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 },
|
pw::spa::utils::ChoiceEnum::Range {
|
||||||
|
default: 8,
|
||||||
|
min: 2,
|
||||||
|
max: 64,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
@@ -524,15 +544,16 @@ fn run_playback(
|
|||||||
Pod::from_bytes(&buffers_values).unwrap(),
|
Pod::from_bytes(&buffers_values).unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
stream.connect(
|
stream
|
||||||
spa::utils::Direction::Output,
|
.connect(
|
||||||
None,
|
spa::utils::Direction::Output,
|
||||||
pw::stream::StreamFlags::AUTOCONNECT
|
None,
|
||||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
| pw::stream::StreamFlags::RT_PROCESS,
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
&mut params,
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
)
|
&mut params,
|
||||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
)
|
||||||
|
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
|
||||||
// Spawn a worker thread to read from rx and push to producer
|
// Spawn a worker thread to read from rx and push to producer
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
@@ -607,7 +628,10 @@ fn run_playback(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||||
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
use ringbuf::{
|
||||||
|
HeapRb,
|
||||||
|
traits::{Consumer, Producer, Split},
|
||||||
|
};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -647,9 +671,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn capture_size_larger_than_mapping_is_clamped() {
|
fn capture_size_larger_than_mapping_is_clamped() {
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
|
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
|
||||||
samples.push(sample)
|
|
||||||
});
|
|
||||||
assert_eq!(samples, vec![1, 2]);
|
assert_eq!(samples, vec![1, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
|||||||
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
|
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
|
||||||
/// node, then resets the accumulators for the next block. Non-audio or
|
/// node, then resets the accumulators for the next block. Non-audio or
|
||||||
/// incomplete blocks are dropped (but still reset).
|
/// incomplete blocks are dropped (but still reset).
|
||||||
fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec<AudioDevice>) {
|
fn push_device(
|
||||||
|
name: &mut String,
|
||||||
|
desc: &mut String,
|
||||||
|
class: &mut String,
|
||||||
|
out: &mut Vec<AudioDevice>,
|
||||||
|
) {
|
||||||
if !name.is_empty() && class.starts_with("Audio/") {
|
if !name.is_empty() && class.starts_with("Audio/") {
|
||||||
out.push(AudioDevice {
|
out.push(AudioDevice {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
description: if desc.is_empty() { name.clone() } else { desc.clone() },
|
description: if desc.is_empty() {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
desc.clone()
|
||||||
|
},
|
||||||
is_input: class == "Audio/Source",
|
is_input: class == "Audio/Source",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
|
|||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("id ") {
|
if line.starts_with("id ") {
|
||||||
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
|
push_device(
|
||||||
|
&mut current_name,
|
||||||
|
&mut current_desc,
|
||||||
|
&mut current_class,
|
||||||
|
&mut devices,
|
||||||
|
);
|
||||||
} else if let Some(val) = line.strip_prefix("node.name = \"") {
|
} else if let Some(val) = line.strip_prefix("node.name = \"") {
|
||||||
current_name = val.trim_end_matches('"').to_string();
|
current_name = val.trim_end_matches('"').to_string();
|
||||||
} else if let Some(val) = line.strip_prefix("node.description = \"") {
|
} else if let Some(val) = line.strip_prefix("node.description = \"") {
|
||||||
@@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
|
|||||||
current_class = val.trim_end_matches('"').to_string();
|
current_class = val.trim_end_matches('"').to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
|
push_device(
|
||||||
|
&mut current_name,
|
||||||
|
&mut current_desc,
|
||||||
|
&mut current_class,
|
||||||
|
&mut devices,
|
||||||
|
);
|
||||||
|
|
||||||
devices.sort_by(|a, b| a.description.cmp(&b.description));
|
devices.sort_by(|a, b| a.description.cmp(&b.description));
|
||||||
devices
|
devices
|
||||||
@@ -108,8 +127,14 @@ mod tests {
|
|||||||
fn source_is_input_sink_is_output() {
|
fn source_is_input_sink_is_output() {
|
||||||
let devices = parse_pw_nodes(SAMPLE_NODES);
|
let devices = parse_pw_nodes(SAMPLE_NODES);
|
||||||
// Find devices by name or description to verify is_input
|
// Find devices by name or description to verify is_input
|
||||||
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap();
|
let mic = devices
|
||||||
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap();
|
.iter()
|
||||||
|
.find(|d| d.name == "alsa_input.builtin")
|
||||||
|
.unwrap();
|
||||||
|
let speakers = devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == "alsa_output.builtin")
|
||||||
|
.unwrap();
|
||||||
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
|
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
|
||||||
|
|
||||||
assert!(mic.is_input);
|
assert!(mic.is_input);
|
||||||
|
|||||||
+151
-43
@@ -2,21 +2,26 @@
|
|||||||
//!
|
//!
|
||||||
//! Records the **full call as you experienced it**: the mixed incoming audio
|
//! Records the **full call as you experienced it**: the mixed incoming audio
|
||||||
//! (everyone you hear) summed with your own transmitted mic, into a single mono
|
//! (everyone you hear) summed with your own transmitted mic, into a single mono
|
||||||
//! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`]
|
//! WAV. Mixing/enqueue is driven by the playout mixer (one
|
||||||
//! per produced 20ms frame, paced by the hardware clock); your mic arrives
|
//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
|
||||||
//! separately from the capture thread via [`Recorder::push_mic`] and is buffered
|
//! clock), while disk writes happen on a dedicated writer thread; your mic
|
||||||
//! in a small FIFO so the two independently-clocked streams stay roughly aligned.
|
//! arrives separately from the capture thread via [`Recorder::push_mic`] and is
|
||||||
|
//! buffered in a small FIFO so the two independently-clocked streams stay
|
||||||
|
//! roughly aligned.
|
||||||
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
|
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
|
||||||
//! between your voice and the recording is bounded) — harmless for a voice
|
//! between your voice and the recording is bounded) — harmless for a voice
|
||||||
//! recording, no realtime crackle concern.
|
//! recording, no realtime crackle concern.
|
||||||
//!
|
//!
|
||||||
//! No external crates: the WAV writer emits the 44-byte canonical header itself
|
//! No external crates: the WAV writer emits the 44-byte canonical header itself
|
||||||
//! and patches the two size fields on [`Recorder::finalize`].
|
//! and patches the two size fields on the writer thread during
|
||||||
|
//! [`Recorder::finalize`].
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Seek, SeekFrom, Write};
|
use std::io::{self, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
|
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
|
||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
@@ -25,6 +30,8 @@ const CHANNELS: u16 = 1;
|
|||||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||||
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
||||||
|
const WRITER_QUEUE_FRAMES: usize = 256;
|
||||||
|
const DROP_LOG_INTERVAL_FRAMES: u64 = 256;
|
||||||
|
|
||||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||||
@@ -118,13 +125,15 @@ impl WavWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your
|
/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
|
||||||
/// transmitted mic with the playout mixer's incoming-mix frames.
|
/// your transmitted mic with the playout mixer's incoming-mix frames.
|
||||||
pub struct Recorder {
|
pub struct Recorder {
|
||||||
writer: WavWriter,
|
frame_tx: SyncSender<Vec<i16>>,
|
||||||
|
writer_thread: JoinHandle<io::Result<()>>,
|
||||||
/// Your transmitted mic samples, awaiting alignment with the next mix frame.
|
/// Your transmitted mic samples, awaiting alignment with the next mix frame.
|
||||||
mic_fifo: VecDeque<i16>,
|
mic_fifo: VecDeque<i16>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
dropped_frames: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Recorder {
|
impl Recorder {
|
||||||
@@ -142,10 +151,15 @@ impl Recorder {
|
|||||||
let path = dir.join(name);
|
let path = dir.join(name);
|
||||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
|
let writer = WavWriter::from_file(file)?;
|
||||||
|
let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
|
||||||
|
let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
writer: WavWriter::from_file(file)?,
|
frame_tx,
|
||||||
|
writer_thread,
|
||||||
mic_fifo: VecDeque::new(),
|
mic_fifo: VecDeque::new(),
|
||||||
path,
|
path,
|
||||||
|
dropped_frames: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
@@ -179,21 +193,73 @@ impl Recorder {
|
|||||||
/// treated as silence (you weren't transmitting), so quiet stretches record
|
/// treated as silence (you weren't transmitting), so quiet stretches record
|
||||||
/// the incoming mix alone.
|
/// the incoming mix alone.
|
||||||
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
|
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
|
||||||
let mut out = Vec::with_capacity(mixed.len());
|
let out = mix_with_mic(mixed, &mut self.mic_fifo);
|
||||||
for &m in mixed {
|
match self.frame_tx.try_send(out) {
|
||||||
let mic = self.mic_fifo.pop_front().unwrap_or(0);
|
Ok(()) => Ok(()),
|
||||||
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
|
Err(TrySendError::Full(_)) => {
|
||||||
out.push(sum as i16);
|
self.dropped_frames = self.dropped_frames.saturating_add(1);
|
||||||
|
if self.dropped_frames == 1
|
||||||
|
|| self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES)
|
||||||
|
{
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"recording: writer queue full; dropped {} frame(s)",
|
||||||
|
self.dropped_frames
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"recording writer thread stopped",
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
self.writer.write_samples(&out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finish the file, patching its size fields. Consumes the recorder.
|
/// Finish the file, patching its size fields. Consumes the recorder.
|
||||||
pub fn finalize(self) -> io::Result<()> {
|
pub fn finalize(self) -> io::Result<()> {
|
||||||
self.writer.finalize()
|
let Self {
|
||||||
|
frame_tx,
|
||||||
|
writer_thread,
|
||||||
|
mic_fifo: _,
|
||||||
|
path: _,
|
||||||
|
dropped_frames: _,
|
||||||
|
} = self;
|
||||||
|
drop(frame_tx);
|
||||||
|
writer_thread
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver<Vec<i16>>) -> io::Result<()> {
|
||||||
|
let mut first_write_error = None;
|
||||||
|
|
||||||
|
for frame in frame_rx {
|
||||||
|
if first_write_error.is_none()
|
||||||
|
&& let Err(e) = writer.write_samples(&frame)
|
||||||
|
{
|
||||||
|
first_write_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalize_result = writer.finalize();
|
||||||
|
if let Some(e) = first_write_error {
|
||||||
|
Err(e)
|
||||||
|
} else {
|
||||||
|
finalize_result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque<i16>) -> Vec<i16> {
|
||||||
|
let mut out = Vec::with_capacity(mixed.len());
|
||||||
|
for &m in mixed {
|
||||||
|
let mic = mic_fifo.pop_front().unwrap_or(0);
|
||||||
|
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
|
||||||
|
out.push(sum as i16);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Civil date (year, month, day) from a count of days since the Unix epoch.
|
/// Civil date (year, month, day) from a count of days since the Unix epoch.
|
||||||
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
|
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
|
||||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
@@ -222,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||||
|
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_wav_samples(path: &Path) -> (Vec<u8>, Vec<i16>) {
|
||||||
|
let bytes = std::fs::read(path).unwrap();
|
||||||
|
let samples = bytes[44..]
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
|
||||||
|
.collect();
|
||||||
|
(bytes, samples)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timestamp_filename_is_utc_and_padded() {
|
fn timestamp_filename_is_utc_and_padded() {
|
||||||
@@ -236,10 +319,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_second_recordings_get_unique_files_without_truncation() {
|
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = unique_temp_dir("peerspeak-collision");
|
||||||
"peerspeak-collision-{}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
@@ -258,6 +338,40 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
|
||||||
|
let dir = unique_temp_dir("peerspeak-recorder-thread");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
|
||||||
|
let path = recorder.path().to_path_buf();
|
||||||
|
|
||||||
|
recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
|
||||||
|
recorder.write_frame(&[10, 20, -32700]).unwrap();
|
||||||
|
recorder.push_mic(&[300, -300]);
|
||||||
|
recorder
|
||||||
|
.write_frame(&[0, 1000, i16::MAX, i16::MIN])
|
||||||
|
.unwrap();
|
||||||
|
recorder.finalize().unwrap();
|
||||||
|
|
||||||
|
let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
|
||||||
|
let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
|
||||||
|
let (bytes, samples) = read_wav_samples(&path);
|
||||||
|
|
||||||
|
assert_eq!(&bytes[0..4], b"RIFF");
|
||||||
|
assert_eq!(&bytes[8..12], b"WAVE");
|
||||||
|
assert_eq!(&bytes[36..40], b"data");
|
||||||
|
let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||||
|
let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
|
||||||
|
assert_eq!(data, expected_data_bytes);
|
||||||
|
assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
|
||||||
|
assert_eq!(bytes.len(), 44 + expected.len() * 2);
|
||||||
|
assert_eq!(samples, expected);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_round_trips_sizes() {
|
fn wav_header_round_trips_sizes() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
@@ -300,38 +414,32 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_is_summed_with_mix_when_present() {
|
fn mic_is_summed_with_mix_when_present() {
|
||||||
let dir = std::env::temp_dir();
|
let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);
|
||||||
let mut r = Recorder {
|
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
|
let first = mix_with_mic(&[10, 20], &mut mic_fifo);
|
||||||
.unwrap(),
|
assert_eq!(first, vec![1010, 2020]);
|
||||||
mic_fifo: VecDeque::new(),
|
assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||||
path: PathBuf::new(),
|
|
||||||
};
|
let second = mix_with_mic(&[0, 0], &mut mic_fifo);
|
||||||
r.push_mic(&[1000, 2000, 3000]);
|
assert_eq!(second, vec![3000, 0]);
|
||||||
// write_frame pops mic per-sample and sums; we can't read the file mid-stream,
|
|
||||||
// so assert the FIFO drains exactly by frame length.
|
|
||||||
r.write_frame(&[10, 20]).unwrap();
|
|
||||||
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
|
||||||
r.write_frame(&[0, 0]).unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.mic_fifo.len(),
|
mic_fifo.len(),
|
||||||
0,
|
0,
|
||||||
"remaining mic sample consumed; rest is silence"
|
"remaining mic sample consumed; rest is silence"
|
||||||
);
|
);
|
||||||
let _ = r.finalize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_fifo_is_capped() {
|
fn mic_fifo_is_capped() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = unique_temp_dir("peerspeak-cap");
|
||||||
let mut r = Recorder {
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
.unwrap(),
|
|
||||||
mic_fifo: VecDeque::new(),
|
let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
|
||||||
path: PathBuf::new(),
|
|
||||||
};
|
|
||||||
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
|
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
|
||||||
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
|
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
|
||||||
let _ = r.finalize();
|
r.finalize().unwrap();
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ impl StereoPullResampler {
|
|||||||
self.frac -= 1.0;
|
self.frac -= 1.0;
|
||||||
}
|
}
|
||||||
let f = self.frac as f32;
|
let f = self.frac as f32;
|
||||||
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
|
let out = (
|
||||||
|
lerp(self.prev.0, self.cur.0, f),
|
||||||
|
lerp(self.prev.1, self.cur.1, f),
|
||||||
|
);
|
||||||
self.frac += self.step;
|
self.frac += self.step;
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
@@ -273,7 +276,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// At step 2.0 we consume ~2 input frames per output frame.
|
// At step 2.0 we consume ~2 input frames per output frame.
|
||||||
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
|
assert!(
|
||||||
|
idx > emitted,
|
||||||
|
"consumed {idx} input, emitted {emitted} output"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||||
|
|||||||
+12
-3
@@ -206,7 +206,10 @@ pub struct ByteLru<V> {
|
|||||||
impl<V: Clone> ByteLru<V> {
|
impl<V: Clone> ByteLru<V> {
|
||||||
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
||||||
pub fn new(cap: usize) -> Self {
|
pub fn new(cap: usize) -> Self {
|
||||||
Self { cap: cap.max(1), entries: Vec::new() }
|
Self {
|
||||||
|
cap: cap.max(1),
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the cached value for these exact `bytes`, building and inserting it
|
/// Return the cached value for these exact `bytes`, building and inserting it
|
||||||
@@ -349,7 +352,10 @@ mod tests {
|
|||||||
fn preset_png_in_range_and_out_of_range() {
|
fn preset_png_in_range_and_out_of_range() {
|
||||||
// Every declared preset index resolves to embedded bytes.
|
// Every declared preset index resolves to embedded bytes.
|
||||||
for i in 0..PRESET_COUNT {
|
for i in 0..PRESET_COUNT {
|
||||||
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
|
assert!(
|
||||||
|
Avatar::Preset(i).preset_png().is_some(),
|
||||||
|
"preset {i} missing"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Out-of-range index gracefully yields None (→ monogram fallback).
|
// Out-of-range index gracefully yields None (→ monogram fallback).
|
||||||
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
||||||
@@ -406,7 +412,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sanitize_incoming_rejects_junk_and_oversize() {
|
fn sanitize_incoming_rejects_junk_and_oversize() {
|
||||||
// Not valid base64 / not a PNG → downgraded to monogram.
|
// Not valid base64 / not a PNG → downgraded to monogram.
|
||||||
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram);
|
assert_eq!(
|
||||||
|
Avatar::Custom("not base64!!!".into()).sanitize_incoming(),
|
||||||
|
Avatar::Monogram
|
||||||
|
);
|
||||||
// Over the byte cap → downgraded without even decoding.
|
// Over the byte cap → downgraded without even decoding.
|
||||||
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
||||||
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
||||||
|
|||||||
+4
-1
@@ -66,7 +66,10 @@ pub fn game_background_filename(game_id: &str) -> String {
|
|||||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||||
/// per theme since `base` comes from the active palette.
|
/// per theme since `base` comes from the active palette.
|
||||||
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
||||||
Color { a: dim.clamp(0.0, 1.0), ..base }
|
Color {
|
||||||
|
a: dim.clamp(0.0, 1.0),
|
||||||
|
..base
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+58
-12
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
|
|||||||
"pink" => generators::pink_noise(amp, len, seed),
|
"pink" => generators::pink_noise(amp, len, seed),
|
||||||
"impulse" => generators::impulse(amp, len),
|
"impulse" => generators::impulse(amp, len),
|
||||||
"silence" => generators::silence(len),
|
"silence" => generators::silence(len),
|
||||||
other => return Err(format!("unknown kind {other:?} (sine sweep white pink impulse silence)")),
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown kind {other:?} (sine sweep white pink impulse silence)"
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
|
wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
|
||||||
@@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
|
|||||||
/// in which frequency range any residual lives.
|
/// in which frequency range any residual lives.
|
||||||
fn cmd_erle(args: &[String]) -> Result<(), String> {
|
fn cmd_erle(args: &[String]) -> Result<(), String> {
|
||||||
let (positional, flags) = parse_args(args);
|
let (positional, flags) = parse_args(args);
|
||||||
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?;
|
let before = positional
|
||||||
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?;
|
.first()
|
||||||
|
.ok_or("erle needs <before.wav> <after.wav>")?;
|
||||||
|
let after = positional
|
||||||
|
.get(1)
|
||||||
|
.ok_or("erle needs <before.wav> <after.wav>")?;
|
||||||
|
|
||||||
let b = wav::read(Path::new(before))?;
|
let b = wav::read(Path::new(before))?;
|
||||||
let a = wav::read(Path::new(after))?;
|
let a = wav::read(Path::new(after))?;
|
||||||
@@ -239,17 +247,34 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
|
|||||||
1000.0 * tail as f32 / sr as f32,
|
1000.0 * tail as f32 / sr as f32,
|
||||||
metrics::dbfs(atten),
|
metrics::dbfs(atten),
|
||||||
);
|
);
|
||||||
println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" });
|
println!(
|
||||||
|
" filter: {taps} taps, mu {mu}{}",
|
||||||
|
if has_near {
|
||||||
|
" (with near-end / double-talk)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
if has_near {
|
if has_near {
|
||||||
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
|
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
|
||||||
println!(
|
println!(
|
||||||
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
|
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
|
||||||
100.0 * canceller.double_talk_rate(),
|
100.0 * canceller.double_talk_rate(),
|
||||||
if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() },
|
if onset > 0 {
|
||||||
|
format!(", near-end onset {:.1}s", onset as f32 / sr as f32)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic)));
|
println!(
|
||||||
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual)));
|
" mic before: {:.1} dBFS rms",
|
||||||
|
metrics::dbfs(metrics::rms(&mic))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" residual echo after: {:.1} dBFS rms",
|
||||||
|
metrics::dbfs(metrics::rms(&residual))
|
||||||
|
);
|
||||||
println!(" ERLE broadband: {broadband:+.1} dB");
|
println!(" ERLE broadband: {broadband:+.1} dB");
|
||||||
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
|
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
|
||||||
|
|
||||||
@@ -278,9 +303,21 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
if flags.present("show") {
|
if flags.present("show") {
|
||||||
println!("\n--- mic (echo present) ---");
|
println!("\n--- mic (echo present) ---");
|
||||||
print!("{}", render::render(&stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default()));
|
print!(
|
||||||
|
"{}",
|
||||||
|
render::render(
|
||||||
|
&stft::analyze(&mic, sr, 2048, 512),
|
||||||
|
&render::RenderOpts::default()
|
||||||
|
)
|
||||||
|
);
|
||||||
println!("\n--- cleaned (post-AEC) ---");
|
println!("\n--- cleaned (post-AEC) ---");
|
||||||
print!("{}", render::render(&stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default()));
|
print!(
|
||||||
|
"{}",
|
||||||
|
render::render(
|
||||||
|
&stft::analyze(&cleaned, sr, 2048, 512),
|
||||||
|
&render::RenderOpts::default()
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -339,13 +376,22 @@ impl Flags {
|
|||||||
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
|
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
|
||||||
}
|
}
|
||||||
fn f32_or(&self, key: &str, default: f32) -> f32 {
|
fn f32_or(&self, key: &str, default: f32) -> f32 {
|
||||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
self.map
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
fn usize_or(&self, key: &str, default: usize) -> usize {
|
fn usize_or(&self, key: &str, default: usize) -> usize {
|
||||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
self.map
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
fn u64_or(&self, key: &str, default: u64) -> u64 {
|
fn u64_or(&self, key: &str, default: u64) -> u64 {
|
||||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
self.map
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -1,9 +1,6 @@
|
|||||||
use peerspeak::network::{
|
|
||||||
gossip::IrohGossipState,
|
|
||||||
RoomState, PeerState,
|
|
||||||
};
|
|
||||||
use iroh::{Endpoint, endpoint::presets};
|
use iroh::{Endpoint, endpoint::presets};
|
||||||
use iroh_gossip::net::Gossip;
|
use iroh_gossip::net::Gossip;
|
||||||
|
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
|
||||||
use tokio::time::{self, Duration};
|
use tokio::time::{self, Duration};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -18,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.address_lookup(lookup_a.clone())
|
.address_lookup(lookup_a.clone())
|
||||||
.bind()
|
.bind()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
endpoint_a.online().await;
|
endpoint_a.online().await;
|
||||||
println!("Node A online. ID: {}", endpoint_a.id());
|
println!("Node A online. ID: {}", endpoint_a.id());
|
||||||
|
|
||||||
@@ -27,7 +24,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a);
|
let room_a = IrohGossipState::new(
|
||||||
|
endpoint_a.clone(),
|
||||||
|
gossip_a.clone(),
|
||||||
|
lookup_a.clone(),
|
||||||
|
secret_a,
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Node B (Client) Setup
|
// 2. Node B (Client) Setup
|
||||||
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
||||||
@@ -46,7 +48,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b);
|
let room_b = IrohGossipState::new(
|
||||||
|
endpoint_b.clone(),
|
||||||
|
gossip_b.clone(),
|
||||||
|
lookup_b.clone(),
|
||||||
|
secret_b,
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Create room on Node A
|
// 3. Create room on Node A
|
||||||
let topic_id = rand::random();
|
let topic_id = rand::random();
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub trait AudioDecoder: Send {
|
|||||||
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
|
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
|
||||||
/// enabling the decoder to perform packet loss concealment (PLC).
|
/// enabling the decoder to perform packet loss concealment (PLC).
|
||||||
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
|
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;
|
pub mod opus_impl;
|
||||||
|
|||||||
+182
-17
@@ -1,5 +1,49 @@
|
|||||||
use crate::codec::{AudioEncoder, AudioDecoder, CodecError};
|
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
|
||||||
use opus::{Encoder, Decoder, Application, Channels};
|
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 {
|
pub struct OpusEncoder {
|
||||||
encoder: Encoder,
|
encoder: Encoder,
|
||||||
@@ -8,11 +52,38 @@ pub struct OpusEncoder {
|
|||||||
impl OpusEncoder {
|
impl OpusEncoder {
|
||||||
/// Creates a new Opus encoder.
|
/// Creates a new Opus encoder.
|
||||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
|
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
|
||||||
pub fn new(sample_rate: u32, channels: Channels, application: Application) -> Result<Self, CodecError> {
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: Channels,
|
||||||
|
application: Application,
|
||||||
|
) -> Result<Self, CodecError> {
|
||||||
let encoder = Encoder::new(sample_rate, channels, application)
|
let encoder = Encoder::new(sample_rate, channels, application)
|
||||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
||||||
Ok(Self { encoder })
|
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 {
|
impl AudioEncoder for OpusEncoder {
|
||||||
@@ -20,9 +91,11 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
// We allocate a buffer for the compressed output.
|
// We allocate a buffer for the compressed output.
|
||||||
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
||||||
let mut compressed = vec![0u8; 4000];
|
let mut compressed = vec![0u8; 4000];
|
||||||
let len = self.encoder.encode(pcm, &mut compressed)
|
let len = self
|
||||||
|
.encoder
|
||||||
|
.encode(pcm, &mut compressed)
|
||||||
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
|
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
|
||||||
|
|
||||||
compressed.truncate(len);
|
compressed.truncate(len);
|
||||||
Ok(compressed)
|
Ok(compressed)
|
||||||
}
|
}
|
||||||
@@ -42,10 +115,18 @@ impl OpusDecoder {
|
|||||||
/// Creates a new Opus decoder.
|
/// Creates a new Opus decoder.
|
||||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
||||||
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
|
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
|
||||||
pub fn new(sample_rate: u32, channels: Channels, frame_samples: usize) -> Result<Self, CodecError> {
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: Channels,
|
||||||
|
frame_samples: usize,
|
||||||
|
) -> Result<Self, CodecError> {
|
||||||
let decoder = Decoder::new(sample_rate, channels)
|
let decoder = Decoder::new(sample_rate, channels)
|
||||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
|
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
|
||||||
Ok(Self { decoder, channels, frame_samples })
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
channels,
|
||||||
|
frame_samples,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channels_count(&self) -> usize {
|
fn channels_count(&self) -> usize {
|
||||||
@@ -73,18 +154,72 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoded_per_channel = self.decoder.decode(input, &mut pcm, false)
|
let decoded_per_channel = self
|
||||||
|
.decoder
|
||||||
|
.decode(input, &mut pcm, false)
|
||||||
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
||||||
|
|
||||||
pcm.truncate(decoded_per_channel * channels_count);
|
pcm.truncate(decoded_per_channel * channels_count);
|
||||||
Ok(pcm)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_round_trip() {
|
fn test_round_trip() {
|
||||||
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
@@ -100,7 +235,10 @@ mod tests {
|
|||||||
|
|
||||||
// encode it
|
// encode it
|
||||||
let compressed = encoder.encode(&pcm).unwrap();
|
let compressed = encoder.encode(&pcm).unwrap();
|
||||||
assert!(!compressed.is_empty(), "Compressed buffer should not be empty");
|
assert!(
|
||||||
|
!compressed.is_empty(),
|
||||||
|
"Compressed buffer should not be empty"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
||||||
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
||||||
@@ -110,13 +248,21 @@ mod tests {
|
|||||||
|
|
||||||
// decode it
|
// decode it
|
||||||
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
||||||
assert_eq!(decoded.len(), 960, "Decoded sample count should be exactly 960");
|
assert_eq!(
|
||||||
|
decoded.len(),
|
||||||
|
960,
|
||||||
|
"Decoded sample count should be exactly 960"
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Round-trip carries signal energy (not silence)
|
// 2. Round-trip carries signal energy (not silence)
|
||||||
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
||||||
let rms = (sum_sq / decoded.len() as f64).sqrt();
|
let rms = (sum_sq / decoded.len() as f64).sqrt();
|
||||||
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
|
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
|
||||||
assert!(rms > 100.0, "Decoded signal should carry energy (RMS was {})", rms);
|
assert!(
|
||||||
|
rms > 100.0,
|
||||||
|
"Decoded signal should carry energy (RMS was {})",
|
||||||
|
rms
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -125,11 +271,19 @@ mod tests {
|
|||||||
|
|
||||||
// decode(None) returns exactly frame_samples (960) samples
|
// decode(None) returns exactly frame_samples (960) samples
|
||||||
let plc_none = decoder.decode(None).unwrap();
|
let plc_none = decoder.decode(None).unwrap();
|
||||||
assert_eq!(plc_none.len(), 960, "decode(None) should yield exactly 960 samples");
|
assert_eq!(
|
||||||
|
plc_none.len(),
|
||||||
|
960,
|
||||||
|
"decode(None) should yield exactly 960 samples"
|
||||||
|
);
|
||||||
|
|
||||||
// decode(Some(&[])) (empty slice) does the same
|
// decode(Some(&[])) (empty slice) does the same
|
||||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
||||||
assert_eq!(plc_empty.len(), 960, "decode(Some(&[])) should yield exactly 960 samples");
|
assert_eq!(
|
||||||
|
plc_empty.len(),
|
||||||
|
960,
|
||||||
|
"decode(Some(&[])) should yield exactly 960 samples"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -140,7 +294,11 @@ mod tests {
|
|||||||
let pcm = vec![0i16; 960];
|
let pcm = vec![0i16; 960];
|
||||||
let compressed = encoder.encode(&pcm).unwrap();
|
let compressed = encoder.encode(&pcm).unwrap();
|
||||||
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
||||||
assert_eq!(decoded.len(), 960, "Decoded sample count should match packet duration");
|
assert_eq!(
|
||||||
|
decoded.len(),
|
||||||
|
960,
|
||||||
|
"Decoded sample count should match packet duration"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -149,11 +307,18 @@ mod tests {
|
|||||||
|
|
||||||
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
||||||
let plc_none = decoder.decode(None).unwrap();
|
let plc_none = decoder.decode(None).unwrap();
|
||||||
assert_eq!(plc_none.len(), 960 * 2, "Stereo decode(None) should yield exactly 1920 samples");
|
assert_eq!(
|
||||||
|
plc_none.len(),
|
||||||
|
960 * 2,
|
||||||
|
"Stereo decode(None) should yield exactly 1920 samples"
|
||||||
|
);
|
||||||
|
|
||||||
// decode(Some(&[])) (empty slice) does the same
|
// decode(Some(&[])) (empty slice) does the same
|
||||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
||||||
assert_eq!(plc_empty.len(), 960 * 2, "Stereo decode(Some(&[])) should yield exactly 1920 samples");
|
assert_eq!(
|
||||||
|
plc_empty.len(),
|
||||||
|
960 * 2,
|
||||||
|
"Stereo decode(Some(&[])) should yield exactly 1920 samples"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+505
-31
@@ -1,9 +1,12 @@
|
|||||||
use crate::notify::Sound;
|
use crate::notify::Sound;
|
||||||
use crate::theme::AppTheme;
|
use crate::theme::AppTheme;
|
||||||
|
use anyhow::Context;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Relay/discovery posture, trading connectivity against how much the n0
|
/// Relay/discovery posture, trading connectivity against how much the n0
|
||||||
/// infrastructure learns about you. See the network module for details.
|
/// infrastructure learns about you. See the network module for details.
|
||||||
@@ -24,8 +27,11 @@ pub enum NetworkMode {
|
|||||||
|
|
||||||
impl NetworkMode {
|
impl NetworkMode {
|
||||||
/// All variants, for presentation in a picker.
|
/// All variants, for presentation in a picker.
|
||||||
pub const ALL: [NetworkMode; 3] =
|
pub const ALL: [NetworkMode; 3] = [
|
||||||
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
NetworkMode::RelayNoDiscovery,
|
||||||
|
NetworkMode::N0Full,
|
||||||
|
NetworkMode::DirectOnly,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
||||||
@@ -42,8 +48,11 @@ pub enum RoomLayout {
|
|||||||
|
|
||||||
impl RoomLayout {
|
impl RoomLayout {
|
||||||
/// All variants, in picker display order.
|
/// All variants, in picker display order.
|
||||||
pub const ALL: [RoomLayout; 3] =
|
pub const ALL: [RoomLayout; 3] = [
|
||||||
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
RoomLayout::ThreeColumn,
|
||||||
|
RoomLayout::BottomDock,
|
||||||
|
RoomLayout::Drawer,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
||||||
@@ -62,8 +71,11 @@ pub enum RecordingMode {
|
|||||||
|
|
||||||
impl RecordingMode {
|
impl RecordingMode {
|
||||||
/// All variants, in picker display order.
|
/// All variants, in picker display order.
|
||||||
pub const ALL: [RecordingMode; 3] =
|
pub const ALL: [RecordingMode; 3] = [
|
||||||
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
|
RecordingMode::Mixed,
|
||||||
|
RecordingMode::Multitrack,
|
||||||
|
RecordingMode::Both,
|
||||||
|
];
|
||||||
|
|
||||||
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
||||||
pub fn is_multitrack(self) -> bool {
|
pub fn is_multitrack(self) -> bool {
|
||||||
@@ -81,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 {
|
impl std::fmt::Display for RoomLayout {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(match self {
|
f.write_str(match self {
|
||||||
@@ -102,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 {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -138,6 +337,10 @@ fn default_chat_drawer_width() -> f32 {
|
|||||||
320.0
|
320.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_playlist_drawer_width() -> f32 {
|
||||||
|
320.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_window_width() -> f32 {
|
fn default_window_width() -> f32 {
|
||||||
900.0
|
900.0
|
||||||
}
|
}
|
||||||
@@ -174,12 +377,19 @@ pub struct AppConfig {
|
|||||||
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub music_broadcast: bool,
|
pub music_broadcast: bool,
|
||||||
|
/// Show the slim now-playing player bar in the room screen.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_player_bar: bool,
|
||||||
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||||
/// its own (in-memory) level and the universal slider is inactive.
|
/// its own (in-memory) level and the universal slider is inactive.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub clip_volume_universal: bool,
|
pub clip_volume_universal: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network_mode: NetworkMode,
|
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 /
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||||
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
|
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -207,6 +417,9 @@ pub struct AppConfig {
|
|||||||
/// Chat drawer width for the drawer layout (px).
|
/// Chat drawer width for the drawer layout (px).
|
||||||
#[serde(default = "default_chat_drawer_width")]
|
#[serde(default = "default_chat_drawer_width")]
|
||||||
pub chat_drawer_width: f32,
|
pub chat_drawer_width: f32,
|
||||||
|
/// Playlist drawer width for the room-screen right-edge music panel (px).
|
||||||
|
#[serde(default = "default_playlist_drawer_width")]
|
||||||
|
pub playlist_drawer_width: f32,
|
||||||
/// Chosen arrangement of the in-call room screen.
|
/// Chosen arrangement of the in-call room screen.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub room_layout: RoomLayout,
|
pub room_layout: RoomLayout,
|
||||||
@@ -289,6 +502,9 @@ pub struct AppConfig {
|
|||||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pixelpass_path: Option<String>,
|
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
|
/// 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
|
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
||||||
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
||||||
@@ -335,6 +551,13 @@ pub struct AppConfig {
|
|||||||
pub window_y: Option<i32>,
|
pub window_y: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LoadOutcome {
|
||||||
|
Missing,
|
||||||
|
Loaded,
|
||||||
|
Recovered,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -348,8 +571,10 @@ impl Default for AppConfig {
|
|||||||
music_playlist: Vec::new(),
|
music_playlist: Vec::new(),
|
||||||
music_volume: 1.0,
|
music_volume: 1.0,
|
||||||
music_broadcast: false,
|
music_broadcast: false,
|
||||||
|
show_player_bar: true,
|
||||||
clip_volume_universal: true,
|
clip_volume_universal: true,
|
||||||
network_mode: NetworkMode::default(),
|
network_mode: NetworkMode::default(),
|
||||||
|
audio_profile: AudioProfile::default(),
|
||||||
presence_mode: crate::presence::PresenceMode::default(),
|
presence_mode: crate::presence::PresenceMode::default(),
|
||||||
echo_cancellation_enabled: false,
|
echo_cancellation_enabled: false,
|
||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
@@ -358,6 +583,7 @@ impl Default for AppConfig {
|
|||||||
threecol_playlist_height: default_threecol_playlist_height(),
|
threecol_playlist_height: default_threecol_playlist_height(),
|
||||||
controls_width: default_controls_width(),
|
controls_width: default_controls_width(),
|
||||||
chat_drawer_width: default_chat_drawer_width(),
|
chat_drawer_width: default_chat_drawer_width(),
|
||||||
|
playlist_drawer_width: default_playlist_drawer_width(),
|
||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
theme: AppTheme::default(),
|
theme: AppTheme::default(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
@@ -384,6 +610,7 @@ impl Default for AppConfig {
|
|||||||
sound_mic_toggle_enabled: true,
|
sound_mic_toggle_enabled: true,
|
||||||
sound_reconnect_failed_enabled: true,
|
sound_reconnect_failed_enabled: true,
|
||||||
pixelpass_path: None,
|
pixelpass_path: None,
|
||||||
|
screen_share: ScreenShareSettings::default(),
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
peer_eq: HashMap::new(),
|
peer_eq: HashMap::new(),
|
||||||
peer_pan: HashMap::new(),
|
peer_pan: HashMap::new(),
|
||||||
@@ -463,24 +690,115 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path()
|
let Some(path) = Self::config_path() else {
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
return Self::default();
|
||||||
&& let Ok(config) = serde_json::from_str(&contents) {
|
};
|
||||||
return config;
|
let (config, _) = Self::load_from(&path);
|
||||||
}
|
config
|
||||||
Self::default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) {
|
pub fn save(&self) {
|
||||||
if let Some(path) = Self::config_path() {
|
if let Some(path) = Self::config_path() {
|
||||||
if let Some(dir) = path.parent() {
|
if let Err(e) = self.save_to(&path) {
|
||||||
let _ = fs::create_dir_all(dir);
|
crate::log_msg(&format!("config: save failed: {e:#}"));
|
||||||
}
|
}
|
||||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
} else {
|
||||||
let _ = fs::write(path, json);
|
crate::log_msg("config: save failed: could not determine a config directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &Path) -> (Self, LoadOutcome) {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => match serde_json::from_str(&contents) {
|
||||||
|
Ok(config) => (config, LoadOutcome::Loaded),
|
||||||
|
Err(e) => {
|
||||||
|
let backup = recover_corrupt_config(path, &format!("failed to parse: {e}"));
|
||||||
|
(Self::default(), backup)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
(Self::default(), LoadOutcome::Missing)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let backup = recover_corrupt_config(path, &format!("failed to read: {e}"));
|
||||||
|
(Self::default(), backup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("config path has no parent directory")?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(self).context("failed to encode config")?;
|
||||||
|
let tmp = config_tmp_path(path)?;
|
||||||
|
let result = (|| -> anyhow::Result<()> {
|
||||||
|
{
|
||||||
|
let mut f = fs::File::create(&tmp)
|
||||||
|
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||||
|
f.write_all(json.as_bytes())
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
f.sync_all().ok();
|
||||||
|
}
|
||||||
|
fs::rename(&tmp, path).with_context(|| {
|
||||||
|
format!("failed to rename {} -> {}", tmp.display(), path.display())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_tmp_path(path: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("config path has no parent directory")?;
|
||||||
|
let mut name = path
|
||||||
|
.file_name()
|
||||||
|
.context("config path has no file name")?
|
||||||
|
.to_os_string();
|
||||||
|
name.push(format!(".tmp.{}", std::process::id()));
|
||||||
|
Ok(parent.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt_backup_path(path: &Path) -> PathBuf {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let mut name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_os_string())
|
||||||
|
.unwrap_or_else(|| "config.json".into());
|
||||||
|
name.push(format!(".corrupt.{secs}"));
|
||||||
|
parent.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome {
|
||||||
|
let backup = corrupt_backup_path(path);
|
||||||
|
match fs::rename(path, &backup) {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"config: {reason}; moved damaged config to {}",
|
||||||
|
backup.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"config: {reason}; failed to move damaged config to {}: {e}",
|
||||||
|
backup.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadOutcome::Recovered
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -495,14 +813,109 @@ mod tests {
|
|||||||
assert_eq!(original, deserialized);
|
assert_eq!(original, deserialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn temp_config_path(tag: &str) -> PathBuf {
|
||||||
|
let mut p = std::env::temp_dir();
|
||||||
|
p.push(format!(
|
||||||
|
"peerspeak-configtest-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
tag
|
||||||
|
));
|
||||||
|
p.push("config.json");
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_then_load_from_round_trips() {
|
||||||
|
let path = temp_config_path("roundtrip");
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
let cfg = AppConfig {
|
||||||
|
username: "Ada".into(),
|
||||||
|
input_device: "mic".into(),
|
||||||
|
output_device: "speaker".into(),
|
||||||
|
noise_gate_threshold: 0.42,
|
||||||
|
..AppConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg.save_to(&path).unwrap();
|
||||||
|
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||||
|
|
||||||
|
assert_eq!(outcome, LoadOutcome::Loaded);
|
||||||
|
assert_eq!(loaded, cfg);
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_missing_returns_default_without_corrupt_backup() {
|
||||||
|
let path = temp_config_path("missing");
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
|
||||||
|
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||||
|
|
||||||
|
assert_eq!(outcome, LoadOutcome::Missing);
|
||||||
|
assert_eq!(loaded, AppConfig::default());
|
||||||
|
assert!(!path.parent().unwrap().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_from_corrupt_file_preserves_original_bytes() {
|
||||||
|
let path = temp_config_path("corrupt");
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
let corrupt = b"{ this is not json";
|
||||||
|
fs::write(&path, corrupt).unwrap();
|
||||||
|
|
||||||
|
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||||
|
|
||||||
|
assert_eq!(outcome, LoadOutcome::Recovered);
|
||||||
|
assert_eq!(loaded, AppConfig::default());
|
||||||
|
assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice()));
|
||||||
|
let backups: Vec<_> = fs::read_dir(path.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().path())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.starts_with("config.json.corrupt."))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(backups.len(), 1, "expected one corrupt backup");
|
||||||
|
assert_eq!(fs::read(&backups[0]).unwrap(), corrupt);
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_to_leaves_no_tmp_file_after_success() {
|
||||||
|
let path = temp_config_path("atomic");
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
|
||||||
|
AppConfig::default().save_to(&path).unwrap();
|
||||||
|
|
||||||
|
let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().path())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.contains(".tmp."))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}");
|
||||||
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_backward_compat_default_fill() {
|
fn test_backward_compat_default_fill() {
|
||||||
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
|
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
|
||||||
|
|
||||||
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
||||||
// Configs predating the presence posture load as friends-only (no beacon).
|
// Configs predating the presence posture load as friends-only (no beacon).
|
||||||
assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal);
|
assert_eq!(
|
||||||
|
deserialized.presence_mode,
|
||||||
|
crate::presence::PresenceMode::Normal
|
||||||
|
);
|
||||||
assert!(!deserialized.echo_cancellation_enabled);
|
assert!(!deserialized.echo_cancellation_enabled);
|
||||||
assert!(deserialized.notifications_enabled);
|
assert!(deserialized.notifications_enabled);
|
||||||
// Configs predating the volume sliders must load at unity gain.
|
// Configs predating the volume sliders must load at unity gain.
|
||||||
@@ -517,6 +930,8 @@ mod tests {
|
|||||||
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
||||||
assert_eq!(deserialized.controls_width, 280.0);
|
assert_eq!(deserialized.controls_width, 280.0);
|
||||||
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
||||||
|
assert_eq!(deserialized.playlist_drawer_width, 320.0);
|
||||||
|
assert!(deserialized.show_player_bar);
|
||||||
assert!(deserialized.custom_sound_self_join.is_none());
|
assert!(deserialized.custom_sound_self_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||||
@@ -525,10 +940,21 @@ mod tests {
|
|||||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||||
assert!(deserialized.custom_sound_reconnect_failed.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
|
// Configs predating the per-sound flags (W6) enable every chime, so an
|
||||||
// upgrade is silent-change-free.
|
// upgrade is silent-change-free.
|
||||||
for sound in Sound::ALL {
|
for sound in Sound::ALL {
|
||||||
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on");
|
assert!(
|
||||||
|
deserialized.sound_enabled(sound),
|
||||||
|
"{sound:?} should default on"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The accessor and mutator agree round-trip.
|
// The accessor and mutator agree round-trip.
|
||||||
let mut cfg = AppConfig::default();
|
let mut cfg = AppConfig::default();
|
||||||
@@ -575,7 +1001,10 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||||
// The pre-existing single background survives untouched (still Option<String>).
|
// The pre-existing single background survives untouched (still Option<String>).
|
||||||
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
assert_eq!(
|
||||||
|
cfg.background.as_deref(),
|
||||||
|
Some("/home/eric/.config/peerspeak/background.png")
|
||||||
|
);
|
||||||
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||||
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||||
assert!(!cfg.game_presence_enabled);
|
assert!(!cfg.game_presence_enabled);
|
||||||
@@ -587,9 +1016,12 @@ mod tests {
|
|||||||
fn test_game_maps_serialize_deterministically() {
|
fn test_game_maps_serialize_deterministically() {
|
||||||
// BTreeMap ordering makes the serialized config stable across runs.
|
// BTreeMap ordering makes the serialized config stable across runs.
|
||||||
let mut cfg = AppConfig::default();
|
let mut cfg = AppConfig::default();
|
||||||
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
cfg.game_backgrounds
|
||||||
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
.insert("steam:730".into(), "/a.png".into());
|
||||||
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
cfg.game_backgrounds
|
||||||
|
.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||||
|
cfg.game_process_map
|
||||||
|
.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||||
let json = serde_json::to_string(&cfg).unwrap();
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
// Keys appear in sorted order (exe: before steam:).
|
// Keys appear in sorted order (exe: before steam:).
|
||||||
let bg = json.find("game_backgrounds").unwrap();
|
let bg = json.find("game_backgrounds").unwrap();
|
||||||
@@ -647,8 +1079,7 @@ mod tests {
|
|||||||
recording_mode: RecordingMode::Both,
|
recording_mode: RecordingMode::Both,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let back: AppConfig =
|
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
|
||||||
assert_eq!(back.recording_mode, RecordingMode::Both);
|
assert_eq!(back.recording_mode, RecordingMode::Both);
|
||||||
// is_multitrack() classifies correctly.
|
// is_multitrack() classifies correctly.
|
||||||
assert!(!RecordingMode::Mixed.is_multitrack());
|
assert!(!RecordingMode::Mixed.is_multitrack());
|
||||||
@@ -701,6 +1132,7 @@ mod tests {
|
|||||||
assert!(def.music_playlist.is_empty());
|
assert!(def.music_playlist.is_empty());
|
||||||
assert_eq!(def.music_volume, 1.0);
|
assert_eq!(def.music_volume, 1.0);
|
||||||
assert!(!def.music_broadcast);
|
assert!(!def.music_broadcast);
|
||||||
|
assert!(def.show_player_bar);
|
||||||
assert!(def.clip_volume_universal);
|
assert!(def.clip_volume_universal);
|
||||||
|
|
||||||
// Missing in JSON → unity (serde default).
|
// Missing in JSON → unity (serde default).
|
||||||
@@ -712,6 +1144,7 @@ mod tests {
|
|||||||
assert!(cfg_missing.music_playlist.is_empty());
|
assert!(cfg_missing.music_playlist.is_empty());
|
||||||
assert_eq!(cfg_missing.music_volume, 1.0);
|
assert_eq!(cfg_missing.music_volume, 1.0);
|
||||||
assert!(!cfg_missing.music_broadcast);
|
assert!(!cfg_missing.music_broadcast);
|
||||||
|
assert!(cfg_missing.show_player_bar);
|
||||||
// Configs predating the toggle default to universal mode.
|
// Configs predating the toggle default to universal mode.
|
||||||
assert!(cfg_missing.clip_volume_universal);
|
assert!(cfg_missing.clip_volume_universal);
|
||||||
|
|
||||||
@@ -723,6 +1156,7 @@ mod tests {
|
|||||||
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
||||||
music_volume: 0.6,
|
music_volume: 0.6,
|
||||||
music_broadcast: true,
|
music_broadcast: true,
|
||||||
|
show_player_bar: false,
|
||||||
clip_volume_universal: false,
|
clip_volume_universal: false,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
@@ -731,15 +1165,20 @@ mod tests {
|
|||||||
assert_eq!(round_tripped.input_volume, 1.5);
|
assert_eq!(round_tripped.input_volume, 1.5);
|
||||||
assert_eq!(round_tripped.output_volume, 0.25);
|
assert_eq!(round_tripped.output_volume, 0.25);
|
||||||
assert_eq!(round_tripped.clip_volume, 0.7);
|
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||||
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]);
|
assert_eq!(
|
||||||
|
round_tripped.music_playlist,
|
||||||
|
vec!["/tmp/song.ogg".to_string()]
|
||||||
|
);
|
||||||
assert_eq!(round_tripped.music_volume, 0.6);
|
assert_eq!(round_tripped.music_volume, 0.6);
|
||||||
assert!(round_tripped.music_broadcast);
|
assert!(round_tripped.music_broadcast);
|
||||||
|
assert!(!round_tripped.show_player_bar);
|
||||||
assert!(!round_tripped.clip_volume_universal);
|
assert!(!round_tripped.clip_volume_universal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_notifications_enabled_specifically() {
|
fn test_notifications_enabled_specifically() {
|
||||||
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let missing_notifications =
|
||||||
|
r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
||||||
assert!(config_missing.notifications_enabled);
|
assert!(config_missing.notifications_enabled);
|
||||||
|
|
||||||
@@ -784,6 +1223,38 @@ mod tests {
|
|||||||
assert_ne!(display_0, display_2);
|
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]
|
#[test]
|
||||||
fn test_unknown_field_tolerance() {
|
fn test_unknown_field_tolerance() {
|
||||||
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
||||||
@@ -795,11 +1266,14 @@ mod tests {
|
|||||||
"unrecognized_field_xyz_123": "some_value"
|
"unrecognized_field_xyz_123": "some_value"
|
||||||
}"#;
|
}"#;
|
||||||
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
|
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
|
||||||
|
|
||||||
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
||||||
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
||||||
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present");
|
assert!(
|
||||||
|
deserialized_res.is_ok(),
|
||||||
|
"Config deserialization failed when an unknown field was present"
|
||||||
|
);
|
||||||
|
|
||||||
let config = deserialized_res.unwrap();
|
let config = deserialized_res.unwrap();
|
||||||
assert_eq!(config.input_device, "");
|
assert_eq!(config.input_device, "");
|
||||||
assert_eq!(config.output_device, "");
|
assert_eq!(config.output_device, "");
|
||||||
|
|||||||
+95
-5
@@ -202,11 +202,15 @@ impl JitterBuffer {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Gap with later packets already buffered: a packet was lost
|
// Gap with later packets already buffered: a packet was lost
|
||||||
// or reordered out of window. Conceal this frame via Opus PLC
|
// or reordered out of window. First try Opus in-band FEC from
|
||||||
// and grow the cushion — the jitter beat our current delay.
|
// the next packet; if unavailable, fall back to plain PLC.
|
||||||
self.next_seq = Some(next.wrapping_add(1));
|
self.next_seq = Some(next.wrapping_add(1));
|
||||||
self.note_disruption();
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::codec::AudioEncoder;
|
use crate::codec::opus_impl::{OpusDecoder, OpusEncoder, OpusParams};
|
||||||
use crate::codec::opus_impl::OpusEncoder;
|
use crate::codec::{AudioDecoder, AudioEncoder};
|
||||||
use opus::{Application, Channels};
|
use opus::{Application, Channels};
|
||||||
|
|
||||||
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
|
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
|
||||||
@@ -233,6 +237,32 @@ mod tests {
|
|||||||
enc.encode(&pcm).unwrap()
|
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]
|
#[test]
|
||||||
fn buffers_then_plays_in_order() {
|
fn buffers_then_plays_in_order() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
@@ -289,6 +319,66 @@ mod tests {
|
|||||||
assert!(jb.pop_frame().is_none());
|
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]
|
#[test]
|
||||||
fn drops_packets_already_played() {
|
fn drops_packets_already_played() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
|||||||
+288
-48
@@ -1,4 +1,4 @@
|
|||||||
use crate::config::{NetworkMode, RecordingMode};
|
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
|
||||||
use crate::friends::Friend;
|
use crate::friends::Friend;
|
||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::presence::{FriendPresence, PresenceMode};
|
use crate::presence::{FriendPresence, PresenceMode};
|
||||||
@@ -9,7 +9,15 @@ pub enum CoreCommand {
|
|||||||
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
||||||
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
|
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
|
||||||
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
||||||
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
Join {
|
||||||
|
name: String,
|
||||||
|
ticket: String,
|
||||||
|
room_name: String,
|
||||||
|
input_device: Option<String>,
|
||||||
|
output_device: Option<String>,
|
||||||
|
echo_cancellation: bool,
|
||||||
|
avatar: crate::avatar::Avatar,
|
||||||
|
},
|
||||||
Leave,
|
Leave,
|
||||||
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||||
/// audio/screen-share work, close the persistent network stack, then ack with
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||||
@@ -41,10 +49,17 @@ pub enum CoreCommand {
|
|||||||
/// Start/stop a standalone capture-only stream that reports the raw mic
|
/// Start/stop a standalone capture-only stream that reports the raw mic
|
||||||
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
||||||
/// Ignored while a room session is active (the in-call meter covers that).
|
/// Ignored while a room session is active (the in-call meter covers that).
|
||||||
SetMicMonitor { enabled: bool, input_device: Option<String> },
|
SetMicMonitor {
|
||||||
|
enabled: bool,
|
||||||
|
input_device: Option<String>,
|
||||||
|
},
|
||||||
/// Set the relay/discovery posture. Takes effect on the next room join,
|
/// Set the relay/discovery posture. Takes effect on the next room join,
|
||||||
/// since the endpoint is (re)built then.
|
/// since the endpoint is (re)built then.
|
||||||
SetNetworkMode(NetworkMode),
|
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
|
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
||||||
/// mix). No-op start if already recording / not in a call.
|
/// mix). No-op start if already recording / not in a call.
|
||||||
SetRecording(bool),
|
SetRecording(bool),
|
||||||
@@ -56,22 +71,40 @@ pub enum CoreCommand {
|
|||||||
/// Send a chat message carrying a file attachment. The app has already read +
|
/// Send a chat message carrying a file attachment. The app has already read +
|
||||||
/// capped the file and built the descriptor; core makes the bytes available
|
/// capped the file and built the descriptor; core makes the bytes available
|
||||||
/// on the file plane and broadcasts the descriptor.
|
/// on the file plane and broadcasts the descriptor.
|
||||||
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
SendChatFile {
|
||||||
|
text: String,
|
||||||
|
attachment: crate::files::ChatAttachment,
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
/// Fetch a received attachment's bytes from its sender over the file plane
|
/// Fetch a received attachment's bytes from its sender over the file plane
|
||||||
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||||
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
||||||
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
FetchAttachment {
|
||||||
|
from: EndpointId,
|
||||||
|
attachment: crate::files::ChatAttachment,
|
||||||
|
},
|
||||||
/// Register `data` as fetchable under `id` for room members (the current
|
/// Register `data` as fetchable under `id` for room members (the current
|
||||||
/// broadcast track). Called once per track when broadcasting.
|
/// broadcast track). Called once per track when broadcasting.
|
||||||
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> },
|
ServeMusicTrack {
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: std::sync::Arc<Vec<u8>>,
|
||||||
|
},
|
||||||
/// Drop a music blob that is no longer current-or-next.
|
/// Drop a music blob that is no longer current-or-next.
|
||||||
ForgetMusicTrack(crate::files::AttachmentId),
|
ForgetMusicTrack(crate::files::AttachmentId),
|
||||||
/// Set (or clear) our broadcast music timeline and re-announce presence.
|
/// Set (or clear) our broadcast music timeline and re-announce presence.
|
||||||
SetMusicPresence(Option<crate::network::MusicPresence>),
|
SetMusicPresence(Option<crate::network::MusicPresence>),
|
||||||
/// Fetch a source peer's current track bytes after tuning into them.
|
/// Fetch a source peer's current track bytes after tuning into them.
|
||||||
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
FetchMusic {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
size: u64,
|
||||||
|
},
|
||||||
/// Fetch a source peer's advertised next track bytes before it becomes current.
|
/// Fetch a source peer's advertised next track bytes before it becomes current.
|
||||||
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
PrefetchMusic {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
size: u64,
|
||||||
|
},
|
||||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
@@ -84,13 +117,20 @@ pub enum CoreCommand {
|
|||||||
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||||
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||||
/// whole desktop audio (the legacy behavior).
|
/// whole desktop audio (the legacy behavior).
|
||||||
StartScreenShare { audio_app: Option<String> },
|
StartScreenShare {
|
||||||
|
audio_app: Option<String>,
|
||||||
|
settings: ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
|
},
|
||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
||||||
/// open it in a local player.
|
/// 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
|
/// 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
|
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
||||||
/// an updated [`UiEvent::IdentityStatus`].
|
/// an updated [`UiEvent::IdentityStatus`].
|
||||||
@@ -98,7 +138,11 @@ pub enum CoreCommand {
|
|||||||
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
||||||
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
|
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
|
||||||
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
|
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
|
||||||
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> },
|
AddFriend {
|
||||||
|
id: EndpointId,
|
||||||
|
name: String,
|
||||||
|
addr: Option<EndpointAddr>,
|
||||||
|
},
|
||||||
/// Remove a friend by id (W7).
|
/// Remove a friend by id (W7).
|
||||||
RemoveFriend(EndpointId),
|
RemoveFriend(EndpointId),
|
||||||
/// Locally rename a friend (W7).
|
/// Locally rename a friend (W7).
|
||||||
@@ -130,6 +174,17 @@ pub enum DeliveryClass {
|
|||||||
BestEffort,
|
BestEffort,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CoalesceKey {
|
||||||
|
InputVolume,
|
||||||
|
OutputVolume,
|
||||||
|
NoiseGate,
|
||||||
|
PeerVolume(EndpointId),
|
||||||
|
PeerPan(EndpointId),
|
||||||
|
PeerGate(EndpointId),
|
||||||
|
PeerEq(EndpointId),
|
||||||
|
}
|
||||||
|
|
||||||
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
||||||
/// actions are Reliable (must land). The only high-frequency commands are the
|
/// actions are Reliable (must land). The only high-frequency commands are the
|
||||||
/// continuous audio sliders, where dropping intermediate values is harmless;
|
/// continuous audio sliders, where dropping intermediate values is harmless;
|
||||||
@@ -166,6 +221,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
input_device: _,
|
input_device: _,
|
||||||
}
|
}
|
||||||
| CoreCommand::SetNetworkMode(_)
|
| CoreCommand::SetNetworkMode(_)
|
||||||
|
| CoreCommand::SetAudioProfile(_)
|
||||||
| CoreCommand::SetRecording(_)
|
| CoreCommand::SetRecording(_)
|
||||||
| CoreCommand::SetRecordingMode(_)
|
| CoreCommand::SetRecordingMode(_)
|
||||||
| CoreCommand::SendChat(_)
|
| CoreCommand::SendChat(_)
|
||||||
@@ -178,10 +234,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
from: _,
|
from: _,
|
||||||
attachment: _,
|
attachment: _,
|
||||||
}
|
}
|
||||||
| CoreCommand::ServeMusicTrack {
|
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||||
id: _,
|
|
||||||
data: _,
|
|
||||||
}
|
|
||||||
| CoreCommand::ForgetMusicTrack(_)
|
| CoreCommand::ForgetMusicTrack(_)
|
||||||
| CoreCommand::SetMusicPresence(_)
|
| CoreCommand::SetMusicPresence(_)
|
||||||
| CoreCommand::FetchMusic {
|
| CoreCommand::FetchMusic {
|
||||||
@@ -196,9 +249,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
}
|
}
|
||||||
| CoreCommand::SetPixelpassPath(_)
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
| CoreCommand::ListAudioApps
|
| CoreCommand::ListAudioApps
|
||||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
| CoreCommand::StopScreenShare
|
| CoreCommand::StopScreenShare
|
||||||
| CoreCommand::ViewShare(_)
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
| CoreCommand::RegenerateIdentity
|
| CoreCommand::RegenerateIdentity
|
||||||
| CoreCommand::AddFriend {
|
| CoreCommand::AddFriend {
|
||||||
id: _,
|
id: _,
|
||||||
@@ -215,57 +275,196 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coalescing bucket for high-frequency continuous controls. A key exists
|
||||||
|
/// exactly for [`DeliveryClass::BestEffort`] commands.
|
||||||
|
pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||||
|
match cmd {
|
||||||
|
CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)),
|
||||||
|
CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)),
|
||||||
|
CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)),
|
||||||
|
CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)),
|
||||||
|
CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume),
|
||||||
|
CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume),
|
||||||
|
CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate),
|
||||||
|
|
||||||
|
CoreCommand::Join {
|
||||||
|
name: _,
|
||||||
|
ticket: _,
|
||||||
|
room_name: _,
|
||||||
|
input_device: _,
|
||||||
|
output_device: _,
|
||||||
|
echo_cancellation: _,
|
||||||
|
avatar: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::Leave
|
||||||
|
| CoreCommand::Shutdown
|
||||||
|
| CoreCommand::ToggleMute
|
||||||
|
| CoreCommand::SetAvatar(_)
|
||||||
|
| CoreCommand::ToggleDeafen
|
||||||
|
| CoreCommand::SetPttMode(_)
|
||||||
|
| CoreCommand::SetPttActive(_)
|
||||||
|
| CoreCommand::SetPeerMuted(_, _)
|
||||||
|
| CoreCommand::SetMicMonitor {
|
||||||
|
enabled: _,
|
||||||
|
input_device: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetNetworkMode(_)
|
||||||
|
| CoreCommand::SetAudioProfile(_)
|
||||||
|
| CoreCommand::SetRecording(_)
|
||||||
|
| CoreCommand::SetRecordingMode(_)
|
||||||
|
| CoreCommand::SendChat(_)
|
||||||
|
| CoreCommand::SendChatFile {
|
||||||
|
text: _,
|
||||||
|
attachment: _,
|
||||||
|
data: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::FetchAttachment {
|
||||||
|
from: _,
|
||||||
|
attachment: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||||
|
| CoreCommand::ForgetMusicTrack(_)
|
||||||
|
| CoreCommand::SetMusicPresence(_)
|
||||||
|
| CoreCommand::FetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::PrefetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
|
| CoreCommand::ListAudioApps
|
||||||
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::StopScreenShare
|
||||||
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RegenerateIdentity
|
||||||
|
| CoreCommand::AddFriend {
|
||||||
|
id: _,
|
||||||
|
name: _,
|
||||||
|
addr: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RemoveFriend(_)
|
||||||
|
| CoreCommand::RenameFriend(_, _)
|
||||||
|
| CoreCommand::RefreshFriends
|
||||||
|
| CoreCommand::SetPresenceMode(_)
|
||||||
|
| CoreCommand::SetGamePresenceEnabled(_)
|
||||||
|
| CoreCommand::SetGameOverride(_)
|
||||||
|
| CoreCommand::SetGameProcessMap(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum UiEvent {
|
pub enum UiEvent {
|
||||||
RoomJoined { ticket: String, self_id: String },
|
RoomJoined {
|
||||||
|
ticket: String,
|
||||||
|
self_id: String,
|
||||||
|
},
|
||||||
RoomLeft,
|
RoomLeft,
|
||||||
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||||
/// leave chime. The persistent identity remains unchanged.
|
/// leave chime. The persistent identity remains unchanged.
|
||||||
RoomReset,
|
RoomReset,
|
||||||
PeerJoined { id: EndpointId, state: PeerState },
|
PeerJoined {
|
||||||
PeerLeft { id: EndpointId },
|
id: EndpointId,
|
||||||
|
state: PeerState,
|
||||||
|
},
|
||||||
|
PeerLeft {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||||
/// has started. This is non-terminal and must not play the failure chime.
|
/// has started. This is non-terminal and must not play the failure chime.
|
||||||
PeerRecoveryStarted { id: EndpointId },
|
PeerRecoveryStarted {
|
||||||
PeerConnectionFailed { id: EndpointId },
|
id: EndpointId,
|
||||||
PeerUpdated { id: EndpointId, state: PeerState },
|
},
|
||||||
|
PeerConnectionFailed {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
|
PeerUpdated {
|
||||||
|
id: EndpointId,
|
||||||
|
state: PeerState,
|
||||||
|
},
|
||||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
/// Audio link to a peer is being (re)established — show a connecting state.
|
||||||
PeerConnecting { id: EndpointId },
|
PeerConnecting {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
/// Audio link to a peer is up and carrying audio.
|
/// Audio link to a peer is up and carrying audio.
|
||||||
PeerConnected { id: EndpointId },
|
PeerConnected {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
AudioLevels(Vec<(EndpointId, f32)>),
|
AudioLevels(Vec<(EndpointId, f32)>),
|
||||||
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
||||||
/// for the settings level meter. Throttled to ~10/sec.
|
/// for the settings level meter. Throttled to ~10/sec.
|
||||||
MicLevel(f32),
|
MicLevel(f32),
|
||||||
/// Call recording started; carries the absolute WAV path being written.
|
/// Call recording started; carries the absolute WAV path being written.
|
||||||
RecordingStarted { path: String },
|
RecordingStarted {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
/// Call recording stopped; carries the finished WAV path.
|
/// Call recording stopped; carries the finished WAV path.
|
||||||
RecordingStopped { path: String },
|
RecordingStopped {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
/// A room text-chat message arrived from a peer (never our own — local
|
/// A room text-chat message arrived from a peer (never our own — local
|
||||||
/// messages are echoed by the UI on send). `from` is the sender's node id
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||||
/// string, used to key their avatar (W4).
|
/// string, used to key their avatar (W4).
|
||||||
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
ChatMessage {
|
||||||
|
from: String,
|
||||||
|
name: String,
|
||||||
|
text: String,
|
||||||
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
|
},
|
||||||
/// An attachment's bytes are now available (auto-fetched for images, or
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
||||||
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
||||||
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
||||||
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
||||||
/// F-12).
|
/// F-12).
|
||||||
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
AttachmentReady {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
AttachmentFailed {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
/// A tuned-in source's track bytes arrived; play them in the music sink.
|
/// A tuned-in source's track bytes arrived; play them in the music sink.
|
||||||
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
MusicReady {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
||||||
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
MusicPrefetched {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
/// A music-track fetch failed (source gone, too large, etc.).
|
/// A music-track fetch failed (source gone, too large, etc.).
|
||||||
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
MusicFetchFailed {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
/// The apps currently producing audio, for the screen-share audio picker
|
/// The apps currently producing audio, for the screen-share audio picker
|
||||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||||
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||||
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||||
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||||
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
AudioAppsListed {
|
||||||
|
apps: Vec<String>,
|
||||||
|
app_audio_supported: bool,
|
||||||
|
},
|
||||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
@@ -278,24 +477,37 @@ pub enum UiEvent {
|
|||||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||||
/// sender-stamped timestamp relative to this machine's clock.
|
/// sender-stamped timestamp relative to this machine's clock.
|
||||||
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
ClockSkewWarning {
|
||||||
|
skew_secs: u64,
|
||||||
|
peer_ahead: bool,
|
||||||
|
},
|
||||||
/// Our node identity (W7): the current node id string, and whether it is
|
/// Our node identity (W7): the current node id string, and whether it is
|
||||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||||
/// running on an ephemeral fallback — a degraded state the UI must surface,
|
/// running on an ephemeral fallback — a degraded state the UI must surface,
|
||||||
/// since the id (and thus friend recognition) won't survive the next launch.
|
/// since the id (and thus friend recognition) won't survive the next launch.
|
||||||
/// `error` carries the reason when degraded, for the UI explainer.
|
/// `error` carries the reason when degraded, for the UI explainer.
|
||||||
IdentityStatus { node_id: String, persisted: bool, error: Option<String> },
|
IdentityStatus {
|
||||||
|
node_id: String,
|
||||||
|
persisted: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
},
|
||||||
/// The friends list (W7), now owned by core. Sent at startup (after load) and
|
/// The friends list (W7), now owned by core. Sent at startup (after load) and
|
||||||
/// after every add/remove/rename so the GUI renders from this snapshot instead
|
/// after every add/remove/rename so the GUI renders from this snapshot instead
|
||||||
/// of owning the store. `read_only` is true when `friends.json` failed to load
|
/// of owning the store. `read_only` is true when `friends.json` failed to load
|
||||||
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
||||||
/// overwrite the damaged file (backlog A16).
|
/// overwrite the damaged file (backlog A16).
|
||||||
FriendsUpdated { friends: Vec<Friend>, read_only: bool },
|
FriendsUpdated {
|
||||||
|
friends: Vec<Friend>,
|
||||||
|
read_only: bool,
|
||||||
|
},
|
||||||
/// A friend's live presence from a successful ping reply (W7): online, or in a
|
/// A friend's live presence from a successful ping reply (W7): online, or in a
|
||||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||||
/// scheduler; absence of a recent event = treat as offline.
|
/// scheduler; absence of a recent event = treat as offline.
|
||||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
FriendPresence {
|
||||||
|
id: EndpointId,
|
||||||
|
presence: FriendPresence,
|
||||||
|
},
|
||||||
/// A manual "Rescan" pass finished (every friend has been probed and its
|
/// A manual "Rescan" pass finished (every friend has been probed and its
|
||||||
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
|
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
|
||||||
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
|
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
|
||||||
@@ -305,7 +517,9 @@ pub enum UiEvent {
|
|||||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted {
|
||||||
|
mode: PresenceMode,
|
||||||
|
},
|
||||||
/// The locally-detected running game changed (game detection). Carries the
|
/// The locally-detected running game changed (game detection). Carries the
|
||||||
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||||
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||||
@@ -319,7 +533,7 @@ pub enum UiEvent {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{delivery_class, CoreCommand, DeliveryClass};
|
use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
|
||||||
use crate::audio::eq::EqSettings;
|
use crate::audio::eq::EqSettings;
|
||||||
use crate::presence::PresenceMode;
|
use crate::presence::PresenceMode;
|
||||||
use iroh::{EndpointId, SecretKey};
|
use iroh::{EndpointId, SecretKey};
|
||||||
@@ -332,17 +546,37 @@ mod tests {
|
|||||||
fn continuous_audio_controls_are_best_effort() {
|
fn continuous_audio_controls_are_best_effort() {
|
||||||
let peer = endpoint_id();
|
let peer = endpoint_id();
|
||||||
let commands = [
|
let commands = [
|
||||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
(
|
||||||
CoreCommand::SetPeerPan(peer, -0.2),
|
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||||
CoreCommand::SetPeerGate(peer, 0.1),
|
CoalesceKey::PeerVolume(peer),
|
||||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
),
|
||||||
CoreCommand::SetInputVolume(0.8),
|
(
|
||||||
CoreCommand::SetOutputVolume(0.9),
|
CoreCommand::SetPeerPan(peer, -0.2),
|
||||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
CoalesceKey::PeerPan(peer),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerGate(peer, 0.1),
|
||||||
|
CoalesceKey::PeerGate(peer),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||||
|
CoalesceKey::PeerEq(peer),
|
||||||
|
),
|
||||||
|
(CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume),
|
||||||
|
(CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume),
|
||||||
|
(
|
||||||
|
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||||
|
CoalesceKey::NoiseGate,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for cmd in commands {
|
for (cmd, key) in commands {
|
||||||
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
||||||
|
assert_eq!(coalesce_key(&cmd), Some(key));
|
||||||
|
assert_eq!(
|
||||||
|
coalesce_key(&cmd).is_some(),
|
||||||
|
delivery_class(&cmd) == DeliveryClass::BestEffort
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,11 +599,17 @@ mod tests {
|
|||||||
},
|
},
|
||||||
CoreCommand::SetPeerMuted(peer, true),
|
CoreCommand::SetPeerMuted(peer, true),
|
||||||
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
||||||
|
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
|
||||||
CoreCommand::SendChat("hello".to_string()),
|
CoreCommand::SendChat("hello".to_string()),
|
||||||
];
|
];
|
||||||
|
|
||||||
for cmd in commands {
|
for cmd in commands {
|
||||||
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
||||||
|
assert_eq!(coalesce_key(&cmd), None);
|
||||||
|
assert_eq!(
|
||||||
|
coalesce_key(&cmd).is_some(),
|
||||||
|
delivery_class(&cmd) == DeliveryClass::BestEffort
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+708
-179
File diff suppressed because it is too large
Load Diff
+29
-7
@@ -283,8 +283,14 @@ mod tests {
|
|||||||
let q = echo.len() / 4;
|
let q = echo.len() / 4;
|
||||||
let early = erle(&echo[..q], &cleaned[..q]);
|
let early = erle(&echo[..q], &cleaned[..q]);
|
||||||
let late = erle(&echo[3 * q..], &cleaned[3 * q..]);
|
let late = erle(&echo[3 * q..], &cleaned[3 * q..]);
|
||||||
assert!(late > early + 10.0, "should improve markedly: early {early:.1} late {late:.1}");
|
assert!(
|
||||||
assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}");
|
late > early + 10.0,
|
||||||
|
"should improve markedly: early {early:.1} late {late:.1}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
late > 20.0,
|
||||||
|
"converged ERLE should exceed 20 dB, got {late:.1}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -307,7 +313,10 @@ mod tests {
|
|||||||
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
||||||
let out = aec.process(&silent_ref, &near);
|
let out = aec.process(&silent_ref, &near);
|
||||||
for (a, b) in near.iter().zip(&out) {
|
for (a, b) in near.iter().zip(&out) {
|
||||||
assert!((a - b).abs() < 1e-6, "near-end should pass through: {a} vs {b}");
|
assert!(
|
||||||
|
(a - b).abs() < 1e-6,
|
||||||
|
"near-end should pass through: {a} vs {b}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,14 +350,24 @@ mod tests {
|
|||||||
let mut late_hits = 0;
|
let mut late_hits = 0;
|
||||||
for i in 0..far.len() {
|
for i in 0..far.len() {
|
||||||
if dtd.update(far[i], mic[i]) {
|
if dtd.update(far[i], mic[i]) {
|
||||||
if i < onset { early_hits += 1 } else { late_hits += 1 }
|
if i < onset {
|
||||||
|
early_hits += 1
|
||||||
|
} else {
|
||||||
|
late_hits += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Echo-only stretch should rarely trip; near-end stretch should trip a lot.
|
// Echo-only stretch should rarely trip; near-end stretch should trip a lot.
|
||||||
let early_rate = early_hits as f32 / onset as f32;
|
let early_rate = early_hits as f32 / onset as f32;
|
||||||
let late_rate = late_hits as f32 / (far.len() - onset) as f32;
|
let late_rate = late_hits as f32 / (far.len() - onset) as f32;
|
||||||
assert!(early_rate < 0.10, "false-positive rate {early_rate:.2} too high");
|
assert!(
|
||||||
assert!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}");
|
early_rate < 0.10,
|
||||||
|
"false-positive rate {early_rate:.2} too high"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
late_rate > 0.50,
|
||||||
|
"missed double-talk, rate only {late_rate:.2}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -380,6 +399,9 @@ mod tests {
|
|||||||
erle_dtd > erle_no + 15.0,
|
erle_dtd > erle_no + 15.0,
|
||||||
"DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB"
|
"DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB"
|
||||||
);
|
);
|
||||||
assert!(erle_dtd > 15.0, "held filter should still cancel echo: {erle_dtd:.1} dB");
|
assert!(
|
||||||
|
erle_dtd > 15.0,
|
||||||
|
"held filter should still cancel echo: {erle_dtd:.1} dB"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,10 @@ mod tests {
|
|||||||
let path = EchoPath::synthetic(480, 480, 0.5, 99);
|
let path = EchoPath::synthetic(480, 480, 0.5, 99);
|
||||||
let echo = path.apply(&far);
|
let echo = path.apply(&far);
|
||||||
let ratio = rms(&echo) / rms(&far);
|
let ratio = rms(&echo) / rms(&far);
|
||||||
assert!((0.3..0.7).contains(&ratio), "echo/far rms ratio {ratio} off target");
|
assert!(
|
||||||
|
(0.3..0.7).contains(&ratio),
|
||||||
|
"echo/far rms ratio {ratio} off target"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+4
-1
@@ -141,7 +141,10 @@ mod tests {
|
|||||||
buf[0] = Complex::new(1.0, 0.0);
|
buf[0] = Complex::new(1.0, 0.0);
|
||||||
fft(&mut buf);
|
fft(&mut buf);
|
||||||
for c in &buf {
|
for c in &buf {
|
||||||
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}");
|
assert!(
|
||||||
|
approx(c.magnitude(), 1.0, 1e-9),
|
||||||
|
"expected flat 1.0, got {c:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -62,11 +62,31 @@ pub struct Band {
|
|||||||
|
|
||||||
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
||||||
pub const VOICE_BANDS: &[Band] = &[
|
pub const VOICE_BANDS: &[Band] = &[
|
||||||
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 },
|
Band {
|
||||||
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 },
|
label: "low (80-300)",
|
||||||
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 },
|
low_hz: 80.0,
|
||||||
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 },
|
high_hz: 300.0,
|
||||||
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 },
|
},
|
||||||
|
Band {
|
||||||
|
label: "low-mid (300-1k)",
|
||||||
|
low_hz: 300.0,
|
||||||
|
high_hz: 1000.0,
|
||||||
|
},
|
||||||
|
Band {
|
||||||
|
label: "mid (1k-3k)",
|
||||||
|
low_hz: 1000.0,
|
||||||
|
high_hz: 3000.0,
|
||||||
|
},
|
||||||
|
Band {
|
||||||
|
label: "high-mid (3k-6k)",
|
||||||
|
low_hz: 3000.0,
|
||||||
|
high_hz: 6000.0,
|
||||||
|
},
|
||||||
|
Band {
|
||||||
|
label: "high (6k-12k)",
|
||||||
|
low_hz: 6000.0,
|
||||||
|
high_hz: 12000.0,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
|
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
|
||||||
|
|||||||
+7
-2
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
|
|||||||
if opts.ascii {
|
if opts.ascii {
|
||||||
for i in 0..steps {
|
for i in 0..steps {
|
||||||
let v = i as f32 / (steps - 1) as f32;
|
let v = i as f32 / (steps - 1) as f32;
|
||||||
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
let idx =
|
||||||
|
((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
||||||
s.push(ASCII_RAMP[idx] as char);
|
s.push(ASCII_RAMP[idx] as char);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -243,7 +244,11 @@ mod tests {
|
|||||||
fn render_produces_grid_of_expected_height() {
|
fn render_produces_grid_of_expected_height() {
|
||||||
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
||||||
let spec = stft::analyze(&sig, 48_000, 1024, 512);
|
let spec = stft::analyze(&sig, 48_000, 1024, 512);
|
||||||
let opts = RenderOpts { width: 40, height: 10, ..Default::default() };
|
let opts = RenderOpts {
|
||||||
|
width: 40,
|
||||||
|
height: 10,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let out = render(&spec, &opts);
|
let out = render(&spec, &opts);
|
||||||
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
||||||
let lines = out.lines().count();
|
let lines = out.lines().count();
|
||||||
|
|||||||
+4
-1
@@ -94,7 +94,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
let peak_hz = s.bin_hz(peak_bin);
|
let peak_hz = s.bin_hz(peak_bin);
|
||||||
assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}");
|
assert!(
|
||||||
|
(peak_hz - freq as f32).abs() < 100.0,
|
||||||
|
"peak at {peak_hz} Hz, want {freq}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+13
-3
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
|||||||
let mut pos = 12usize;
|
let mut pos = 12usize;
|
||||||
while pos + 8 <= bytes.len() {
|
while pos + 8 <= bytes.len() {
|
||||||
let id = &bytes[pos..pos + 4];
|
let id = &bytes[pos..pos + 4];
|
||||||
let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize;
|
let size = u32::from_le_bytes([
|
||||||
|
bytes[pos + 4],
|
||||||
|
bytes[pos + 5],
|
||||||
|
bytes[pos + 6],
|
||||||
|
bytes[pos + 7],
|
||||||
|
]) as usize;
|
||||||
let body_start = pos + 8;
|
let body_start = pos + 8;
|
||||||
let body_end = (body_start + size).min(bytes.len());
|
let body_end = (body_start + size).min(bytes.len());
|
||||||
match id {
|
match id {
|
||||||
@@ -45,7 +50,9 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
|||||||
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
|
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
|
||||||
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
||||||
if audio_format != 1 {
|
if audio_format != 1 {
|
||||||
return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)"));
|
return Err(format!(
|
||||||
|
"unsupported WAV format tag {audio_format} (need PCM=1)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b"data" => {
|
b"data" => {
|
||||||
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
|||||||
samples.push(avg / 32768.0);
|
samples.push(avg / 32768.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(WavData { samples, sample_rate })
|
Ok(WavData {
|
||||||
|
samples,
|
||||||
|
sample_rate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
|
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
|
||||||
|
|||||||
+13
-7
@@ -76,10 +76,7 @@ pub fn sanitize_filename(raw: &str) -> String {
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Drop control chars; turn other whitespace into single spaces later.
|
// Drop control chars; turn other whitespace into single spaces later.
|
||||||
let cleaned: String = base
|
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_control())
|
|
||||||
.collect();
|
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
let collapsed = collapsed.trim_matches('.').trim();
|
let collapsed = collapsed.trim_matches('.').trim();
|
||||||
|
|
||||||
@@ -145,7 +142,10 @@ pub fn looks_like_audio_name(name: &str) -> bool {
|
|||||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
matches!(
|
||||||
|
extension.to_ascii_lowercase().as_str(),
|
||||||
|
"wav" | "mp3" | "ogg" | "oga" | "flac"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||||
@@ -240,7 +240,11 @@ mod tests {
|
|||||||
let long_stem = "x".repeat(200);
|
let long_stem = "x".repeat(200);
|
||||||
let name = format!("{long_stem}.png");
|
let name = format!("{long_stem}.png");
|
||||||
let out = sanitize_filename(&name);
|
let out = sanitize_filename(&name);
|
||||||
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count());
|
assert!(
|
||||||
|
out.chars().count() <= MAX_FILENAME_LEN,
|
||||||
|
"len was {}",
|
||||||
|
out.chars().count()
|
||||||
|
);
|
||||||
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +258,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_sniffing_recognizes_containers() {
|
fn image_sniffing_recognizes_containers() {
|
||||||
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]));
|
assert!(is_probably_image(&[
|
||||||
|
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0
|
||||||
|
]));
|
||||||
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
||||||
assert!(is_probably_image(b"GIF89a...."));
|
assert!(is_probably_image(b"GIF89a...."));
|
||||||
let mut webp = b"RIFF".to_vec();
|
let mut webp = b"RIFF".to_vec();
|
||||||
|
|||||||
+22
-8
@@ -66,7 +66,11 @@ impl FriendStore {
|
|||||||
if self.contains(&id) {
|
if self.contains(&id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.friends.push(Friend { id, name, last_addr: addr });
|
self.friends.push(Friend {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
last_addr: addr,
|
||||||
|
});
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
|
|||||||
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
||||||
/// overwritten with an empty list.
|
/// overwritten with an empty list.
|
||||||
pub fn load() -> Result<FriendStore> {
|
pub fn load() -> Result<FriendStore> {
|
||||||
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
let path =
|
||||||
|
friends_path().context("could not determine a config directory for the friends list")?;
|
||||||
load_at(&path)
|
load_at(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
||||||
pub fn save(store: &FriendStore) -> Result<()> {
|
pub fn save(store: &FriendStore) -> Result<()> {
|
||||||
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
let path =
|
||||||
|
friends_path().context("could not determine a config directory for the friends list")?;
|
||||||
save_at(&path, store)
|
save_at(&path, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
||||||
fn load_at(path: &Path) -> Result<FriendStore> {
|
fn load_at(path: &Path) -> Result<FriendStore> {
|
||||||
match fs::read_to_string(path) {
|
match fs::read_to_string(path) {
|
||||||
Ok(s) => serde_json::from_str(&s)
|
Ok(s) => {
|
||||||
.with_context(|| format!("failed to parse {}", path.display())),
|
serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
}
|
}
|
||||||
@@ -141,11 +148,14 @@ fn load_at(path: &Path) -> Result<FriendStore> {
|
|||||||
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
|
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
|
||||||
/// rename, so a crash mid-write can't leave a truncated list.
|
/// rename, so a crash mid-write can't leave a truncated list.
|
||||||
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
|
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
|
||||||
let parent = path.parent().context("friends path has no parent directory")?;
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("friends path has no parent directory")?;
|
||||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
|
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
|
||||||
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
|
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
|
||||||
fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?;
|
fs::write(&tmp, json.as_bytes())
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
fs::rename(&tmp, path)
|
fs::rename(&tmp, path)
|
||||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -219,7 +229,11 @@ mod tests {
|
|||||||
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
||||||
fn temp_path(tag: &str) -> PathBuf {
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
let mut p = std::env::temp_dir();
|
let mut p = std::env::temp_dir();
|
||||||
p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag));
|
p.push(format!(
|
||||||
|
"peerspeak-friendstest-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
tag
|
||||||
|
));
|
||||||
p.push("friends.json");
|
p.push("friends.json");
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-12
@@ -9,11 +9,9 @@
|
|||||||
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||||
//! debounce is unit-tested without any I/O.
|
//! debounce is unit-tested without any I/O.
|
||||||
|
|
||||||
use super::{
|
|
||||||
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
|
||||||
};
|
|
||||||
use super::scan;
|
use super::scan;
|
||||||
use super::steam::SteamProbe;
|
use super::steam::SteamProbe;
|
||||||
|
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -145,9 +143,14 @@ fn worker_loop(
|
|||||||
let steam_game = steam.detect();
|
let steam_game = steam.detect();
|
||||||
let processes = scan::running_executables();
|
let processes = scan::running_executables();
|
||||||
|
|
||||||
if let Some(new_current) =
|
if let Some(new_current) = poll_once(
|
||||||
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
&mut debouncer,
|
||||||
{
|
&override_,
|
||||||
|
steam_game,
|
||||||
|
&processes,
|
||||||
|
&process_map,
|
||||||
|
&denylist,
|
||||||
|
) {
|
||||||
// A closed receiver means core shut down; stop quietly.
|
// A closed receiver means core shut down; stop quietly.
|
||||||
if tx.send(new_current).is_err() {
|
if tx.send(new_current).is_err() {
|
||||||
return;
|
return;
|
||||||
@@ -169,11 +172,18 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||||
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
DetectedGame {
|
||||||
|
id: id.into(),
|
||||||
|
name: Some(name.into()),
|
||||||
|
source,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -185,17 +195,38 @@ mod tests {
|
|||||||
|
|
||||||
// First poll: detected but not yet published (needs two hits).
|
// First poll: detected but not yet published (needs two hits).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(steam.clone()),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
// Second poll: published.
|
// Second poll: published.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(steam.clone()),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
Some(Some(steam))
|
Some(Some(steam))
|
||||||
);
|
);
|
||||||
// Third identical poll: no change event.
|
// Third identical poll: no change event.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(game("steam:730", "CS2", GameSource::Steam)),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +240,9 @@ mod tests {
|
|||||||
|
|
||||||
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
let published = change.expect("should publish on second hit").expect("a game");
|
let published = change
|
||||||
|
.expect("should publish on second hit")
|
||||||
|
.expect("a game");
|
||||||
assert_eq!(published.id, "exe:hl2_linux");
|
assert_eq!(published.id, "exe:hl2_linux");
|
||||||
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-17
@@ -92,11 +92,20 @@ pub fn resolve(
|
|||||||
processes: &[DetectedGame],
|
processes: &[DetectedGame],
|
||||||
) -> Resolution {
|
) -> Resolution {
|
||||||
match override_ {
|
match override_ {
|
||||||
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
ManualOverride::ForceNone => Resolution {
|
||||||
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
game: None,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
ManualOverride::Force(g) => Resolution {
|
||||||
|
game: Some(g.clone()),
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
ManualOverride::Auto => {
|
ManualOverride::Auto => {
|
||||||
let game = steam.or_else(|| processes.first().cloned());
|
let game = steam.or_else(|| processes.first().cloned());
|
||||||
Resolution { game, immediate: false }
|
Resolution {
|
||||||
|
game,
|
||||||
|
immediate: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +201,11 @@ impl Debouncer {
|
|||||||
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||||
/// hypothetical `minecraft`), trims surrounding whitespace.
|
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||||
pub fn normalize_exe(raw: &str) -> String {
|
pub fn normalize_exe(raw: &str) -> String {
|
||||||
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
raw.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||||
@@ -245,8 +258,10 @@ pub fn match_processes(
|
|||||||
denylist: &BTreeSet<&str>,
|
denylist: &BTreeSet<&str>,
|
||||||
) -> Vec<DetectedGame> {
|
) -> Vec<DetectedGame> {
|
||||||
// Normalize the user map once so lookups are basename/case-insensitive.
|
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||||
let normalized_map: BTreeMap<String, &String> =
|
let normalized_map: BTreeMap<String, &String> = user_map
|
||||||
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
.iter()
|
||||||
|
.map(|(k, v)| (normalize_exe(k), v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||||
let mut out: Vec<DetectedGame> = Vec::new();
|
let mut out: Vec<DetectedGame> = Vec::new();
|
||||||
@@ -288,8 +303,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stable_ids_are_namespaced() {
|
fn stable_ids_are_namespaced() {
|
||||||
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||||
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
assert_eq!(
|
||||||
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
DetectedGame::exe_id("/usr/games/hl2_linux"),
|
||||||
|
"exe:hl2_linux"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
|
||||||
|
"exe:minecraft.exe"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -318,8 +339,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolve_falls_back_to_first_process_then_none() {
|
fn resolve_falls_back_to_first_process_then_none() {
|
||||||
let procs = vec![
|
let procs = vec![
|
||||||
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
DetectedGame {
|
||||||
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
id: "exe:a".into(),
|
||||||
|
name: Some("A".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
},
|
||||||
|
DetectedGame {
|
||||||
|
id: "exe:b".into(),
|
||||||
|
name: Some("B".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
let r = resolve(&ManualOverride::Auto, None, &procs);
|
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||||
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||||
@@ -426,7 +455,10 @@ mod tests {
|
|||||||
// --- process matching --------------------------------------------------
|
// --- process matching --------------------------------------------------
|
||||||
|
|
||||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -461,14 +493,13 @@ mod tests {
|
|||||||
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||||
let deny = builtin_denylist();
|
let deny = builtin_denylist();
|
||||||
// Same game twice (two processes) + reverse discovery order.
|
// Same game twice (two processes) + reverse discovery order.
|
||||||
let running = vec![
|
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
|
||||||
"/b/zed".into(),
|
|
||||||
"/a/alpha".into(),
|
|
||||||
"/c/alpha".into(),
|
|
||||||
];
|
|
||||||
let got = match_processes(&running, &user, &deny);
|
let got = match_processes(&running, &user, &deny);
|
||||||
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||||
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
assert_eq!(
|
||||||
|
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["exe:alpha", "exe:zed"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+13
-7
@@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec<String> {
|
|||||||
fn windows_toolhelp_executables() -> Vec<String> {
|
fn windows_toolhelp_executables() -> Vec<String> {
|
||||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||||
TH32CS_SNAPPROCESS,
|
TH32CS_SNAPPROCESS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec<String> {
|
|||||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||||
while ok != 0 {
|
while ok != 0 {
|
||||||
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||||
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len());
|
let end = entry
|
||||||
|
.szExeFile
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(entry.szExeFile.len());
|
||||||
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
out.push(name);
|
out.push(name);
|
||||||
@@ -93,15 +97,16 @@ fn windows_toolhelp_executables() -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn enumerates_at_least_this_process() {
|
fn enumerates_at_least_this_process() {
|
||||||
// The test runner itself is a process, so /proc enumeration must be
|
// The test runner itself is a process, so /proc enumeration must be
|
||||||
// non-empty and include something that normalizes to our own exe basename.
|
// 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");
|
assert!(
|
||||||
|
!exes.is_empty(),
|
||||||
|
"expected to see running processes via /proc"
|
||||||
|
);
|
||||||
// Our own /proc/self/exe basename should appear among them.
|
// Our own /proc/self/exe basename should appear among them.
|
||||||
let me = std::fs::read_link("/proc/self/exe")
|
let me = std::fs::read_link("/proc/self/exe")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -109,7 +114,8 @@ mod tests {
|
|||||||
if let Some(me) = me {
|
if let Some(me) = me {
|
||||||
let me_norm = super::super::normalize_exe(&me);
|
let me_norm = super::super::normalize_exe(&me);
|
||||||
assert!(
|
assert!(
|
||||||
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
|
exes.iter()
|
||||||
|
.any(|e| super::super::normalize_exe(e) == me_norm),
|
||||||
"running list should include our own executable {me_norm:?}"
|
"running list should include our own executable {me_norm:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-20
@@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
|||||||
|
|
||||||
#[cfg(any(windows, test))]
|
#[cfg(any(windows, test))]
|
||||||
fn validate_reg_len(len: u32) -> Option<usize> {
|
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||||
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
|
||||||
.then_some(len as usize / 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
#[cfg(any(windows, test))]
|
||||||
@@ -48,7 +47,14 @@ fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
|||||||
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||||
let root = vdf::parse(registry_vdf).ok()?;
|
let root = vdf::parse(registry_vdf).ok()?;
|
||||||
let raw = root
|
let raw = root
|
||||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
.get_path(&[
|
||||||
|
"Registry",
|
||||||
|
"HKCU",
|
||||||
|
"Software",
|
||||||
|
"Valve",
|
||||||
|
"Steam",
|
||||||
|
"RunningAppID",
|
||||||
|
])
|
||||||
.and_then(Value::as_str)?;
|
.and_then(Value::as_str)?;
|
||||||
let id: u32 = raw.trim().parse().ok()?;
|
let id: u32 = raw.trim().parse().ok()?;
|
||||||
(id != 0).then_some(id)
|
(id != 0).then_some(id)
|
||||||
@@ -196,7 +202,13 @@ impl SteamProbe {
|
|||||||
return cached.name.clone();
|
return cached.name.clone();
|
||||||
}
|
}
|
||||||
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||||
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
self.manifests.insert(
|
||||||
|
app_id,
|
||||||
|
CachedManifest {
|
||||||
|
mtime,
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,11 @@ impl SteamProbe {
|
|||||||
paths.push(root.clone());
|
paths.push(root.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
self.libraries = CachedLibraries {
|
||||||
|
source,
|
||||||
|
mtime,
|
||||||
|
paths: paths.clone(),
|
||||||
|
};
|
||||||
paths
|
paths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,8 +376,8 @@ mod win {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
use windows_sys::Win32::System::Registry::{
|
use windows_sys::Win32::System::Registry::{
|
||||||
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
|
||||||
REG_DWORD, REG_SZ,
|
RegQueryValueExW,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||||
@@ -374,9 +390,8 @@ mod win {
|
|||||||
let subkey = wide("Software\\Valve\\Steam");
|
let subkey = wide("Software\\Valve\\Steam");
|
||||||
let mut hkey: HKEY = std::ptr::null_mut();
|
let mut hkey: HKEY = std::ptr::null_mut();
|
||||||
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||||
let rc = unsafe {
|
let rc =
|
||||||
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
|
||||||
};
|
|
||||||
(rc == ERROR_SUCCESS).then_some(hkey)
|
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,13 +478,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_string_lengths_are_bounded_and_trimmed() {
|
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||||
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
assert_eq!(
|
||||||
|
validate_reg_len(5),
|
||||||
|
None,
|
||||||
|
"odd byte lengths are invalid UTF-16"
|
||||||
|
);
|
||||||
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||||
assert_eq!(validate_reg_len(8), Some(4));
|
assert_eq!(validate_reg_len(8), Some(4));
|
||||||
|
|
||||||
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||||
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||||
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
assert_eq!(
|
||||||
|
decode_reg_sz(raw, returned_bytes).as_deref(),
|
||||||
|
Some("C:\\Steam")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -495,10 +517,13 @@ mod tests {
|
|||||||
"contentstatsid" "12345"
|
"contentstatsid" "12345"
|
||||||
}"#;
|
}"#;
|
||||||
let got = parse_library_paths(current);
|
let got = parse_library_paths(current);
|
||||||
assert_eq!(got, vec![
|
assert_eq!(
|
||||||
PathBuf::from("/home/eric/.local/share/Steam"),
|
got,
|
||||||
PathBuf::from("/mnt/games/SteamLibrary"),
|
vec![
|
||||||
]);
|
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||||
|
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Legacy shape: numeric keys map straight to path strings.
|
// Legacy shape: numeric keys map straight to path strings.
|
||||||
let legacy = r#""LibraryFolders" {
|
let legacy = r#""LibraryFolders" {
|
||||||
@@ -522,13 +547,25 @@ mod tests {
|
|||||||
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||||
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||||
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
// Absent → None (a non-Steam process).
|
// Absent → None (a non-Steam process).
|
||||||
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
// Not fooled by a different var that merely contains the substring.
|
// Not fooled by a different var that merely contains the substring.
|
||||||
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
// Garbage value → None, no panic.
|
// Garbage value → None, no panic.
|
||||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+33
-7
@@ -234,10 +234,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
let root = parse(acf).unwrap();
|
let root = parse(acf).unwrap();
|
||||||
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
assert_eq!(
|
||||||
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
root.get_path(&["AppState", "name"]).and_then(Value::as_str),
|
||||||
|
Some("Counter-Strike 2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
root.get_path(&["AppState", "appid"])
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("730")
|
||||||
|
);
|
||||||
// Case-insensitive key lookup.
|
// Case-insensitive key lookup.
|
||||||
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
assert_eq!(
|
||||||
|
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
|
||||||
|
Some("Counter-Strike 2")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -262,8 +272,14 @@ mod tests {
|
|||||||
"#;
|
"#;
|
||||||
let root = parse(vdf).unwrap();
|
let root = parse(vdf).unwrap();
|
||||||
let lf = root.get("libraryfolders").unwrap();
|
let lf = root.get("libraryfolders").unwrap();
|
||||||
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
assert_eq!(
|
||||||
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
lf.get_path(&["0", "path"]).and_then(Value::as_str),
|
||||||
|
Some(r"C:\Program Files (x86)\Steam")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lf.get_path(&["1", "path"]).and_then(Value::as_str),
|
||||||
|
Some("/home/eric/.local/share/Steam")
|
||||||
|
);
|
||||||
// The library folder ids are iterable for discovery.
|
// The library folder ids are iterable for discovery.
|
||||||
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||||
assert_eq!(ids, vec!["0", "1"]);
|
assert_eq!(ids, vec!["0", "1"]);
|
||||||
@@ -292,7 +308,14 @@ mod tests {
|
|||||||
"#;
|
"#;
|
||||||
let root = parse(reg).unwrap();
|
let root = parse(reg).unwrap();
|
||||||
let appid = root
|
let appid = root
|
||||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
.get_path(&[
|
||||||
|
"Registry",
|
||||||
|
"HKCU",
|
||||||
|
"Software",
|
||||||
|
"Valve",
|
||||||
|
"Steam",
|
||||||
|
"RunningAppID",
|
||||||
|
])
|
||||||
.and_then(Value::as_str);
|
.and_then(Value::as_str);
|
||||||
assert_eq!(appid, Some("570"));
|
assert_eq!(appid, Some("570"));
|
||||||
}
|
}
|
||||||
@@ -301,7 +324,10 @@ mod tests {
|
|||||||
fn handles_comments_and_barewords() {
|
fn handles_comments_and_barewords() {
|
||||||
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||||
let root = parse(vdf).unwrap();
|
let root = parse(vdf).unwrap();
|
||||||
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
assert_eq!(
|
||||||
|
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
|
||||||
|
Some("barevalue")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+7
-4
@@ -89,9 +89,9 @@ impl HotkeyAction {
|
|||||||
|
|
||||||
pub fn tier(self) -> HotkeyTier {
|
pub fn tier(self) -> HotkeyTier {
|
||||||
match self {
|
match self {
|
||||||
HotkeyAction::ToggleMute
|
HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
|
||||||
| HotkeyAction::ToggleDeafen
|
HotkeyTier::AppWide
|
||||||
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
}
|
||||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_single_character_case_folds() {
|
fn parse_single_character_case_folds() {
|
||||||
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
|
assert_eq!(
|
||||||
|
parse_binding("M"),
|
||||||
|
Some(KeyBinding::Character("m".to_string()))
|
||||||
|
);
|
||||||
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
|
|||||||
/// A *missing* file (first ever run, or right after a reset) is the normal
|
/// A *missing* file (first ever run, or right after a reset) is the normal
|
||||||
/// create path.
|
/// create path.
|
||||||
pub fn load_or_create() -> Result<SecretKey> {
|
pub fn load_or_create() -> Result<SecretKey> {
|
||||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
let path =
|
||||||
|
identity_path().context("could not determine a config directory for the identity key")?;
|
||||||
load_or_create_at(&path)
|
load_or_create_at(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ pub fn load_or_create() -> Result<SecretKey> {
|
|||||||
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
|
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
|
||||||
/// unrecoverable, so callers should confirm with the user first.
|
/// unrecoverable, so callers should confirm with the user first.
|
||||||
pub fn regenerate() -> Result<SecretKey> {
|
pub fn regenerate() -> Result<SecretKey> {
|
||||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
let path =
|
||||||
|
identity_path().context("could not determine a config directory for the identity key")?;
|
||||||
let key = SecretKey::generate();
|
let key = SecretKey::generate();
|
||||||
save_at(&path, &key)?;
|
save_at(&path, &key)?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
|
|||||||
|
|
||||||
/// Atomic, `0600` write at the default identity path. See [`save_at`].
|
/// Atomic, `0600` write at the default identity path. See [`save_at`].
|
||||||
pub fn save(key: &SecretKey) -> Result<()> {
|
pub fn save(key: &SecretKey) -> Result<()> {
|
||||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
let path =
|
||||||
|
identity_path().context("could not determine a config directory for the identity key")?;
|
||||||
save_at(&path, key)
|
save_at(&path, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +83,15 @@ fn load_or_create_at(path: &std::path::Path) -> Result<SecretKey> {
|
|||||||
/// perms are applied before the rename so the secret is never briefly
|
/// perms are applied before the rename so the secret is never briefly
|
||||||
/// world-readable.
|
/// world-readable.
|
||||||
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
|
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
|
||||||
let parent = path.parent().context("identity path has no parent directory")?;
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.context("identity path has no parent directory")?;
|
||||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
|
||||||
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||||
{
|
{
|
||||||
let mut f =
|
let mut f = fs::File::create(&tmp)
|
||||||
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?;
|
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|||||||
+28
-21
@@ -1,27 +1,27 @@
|
|||||||
pub mod audio;
|
|
||||||
pub mod codec;
|
|
||||||
pub mod dsp;
|
|
||||||
pub mod network;
|
|
||||||
pub mod protocol;
|
|
||||||
pub mod core;
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod audio;
|
||||||
pub mod identity;
|
|
||||||
pub mod friends;
|
|
||||||
pub mod presence;
|
|
||||||
pub mod presence_net;
|
|
||||||
pub mod theme;
|
|
||||||
pub mod notify;
|
|
||||||
pub mod screenshare;
|
|
||||||
pub mod sanitize;
|
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod recents;
|
pub mod codec;
|
||||||
|
pub mod config;
|
||||||
|
pub mod core;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod dsp;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod playlist;
|
pub mod friends;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
|
pub mod hotkeys;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod network;
|
||||||
|
pub mod notify;
|
||||||
|
pub mod playlist;
|
||||||
|
pub mod presence;
|
||||||
|
pub mod presence_net;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod recents;
|
||||||
|
pub mod sanitize;
|
||||||
|
pub mod screenshare;
|
||||||
|
pub mod theme;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||||
bytes.iter()
|
bytes
|
||||||
|
.iter()
|
||||||
.take(6)
|
.take(6)
|
||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
fn rotated_log_path(path: &Path) -> PathBuf {
|
||||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("peerspeak.log");
|
||||||
path.with_file_name(format!("{file_name}.1"))
|
path.with_file_name(format!("{file_name}.1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +104,10 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
|
|||||||
let rotated = rotated_log_path(path);
|
let rotated = rotated_log_path(path);
|
||||||
let _ = std::fs::remove_file(&rotated);
|
let _ = std::fs::remove_file(&rotated);
|
||||||
if std::fs::rename(path, &rotated).is_err() {
|
if std::fs::rename(path, &rotated).is_err() {
|
||||||
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
|
let _ = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+274
-95
@@ -1,14 +1,14 @@
|
|||||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
use crate::network::{NetError, PeerSpeakTicket, PeerState, RoomEvent, RoomState};
|
||||||
|
use async_trait::async_trait;
|
||||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
|
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
|
||||||
use iroh_gossip::net::Gossip;
|
use iroh_gossip::net::Gossip;
|
||||||
use iroh_gossip::proto::TopicId;
|
use iroh_gossip::proto::TopicId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
|
|
||||||
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
||||||
/// can never be lifted out of this protocol/version into another context.
|
/// can never be lifted out of this protocol/version into another context.
|
||||||
@@ -78,7 +78,12 @@ fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMe
|
|||||||
fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload {
|
fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload {
|
||||||
let author = secret.public();
|
let author = secret.public();
|
||||||
let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg));
|
let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg));
|
||||||
GossipPayload { author, ts, msg, sig }
|
GossipPayload {
|
||||||
|
author,
|
||||||
|
ts,
|
||||||
|
msg,
|
||||||
|
sig,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a received gossip payload was rejected (logging + tests).
|
/// Why a received gossip payload was rejected (logging + tests).
|
||||||
@@ -233,7 +238,10 @@ impl ClockSkewMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drop_oldest_authors(&mut self) {
|
fn drop_oldest_authors(&mut self) {
|
||||||
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
let remove_count = self
|
||||||
|
.authors
|
||||||
|
.len()
|
||||||
|
.saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||||
let mut by_age: Vec<_> = self
|
let mut by_age: Vec<_> = self
|
||||||
.authors
|
.authors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -339,7 +347,8 @@ fn verify_gossip(
|
|||||||
return Err(GossipReject::OutOfWindow);
|
return Err(GossipReject::OutOfWindow);
|
||||||
}
|
}
|
||||||
if let GossipMessage::Announce(state) = &payload.msg
|
if let GossipMessage::Announce(state) = &payload.msg
|
||||||
&& state.addr.id != payload.author {
|
&& state.addr.id != payload.author
|
||||||
|
{
|
||||||
return Err(GossipReject::AnnounceAddressMismatch);
|
return Err(GossipReject::AnnounceAddressMismatch);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -466,7 +475,8 @@ impl RoomState for IrohGossipState {
|
|||||||
// Make every dial target resolvable: the ticket host plus any retained
|
// Make every dial target resolvable: the ticket host plus any retained
|
||||||
// peers handed in (their addresses may have aged out of a fresh endpoint's
|
// peers handed in (their addresses may have aged out of a fresh endpoint's
|
||||||
// book even though the persistent lookup usually still holds them).
|
// book even though the persistent lookup usually still holds them).
|
||||||
self.address_lookup.add_endpoint_info(ticket.host_addr.clone());
|
self.address_lookup
|
||||||
|
.add_endpoint_info(ticket.host_addr.clone());
|
||||||
for addr in &extra_bootstrap {
|
for addr in &extra_bootstrap {
|
||||||
self.address_lookup.add_endpoint_info(addr.clone());
|
self.address_lookup.add_endpoint_info(addr.clone());
|
||||||
}
|
}
|
||||||
@@ -478,7 +488,10 @@ impl RoomState for IrohGossipState {
|
|||||||
compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
|
compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
|
||||||
crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));
|
crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));
|
||||||
|
|
||||||
let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await
|
let gossip_topic = self
|
||||||
|
.gossip
|
||||||
|
.subscribe(topic_id, bootstrap_peers)
|
||||||
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
let err = format!("Failed to join gossip topic: {}", e);
|
let err = format!("Failed to join gossip topic: {}", e);
|
||||||
crate::log_msg(&err);
|
crate::log_msg(&err);
|
||||||
@@ -505,32 +518,44 @@ impl RoomState for IrohGossipState {
|
|||||||
let topic_bytes = ticket.topic_id;
|
let topic_bytes = ticket.topic_id;
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
crate::log_msg(&format!(
|
||||||
|
"Spawned gossip topic loop for self_id={:?}",
|
||||||
|
self_id
|
||||||
|
));
|
||||||
let mut state_mutations_seen = HashMap::new();
|
let mut state_mutations_seen = HashMap::new();
|
||||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||||
|
|
||||||
// Broadcast initial state
|
// Broadcast initial state
|
||||||
let initial_payload = {
|
let initial_payload = {
|
||||||
let guard = self_state_clone.lock().unwrap();
|
let guard = self_state_clone.lock().unwrap();
|
||||||
guard.as_ref().map(|s| sign_gossip(
|
guard.as_ref().map(|s| {
|
||||||
&secret_key,
|
sign_gossip(
|
||||||
&topic_bytes,
|
&secret_key,
|
||||||
now_millis(),
|
&topic_bytes,
|
||||||
GossipMessage::Announce(s.clone()),
|
now_millis(),
|
||||||
))
|
GossipMessage::Announce(s.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(payload) = initial_payload
|
if let Some(payload) = initial_payload
|
||||||
&& let Ok(bytes) = serde_json::to_vec(&payload) {
|
&& let Ok(bytes) = serde_json::to_vec(&payload)
|
||||||
crate::log_msg(&format!("Broadcasting initial state from self_id={:?}", self_id));
|
{
|
||||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
crate::log_msg(&format!(
|
||||||
}
|
"Broadcasting initial state from self_id={:?}",
|
||||||
|
self_id
|
||||||
|
));
|
||||||
|
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Stream topic messages
|
// Stream topic messages
|
||||||
while let Some(res) = gossip_receiver.next().await {
|
while let Some(res) = gossip_receiver.next().await {
|
||||||
match res {
|
match res {
|
||||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
crate::log_msg(&format!(
|
||||||
|
"Gossip received Event::Received from delivery={:?}",
|
||||||
|
msg.delivered_from
|
||||||
|
));
|
||||||
// Reject oversized frames BEFORE deserializing: parsing
|
// Reject oversized frames BEFORE deserializing: parsing
|
||||||
// allocates, so a size check has to precede `from_slice` to
|
// allocates, so a size check has to precede `from_slice` to
|
||||||
// bound the memory a hostile peer can make us hold.
|
// bound the memory a hostile peer can make us hold.
|
||||||
@@ -577,13 +602,13 @@ impl RoomState for IrohGossipState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let our_id = {
|
let our_id = {
|
||||||
self_state_clone.lock().unwrap()
|
self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id)
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.addr.id)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if Some(payload.author) == our_id {
|
if Some(payload.author) == our_id {
|
||||||
crate::log_msg("Gossip Event::Received from ourselves; ignoring");
|
crate::log_msg(
|
||||||
|
"Gossip Event::Received from ourselves; ignoring",
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +660,9 @@ impl RoomState for IrohGossipState {
|
|||||||
// Screen-share tickets are capabilities and
|
// Screen-share tickets are capabilities and
|
||||||
// peer-supplied: cap/validate once at ingest
|
// peer-supplied: cap/validate once at ingest
|
||||||
// so invalid offers never render a Watch button.
|
// so invalid offers never render a Watch button.
|
||||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
state.sharing = state
|
||||||
|
.sharing
|
||||||
|
.and_then(crate::screenshare::sanitize_ticket);
|
||||||
// The game-presence label is untrusted
|
// The game-presence label is untrusted
|
||||||
// peer text like the name: sanitize +
|
// peer text like the name: sanitize +
|
||||||
// length-cap at ingest (strip bidi/control,
|
// length-cap at ingest (strip bidi/control,
|
||||||
@@ -651,8 +678,11 @@ impl RoomState for IrohGossipState {
|
|||||||
// bounds a future fetch (reject anything
|
// bounds a future fetch (reject anything
|
||||||
// outside the attachment cap).
|
// outside the attachment cap).
|
||||||
state.music = state.music.and_then(|mut m| {
|
state.music = state.music.and_then(|mut m| {
|
||||||
let name = crate::sanitize::sanitize_game_label(&m.name);
|
let name =
|
||||||
if name.is_empty() || !crate::files::size_within_cap(m.size) {
|
crate::sanitize::sanitize_game_label(&m.name);
|
||||||
|
if name.is_empty()
|
||||||
|
|| !crate::files::size_within_cap(m.size)
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
m.name = name;
|
m.name = name;
|
||||||
@@ -679,8 +709,10 @@ impl RoomState for IrohGossipState {
|
|||||||
// cleared ONLY once re-admitted — clearing it before
|
// cleared ONLY once re-admitted — clearing it before
|
||||||
// a possible reject would orphan its recovery state
|
// a possible reject would orphan its recovery state
|
||||||
// (Tier C F-01 audit).
|
// (Tier C F-01 audit).
|
||||||
let is_reconnecting =
|
let is_reconnecting = disconnected_peers
|
||||||
disconnected_peers.lock().unwrap().contains(&payload.author);
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&payload.author);
|
||||||
let admitted = {
|
let admitted = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
let is_new = !peer_map.contains_key(&payload.author);
|
let is_new = !peer_map.contains_key(&payload.author);
|
||||||
@@ -689,11 +721,17 @@ impl RoomState for IrohGossipState {
|
|||||||
// memory/tasks/dials without bound (Tier C
|
// memory/tasks/dials without bound (Tier C
|
||||||
// F-01). Existing-peer updates and reconnects
|
// F-01). Existing-peer updates and reconnects
|
||||||
// are exempt; only brand-new authors are gated.
|
// are exempt; only brand-new authors are gated.
|
||||||
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
|
let subject_to_cap =
|
||||||
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
|
announce_subject_to_cap(is_new, is_reconnecting);
|
||||||
|
if !admit_into_roster(
|
||||||
|
peer_map.len(),
|
||||||
|
subject_to_cap,
|
||||||
|
MAX_ACTIVE_PEERS,
|
||||||
|
) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
let state_changed =
|
||||||
|
peer_map.get(&payload.author) != Some(&state);
|
||||||
if is_new || state_changed {
|
if is_new || state_changed {
|
||||||
peer_map.insert(payload.author, state.clone());
|
peer_map.insert(payload.author, state.clone());
|
||||||
}
|
}
|
||||||
@@ -723,35 +761,54 @@ impl RoomState for IrohGossipState {
|
|||||||
// address set, so leave/re-announce cycles
|
// address set, so leave/re-announce cycles
|
||||||
// can't accumulate attacker-supplied history
|
// can't accumulate attacker-supplied history
|
||||||
// (Tier C F-01).
|
// (Tier C F-01).
|
||||||
let _ = address_lookup.set_endpoint_info(state.addr.clone());
|
let _ = address_lookup
|
||||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
.set_endpoint_info(state.addr.clone());
|
||||||
|
let _ = event_tx
|
||||||
|
.send(RoomEvent::PeerJoined(payload.author, state))
|
||||||
|
.await;
|
||||||
} else if state_changed {
|
} else if state_changed {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
"Gossip peer state updated: {}, state: {}",
|
"Gossip peer state updated: {}, state: {}",
|
||||||
crate::short_id(&payload.author.to_string()),
|
crate::short_id(&payload.author.to_string()),
|
||||||
peer_state_for_log(&state)
|
peer_state_for_log(&state)
|
||||||
));
|
));
|
||||||
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
let _ = event_tx
|
||||||
|
.send(RoomEvent::PeerUpdated(payload.author, state))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GossipMessage::Leave => {
|
GossipMessage::Leave => {
|
||||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
crate::log_msg(&format!(
|
||||||
|
"Gossip peer leave request from author={:?}",
|
||||||
|
payload.author
|
||||||
|
));
|
||||||
// Drop this id's address-lookup entry so cycling
|
// Drop this id's address-lookup entry so cycling
|
||||||
// distinct identities through Announce→Leave can't
|
// distinct identities through Announce→Leave can't
|
||||||
// grow the lookup for the room's lifetime (Tier C
|
// grow the lookup for the room's lifetime (Tier C
|
||||||
// F-01 audit). Re-announce re-populates it.
|
// F-01 audit). Re-announce re-populates it.
|
||||||
let _ = address_lookup.remove_endpoint_info(payload.author);
|
let _ = address_lookup.remove_endpoint_info(payload.author);
|
||||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
let removed =
|
||||||
|
peers.lock().unwrap().remove(&payload.author).is_some();
|
||||||
let was_disconnected = disconnected_peers
|
let was_disconnected = disconnected_peers
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.remove(&payload.author);
|
.remove(&payload.author);
|
||||||
if removed || was_disconnected {
|
if removed || was_disconnected {
|
||||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
let _ = event_tx
|
||||||
|
.send(RoomEvent::PeerLeft(payload.author))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GossipMessage::Chat { name, text, ts, attachment } => {
|
GossipMessage::Chat {
|
||||||
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
|
name,
|
||||||
|
text,
|
||||||
|
ts,
|
||||||
|
attachment,
|
||||||
|
} => {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Gossip chat from author={:?}",
|
||||||
|
payload.author
|
||||||
|
));
|
||||||
// Defensively normalize an untrusted attachment
|
// Defensively normalize an untrusted attachment
|
||||||
// descriptor: sanitize the filename and drop it
|
// descriptor: sanitize the filename and drop it
|
||||||
// entirely if it declares an out-of-cap size.
|
// entirely if it declares an out-of-cap size.
|
||||||
@@ -762,18 +819,23 @@ impl RoomState for IrohGossipState {
|
|||||||
a.name = crate::files::sanitize_filename(&a.name);
|
a.name = crate::files::sanitize_filename(&a.name);
|
||||||
Some(a)
|
Some(a)
|
||||||
});
|
});
|
||||||
let _ = event_tx.send(RoomEvent::ChatMessage {
|
let _ = event_tx
|
||||||
from: payload.author,
|
.send(RoomEvent::ChatMessage {
|
||||||
name,
|
from: payload.author,
|
||||||
text,
|
name,
|
||||||
ts,
|
text,
|
||||||
attachment,
|
ts,
|
||||||
}).await;
|
attachment,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_msg(&format!("Gossip failed to deserialize payload: {:?}", e));
|
crate::log_msg(&format!(
|
||||||
|
"Gossip failed to deserialize payload: {:?}",
|
||||||
|
e
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,18 +844,24 @@ impl RoomState for IrohGossipState {
|
|||||||
// Resend state on new neighbor connection to guarantee synchronization
|
// Resend state on new neighbor connection to guarantee synchronization
|
||||||
let payload_opt = {
|
let payload_opt = {
|
||||||
let guard = self_state_clone.lock().unwrap();
|
let guard = self_state_clone.lock().unwrap();
|
||||||
guard.as_ref().map(|state| sign_gossip(
|
guard.as_ref().map(|state| {
|
||||||
&secret_key,
|
sign_gossip(
|
||||||
&topic_bytes,
|
&secret_key,
|
||||||
now_millis(),
|
&topic_bytes,
|
||||||
GossipMessage::Announce(state.clone()),
|
now_millis(),
|
||||||
))
|
GossipMessage::Announce(state.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
if let Some(payload) = payload_opt
|
if let Some(payload) = payload_opt
|
||||||
&& let Ok(bytes) = serde_json::to_vec(&payload) {
|
&& let Ok(bytes) = serde_json::to_vec(&payload)
|
||||||
crate::log_msg(&format!("Broadcasting state to new neighbor={:?}", peer_id));
|
{
|
||||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
crate::log_msg(&format!(
|
||||||
}
|
"Broadcasting state to new neighbor={:?}",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
|
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => {
|
Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => {
|
||||||
crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id));
|
crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id));
|
||||||
@@ -807,7 +875,10 @@ impl RoomState for IrohGossipState {
|
|||||||
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
||||||
if removed {
|
if removed {
|
||||||
disconnected_peers.lock().unwrap().insert(peer_id);
|
disconnected_peers.lock().unwrap().insert(peer_id);
|
||||||
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
crate::log_msg(&format!(
|
||||||
|
"Peer connection lost (NeighborDown): {:?}",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,7 +915,9 @@ impl RoomState for IrohGossipState {
|
|||||||
);
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
crate::log_msg("Broadcasting updated self state to gossip");
|
crate::log_msg("Broadcasting updated self state to gossip");
|
||||||
sender.broadcast(bytes.into()).await
|
sender
|
||||||
|
.broadcast(bytes.into())
|
||||||
|
.await
|
||||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,10 +982,17 @@ impl RoomState for IrohGossipState {
|
|||||||
&self.secret_key,
|
&self.secret_key,
|
||||||
&topic,
|
&topic,
|
||||||
ts,
|
ts,
|
||||||
GossipMessage::Chat { name, text, ts, attachment },
|
GossipMessage::Chat {
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
ts,
|
||||||
|
attachment,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
sender.broadcast(bytes.into()).await
|
sender
|
||||||
|
.broadcast(bytes.into())
|
||||||
|
.await
|
||||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -934,12 +1014,7 @@ impl RoomState for IrohGossipState {
|
|||||||
|
|
||||||
let sender_opt = self.active_sender.lock().unwrap().take();
|
let sender_opt = self.active_sender.lock().unwrap().take();
|
||||||
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
||||||
let payload = sign_gossip(
|
let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave);
|
||||||
&self.secret_key,
|
|
||||||
&topic,
|
|
||||||
now_millis(),
|
|
||||||
GossipMessage::Leave,
|
|
||||||
);
|
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
crate::log_msg("Broadcasting Leave message to gossip");
|
crate::log_msg("Broadcasting Leave message to gossip");
|
||||||
let _ = sender.broadcast(bytes.into()).await;
|
let _ = sender.broadcast(bytes.into()).await;
|
||||||
@@ -1060,7 +1135,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
||||||
// A brand-new author counts against the cap...
|
// A brand-new author counts against the cap...
|
||||||
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
|
assert!(announce_subject_to_cap(
|
||||||
|
/* is_new */ true, /* is_reconnecting */ false
|
||||||
|
));
|
||||||
// ...but an ordinary update from an in-roster peer does not...
|
// ...but an ordinary update from an in-roster peer does not...
|
||||||
assert!(!announce_subject_to_cap(false, false));
|
assert!(!announce_subject_to_cap(false, false));
|
||||||
// ...and neither does a re-announce from a peer mid-reconnect, even
|
// ...and neither does a re-announce from a peer mid-reconnect, even
|
||||||
@@ -1070,7 +1147,11 @@ mod tests {
|
|||||||
// Combined with admit_into_roster: a reconnecting author passes at a full
|
// Combined with admit_into_roster: a reconnecting author passes at a full
|
||||||
// roster, a brand-new one does not.
|
// roster, a brand-new one does not.
|
||||||
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
||||||
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
|
assert!(!admit_into_roster(
|
||||||
|
3,
|
||||||
|
announce_subject_to_cap(true, false),
|
||||||
|
3
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1105,7 +1186,10 @@ mod tests {
|
|||||||
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
monitor.observe(author, -123_000, 69_999),
|
monitor.observe(author, -123_000, 69_999),
|
||||||
Some(ClockSkewWarning { author, skew_ms: -123_000 })
|
Some(ClockSkewWarning {
|
||||||
|
author,
|
||||||
|
skew_ms: -123_000
|
||||||
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
||||||
}
|
}
|
||||||
@@ -1125,7 +1209,10 @@ mod tests {
|
|||||||
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
monitor.observe(author, 128_000, 320_000),
|
monitor.observe(author, 128_000, 320_000),
|
||||||
Some(ClockSkewWarning { author, skew_ms: 128_000 })
|
Some(ClockSkewWarning {
|
||||||
|
author,
|
||||||
|
skew_ms: 128_000
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,11 +1228,17 @@ mod tests {
|
|||||||
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
monitor.observe(b, 121_000, 2_000),
|
monitor.observe(b, 121_000, 2_000),
|
||||||
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
|
Some(ClockSkewWarning {
|
||||||
|
author: b,
|
||||||
|
skew_ms: 121_000
|
||||||
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
monitor.observe(a, -121_000, 2_000),
|
monitor.observe(a, -121_000, 2_000),
|
||||||
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
|
Some(ClockSkewWarning {
|
||||||
|
author: a,
|
||||||
|
skew_ms: -121_000
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,7 +1295,10 @@ mod tests {
|
|||||||
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
|
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
|
||||||
let addr = EndpointAddr::from_parts(
|
let addr = EndpointAddr::from_parts(
|
||||||
id,
|
id,
|
||||||
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
|
[
|
||||||
|
TransportAddr::Relay(short.clone()),
|
||||||
|
TransportAddr::Relay(long),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
let out = sanitize_endpoint_addr(&addr);
|
let out = sanitize_endpoint_addr(&addr);
|
||||||
let relays: Vec<_> = out.relay_urls().cloned().collect();
|
let relays: Vec<_> = out.relay_urls().cloned().collect();
|
||||||
@@ -1222,7 +1318,12 @@ mod tests {
|
|||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [9u8; 32];
|
let topic = [9u8; 32];
|
||||||
let peer_state = sample_peer_state_for(secret.public());
|
let peer_state = sample_peer_state_for(secret.public());
|
||||||
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
let payload = sign_gossip(
|
||||||
|
&secret,
|
||||||
|
&topic,
|
||||||
|
1000,
|
||||||
|
GossipMessage::Announce(peer_state.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&payload).unwrap();
|
let serialized = serde_json::to_string(&payload).unwrap();
|
||||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
||||||
@@ -1252,7 +1353,13 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let serialized = serde_json::to_string(&original).unwrap();
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
if let GossipMessage::Chat { name, text, ts, attachment } = deserialized {
|
if let GossipMessage::Chat {
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
ts,
|
||||||
|
attachment,
|
||||||
|
} = deserialized
|
||||||
|
{
|
||||||
assert_eq!(name, "Alice");
|
assert_eq!(name, "Alice");
|
||||||
assert_eq!(text, "Hello");
|
assert_eq!(text, "Hello");
|
||||||
assert_eq!(ts, 123456789);
|
assert_eq!(ts, 123456789);
|
||||||
@@ -1305,7 +1412,10 @@ mod tests {
|
|||||||
// defaulting the attachment to None (serde(default)).
|
// defaulting the attachment to None (serde(default)).
|
||||||
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
|
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
|
||||||
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
|
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
|
||||||
if let GossipMessage::Chat { name, attachment, .. } = parsed {
|
if let GossipMessage::Chat {
|
||||||
|
name, attachment, ..
|
||||||
|
} = parsed
|
||||||
|
{
|
||||||
assert_eq!(name, "Old");
|
assert_eq!(name, "Old");
|
||||||
assert_eq!(attachment, None);
|
assert_eq!(attachment, None);
|
||||||
} else {
|
} else {
|
||||||
@@ -1368,7 +1478,10 @@ mod tests {
|
|||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [1u8; 32];
|
let topic = [1u8; 32];
|
||||||
let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
||||||
assert_eq!(verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(()));
|
assert_eq!(
|
||||||
|
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1392,7 +1505,12 @@ mod tests {
|
|||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [4u8; 32];
|
let topic = [4u8; 32];
|
||||||
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
||||||
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None };
|
p.msg = GossipMessage::Chat {
|
||||||
|
name: "x".into(),
|
||||||
|
text: "y".into(),
|
||||||
|
ts: 5_000,
|
||||||
|
attachment: None,
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||||
Err(GossipReject::BadSignature)
|
Err(GossipReject::BadSignature)
|
||||||
@@ -1418,16 +1536,34 @@ mod tests {
|
|||||||
let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
|
let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
|
||||||
// Far in the past relative to "now" → stale (replay).
|
// Far in the past relative to "now" → stale (replay).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS),
|
verify_gossip(
|
||||||
|
&p,
|
||||||
|
&topic,
|
||||||
|
1_000_000 + GOSSIP_FRESHNESS_MS + 1,
|
||||||
|
GOSSIP_FRESHNESS_MS
|
||||||
|
),
|
||||||
Err(GossipReject::OutOfWindow)
|
Err(GossipReject::OutOfWindow)
|
||||||
);
|
);
|
||||||
// Implausibly future.
|
// Implausibly future.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_gossip(&p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS),
|
verify_gossip(
|
||||||
|
&p,
|
||||||
|
&topic,
|
||||||
|
1_000_000 - GOSSIP_FRESHNESS_MS - 1,
|
||||||
|
GOSSIP_FRESHNESS_MS
|
||||||
|
),
|
||||||
Err(GossipReject::OutOfWindow)
|
Err(GossipReject::OutOfWindow)
|
||||||
);
|
);
|
||||||
// Within the window (clock skew tolerance) → accepted.
|
// Within the window (clock skew tolerance) → accepted.
|
||||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
assert!(
|
||||||
|
verify_gossip(
|
||||||
|
&p,
|
||||||
|
&topic,
|
||||||
|
1_000_000 + GOSSIP_FRESHNESS_MS - 1,
|
||||||
|
GOSSIP_FRESHNESS_MS
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1449,10 +1585,30 @@ mod tests {
|
|||||||
let author = fresh_id();
|
let author = fresh_id();
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
|
|
||||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
assert!(admit_state_mutation(
|
||||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
&mut seen,
|
||||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
|
author,
|
||||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
|
&GossipMessage::Leave,
|
||||||
|
10
|
||||||
|
));
|
||||||
|
assert!(!admit_state_mutation(
|
||||||
|
&mut seen,
|
||||||
|
author,
|
||||||
|
&GossipMessage::Leave,
|
||||||
|
10
|
||||||
|
));
|
||||||
|
assert!(!admit_state_mutation(
|
||||||
|
&mut seen,
|
||||||
|
author,
|
||||||
|
&GossipMessage::Leave,
|
||||||
|
9
|
||||||
|
));
|
||||||
|
assert!(admit_state_mutation(
|
||||||
|
&mut seen,
|
||||||
|
author,
|
||||||
|
&GossipMessage::Leave,
|
||||||
|
11
|
||||||
|
));
|
||||||
|
|
||||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||||
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
|
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
|
||||||
@@ -1465,13 +1621,26 @@ mod tests {
|
|||||||
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
||||||
let author = fresh_id();
|
let author = fresh_id();
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None };
|
let later_chat = GossipMessage::Chat {
|
||||||
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None };
|
name: "A".into(),
|
||||||
|
text: "later".into(),
|
||||||
|
ts: 200,
|
||||||
|
attachment: None,
|
||||||
|
};
|
||||||
|
let earlier_chat = GossipMessage::Chat {
|
||||||
|
name: "A".into(),
|
||||||
|
text: "earlier".into(),
|
||||||
|
ts: 100,
|
||||||
|
attachment: None,
|
||||||
|
};
|
||||||
|
|
||||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||||
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
|
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
|
||||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||||
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
|
assert!(
|
||||||
|
seen.is_empty(),
|
||||||
|
"chat must not populate the state-mutation replay map"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1481,8 +1650,18 @@ mod tests {
|
|||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||||
|
|
||||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
|
assert!(admit_state_mutation(
|
||||||
|
&mut seen,
|
||||||
|
author,
|
||||||
|
&GossipMessage::Leave,
|
||||||
|
5
|
||||||
|
));
|
||||||
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
|
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
|
||||||
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
|
assert!(admit_state_mutation(
|
||||||
|
&mut seen,
|
||||||
|
other,
|
||||||
|
&GossipMessage::Leave,
|
||||||
|
5
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-26
@@ -1,16 +1,16 @@
|
|||||||
use crate::network::{NetworkTransport, NetError, ConnEvent};
|
use crate::network::{ConnEvent, NetError, NetworkTransport};
|
||||||
use iroh::{Endpoint, EndpointId};
|
use async_trait::async_trait;
|
||||||
use iroh::endpoint::{Connection, ConnectionError, VarInt};
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use iroh::endpoint::{Connection, ConnectionError, VarInt};
|
||||||
|
use iroh::{Endpoint, EndpointId};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::time::Duration;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
|
||||||
use crate::files::{AttachmentId, ChatAttachment};
|
use crate::files::{AttachmentId, ChatAttachment};
|
||||||
|
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
||||||
|
|
||||||
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
||||||
/// useless latency — keep it shallow and drop the oldest frame when full.
|
/// useless latency — keep it shallow and drop the oldest frame when full.
|
||||||
@@ -111,7 +111,13 @@ impl Shared {
|
|||||||
let shared = self.clone();
|
let shared = self.clone();
|
||||||
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
|
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
|
||||||
let inbound_tx_ret = inbound_tx.clone();
|
let inbound_tx_ret = inbound_tx.clone();
|
||||||
peers.insert(peer_id, PeerHandle { supervisor, inbound_tx });
|
peers.insert(
|
||||||
|
peer_id,
|
||||||
|
PeerHandle {
|
||||||
|
supervisor,
|
||||||
|
inbound_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
|
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
|
||||||
inbound_tx_ret
|
inbound_tx_ret
|
||||||
}
|
}
|
||||||
@@ -123,7 +129,10 @@ impl Shared {
|
|||||||
self.addrs.lock().unwrap().remove(&peer_id);
|
self.addrs.lock().unwrap().remove(&peer_id);
|
||||||
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
|
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
|
||||||
handle.supervisor.abort();
|
handle.supervisor.abort();
|
||||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
crate::log_msg(&format!(
|
||||||
|
"Transport: stopped supervising peer {:?}",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +217,15 @@ async fn supervise(
|
|||||||
let mut backoff = INITIAL_BACKOFF;
|
let mut backoff = INITIAL_BACKOFF;
|
||||||
|
|
||||||
// Show "connecting" until the first link is actually up.
|
// Show "connecting" until the first link is actually up.
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
let _ = shared
|
||||||
|
.conn_events_tx
|
||||||
|
.try_send(ConnEvent::Connecting(peer_id));
|
||||||
|
|
||||||
let mut conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
let mut conn =
|
||||||
Some(conn) => conn,
|
match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
||||||
None => return, // retired before we ever connected
|
Some(conn) => conn,
|
||||||
};
|
None => return, // retired before we ever connected
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// A healthy link resets the dialer's backoff for the next outage.
|
// A healthy link resets the dialer's backoff for the next outage.
|
||||||
@@ -223,8 +235,14 @@ async fn supervise(
|
|||||||
shared.senders.lock().unwrap().insert(peer_id, send_tx);
|
shared.senders.lock().unwrap().insert(peer_id, send_tx);
|
||||||
// Publish the live connection so an intentional leave can close it with
|
// Publish the live connection so an intentional leave can close it with
|
||||||
// the goodbye code.
|
// the goodbye code.
|
||||||
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone());
|
shared
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id));
|
.live_conns
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(peer_id, conn.clone());
|
||||||
|
let _ = shared
|
||||||
|
.conn_events_tx
|
||||||
|
.try_send(ConnEvent::Connected(peer_id));
|
||||||
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
|
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
|
||||||
|
|
||||||
// Run until the link dies, a replacement arrives, or we're retired. The
|
// Run until the link dies, a replacement arrives, or we're retired. The
|
||||||
@@ -272,21 +290,36 @@ async fn supervise(
|
|||||||
match wake {
|
match wake {
|
||||||
Wake::Shutdown => return,
|
Wake::Shutdown => return,
|
||||||
Wake::Replacement(new_conn) => {
|
Wake::Replacement(new_conn) => {
|
||||||
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id));
|
crate::log_msg(&format!(
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
"Transport: peer {:?} replaced with new inbound link",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
|
let _ = shared
|
||||||
|
.conn_events_tx
|
||||||
|
.try_send(ConnEvent::Connecting(peer_id));
|
||||||
conn = new_conn;
|
conn = new_conn;
|
||||||
}
|
}
|
||||||
Wake::Closed(reason) => {
|
Wake::Closed(reason) => {
|
||||||
// A graceful application close means the peer left on purpose —
|
// A graceful application close means the peer left on purpose —
|
||||||
// don't reconnect; tell the core to evict it now.
|
// don't reconnect; tell the core to evict it now.
|
||||||
if is_graceful_leave(&reason) {
|
if is_graceful_leave(&reason) {
|
||||||
crate::log_msg(&format!("Transport: peer {:?} left gracefully ({:?})", peer_id, reason));
|
crate::log_msg(&format!(
|
||||||
|
"Transport: peer {:?} left gracefully ({:?})",
|
||||||
|
peer_id, reason
|
||||||
|
));
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
|
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id));
|
crate::log_msg(&format!(
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
"Transport: peer {:?} link dropped; reconnecting",
|
||||||
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
peer_id
|
||||||
|
));
|
||||||
|
let _ = shared
|
||||||
|
.conn_events_tx
|
||||||
|
.try_send(ConnEvent::Connecting(peer_id));
|
||||||
|
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Some(conn) => conn,
|
Some(conn) => conn,
|
||||||
None => return, // retired while reconnecting
|
None => return, // retired while reconnecting
|
||||||
};
|
};
|
||||||
@@ -405,7 +438,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
|||||||
// only happens if links are churning, and the supervisor gets the next one.
|
// only happens if links are churning, and the supervisor gets the next one.
|
||||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||||
if inbound_tx.try_send(connection).is_err() {
|
if inbound_tx.try_send(connection).is_err() {
|
||||||
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
crate::log_msg(&format!(
|
||||||
|
"Transport: dropped inbound link from {:?} (queue full)",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -545,7 +581,14 @@ impl IrohTransport {
|
|||||||
/// all supervisors so none linger redialing the about-to-close endpoint.
|
/// all supervisors so none linger redialing the about-to-close endpoint.
|
||||||
/// Call this before shutting the router down.
|
/// Call this before shutting the router down.
|
||||||
pub async fn leave(&self) {
|
pub async fn leave(&self) {
|
||||||
let conns: Vec<Connection> = self.shared.live_conns.lock().unwrap().drain().map(|(_, c)| c).collect();
|
let conns: Vec<Connection> = self
|
||||||
|
.shared
|
||||||
|
.live_conns
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.drain()
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
.collect();
|
||||||
for conn in &conns {
|
for conn in &conns {
|
||||||
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
|
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
|
||||||
}
|
}
|
||||||
@@ -639,7 +682,9 @@ impl IrohTransport {
|
|||||||
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err(NetError::Other("file fetch: sender no longer has the file".to_string()));
|
return Err(NetError::Other(
|
||||||
|
"file fetch: sender no longer has the file".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-29
@@ -1,10 +1,10 @@
|
|||||||
use iroh::{EndpointId, EndpointAddr};
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum NetError {
|
pub enum NetError {
|
||||||
@@ -148,7 +148,10 @@ pub enum RoomEvent {
|
|||||||
/// A validly signed gossip payload was rejected only because its timestamp is
|
/// A validly signed gossip payload was rejected only because its timestamp is
|
||||||
/// outside the replay-protection window. The peer is not in the roster yet,
|
/// outside the replay-protection window. The peer is not in the roster yet,
|
||||||
/// so this surfaces as a room-level warning instead of a peer-card state.
|
/// so this surfaces as a room-level warning instead of a peer-card state.
|
||||||
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
ClockSkewSuspected {
|
||||||
|
author: EndpointId,
|
||||||
|
skew_ms: i64,
|
||||||
|
},
|
||||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
/// A peer sent a room text-chat message. Carries the sender's id, their
|
||||||
/// display name (embedded so it shows even without a presence entry), the
|
/// display name (embedded so it shows even without a presence entry), the
|
||||||
/// text, and a sender-stamped millisecond timestamp.
|
/// text, and a sender-stamped millisecond timestamp.
|
||||||
@@ -203,10 +206,12 @@ impl PeerSpeakTicket {
|
|||||||
/// is idempotent.
|
/// is idempotent.
|
||||||
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
||||||
match ticket_str.parse::<PeerSpeakTicket>() {
|
match ticket_str.parse::<PeerSpeakTicket>() {
|
||||||
Ok(t) => {
|
Ok(t) => PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
|
host_addr: my_addr,
|
||||||
.to_string()
|
topic_id: t.topic_id,
|
||||||
|
name: t.name,
|
||||||
}
|
}
|
||||||
|
.to_string(),
|
||||||
Err(_) => ticket_str.to_string(),
|
Err(_) => ticket_str.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +220,10 @@ impl PeerSpeakTicket {
|
|||||||
/// can't be parsed or carries no label. Pure; used to label the gathering both
|
/// can't be parsed or carries no label. Pure; used to label the gathering both
|
||||||
/// in the room UI and in the presence we report to friends.
|
/// in the room UI and in the presence we report to friends.
|
||||||
pub fn label_of(ticket_str: &str) -> String {
|
pub fn label_of(ticket_str: &str) -> String {
|
||||||
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default()
|
ticket_str
|
||||||
|
.parse::<PeerSpeakTicket>()
|
||||||
|
.map(|t| t.name)
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
|
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
|
||||||
@@ -223,7 +231,10 @@ impl PeerSpeakTicket {
|
|||||||
/// the recents list (the host address and label change between members/sessions,
|
/// the recents list (the host address and label change between members/sessions,
|
||||||
/// but the topic uniquely identifies the gathering).
|
/// but the topic uniquely identifies the gathering).
|
||||||
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
|
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
|
||||||
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id)
|
ticket_str
|
||||||
|
.parse::<PeerSpeakTicket>()
|
||||||
|
.ok()
|
||||||
|
.map(|t| t.topic_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +257,8 @@ impl FromStr for PeerSpeakTicket {
|
|||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
||||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||||
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
|
let ticket: PeerSpeakTicket =
|
||||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||||
Ok(ticket)
|
Ok(ticket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,13 +338,13 @@ pub trait RoomState: Send + Sync {
|
|||||||
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod iroh_impl;
|
|
||||||
pub mod gossip;
|
pub mod gossip;
|
||||||
|
pub mod iroh_impl;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use iroh::{SecretKey, EndpointAddr};
|
use iroh::{EndpointAddr, SecretKey};
|
||||||
|
|
||||||
fn sample_peer_state() -> PeerState {
|
fn sample_peer_state() -> PeerState {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
@@ -371,9 +382,12 @@ mod tests {
|
|||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
let topic_id = [3u8; 32];
|
let topic_id = [3u8; 32];
|
||||||
// A labelled ticket: restamp keeps the label, label_of reads it.
|
// A labelled ticket: restamp keeps the label, label_of reads it.
|
||||||
let labelled =
|
let labelled = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
host_addr: EndpointAddr::from(host),
|
||||||
.to_string();
|
topic_id,
|
||||||
|
name: "HangOut".into(),
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
||||||
let member = SecretKey::generate().public();
|
let member = SecretKey::generate().public();
|
||||||
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
|
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
|
||||||
@@ -410,10 +424,8 @@ mod tests {
|
|||||||
|
|
||||||
// valid URL-safe-base64 that decodes to non-JSON bytes
|
// valid URL-safe-base64 that decodes to non-JSON bytes
|
||||||
let bad_json = b"hello world";
|
let bad_json = b"hello world";
|
||||||
let encoded = base64::Engine::encode(
|
let encoded =
|
||||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
|
||||||
bad_json,
|
|
||||||
);
|
|
||||||
let res3 = encoded.parse::<PeerSpeakTicket>();
|
let res3 = encoded.parse::<PeerSpeakTicket>();
|
||||||
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
||||||
}
|
}
|
||||||
@@ -424,9 +436,12 @@ mod tests {
|
|||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
let member = SecretKey::generate().public();
|
let member = SecretKey::generate().public();
|
||||||
let topic_id = [42u8; 32];
|
let topic_id = [42u8; 32];
|
||||||
let original =
|
let original = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
host_addr: EndpointAddr::from(host),
|
||||||
.to_string();
|
topic_id,
|
||||||
|
name: "HangOut".into(),
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
||||||
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
|
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
|
||||||
@@ -441,18 +456,27 @@ mod tests {
|
|||||||
fn test_restamp_is_idempotent_for_same_addr() {
|
fn test_restamp_is_idempotent_for_same_addr() {
|
||||||
let me = SecretKey::generate().public();
|
let me = SecretKey::generate().public();
|
||||||
let topic_id = [7u8; 32];
|
let topic_id = [7u8; 32];
|
||||||
let mine =
|
let mine = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
|
host_addr: EndpointAddr::from(me),
|
||||||
.to_string();
|
topic_id,
|
||||||
|
name: String::new(),
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
// Re-stamping my own ticket with my own addr changes nothing.
|
// Re-stamping my own ticket with my own addr changes nothing.
|
||||||
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine);
|
assert_eq!(
|
||||||
|
PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)),
|
||||||
|
mine
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restamp_passes_through_unparseable() {
|
fn test_restamp_passes_through_unparseable() {
|
||||||
let me = SecretKey::generate().public();
|
let me = SecretKey::generate().public();
|
||||||
// A malformed ticket is returned unchanged (the join will fail anyway).
|
// A malformed ticket is returned unchanged (the join will fail anyway).
|
||||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
assert_eq!(
|
||||||
|
PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
|
||||||
|
"not-a-ticket"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+120
-14
@@ -11,12 +11,16 @@
|
|||||||
//! missing chime should never disrupt a call.
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||||
|
static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
|
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
|
||||||
/// toggle gates everything; these silence individual events while the master
|
/// toggle gates everything; these silence individual events while the master
|
||||||
@@ -57,7 +61,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
|
|||||||
master_enabled && sound_enabled
|
master_enabled && sound_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A notification event with a distinct chime.
|
/// A notification event with a distinct chime.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Sound {
|
pub enum Sound {
|
||||||
@@ -195,14 +198,38 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
|||||||
if let Some(path) = guard.get(sound.name()) {
|
if let Some(path) = guard.get(sound.name()) {
|
||||||
return Some(path.clone());
|
return Some(path.clone());
|
||||||
}
|
}
|
||||||
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
|
let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
|
||||||
if std::fs::write(&path, sound.bytes()).is_err() {
|
Ok(path) => path,
|
||||||
return None;
|
Err(_) => return None,
|
||||||
}
|
};
|
||||||
guard.insert(sound.name(), path.clone());
|
guard.insert(sound.name(), path.clone());
|
||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||||
|
let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
let path = dir.join(format!(
|
||||||
|
"peerspeak-{stem}-{}-{counter}-{nanos}.wav",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = options.open(&path)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
#[cfg(any(windows, test))]
|
||||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||||
s.replace('\'', "''")
|
s.replace('\'', "''")
|
||||||
@@ -249,6 +276,19 @@ fn spawn_player(path: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn temp_wav_dir(tag: &str) -> PathBuf {
|
||||||
|
let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"peerspeak-notifytest-{}-{tag}-{counter}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_play_truth_table() {
|
fn test_should_play_truth_table() {
|
||||||
// Plays only when BOTH the master and the per-sound flag are on.
|
// Plays only when BOTH the master and the per-sound flag are on.
|
||||||
@@ -264,10 +304,7 @@ mod tests {
|
|||||||
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||||
r"C:\Users\O''Brien\chime.wav"
|
r"C:\Users\O''Brien\chime.wav"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
|
||||||
escape_powershell_single_quoted("a'b'c"),
|
|
||||||
"a''b''c"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -297,7 +334,10 @@ mod tests {
|
|||||||
// bare `~` -> home dir
|
// bare `~` -> home dir
|
||||||
assert_eq!(expand_tilde("~"), home);
|
assert_eq!(expand_tilde("~"), home);
|
||||||
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
|
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
|
||||||
assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav"));
|
assert_eq!(
|
||||||
|
expand_tilde("~/sub/dir/file.wav"),
|
||||||
|
home.join("sub/dir/file.wav")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// absolute path (`/etc/foo.wav`) -> unchanged
|
// absolute path (`/etc/foo.wav`) -> unchanged
|
||||||
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
|
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
|
||||||
@@ -310,10 +350,19 @@ mod tests {
|
|||||||
// leading/trailing whitespace is trimmed
|
// leading/trailing whitespace is trimmed
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
assert_eq!(expand_tilde(" ~ "), home);
|
assert_eq!(expand_tilde(" ~ "), home);
|
||||||
assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav"));
|
assert_eq!(
|
||||||
|
expand_tilde(" ~/sub/dir/file.wav "),
|
||||||
|
home.join("sub/dir/file.wav")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav"));
|
assert_eq!(
|
||||||
assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav"));
|
expand_tilde(" /etc/foo.wav "),
|
||||||
|
PathBuf::from("/etc/foo.wav")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde(" foo/bar.wav "),
|
||||||
|
PathBuf::from("foo/bar.wav")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -332,4 +381,61 @@ mod tests {
|
|||||||
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
|
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
|
||||||
assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
|
assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_private_wav_writes_exact_bytes() {
|
||||||
|
let dir = temp_wav_dir("writes");
|
||||||
|
let bytes = b"RIFFpeerspeak-test";
|
||||||
|
|
||||||
|
let path = write_private_wav(&dir, "unit", bytes).unwrap();
|
||||||
|
|
||||||
|
assert!(path.exists());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn write_private_wav_creates_0600_file() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = temp_wav_dir("mode");
|
||||||
|
let path = write_private_wav(&dir, "unit", b"mode").unwrap();
|
||||||
|
|
||||||
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_private_wav_uses_unique_paths() {
|
||||||
|
let dir = temp_wav_dir("unique");
|
||||||
|
|
||||||
|
let first = write_private_wav(&dir, "same-stem", b"first").unwrap();
|
||||||
|
let second = write_private_wav(&dir, "same-stem", b"second").unwrap();
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(first.exists());
|
||||||
|
assert!(second.exists());
|
||||||
|
assert_eq!(std::fs::read(&first).unwrap(), b"first");
|
||||||
|
assert_eq!(std::fs::read(&second).unwrap(), b"second");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_new_refuses_existing_path() {
|
||||||
|
let dir = temp_wav_dir("create-new");
|
||||||
|
let path = dir.join("preexisting.wav");
|
||||||
|
std::fs::write(&path, b"original").unwrap();
|
||||||
|
|
||||||
|
let err = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -49,9 +49,7 @@ pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Ve
|
|||||||
|
|
||||||
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
||||||
let lower = entry.to_ascii_lowercase();
|
let lower = entry.to_ascii_lowercase();
|
||||||
if lower.starts_with("http://")
|
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|
||||||
|| lower.starts_with("https://")
|
|
||||||
|| lower.starts_with("ftp://")
|
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -107,7 +105,10 @@ File3=/var/audio/two.MP3
|
|||||||
#[test]
|
#[test]
|
||||||
fn playlist_kind_is_case_insensitive() {
|
fn playlist_kind_is_case_insensitive() {
|
||||||
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
||||||
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u));
|
assert_eq!(
|
||||||
|
playlist_kind(Path::new("mix.m3u8")),
|
||||||
|
Some(PlaylistKind::M3u)
|
||||||
|
);
|
||||||
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
||||||
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-19
@@ -36,8 +36,11 @@ pub enum PresenceMode {
|
|||||||
|
|
||||||
impl PresenceMode {
|
impl PresenceMode {
|
||||||
/// All postures, default first — the option list for the Settings/home picker.
|
/// All postures, default first — the option list for the Settings/home picker.
|
||||||
pub const ALL: [PresenceMode; 3] =
|
pub const ALL: [PresenceMode; 3] = [
|
||||||
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable];
|
PresenceMode::Normal,
|
||||||
|
PresenceMode::Invisible,
|
||||||
|
PresenceMode::Discoverable,
|
||||||
|
];
|
||||||
|
|
||||||
/// Whether this posture publishes to discovery (the only mode that does).
|
/// Whether this posture publishes to discovery (the only mode that does).
|
||||||
pub fn publishes_to_discovery(self) -> bool {
|
pub fn publishes_to_discovery(self) -> bool {
|
||||||
@@ -193,7 +196,11 @@ mod tests {
|
|||||||
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
|
||||||
// Stranger is NEVER answered, in any mode.
|
// Stranger is NEVER answered, in any mode.
|
||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
|
||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable));
|
assert!(!should_answer(
|
||||||
|
&stranger,
|
||||||
|
&friends,
|
||||||
|
PresenceMode::Discoverable
|
||||||
|
));
|
||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +221,10 @@ mod tests {
|
|||||||
ControlMsg::Ping,
|
ControlMsg::Ping,
|
||||||
ControlMsg::Pong { room: None },
|
ControlMsg::Pong { room: None },
|
||||||
ControlMsg::Pong {
|
ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }),
|
room: Some(RoomPresence {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: "abc".into(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
for msg in cases {
|
for msg in cases {
|
||||||
@@ -245,19 +255,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Valid ticket -> InRoom with a sanitized name.
|
// Valid ticket -> InRoom with a sanitized name.
|
||||||
let t = valid_ticket(friend);
|
let t = valid_ticket(friend);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
name: "HangOut".into(),
|
||||||
|
ticket: t.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
Some(FriendPresence::InRoom {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: t
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
|
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
|
||||||
// A friend reporting a room with an unparseable ticket is treated as just
|
// A friend reporting a room with an unparseable ticket is treated as just
|
||||||
// Online — no dead/hostile Join button is surfaced.
|
// Online — no dead/hostile Join button is surfaced.
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
&ControlMsg::Pong {
|
||||||
}, id());
|
room: Some(RoomPresence {
|
||||||
|
name: "Trap".into(),
|
||||||
|
ticket: "not-a-ticket".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
id(),
|
||||||
|
);
|
||||||
assert_eq!(got, Some(FriendPresence::Online));
|
assert_eq!(got, Some(FriendPresence::Online));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,9 +294,15 @@ mod tests {
|
|||||||
let friend = id();
|
let friend = id();
|
||||||
let attacker = id();
|
let attacker = id();
|
||||||
let t = valid_ticket(attacker);
|
let t = valid_ticket(attacker);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
|
name: "Redirect".into(),
|
||||||
|
ticket: t,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
assert_eq!(got, Some(FriendPresence::Online));
|
assert_eq!(got, Some(FriendPresence::Online));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,10 +321,18 @@ mod tests {
|
|||||||
let t = valid_ticket(friend);
|
let t = valid_ticket(friend);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
presence_from_probe(Some((
|
presence_from_probe(Some((
|
||||||
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) },
|
&ControlMsg::Pong {
|
||||||
|
room: Some(RoomPresence {
|
||||||
|
name: "Den".into(),
|
||||||
|
ticket: t.clone()
|
||||||
|
})
|
||||||
|
},
|
||||||
friend,
|
friend,
|
||||||
))),
|
))),
|
||||||
FriendPresence::InRoom { name: "Den".into(), ticket: t }
|
FriendPresence::InRoom {
|
||||||
|
name: "Den".into(),
|
||||||
|
ticket: t
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
|
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -304,9 +346,15 @@ mod tests {
|
|||||||
// Control/bidi characters in a peer-supplied name are stripped.
|
// Control/bidi characters in a peer-supplied name are stripped.
|
||||||
let friend = id();
|
let friend = id();
|
||||||
let t = valid_ticket(friend);
|
let t = valid_ticket(friend);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
|
name: "Hang\u{202e}Out\u{0007}".into(),
|
||||||
|
ticket: t.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
match got {
|
match got {
|
||||||
Some(FriendPresence::InRoom { name, .. }) => {
|
Some(FriendPresence::InRoom { name, .. }) => {
|
||||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||||
|
|||||||
+21
-8
@@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
|||||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||||
/// also accepted (and used by hermetic tests).
|
/// also accepted (and used by hermetic tests).
|
||||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
|
pub async fn probe(
|
||||||
|
endpoint: &Endpoint,
|
||||||
|
peer: impl Into<EndpointAddr>,
|
||||||
|
) -> Result<(EndpointId, ControlMsg)> {
|
||||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||||
.await
|
.await
|
||||||
.context("timed out connecting to peer")?
|
.context("timed out connecting to peer")?
|
||||||
@@ -62,7 +65,10 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
|
|||||||
let from = conn.remote_id();
|
let from = conn.remote_id();
|
||||||
|
|
||||||
let io = async {
|
let io = async {
|
||||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
let (mut send, mut recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.context("failed to open control stream")?;
|
||||||
send.write_all(&encode(&ControlMsg::Ping)?)
|
send.write_all(&encode(&ControlMsg::Ping)?)
|
||||||
.await
|
.await
|
||||||
.context("failed to write ping")?;
|
.context("failed to write ping")?;
|
||||||
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
|||||||
|
|
||||||
let io = async {
|
let io = async {
|
||||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
||||||
let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?;
|
let bytes = recv
|
||||||
|
.read_to_end(MAX_MSG)
|
||||||
|
.await
|
||||||
|
.context("failed to read ping")?;
|
||||||
match decode(&bytes)? {
|
match decode(&bytes)? {
|
||||||
ControlMsg::Ping => {}
|
ControlMsg::Ping => {}
|
||||||
other => bail!("expected a ping, got {other:?}"),
|
other => bail!("expected a ping, got {other:?}"),
|
||||||
@@ -211,7 +220,10 @@ mod tests {
|
|||||||
let handler: Handler = Arc::new(move |from| {
|
let handler: Handler = Arc::new(move |from| {
|
||||||
if from == allowed {
|
if from == allowed {
|
||||||
Some(ControlMsg::Pong {
|
Some(ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }),
|
room: Some(RoomPresence {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: "t".into(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None // stranger -> no reply
|
None // stranger -> no reply
|
||||||
@@ -221,10 +233,11 @@ mod tests {
|
|||||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||||
|
|
||||||
// The allowed prober gets a Pong with the room.
|
// The allowed prober gets a Pong with the room.
|
||||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
let (from, pong) =
|
||||||
.await
|
tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||||
.expect("probe timed out")
|
.await
|
||||||
.expect("probe failed");
|
.expect("probe timed out")
|
||||||
|
.expect("probe failed");
|
||||||
assert_eq!(from, server_addr.id);
|
assert_eq!(from, server_addr.id);
|
||||||
match pong {
|
match pong {
|
||||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||||
|
|||||||
+31
-11
@@ -33,12 +33,16 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
/// 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.
|
/// 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
|
/// v4–v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
|
||||||
/// current shared-listening track descriptor and playback timeline. Bytes still
|
/// gained an optional `music` presence field (a current shared-listening track
|
||||||
/// ride the files plane by id; gossip carries only the descriptor/timeline.
|
/// descriptor + playback timeline; the audio bytes still ride the files plane by
|
||||||
///
|
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
|
||||||
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next
|
/// gained optional prefetch hints for the next track so tuned-in listeners can
|
||||||
/// track so tuned-in listeners can fetch it before the DJ advances.
|
/// 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;
|
pub const GOSSIP_PROTO: u32 = 5;
|
||||||
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
@@ -83,10 +87,22 @@ mod tests {
|
|||||||
/// so a version bump can't silently forget to update the wire string.
|
/// so a version bump can't silently forget to update the wire string.
|
||||||
#[test]
|
#[test]
|
||||||
fn alpns_match_their_proto_versions() {
|
fn alpns_match_their_proto_versions() {
|
||||||
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
|
assert_eq!(
|
||||||
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
|
AUDIO_ALPN,
|
||||||
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes());
|
format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
|
||||||
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FRIENDS_ALPN,
|
||||||
|
format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FILES_ALPN,
|
||||||
|
format!("peerspeak/files/{FILES_PROTO}").as_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GOSSIP_SIG_DOMAIN,
|
||||||
|
format!("peerspeak-gossip-v{GOSSIP_PROTO}")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -95,7 +111,11 @@ mod tests {
|
|||||||
let mut b = a;
|
let mut b = a;
|
||||||
b[5] = 10;
|
b[5] = 10;
|
||||||
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
||||||
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
|
assert_ne!(
|
||||||
|
versioned_topic(a),
|
||||||
|
versioned_topic(b),
|
||||||
|
"distinct rooms stay distinct"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+14
-3
@@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool {
|
|||||||
/// supplies `now` (unix seconds) and persists the list afterwards.
|
/// supplies `now` (unix seconds) and persists the list afterwards.
|
||||||
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
||||||
list.retain(|r| !same_room(&r.ticket, &ticket));
|
list.retain(|r| !same_room(&r.ticket, &ticket));
|
||||||
list.insert(0, Recent { name, ticket, joined_at: now });
|
list.insert(
|
||||||
|
0,
|
||||||
|
Recent {
|
||||||
|
name,
|
||||||
|
ticket,
|
||||||
|
joined_at: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
list.truncate(RECENTS_MAX);
|
list.truncate(RECENTS_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +93,12 @@ mod tests {
|
|||||||
/// Build a real, parseable ticket for a fresh room with the given label.
|
/// Build a real, parseable ticket for a fresh room with the given label.
|
||||||
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() }
|
PeerSpeakTicket {
|
||||||
.to_string()
|
host_addr: EndpointAddr::from(host),
|
||||||
|
topic_id: topic,
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+27
-6
@@ -85,7 +85,10 @@ pub enum Segment {
|
|||||||
/// Trailing characters commonly adjacent to a URL in prose that should NOT be
|
/// Trailing characters commonly adjacent to a URL in prose that should NOT be
|
||||||
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
|
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
|
||||||
fn is_url_trailing_punct(c: char) -> bool {
|
fn is_url_trailing_punct(c: char) -> bool {
|
||||||
matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'')
|
matches!(
|
||||||
|
c,
|
||||||
|
'.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\''
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`,
|
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`,
|
||||||
@@ -144,7 +147,10 @@ mod tests {
|
|||||||
fn strips_control_chars_and_collapses_whitespace() {
|
fn strips_control_chars_and_collapses_whitespace() {
|
||||||
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
|
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
|
||||||
// collapse; ends trim.
|
// collapse; ends trim.
|
||||||
assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m");
|
assert_eq!(
|
||||||
|
sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "),
|
||||||
|
"a b c d [31m"
|
||||||
|
);
|
||||||
// A name that is only control/whitespace cleans to empty.
|
// A name that is only control/whitespace cleans to empty.
|
||||||
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
|
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
|
||||||
}
|
}
|
||||||
@@ -183,7 +189,10 @@ mod tests {
|
|||||||
let mid = "g".repeat(56);
|
let mid = "g".repeat(56);
|
||||||
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||||
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||||
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
|
assert_eq!(
|
||||||
|
sanitize_game_label(&long).chars().count(),
|
||||||
|
GAME_LABEL_MAX_CHARS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -220,7 +229,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn linkify_plain_text_has_no_links() {
|
fn linkify_plain_text_has_no_links() {
|
||||||
let segs = linkify("just a normal message, nothing here");
|
let segs = linkify("just a normal message, nothing here");
|
||||||
assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]);
|
assert_eq!(
|
||||||
|
segs,
|
||||||
|
vec![Segment::Text("just a normal message, nothing here".into())]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -280,7 +292,12 @@ mod tests {
|
|||||||
fn linkify_only_matches_http_schemes() {
|
fn linkify_only_matches_http_schemes() {
|
||||||
// Non-web schemes and bare domains are NOT linkified (conservative).
|
// Non-web schemes and bare domains are NOT linkified (conservative).
|
||||||
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
|
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
|
||||||
assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]);
|
assert_eq!(
|
||||||
|
segs,
|
||||||
|
vec![Segment::Text(
|
||||||
|
"email me@x.com or ftp://x.com or visit x.com".into()
|
||||||
|
)]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -293,7 +310,11 @@ mod tests {
|
|||||||
"weird))) http://c.com]]] tail",
|
"weird))) http://c.com]]] tail",
|
||||||
"unicode 世界 http://d.com/路径 more 世界",
|
"unicode 世界 http://d.com/路径 more 世界",
|
||||||
] {
|
] {
|
||||||
assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}");
|
assert_eq!(
|
||||||
|
reassemble(&linkify(msg)),
|
||||||
|
msg,
|
||||||
|
"roundtrip failed for {msg:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+305
-41
@@ -21,6 +21,8 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
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
|
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
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
|
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||||
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
/// (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.
|
/// 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![
|
let mut args = vec![
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
"--output".to_string(),
|
"--output".to_string(),
|
||||||
@@ -149,18 +155,52 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
|||||||
args.push(format!("--app={name}"));
|
args.push(format!("--app={name}"));
|
||||||
args.push("--strict-audio".to_string());
|
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
|
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:
|
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||||
/// trim, reject empty / overlong, and reject names carrying control characters
|
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||||
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||||
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||||
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
let ok = !name.is_empty()
|
let ok =
|
||||||
&& name.len() <= MAX_APP_NAME_LEN
|
!name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
|
||||||
&& !name.chars().any(|c| c.is_control());
|
|
||||||
ok.then(|| name.to_string())
|
ok.then(|| name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,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
|
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
/// 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
|
/// 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,
|
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
||||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
/// them, so pixelpass keeps its own defaults in the common case.
|
||||||
pub async fn spawn_host(
|
pub async fn spawn_host(
|
||||||
bin: &Path,
|
bin: &Path,
|
||||||
audio_app: Option<&str>,
|
audio_app: Option<&str>,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
) -> std::io::Result<(Child, String)> {
|
) -> 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)
|
let mut child = Command::new(bin)
|
||||||
.args(host_args(audio_app))
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
// Capture stderr (not null): pixelpass prints its startup precondition
|
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||||
@@ -429,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
|
/// 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
|
/// stream in a local player (mpv/VLC in the configured order, then fallback).
|
||||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
/// Returns the live viewer child so the caller can kill it on room-leave; it also
|
||||||
/// player window closes (its tunnel ends).
|
/// self-exits when the player window closes (its tunnel ends).
|
||||||
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
pub async fn spawn_viewer(
|
||||||
|
bin: &Path,
|
||||||
|
ticket: &str,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
) -> std::io::Result<Child> {
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.args(viewer_args(ticket))
|
.args(viewer_args(ticket))
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
@@ -466,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;
|
let _ = child.kill().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
@@ -548,23 +599,33 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
/// Open the viewer stream URL in a media player, then fall back to vlc. The
|
||||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||||
/// background task so it doesn't linger as a zombie when its window closes.
|
/// its window closes.
|
||||||
fn launch_player(url: &str) -> std::io::Result<()> {
|
///
|
||||||
const MPV_ARGS: &[&str] = &[
|
/// The flags keep latency low while preserving A/V sync. We deliberately do
|
||||||
"--profile=low-latency",
|
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
|
||||||
"--untimed",
|
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
|
||||||
"--hwdec=auto",
|
/// progressively out of sync with its audio. Pacing to the audio clock costs a
|
||||||
"--audio-buffer=0.2",
|
/// little latency (negligible for pointing at a desktop) and keeps a shared
|
||||||
"--demuxer-max-bytes=2M",
|
/// video in sync. We also leave hwdec at the `low-latency` default (software
|
||||||
"--demuxer-readahead-secs=0.5",
|
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
|
||||||
];
|
/// kept playing.
|
||||||
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
|
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,
|
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::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
"no media player found — install mpv or vlc to watch screen shares",
|
"no media player found — install mpv or vlc to watch screen shares",
|
||||||
@@ -578,7 +639,64 @@ fn launch_player(url: &str) -> std::io::Result<()> {
|
|||||||
Ok(())
|
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)
|
Command::new(bin)
|
||||||
.args(args)
|
.args(args)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
@@ -599,7 +717,10 @@ mod tests {
|
|||||||
// i.e. after the `--` end-of-options guard, never parsed as a flag.
|
// i.e. after the `--` end-of-options guard, never parsed as a flag.
|
||||||
let args = viewer_args("--malicious-flag");
|
let args = viewer_args("--malicious-flag");
|
||||||
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
|
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
|
||||||
let guard = args.iter().position(|a| a == "--").expect("`--` guard present");
|
let guard = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--")
|
||||||
|
.expect("`--` guard present");
|
||||||
let ticket = args.len() - 1;
|
let ticket = args.len() - 1;
|
||||||
assert!(guard < ticket, "ticket must follow the `--` guard");
|
assert!(guard < ticket, "ticket must follow the `--` guard");
|
||||||
// The real flags are parsed before the guard.
|
// The real flags are parsed before the guard.
|
||||||
@@ -619,7 +740,11 @@ mod tests {
|
|||||||
fn host_args_without_app_shares_whole_desktop() {
|
fn host_args_without_app_shares_whole_desktop() {
|
||||||
// No app selected → no --app flag → pixelpass keeps its default
|
// No app selected → no --app flag → pixelpass keeps its default
|
||||||
// (whole-desktop) audio capture.
|
// (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]
|
#[test]
|
||||||
@@ -627,13 +752,20 @@ mod tests {
|
|||||||
// The chosen app rides in the `--app=<name>` single-token form so a
|
// 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
|
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
||||||
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||||
|
let settings = ScreenShareSettings::default();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
host_args(Some("Firefox")),
|
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
|
||||||
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
|
vec![
|
||||||
|
"--host",
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"--app=Firefox",
|
||||||
|
"--strict-audio"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
// The hyphen-leading name is still bound to --app as a single token;
|
// The hyphen-leading name is still bound to --app as a single token;
|
||||||
// --strict-audio is the trailing flag.
|
// --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[3], "--app=-rm -rf");
|
||||||
assert_eq!(args[4], "--strict-audio");
|
assert_eq!(args[4], "--strict-audio");
|
||||||
}
|
}
|
||||||
@@ -642,13 +774,130 @@ mod tests {
|
|||||||
fn host_args_blank_or_control_app_is_dropped() {
|
fn host_args_blank_or_control_app_is_dropped() {
|
||||||
// An empty / whitespace / control-laden selection is sanitized away,
|
// An empty / whitespace / control-laden selection is sanitized away,
|
||||||
// falling back to whole-desktop capture rather than a broken flag.
|
// 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")), vec!["--host", "--output", "json"]);
|
assert_eq!(
|
||||||
|
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]
|
#[test]
|
||||||
fn sanitize_app_name_trims_and_rejects_garbage() {
|
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||||
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()));
|
assert_eq!(
|
||||||
|
sanitize_app_name(" Firefox \n"),
|
||||||
|
Some("Firefox".to_string())
|
||||||
|
);
|
||||||
assert_eq!(sanitize_app_name(""), None);
|
assert_eq!(sanitize_app_name(""), None);
|
||||||
assert_eq!(sanitize_app_name(" "), None);
|
assert_eq!(sanitize_app_name(" "), None);
|
||||||
assert_eq!(sanitize_app_name("a\tb"), None);
|
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||||
@@ -667,7 +916,11 @@ mod tests {
|
|||||||
]"#;
|
]"#;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_audio_apps(stdout),
|
parse_audio_apps(stdout),
|
||||||
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
|
vec![
|
||||||
|
"Firefox".to_string(),
|
||||||
|
"Spotify".to_string(),
|
||||||
|
"mpv".to_string()
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,13 +1007,19 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
|||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
assert_eq!(
|
||||||
|
sanitize_ticket(format!(" {ticket}\n")),
|
||||||
|
Some(ticket.to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||||
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
assert_eq!(
|
||||||
|
sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))),
|
||||||
|
None
|
||||||
|
);
|
||||||
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +1043,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
|||||||
fn parses_connected_url() {
|
fn parses_connected_url() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
|
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
|
||||||
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string()))
|
Some(PixelpassEvent::Connected(
|
||||||
|
"http://127.0.0.1:5500".to_string()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,7 +1180,10 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
|||||||
let dir = Path::new("bin");
|
let dir = Path::new("bin");
|
||||||
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
assert_eq!(
|
||||||
|
candidates,
|
||||||
|
vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]
|
||||||
|
);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -211,7 +211,7 @@ impl AppTheme {
|
|||||||
overlay: hex(0x6272a4),
|
overlay: hex(0x6272a4),
|
||||||
text: hex(0xf8f8f2),
|
text: hex(0xf8f8f2),
|
||||||
subtext: hex(0xbdc0d4),
|
subtext: hex(0xbdc0d4),
|
||||||
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
|
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
|
||||||
lavender: hex(0x8be9fd), // cyan
|
lavender: hex(0x8be9fd), // cyan
|
||||||
red: hex(0xff5555),
|
red: hex(0xff5555),
|
||||||
maroon: hex(0xff79c6), // pink
|
maroon: hex(0xff79c6), // pink
|
||||||
@@ -400,7 +400,11 @@ mod tests {
|
|||||||
for theme in AppTheme::ALL {
|
for theme in AppTheme::ALL {
|
||||||
let p = theme.palette();
|
let p = theme.palette();
|
||||||
let sub = contrast_ratio(p.subtext, p.base);
|
let sub = contrast_ratio(p.subtext, p.base);
|
||||||
assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label());
|
assert!(
|
||||||
|
sub >= 3.0,
|
||||||
|
"{}: subtext contrast {sub:.2} < 3.0",
|
||||||
|
theme.label()
|
||||||
|
);
|
||||||
let accent = contrast_ratio(p.blue, p.base);
|
let accent = contrast_ratio(p.blue, p.base);
|
||||||
assert!(
|
assert!(
|
||||||
accent >= 3.0,
|
accent >= 3.0,
|
||||||
|
|||||||
+130
-81
@@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget};
|
|||||||
use iced::advanced::{Layout, Shell};
|
use iced::advanced::{Layout, Shell};
|
||||||
use iced::widget::text_input;
|
use iced::widget::text_input;
|
||||||
use iced::{
|
use iced::{
|
||||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
|
||||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
Size, Vector, alignment,
|
||||||
};
|
};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -27,11 +27,7 @@ pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
|||||||
(start != end).then(|| value.select(start, end).to_string())
|
(start != end).then(|| value.select(start, end).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cut_selection(
|
pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
|
||||||
value: &str,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> (Edit, Option<String>) {
|
|
||||||
let mut value = text_input::Value::new(value);
|
let mut value = text_input::Value::new(value);
|
||||||
let (start, end) = normalized_range(&value, start, end);
|
let (start, end) = normalized_range(&value, start, end);
|
||||||
|
|
||||||
@@ -74,24 +70,26 @@ 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) {
|
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||||
let value = text_input::Value::new(value);
|
let value = text_input::Value::new(value);
|
||||||
|
|
||||||
(0, value.len())
|
(0, value.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalized_range(
|
fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
|
||||||
value: &text_input::Value,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
) -> (usize, usize) {
|
|
||||||
let len = value.len();
|
let len = value.len();
|
||||||
|
|
||||||
(start.min(end).min(len), start.max(end).min(len))
|
(start.min(end).min(len), start.max(end).min(len))
|
||||||
}
|
}
|
||||||
|
|
||||||
type InputStyleFn<'a, Theme> =
|
type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
|
||||||
|
|
||||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||||
placeholder: &str,
|
placeholder: &str,
|
||||||
@@ -119,12 +117,8 @@ where
|
|||||||
.locked(true)
|
.locked(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ContextInput<
|
pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||||
'a,
|
where
|
||||||
Message,
|
|
||||||
Theme = iced::Theme,
|
|
||||||
Renderer = iced::Renderer,
|
|
||||||
> where
|
|
||||||
Theme: text_input::Catalog,
|
Theme: text_input::Catalog,
|
||||||
Renderer: text::Renderer,
|
Renderer: text::Renderer,
|
||||||
{
|
{
|
||||||
@@ -137,8 +131,7 @@ pub struct ContextInput<
|
|||||||
style: Option<InputStyleFn<'a, Theme>>,
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Message, Theme, Renderer>
|
impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
|
||||||
ContextInput<'a, Message, Theme, Renderer>
|
|
||||||
where
|
where
|
||||||
Message: Clone + 'a,
|
Message: Clone + 'a,
|
||||||
Theme: text_input::Catalog + 'a,
|
Theme: text_input::Catalog + 'a,
|
||||||
@@ -172,16 +165,13 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_input(
|
pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
|
||||||
mut self,
|
let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
|
||||||
on_input: impl Fn(String) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
|
||||||
Rc::new(on_input);
|
|
||||||
let input_callback = Rc::clone(&on_input);
|
let input_callback = Rc::clone(&on_input);
|
||||||
|
|
||||||
self.input =
|
self.input = self
|
||||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
.input
|
||||||
|
.on_input(move |value| input_callback.as_ref()(value));
|
||||||
self.on_input = Some(on_input);
|
self.on_input = Some(on_input);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -196,16 +186,13 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_paste(
|
pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
|
||||||
mut self,
|
let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
|
||||||
on_paste: impl Fn(String) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
|
||||||
Rc::new(on_paste);
|
|
||||||
let paste_callback = Rc::clone(&on_paste);
|
let paste_callback = Rc::clone(&on_paste);
|
||||||
|
|
||||||
self.input =
|
self.input = self
|
||||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
.input
|
||||||
|
.on_paste(move |value| paste_callback.as_ref()(value));
|
||||||
self.on_paste = Some(on_paste);
|
self.on_paste = Some(on_paste);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -235,18 +222,12 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn line_height(
|
pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
|
||||||
mut self,
|
|
||||||
line_height: impl Into<text::LineHeight>,
|
|
||||||
) -> Self {
|
|
||||||
self.input = self.input.line_height(line_height);
|
self.input = self.input.line_height(line_height);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn align_x(
|
pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
|
||||||
mut self,
|
|
||||||
alignment: impl Into<alignment::Horizontal>,
|
|
||||||
) -> Self {
|
|
||||||
self.input = self.input.align_x(alignment);
|
self.input = self.input.align_x(alignment);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -379,11 +360,51 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
tree.state.downcast_mut::<ContextInputState>().menu = cursor
|
||||||
cursor.position().map(|anchor| MenuState {
|
.position()
|
||||||
anchor,
|
.map(|anchor| MenuState { anchor, selection });
|
||||||
selection,
|
|
||||||
});
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
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.capture_event();
|
||||||
shell.request_redraw();
|
shell.request_redraw();
|
||||||
@@ -477,8 +498,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Message, Theme, Renderer>
|
impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
|
||||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
|
||||||
for Element<'a, Message, Theme, Renderer>
|
for Element<'a, Message, Theme, Renderer>
|
||||||
where
|
where
|
||||||
Message: Clone + 'a,
|
Message: Clone + 'a,
|
||||||
@@ -516,12 +536,7 @@ enum MenuAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MenuAction {
|
impl MenuAction {
|
||||||
const ALL: [Self; 4] = [
|
const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
|
||||||
Self::Cut,
|
|
||||||
Self::Copy,
|
|
||||||
Self::Paste,
|
|
||||||
Self::SelectAll,
|
|
||||||
];
|
|
||||||
|
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -564,8 +579,7 @@ where
|
|||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) {
|
) {
|
||||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||||
let hovered_style =
|
let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
|
||||||
let bounds = layout.bounds();
|
let bounds = layout.bounds();
|
||||||
let viewport = Rectangle::INFINITE;
|
let viewport = Rectangle::INFINITE;
|
||||||
|
|
||||||
@@ -640,9 +654,7 @@ where
|
|||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||||
key: iced::keyboard::Key::Named(
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||||
iced::keyboard::key::Named::Escape,
|
|
||||||
),
|
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
self.close(shell);
|
self.close(shell);
|
||||||
@@ -718,11 +730,7 @@ where
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hit_action(
|
fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
|
||||||
&self,
|
|
||||||
bounds: Rectangle,
|
|
||||||
position: Point,
|
|
||||||
) -> Option<MenuAction> {
|
|
||||||
if !bounds.contains(position) {
|
if !bounds.contains(position) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -758,12 +766,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Paste => {
|
MenuAction::Paste => {
|
||||||
let clip = clipboard
|
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Standard).unwrap_or_default());
|
||||||
.read(clipboard::Kind::Standard)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_control())
|
|
||||||
.collect::<String>();
|
|
||||||
let edit = paste(self.value, start, end, &clip);
|
let edit = paste(self.value, start, end, &clip);
|
||||||
|
|
||||||
self.publish_paste(edit, shell);
|
self.publish_paste(edit, shell);
|
||||||
@@ -883,6 +886,16 @@ mod tests {
|
|||||||
assert_eq!(clip, None);
|
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]
|
#[test]
|
||||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -932,12 +945,48 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn locked_menu_allows_copy_and_select_all_only() {
|
fn locked_menu_allows_copy_and_select_all_only() {
|
||||||
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
assert!(!menu_action_enabled(
|
||||||
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
MenuAction::Cut,
|
||||||
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
true,
|
||||||
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(menu_action_enabled(
|
||||||
|
MenuAction::Copy,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::Paste,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(menu_action_enabled(
|
||||||
|
MenuAction::SelectAll,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
assert!(!menu_action_enabled(
|
||||||
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
MenuAction::Copy,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::SelectAll,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-116
@@ -3,16 +3,15 @@ use iced::advanced::layout;
|
|||||||
use iced::advanced::mouse;
|
use iced::advanced::mouse;
|
||||||
use iced::advanced::renderer;
|
use iced::advanced::renderer;
|
||||||
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
||||||
use iced::advanced::widget::tree::{self, Tree};
|
|
||||||
use iced::advanced::widget::Widget;
|
use iced::advanced::widget::Widget;
|
||||||
|
use iced::advanced::widget::tree::{self, Tree};
|
||||||
use iced::advanced::{Layout, Shell};
|
use iced::advanced::{Layout, Shell};
|
||||||
use iced::widget::text::{
|
use iced::widget::text::{
|
||||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
|
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
|
||||||
Wrapping,
|
|
||||||
};
|
};
|
||||||
use iced::{
|
use iced::{
|
||||||
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
|
Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
|
||||||
Rectangle, Size, Vector, keyboard,
|
alignment, keyboard,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAG_THRESHOLD: f32 = 3.0;
|
const DRAG_THRESHOLD: f32 = 3.0;
|
||||||
@@ -28,11 +27,7 @@ const HIT_SEARCH_STEPS: usize = 24;
|
|||||||
// widget's per-line offsets would stop being global and selection/copy across
|
// widget's per-line offsets would stop being global and selection/copy across
|
||||||
// lines would break — revisit then.
|
// lines would break — revisit then.
|
||||||
|
|
||||||
pub fn selected_substring(
|
pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option<String> {
|
||||||
text: &str,
|
|
||||||
anchor: usize,
|
|
||||||
cursor: usize,
|
|
||||||
) -> Option<String> {
|
|
||||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||||
|
|
||||||
(start != end).then(|| text[start..end].to_owned())
|
(start != end).then(|| text[start..end].to_owned())
|
||||||
@@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) {
|
|||||||
(0, text.len())
|
(0, text.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalized_byte_range(
|
fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) {
|
||||||
text: &str,
|
|
||||||
anchor: usize,
|
|
||||||
cursor: usize,
|
|
||||||
) -> (usize, usize) {
|
|
||||||
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
||||||
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
||||||
|
|
||||||
@@ -75,13 +66,8 @@ where
|
|||||||
SelectableRichText::with_spans(spans)
|
SelectableRichText::with_spans(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SelectableRichText<
|
pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||||
'a,
|
where
|
||||||
Link,
|
|
||||||
Message,
|
|
||||||
Theme = iced::Theme,
|
|
||||||
Renderer = iced::Renderer,
|
|
||||||
> where
|
|
||||||
Link: Clone + 'static,
|
Link: Clone + 'static,
|
||||||
Theme: Catalog,
|
Theme: Catalog,
|
||||||
Renderer: advanced_text::Renderer,
|
Renderer: advanced_text::Renderer,
|
||||||
@@ -101,8 +87,7 @@ pub struct SelectableRichText<
|
|||||||
selection_color: Color,
|
selection_color: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Link, Message, Theme, Renderer>
|
impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||||
SelectableRichText<'a, Link, Message, Theme, Renderer>
|
|
||||||
where
|
where
|
||||||
Link: Clone + 'static,
|
Link: Clone + 'static,
|
||||||
Theme: Catalog,
|
Theme: Catalog,
|
||||||
@@ -127,9 +112,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_spans(
|
pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
|
||||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
spans: Box::new(spans),
|
spans: Box::new(spans),
|
||||||
..Self::new()
|
..Self::new()
|
||||||
@@ -166,10 +149,7 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn align_y(
|
pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
|
||||||
mut self,
|
|
||||||
alignment: impl Into<alignment::Vertical>,
|
|
||||||
) -> Self {
|
|
||||||
self.align_y = alignment.into();
|
self.align_y = alignment.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -179,10 +159,7 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_link_click(
|
pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
|
||||||
mut self,
|
|
||||||
on_link_click: impl Fn(Link) -> Message + 'a,
|
|
||||||
) -> Self {
|
|
||||||
self.on_link_click = Some(Box::new(on_link_click));
|
self.on_link_click = Some(Box::new(on_link_click));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -356,26 +333,16 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (index, span) in spans.iter().enumerate() {
|
for (index, span) in spans.iter().enumerate() {
|
||||||
let is_hovered_link = self.on_link_click.is_some()
|
let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link;
|
||||||
&& Some(index) == self.hovered_link;
|
|
||||||
|
|
||||||
if span.highlight.is_some()
|
if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link {
|
||||||
|| span.underline
|
|
||||||
|| span.strikethrough
|
|
||||||
|| is_hovered_link
|
|
||||||
{
|
|
||||||
let regions = state.paragraph.span_bounds(index);
|
let regions = state.paragraph.span_bounds(index);
|
||||||
|
|
||||||
if let Some(highlight) = span.highlight {
|
if let Some(highlight) = span.highlight {
|
||||||
for bounds in ®ions {
|
for bounds in ®ions {
|
||||||
let bounds = Rectangle::new(
|
let bounds = Rectangle::new(
|
||||||
bounds.position()
|
bounds.position() - Vector::new(span.padding.left, span.padding.top),
|
||||||
- Vector::new(
|
bounds.size() + Size::new(span.padding.x(), span.padding.y()),
|
||||||
span.padding.left,
|
|
||||||
span.padding.top,
|
|
||||||
),
|
|
||||||
bounds.size()
|
|
||||||
+ Size::new(span.padding.x(), span.padding.y()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
@@ -390,26 +357,17 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
if span.underline || span.strikethrough || is_hovered_link {
|
if span.underline || span.strikethrough || is_hovered_link {
|
||||||
let size = span
|
let size = span.size.or(self.size).unwrap_or(renderer.default_size());
|
||||||
.size
|
|
||||||
.or(self.size)
|
|
||||||
.unwrap_or(renderer.default_size());
|
|
||||||
|
|
||||||
let line_height = span
|
let line_height = span
|
||||||
.line_height
|
.line_height
|
||||||
.unwrap_or(self.line_height)
|
.unwrap_or(self.line_height)
|
||||||
.to_absolute(size);
|
.to_absolute(size);
|
||||||
|
|
||||||
let color = span
|
let color = span.color.or(style.color).unwrap_or(defaults.text_color);
|
||||||
.color
|
|
||||||
.or(style.color)
|
|
||||||
.unwrap_or(defaults.text_color);
|
|
||||||
|
|
||||||
let baseline = translation
|
let baseline =
|
||||||
+ Vector::new(
|
translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
|
||||||
0.0,
|
|
||||||
size.0 + (line_height.0 - size.0) / 2.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if span.underline || is_hovered_link {
|
if span.underline || is_hovered_link {
|
||||||
for bounds in ®ions {
|
for bounds in ®ions {
|
||||||
@@ -497,13 +455,10 @@ where
|
|||||||
state.dragging = true;
|
state.dragging = true;
|
||||||
state.press_position = Some(position);
|
state.press_position = Some(position);
|
||||||
state.span_pressed = self.hovered_link;
|
state.span_pressed = self.hovered_link;
|
||||||
state.selection = state
|
state.selection = state.paragraph.hit_test(position).map(|hit| {
|
||||||
.paragraph
|
let offset = hit.cursor().min(flat_text.len());
|
||||||
.hit_test(position)
|
(offset, offset)
|
||||||
.map(|hit| {
|
});
|
||||||
let offset = hit.cursor().min(flat_text.len());
|
|
||||||
(offset, offset)
|
|
||||||
});
|
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
shell.request_redraw();
|
shell.request_redraw();
|
||||||
} else if state.active || state.selection.is_some() {
|
} else if state.active || state.selection.is_some() {
|
||||||
@@ -521,8 +476,7 @@ where
|
|||||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||||
&& let Some((anchor, _)) = state.selection
|
&& let Some((anchor, _)) = state.selection
|
||||||
{
|
{
|
||||||
state.selection =
|
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
|
||||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
|
||||||
shell.request_redraw();
|
shell.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,16 +494,14 @@ where
|
|||||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||||
&& let Some((anchor, _)) = state.selection
|
&& let Some((anchor, _)) = state.selection
|
||||||
{
|
{
|
||||||
state.selection =
|
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
|
||||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dragged {
|
if !dragged {
|
||||||
if let (Some(on_link_clicked), Some(span)) =
|
if let (Some(on_link_clicked), Some(span)) =
|
||||||
(&self.on_link_click, state.span_pressed)
|
(&self.on_link_click, state.span_pressed)
|
||||||
&& Some(span) == self.hovered_link
|
&& Some(span) == self.hovered_link
|
||||||
&& let Some(link) =
|
&& let Some(link) = spans.get(span).and_then(|span| span.link.clone())
|
||||||
spans.get(span).and_then(|span| span.link.clone())
|
|
||||||
{
|
{
|
||||||
shell.publish(on_link_clicked(link));
|
shell.publish(on_link_clicked(link));
|
||||||
}
|
}
|
||||||
@@ -570,25 +522,22 @@ where
|
|||||||
physical_key,
|
physical_key,
|
||||||
modifiers,
|
modifiers,
|
||||||
..
|
..
|
||||||
}) if state.active && modifiers.command() => {
|
}) if state.active && modifiers.command() => match key.to_latin(*physical_key) {
|
||||||
match key.to_latin(*physical_key) {
|
Some('c') | Some('C') => {
|
||||||
Some('c') | Some('C') => {
|
if let Some((anchor, cursor)) = state.selection
|
||||||
if let Some((anchor, cursor)) = state.selection
|
&& let Some(selected) = selected_substring(&flat_text, anchor, cursor)
|
||||||
&& let Some(selected) =
|
{
|
||||||
selected_substring(&flat_text, anchor, cursor)
|
clipboard.write(clipboard::Kind::Standard, selected);
|
||||||
{
|
|
||||||
clipboard.write(clipboard::Kind::Standard, selected);
|
|
||||||
shell.capture_event();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some('a') | Some('A') => {
|
|
||||||
state.selection = Some(select_all(&flat_text));
|
|
||||||
shell.capture_event();
|
shell.capture_event();
|
||||||
shell.request_redraw();
|
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
Some('a') | Some('A') => {
|
||||||
|
state.selection = Some(select_all(&flat_text));
|
||||||
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,14 +606,8 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
if state.spans != config.spans {
|
if state.spans != config.spans {
|
||||||
state.paragraph =
|
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
|
||||||
Renderer::Paragraph::with_spans(text_with_spans());
|
state.spans = config.spans.iter().cloned().map(Span::to_static).collect();
|
||||||
state.spans = config
|
|
||||||
.spans
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(Span::to_static)
|
|
||||||
.collect();
|
|
||||||
} else {
|
} else {
|
||||||
match state.paragraph.compare(advanced_text::Text {
|
match state.paragraph.compare(advanced_text::Text {
|
||||||
content: (),
|
content: (),
|
||||||
@@ -682,8 +625,7 @@ where
|
|||||||
state.paragraph.resize(bounds);
|
state.paragraph.resize(bounds);
|
||||||
}
|
}
|
||||||
advanced_text::Difference::Shape => {
|
advanced_text::Difference::Shape => {
|
||||||
state.paragraph =
|
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
|
||||||
Renderer::Paragraph::with_spans(text_with_spans());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,13 +703,7 @@ fn selection_rect_for_line<P: Paragraph>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn x_for_offset<P: Paragraph>(
|
fn x_for_offset<P: Paragraph>(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 {
|
||||||
paragraph: &P,
|
|
||||||
y: f32,
|
|
||||||
offset: usize,
|
|
||||||
left: f32,
|
|
||||||
right: f32,
|
|
||||||
) -> f32 {
|
|
||||||
let mut low = left;
|
let mut low = left;
|
||||||
let mut high = right.max(left);
|
let mut high = right.max(left);
|
||||||
|
|
||||||
@@ -787,10 +723,7 @@ fn x_for_offset<P: Paragraph>(
|
|||||||
high
|
high
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visual_lines<P: Paragraph>(
|
fn visual_lines<P: Paragraph>(paragraph: &P, span_count: usize) -> Vec<Rectangle> {
|
||||||
paragraph: &P,
|
|
||||||
span_count: usize,
|
|
||||||
) -> Vec<Rectangle> {
|
|
||||||
let mut lines: Vec<Rectangle> = Vec::new();
|
let mut lines: Vec<Rectangle> = Vec::new();
|
||||||
|
|
||||||
for span in 0..span_count {
|
for span in 0..span_count {
|
||||||
@@ -820,10 +753,7 @@ fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
|||||||
let right = (a.x + a.width).max(b.x + b.width);
|
let right = (a.x + a.width).max(b.x + b.width);
|
||||||
let bottom = (a.y + a.height).max(b.y + b.height);
|
let bottom = (a.y + a.height).max(b.y + b.height);
|
||||||
|
|
||||||
Rectangle::new(
|
Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top))
|
||||||
Point::new(left, top),
|
|
||||||
Size::new(right - left, bottom - top),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use iroh::endpoint::presets;
|
|||||||
use iroh::protocol::Router;
|
use iroh::protocol::Router;
|
||||||
use iroh::{Endpoint, RelayMode};
|
use iroh::{Endpoint, RelayMode};
|
||||||
|
|
||||||
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
use peerspeak::files::{AttachmentKind, ChatAttachment};
|
||||||
use peerspeak::network::NetworkTransport;
|
use peerspeak::network::NetworkTransport;
|
||||||
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||||
use peerspeak::protocol::FILES_ALPN;
|
use peerspeak::protocol::FILES_ALPN;
|
||||||
@@ -52,7 +52,12 @@ async fn spawn_node() -> Node {
|
|||||||
.accept(FILES_ALPN, file_router)
|
.accept(FILES_ALPN, file_router)
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
Node { endpoint, transport, _router: router, lookup }
|
Node {
|
||||||
|
endpoint,
|
||||||
|
transport,
|
||||||
|
_router: router,
|
||||||
|
lookup,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||||
@@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() {
|
|||||||
|
|
||||||
let blob = big_blob();
|
let blob = big_blob();
|
||||||
let id = [42u8; 32];
|
let id = [42u8; 32];
|
||||||
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
server
|
||||||
|
.transport
|
||||||
|
.serve_attachment(id, Arc::new(blob.clone()));
|
||||||
|
|
||||||
let att = ChatAttachment {
|
let att = ChatAttachment {
|
||||||
name: "exterior-landscape.jpg".to_string(),
|
name: "exterior-landscape.jpg".to_string(),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async fn evicted_within(
|
|||||||
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
|
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
|
||||||
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
|
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
|
||||||
Ok(None) => return false, // channel closed
|
Ok(None) => return false, // channel closed
|
||||||
Err(_) => return false, // timed out — no eviction
|
Err(_) => return false, // timed out — no eviction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ async fn left_within(
|
|||||||
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
|
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
|
||||||
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
|
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
|
||||||
Ok(None) => return false, // channel closed
|
Ok(None) => return false, // channel closed
|
||||||
Err(_) => return false, // timed out — no leave
|
Err(_) => return false, // timed out — no leave
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-11
@@ -23,8 +23,8 @@ use iroh::protocol::{AcceptError, ProtocolHandler};
|
|||||||
use peerspeak::codec::AudioEncoder;
|
use peerspeak::codec::AudioEncoder;
|
||||||
use peerspeak::codec::opus_impl::OpusEncoder;
|
use peerspeak::codec::opus_impl::OpusEncoder;
|
||||||
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
||||||
use peerspeak::network::{ConnEvent, NetworkTransport};
|
|
||||||
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
||||||
|
use peerspeak::network::{ConnEvent, NetworkTransport};
|
||||||
use peerspeak::protocol::AUDIO_ALPN;
|
use peerspeak::protocol::AUDIO_ALPN;
|
||||||
|
|
||||||
struct Node {
|
struct Node {
|
||||||
@@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer {
|
|||||||
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
|
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
CapturePeer { endpoint, _router: router, lookup, conns_rx }
|
CapturePeer {
|
||||||
|
endpoint,
|
||||||
|
_router: router,
|
||||||
|
lookup,
|
||||||
|
conns_rx,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a node with a specific secret key. Reusing a key gives the respawned
|
/// Spawn a node with a specific secret key. Reusing a key gives the respawned
|
||||||
@@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
|||||||
received += 1;
|
received += 1;
|
||||||
|
|
||||||
if let Some(frame) = jitter.pop_frame() {
|
if let Some(frame) = jitter.pop_frame() {
|
||||||
assert_eq!(frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame");
|
assert_eq!(
|
||||||
|
frame.len(),
|
||||||
|
FRAME_SAMPLES,
|
||||||
|
"decoded frame is one 20ms frame"
|
||||||
|
);
|
||||||
decoded_frames += 1;
|
decoded_frames += 1;
|
||||||
}
|
}
|
||||||
if received >= N {
|
if received >= N {
|
||||||
@@ -290,7 +299,11 @@ async fn dialer_reconnects_after_link_drops() {
|
|||||||
.expect("timed out awaiting initial connection")
|
.expect("timed out awaiting initial connection")
|
||||||
.expect("connection channel closed");
|
.expect("connection channel closed");
|
||||||
assert!(
|
assert!(
|
||||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
await_reconnect(
|
||||||
|
&mut conn_events,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||||
|
)
|
||||||
|
.await,
|
||||||
"initial link should report Connecting then Connected"
|
"initial link should report Connecting then Connected"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -306,7 +319,11 @@ async fn dialer_reconnects_after_link_drops() {
|
|||||||
.expect("timed out awaiting reconnect")
|
.expect("timed out awaiting reconnect")
|
||||||
.expect("connection channel closed");
|
.expect("connection channel closed");
|
||||||
assert!(
|
assert!(
|
||||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await,
|
await_reconnect(
|
||||||
|
&mut conn_events,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(15)
|
||||||
|
)
|
||||||
|
.await,
|
||||||
"dropped link should report Connecting (down) then Connected (recovered)"
|
"dropped link should report Connecting (down) then Connected (recovered)"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +336,12 @@ async fn dialer_reconnects_after_link_drops() {
|
|||||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await;
|
let received = count_audio(
|
||||||
|
&conn2,
|
||||||
|
25,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(3),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
received >= 20,
|
received >= 20,
|
||||||
"audio should resume after reconnect; got {received} frames"
|
"audio should resume after reconnect; got {received} frames"
|
||||||
@@ -359,7 +381,11 @@ async fn dialer_reports_left_on_graceful_close() {
|
|||||||
.expect("timed out awaiting initial connection")
|
.expect("timed out awaiting initial connection")
|
||||||
.expect("connection channel closed");
|
.expect("connection channel closed");
|
||||||
assert!(
|
assert!(
|
||||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
await_reconnect(
|
||||||
|
&mut conn_events,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||||
|
)
|
||||||
|
.await,
|
||||||
"initial link should report Connecting then Connected"
|
"initial link should report Connecting then Connected"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,7 +410,10 @@ async fn dialer_reports_left_on_graceful_close() {
|
|||||||
|
|
||||||
// And no re-dial reaches the peer within a short window.
|
// And no re-dial reaches the peer within a short window.
|
||||||
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
|
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
|
||||||
assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave");
|
assert!(
|
||||||
|
redial.is_err(),
|
||||||
|
"supervisor must not re-dial after a graceful leave"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -429,7 +458,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
|||||||
.expect("timed out awaiting initial connection (retained address path)")
|
.expect("timed out awaiting initial connection (retained address path)")
|
||||||
.expect("connection channel closed");
|
.expect("connection channel closed");
|
||||||
assert!(
|
assert!(
|
||||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
await_reconnect(
|
||||||
|
&mut conn_events,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||||
|
)
|
||||||
|
.await,
|
||||||
"initial link should report Connecting then Connected"
|
"initial link should report Connecting then Connected"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -443,7 +476,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
|||||||
.expect("timed out awaiting reconnect (retained address path)")
|
.expect("timed out awaiting reconnect (retained address path)")
|
||||||
.expect("connection channel closed");
|
.expect("connection channel closed");
|
||||||
assert!(
|
assert!(
|
||||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await,
|
await_reconnect(
|
||||||
|
&mut conn_events,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(15)
|
||||||
|
)
|
||||||
|
.await,
|
||||||
"reconnect should report Connecting then Connected with no lookup at all"
|
"reconnect should report Connecting then Connected with no lookup at all"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -455,7 +492,12 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
|||||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await;
|
let received = count_audio(
|
||||||
|
&conn2,
|
||||||
|
25,
|
||||||
|
tokio::time::Instant::now() + Duration::from_secs(3),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
received >= 20,
|
received >= 20,
|
||||||
"audio should resume after reconnecting via the retained address; got {received} frames"
|
"audio should resume after reconnecting via the retained address; got {received} frames"
|
||||||
|
|||||||
Reference in New Issue
Block a user