Merge Windows-compat quick wins (W5/W6/W9) into windows port
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled

This commit is contained in:
2026-06-19 04:03:55 -04:00
6 changed files with 100 additions and 38 deletions
+1 -1
View File
@@ -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).
+38 -6
View File
@@ -2571,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)
@@ -3277,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")
@@ -3296,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 {
+7 -6
View File
@@ -56,17 +56,18 @@ 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(unix)] #[cfg(target_os = "linux")]
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
@@ -90,7 +91,7 @@ impl std::fmt::Display for AudioDevice {
// Enumerate audio input/output devices for the pickers (sorted by description), // Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire // returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows. // (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(unix)] #[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices; pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)] #[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices; pub use cpal_impl::enumerate_audio_devices;
@@ -102,9 +103,9 @@ pub use cpal_impl::enumerate_audio_devices;
/// 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`]). /// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
#[cfg(unix)] #[cfg(target_os = "linux")]
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;
+6 -6
View File
@@ -18,20 +18,20 @@
//! 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 PipeWire backend directly, so it is a Linux-only tool.
//! On non-Unix targets `main` is a stub that explains the limitation. //! On non-Linux targets `main` is 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(not(target_os = "linux"))]
fn main() { fn main() {
eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly)."); eprintln!("audio_probe is only supported on Linux builds (it drives the PipeWire backend directly).");
} }
#[cfg(unix)] #[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;
+9
View File
@@ -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(),
+21 -1
View File
@@ -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")]);
}
} }