Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 4.8 7ff7766ede packaging: add test-pack split PKGBUILD (peerspeak + pixelpass)
One `makepkg -si` from packaging/test-pack/ builds and installs both
peerspeak and pixelpass from the public gitbutter repos over https, so a
tester can clone the repo and get a working voice+screenshare pair in one
command. pixelpass installs to /usr/bin so peerspeak's screen-share button
finds it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:56:51 -04:00
16 changed files with 355 additions and 1752 deletions
-34
View File
@@ -1,34 +0,0 @@
name: cargo-deny
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
# sources) on every push to main and every PR. Runs on a *locked* tree so the
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
# cannot reach CI until Cargo.lock is deliberately updated.
on:
push:
branches: [main]
pull_request:
jobs:
cargo-deny:
runs-on: ubuntu-latest
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
# `cargo metadata`. Adjust the runner label if your act_runner uses a
# different one.
container: rust:1
steps:
- uses: actions/checkout@v4
- name: Install cargo-deny (pinned prebuilt)
run: |
set -euo pipefail
version=0.19.9
curl -sSfL \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
cargo-deny --version
- name: cargo deny check
run: cargo deny --locked check
-85
View File
@@ -1,85 +0,0 @@
name: windows-build
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
# must provide both.
on:
push:
# `main` plus the in-progress port branches, so the Windows path is exercised
# before merge rather than only after.
branches: [main, "windows-port-**"]
pull_request:
# Allow manual runs from the Gitea Actions UI.
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
# which CMake 4.x rejects unless this is set. See the opus spike report.
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
jobs:
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (MSVC, pinned to repo toolchain if present)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Show toolchain + build prerequisites
shell: bash
run: |
set -euo pipefail
rustc --version
cargo --version
# libopus is built from source via cmake; fail early with a clear
# message if the runner lacks it rather than deep in the opus build.
if ! command -v cmake >/dev/null 2>&1; then
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
exit 1
fi
cmake --version
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
# get compiled — same supply-chain stance as the cargo-deny job.
- name: Build (all targets, msvc)
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
# real iroh/QUIC endpoints and need working loopback networking, which isn't
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
# runner is confirmed.
- name: Unit tests (lib, msvc)
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
# Informational for now (not `-D warnings`): the Windows tree may surface
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
# it's clean.
- name: Clippy (msvc)
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
Generated
+24 -268
View File
@@ -105,28 +105,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -136,12 +114,12 @@ dependencies = [
"android-properties",
"bitflags 2.11.1",
"cc",
"jni 0.22.4",
"jni",
"libc",
"log",
"ndk 0.9.0",
"ndk",
"ndk-context",
"ndk-sys 0.6.0+11769913",
"ndk-sys",
"num_enum",
"thiserror 2.0.18",
]
@@ -734,12 +712,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -1030,26 +1002,6 @@ dependencies = [
"libm",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen",
]
[[package]]
name = "cosmic-text"
version = "0.15.0"
@@ -1074,29 +1026,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1299,12 +1228,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -2304,7 +2227,7 @@ dependencies = [
"http",
"idna",
"ipnet",
"jni 0.22.4",
"jni",
"rand 0.10.1",
"rustls",
"thiserror 2.0.18",
@@ -2324,7 +2247,7 @@ dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni 0.22.4",
"jni",
"once_cell",
"prefix-trie",
"rand 0.10.1",
@@ -2347,7 +2270,7 @@ dependencies = [
"hickory-proto",
"ipconfig",
"ipnet",
"jni 0.22.4",
"jni",
"moka",
"ndk-context",
"once_cell",
@@ -3179,22 +3102,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -3556,15 +3463,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -3698,7 +3596,7 @@ dependencies = [
"dispatch",
"futures-channel",
"futures-lite",
"jni 0.22.4",
"jni",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
@@ -3797,20 +3695,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3820,7 +3704,7 @@ dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.6.0+11769913",
"ndk-sys",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
@@ -3832,15 +3716,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -4591,29 +4466,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -4748,7 +4600,6 @@ dependencies = [
"async-trait",
"base64",
"bytes",
"cpal",
"dirs",
"iced",
"image",
@@ -5585,7 +5436,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"jni",
"log",
"once_cell",
"rustls",
@@ -6026,7 +5877,7 @@ dependencies = [
"fastrand",
"js-sys",
"memmap2",
"ndk 0.9.0",
"ndk",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -7311,7 +7162,7 @@ dependencies = [
"log",
"metal",
"naga",
"ndk-sys 0.6.0+11769913",
"ndk-sys",
"objc",
"once_cell",
"ordered-float",
@@ -7396,16 +7247,6 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -7413,7 +7254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7437,16 +7278,6 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -7457,7 +7288,7 @@ dependencies = [
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7555,22 +7386,13 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7589,7 +7411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7601,22 +7423,13 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -7628,35 +7441,20 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@@ -7668,36 +7466,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7710,48 +7490,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -7780,7 +7536,7 @@ dependencies = [
"js-sys",
"libc",
"memmap2",
"ndk 0.9.0",
"ndk",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
+7 -23
View File
@@ -30,12 +30,17 @@ bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
iroh = "1.0.0-rc.0"
iroh-gossip = "0.99.0"
opus = "0.3.1"
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
# the playback RT callback to fill exactly what the device asks for instead of
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
rand = "0.10.1"
ringbuf = "0.5.0"
serde = { version = "1.0.228", features = ["derive"] }
@@ -43,24 +48,3 @@ serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18"
# --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
[target.'cfg(target_os = "linux")'.dependencies]
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
# quantum), used by the playback RT callback to fill exactly what the device asks
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
# The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
# Native file picker via the XDG desktop portal (no GTK) on Linux.
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
[target.'cfg(windows)'.dependencies]
# Native file picker using the built-in Win32 dialog backend on Windows.
rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15"
-77
View File
@@ -1,77 +0,0 @@
# PeerSpeak on Windows
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
checks listed below.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
Windows paths are resolved through `dirs`:
- Config: `%APPDATA%\peerspeak\config.json`
- Identity: `%APPDATA%\peerspeak\identity.key`
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
## Building
### Native Windows
Install MSVC Build Tools and CMake, then build normally:
```powershell
cargo build --release
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
```powershell
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
cargo build --release
```
### Cross-compile from Linux
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
```sh
rustup target add x86_64-pc-windows-gnu
sudo pacman -S mingw-w64-gcc cmake
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
```
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
spike) lives in the maintainer's handoff docs, outside the repo.
## First run and networking
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
The default network mode keeps the n0 relay available for NAT traversal without publishing
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
connections are expected and valid.
## Known gaps
| Item | Status |
|---|---|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
Before calling Windows support done, verify a real Windows machine can create/join a room,
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
play notification chimes.
+112
View File
@@ -0,0 +1,112 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
# finds it. Shared version string is derived from peerspeak's git.
#
# Clone this repo and build from here:
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
# cd peerspeak/packaging/test-pack
# makepkg -si
pkgbase=peerspeak-git
pkgname=('peerspeak-git' 'pixelpass')
pkgver=0.1.0
pkgrel=1
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
makedepends=('git' 'cargo' 'pkgconf')
options=('!lto' '!debug')
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
sha256sums=('SKIP'
'SKIP')
pkgver() {
cd "$srcdir/peerspeak"
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
printf '%s.r%s.g%s' \
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
prepare() {
# Vendor deps up front so build() can run --frozen (no surprise network).
export CARGO_HOME="$srcdir/cargo-home"
local host; host="$(rustc -vV | sed -n 's/host: //p')"
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
}
build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$srcdir/peerspeak"
cargo build --frozen --release --bin peerspeak
cd "$srcdir/pixelpass"
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
check() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
# peerspeak library unit tests only — its integration suites bind real
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
cd "$srcdir/peerspeak"
cargo test --frozen --release --lib
}
package_peerspeak-git() {
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
depends=('pipewire' 'opus')
optdepends=('pixelpass: screen sharing inside a room'
'mpv: screen-share viewer (vlc is used as a fallback)')
provides=('peerspeak')
conflicts=('peerspeak')
license=('custom')
cd "$srcdir/peerspeak"
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
install -Dm644 "packaging/peerspeak.desktop" \
"$pkgdir/usr/share/applications/peerspeak.desktop"
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
install -Dm644 "assets/icons/peerspeak.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
local s
for s in 16 24 32 48 64 128 256 512; do
install -Dm644 "assets/icons/peerspeak-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
done
}
package_pixelpass() {
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
license=('MIT' 'Apache-2.0' 'OFL-1.1')
cd "$srcdir/pixelpass"
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/pixelpass.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
install -Dm0644 assets/NotoSans-OFL.txt \
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
}
+52
View File
@@ -0,0 +1,52 @@
# PeerSpeak + PixelPass — CachyOS/Arch test pack
A single **split PKGBUILD** that builds the latest code from the public gitbutter
repos and installs **both** programs at once:
- `peerspeak` — decentralized P2P voice chat
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
## Build & install (one command)
```sh
git clone https://gitbutter.xyz/mollusk/peerspeak.git
cd peerspeak/packaging/test-pack
makepkg -si
```
`makepkg -si` auto-installs every dependency via pacman before building —
including the Rust toolchain itself (the `cargo` makedepend is provided by the
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
git sources re-pull `main` and the version bumps automatically.
> Skip the test step with `makepkg -si --nocheck` for a faster build.
## Running the cross-internet test
1. Launch `peerspeak` on both machines.
2. One person **creates** a room and shares the room code/ticket with the other.
3. The other **joins** with that code.
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
falls back to a public n0 relay — **no port forwarding required**.
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
nothing needs to be opened inbound for relay mode).
### What we're smoke-testing
- Two real humans, two networks, over the internet.
- Mic capture + remote playback both directions, no crackle/dropouts.
- Mute / deafen, push-to-talk.
- Text chat in-room.
- Avatars (presets + custom upload) show up on the other side.
- Screen share: click the screen-share control → it launches `pixelpass`; the
viewer opens in `mpv` on the receiving side.
- Notification chimes (join/leave/etc.).
- Leave / rejoin cleanly.
If anything misbehaves, grab the log path peerspeak prints on startup and the
exact repro steps.
+36 -98
View File
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode;
@@ -553,9 +553,11 @@ pub fn run_gui() -> iced::Result {
// the icon from the .desktop file matched by app_id instead).
icon: window_icon(),
// app_id must match the .desktop basename so Wayland compositors
// (e.g. KWin) attach our launcher icon to the window. The field is
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
platform_specific: platform_specific_settings(),
// (e.g. KWin) attach our launcher icon to the window.
platform_specific: iced::window::settings::PlatformSpecific {
application_id: "peerspeak".to_string(),
..Default::default()
},
// We save the final size ourselves on CloseRequested, then exit.
exit_on_close_request: false,
..Default::default()
@@ -563,22 +565,6 @@ pub fn run_gui() -> iced::Result {
.run()
}
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
/// set there and left at defaults on Windows.
#[cfg(target_os = "linux")]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
iced::window::settings::PlatformSpecific {
application_id: "peerspeak".to_string(),
..Default::default()
}
}
#[cfg(not(target_os = "linux"))]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
iced::window::settings::PlatformSpecific::default()
}
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
@@ -1364,28 +1350,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https,
// but re-check here so this can't be widened into launching arbitrary
// schemes/args. Each opener receives the URL as a single argv entry
// (no shell), so there's no injection surface:
// - Unix: `xdg-open <url>`.
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
// default browser without going through `cmd`/`start`, which would
// otherwise re-parse `&` in query strings.
if url.starts_with("http://") || url.starts_with("https://") {
let spawned = {
#[cfg(unix)]
{
std::process::Command::new("xdg-open").arg(&url).spawn()
}
#[cfg(windows)]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", &url])
.spawn()
}
};
if let Err(e) = spawned {
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
}
// schemes/args. `xdg-open` receives the URL as a single argv entry
// (no shell), so there's no injection surface.
if (url.starts_with("http://") || url.starts_with("https://"))
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn()
{
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
}
}
AppMessage::ToggleMicTest(enabled) => {
@@ -2571,28 +2541,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
mic_meter,
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
vertical_space(4.0),
{
let control: Element<'_, AppMessage> = {
#[cfg(target_os = "linux")]
{
column![
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
].spacing(8).into()
}
#[cfg(not(target_os = "linux"))]
{
column![
checkbox(false)
.label("Echo cancellation"),
text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
].spacing(8).into()
}
};
control
},
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill),
]
.spacing(10)
@@ -3295,40 +3247,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![]
},
vertical_space(20.0),
{
// Echo cancellation is wired at join time on Linux; other
// targets show an inert status row instead of a dead toggle.
let control: Element<'_, AppMessage> = {
#[cfg(target_os = "linux")]
{
tooltip(
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
container(
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.size(11)
.color(color_text),
)
.padding(8)
.max_width(260.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Top,
)
.gap(8)
.into()
}
#[cfg(not(target_os = "linux"))]
{
column![
checkbox(false)
.label("Echo cancellation"),
text("Not available on Windows yet.").size(11).color(color_subtext),
].spacing(4).into()
}
};
control
},
// Echo cancellation — same flag + message as the Settings checkbox, so
// toggling here and there stay in sync automatically (single source of
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
// applies on the NEXT join (the PipeWire-module AEC is wired at join
// time, not hot-swappable mid-call).
tooltip(
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
container(
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.size(11)
.color(color_text),
)
.padding(8)
.max_width(260.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Top,
)
.gap(8),
vertical_space(20.0),
{
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
-804
View File
@@ -1,804 +0,0 @@
//! Windows audio backend — cpal / WASAPI (Phase 1).
//!
//! Implements [`AudioBackend`] on top of [`cpal`], which wraps WASAPI on Windows.
//! It is the Windows counterpart to `pipewire_impl.rs` and deliberately preserves
//! the exact same contract so the rest of the app (mixer, encoder, jitter buffer)
//! is unchanged:
//!
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
//! The RT capture callback only downmixes and pushes samples into a lock-free
//! ring; the owning thread drains that ring, frames it, and sends — so the
//! callback never allocates, locks, or touches an mpsc channel.
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
//! drained from a ring buffer that is paced to the device's hardware clock via
//! `ring_fill` exactly as the PipeWire backend does.
//!
//! ## Threading and the `!Send` stream
//!
//! `cpal::Stream` is `!Send` (some backends require it to be created and dropped
//! on the same thread), but [`AudioBackend`] is `Send + Sync` and the backend is
//! shared through an `Arc`. So the stream never lives in the struct: each of
//! `start_capture`/`start_playback` spawns one owning thread that builds the
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
//! handle). The stream's RT callback does the actual audio work; the owning
//! thread additionally feeds the playback ring (or drains the capture ring).
//!
//! `start_*` does not return until the owning thread reports back over a readiness
//! channel that the device resolved and the stream is built and playing — so a
//! device/format/WASAPI failure surfaces as a real `Err` to the caller instead of
//! leaving the UI in a joined-but-silent room.
//!
//! ## Sample rate
//!
//! The whole pipeline assumes 48 kHz (Opus + the 960-sample frame). Phase 1 only
//! selects a native-48 kHz device config; if the device can't do 48 kHz we return
//! a clear error rather than silently producing pitch-shifted audio. Arbitrary
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, FromSample, Sample, SampleFormat, SampleRate, SizedSample, Stream, StreamConfig};
use ringbuf::{
traits::{Consumer, Producer, Split},
HeapRb,
};
use super::{AudioBackend, AudioDevice, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
const SAMPLE_RATE: u32 = 48_000;
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
/// and `core::jitter::FRAME_SAMPLES`.
const CAPTURE_FRAME: usize = 960;
/// Lock-free capture ring capacity (mono samples) between the RT callback and the
/// owning drain thread: 8 frames = 160 ms of headroom, so a scheduling hiccup on
/// the drain thread doesn't immediately overrun the RT producer.
const CAPTURE_RING_CAPACITY: usize = CAPTURE_FRAME * 8;
/// How long the capture drain thread sleeps when the ring is momentarily empty,
/// before polling again. Small enough to stay well under the 20 ms frame cadence.
const CAPTURE_POLL: Duration = Duration::from_millis(5);
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
/// headroom and never has to drop frames in steady state.
const RING_CAPACITY: usize = 9600 * PLAYBACK_CHANNELS;
/// How often a blocked playback worker re-checks its `running` flag, bounding how
/// long `stop()` can take to join it (mirrors the PipeWire backend's `WORKER_POLL`).
const WORKER_POLL: Duration = Duration::from_millis(100);
/// Windows audio backend. See module docs.
pub struct CpalBackend {
capture: Mutex<Option<StreamWorker>>,
playback: Mutex<Option<StreamWorker>>,
}
/// A spawned owning thread plus the flag that tells it to drop its stream and exit.
struct StreamWorker {
running: Arc<AtomicBool>,
thread: JoinHandle<()>,
}
impl CpalBackend {
pub fn new() -> Self {
Self {
capture: Mutex::new(None),
playback: Mutex::new(None),
}
}
}
impl Default for CpalBackend {
fn default() -> Self {
Self::new()
}
}
impl AudioBackend for CpalBackend {
fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let guard = self.capture.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-capture".to_string())
.spawn(move || {
run_capture(tx, target_node, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "capture")
}
fn start_playback(
&self,
rx: Receiver<Vec<i16>>,
target_node: Option<String>,
ring_fill: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
let guard = self.playback.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Playback already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-playback".to_string())
.spawn(move || {
run_playback(rx, target_node, ring_fill, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "playback")
}
fn stop(&self) -> Result<(), AudioError> {
for slot in [&self.capture, &self.playback] {
if let Some(worker) = slot.lock().unwrap().take() {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
}
}
Ok(())
}
}
/// Block until the just-spawned worker reports (over `ready_rx`) that its stream
/// is built and playing, then either install it (`Ok`) or join it and surface the
/// real error. This is what makes `start_capture`/`start_playback` fail loudly
/// instead of returning `Ok` into a joined-but-silent room (Codex review W1).
fn finish_start(
mut guard: std::sync::MutexGuard<'_, Option<StreamWorker>>,
worker: StreamWorker,
ready_rx: Receiver<Result<(), AudioError>>,
what: &str,
) -> Result<(), AudioError> {
match ready_rx.recv() {
Ok(Ok(())) => {
*guard = Some(worker);
Ok(())
}
// Setup failed (Err) or the worker exited before reporting (recv Err):
// either way it has stopped, so reap it and surface the error.
Ok(Err(e)) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(e)
}
Err(_) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(AudioError::Init(format!(
"cpal {what} worker exited before reporting readiness"
)))
}
}
}
// ---------------------------------------------------------------------------
// Device enumeration (for the settings device pickers)
// ---------------------------------------------------------------------------
/// Enumerate WASAPI input/output devices via cpal, sorted by description to match
/// the PipeWire backend's stable UI ordering.
///
/// cpal exposes a single friendly name per device, which is also what [`resolve`]
/// matches `target_node` against — so `name` and `description` are the same string
/// and a saved selection round-trips. Note: WASAPI device names are less stable
/// across driver/endpoint changes than PipeWire node names, so a saved device may
/// not always be found again; selection then falls back to the system default.
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let host = cpal::default_host();
let mut devices = Vec::new();
if let Ok(inputs) = host.input_devices() {
for device in inputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: true,
});
}
}
}
if let Ok(outputs) = host.output_devices() {
for device in outputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: false,
});
}
}
}
devices.sort_by(|a, b| a.description.cmp(&b.description));
devices
}
// ---------------------------------------------------------------------------
// Device / config selection
// ---------------------------------------------------------------------------
/// Resolve a device (by `target` name, else the system default) and a stream
/// config running natively at [`SAMPLE_RATE`].
///
/// For output we require [`PLAYBACK_CHANNELS`] (stereo) so the interleaved ring
/// maps 1:1 to the device buffer; for input we prefer mono but accept any channel
/// count and downmix. A device with no 48 kHz config is a hard error (no
/// resampling yet — see module docs).
fn resolve(
output: bool,
target: Option<String>,
) -> Result<(Device, StreamConfig, SampleFormat), AudioError> {
let host = cpal::default_host();
let default = || {
if output {
host.default_output_device()
} else {
host.default_input_device()
}
};
let device = match target {
// A saved device name that no longer resolves falls back to the system
// default — but log it, because WASAPI friendly names can change across
// driver/endpoint changes, so a silent fallback otherwise looks like
// "audio went to the wrong device for no reason" (review W7).
Some(ref name) => match find_device_by_name(&host, output, name) {
Some(dev) => Some(dev),
None => {
crate::log_msg(&format!(
"cpal: saved {} device '{name}' not found; using system default",
if output { "output" } else { "input" },
));
default()
}
},
None => default(),
}
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
let supported = choose_config(&device, output)?;
let sample_format = supported.sample_format();
let config = supported.config();
Ok((device, config, sample_format))
}
fn find_device_by_name(host: &cpal::Host, output: bool, name: &str) -> Option<Device> {
let devices = if output {
host.output_devices().ok()?
} else {
host.input_devices().ok()?
};
devices.into_iter().find(|d| d.name().is_ok_and(|n| n == name))
}
/// Pick a supported config at exactly [`SAMPLE_RATE`]. Output must be stereo;
/// input prefers mono, then any channel count (downmixed later).
fn choose_config(
device: &Device,
output: bool,
) -> Result<cpal::SupportedStreamConfig, AudioError> {
let ranges: Vec<cpal::SupportedStreamConfigRange> = if output {
device
.supported_output_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
} else {
device
.supported_input_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
};
// A range covers a sample-rate span and a fixed channel count.
let supports_48k = |r: &cpal::SupportedStreamConfigRange| {
r.min_sample_rate().0 <= SAMPLE_RATE && SAMPLE_RATE <= r.max_sample_rate().0
};
let pick = |channels: Option<u16>| {
ranges
.iter()
.find(|r| supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
.cloned()
};
let chosen = if output {
pick(Some(PLAYBACK_CHANNELS as u16))
} else {
pick(Some(1)).or_else(|| pick(None))
};
chosen
.map(|r| r.with_sample_rate(SampleRate(SAMPLE_RATE)))
.ok_or_else(|| {
AudioError::Device(format!(
"device '{}' has no {SAMPLE_RATE} Hz {} config; resampling not yet implemented (Phase 1.1)",
device.name().unwrap_or_else(|_| "<unknown>".to_string()),
if output { "stereo output" } else { "input" },
))
})
}
// ---------------------------------------------------------------------------
// Capture
// ---------------------------------------------------------------------------
fn run_capture(
tx: Sender<Vec<i16>>,
target: Option<String>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
// The RT callback pushes mono samples into this lock-free ring; we drain it on
// this (non-RT) thread, so the callback never allocates or sends on a channel.
let rb = HeapRb::<i16>::new(CAPTURE_RING_CAPACITY);
let (producer, mut consumer) = rb.split();
let overrun = Arc::new(AtomicU64::new(0));
// Fallible device/stream setup. We report the real error to `start_capture`
// before doing any work, so a join never lands in a silent room.
let setup = || -> Result<(Stream, String, SampleFormat, usize), AudioError> {
let (device, config, sample_format) = resolve(false, target)?;
let channels = config.channels as usize;
let stream = match sample_format {
SampleFormat::F32 => {
build_input::<f32, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::I16 => {
build_input::<i16, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::U16 => {
build_input::<u16, _>(&device, &config, producer, channels, overrun.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported capture sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format, channels))
};
let (stream, dev_name, sample_format, channels) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal capture started: device='{dev_name}' format={sample_format:?} channels={channels} rate={SAMPLE_RATE} Hz"
));
// Drain the RT ring on this thread: pop mono samples, frame them (the `Vec`
// allocation lives here, off the RT path), and send completed frames. Keep
// `stream` alive until `stop()` flips the flag.
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
let mut last_overrun = 0u64;
while running.load(Ordering::Relaxed) {
let mut drained = false;
while let Some(sample) = consumer.try_pop() {
drained = true;
if let Some(frame) = acc.push(sample) {
// Consumer gone (call ended) → stop feeding; the stream is
// dropped below on the way out.
if tx.send(frame).is_err() {
drop(stream);
return;
}
}
}
let o = overrun.load(Ordering::Relaxed);
if o != last_overrun {
crate::log_msg(&format!(
"cpal capture overrun: dropped {} samples (drain thread fell behind)",
o - last_overrun
));
last_overrun = o;
}
if !drained {
thread::sleep(CAPTURE_POLL);
}
}
drop(stream);
}
fn build_input<T, P>(
device: &Device,
config: &StreamConfig,
mut producer: P,
channels: usize,
overrun: Arc<AtomicU64>,
) -> Result<Stream, AudioError>
where
T: SizedSample + Send + 'static,
i16: FromSample<T>,
P: Producer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
device
.build_input_stream::<T, _, _>(
config,
move |data: &[T], _| {
// RT-safe: downmix + wait-free push only. A full ring means the
// drain thread stalled; count the drop and keep going.
for frame in data.chunks_exact(channels) {
let mono = downmix_to_mono(frame);
if producer.try_push(mono).is_err() {
overrun.fetch_add(1, Ordering::Relaxed);
}
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Average a device frame's channels down to a single mono i16. For a 1-channel
/// device this is just the converted sample.
fn downmix_to_mono<T>(frame: &[T]) -> i16
where
T: Copy,
i16: FromSample<T>,
{
if frame.is_empty() {
return 0;
}
let sum: i32 = frame.iter().map(|&s| i16::from_sample(s) as i32).sum();
(sum / frame.len() as i32) as i16
}
/// Accumulates mono samples into fixed-size [`CAPTURE_FRAME`] frames. Pulled out
/// of the RT callback so the framing is unit-testable.
struct FrameAccumulator {
buf: Vec<i16>,
frame_len: usize,
}
impl FrameAccumulator {
fn new(frame_len: usize) -> Self {
Self {
buf: Vec::with_capacity(frame_len),
frame_len,
}
}
/// Push one sample; returns a completed frame when the buffer fills.
fn push(&mut self, sample: i16) -> Option<Vec<i16>> {
self.buf.push(sample);
if self.buf.len() == self.frame_len {
Some(std::mem::replace(
&mut self.buf,
Vec::with_capacity(self.frame_len),
))
} else {
None
}
}
}
// ---------------------------------------------------------------------------
// Playback
// ---------------------------------------------------------------------------
fn run_playback(
rx: Receiver<Vec<i16>>,
target: Option<String>,
ring_fill: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
let rb = HeapRb::<i16>::new(RING_CAPACITY);
let (mut producer, consumer) = rb.split();
// Prefill to the steady-state depth so playout starts at target. `ring_fill`
// is an EXACT occupancy counter maintained by deltas (worker fetch_add on
// push, RT callback fetch_sub on pop) — not ringbuf's cached `occupied_len`,
// which is stale across the split halves and would lie high and starve the
// ring. See pipewire_impl.rs for the full rationale.
for _ in 0..PLAYBACK_TARGET_SAMPLES {
let _ = producer.try_push(0);
}
ring_fill.store(PLAYBACK_TARGET_SAMPLES, Ordering::Relaxed);
// Diagnostics (mirrors the PipeWire backend's playout-health line).
let underrun = Arc::new(AtomicU64::new(0));
let dropped = Arc::new(AtomicU64::new(0));
// Largest single output-callback length seen (interleaved samples). WASAPI
// shared-mode picks its own period, so this can exceed the prefill target —
// which would force an underrun every cycle (review W2). The callback only
// does a wait-free fetch_max; the health logger reports/warns off the RT path.
let max_cb = Arc::new(AtomicUsize::new(0));
// Fallible device/stream setup; report the real error to `start_playback`
// before any work so a failure surfaces instead of a silent room. `consumer`
// is moved into the output callback here.
let setup = || -> Result<(Stream, String, SampleFormat), AudioError> {
let (device, config, sample_format) = resolve(true, target)?;
let stream = match sample_format {
SampleFormat::F32 => {
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::I16 => {
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::U16 => {
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format))
};
let (stream, dev_name, sample_format) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal playback started: device='{dev_name}' format={sample_format:?} channels={PLAYBACK_CHANNELS} rate={SAMPLE_RATE} Hz"
));
let logger = spawn_health_logger(
running.clone(),
ring_fill.clone(),
underrun.clone(),
dropped.clone(),
max_cb.clone(),
);
// Feed the ring from the network mixer until `stop()` flips `running` or the
// sender disconnects (call ended). Clock-paced production keeps the ring near
// target, so the drop path below should never fire in steady state.
drain_loop(&rx, &running, |frame| {
if ring_fill.load(Ordering::Relaxed) + frame.len() > RING_CAPACITY {
dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
ring_fill.fetch_add(frame.len(), Ordering::Relaxed);
});
// We're shutting down (either stop() or disconnect). Ensure the logger sees it
// even on the disconnect path, then drop the stream.
running.store(false, Ordering::Relaxed);
let _ = logger.join();
drop(stream);
}
fn build_output<T, C>(
device: &Device,
config: &StreamConfig,
mut consumer: C,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> Result<Stream, AudioError>
where
T: SizedSample + FromSample<i16> + Send + 'static,
C: Consumer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal playback stream error: {e}"));
device
.build_output_stream::<T, _, _>(
config,
move |data: &mut [T], _| {
// Wait-free; the logger thread reads this off the RT path.
max_cb.fetch_max(data.len(), Ordering::Relaxed);
let (popped, starved) = fill_output(&mut consumer, data);
if starved > 0 {
underrun.fetch_add(starved, Ordering::Relaxed);
}
if popped > 0 {
// Decrement the exact occupancy by what we actually pulled
// (underruns removed nothing) so the mixer paces against the
// true ring depth.
ring_fill.fetch_sub(popped, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Drain the ring into the device buffer, substituting silence on underrun.
/// Returns `(samples_popped, samples_starved)`. RT-safe (wait-free `try_pop`).
fn fill_output<T, C>(consumer: &mut C, out: &mut [T]) -> (usize, u64)
where
T: Sample + FromSample<i16>,
C: Consumer<Item = i16>,
{
let mut popped = 0usize;
let mut starved = 0u64;
for slot in out.iter_mut() {
match consumer.try_pop() {
Some(v) => {
*slot = T::from_sample(v);
popped += 1;
}
None => {
*slot = T::from_sample(0i16);
starved += 1;
}
}
}
(popped, starved)
}
/// Once-per-second playout-health line (mirrors the PipeWire backend). Quiet
/// unless a second actually glitched, or `PEERSPEAK_AUDIO_VERBOSE` is set.
fn spawn_health_logger(
running: Arc<AtomicBool>,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
dropped: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> JoinHandle<()> {
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
thread::spawn(move || {
let (mut last_u, mut last_d) = (0u64, 0u64);
let mut reported_cb = 0usize;
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(1));
let u = underrun.load(Ordering::Relaxed);
let d = dropped.load(Ordering::Relaxed);
let fill = ring_fill.load(Ordering::Relaxed);
let (du, dd) = (u - last_u, d - last_d);
last_u = u;
last_d = d;
if verbose || du > 0 || dd > 0 {
crate::log_msg(&format!(
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d})",
fill / (48 * PLAYBACK_CHANNELS),
));
}
// Report the device's callback size the first time it's seen (and on
// any new high). If a callback asks for more than the prefill target,
// the ring can't satisfy it and underruns every cycle — the W2 bug
// signature; warn so a real-host log shows whether it's biting.
let cb = max_cb.load(Ordering::Relaxed);
if cb > reported_cb {
reported_cb = cb;
let ms = cb / (48 * PLAYBACK_CHANNELS);
if cb > PLAYBACK_TARGET_SAMPLES {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)",
));
} else if verbose {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}",
));
}
}
}
})
}
/// Pump frames from `rx` to `on_frame` until `running` goes false or the sender
/// disconnects. The timed receive re-checks `running` at least every
/// [`WORKER_POLL`], so `stop()` can join the worker promptly instead of hanging
/// on a parked blocking `recv()` (same A7 fix as the PipeWire backend). Pure
/// w.r.t. its inputs, so it's unit-testable.
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn downmix_averages_channels() {
assert_eq!(downmix_to_mono::<i16>(&[100, 100]), 100);
assert_eq!(downmix_to_mono::<i16>(&[100, -100]), 0);
assert_eq!(downmix_to_mono::<i16>(&[50]), 50);
assert_eq!(downmix_to_mono::<i16>(&[]), 0);
// 4-channel average rounds toward zero (integer division).
assert_eq!(downmix_to_mono::<i16>(&[10, 20, 30, 41]), 25);
}
#[test]
fn frame_accumulator_emits_full_frames() {
let mut acc = FrameAccumulator::new(3);
assert_eq!(acc.push(1), None);
assert_eq!(acc.push(2), None);
assert_eq!(acc.push(3), Some(vec![1, 2, 3]));
// Resets for the next frame.
assert_eq!(acc.push(4), None);
assert_eq!(acc.push(5), None);
assert_eq!(acc.push(6), Some(vec![4, 5, 6]));
}
#[test]
fn fill_output_pops_then_substitutes_silence() {
let rb = HeapRb::<i16>::new(8);
let (mut prod, mut cons) = rb.split();
for v in [1, 2, 3] {
prod.try_push(v).unwrap();
}
let mut out = [0i16; 5];
let (popped, starved) = fill_output(&mut cons, &mut out);
assert_eq!(popped, 3);
assert_eq!(starved, 2);
assert_eq!(out, [1, 2, 3, 0, 0]);
}
#[test]
fn drain_loop_exits_when_running_flips_even_with_sender_alive() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |_| {}));
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
thread::sleep(WORKER_POLL + Duration::from_millis(150));
assert!(
h.is_finished(),
"drain_loop must exit after running=false even while the sender is alive"
);
drop(tx);
h.join().unwrap();
}
#[test]
fn drain_loop_returns_on_disconnect() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
drop(tx);
drain_loop(&rx, &running, |_| panic!("no frame should arrive"));
}
#[test]
fn drain_loop_delivers_frames() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let got = Arc::new(Mutex::new(Vec::new()));
let g2 = got.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |f| g2.lock().unwrap().push(f)));
tx.send(vec![1, 2, 3]).unwrap();
tx.send(vec![4, 5]).unwrap();
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
drop(tx);
h.join().unwrap();
assert_eq!(*got.lock().unwrap(), vec![vec![1, 2, 3], vec![4, 5]]);
}
}
+1 -45
View File
@@ -56,56 +56,12 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>;
}
pub mod echo_cancel;
pub mod eq;
pub mod gate;
pub mod limiter;
pub mod multitrack;
pub mod pan;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli;
pub mod recorder;
/// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`);
/// `description` is the human-facing label shown in the UI. The two may be equal
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
// Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices;
/// The audio backend implementation for the current platform.
///
/// The whole app constructs and threads this alias (via
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
/// platform selection lives entirely here. Both implementations satisfy the
/// [`AudioBackend`] trait, which is the only interface the core talks to.
///
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
#[cfg(target_os = "linux")]
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
#[cfg(windows)]
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
+13 -1
View File
@@ -1,6 +1,18 @@
use super::AudioDevice;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let output = Command::new("pw-cli")
.arg("list-objects")
+92 -218
View File
@@ -1,11 +1,11 @@
//! Audio playout diagnostic probe.
//!
//! Drives a phase-continuous sine tone through the *real* playback path
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
//! hardware clock. No network, no microphone — this isolates the local output
//! path so we can confirm the clock-paced playout is glitch-free.
//! PipeWire hardware clock. No network, no microphone — this isolates the local
//! output path so we can confirm the clock-paced playout is glitch-free.
//!
//! Use your ears on the tone (any click/pop is a glitch) together with the
//! `playout-health:` lines tailed to stdout:
@@ -17,231 +17,105 @@
//!
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30
//!
//! This probe exercises the platform playback backend directly: PipeWire on Linux
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
#[cfg(target_os = "linux")]
fn main() {
unix_probe::run();
}
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
#[cfg(windows)]
fn main() {
win_probe::run();
}
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
#[cfg(not(any(target_os = "linux", windows)))]
fn main() {
eprintln!("audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly).");
}
const SAMPLE_RATE: f32 = 48_000.0;
#[cfg(target_os = "linux")]
mod unix_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
const SAMPLE_RATE: f32 = 48_000.0;
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
}
#[cfg(windows)]
mod win_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::cpal_impl::CpalBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = CpalBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the cpal/WASAPI hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
});
}
+4 -13
View File
@@ -1,7 +1,7 @@
pub mod messages;
pub mod jitter;
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
@@ -237,7 +237,7 @@ fn run_mic_monitor(
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a
/// room session is active — `backend.stop()` would also tear down the call's
/// capture/playback. Monitor and session are mutually exclusive by construction.
fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
if let Some(m) = monitor {
let _ = backend.stop();
let _ = m.thread.join();
@@ -390,7 +390,6 @@ struct ActiveSession {
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop).
@@ -401,7 +400,7 @@ struct ActiveSession {
}
impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) {
crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
@@ -432,7 +431,6 @@ impl ActiveSession {
// Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel);
crate::log_msg("Leaving room...");
@@ -733,7 +731,7 @@ async fn run_core_loop(
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PlatformAudioBackend::new());
let audio_backend = Arc::new(PipeWireBackend::new());
let is_muted = Arc::new(AtomicBool::new(false));
let is_deafened = Arc::new(AtomicBool::new(false));
@@ -1097,9 +1095,7 @@ async fn run_core_loop(
// The guard unloads the module on drop — including the early-return
// paths below, since it's a local until moved into the session. On
// any failure, warn and fall back to the direct devices.
#[cfg(target_os = "linux")]
let mut echo_cancel_guard = None;
#[cfg(target_os = "linux")]
let (capture_target, playback_target) = if echo_cancellation {
match crate::audio::echo_cancel::enable(
input_device.as_deref(),
@@ -1126,10 +1122,6 @@ async fn run_core_loop(
} else {
(input_device.clone(), output_device.clone())
};
#[cfg(not(target_os = "linux"))]
let _ = echo_cancellation;
#[cfg(not(target_os = "linux"))]
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
@@ -1640,7 +1632,6 @@ async fn run_core_loop(
conn_event_task,
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::new(),
+8 -24
View File
@@ -24,9 +24,6 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
#[cfg(unix)]
const LOG_MODE: u32 = 0o600;
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
@@ -87,6 +84,8 @@ fn prepare_log_file(path: &Path) -> std::io::Result<File> {
}
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
@@ -99,23 +98,12 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
}
}
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
// The log can carry capability-bearing values (redacted, but still): keep it
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
// the directory ACL, so this hardening is Unix-only.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(LOG_MODE);
}
let file = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Re-assert the mode in case the file pre-existed with looser perms.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(LOG_MODE)
.open(path)?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
Ok(file)
}
@@ -138,7 +126,6 @@ pub fn log_msg(msg: &str) {
mod tests {
use super::*;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn temp_log_dir() -> PathBuf {
@@ -158,9 +145,6 @@ mod tests {
assert_eq!(redact_for_log(" "), "<redacted:empty>");
}
// Owner-only log perms are a Unix concept; on Windows the file inherits the
// directory ACL and there's no mode to assert.
#[cfg(unix)]
#[test]
fn log_file_is_created_private() {
let dir = temp_log_dir();
+5 -41
View File
@@ -3,12 +3,11 @@
//!
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
//! binary is self-contained — no asset directory to ship alongside it. On first
//! use each sound is written once to a temp file, then played fire-and-forget.
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
//! detached thread that waits on the child, so it never blocks the UI and never
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
//! missing chime should never disrupt a call.
//! use each sound is written once to a temp file, then played fire-and-forget
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
//! on a detached thread that waits on the child, so it never blocks the UI and
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
//! a missing chime should never disrupt a call.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -203,14 +202,8 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
Some(path)
}
#[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String {
s.replace('\'', "''")
}
/// Try each available player in turn, waiting on the first that starts (which
/// reaps the child). Runs on a detached thread, so the wait is harmless.
#[cfg(not(windows))]
fn spawn_player(path: &Path) {
for player in ["pw-play", "paplay", "aplay"] {
let started = Command::new(player)
@@ -228,23 +221,6 @@ fn spawn_player(path: &Path) {
}
}
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
/// `PlaySync()` blocking for the sound duration is fine.
#[cfg(windows)]
fn spawn_player(path: &Path) {
let path = escape_powershell_single_quoted(&path.display().to_string());
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
let _ = Command::new("powershell")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(test)]
mod tests {
use super::*;
@@ -258,18 +234,6 @@ mod tests {
assert!(!should_play(false, false));
}
#[test]
fn test_powershell_single_quote_escape() {
assert_eq!(
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav"
);
assert_eq!(
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
}
#[test]
fn test_sound_indices_unique_and_match_all() {
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
+1 -21
View File
@@ -25,16 +25,6 @@ use tokio::process::{Child, Command};
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
#[cfg(windows)]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
}
#[cfg(not(windows))]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
[dir.join(PIXELPASS_BIN)]
}
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
@@ -153,7 +143,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
}
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.flat_map(|dir| pixelpass_path_candidates(&dir))
.map(|dir| dir.join(PIXELPASS_BIN))
.find(|c| c.is_file())
}
@@ -523,14 +513,4 @@ mod tests {
// only assert it doesn't return the empty path as a match.
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
}
#[test]
fn pixelpass_path_candidates_are_platform_specific() {
let dir = Path::new("bin");
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[cfg(windows)]
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
#[cfg(not(windows))]
assert_eq!(candidates, vec![dir.join("pixelpass")]);
}
}