Merge windows-port-phase2: native Windows cpal/WASAPI audio port (b0fdd4e)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

Brings the native Windows audio backend to main after a live host<->VM smoke
test: cpal/WASAPI capture+playback, device remap/resampling (W4/B5), cpal
RT-audit closed (B1-B5 + P3), Windows notification chimes, and Wine startup fix.

Verified on real Win11 (libvirt VM) this session: 2-way audio (host<->VM both
directions), audible join/leave/reconnect chimes, GUI renders, echo-cancel
correctly gated off. Linux unchanged (all changes cfg(windows); cargo test --lib
326/0, clippy clean). Windows build is GNU cross-compiled (b0fdd4e tester zip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 02:55:10 -04:00
co-authored by Claude Opus 4.8
15 changed files with 2735 additions and 209 deletions
+34
View File
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,85 @@
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
+268 -24
View File
@@ -105,6 +105,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -114,12 +136,12 @@ dependencies = [
"android-properties", "android-properties",
"bitflags 2.11.1", "bitflags 2.11.1",
"cc", "cc",
"jni", "jni 0.22.4",
"libc", "libc",
"log", "log",
"ndk", "ndk 0.9.0",
"ndk-context", "ndk-context",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"num_enum", "num_enum",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -712,6 +734,12 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cexpr" name = "cexpr"
version = "0.6.0" version = "0.6.0"
@@ -1002,6 +1030,26 @@ dependencies = [
"libm", "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]] [[package]]
name = "cosmic-text" name = "cosmic-text"
version = "0.15.0" version = "0.15.0"
@@ -1026,6 +1074,29 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1228,6 +1299,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -2227,7 +2304,7 @@ dependencies = [
"http", "http",
"idna", "idna",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"rand 0.10.1", "rand 0.10.1",
"rustls", "rustls",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -2247,7 +2324,7 @@ dependencies = [
"data-encoding", "data-encoding",
"idna", "idna",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"once_cell", "once_cell",
"prefix-trie", "prefix-trie",
"rand 0.10.1", "rand 0.10.1",
@@ -2270,7 +2347,7 @@ dependencies = [
"hickory-proto", "hickory-proto",
"ipconfig", "ipconfig",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"moka", "moka",
"ndk-context", "ndk-context",
"once_cell", "once_cell",
@@ -3102,6 +3179,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -3463,6 +3556,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -3596,7 +3698,7 @@ dependencies = [
"dispatch", "dispatch",
"futures-channel", "futures-channel",
"futures-lite", "futures-lite",
"jni", "jni 0.22.4",
"ndk-context", "ndk-context",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
@@ -3695,6 +3797,20 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -3704,7 +3820,7 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"num_enum", "num_enum",
"raw-window-handle", "raw-window-handle",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -3716,6 +3832,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 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]] [[package]]
name = "ndk-sys" name = "ndk-sys"
version = "0.6.0+11769913" version = "0.6.0+11769913"
@@ -4466,6 +4591,29 @@ dependencies = [
"objc2-foundation 0.2.2", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -4600,6 +4748,7 @@ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"bytes", "bytes",
"cpal",
"dirs", "dirs",
"iced", "iced",
"image", "image",
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"jni", "jni 0.22.4",
"log", "log",
"once_cell", "once_cell",
"rustls", "rustls",
@@ -5877,7 +6026,7 @@ dependencies = [
"fastrand", "fastrand",
"js-sys", "js-sys",
"memmap2", "memmap2",
"ndk", "ndk 0.9.0",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -7162,7 +7311,7 @@ dependencies = [
"log", "log",
"metal", "metal",
"naga", "naga",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"objc", "objc",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
@@ -7247,6 +7396,16 @@ dependencies = [
"thiserror 2.0.18", "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]] [[package]]
name = "windows" name = "windows"
version = "0.58.0" version = "0.58.0"
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [ dependencies = [
"windows-core 0.58.0", "windows-core 0.58.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7278,6 +7437,16 @@ dependencies = [
"windows-core 0.62.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.58.0" version = "0.58.0"
@@ -7288,7 +7457,7 @@ dependencies = [
"windows-interface 0.58.0", "windows-interface 0.58.0",
"windows-result 0.2.0", "windows-result 0.2.0",
"windows-strings 0.1.0", "windows-strings 0.1.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7386,13 +7555,22 @@ dependencies = [
"windows-strings 0.5.1", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [ dependencies = [
"windows-result 0.2.0", "windows-result 0.2.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7423,13 +7601,22 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7441,20 +7628,35 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
] ]
[[package]] [[package]]
@@ -7466,18 +7668,36 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7490,24 +7710,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7536,7 +7780,7 @@ dependencies = [
"js-sys", "js-sys",
"libc", "libc",
"memmap2", "memmap2",
"ndk", "ndk 0.9.0",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
+23 -7
View File
@@ -30,17 +30,12 @@ bytes = "1.11.1"
dirs = "6.0.0" dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] } iced = { version = "0.14.0", features = ["canvas", "image"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep # W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small) and a native file picker (xdg-portal backend, no GTK). # 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).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } 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 = "1.0.0-rc.0"
iroh-gossip = "0.99.0" iroh-gossip = "0.99.0"
opus = "0.3.1" 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" rand = "0.10.1"
ringbuf = "0.5.0" ringbuf = "0.5.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -48,3 +43,24 @@ serde_json = "1.0.150"
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18" 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
@@ -0,0 +1,77 @@
# 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 | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
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.
+98 -36
View File
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState; use crate::network::PeerState;
use crate::notify::{self, Sound}; use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN}; use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
@@ -553,11 +553,9 @@ pub fn run_gui() -> iced::Result {
// the icon from the .desktop file matched by app_id instead). // the icon from the .desktop file matched by app_id instead).
icon: window_icon(), icon: window_icon(),
// app_id must match the .desktop basename so Wayland compositors // app_id must match the .desktop basename so Wayland compositors
// (e.g. KWin) attach our launcher icon to the window. // (e.g. KWin) attach our launcher icon to the window. The field is
platform_specific: iced::window::settings::PlatformSpecific { // Linux-only in iced (X11/Wayland); see platform_specific_settings().
application_id: "peerspeak".to_string(), platform_specific: platform_specific_settings(),
..Default::default()
},
// We save the final size ourselves on CloseRequested, then exit. // We save the final size ourselves on CloseRequested, then exit.
exit_on_close_request: false, exit_on_close_request: false,
..Default::default() ..Default::default()
@@ -565,6 +563,22 @@ pub fn run_gui() -> iced::Result {
.run() .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 /// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps /// 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. /// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
@@ -1350,12 +1364,28 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Defence in depth: only ever hand http(s) URLs to the opener. The // 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, // 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 // but re-check here so this can't be widened into launching arbitrary
// schemes/args. `xdg-open` receives the URL as a single argv entry // schemes/args. Each opener receives the URL as a single argv entry
// (no shell), so there's no injection surface. // (no shell), so there's no injection surface:
if (url.starts_with("http://") || url.starts_with("https://")) // - Unix: `xdg-open <url>`.
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn() // - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
{ // default browser without going through `cmd`/`start`, which would
crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); // 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}"));
}
} }
} }
AppMessage::ToggleMicTest(enabled) => { AppMessage::ToggleMicTest(enabled) => {
@@ -2541,10 +2571,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
mic_meter, 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), 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), vertical_space(4.0),
checkbox(state.config.echo_cancellation_enabled) {
.label("Echo cancellation") let control: Element<'_, AppMessage> = {
.on_toggle(AppMessage::ToggleEchoCancellation), #[cfg(target_os = "linux")]
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext), {
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
},
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
] ]
.spacing(10) .spacing(10)
@@ -3247,26 +3295,40 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![] column![]
}, },
vertical_space(20.0), vertical_space(20.0),
// Echo cancellation — same flag + message as the Settings checkbox, so {
// toggling here and there stay in sync automatically (single source of // Echo cancellation is wired at join time on Linux; other
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it // targets show an inert status row instead of a dead toggle.
// applies on the NEXT join (the PipeWire-module AEC is wired at join let control: Element<'_, AppMessage> = {
// time, not hot-swappable mid-call). #[cfg(target_os = "linux")]
tooltip( {
checkbox(state.config.echo_cancellation_enabled) tooltip(
.label("Echo cancellation") checkbox(state.config.echo_cancellation_enabled)
.on_toggle(AppMessage::ToggleEchoCancellation), .label("Echo cancellation")
container( .on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise. Applies on your next room join.") container(
.size(11) text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.color(color_text), .size(11)
) .color(color_text),
.padding(8) )
.max_width(260.0) .padding(8)
.style(c_style(color_crust, color_surface, 6.0)), .max_width(260.0)
iced::widget::tooltip::Position::Top, .style(c_style(color_crust, color_surface, 6.0)),
) iced::widget::tooltip::Position::Top,
.gap(8), )
.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
},
vertical_space(20.0), vertical_space(20.0),
{ {
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
File diff suppressed because it is too large Load Diff
+49 -1
View File
@@ -56,12 +56,60 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>; fn stop(&self) -> Result<(), AudioError>;
} }
pub mod echo_cancel;
pub mod eq; pub mod eq;
pub mod gate; pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
pub mod pan; pub mod pan;
// 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
// backend wires it in.
pub mod resample;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl; pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; 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;
+1 -13
View File
@@ -1,18 +1,6 @@
use super::AudioDevice;
use std::process::Command; 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> { pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let output = Command::new("pw-cli") let output = Command::new("pw-cli")
.arg("list-objects") .arg("list-objects")
+307
View File
@@ -0,0 +1,307 @@
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
//!
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
//! channel layout. These convert at the device boundary so such a device plays and
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
//!
//! ## Where each is used
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
//! to 48 kHz on the capture drain thread — off the RT callback.
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
//! bus to the device rate inside the output RT callback, pulling internal frames
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
//!
//! ## Quality
//! This is plain linear interpolation with no anti-aliasing filter: correct,
//! allocation-free, and adequate for speech, but it adds some aliasing when
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
//! replace the internals without touching the cpal backend. The matching-rate /
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
#[inline]
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
a + (b - a) * frac
}
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
/// receive output samples at `out_rate` through an `emit` callback. It carries the
/// fractional read position and the previous input sample across calls, so feeding
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
/// [`process`](Self::process) allocates.
pub struct PushResampler {
/// Input samples consumed per output sample (`in_rate / out_rate`).
step: f64,
/// Position of the next output sample, in input-sample units, measured from the
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
/// after each input is consumed.
next: f64,
/// The previous input sample (left edge of the current interpolation segment).
prev: f32,
/// Whether any input has been seen yet (anchors the first output at input[0]).
started: bool,
}
impl PushResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
/// cpal backend's `resolve()` also rejects such rates up front, so this is
/// belt-and-suspenders against a future caller (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
next: 0.0,
prev: 0.0,
started: false,
}
}
/// Feed one input sample; `emit` is called for each output sample produced
/// (zero or more, depending on the rate ratio).
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
if !self.started {
// First sample: just establish the left edge. Linear interpolation
// needs the next input as the right edge, so the first output is
// produced on the next push. This gives exact alignment
// (`output[k] == input[k]` at equal rates) with one input-sample of
// latency — negligible (~20 µs at 48 kHz).
self.started = true;
self.prev = cur;
self.next = 0.0;
return;
}
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
// every output whose position falls in [0, 1).
while self.next < 1.0 {
emit(lerp(self.prev, cur, self.next as f32));
self.next += self.step;
}
self.next -= 1.0;
self.prev = cur;
}
/// Convenience for tests / batch callers: push a whole slice.
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
for &s in input {
self.push(s, &mut emit);
}
}
}
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
/// pulling input frames at `in_rate` from a closure on demand. Call
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
/// callback.
pub struct StereoPullResampler {
/// Input frames consumed per output frame (`in_rate / out_rate`).
step: f64,
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
frac: f64,
/// Left edge of the current interpolation segment.
prev: (f32, f32),
/// Right edge of the current interpolation segment.
cur: (f32, f32),
/// Whether `prev`/`cur` have been primed from the puller yet.
primed: bool,
}
impl StereoPullResampler {
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
pub fn new(in_rate: u32, out_rate: u32) -> Self {
Self {
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
frac: 0.0,
prev: (0.0, 0.0),
cur: (0.0, 0.0),
primed: false,
}
}
/// Produce the next output frame, pulling input frames via `pull` as needed.
/// Returns `None` if `pull` returns `None` before the frame can be formed
/// (underrun); the caller should substitute silence for that frame.
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
if !self.primed {
// Prime both edges from two pulls so the first output frame aligns
// exactly with the first input frame (`out[0] == in[0]` at equal
// rates). Needs two frames available to start, which the prefilled
// playback ring always has.
self.prev = pull()?;
self.cur = pull()?;
self.primed = true;
self.frac = 0.0;
}
// Advance the segment until the read position lands inside [prev, cur).
while self.frac >= 1.0 {
self.prev = self.cur;
self.cur = pull()?;
self.frac -= 1.0;
}
let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
self.frac += self.step;
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
#[test]
fn push_identity_when_rates_match() {
let mut r = PushResampler::new(48_000, 48_000);
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
assert_eq!(out.len(), input.len() - 1);
for (a, b) in out.iter().zip(input.iter()) {
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
}
}
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
#[test]
fn push_upsample_2x_interpolates_midpoints() {
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
let input = [0.0, 1.0, 2.0, 3.0];
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// (n - 1) segments at 2 outputs each = 6.
assert_eq!(out.len(), 6, "out {out:?}");
// A half-step between 1.0 and 2.0 must appear near 1.5.
assert!(
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
"expected a ~1.5 midpoint in {out:?}"
);
}
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
#[test]
fn push_downsample_reduces_count() {
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
let mut out = Vec::new();
r.process(&input, |s| out.push(s));
// 441 in @ 48k -> ~405 out @ 44.1k.
assert!(
(390..=410).contains(&out.len()),
"expected ~405 outputs, got {}",
out.len()
);
// Output stays within the input's value range and is non-decreasing.
for w in out.windows(2) {
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
}
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
}
/// Pull resampler at equal rates returns each input frame in order, aligned.
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
/// outputs (the last frame emits once a successor arrives).
#[test]
fn pull_identity_when_rates_match() {
let mut r = StereoPullResampler::new(48_000, 48_000);
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
let mut idx = 0;
let mut out = Vec::new();
while let Some(f) = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
}) {
out.push(f);
}
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
for (got, want) in out.iter().zip(frames.iter()) {
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
}
}
/// Pull resampler reports underrun (`None`) once the source is exhausted.
#[test]
fn pull_returns_none_on_underrun() {
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
let frames = [(0.0, 0.0), (1.0, -1.0)];
let mut idx = 0;
let mut pull = || {
let v = frames.get(idx).copied();
idx += 1;
v
};
// First frame primes + emits; subsequent calls eventually exhaust the source.
let mut produced = 0;
let mut hit_none = false;
for _ in 0..10 {
if r.next(&mut pull).is_some() {
produced += 1;
} else {
hit_none = true;
break;
}
}
assert!(produced >= 1, "should produce at least the primed frame");
assert!(hit_none, "should report underrun once the puller is dry");
}
/// Downsampling via pull consumes more input frames than it emits output frames.
#[test]
fn pull_downsample_consumes_more_than_it_emits() {
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
let mut idx = 0;
let mut emitted = 0;
for _ in 0..40 {
let f = r.next(|| {
let v = input.get(idx).copied();
idx += 1;
v
});
if f.is_some() {
emitted += 1;
} else {
break;
}
}
// At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
}
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
#[test]
fn push_zero_rate_does_not_spin() {
let mut r = PushResampler::new(0, 48_000);
let mut count = 0usize;
// Feed two samples; with a clamped non-zero step this returns promptly.
r.push(0.0, |_| count += 1);
r.push(1.0, |_| count += 1);
// Reaching here at all is the assertion (no hang); some output is produced.
assert!(count >= 1);
}
/// A zero output rate must not make the pull resampler's segment-advance loop
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
#[test]
fn pull_zero_out_rate_does_not_spin() {
let mut r = StereoPullResampler::new(48_000, 0);
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
let mut idx = 0;
let got = r.next(|| {
let v = frames.get(idx).copied();
idx += 1;
v
});
// Terminates and yields the primed frame instead of hanging.
assert!(got.is_some());
}
}
+229 -99
View File
@@ -1,11 +1,11 @@
//! Audio playout diagnostic probe. //! Audio playout diagnostic probe.
//! //!
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path //! Drives a phase-continuous sine tone through the *real* playback path
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production //! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
//! the production mixer uses (`core/mod.rs`): generate a frame only while the //! 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 //! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
//! PipeWire hardware clock. No network, no microphone — this isolates the local //! hardware clock. No network, no microphone — this isolates the local output
//! output path so we can confirm the clock-paced playout is glitch-free. //! 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 //! Use your ears on the tone (any click/pop is a glitch) together with the
//! `playout-health:` lines tailed to stdout: //! `playout-health:` lines tailed to stdout:
@@ -17,105 +17,235 @@
//! //!
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node] //! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30 //! 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.
use std::io::{BufRead, BufReader, Seek, SeekFrom}; #[cfg(target_os = "linux")]
use std::sync::Arc; fn main() {
use std::sync::atomic::AtomicUsize; unix_probe::run();
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[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();
// 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.");
} }
/// Open the app log, seek to the end, and echo new lines (the `playout-health:` #[cfg(windows)]
/// reports) to stdout once they appear. fn main() {
fn spawn_log_tailer() { win_probe::run();
let path = peerspeak::log_file_path(); }
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it). #[cfg(not(any(target_os = "linux", windows)))]
let file = loop { fn main() {
if let Ok(f) = std::fs::File::open(&path) { eprintln!(
break f; "audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
);
}
#[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;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
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 = 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;
} }
std::thread::sleep(Duration::from_millis(100)); let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
}; for _ in 0..FRAME_SAMPLES {
let mut reader = BufReader::new(file); let t = n as f32 / SAMPLE_RATE;
let _ = reader.seek(SeekFrom::End(0)); // 0.25 amplitude: clearly audible but not harsh.
loop { let sample =
let mut line = String::new(); (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
match reader.read_line(&mut line) { // Stereo playback bus: duplicate the probe tone to L/R.
Ok(0) => std::thread::sleep(Duration::from_millis(150)), frame.push(sample);
Ok(_) => { frame.push(sample);
if line.contains("playout-health:") { n += 1;
print!("{line}"); }
} 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;
} }
Err(_) => std::thread::sleep(Duration::from_millis(150)), 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;
} }
} }
});
// 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)),
}
}
});
}
} }
+33 -15
View File
@@ -1,7 +1,7 @@
pub mod messages; pub mod messages;
pub mod jitter; pub mod jitter;
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings}; use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; 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 /// 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 /// room session is active — `backend.stop()` would also tear down the call's
/// capture/playback. Monitor and session are mutually exclusive by construction. /// capture/playback. Monitor and session are mutually exclusive by construction.
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) { fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
if let Some(m) = monitor { if let Some(m) = monitor {
let _ = backend.stop(); let _ = backend.stop();
let _ = m.thread.join(); let _ = m.thread.join();
@@ -390,6 +390,7 @@ struct ActiveSession {
grace_timers: GraceTimers, grace_timers: GraceTimers,
transport: Arc<IrohTransport>, transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop. /// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>, echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it /// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop). /// also dies if the session is dropped without an explicit stop).
@@ -400,7 +401,7 @@ struct ActiveSession {
} }
impl ActiveSession { impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) { async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
crate::log_msg("ActiveSession::shutdown started"); crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming // Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers // promptly (kill_on_drop is the backstop, but kill explicitly so viewers
@@ -431,6 +432,7 @@ impl ActiveSession {
// Unload the echo-cancel module now that the audio streams releasing its // Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.) // virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel); drop(self.echo_cancel);
crate::log_msg("Leaving room..."); crate::log_msg("Leaving room...");
@@ -731,7 +733,7 @@ async fn run_core_loop(
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> = let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
Arc::new(std::sync::Mutex::new(HashMap::new())); Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PipeWireBackend::new()); let audio_backend = Arc::new(PlatformAudioBackend::new());
let is_muted = Arc::new(AtomicBool::new(false)); let is_muted = Arc::new(AtomicBool::new(false));
let is_deafened = Arc::new(AtomicBool::new(false)); let is_deafened = Arc::new(AtomicBool::new(false));
@@ -1095,7 +1097,9 @@ async fn run_core_loop(
// The guard unloads the module on drop — including the early-return // The guard unloads the module on drop — including the early-return
// paths below, since it's a local until moved into the session. On // paths below, since it's a local until moved into the session. On
// any failure, warn and fall back to the direct devices. // any failure, warn and fall back to the direct devices.
#[cfg(target_os = "linux")]
let mut echo_cancel_guard = None; let mut echo_cancel_guard = None;
#[cfg(target_os = "linux")]
let (capture_target, playback_target) = if echo_cancellation { let (capture_target, playback_target) = if echo_cancellation {
match crate::audio::echo_cancel::enable( match crate::audio::echo_cancel::enable(
input_device.as_deref(), input_device.as_deref(),
@@ -1122,6 +1126,10 @@ async fn run_core_loop(
} else { } else {
(input_device.clone(), output_device.clone()) (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) { 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; let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
@@ -1632,6 +1640,7 @@ async fn run_core_loop(
conn_event_task, conn_event_task,
grace_timers, grace_timers,
transport: transport.clone(), transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard, echo_cancel: echo_cancel_guard,
screenshare_host: None, screenshare_host: None,
screenshare_viewers: Vec::new(), screenshare_viewers: Vec::new(),
@@ -1807,17 +1816,26 @@ async fn run_core_loop(
} }
CoreCommand::SetNetworkMode(mode) => { CoreCommand::SetNetworkMode(mode) => {
network_mode = mode; // Skip when the posture is unchanged. The GUI re-sends the saved
// Rebuild the persistent stack to the new posture immediately if // network mode as part of its startup config-sync, and that mode
// idle; if a call is active, defer to the next Leave/Join so the // usually already matches the freshly-built stack — rebuilding the
// live call isn't disrupted (preserves "applies on next join"). // iroh endpoint for an identical posture just churns the network
if active_session.is_none() { // and adds a needless ~1s teardown+rebuild bounce at every launch
let lookup = net.memory_lookup.clone(); // (seen on both Linux and Windows/Wine). A real change still
net.shutdown().await; // rebuilds exactly as before.
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); if mode != network_mode {
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; network_mode = mode;
} else { // Rebuild the persistent stack to the new posture immediately if
net_rebuild_pending = true; // idle; if a call is active, defer to the next Leave/Join so the
// live call isn't disrupted (preserves "applies on next join").
if active_session.is_none() {
let lookup = net.memory_lookup.clone();
net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else {
net_rebuild_pending = true;
}
} }
} }
+24 -8
View File
@@ -24,6 +24,9 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; 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; const LOG_MODE: u32 = 0o600;
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log` /// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
@@ -84,8 +87,6 @@ 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> { 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() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
@@ -98,12 +99,23 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
} }
} }
let file = std::fs::OpenOptions::new() let mut opts = std::fs::OpenOptions::new();
.create(true) opts.create(true).append(true);
.append(true) // The log can carry capability-bearing values (redacted, but still): keep it
.mode(LOG_MODE) // owner-only on Unix via the open mode. Windows has no mode bits; it inherits
.open(path)?; // the directory ACL, so this hardening is Unix-only.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE)); #[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));
}
Ok(file) Ok(file)
} }
@@ -126,6 +138,7 @@ pub fn log_msg(msg: &str) {
mod tests { mod tests {
use super::*; use super::*;
use std::io::Write; use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
fn temp_log_dir() -> PathBuf { fn temp_log_dir() -> PathBuf {
@@ -145,6 +158,9 @@ mod tests {
assert_eq!(redact_for_log(" "), "<redacted:empty>"); 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] #[test]
fn log_file_is_created_private() { fn log_file_is_created_private() {
let dir = temp_log_dir(); let dir = temp_log_dir();
+41 -5
View File
@@ -3,11 +3,12 @@
//! //!
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single //! 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 //! 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 //! 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 //! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
//! on a detached thread that waits on the child, so it never blocks the UI and //! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
//! never leaves a zombie. Any failure (no player, no audio) is silent by design — //! detached thread that waits on the child, so it never blocks the UI and never
//! a missing chime should never disrupt a call. //! 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::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
Some(path) 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 /// 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. /// reaps the child). Runs on a detached thread, so the wait is harmless.
#[cfg(not(windows))]
fn spawn_player(path: &Path) { fn spawn_player(path: &Path) {
for player in ["pw-play", "paplay", "aplay"] { for player in ["pw-play", "paplay", "aplay"] {
let started = Command::new(player) let started = Command::new(player)
@@ -221,6 +228,23 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -234,6 +258,18 @@ mod tests {
assert!(!should_play(false, false)); 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] #[test]
fn test_sound_indices_unique_and_match_all() { fn test_sound_indices_unique_and_match_all() {
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag // `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
+21 -1
View File
@@ -25,6 +25,16 @@ use tokio::process::{Child, Command};
/// points elsewhere. /// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass"; 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 /// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
/// growth, but reject unbounded gossip payloads before the UI offers "Watch". /// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512; const MAX_TICKET_LEN: usize = 512;
@@ -143,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
} }
let path_var = std::env::var_os("PATH")?; let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var) std::env::split_paths(&path_var)
.map(|dir| dir.join(PIXELPASS_BIN)) .flat_map(|dir| pixelpass_path_candidates(&dir))
.find(|c| c.is_file()) .find(|c| c.is_file())
} }
@@ -513,4 +523,14 @@ mod tests {
// only assert it doesn't return the empty path as a match. // only assert it doesn't return the empty path as a match.
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new(""))); 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")]);
}
} }