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
# `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
# 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).
+56 -24
View File
@@ -2571,10 +2571,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
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),
vertical_space(4.0),
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
{
let control: Element<'_, AppMessage> = {
#[cfg(target_os = "linux")]
{
column![
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
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(10)
@@ -3277,26 +3295,40 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![]
},
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
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
// applies on the NEXT join (the PipeWire-module AEC is wired at join
// time, not hot-swappable mid-call).
tooltip(
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
container(
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.size(11)
.color(color_text),
)
.padding(8)
.max_width(260.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Top,
)
.gap(8),
{
// Echo cancellation is wired at join time on Linux; other
// targets show an inert status row instead of a dead toggle.
let control: Element<'_, AppMessage> = {
#[cfg(target_os = "linux")]
{
tooltip(
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
container(
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.size(11)
.color(color_text),
)
.padding(8)
.max_width(260.0)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Top,
)
.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),
{
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>;
}
pub mod echo_cancel;
pub mod eq;
pub mod gate;
pub mod limiter;
pub mod multitrack;
pub mod pan;
#[cfg(unix)]
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(unix)]
#[cfg(target_os = "linux")]
pub mod pw_cli;
pub mod recorder;
@@ -90,7 +91,7 @@ impl std::fmt::Display for AudioDevice {
// 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(unix)]
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
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
/// [`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`]).
#[cfg(unix)]
#[cfg(target_os = "linux")]
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
#[cfg(windows)]
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]
//! e.g. cargo run --release --bin audio_probe -- 440 30
//!
//! This probe exercises the PipeWire backend directly, so it is a Unix-only tool.
//! On non-Unix targets `main` is a stub that explains the limitation.
//! This probe exercises the PipeWire backend directly, so it is a Linux-only tool.
//! On non-Linux targets `main` is a stub that explains the limitation.
#[cfg(unix)]
#[cfg(target_os = "linux")]
fn main() {
unix_probe::run();
}
#[cfg(not(unix))]
#[cfg(not(target_os = "linux"))]
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 {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
+9
View File
@@ -390,6 +390,7 @@ struct ActiveSession {
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// 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
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel);
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
// paths below, since it's a local until moved into the session. On
// any failure, warn and fall back to the direct devices.
#[cfg(target_os = "linux")]
let mut echo_cancel_guard = None;
#[cfg(target_os = "linux")]
let (capture_target, playback_target) = if echo_cancellation {
match crate::audio::echo_cancel::enable(
input_device.as_deref(),
@@ -1122,6 +1126,10 @@ async fn run_core_loop(
} else {
(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) {
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,
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::new(),
+21 -1
View File
@@ -25,6 +25,16 @@ use tokio::process::{Child, Command};
/// points elsewhere.
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
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
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")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(PIXELPASS_BIN))
.flat_map(|dir| pixelpass_path_candidates(&dir))
.find(|c| c.is_file())
}
@@ -513,4 +523,14 @@ mod tests {
// only assert it doesn't return the empty path as a match.
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")]);
}
}