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
|
|
||||||
+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(unix)'.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 }
|
|
||||||
# NOTE: the Windows audio backend (cpal/WASAPI) lands in Phase 1. Until then the
|
|
||||||
# Windows build uses the no-op `CpalBackend` stub in `src/audio/cpal_impl.rs`,
|
|
||||||
# which needs no extra dependency.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+6
-22
@@ -1350,28 +1350,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
// 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.
|
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||||||
if url.starts_with("http://") || url.starts_with("https://") {
|
|
||||||
let spawned = {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
std::process::Command::new("xdg-open").arg(&url).spawn()
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
std::process::Command::new("rundll32")
|
|
||||||
.args(["url.dll,FileProtocolHandler", &url])
|
|
||||||
.spawn()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = spawned {
|
|
||||||
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMessage::ToggleMicTest(enabled) => {
|
AppMessage::ToggleMicTest(enabled) => {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
//! Windows audio backend (cpal/WASAPI) — **Phase 0 stub**.
|
|
||||||
//!
|
|
||||||
//! This is a compile-and-run placeholder so the Windows build links and the app
|
|
||||||
//! starts up (networking, UI, and text chat all functional) while the real
|
|
||||||
//! capture/playback implementation lands in Phase 1. Every method satisfies the
|
|
||||||
//! [`AudioBackend`] contract as a no-op: no microphone is captured and nothing is
|
|
||||||
//! played. It deliberately pulls in no extra dependency — `cpal` is added only
|
|
||||||
//! when the real implementation arrives.
|
|
||||||
//!
|
|
||||||
//! Phase 1 will replace this with cpal streams on the WASAPI host, mapping:
|
|
||||||
//! - `start_capture` → input stream, f32→i16, mono 48 kHz, into `tx`;
|
|
||||||
//! - `start_playback` → output stream draining a `ringbuf`, keeping `ring_fill`
|
|
||||||
//! updated so the existing hardware-clock pacing in the mixer keeps working;
|
|
||||||
//! - `stop` → drop the streams.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
|
||||||
|
|
||||||
use super::{AudioBackend, AudioError};
|
|
||||||
|
|
||||||
/// No-op Windows audio backend (Phase 0). See module docs.
|
|
||||||
pub struct CpalBackend;
|
|
||||||
|
|
||||||
impl CpalBackend {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
crate::log_msg("CpalBackend: Phase 0 stub active (no audio I/O yet)");
|
|
||||||
CpalBackend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
// No capture stream yet: dropping `_tx` simply means no samples are ever
|
|
||||||
// produced (silent mic), which is the intended Phase 0 behaviour.
|
|
||||||
crate::log_msg("CpalBackend::start_capture: not yet implemented (Phase 1) — capturing silence");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_playback(
|
|
||||||
&self,
|
|
||||||
rx: Receiver<Vec<i16>>,
|
|
||||||
_target_node: Option<String>,
|
|
||||||
_ring_fill: Arc<AtomicUsize>,
|
|
||||||
) -> Result<(), AudioError> {
|
|
||||||
// Drain and discard incoming audio on a detached thread so the mixer's
|
|
||||||
// producer never blocks or sees a closed channel. This keeps the rest of
|
|
||||||
// the pipeline running normally while output is silent.
|
|
||||||
std::thread::spawn(move || while rx.recv().is_ok() {});
|
|
||||||
crate::log_msg("CpalBackend::start_playback: not yet implemented (Phase 1) — discarding output");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop(&self) -> Result<(), AudioError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,24 +62,6 @@ pub mod gate;
|
|||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod multitrack;
|
pub mod multitrack;
|
||||||
pub mod pan;
|
pub mod pan;
|
||||||
#[cfg(unix)]
|
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
#[cfg(windows)]
|
|
||||||
pub mod cpal_impl;
|
|
||||||
pub mod pw_cli;
|
pub mod pw_cli;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
|
||||||
/// 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/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
|
||||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the
|
|
||||||
/// Phase 1 capture/playback implementation lands.
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
|
||||||
|
|||||||
+88
-104
@@ -17,121 +17,105 @@
|
|||||||
//!
|
//!
|
||||||
//! 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 PipeWire backend directly, so it is a Unix-only tool.
|
|
||||||
//! On non-Unix targets `main` is a stub that explains the limitation.
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
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(not(unix))]
|
use peerspeak::audio::AudioBackend;
|
||||||
fn main() {
|
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||||
eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly).");
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
const SAMPLE_RATE: f32 = 48_000.0;
|
||||||
mod unix_probe {
|
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use peerspeak::audio::AudioBackend;
|
#[tokio::main]
|
||||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
async fn main() {
|
||||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
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();
|
||||||
|
|
||||||
const SAMPLE_RATE: f32 = 48_000.0;
|
// 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") };
|
||||||
|
|
||||||
#[tokio::main]
|
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||||
pub async fn run() {
|
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||||
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
|
// Tail the app log (where playout-health lines land) to stdout in the
|
||||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
// background so it's all in one terminal.
|
||||||
// show the steady-state numbers.
|
spawn_log_tailer();
|
||||||
// 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.");
|
let backend = PipeWireBackend::new();
|
||||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
// Tail the app log (where playout-health lines land) to stdout in the
|
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||||
// background so it's all in one terminal.
|
eprintln!("failed to start playback: {e}");
|
||||||
spawn_log_tailer();
|
return;
|
||||||
|
|
||||||
let backend = PipeWireBackend::new();
|
|
||||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
|
||||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
|
||||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
|
||||||
eprintln!("failed to start playback: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
|
||||||
// exactly like the production mixer: only produce while the ring is below
|
|
||||||
// target, so production tracks the PipeWire hardware clock.
|
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
|
||||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
|
||||||
while tokio::time::Instant::now() < deadline {
|
|
||||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
|
||||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
|
||||||
for _ in 0..FRAME_SAMPLES {
|
|
||||||
let t = n as f32 / SAMPLE_RATE;
|
|
||||||
// 0.25 amplitude: clearly audible but not harsh.
|
|
||||||
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
|
||||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
|
||||||
frame.push(sample);
|
|
||||||
frame.push(sample);
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
if tx.send(frame).is_err() {
|
|
||||||
eprintln!("playback channel closed early");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let the ring drain, then stop.
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
let _ = backend.stop();
|
|
||||||
println!("\naudio_probe: done.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||||
/// reports) to stdout once they appear.
|
// exactly like the production mixer: only produce while the ring is below
|
||||||
fn spawn_log_tailer() {
|
// target, so production tracks the PipeWire hardware clock.
|
||||||
let path = peerspeak::log_file_path();
|
use std::sync::atomic::Ordering;
|
||||||
std::thread::spawn(move || {
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||||
// Wait for the file to exist (first log_msg creates it).
|
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||||
let file = loop {
|
while tokio::time::Instant::now() < deadline {
|
||||||
if let Ok(f) = std::fs::File::open(&path) {
|
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||||
break f;
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
}
|
continue;
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
}
|
||||||
};
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||||
let mut reader = BufReader::new(file);
|
for _ in 0..FRAME_SAMPLES {
|
||||||
let _ = reader.seek(SeekFrom::End(0));
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
loop {
|
// 0.25 amplitude: clearly audible but not harsh.
|
||||||
let mut line = String::new();
|
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||||
match reader.read_line(&mut line) {
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
frame.push(sample);
|
||||||
Ok(_) => {
|
frame.push(sample);
|
||||||
if line.contains("playout-health:") {
|
n += 1;
|
||||||
print!("{line}");
|
}
|
||||||
}
|
if tx.send(frame).is_err() {
|
||||||
|
eprintln!("playback channel closed early");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the ring drain, then stop.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
let _ = backend.stop();
|
||||||
|
println!("\naudio_probe: done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||||
|
/// reports) to stdout once they appear.
|
||||||
|
fn spawn_log_tailer() {
|
||||||
|
let path = peerspeak::log_file_path();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Wait for the file to exist (first log_msg creates it).
|
||||||
|
let file = loop {
|
||||||
|
if let Ok(f) = std::fs::File::open(&path) {
|
||||||
|
break f;
|
||||||
|
}
|
||||||
|
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)),
|
|
||||||
}
|
}
|
||||||
|
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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();
|
||||||
@@ -400,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
|
||||||
@@ -731,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));
|
||||||
|
|||||||
+8
-24
@@ -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)]
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||||
{
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
opts.mode(LOG_MODE);
|
|
||||||
}
|
|
||||||
let file = opts.open(path)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
// Re-assert the mode in case the file pre-existed with looser perms.
|
|
||||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
|
||||||
}
|
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user