Windows port Phase 2: cpal device enumeration #4
+1
-1
@@ -49,7 +49,7 @@ tokio-stream = "0.1.18"
|
|||||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
# 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
|
# 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).
|
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# PeerSpeak on Windows
|
||||||
|
|
||||||
|
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
||||||
|
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
||||||
|
checks listed below.
|
||||||
|
|
||||||
|
## What works today
|
||||||
|
|
||||||
|
| Area | Status |
|
||||||
|
|---|---|
|
||||||
|
| GUI | Iced/wgpu builds and renders under Wine. |
|
||||||
|
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
||||||
|
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
||||||
|
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
||||||
|
| Identity | `ring` identity generation/load is platform-neutral. |
|
||||||
|
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
||||||
|
|
||||||
|
Windows paths are resolved through `dirs`:
|
||||||
|
|
||||||
|
- Config: `%APPDATA%\peerspeak\config.json`
|
||||||
|
- Identity: `%APPDATA%\peerspeak\identity.key`
|
||||||
|
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Native Windows
|
||||||
|
|
||||||
|
Install MSVC Build Tools and CMake, then build normally:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-compile from Linux
|
||||||
|
|
||||||
|
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
sudo pacman -S mingw-w64-gcc cmake
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
|
||||||
|
```
|
||||||
|
|
||||||
|
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
|
||||||
|
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
|
||||||
|
spike) lives in the maintainer's handoff docs, outside the repo.
|
||||||
|
|
||||||
|
## First run and networking
|
||||||
|
|
||||||
|
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
||||||
|
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
||||||
|
|
||||||
|
The default network mode keeps the n0 relay available for NAT traversal without publishing
|
||||||
|
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
||||||
|
connections are expected and valid.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
||||||
|
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
||||||
|
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
||||||
|
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
|
||||||
|
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||||
|
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
||||||
|
|
||||||
|
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||||
|
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||||
|
play notification chimes.
|
||||||
+58
-12
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
|||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::notify::{self, Sound};
|
use crate::notify::{self, Sound};
|
||||||
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
||||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||||||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||||
use crate::presence::PresenceMode;
|
use crate::presence::PresenceMode;
|
||||||
@@ -553,11 +553,9 @@ pub fn run_gui() -> iced::Result {
|
|||||||
// the icon from the .desktop file matched by app_id instead).
|
// the icon from the .desktop file matched by app_id instead).
|
||||||
icon: window_icon(),
|
icon: window_icon(),
|
||||||
// app_id must match the .desktop basename so Wayland compositors
|
// app_id must match the .desktop basename so Wayland compositors
|
||||||
// (e.g. KWin) attach our launcher icon to the window.
|
// (e.g. KWin) attach our launcher icon to the window. The field is
|
||||||
platform_specific: iced::window::settings::PlatformSpecific {
|
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
|
||||||
application_id: "peerspeak".to_string(),
|
platform_specific: platform_specific_settings(),
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
// We save the final size ourselves on CloseRequested, then exit.
|
// We save the final size ourselves on CloseRequested, then exit.
|
||||||
exit_on_close_request: false,
|
exit_on_close_request: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -565,6 +563,22 @@ pub fn run_gui() -> iced::Result {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
|
||||||
|
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
|
||||||
|
/// set there and left at defaults on Windows.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||||
|
iced::window::settings::PlatformSpecific {
|
||||||
|
application_id: "peerspeak".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||||
|
iced::window::settings::PlatformSpecific::default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
||||||
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
||||||
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
||||||
@@ -2557,10 +2571,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
mic_meter,
|
mic_meter,
|
||||||
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
||||||
vertical_space(4.0),
|
vertical_space(4.0),
|
||||||
|
{
|
||||||
|
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)
|
||||||
@@ -3263,11 +3295,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
column![]
|
column![]
|
||||||
},
|
},
|
||||||
vertical_space(20.0),
|
vertical_space(20.0),
|
||||||
// Echo cancellation — same flag + message as the Settings checkbox, so
|
{
|
||||||
// toggling here and there stay in sync automatically (single source of
|
// Echo cancellation is wired at join time on Linux; other
|
||||||
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
|
// targets show an inert status row instead of a dead toggle.
|
||||||
// applies on the NEXT join (the PipeWire-module AEC is wired at join
|
let control: Element<'_, AppMessage> = {
|
||||||
// time, not hot-swappable mid-call).
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
tooltip(
|
tooltip(
|
||||||
checkbox(state.config.echo_cancellation_enabled)
|
checkbox(state.config.echo_cancellation_enabled)
|
||||||
.label("Echo cancellation")
|
.label("Echo cancellation")
|
||||||
@@ -3282,7 +3315,20 @@ 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 {
|
||||||
|
|||||||
+942
-99
File diff suppressed because it is too large
Load Diff
+36
-6
@@ -56,19 +56,50 @@ 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(unix)]
|
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||||
|
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||||
|
// backend wires it in.
|
||||||
|
pub mod resample;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod echo_cancel;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod cpal_impl;
|
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 audio backend implementation for the current platform.
|
||||||
///
|
///
|
||||||
/// The whole app constructs and threads this alias (via
|
/// The whole app constructs and threads this alias (via
|
||||||
@@ -76,10 +107,9 @@ pub mod recorder;
|
|||||||
/// platform selection lives entirely here. Both implementations satisfy the
|
/// platform selection lives entirely here. Both implementations satisfy the
|
||||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||||
///
|
///
|
||||||
/// - Linux/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the
|
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||||
/// Phase 1 capture/playback implementation lands.
|
#[cfg(target_os = "linux")]
|
||||||
#[cfg(unix)]
|
|
||||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||||
|
|||||||
+1
-13
@@ -1,18 +1,6 @@
|
|||||||
|
use super::AudioDevice;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct AudioDevice {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub is_input: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for AudioDevice {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||||
let output = Command::new("pw-cli")
|
let output = Command::new("pw-cli")
|
||||||
.arg("list-objects")
|
.arg("list-objects")
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
-12
@@ -1,11 +1,11 @@
|
|||||||
//! Audio playout diagnostic probe.
|
//! Audio playout diagnostic probe.
|
||||||
//!
|
//!
|
||||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
//! hardware clock. No network, no microphone — this isolates the local output
|
||||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||||
//!
|
//!
|
||||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||||
//! `playout-health:` lines tailed to stdout:
|
//! `playout-health:` lines tailed to stdout:
|
||||||
@@ -18,20 +18,27 @@
|
|||||||
//! 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.
|
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
||||||
//! On non-Unix targets `main` is a stub that explains the limitation.
|
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(target_os = "linux")]
|
||||||
fn main() {
|
fn main() {
|
||||||
unix_probe::run();
|
unix_probe::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(windows)]
|
||||||
fn main() {
|
fn main() {
|
||||||
eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly).");
|
win_probe::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[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")]
|
||||||
mod unix_probe {
|
mod unix_probe {
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -88,7 +95,114 @@ mod unix_probe {
|
|||||||
for _ in 0..FRAME_SAMPLES {
|
for _ in 0..FRAME_SAMPLES {
|
||||||
let t = n as f32 / SAMPLE_RATE;
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
// 0.25 amplitude: clearly audible but not harsh.
|
// 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.
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||||
frame.push(sample);
|
frame.push(sample);
|
||||||
frame.push(sample);
|
frame.push(sample);
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ struct ActiveSession {
|
|||||||
grace_timers: GraceTimers,
|
grace_timers: GraceTimers,
|
||||||
transport: Arc<IrohTransport>,
|
transport: Arc<IrohTransport>,
|
||||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
||||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||||
/// also dies if the session is dropped without an explicit stop).
|
/// also dies if the session is dropped without an explicit stop).
|
||||||
@@ -431,6 +432,7 @@ impl ActiveSession {
|
|||||||
|
|
||||||
// Unload the echo-cancel module now that the audio streams releasing its
|
// Unload the echo-cancel module now that the audio streams releasing its
|
||||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
drop(self.echo_cancel);
|
drop(self.echo_cancel);
|
||||||
|
|
||||||
crate::log_msg("Leaving room...");
|
crate::log_msg("Leaving room...");
|
||||||
@@ -1095,7 +1097,9 @@ async fn run_core_loop(
|
|||||||
// The guard unloads the module on drop — including the early-return
|
// The guard unloads the module on drop — including the early-return
|
||||||
// paths below, since it's a local until moved into the session. On
|
// paths below, since it's a local until moved into the session. On
|
||||||
// any failure, warn and fall back to the direct devices.
|
// any failure, warn and fall back to the direct devices.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
let mut echo_cancel_guard = None;
|
let mut echo_cancel_guard = None;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
let (capture_target, playback_target) = if echo_cancellation {
|
let (capture_target, playback_target) = if echo_cancellation {
|
||||||
match crate::audio::echo_cancel::enable(
|
match crate::audio::echo_cancel::enable(
|
||||||
input_device.as_deref(),
|
input_device.as_deref(),
|
||||||
@@ -1122,6 +1126,10 @@ async fn run_core_loop(
|
|||||||
} else {
|
} else {
|
||||||
(input_device.clone(), output_device.clone())
|
(input_device.clone(), output_device.clone())
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let _ = echo_cancellation;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||||
|
|
||||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
||||||
@@ -1632,6 +1640,7 @@ async fn run_core_loop(
|
|||||||
conn_event_task,
|
conn_event_task,
|
||||||
grace_timers,
|
grace_timers,
|
||||||
transport: transport.clone(),
|
transport: transport.clone(),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
echo_cancel: echo_cancel_guard,
|
echo_cancel: echo_cancel_guard,
|
||||||
screenshare_host: None,
|
screenshare_host: None,
|
||||||
screenshare_viewers: Vec::new(),
|
screenshare_viewers: Vec::new(),
|
||||||
@@ -1807,6 +1816,14 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetNetworkMode(mode) => {
|
CoreCommand::SetNetworkMode(mode) => {
|
||||||
|
// 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;
|
network_mode = mode;
|
||||||
// Rebuild the persistent stack to the new posture immediately if
|
// Rebuild the persistent stack to the new posture immediately if
|
||||||
// idle; if a call is active, defer to the next Leave/Join so the
|
// idle; if a call is active, defer to the next Leave/Join so the
|
||||||
@@ -1820,6 +1837,7 @@ async fn run_core_loop(
|
|||||||
net_rebuild_pending = true;
|
net_rebuild_pending = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::RegenerateIdentity => {
|
CoreCommand::RegenerateIdentity => {
|
||||||
// Mint + persist a fresh identity, discarding the old one. The
|
// Mint + persist a fresh identity, discarding the old one. The
|
||||||
|
|||||||
+41
-5
@@ -3,11 +3,12 @@
|
|||||||
//!
|
//!
|
||||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||||
//! a missing chime should never disrupt a call.
|
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||||
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
|||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||||
|
s.replace('\'', "''")
|
||||||
|
}
|
||||||
|
|
||||||
/// Try each available player in turn, waiting on the first that starts (which
|
/// Try each available player in turn, waiting on the first that starts (which
|
||||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||||
|
#[cfg(not(windows))]
|
||||||
fn spawn_player(path: &Path) {
|
fn spawn_player(path: &Path) {
|
||||||
for player in ["pw-play", "paplay", "aplay"] {
|
for player in ["pw-play", "paplay", "aplay"] {
|
||||||
let started = Command::new(player)
|
let started = Command::new(player)
|
||||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||||
|
/// `PlaySync()` blocking for the sound duration is fine.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn spawn_player(path: &Path) {
|
||||||
|
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||||
|
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||||
|
let _ = Command::new("powershell")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-NonInteractive")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(command)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -234,6 +258,18 @@ mod tests {
|
|||||||
assert!(!should_play(false, false));
|
assert!(!should_play(false, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_powershell_single_quote_escape() {
|
||||||
|
assert_eq!(
|
||||||
|
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||||
|
r"C:\Users\O''Brien\chime.wav"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
escape_powershell_single_quoted("a'b'c"),
|
||||||
|
"a''b''c"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sound_indices_unique_and_match_all() {
|
fn test_sound_indices_unique_and_match_all() {
|
||||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||||
|
|||||||
+21
-1
@@ -25,6 +25,16 @@ use tokio::process::{Child, Command};
|
|||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
const PIXELPASS_BIN: &str = "pixelpass";
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
|
||||||
|
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||||
|
[dir.join(PIXELPASS_BIN)]
|
||||||
|
}
|
||||||
|
|
||||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||||
const MAX_TICKET_LEN: usize = 512;
|
const MAX_TICKET_LEN: usize = 512;
|
||||||
@@ -143,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
let path_var = std::env::var_os("PATH")?;
|
let path_var = std::env::var_os("PATH")?;
|
||||||
std::env::split_paths(&path_var)
|
std::env::split_paths(&path_var)
|
||||||
.map(|dir| dir.join(PIXELPASS_BIN))
|
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
||||||
.find(|c| c.is_file())
|
.find(|c| c.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,4 +523,14 @@ mod tests {
|
|||||||
// only assert it doesn't return the empty path as a match.
|
// only assert it doesn't return the empty path as a match.
|
||||||
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pixelpass_path_candidates_are_platform_specific() {
|
||||||
|
let dir = Path::new("bin");
|
||||||
|
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user