Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff7766ede |
@@ -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
|
|
||||||
@@ -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
@@ -105,28 +105,6 @@ 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"
|
||||||
@@ -136,12 +114,12 @@ dependencies = [
|
|||||||
"android-properties",
|
"android-properties",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cc",
|
"cc",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk 0.9.0",
|
"ndk",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"ndk-sys 0.6.0+11769913",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -734,12 +712,6 @@ 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"
|
||||||
@@ -1030,26 +1002,6 @@ 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"
|
||||||
@@ -1074,29 +1026,6 @@ 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"
|
||||||
@@ -1299,12 +1228,6 @@ 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"
|
||||||
@@ -2304,7 +2227,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2324,7 +2247,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -2347,7 +2270,7 @@ dependencies = [
|
|||||||
"hickory-proto",
|
"hickory-proto",
|
||||||
"ipconfig",
|
"ipconfig",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"moka",
|
"moka",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3179,22 +3102,6 @@ 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"
|
||||||
@@ -3556,15 +3463,6 @@ 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"
|
||||||
@@ -3698,7 +3596,7 @@ dependencies = [
|
|||||||
"dispatch",
|
"dispatch",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
@@ -3797,20 +3695,6 @@ 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"
|
||||||
@@ -3820,7 +3704,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys 0.6.0+11769913",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -3832,15 +3716,6 @@ 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"
|
||||||
@@ -4591,29 +4466,6 @@ 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"
|
||||||
@@ -4748,7 +4600,6 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cpal",
|
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -5585,7 +5436,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni 0.22.4",
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -6026,7 +5877,7 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"ndk 0.9.0",
|
"ndk",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -7311,7 +7162,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"metal",
|
"metal",
|
||||||
"naga",
|
"naga",
|
||||||
"ndk-sys 0.6.0+11769913",
|
"ndk-sys",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -7396,16 +7247,6 @@ 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"
|
||||||
@@ -7413,7 +7254,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 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7437,16 +7278,6 @@ 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"
|
||||||
@@ -7457,7 +7288,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 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7555,22 +7386,13 @@ 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 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7589,7 +7411,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 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7601,22 +7423,13 @@ 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 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7628,35 +7441,20 @@ 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 0.52.6",
|
"windows_aarch64_gnullvm",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7668,36 +7466,18 @@ 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"
|
||||||
@@ -7710,48 +7490,24 @@ 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"
|
||||||
@@ -7780,7 +7536,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"ndk 0.9.0",
|
"ndk",
|
||||||
"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",
|
||||||
|
|||||||
+7
-23
@@ -30,12 +30,17 @@ 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). The matching native file picker (`rfd`) is platform-
|
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
|
||||||
# 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"] }
|
||||||
@@ -43,24 +48,3 @@ 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"
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+16
-78
@@ -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::{AudioDevice, enumerate_audio_devices};
|
use crate::audio::pw_cli::{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,9 +553,11 @@ 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. The field is
|
// (e.g. KWin) attach our launcher icon to the window.
|
||||||
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
|
platform_specific: iced::window::settings::PlatformSpecific {
|
||||||
platform_specific: platform_specific_settings(),
|
application_id: "peerspeak".to_string(),
|
||||||
|
..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()
|
||||||
@@ -563,22 +565,6 @@ 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.
|
||||||
@@ -1364,30 +1350,14 @@ 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. Each opener receives the URL as a single argv entry
|
// schemes/args. `xdg-open` receives the URL as a single argv entry
|
||||||
// (no shell), so there's no injection surface:
|
// (no shell), so there's no injection surface.
|
||||||
// - Unix: `xdg-open <url>`.
|
if (url.starts_with("http://") || url.starts_with("https://"))
|
||||||
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
|
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn()
|
||||||
// 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}"));
|
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
AppMessage::ToggleMicTest(enabled) => {
|
AppMessage::ToggleMicTest(enabled) => {
|
||||||
state.mic_test_active = enabled;
|
state.mic_test_active = enabled;
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -2571,28 +2541,10 @@ 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),
|
||||||
{
|
|
||||||
let control: Element<'_, AppMessage> = {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
column![
|
|
||||||
checkbox(state.config.echo_cancellation_enabled)
|
checkbox(state.config.echo_cancellation_enabled)
|
||||||
.label("Echo cancellation")
|
.label("Echo cancellation")
|
||||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
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)
|
||||||
@@ -3295,12 +3247,11 @@ 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
|
||||||
// Echo cancellation is wired at join time on Linux; other
|
// toggling here and there stay in sync automatically (single source of
|
||||||
// targets show an inert status row instead of a dead toggle.
|
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
|
||||||
let control: Element<'_, AppMessage> = {
|
// applies on the NEXT join (the PipeWire-module AEC is wired at join
|
||||||
#[cfg(target_os = "linux")]
|
// time, not hot-swappable mid-call).
|
||||||
{
|
|
||||||
tooltip(
|
tooltip(
|
||||||
checkbox(state.config.echo_cancellation_enabled)
|
checkbox(state.config.echo_cancellation_enabled)
|
||||||
.label("Echo cancellation")
|
.label("Echo cancellation")
|
||||||
@@ -3315,20 +3266,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.style(c_style(color_crust, color_surface, 6.0)),
|
.style(c_style(color_crust, color_surface, 6.0)),
|
||||||
iced::widget::tooltip::Position::Top,
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -56,56 +56,12 @@ 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;
|
||||||
#[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;
|
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,18 @@
|
|||||||
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")
|
||||||
|
|||||||
+20
-146
@@ -1,11 +1,11 @@
|
|||||||
//! Audio playout diagnostic probe.
|
//! Audio playout diagnostic probe.
|
||||||
//!
|
//!
|
||||||
//! Drives a phase-continuous sine tone through the *real* playback path
|
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
||||||
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
||||||
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
//! 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
|
||||||
//! hardware clock. No network, no microphone — this isolates the local output
|
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
||||||
//! path so we can confirm the clock-paced playout is glitch-free.
|
//! 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
|
//! 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,41 +17,21 @@
|
|||||||
//!
|
//!
|
||||||
//! 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.
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
fn main() {
|
use std::sync::Arc;
|
||||||
unix_probe::run();
|
use std::sync::atomic::AtomicUsize;
|
||||||
}
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(windows)]
|
use peerspeak::audio::AudioBackend;
|
||||||
fn main() {
|
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||||
win_probe::run();
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", windows)))]
|
const SAMPLE_RATE: f32 = 48_000.0;
|
||||||
fn main() {
|
|
||||||
eprintln!("audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly).");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[tokio::main]
|
||||||
mod unix_probe {
|
async fn main() {
|
||||||
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 mut args = std::env::args().skip(1);
|
||||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
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 secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||||
@@ -109,11 +89,11 @@ mod unix_probe {
|
|||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
let _ = backend.stop();
|
let _ = backend.stop();
|
||||||
println!("\naudio_probe: done.");
|
println!("\naudio_probe: done.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||||
/// reports) to stdout once they appear.
|
/// reports) to stdout once they appear.
|
||||||
fn spawn_log_tailer() {
|
fn spawn_log_tailer() {
|
||||||
let path = peerspeak::log_file_path();
|
let path = peerspeak::log_file_path();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// Wait for the file to exist (first log_msg creates it).
|
// Wait for the file to exist (first log_msg creates it).
|
||||||
@@ -138,110 +118,4 @@ mod unix_probe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-13
@@ -1,7 +1,7 @@
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
|
|
||||||
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
|
||||||
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: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
|
fn stop_mic_monitor(backend: &PipeWireBackend, 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,7 +390,6 @@ 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).
|
||||||
@@ -401,7 +400,7 @@ struct ActiveSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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");
|
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
|
||||||
@@ -432,7 +431,6 @@ 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...");
|
||||||
@@ -733,7 +731,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(PlatformAudioBackend::new());
|
let audio_backend = Arc::new(PipeWireBackend::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));
|
||||||
@@ -1097,9 +1095,7 @@ 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(),
|
||||||
@@ -1126,10 +1122,6 @@ 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;
|
||||||
@@ -1640,7 +1632,6 @@ 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(),
|
||||||
|
|||||||
+7
-23
@@ -24,9 +24,6 @@ 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`
|
||||||
@@ -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> {
|
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);
|
||||||
}
|
}
|
||||||
@@ -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();
|
let file = std::fs::OpenOptions::new()
|
||||||
opts.create(true).append(true);
|
.create(true)
|
||||||
// The log can carry capability-bearing values (redacted, but still): keep it
|
.append(true)
|
||||||
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
|
.mode(LOG_MODE)
|
||||||
// the directory ACL, so this hardening is Unix-only.
|
.open(path)?;
|
||||||
#[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 _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||||
}
|
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +126,6 @@ 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 {
|
||||||
@@ -158,9 +145,6 @@ 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();
|
||||||
|
|||||||
+5
-41
@@ -3,12 +3,11 @@
|
|||||||
//!
|
//!
|
||||||
//! 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
|
||||||
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||||
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||||
//! detached thread that waits on the child, so it never blocks the UI and never
|
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
//! a missing chime should never disrupt a call.
|
||||||
//! 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};
|
||||||
@@ -203,14 +202,8 @@ 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)
|
||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -258,18 +234,6 @@ 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
|
||||||
|
|||||||
+1
-21
@@ -25,16 +25,6 @@ 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;
|
||||||
@@ -153,7 +143,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)
|
||||||
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
.map(|dir| dir.join(PIXELPASS_BIN))
|
||||||
.find(|c| c.is_file())
|
.find(|c| c.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,14 +513,4 @@ 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")]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user