Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f3d0ac2ea | ||
|
|
2eae95ede0 | ||
|
|
fdd532de53 | ||
|
|
46809153d8 | ||
|
|
6ccad0d37a | ||
|
|
ddb3d2aabc | ||
|
|
bbbe2d8f17 | ||
|
|
63b45e03ab | ||
|
|
2937e5191a |
+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 | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
|
||||||
|
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||||
|
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
|
||||||
|
|
||||||
|
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||||
|
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||||
|
play notification chimes.
|
||||||
+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 {
|
||||||
|
|||||||
+246
-44
@@ -7,6 +7,9 @@
|
|||||||
//!
|
//!
|
||||||
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
|
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
|
||||||
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
|
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
|
||||||
|
//! The RT capture callback only downmixes and pushes samples into a lock-free
|
||||||
|
//! ring; the owning thread drains that ring, frames it, and sends — so the
|
||||||
|
//! callback never allocates, locks, or touches an mpsc channel.
|
||||||
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
|
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
|
||||||
//! drained from a ring buffer that is paced to the device's hardware clock via
|
//! drained from a ring buffer that is paced to the device's hardware clock via
|
||||||
//! `ring_fill` exactly as the PipeWire backend does.
|
//! `ring_fill` exactly as the PipeWire backend does.
|
||||||
@@ -20,7 +23,12 @@
|
|||||||
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
|
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
|
||||||
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
|
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
|
||||||
//! handle). The stream's RT callback does the actual audio work; the owning
|
//! handle). The stream's RT callback does the actual audio work; the owning
|
||||||
//! thread additionally feeds the playback ring from the network mixer.
|
//! thread additionally feeds the playback ring (or drains the capture ring).
|
||||||
|
//!
|
||||||
|
//! `start_*` does not return until the owning thread reports back over a readiness
|
||||||
|
//! channel that the device resolved and the stream is built and playing — so a
|
||||||
|
//! device/format/WASAPI failure surfaces as a real `Err` to the caller instead of
|
||||||
|
//! leaving the UI in a joined-but-silent room.
|
||||||
//!
|
//!
|
||||||
//! ## Sample rate
|
//! ## Sample rate
|
||||||
//!
|
//!
|
||||||
@@ -30,7 +38,7 @@
|
|||||||
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
|
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -42,13 +50,20 @@ use ringbuf::{
|
|||||||
HeapRb,
|
HeapRb,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{AudioBackend, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
|
use super::{AudioBackend, AudioDevice, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
|
||||||
|
|
||||||
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
|
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
|
||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
|
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
|
||||||
/// and `core::jitter::FRAME_SAMPLES`.
|
/// and `core::jitter::FRAME_SAMPLES`.
|
||||||
const CAPTURE_FRAME: usize = 960;
|
const CAPTURE_FRAME: usize = 960;
|
||||||
|
/// Lock-free capture ring capacity (mono samples) between the RT callback and the
|
||||||
|
/// owning drain thread: 8 frames = 160 ms of headroom, so a scheduling hiccup on
|
||||||
|
/// the drain thread doesn't immediately overrun the RT producer.
|
||||||
|
const CAPTURE_RING_CAPACITY: usize = CAPTURE_FRAME * 8;
|
||||||
|
/// How long the capture drain thread sleeps when the ring is momentarily empty,
|
||||||
|
/// before polling again. Small enough to stay well under the 20 ms frame cadence.
|
||||||
|
const CAPTURE_POLL: Duration = Duration::from_millis(5);
|
||||||
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
|
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
|
||||||
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
|
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
|
||||||
/// headroom and never has to drop frames in steady state.
|
/// headroom and never has to drop frames in steady state.
|
||||||
@@ -90,22 +105,20 @@ impl AudioBackend for CpalBackend {
|
|||||||
tx: Sender<Vec<i16>>,
|
tx: Sender<Vec<i16>>,
|
||||||
target_node: Option<String>,
|
target_node: Option<String>,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
let mut guard = self.capture.lock().unwrap();
|
let guard = self.capture.lock().unwrap();
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Err(AudioError::Stream("Capture already started".to_string()));
|
return Err(AudioError::Stream("Capture already started".to_string()));
|
||||||
}
|
}
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_thread = running.clone();
|
let running_thread = running.clone();
|
||||||
|
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
|
||||||
let thread = thread::Builder::new()
|
let thread = thread::Builder::new()
|
||||||
.name("peerspeak-cpal-capture".to_string())
|
.name("peerspeak-cpal-capture".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run_capture(tx, target_node, running_thread) {
|
run_capture(tx, target_node, running_thread, ready_tx);
|
||||||
crate::log_msg(&format!("cpal capture error: {e}"));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
*guard = Some(StreamWorker { running, thread });
|
finish_start(guard, StreamWorker { running, thread }, ready_rx, "capture")
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_playback(
|
fn start_playback(
|
||||||
@@ -114,22 +127,20 @@ impl AudioBackend for CpalBackend {
|
|||||||
target_node: Option<String>,
|
target_node: Option<String>,
|
||||||
ring_fill: Arc<AtomicUsize>,
|
ring_fill: Arc<AtomicUsize>,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
let mut guard = self.playback.lock().unwrap();
|
let guard = self.playback.lock().unwrap();
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Err(AudioError::Stream("Playback already started".to_string()));
|
return Err(AudioError::Stream("Playback already started".to_string()));
|
||||||
}
|
}
|
||||||
let running = Arc::new(AtomicBool::new(true));
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
let running_thread = running.clone();
|
let running_thread = running.clone();
|
||||||
|
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
|
||||||
let thread = thread::Builder::new()
|
let thread = thread::Builder::new()
|
||||||
.name("peerspeak-cpal-playback".to_string())
|
.name("peerspeak-cpal-playback".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run_playback(rx, target_node, ring_fill, running_thread) {
|
run_playback(rx, target_node, ring_fill, running_thread, ready_tx);
|
||||||
crate::log_msg(&format!("cpal playback error: {e}"));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
*guard = Some(StreamWorker { running, thread });
|
finish_start(guard, StreamWorker { running, thread }, ready_rx, "playback")
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) -> Result<(), AudioError> {
|
fn stop(&self) -> Result<(), AudioError> {
|
||||||
@@ -143,6 +154,81 @@ impl AudioBackend for CpalBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Block until the just-spawned worker reports (over `ready_rx`) that its stream
|
||||||
|
/// is built and playing, then either install it (`Ok`) or join it and surface the
|
||||||
|
/// real error. This is what makes `start_capture`/`start_playback` fail loudly
|
||||||
|
/// instead of returning `Ok` into a joined-but-silent room (Codex review W1).
|
||||||
|
fn finish_start(
|
||||||
|
mut guard: std::sync::MutexGuard<'_, Option<StreamWorker>>,
|
||||||
|
worker: StreamWorker,
|
||||||
|
ready_rx: Receiver<Result<(), AudioError>>,
|
||||||
|
what: &str,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
match ready_rx.recv() {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
*guard = Some(worker);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Setup failed (Err) or the worker exited before reporting (recv Err):
|
||||||
|
// either way it has stopped, so reap it and surface the error.
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
worker.running.store(false, Ordering::Relaxed);
|
||||||
|
let _ = worker.thread.join();
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
worker.running.store(false, Ordering::Relaxed);
|
||||||
|
let _ = worker.thread.join();
|
||||||
|
Err(AudioError::Init(format!(
|
||||||
|
"cpal {what} worker exited before reporting readiness"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device enumeration (for the settings device pickers)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Enumerate WASAPI input/output devices via cpal, sorted by description to match
|
||||||
|
/// the PipeWire backend's stable UI ordering.
|
||||||
|
///
|
||||||
|
/// cpal exposes a single friendly name per device, which is also what [`resolve`]
|
||||||
|
/// matches `target_node` against — so `name` and `description` are the same string
|
||||||
|
/// and a saved selection round-trips. Note: WASAPI device names are less stable
|
||||||
|
/// across driver/endpoint changes than PipeWire node names, so a saved device may
|
||||||
|
/// not always be found again; selection then falls back to the system default.
|
||||||
|
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(inputs) = host.input_devices() {
|
||||||
|
for device in inputs {
|
||||||
|
if let Ok(name) = device.name() {
|
||||||
|
devices.push(AudioDevice {
|
||||||
|
description: name.clone(),
|
||||||
|
name,
|
||||||
|
is_input: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(outputs) = host.output_devices() {
|
||||||
|
for device in outputs {
|
||||||
|
if let Ok(name) = device.name() {
|
||||||
|
devices.push(AudioDevice {
|
||||||
|
description: name.clone(),
|
||||||
|
name,
|
||||||
|
is_input: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.sort_by(|a, b| a.description.cmp(&b.description));
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Device / config selection
|
// Device / config selection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,7 +254,20 @@ fn resolve(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let device = match target {
|
let device = match target {
|
||||||
Some(name) => find_device_by_name(&host, output, &name).or_else(default),
|
// A saved device name that no longer resolves falls back to the system
|
||||||
|
// default — but log it, because WASAPI friendly names can change across
|
||||||
|
// driver/endpoint changes, so a silent fallback otherwise looks like
|
||||||
|
// "audio went to the wrong device for no reason" (review W7).
|
||||||
|
Some(ref name) => match find_device_by_name(&host, output, name) {
|
||||||
|
Some(dev) => Some(dev),
|
||||||
|
None => {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal: saved {} device '{name}' not found; using system default",
|
||||||
|
if output { "output" } else { "input" },
|
||||||
|
));
|
||||||
|
default()
|
||||||
|
}
|
||||||
|
},
|
||||||
None => default(),
|
None => default(),
|
||||||
}
|
}
|
||||||
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
|
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
|
||||||
@@ -242,53 +341,108 @@ fn run_capture(
|
|||||||
tx: Sender<Vec<i16>>,
|
tx: Sender<Vec<i16>>,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
) -> Result<(), AudioError> {
|
ready: Sender<Result<(), AudioError>>,
|
||||||
|
) {
|
||||||
|
// The RT callback pushes mono samples into this lock-free ring; we drain it on
|
||||||
|
// this (non-RT) thread, so the callback never allocates or sends on a channel.
|
||||||
|
let rb = HeapRb::<i16>::new(CAPTURE_RING_CAPACITY);
|
||||||
|
let (producer, mut consumer) = rb.split();
|
||||||
|
let overrun = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
// Fallible device/stream setup. We report the real error to `start_capture`
|
||||||
|
// before doing any work, so a join never lands in a silent room.
|
||||||
|
let setup = || -> Result<(Stream, String, SampleFormat, usize), AudioError> {
|
||||||
let (device, config, sample_format) = resolve(false, target)?;
|
let (device, config, sample_format) = resolve(false, target)?;
|
||||||
let channels = config.channels as usize;
|
let channels = config.channels as usize;
|
||||||
|
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => build_input::<f32>(&device, &config, tx, channels),
|
SampleFormat::F32 => {
|
||||||
SampleFormat::I16 => build_input::<i16>(&device, &config, tx, channels),
|
build_input::<f32, _>(&device, &config, producer, channels, overrun.clone())
|
||||||
SampleFormat::U16 => build_input::<u16>(&device, &config, tx, channels),
|
}
|
||||||
|
SampleFormat::I16 => {
|
||||||
|
build_input::<i16, _>(&device, &config, producer, channels, overrun.clone())
|
||||||
|
}
|
||||||
|
SampleFormat::U16 => {
|
||||||
|
build_input::<u16, _>(&device, &config, producer, channels, overrun.clone())
|
||||||
|
}
|
||||||
other => Err(AudioError::Stream(format!(
|
other => Err(AudioError::Stream(format!(
|
||||||
"unsupported capture sample format: {other:?}"
|
"unsupported capture sample format: {other:?}"
|
||||||
))),
|
))),
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||||
|
Ok((stream, name, sample_format, channels))
|
||||||
|
};
|
||||||
|
|
||||||
// The RT callback does the work; this thread just keeps `stream` alive until
|
let (stream, dev_name, sample_format, channels) = match setup() {
|
||||||
// `stop()` flips the flag, at which point the stream is dropped (= stopped).
|
Ok(v) => {
|
||||||
while running.load(Ordering::Relaxed) {
|
let _ = ready.send(Ok(()));
|
||||||
thread::sleep(WORKER_POLL);
|
v
|
||||||
}
|
}
|
||||||
Ok(())
|
Err(e) => {
|
||||||
|
let _ = ready.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal capture started: device='{dev_name}' format={sample_format:?} channels={channels} rate={SAMPLE_RATE} Hz"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Drain the RT ring on this thread: pop mono samples, frame them (the `Vec`
|
||||||
|
// allocation lives here, off the RT path), and send completed frames. Keep
|
||||||
|
// `stream` alive until `stop()` flips the flag.
|
||||||
|
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
|
||||||
|
let mut last_overrun = 0u64;
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
let mut drained = false;
|
||||||
|
while let Some(sample) = consumer.try_pop() {
|
||||||
|
drained = true;
|
||||||
|
if let Some(frame) = acc.push(sample) {
|
||||||
|
// Consumer gone (call ended) → stop feeding; the stream is
|
||||||
|
// dropped below on the way out.
|
||||||
|
if tx.send(frame).is_err() {
|
||||||
|
drop(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let o = overrun.load(Ordering::Relaxed);
|
||||||
|
if o != last_overrun {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal capture overrun: dropped {} samples (drain thread fell behind)",
|
||||||
|
o - last_overrun
|
||||||
|
));
|
||||||
|
last_overrun = o;
|
||||||
|
}
|
||||||
|
if !drained {
|
||||||
|
thread::sleep(CAPTURE_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_input<T>(
|
fn build_input<T, P>(
|
||||||
device: &Device,
|
device: &Device,
|
||||||
config: &StreamConfig,
|
config: &StreamConfig,
|
||||||
tx: Sender<Vec<i16>>,
|
mut producer: P,
|
||||||
channels: usize,
|
channels: usize,
|
||||||
|
overrun: Arc<AtomicU64>,
|
||||||
) -> Result<Stream, AudioError>
|
) -> Result<Stream, AudioError>
|
||||||
where
|
where
|
||||||
T: SizedSample + Send + 'static,
|
T: SizedSample + Send + 'static,
|
||||||
i16: FromSample<T>,
|
i16: FromSample<T>,
|
||||||
|
P: Producer<Item = i16> + Send + 'static,
|
||||||
{
|
{
|
||||||
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
|
|
||||||
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
|
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
|
||||||
device
|
device
|
||||||
.build_input_stream::<T, _, _>(
|
.build_input_stream::<T, _, _>(
|
||||||
config,
|
config,
|
||||||
move |data: &[T], _| {
|
move |data: &[T], _| {
|
||||||
|
// RT-safe: downmix + wait-free push only. A full ring means the
|
||||||
|
// drain thread stalled; count the drop and keep going.
|
||||||
for frame in data.chunks_exact(channels) {
|
for frame in data.chunks_exact(channels) {
|
||||||
let mono = downmix_to_mono(frame);
|
let mono = downmix_to_mono(frame);
|
||||||
if let Some(full) = acc.push(mono) {
|
if producer.try_push(mono).is_err() {
|
||||||
// Consumer gone (call ended) → stop feeding; the owning
|
overrun.fetch_add(1, Ordering::Relaxed);
|
||||||
// thread will drop the stream on `stop()`.
|
|
||||||
if tx.send(full).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -350,9 +504,8 @@ fn run_playback(
|
|||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
ring_fill: Arc<AtomicUsize>,
|
ring_fill: Arc<AtomicUsize>,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
) -> Result<(), AudioError> {
|
ready: Sender<Result<(), AudioError>>,
|
||||||
let (device, config, sample_format) = resolve(true, target)?;
|
) {
|
||||||
|
|
||||||
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||||
let (mut producer, consumer) = rb.split();
|
let (mut producer, consumer) = rb.split();
|
||||||
|
|
||||||
@@ -369,29 +522,56 @@ fn run_playback(
|
|||||||
// Diagnostics (mirrors the PipeWire backend's playout-health line).
|
// Diagnostics (mirrors the PipeWire backend's playout-health line).
|
||||||
let underrun = Arc::new(AtomicU64::new(0));
|
let underrun = Arc::new(AtomicU64::new(0));
|
||||||
let dropped = Arc::new(AtomicU64::new(0));
|
let dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
// Largest single output-callback length seen (interleaved samples). WASAPI
|
||||||
|
// shared-mode picks its own period, so this can exceed the prefill target —
|
||||||
|
// which would force an underrun every cycle (review W2). The callback only
|
||||||
|
// does a wait-free fetch_max; the health logger reports/warns off the RT path.
|
||||||
|
let max_cb = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
// Fallible device/stream setup; report the real error to `start_playback`
|
||||||
|
// before any work so a failure surfaces instead of a silent room. `consumer`
|
||||||
|
// is moved into the output callback here.
|
||||||
|
let setup = || -> Result<(Stream, String, SampleFormat), AudioError> {
|
||||||
|
let (device, config, sample_format) = resolve(true, target)?;
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => {
|
SampleFormat::F32 => {
|
||||||
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||||
}
|
}
|
||||||
SampleFormat::I16 => {
|
SampleFormat::I16 => {
|
||||||
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||||
}
|
}
|
||||||
SampleFormat::U16 => {
|
SampleFormat::U16 => {
|
||||||
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||||
}
|
}
|
||||||
other => Err(AudioError::Stream(format!(
|
other => Err(AudioError::Stream(format!(
|
||||||
"unsupported playback sample format: {other:?}"
|
"unsupported playback sample format: {other:?}"
|
||||||
))),
|
))),
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||||
|
Ok((stream, name, sample_format))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (stream, dev_name, sample_format) = match setup() {
|
||||||
|
Ok(v) => {
|
||||||
|
let _ = ready.send(Ok(()));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ready.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal playback started: device='{dev_name}' format={sample_format:?} channels={PLAYBACK_CHANNELS} rate={SAMPLE_RATE} Hz"
|
||||||
|
));
|
||||||
|
|
||||||
let logger = spawn_health_logger(
|
let logger = spawn_health_logger(
|
||||||
running.clone(),
|
running.clone(),
|
||||||
ring_fill.clone(),
|
ring_fill.clone(),
|
||||||
underrun.clone(),
|
underrun.clone(),
|
||||||
dropped.clone(),
|
dropped.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Feed the ring from the network mixer until `stop()` flips `running` or the
|
// Feed the ring from the network mixer until `stop()` flips `running` or the
|
||||||
@@ -413,7 +593,6 @@ fn run_playback(
|
|||||||
running.store(false, Ordering::Relaxed);
|
running.store(false, Ordering::Relaxed);
|
||||||
let _ = logger.join();
|
let _ = logger.join();
|
||||||
drop(stream);
|
drop(stream);
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_output<T, C>(
|
fn build_output<T, C>(
|
||||||
@@ -422,6 +601,7 @@ fn build_output<T, C>(
|
|||||||
mut consumer: C,
|
mut consumer: C,
|
||||||
ring_fill: Arc<AtomicUsize>,
|
ring_fill: Arc<AtomicUsize>,
|
||||||
underrun: Arc<AtomicU64>,
|
underrun: Arc<AtomicU64>,
|
||||||
|
max_cb: Arc<AtomicUsize>,
|
||||||
) -> Result<Stream, AudioError>
|
) -> Result<Stream, AudioError>
|
||||||
where
|
where
|
||||||
T: SizedSample + FromSample<i16> + Send + 'static,
|
T: SizedSample + FromSample<i16> + Send + 'static,
|
||||||
@@ -432,6 +612,8 @@ where
|
|||||||
.build_output_stream::<T, _, _>(
|
.build_output_stream::<T, _, _>(
|
||||||
config,
|
config,
|
||||||
move |data: &mut [T], _| {
|
move |data: &mut [T], _| {
|
||||||
|
// Wait-free; the logger thread reads this off the RT path.
|
||||||
|
max_cb.fetch_max(data.len(), Ordering::Relaxed);
|
||||||
let (popped, starved) = fill_output(&mut consumer, data);
|
let (popped, starved) = fill_output(&mut consumer, data);
|
||||||
if starved > 0 {
|
if starved > 0 {
|
||||||
underrun.fetch_add(starved, Ordering::Relaxed);
|
underrun.fetch_add(starved, Ordering::Relaxed);
|
||||||
@@ -480,10 +662,12 @@ fn spawn_health_logger(
|
|||||||
ring_fill: Arc<AtomicUsize>,
|
ring_fill: Arc<AtomicUsize>,
|
||||||
underrun: Arc<AtomicU64>,
|
underrun: Arc<AtomicU64>,
|
||||||
dropped: Arc<AtomicU64>,
|
dropped: Arc<AtomicU64>,
|
||||||
|
max_cb: Arc<AtomicUsize>,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
|
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let (mut last_u, mut last_d) = (0u64, 0u64);
|
let (mut last_u, mut last_d) = (0u64, 0u64);
|
||||||
|
let mut reported_cb = 0usize;
|
||||||
while running.load(Ordering::Relaxed) {
|
while running.load(Ordering::Relaxed) {
|
||||||
thread::sleep(Duration::from_secs(1));
|
thread::sleep(Duration::from_secs(1));
|
||||||
let u = underrun.load(Ordering::Relaxed);
|
let u = underrun.load(Ordering::Relaxed);
|
||||||
@@ -498,6 +682,24 @@ fn spawn_health_logger(
|
|||||||
fill / (48 * PLAYBACK_CHANNELS),
|
fill / (48 * PLAYBACK_CHANNELS),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Report the device's callback size the first time it's seen (and on
|
||||||
|
// any new high). If a callback asks for more than the prefill target,
|
||||||
|
// the ring can't satisfy it and underruns every cycle — the W2 bug
|
||||||
|
// signature; warn so a real-host log shows whether it's biting.
|
||||||
|
let cb = max_cb.load(Ordering::Relaxed);
|
||||||
|
if cb > reported_cb {
|
||||||
|
reported_cb = cb;
|
||||||
|
let ms = cb / (48 * PLAYBACK_CHANNELS);
|
||||||
|
if cb > PLAYBACK_TARGET_SAMPLES {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)",
|
||||||
|
));
|
||||||
|
} else if verbose {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-6
@@ -56,19 +56,46 @@ 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)]
|
#[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 +103,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")
|
||||||
|
|||||||
+121
-11
@@ -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,25 @@
|
|||||||
//! 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;
|
||||||
@@ -135,3 +140,108 @@ mod unix_probe {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win_probe {
|
||||||
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use peerspeak::audio::AudioBackend;
|
||||||
|
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||||
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||||
|
|
||||||
|
const SAMPLE_RATE: f32 = 48_000.0;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn run() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||||
|
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||||
|
let target_node: Option<String> = args.next();
|
||||||
|
|
||||||
|
// The playout-health logger is quiet in normal operation (it only logs
|
||||||
|
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||||
|
// show the steady-state numbers.
|
||||||
|
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||||
|
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||||
|
|
||||||
|
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||||
|
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||||
|
|
||||||
|
// Tail the app log (where playout-health lines land) to stdout in the
|
||||||
|
// background so it's all in one terminal.
|
||||||
|
spawn_log_tailer();
|
||||||
|
|
||||||
|
let backend = CpalBackend::new();
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
|
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||||
|
eprintln!("failed to start playback: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||||
|
// exactly like the production mixer: only produce while the ring is below
|
||||||
|
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||||
|
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||||
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||||
|
for _ in 0..FRAME_SAMPLES {
|
||||||
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
|
// 0.25 amplitude: clearly audible but not harsh.
|
||||||
|
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||||
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||||
|
frame.push(sample);
|
||||||
|
frame.push(sample);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if tx.send(frame).is_err() {
|
||||||
|
eprintln!("playback channel closed early");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the ring drain, then stop.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
let _ = backend.stop();
|
||||||
|
println!("\naudio_probe: done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||||
|
/// reports) to stdout once they appear.
|
||||||
|
fn spawn_log_tailer() {
|
||||||
|
let path = peerspeak::log_file_path();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Wait for the file to exist (first log_msg creates it).
|
||||||
|
let file = loop {
|
||||||
|
if let Ok(f) = std::fs::File::open(&path) {
|
||||||
|
break f;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
};
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let _ = reader.seek(SeekFrom::End(0));
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||||
|
Ok(_) => {
|
||||||
|
if line.contains("playout-health:") {
|
||||||
|
print!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
+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