Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae29d1fea2 | ||
|
|
7ff7766ede | ||
|
|
b0fdd4e058 | ||
|
|
306bc295b1 | ||
|
|
8e0b4c16ec | ||
|
|
f52b5ea64e | ||
|
|
4d07e03395 | ||
|
|
20bfcffe6d | ||
|
|
185d47aa8d |
+2
-2
@@ -68,9 +68,9 @@ connections are expected and valid.
|
||||
| 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. |
|
||||
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
|
||||
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
|
||||
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
||||
|
||||
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||
|
||||
@@ -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.
|
||||
+782
-141
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,10 @@ pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
// backend wires it in.
|
||||
pub mod resample;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
|
||||
//!
|
||||
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
|
||||
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
|
||||
//! channel layout. These convert at the device boundary so such a device plays and
|
||||
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
|
||||
//!
|
||||
//! ## Where each is used
|
||||
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
|
||||
//! to 48 kHz on the capture drain thread — off the RT callback.
|
||||
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
|
||||
//! bus to the device rate inside the output RT callback, pulling internal frames
|
||||
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
|
||||
//!
|
||||
//! ## Quality
|
||||
//! This is plain linear interpolation with no anti-aliasing filter: correct,
|
||||
//! allocation-free, and adequate for speech, but it adds some aliasing when
|
||||
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
|
||||
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
|
||||
//! replace the internals without touching the cpal backend. The matching-rate /
|
||||
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
|
||||
|
||||
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
|
||||
#[inline]
|
||||
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
|
||||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
|
||||
/// receive output samples at `out_rate` through an `emit` callback. It carries the
|
||||
/// fractional read position and the previous input sample across calls, so feeding
|
||||
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
|
||||
/// [`process`](Self::process) allocates.
|
||||
pub struct PushResampler {
|
||||
/// Input samples consumed per output sample (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output sample, in input-sample units, measured from the
|
||||
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
|
||||
/// after each input is consumed.
|
||||
next: f64,
|
||||
/// The previous input sample (left edge of the current interpolation segment).
|
||||
prev: f32,
|
||||
/// Whether any input has been seen yet (anchors the first output at input[0]).
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl PushResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
|
||||
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
|
||||
/// cpal backend's `resolve()` also rejects such rates up front, so this is
|
||||
/// belt-and-suspenders against a future caller (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
next: 0.0,
|
||||
prev: 0.0,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one input sample; `emit` is called for each output sample produced
|
||||
/// (zero or more, depending on the rate ratio).
|
||||
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
|
||||
if !self.started {
|
||||
// First sample: just establish the left edge. Linear interpolation
|
||||
// needs the next input as the right edge, so the first output is
|
||||
// produced on the next push. This gives exact alignment
|
||||
// (`output[k] == input[k]` at equal rates) with one input-sample of
|
||||
// latency — negligible (~20 µs at 48 kHz).
|
||||
self.started = true;
|
||||
self.prev = cur;
|
||||
self.next = 0.0;
|
||||
return;
|
||||
}
|
||||
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
|
||||
// every output whose position falls in [0, 1).
|
||||
while self.next < 1.0 {
|
||||
emit(lerp(self.prev, cur, self.next as f32));
|
||||
self.next += self.step;
|
||||
}
|
||||
self.next -= 1.0;
|
||||
self.prev = cur;
|
||||
}
|
||||
|
||||
/// Convenience for tests / batch callers: push a whole slice.
|
||||
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
|
||||
for &s in input {
|
||||
self.push(s, &mut emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
|
||||
/// pulling input frames at `in_rate` from a closure on demand. Call
|
||||
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
|
||||
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
|
||||
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
|
||||
/// callback.
|
||||
pub struct StereoPullResampler {
|
||||
/// Input frames consumed per output frame (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
|
||||
frac: f64,
|
||||
/// Left edge of the current interpolation segment.
|
||||
prev: (f32, f32),
|
||||
/// Right edge of the current interpolation segment.
|
||||
cur: (f32, f32),
|
||||
/// Whether `prev`/`cur` have been primed from the puller yet.
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl StereoPullResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
|
||||
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
frac: 0.0,
|
||||
prev: (0.0, 0.0),
|
||||
cur: (0.0, 0.0),
|
||||
primed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce the next output frame, pulling input frames via `pull` as needed.
|
||||
/// Returns `None` if `pull` returns `None` before the frame can be formed
|
||||
/// (underrun); the caller should substitute silence for that frame.
|
||||
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
|
||||
if !self.primed {
|
||||
// Prime both edges from two pulls so the first output frame aligns
|
||||
// exactly with the first input frame (`out[0] == in[0]` at equal
|
||||
// rates). Needs two frames available to start, which the prefilled
|
||||
// playback ring always has.
|
||||
self.prev = pull()?;
|
||||
self.cur = pull()?;
|
||||
self.primed = true;
|
||||
self.frac = 0.0;
|
||||
}
|
||||
// Advance the segment until the read position lands inside [prev, cur).
|
||||
while self.frac >= 1.0 {
|
||||
self.prev = self.cur;
|
||||
self.cur = pull()?;
|
||||
self.frac -= 1.0;
|
||||
}
|
||||
let f = self.frac as f32;
|
||||
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
|
||||
self.frac += self.step;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
|
||||
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
|
||||
#[test]
|
||||
fn push_identity_when_rates_match() {
|
||||
let mut r = PushResampler::new(48_000, 48_000);
|
||||
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
assert_eq!(out.len(), input.len() - 1);
|
||||
for (a, b) in out.iter().zip(input.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
|
||||
#[test]
|
||||
fn push_upsample_2x_interpolates_midpoints() {
|
||||
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
|
||||
let input = [0.0, 1.0, 2.0, 3.0];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// (n - 1) segments at 2 outputs each = 6.
|
||||
assert_eq!(out.len(), 6, "out {out:?}");
|
||||
// A half-step between 1.0 and 2.0 must appear near 1.5.
|
||||
assert!(
|
||||
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
|
||||
"expected a ~1.5 midpoint in {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
|
||||
#[test]
|
||||
fn push_downsample_reduces_count() {
|
||||
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
|
||||
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// 441 in @ 48k -> ~405 out @ 44.1k.
|
||||
assert!(
|
||||
(390..=410).contains(&out.len()),
|
||||
"expected ~405 outputs, got {}",
|
||||
out.len()
|
||||
);
|
||||
// Output stays within the input's value range and is non-decreasing.
|
||||
for w in out.windows(2) {
|
||||
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
|
||||
}
|
||||
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
|
||||
}
|
||||
|
||||
/// Pull resampler at equal rates returns each input frame in order, aligned.
|
||||
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
|
||||
/// outputs (the last frame emits once a successor arrives).
|
||||
#[test]
|
||||
fn pull_identity_when_rates_match() {
|
||||
let mut r = StereoPullResampler::new(48_000, 48_000);
|
||||
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
|
||||
let mut idx = 0;
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
}) {
|
||||
out.push(f);
|
||||
}
|
||||
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
|
||||
for (got, want) in out.iter().zip(frames.iter()) {
|
||||
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull resampler reports underrun (`None`) once the source is exhausted.
|
||||
#[test]
|
||||
fn pull_returns_none_on_underrun() {
|
||||
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
|
||||
let frames = [(0.0, 0.0), (1.0, -1.0)];
|
||||
let mut idx = 0;
|
||||
let mut pull = || {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
};
|
||||
// First frame primes + emits; subsequent calls eventually exhaust the source.
|
||||
let mut produced = 0;
|
||||
let mut hit_none = false;
|
||||
for _ in 0..10 {
|
||||
if r.next(&mut pull).is_some() {
|
||||
produced += 1;
|
||||
} else {
|
||||
hit_none = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(produced >= 1, "should produce at least the primed frame");
|
||||
assert!(hit_none, "should report underrun once the puller is dry");
|
||||
}
|
||||
|
||||
/// Downsampling via pull consumes more input frames than it emits output frames.
|
||||
#[test]
|
||||
fn pull_downsample_consumes_more_than_it_emits() {
|
||||
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
|
||||
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
|
||||
let mut idx = 0;
|
||||
let mut emitted = 0;
|
||||
for _ in 0..40 {
|
||||
let f = r.next(|| {
|
||||
let v = input.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
if f.is_some() {
|
||||
emitted += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// At step 2.0 we consume ~2 input frames per output frame.
|
||||
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
|
||||
}
|
||||
|
||||
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
|
||||
#[test]
|
||||
fn push_zero_rate_does_not_spin() {
|
||||
let mut r = PushResampler::new(0, 48_000);
|
||||
let mut count = 0usize;
|
||||
// Feed two samples; with a clamped non-zero step this returns promptly.
|
||||
r.push(0.0, |_| count += 1);
|
||||
r.push(1.0, |_| count += 1);
|
||||
// Reaching here at all is the assertion (no hang); some output is produced.
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
/// A zero output rate must not make the pull resampler's segment-advance loop
|
||||
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
|
||||
#[test]
|
||||
fn pull_zero_out_rate_does_not_spin() {
|
||||
let mut r = StereoPullResampler::new(48_000, 0);
|
||||
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
|
||||
let mut idx = 0;
|
||||
let got = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
// Terminates and yields the primed frame instead of hanging.
|
||||
assert!(got.is_some());
|
||||
}
|
||||
}
|
||||
+124
-10
@@ -1,11 +1,11 @@
|
||||
//! Audio playout diagnostic probe.
|
||||
//!
|
||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
||||
//! hardware clock. No network, no microphone — this isolates the local output
|
||||
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||
//!
|
||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||
//! `playout-health:` lines tailed to stdout:
|
||||
@@ -18,17 +18,24 @@
|
||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||
//!
|
||||
//! This probe exercises the PipeWire backend directly, so it is a Linux-only tool.
|
||||
//! On non-Linux targets `main` is a stub that explains the limitation.
|
||||
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
||||
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
unix_probe::run();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
eprintln!("audio_probe is only supported on Linux builds (it drives the PipeWire backend directly).");
|
||||
win_probe::run();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -88,7 +95,114 @@ mod unix_probe {
|
||||
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;
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = CpalBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
|
||||
+20
-11
@@ -1816,17 +1816,26 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::SetNetworkMode(mode) => {
|
||||
network_mode = mode;
|
||||
// Rebuild the persistent stack to the new posture immediately if
|
||||
// idle; if a call is active, defer to the next Leave/Join so the
|
||||
// live call isn't disrupted (preserves "applies on next join").
|
||||
if active_session.is_none() {
|
||||
let lookup = net.memory_lookup.clone();
|
||||
net.shutdown().await;
|
||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
|
||||
} else {
|
||||
net_rebuild_pending = true;
|
||||
// Skip when the posture is unchanged. The GUI re-sends the saved
|
||||
// network mode as part of its startup config-sync, and that mode
|
||||
// usually already matches the freshly-built stack — rebuilding the
|
||||
// iroh endpoint for an identical posture just churns the network
|
||||
// and adds a needless ~1s teardown+rebuild bounce at every launch
|
||||
// (seen on both Linux and Windows/Wine). A real change still
|
||||
// rebuilds exactly as before.
|
||||
if mode != network_mode {
|
||||
network_mode = mode;
|
||||
// Rebuild the persistent stack to the new posture immediately if
|
||||
// idle; if a call is active, defer to the next Leave/Join so the
|
||||
// live call isn't disrupted (preserves "applies on next join").
|
||||
if active_session.is_none() {
|
||||
let lookup = net.memory_lookup.clone();
|
||||
net.shutdown().await;
|
||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
|
||||
} else {
|
||||
net_rebuild_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user