diff --git a/Cargo.toml b/Cargo.toml index 8ed7269..f9bfde3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/src/app/mod.rs b/src/app/mod.rs index 530019d..fc6dfe5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 29eb492..2699c90 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -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; diff --git a/src/bin/audio_probe.rs b/src/bin/audio_probe.rs index c3fcee3..b08712d 100644 --- a/src/bin/audio_probe.rs +++ b/src/bin/audio_probe.rs @@ -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; diff --git a/src/core/mod.rs b/src/core/mod.rs index 41869b1..e3bc8d9 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -390,6 +390,7 @@ struct ActiveSession { grace_timers: GraceTimers, transport: Arc, /// Loaded PipeWire echo-cancel module (if enabled); unloads on drop. + #[cfg(target_os = "linux")] echo_cancel: Option, /// 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(), diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 2029220..6187673 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -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 { } 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 = 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")]); + } }