diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 374f8309..38a6319d 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -1014,6 +1014,12 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow <Control><Alt><Shift>s + + + Mute or unmute your microphone (only while the stream sends one) + <Control><Alt><Shift>v + + diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 2301ada3..91218a19 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -607,6 +607,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) if touched.has("mic_enabled") { o.mic_enabled = Some(values.mic_enabled); } + if touched.has("echo_cancel") { + o.echo_cancel = Some(values.echo_cancel); + } if touched.has("touch_mode") { o.touch_mode = Some(values.touch_mode.clone()); } @@ -1301,7 +1304,11 @@ pub fn show_scoped( ); let mic_row = adw::SwitchRow::builder() .title("Stream microphone") - .subtitle("Sends your microphone to the host's virtual mic") + .subtitle("Sends your microphone to the host's virtual mic — Ctrl+Alt+Shift+V mutes it mid-stream") + .build(); + let echo_row = adw::SwitchRow::builder() + .title("Echo cancellation") + .subtitle("Keeps the host's audio, playing from this machine's speakers, out of the uplink") .build(); // Endpoint pickers (from the PipeWire probe): visible labels are descriptions, the // stored value is the node name. Hidden when the probe found nothing; a saved @@ -1345,12 +1352,23 @@ pub fn show_scoped( "Microphone", "The input that feeds the host's virtual mic", ); - // The device pick only matters while the mic streams at all. + // The device pick and the echo canceller only matter while the mic streams at all — both + // follow it. One handler each (the pickers are optional, the echo row never is), and the + // initial state is set here because the seed block further down fires these too. + // + // Insensitivity covers the whole row, including the per-row Reset a profile scope adds: + // an echo_cancel override can only be reset while the mic row is on. Turn it on, reset, + // turn it back off — the alternative is a control that looks live and isn't. if let Some(r) = &micdev_row { let w = r.widget().clone(); w.set_sensitive(mic_row.is_active()); mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active())); } + { + let w = echo_row.clone(); + w.set_sensitive(mic_row.is_active()); + mic_row.connect_active_notify(move |m| w.set_sensitive(m.is_active())); + } // ---- Controllers ---- // Controller forwarding: Automatic forwards EVERY real controller, each as its own pad @@ -1453,6 +1471,7 @@ pub fn show_scoped( inhibit_row.set_active(s.inhibit_shortcuts); invert_row.set_active(s.invert_scroll); mic_row.set_active(s.mic_enabled); + echo_row.set_active(s.echo_cancel); hdr_row.set_active(s.hdr_enabled); chroma_row.set_active(s.enable_444); library_row.set_active(s.library_enabled); @@ -1673,6 +1692,12 @@ pub fn show_scoped( invert_scroll ); toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled); + toggle!( + echo_row, + "echo_cancel", + o.echo_cancel.is_some(), + echo_cancel + ); { let revert = { let (row, globals, touched) = @@ -1781,6 +1806,7 @@ pub fn show_scoped( audio_group.add(r.widget()); } audio_group.add(&mic_row); + audio_group.add(&echo_row); if let (Some(r), false) = (&micdev_row, profile_mode) { audio_group.add(r.widget()); } @@ -1890,6 +1916,7 @@ pub fn show_scoped( s.inhibit_shortcuts = inhibit_row.is_active(); s.invert_scroll = invert_row.is_active(); s.mic_enabled = mic_row.is_active(); + s.echo_cancel = echo_row.is_active(); s.hdr_enabled = hdr_row.is_active(); s.enable_444 = chroma_row.is_active(); s.audio_channels = match surround_row.selected() { diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index dbb8ace3..77649e28 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -272,6 +272,7 @@ mod session_main { // compositors only). cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop, mic_enabled: settings.mic_enabled, + echo_cancel: settings.echo_cancel, clipboard, // The Settings preference (auto → VAAPI where it exists; the presenter // demotes to software on boxes whose Vulkan can't import the dmabufs). diff --git a/clients/windows/src/app/help.rs b/clients/windows/src/app/help.rs index 301e6fd8..8332eae3 100644 --- a/clients/windows/src/app/help.rs +++ b/clients/windows/src/app/help.rs @@ -21,6 +21,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[ "Ctrl+Alt+Shift+S", "Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)", ), + ( + "Ctrl+Alt+Shift+V", + "Mute or unmute your microphone (only while the stream sends one)", + ), ( "LB+RB+Start+Back", "Controller: release input / leave fullscreen \u{2014} hold to disconnect", diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index c03d7005..260bee70 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -428,6 +428,7 @@ struct OverrideFlags { compositor: bool, audio_channels: bool, mic_enabled: bool, + echo_cancel: bool, touch_mode: bool, mouse_mode: bool, invert_scroll: bool, @@ -455,6 +456,7 @@ impl OverrideFlags { compositor: o.compositor.is_some(), audio_channels: o.audio_channels.is_some(), mic_enabled: o.mic_enabled.is_some(), + echo_cancel: o.echo_cancel.is_some(), touch_mode: o.touch_mode.is_some(), mouse_mode: o.mouse_mode.is_some(), invert_scroll: o.invert_scroll.is_some(), @@ -913,6 +915,12 @@ pub(crate) fn settings_page( let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| { s.mic_enabled = on }); + // Echo cancellation is meaningless without an uplink, so it greys out with the mic above + // it. Every commit bumps `rev` and re-renders this screen, so the two stay in step live. + let echo_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.echo_cancel, |s, on| { + s.echo_cancel = on + }) + .enabled(s.mic_enabled); let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity()); let hud_combo = setting_combo(ctx, scope, (rev, set_rev), hud_names, hud_i, |s, i| { @@ -1186,7 +1194,19 @@ pub(crate) fn settings_page( "Stream microphone to the host", over.mic_enabled, mic_toggle, - "This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.", + "This device\u{2019}s microphone feeds the host\u{2019}s virtual mic. \ + Ctrl+Alt+Shift+V mutes and unmutes it during a stream.", + ), + described_overridable( + (rev, set_rev), + scope, + "echo_cancel", + "Echo cancellation", + over.echo_cancel, + echo_toggle, + "Keeps the host\u{2019}s audio, playing from this machine\u{2019}s \ + speakers, from being picked up and sent straight back. Turn it off if \ + your microphone already does its own processing.", ), ], Some("Applies from the next session."), @@ -1587,5 +1607,16 @@ mod tests { ..Default::default() }; assert!(OverrideFlags::of(Some(&p2)).resolution); + + // The audio pair: the mic and its echo canceller are separate overrides, so a profile + // can pin one without claiming the other. + let mut p3 = StreamProfile::new("t3".to_string()); + p3.overrides = SettingsOverlay { + echo_cancel: Some(false), + ..Default::default() + }; + let f3 = OverrideFlags::of(Some(&p3)); + assert!(f3.echo_cancel); + assert!(!f3.mic_enabled); } } diff --git a/crates/pf-client-core/src/audio.rs b/crates/pf-client-core/src/audio.rs index d40a185d..30ceaaf7 100644 --- a/crates/pf-client-core/src/audio.rs +++ b/crates/pf-client-core/src/audio.rs @@ -339,12 +339,22 @@ pub struct MicStreamer { } impl MicStreamer { - pub fn spawn(connector: Arc) -> Result { + /// `muted` is the in-stream mute (B4), shared live with the capture callback: set, the + /// callback keeps pulling and discarding whole frames but sends nothing. Muting by + /// STOPPING the stream was rejected — it re-primes the device buffers and re-runs the + /// source selection below on every unmute, so the first second back is glitchy. + /// + /// `echo_cancel` is the Settings toggle; `PUNKTFUNK_NO_AEC=1` overrides it off. + pub fn spawn( + connector: Arc, + muted: Arc, + echo_cancel: bool, + ) -> Result { let (quit_tx, quit_rx) = pipewire::channel::channel::(); let thread = std::thread::Builder::new() .name("punktfunk-mic".into()) .spawn(move || { - if let Err(e) = mic_thread(&connector, quit_rx) { + if let Err(e) = mic_thread(&connector, quit_rx, muted, echo_cancel) { tracing::warn!(error = %e, "mic uplink thread ended"); } }) @@ -373,12 +383,17 @@ struct MicData { encoder: opus::Encoder, seq: u32, out: Vec, + /// The in-stream mute (B4), flipped by the session's chord. Read per callback. + muted: Arc, } -/// `PUNKTFUNK_NO_AEC=1` — the opt-out for every mic echo-cancellation hook (here: the -/// echo-cancelled-source preference; the WASAPI twin gates its Communications category on it). -fn aec_disabled() -> bool { - std::env::var("PUNKTFUNK_NO_AEC").is_ok_and(|v| !v.is_empty() && v != "0") +/// Whether the mic echo-cancellation hooks run this session: the `echo_cancel` setting, with +/// `PUNKTFUNK_NO_AEC=1` as a one-way override OFF. The env var wins — it is the escape hatch +/// for a box whose canceller misbehaves, and it predates the setting; nothing turns AEC back +/// on once it is set. Here the hook is the echo-cancelled-source preference below; the WASAPI +/// twin gates its Communications stream category the same way. +fn aec_enabled(echo_cancel: bool) -> bool { + echo_cancel && !std::env::var("PUNKTFUNK_NO_AEC").is_ok_and(|v| !v.is_empty() && v != "0") } /// The capture stream's `target.object`, in preference order: the Settings microphone pick @@ -390,19 +405,20 @@ fn aec_disabled() -> bool { /// Preference-only by design: loading `libpipewire-module-echo-cancel` ourselves needs /// `pw_context_load_module`, which the pipewire crate (0.9) doesn't expose safely — until it /// does, we only ever target processing the user (or their session) already set up. -fn mic_capture_target() -> Option { +fn mic_capture_target(echo_cancel: bool) -> Option { if let Ok(target) = std::env::var("PUNKTFUNK_AUDIO_SOURCE") { if !target.is_empty() { return Some(target); } } - if aec_disabled() { + if !aec_enabled(echo_cancel) { return None; } let name = echo_cancel_source()?; tracing::info!( source = %name, - "mic capture targets the echo-cancelled source (PUNKTFUNK_NO_AEC=1 to disable)" + "mic capture targets the echo-cancelled source (Echo cancellation off, or \ + PUNKTFUNK_NO_AEC=1, disables this)" ); Some(name) } @@ -427,6 +443,8 @@ fn echo_cancel_source() -> Option { fn mic_thread( connector: &Arc, quit_rx: pipewire::channel::Receiver, + muted: Arc, + echo_cancel: bool, ) -> Result<()> { use pipewire as pw; use pw::{properties::properties, spa}; @@ -468,7 +486,7 @@ fn mic_thread( // sat ahead of the encoder as latency (the playback stream always asked for 5 ms). *pw::keys::NODE_LATENCY => "480/48000", }; - if let Some(target) = mic_capture_target() { + if let Some(target) = mic_capture_target(echo_cancel) { // Raw key: the `keys::TARGET_OBJECT` constant is feature-gated on a newer // libpipewire than we require; the wire name is stable. props.insert("target.object", target); @@ -482,6 +500,7 @@ fn mic_thread( encoder, seq: 0, out: vec![0u8; 4000], + muted, }; let _listener = stream @@ -506,6 +525,17 @@ fn mic_thread( .push_back(f32::from_le_bytes([s[0], s[1], s[2], s[3]])); } } + // Muted (B4): the stream stays open and the device keeps its primed buffers — + // only the sending stops. Whole frames are discarded so the ring can't grow, + // and `seq` deliberately does NOT advance: the host sees one continuous + // sequence with a silent pause in the middle rather than a gap the size of the + // mute, which its de-jitter would try to conceal frame by frame. + if ud.muted.load(std::sync::atomic::Ordering::Relaxed) { + let whole = + (ud.ring.len() / (MIC_FRAME * MIC_CHANNELS)) * (MIC_FRAME * MIC_CHANNELS); + ud.ring.drain(..whole); + return; + } // Ship every complete 10 ms mono frame. while ud.ring.len() >= MIC_FRAME * MIC_CHANNELS { let pcm: Vec = ud.ring.drain(..MIC_FRAME * MIC_CHANNELS).collect(); diff --git a/crates/pf-client-core/src/audio_wasapi.rs b/crates/pf-client-core/src/audio_wasapi.rs index 4b191df9..5e3cf618 100644 --- a/crates/pf-client-core/src/audio_wasapi.rs +++ b/crates/pf-client-core/src/audio_wasapi.rs @@ -233,13 +233,23 @@ pub struct MicStreamer { } impl MicStreamer { - pub fn spawn(connector: Arc) -> Result { + /// `muted` is the in-stream mute (B4), shared live with the capture loop: set, the loop + /// keeps reading the endpoint and discarding whole frames but sends nothing. Muting by + /// STOPPING the client was rejected — an `IAudioClient` stop/start re-primes the endpoint + /// buffers and re-runs the category negotiation below on every unmute. + /// + /// `echo_cancel` is the Settings toggle; `PUNKTFUNK_NO_AEC=1` overrides it off. + pub fn spawn( + connector: Arc, + muted: Arc, + echo_cancel: bool, + ) -> Result { let stop = Arc::new(AtomicBool::new(false)); let stop_t = stop.clone(); let thread = std::thread::Builder::new() .name("punktfunk-mic".into()) .spawn(move || { - if let Err(e) = mic_thread(&connector, stop_t) { + if let Err(e) = mic_thread(&connector, stop_t, muted, echo_cancel) { tracing::warn!(error = %format!("{e:#}"), "mic uplink thread ended"); } }) @@ -260,7 +270,21 @@ impl Drop for MicStreamer { } } -fn mic_thread(connector: &Arc, stop: Arc) -> Result<()> { +/// Whether the mic echo-cancellation hooks run this session: the `echo_cancel` setting, with +/// `PUNKTFUNK_NO_AEC=1` as a one-way override OFF. The env var wins — it is the escape hatch +/// for a box whose canceller misbehaves, and it predates the setting; nothing turns AEC back +/// on once it is set. Here the hook is the Communications stream category below; the PipeWire +/// twin gates its echo-cancelled-source preference the same way. +fn aec_enabled(echo_cancel: bool) -> bool { + echo_cancel && !std::env::var("PUNKTFUNK_NO_AEC").is_ok_and(|v| !v.is_empty() && v != "0") +} + +fn mic_thread( + connector: &Arc, + stop: Arc, + muted: Arc, + echo_cancel: bool, +) -> Result<()> { wasapi::initialize_mta() .ok() .context("CoInitializeEx (MTA)")?; @@ -289,8 +313,9 @@ fn mic_thread(connector: &Arc, stop: Arc) -> Result<() // this box fed straight back into the host's virtual mic. Must precede Initialize // (SetClientProperties is a pre-init call; the wasapi crate QIs IAudioClient2 inside). // Best-effort: an endpoint without IAudioClient2 just keeps the default category. - // PUNKTFUNK_NO_AEC=1 opts out (same lever as the Linux echo-cancel-source preference). - if !std::env::var("PUNKTFUNK_NO_AEC").is_ok_and(|v| !v.is_empty() && v != "0") { + // The "Echo cancellation" setting opts out, and PUNKTFUNK_NO_AEC=1 overrides that off + // (same lever as the Linux echo-cancel-source preference) — see `aec_enabled`. + if aec_enabled(echo_cancel) { if let Err(e) = audio_client.set_properties( AudioClientProperties::new().set_category(StreamCategory::Communications), ) { @@ -349,6 +374,16 @@ fn mic_thread(connector: &Arc, stop: Arc) -> Result<() let r = f32::from_le_bytes([c[4], c[5], c[6], c[7]]); ring.push_back((l + r) * 0.5); } + // Muted (B4): the capture client stays started and keeps its primed buffers — only + // the sending stops. Whole frames are discarded so the ring can't grow, and `seq` + // deliberately does NOT advance: the host sees one continuous sequence with a silent + // pause in the middle rather than a gap the size of the mute, which its de-jitter + // would try to conceal frame by frame. + if muted.load(Ordering::Relaxed) { + let drop_n = (ring.len() / MIC_FRAME) * MIC_FRAME; + ring.drain(..drop_n); + continue; + } // Ship every complete 10 ms mono frame. while ring.len() >= MIC_FRAME { let pcm: Vec = ring.drain(..MIC_FRAME).collect(); diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 608ebf1e..9e7b813f 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -62,6 +62,8 @@ pub struct SettingsOverlay { #[serde(skip_serializing_if = "Option::is_none")] pub mic_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub echo_cancel: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub touch_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mouse_mode: Option, @@ -122,6 +124,9 @@ impl SettingsOverlay { if let Some(v) = self.mic_enabled { s.mic_enabled = v; } + if let Some(v) = self.echo_cancel { + s.echo_cancel = v; + } if let Some(v) = &self.touch_mode { s.touch_mode = v.clone(); } @@ -197,6 +202,9 @@ impl SettingsOverlay { if after.mic_enabled != before.mic_enabled { self.mic_enabled = Some(after.mic_enabled); } + if after.echo_cancel != before.echo_cancel { + self.echo_cancel = Some(after.echo_cancel); + } if after.touch_mode != before.touch_mode { self.touch_mode = Some(after.touch_mode.clone()); } @@ -243,6 +251,7 @@ impl SettingsOverlay { "compositor" => self.compositor = None, "audio_channels" => self.audio_channels = None, "mic_enabled" => self.mic_enabled = None, + "echo_cancel" => self.echo_cancel = None, "touch_mode" => self.touch_mode = None, "mouse_mode" => self.mouse_mode = None, "invert_scroll" => self.invert_scroll = None, @@ -437,6 +446,7 @@ mod tests { compositor: Some("gamescope".into()), audio_channels: Some(6), mic_enabled: Some(true), + echo_cancel: Some(false), touch_mode: Some("pointer".into()), mouse_mode: Some("desktop".into()), invert_scroll: Some(true), @@ -457,6 +467,7 @@ mod tests { assert_eq!(out.compositor, "gamescope"); assert_eq!(out.audio_channels, 6); assert!(out.mic_enabled); + assert!(!out.echo_cancel); assert_eq!(out.touch_mode, "pointer"); assert_eq!(out.mouse_mode, "desktop"); assert!(out.invert_scroll); @@ -529,6 +540,39 @@ mod tests { assert_eq!(o2, o); } + /// `echo_cancel` is a first-class overlay field, not an `extra` passenger: it applies, + /// absorbs, clears, and serialises under the `echo_cancel` key the Apple and Android + /// clients write — one catalog has to round-trip through all three. + #[test] + fn echo_cancel_is_a_first_class_override() { + let base = Settings::default(); + assert!(base.echo_cancel, "the setting ships on"); + + let mut o = SettingsOverlay::default(); + let before = o.apply(&base); + let mut after = before.clone(); + after.echo_cancel = false; + o.absorb(&before, &after); + assert_eq!(o.echo_cancel, Some(false)); + assert!(!o.apply(&base).echo_cancel); + assert!( + o.extra.is_empty(), + "modelled fields must never land in the passthrough" + ); + + // Serialised under the shared key, and read back from a foreign client's file. + let text = serde_json::to_string(&o).unwrap(); + assert!(text.contains("\"echo_cancel\":false"), "{text}"); + let from_apple: SettingsOverlay = + serde_json::from_str(r#"{"mic_enabled":true,"echo_cancel":false}"#).unwrap(); + assert_eq!(from_apple.echo_cancel, Some(false)); + assert!(from_apple.extra.is_empty()); + + assert!(o.clear("echo_cancel")); + assert_eq!(o.echo_cancel, None); + assert!(o.is_empty()); + } + /// `clear` is the explicit way back to inheriting, including the resolution tri-state. #[test] fn clear_drops_one_override() { diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 0773e116..3bc02380 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -41,6 +41,9 @@ pub struct SessionParams { pub display_hdr: Option, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, + /// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]). + /// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off. + pub echo_cancel: bool, /// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The /// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`. pub clipboard: bool, @@ -123,8 +126,9 @@ pub struct Stats { pub lost_pct: f32, /// Mic uplink frames this window: handed to the QUIC datagram send, and shed anywhere /// client-side (queue-full at the producer + the pump's stale-oldest backlog governor — - /// see [`NativeClient::mic_stats`]). Both stay 0 while the mic is off, so the OSD - /// renders the mic line only when the uplink is live. + /// see [`NativeClient::mic_stats`]). Both stay 0 while the mic is off OR muted (a mute + /// stops the sending, not the capture), so the OSD renders the mic line only while voice + /// is actually going out — the muted case has its own badge, which does not need stats on. pub mic_sent: u32, pub mic_dropped: u32, /// The decode path frames actually took this window (`"vaapi"`/`"software"`, empty @@ -166,10 +170,61 @@ pub enum SessionEvent { Stats(Stats), } +/// The in-stream microphone mute (B4), shared between the embedder's toggle (a keyboard chord +/// in the presenter) and the capture callback that reads it every quantum. +/// +/// Two flags, not one, so the indicator can never lie: `live` is raised by the pump only once +/// the uplink is actually running, so a session whose mic is off in Settings — or whose capture +/// device failed to open — reports "no mic here" and the chord is a documented no-op instead of +/// silently latching a mute nothing implements. Per session by design: the mute is a moment +/// ("don't send the doorbell"), not a preference, so it is never persisted and every new +/// session starts unmuted. +#[derive(Clone, Default)] +pub struct MicControl { + muted: Arc, + live: Arc, +} + +impl MicControl { + /// True when this session has a running uplink to mute at all. + pub fn live(&self) -> bool { + self.live.load(Ordering::Relaxed) + } + + /// True when the user has muted a uplink that exists — what the OSD indicator draws. + pub fn muted(&self) -> bool { + self.live() && self.muted.load(Ordering::Relaxed) + } + + /// Flip the mute. `Some(now_muted)` when it applied, `None` when this session has no + /// uplink (the caller says so rather than pretending something happened). + pub fn toggle(&self) -> Option { + if !self.live() { + return None; + } + let next = !self.muted.load(Ordering::Relaxed); + self.muted.store(next, Ordering::Relaxed); + Some(next) + } + + /// The capture side's handle on the flag (the streamer reads it per quantum). + fn flag(&self) -> Arc { + self.muted.clone() + } + + /// The pump's report that the uplink came up (or went away). + fn set_live(&self, live: bool) { + self.live.store(live, Ordering::Relaxed); + } +} + pub struct SessionHandle { pub events: async_channel::Receiver, pub frames: async_channel::Receiver, pub stop: Arc, + /// The in-stream mic mute. Inert (`live()` false) until the pump has the uplink running, + /// and for the whole session when the mic is off in Settings. + pub mic: MicControl, /// The pump thread. A Vulkan-Video pump SUBMITS to the shared device's decode /// queue — the presenter must join this before any `vkDeviceWaitIdle`/teardown /// (external-sync rule over every device queue). @@ -182,14 +237,17 @@ pub fn start(params: SessionParams) -> SessionHandle { let (frame_tx, frame_rx) = async_channel::bounded(2); let stop = Arc::new(AtomicBool::new(false)); let stop_w = stop.clone(); + let mic = MicControl::default(); + let mic_w = mic.clone(); let thread = std::thread::Builder::new() .name("punktfunk-session".into()) - .spawn(move || pump(params, ev_tx, frame_tx, stop_w)) + .spawn(move || pump(params, ev_tx, frame_tx, stop_w, mic_w)) .expect("spawn session thread"); SessionHandle { events: ev_rx, frames: frame_rx, stop, + mic, thread: Some(thread), } } @@ -242,6 +300,7 @@ fn pump( ev_tx: async_channel::Sender, frame_tx: async_channel::Sender, stop: Arc, + mic: MicControl, ) { // PUNKTFUNK_PREFER_PYROWAVE=1 — the Phase-2 lab opt-in for the wired-LAN wavelet codec // (a Settings toggle is the Phase-3 productization). Riding `preferred_codec` is exactly @@ -385,14 +444,18 @@ fn pump( .ok() }) .flatten(); + // The uplink, and with it the mute the embedder's chord drives. `set_live` is what makes + // the chord (and its indicator) real: a mic turned off in Settings, or a capture device + // that wouldn't open, leaves it false and the chord stays an honest no-op. let _mic = params .mic_enabled .then(|| { - audio::MicStreamer::spawn(connector.clone()) + audio::MicStreamer::spawn(connector.clone(), mic.flag(), params.echo_cancel) .map_err(|e| tracing::warn!(error = %e, "mic uplink disabled")) .ok() }) .flatten(); + mic.set_live(_mic.is_some()); // Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP // step, drift) keep the capture-clock latency stats honest — never cached at session start. @@ -863,6 +926,10 @@ fn pump( "session ended" ); stop.store(true, Ordering::SeqCst); + // The uplink is about to be dropped with the rest of this frame — stop claiming a mute + // surface, so an embedder still holding the handle through its end path (browse mode + // returns to the console with it) can't draw a muted mic that no longer exists. + mic.set_live(false); if let Some(t) = audio_thread { let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set } @@ -973,4 +1040,28 @@ mod tests { assert!(parse_debug_reconfigure(bad).is_none(), "{bad:?} parsed"); } } + + /// The mute is inert until the pump reports a live uplink — a session without a mic must + /// answer "nothing to mute" rather than latching a mute and drawing the indicator. + #[test] + fn mic_mute_is_a_no_op_without_an_uplink() { + let mic = MicControl::default(); + assert!(!mic.live()); + assert_eq!(mic.toggle(), None, "no uplink, nothing to toggle"); + assert!(!mic.muted(), "and nothing to show"); + + mic.set_live(true); + assert_eq!(mic.toggle(), Some(true)); + assert!(mic.muted()); + // The capture side reads the same flag the toggle writes. + assert!(mic.flag().load(Ordering::Relaxed)); + assert_eq!(mic.toggle(), Some(false)); + assert!(!mic.muted()); + + // A mute that outlives its uplink stops being shown (session end clears `live`). + assert_eq!(mic.toggle(), Some(true)); + mic.set_live(false); + assert!(!mic.muted()); + assert_eq!(mic.toggle(), None); + } } diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 828d6c7b..6ee5c3ca 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -727,6 +727,15 @@ pub struct Settings { pub inhibit_shortcuts: bool, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, + /// Run the mic uplink through the platform's echo cancellation (the Apple/Android clients' + /// "Echo cancellation" toggle, same `echo_cancel` key). On Linux that means preferring an + /// echo-cancelled PipeWire source; on Windows, asking WASAPI for the Communications stream + /// category so the endpoint's own canceller engages. Default ON — without it, a laptop + /// speaker playing the host's audio is heard by this device's mic and sent straight back. + /// Only meaningful while `mic_enabled`. `PUNKTFUNK_NO_AEC=1` overrides it off (see + /// `audio::aec_enabled`). `default` so pre-existing stores load with it on. + #[serde(default = "default_true")] + pub echo_cancel: bool, /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /// can capture; the resolved count drives the decoder + playback layout. pub audio_channels: u8, @@ -882,6 +891,7 @@ impl Default for Settings { mouse_mode: "capture".into(), inhibit_shortcuts: true, mic_enabled: false, + echo_cancel: true, audio_channels: 2, codec: "auto".into(), decoder: "auto".into(), @@ -1063,6 +1073,9 @@ mod tests { assert_eq!(s.forward_pad, ""); assert!(s.fullscreen_on_stream); assert!(!s.library_enabled); + // Echo cancellation post-dates every stored file: it must load ON, or an upgrade + // would silently turn a user's echo protection off. + assert!(s.echo_cancel); } /// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off, diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index dedbbd4e..0c5da654 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -26,6 +26,7 @@ enum RowId { Hdr, Audio, Mic, + EchoCancel, Pad, PadType, Touch, @@ -33,7 +34,7 @@ enum RowId { Stats, } -const ROWS: [RowId; 14] = [ +const ROWS: [RowId; 15] = [ RowId::Resolution, RowId::Refresh, RowId::Bitrate, @@ -43,6 +44,7 @@ const ROWS: [RowId; 14] = [ RowId::Hdr, RowId::Audio, RowId::Mic, + RowId::EchoCancel, RowId::Pad, RowId::PadType, RowId::Touch, @@ -179,6 +181,9 @@ impl SettingsScreen { fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { let s = &ctx.settings; + // Echo cancellation only means anything while the mic streams — dimmed and inert while it + // doesn't, the same relationship the desktop shells draw with a greyed-out row. + let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled; let (header, label, value): (Option<&'static str>, &str, String) = match id { RowId::Resolution => ( Some("Stream"), @@ -231,6 +236,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { .into(), ), RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()), + RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()), RowId::Pad => ( Some("Controller"), "Use controller", @@ -264,10 +270,10 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { header, label: label.into(), value: Some(value), - value_dim: false, + value_dim: !enabled, caret: false, - adjustable: true, - enabled: true, + adjustable: enabled, + enabled, } } @@ -288,7 +294,14 @@ fn detail(id: RowId) -> &'static str { "HDR10 — engages when the host sends HDR content and this display supports it." } RowId::Audio => "The speaker layout requested from the host.", - RowId::Mic => "Send this device's microphone to the host's virtual mic.", + RowId::Mic => { + "Send this device's microphone to the host's virtual mic. \ + Ctrl+Alt+Shift+V mutes and unmutes it while streaming." + } + RowId::EchoCancel => { + "Stops the host's audio, playing from this device's speakers, being picked up \ + and sent back. Turn it off if your microphone already runs its own processing." + } RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", RowId::Touch => { @@ -361,6 +374,14 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0) } RowId::Mic => toggle(&mut s.mic_enabled, delta, wrap), + // Inert while the mic is off — a boundary thud, matching what the dimmed row shows. + RowId::EchoCancel => { + if s.mic_enabled { + toggle(&mut s.echo_cancel, delta, wrap) + } else { + None + } + } RowId::Pad => { // Automatic first, then every connected pad by stable key. let keys: Vec = std::iter::once(String::new()) @@ -494,6 +515,40 @@ mod tests { assert!(!ctx.settings.mic_enabled); } + /// Echo cancellation follows the microphone: inert and dimmed while the mic is off, live + /// the moment it goes on. A row that silently accepted a change nobody could act on would + /// be the same lie as an enabled-looking control. + #[test] + fn echo_cancellation_follows_the_microphone() { + let (mut settings, pads) = ctx_parts(); + settings.mic_enabled = false; + assert!(settings.echo_cancel, "it ships on"); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + assert!(!row_spec(RowId::EchoCancel, &ctx).enabled); + assert!( + !adjust(RowId::EchoCancel, -1, false, &mut ctx), + "mic off = thud" + ); + assert!(!adjust(RowId::EchoCancel, 1, true, &mut ctx), "A too"); + assert!(ctx.settings.echo_cancel, "and nothing was written"); + + ctx.settings.mic_enabled = true; + assert!(row_spec(RowId::EchoCancel, &ctx).enabled); + assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx)); + assert!(!ctx.settings.echo_cancel); + assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx)); + assert!(ctx.settings.echo_cancel); + } + #[test] fn touch_mode_steps_and_wraps() { let (mut settings, pads) = ctx_parts(); diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 45e17b3a..1afa6021 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -48,6 +48,9 @@ struct Drawn { height: u32, stats: Option, hint: Option, + /// The mic-mute badge is up. Part of the damage key like everything else here — the badge + /// is static once drawn, so a muted stream still re-renders nothing per frame. + mic_muted: bool, /// The UI scale this was drawn at, in percent — part of the damage key so dragging the window /// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping /// the stale one (the text is identical, so nothing else here would notice). @@ -390,7 +393,12 @@ impl Overlay for SkiaOverlay { // spinning through the damage gate; `+ 1` keeps an active resize's step nonzero // even on its first frame (phase 0) so the guard below doesn't skip it. let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1); - if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 { + if ctx.stats.is_none() + && ctx.hint.is_none() + && !ctx.mic_muted + && banner_step == 0 + && resize_step == 0 + { self.drawn = Drawn::default(); // forget content so re-show re-renders return Ok(None); } @@ -402,6 +410,7 @@ impl Overlay for SkiaOverlay { height: ctx.height, stats: ctx.stats.map(str::to_owned), hint: ctx.hint.map(str::to_owned), + mic_muted: ctx.mic_muted, scale_pct: (scale * 100.0).round() as u16, banner_step, resize_step, @@ -437,6 +446,11 @@ impl Overlay for SkiaOverlay { if let Some(stats) = &want.stats { draw_osd_panel(canvas, font, stats, ctx.width, scale); } + // Top-RIGHT, so it never collides with the stats panel or the bottom pill: the badge + // has to stay readable at every stats tier, including Off. + if want.mic_muted { + draw_mic_muted_badge(canvas, font, ctx.width, scale); + } if let Some(hint) = &want.hint { draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale); } else if banner_step > 0 { @@ -660,6 +674,49 @@ fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, sca } } +/// The mic-mute badge: a dot in the error colour plus the words, on the same translucent pill +/// as the rest of the chrome, pinned to the TOP-RIGHT corner. +/// +/// It is drawn from `FrameCtx::mic_muted` alone — not from the stats text — because it must +/// survive the stats overlay being Off, which is where most people leave it. Words, not a +/// glyph: the chrome font is a system monospace resolved at runtime and cannot be relied on to +/// carry a crossed-out microphone. Persistent by design (no fade): a fading indicator answers +/// "did the chord register?" but not "am I muted right now?", and the second question is the +/// one that matters ten minutes later. +fn draw_mic_muted_badge(canvas: &Canvas, base_font: &Font, width: u32, scale: f32) { + const LABEL: &str = "Microphone muted"; + // Short line — it fits any window the stream runs in, so it takes the display scale as-is. + let font = &chrome_font(base_font, scale); + let (_, metrics) = font.metrics(); + let line_h = metrics.descent - metrics.ascent; + let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale); + let dot_r = 4.0 * scale; + let dot_gap = 8.0 * scale; + let text_w = font.measure_str(LABEL, None).0; + let w = text_w + 2.0 * dot_r + dot_gap + 2.0 * pad_x; + let h = line_h + 2.0 * pad_y; + let margin = base::OSD_MARGIN * scale; + let (x, y) = (width as f32 - w - margin, margin); + canvas.draw_rrect( + RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0), + &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None), + ); + canvas.draw_circle( + Point::new(x + pad_x + dot_r, y + h / 2.0), + dot_r, + &Paint::new(crate::theme::ERROR, None), + ); + canvas.draw_str( + LABEL, + Point::new( + x + pad_x + 2.0 * dot_r + dot_gap, + y + pad_y - metrics.ascent, + ), + font, + &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None), + ); +} + /// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and /// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder /// rebuild reads as a deliberate pause rather than the stream stretching to the changed diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index 4c7e39c6..8ce4bb74 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -6,7 +6,7 @@ use crate::anim::{approach, Spring, TRAY_C, TRAY_K}; use crate::library::{BUMP_C, BUMP_K}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, FAINT, W, WHITE}; +use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, FAINT, W, WHITE}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; @@ -35,7 +35,9 @@ pub(crate) struct RowSpec { pub caret: bool, /// Show ‹ › chevrons while focused (left/right steps the value). pub adjustable: bool, - /// Action rows render dimmed when not yet actionable. + /// Rows render dimmed when they aren't actionable: an action row's centered label loses + /// its brand tint, a value row's label greys — the look for a setting that depends on + /// another one being on (Echo cancellation under Microphone). pub enabled: bool, } @@ -229,7 +231,7 @@ impl MenuList { baseline, W::SemiBold, 16.0 * k, - WHITE, + if row.enabled { WHITE } else { DIM }, ); let value = row.value.as_deref().unwrap_or_default(); let vcolor = if row.value_dim { diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index e023aa7e..472d4112 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -43,6 +43,11 @@ pub struct FrameCtx<'a> { pub stats: Option<&'a str>, /// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden. pub hint: Option<&'a str>, + /// The user muted their microphone mid-stream (Ctrl+Alt+Shift+V). Draws a persistent + /// badge, deliberately independent of the stats tier: a muted mic is a fact about what + /// the host is hearing, and "did my mute take?" must be answerable with the overlay off. + /// False whenever this session has no mic uplink at all — the badge never invents one. + pub mic_muted: bool, /// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md, /// client UX): draw a full-screen scrim + spinner so the host's 0.3–2 s virtual-display /// and encoder rebuild reads as an intentional pause rather than the stream stretching to diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 123b5a53..0dc9950d 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -12,6 +12,9 @@ //! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed; //! the stdout line always carries the full Detailed text so parsers see a stable //! shape). Logs go to stderr (the binary configures tracing so). +//! +//! In-stream chords all share the Ctrl+Alt+Shift prefix: Q release/engage, M mouse model, +//! D disconnect, S stats tier, V microphone mute. use crate::input::{Capture, FingerPhase}; use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase}; @@ -672,6 +675,23 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity"); continue; } + // Mic mute (B4) — "V" for voice; M and S are taken. Per session and never + // persisted: this is the doorbell/cough key, not a settings change. The + // uplink keeps running while muted (see `MicStreamer::spawn`); only the + // sending stops. A session streaming no mic says so instead of silently + // swallowing the chord — the overlay would have nothing to show either. + if chord && sc == Scancode::V { + if let Some(st) = &stream { + match st.handle.mic.toggle() { + Some(muted) => tracing::info!(muted, "chord: microphone mute"), + None => tracing::info!( + "chord: microphone mute — this session streams no \ + microphone (turn it on in Settings)" + ), + } + } + continue; + } // F11 or Alt+Enter (some keyboards' Fn layer sends a media key for // plain F11 — the Moonlight-standard alias always exists). let alt_enter = @@ -1199,6 +1219,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result let resizing = stream .as_ref() .is_some_and(|st| st.connector.is_some() && st.resize_overlay.active()); + // Read live from the session's control rather than mirrored into StreamState: the + // pump is what knows whether an uplink exists (it may have failed to open), and a + // mirrored copy would be the thing that goes stale at session end. + let mic_muted = stream.as_ref().is_some_and(|st| st.handle.mic.muted()); let ctx = FrameCtx { width: pw, height: ph, @@ -1208,6 +1232,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result scale: overlay_scale(window.display_scale(), osd_scale_pref), stats, hint, + mic_muted, resizing, pad: pad.as_ref().map(|p| p.name.as_str()), pad_pref: pad.as_ref().map(|p| p.pref), @@ -2017,8 +2042,10 @@ fn stats_text( if s.lost > 0 { text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct)); } - // The mic uplink line renders only while the mic is live (a healthy 10 ms-frame uplink - // reads ~100 f/s) and only in Detailed — drops here are the client shedding backlog. + // The mic uplink line renders only while voice is actually going out (a healthy 10 ms-frame + // uplink reads ~100 f/s) and only in Detailed — drops here are the client shedding backlog. + // A muted mic reads 0 and drops the line; the mute has its own always-on badge instead, so + // this stays a throughput readout rather than doubling as a mute indicator. if detailed && (s.mic_sent > 0 || s.mic_dropped > 0) { text.push_str(&format!("\nmic {} f/s", s.mic_sent)); if s.mic_dropped > 0 { diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index ca1c3634..3b28fbe5 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -99,7 +99,20 @@ claims a sink advertising exactly that many channels, so applications produce re from a stereo endpoint is an upmix, not new channels. Offered everywhere except the Decky plugin. **Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the -Apple app.* Sends this device's microphone to the host's virtual mic. +Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the +row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending +anything — see [Muting your microphone](/docs/input#muting-your-microphone). + +**Echo cancellation** — *default: on.* Stops the host's audio, playing out of this device's +speakers, from being picked up by the microphone and sent straight back. It hands the microphone +to the system's own canceller rather than doing the work itself: on **Linux** that means capturing +from an echo-cancelled PipeWire source when your desktop provides one, on **Windows** asking WASAPI +for the Communications stream category so the endpoint's processing engages, and on **Apple** and +**Android** the platform's voice-processing mode. Turn it off if your microphone already runs its +own processing, or if the canceller makes your voice sound thin. The row sits under the microphone +toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and +console-home clients; Decky has no toggle. What it can and can't fix is in +[Why do I hear myself](/docs/echo). **Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index db20455f..f403f3ba 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -240,6 +240,7 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last. | | `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). | | `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. | +| `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | ## Bitrate diff --git a/docs-site/content/docs/echo.md b/docs-site/content/docs/echo.md index 14102c58..59acbc14 100644 --- a/docs-site/content/docs/echo.md +++ b/docs-site/content/docs/echo.md @@ -14,9 +14,15 @@ phone, tablet or laptop without headphones — its microphone picks that sound b it to the host along with your voice. Everyone in your voice chat hears the game twice, and you hear yourself whenever anything routes the mic back. -**Fix: use headphones on the device you're streaming on.** Newer clients cancel this -automatically (acoustic echo cancellation is rolling out per platform); headphones are the -reliable fix everywhere today. +**Fix: use headphones on the device you're streaming on.** Clients also try to cancel it for +you: **Echo cancellation** in [client settings](/docs/client-settings#audio) is on by default on +Linux, Windows, the console home, the Apple apps and Android, and hands the microphone to the +system's own canceller. How much it removes depends on the device — a laptop with a good array +mic can clear it entirely, a cheap USB mic next to a speaker can't — so headphones remain the +reliable fix everywhere. + +If you just need to stop talking for a moment, **Ctrl+Alt+Shift+V** mutes the microphone without +leaving the stream; see [Input](/docs/input#getting-your-input-back). ## "Listen to this device" and app monitoring (Windows hosts) diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index 64c04911..69ef2851 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -21,9 +21,10 @@ window — see [Mouse modes](#mouse-modes) below. | **Ctrl+Alt+Shift+M** | Switch the mouse mode (capture ⇄ desktop) | | **Ctrl+Alt+Shift+D** | Disconnect | | **Ctrl+Alt+Shift+S** | Cycle the [stats overlay](/docs/stats) — off · compact · normal · detailed | +| **Ctrl+Alt+Shift+V** | Mute or unmute your microphone | | **F11** or **Alt+Enter** | Toggle fullscreen | -While input is released the session window prints the list over the stream: +While input is released the session window prints the shortest list over the stream: ```text Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+M mouse mode · @@ -31,7 +32,25 @@ Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats ``` With a controller in use the same hint names the controller chord instead of the mouse-mode and -stats entries. +stats entries. The full list is always available without a stream running — see below. + +### Muting your microphone + +**Ctrl+Alt+Shift+V** stops sending your microphone to the host, and pressing it again resumes. +The uplink itself keeps running underneath, so unmuting is instant rather than a second of the +device warming back up. + +While you are muted a **Microphone muted** badge sits in the top-right corner of the stream. It +is deliberately separate from the [stats overlay](/docs/stats): it shows even with stats off, +because "am I still muted?" is a question you ask ten minutes later. + +The mute lasts for that stream only — the next session starts unmuted, and nothing is written to +your settings. If the stream isn't sending a microphone at all (**Stream microphone** off in +[client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears, +rather than pretending to mute something. + +This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute +shortcut yet; turn **Stream microphone** off in their settings instead. Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked for with the chord stays released until you opt back in. Either way, keys and buttons you were @@ -39,11 +58,13 @@ holding are released on the host, so nothing sticks down. You can look the shortcuts up again without a stream running: the Linux client has **Keyboard Shortcuts** in its main menu, and the Windows client has a **Shortcuts** screen reached from its -host list. +host list. Both list the microphone mute; the in-stream hint over the video doesn't, to keep it +to one readable line. ### On the other clients -- **macOS** honours the same four combos, written **⌃⌥⇧Q / M / D / S**. **⌘⎋** also toggles capture, +- **macOS** honours the release, mouse-mode, disconnect and stats combos, written + **⌃⌥⇧Q / M / D / S** — but not the microphone mute. **⌘⎋** also toggles capture, **⌃⌘F** toggles fullscreen, and **⌃⌥⇧C** starts or stops [clipboard sharing](/docs/clipboard). The **Stream** menu lists them all except the mouse-mode combo, which works but has no menu item. - **iPhone and iPad** with a hardware keyboard: **⌃⌥⇧Q** releases input while it is captured, and diff --git a/docs-site/content/docs/stats.md b/docs-site/content/docs/stats.md index a864395c..ce7b83da 100644 --- a/docs-site/content/docs/stats.md +++ b/docs-site/content/docs/stats.md @@ -40,7 +40,7 @@ in-stream: | Android · iPhone | a **three-finger tap** | **Ctrl+Alt+Shift+S** is one of a small set of shortcuts a stream reserves; the others — release -captured input, switch mouse mode, disconnect — are in +captured input, switch mouse mode, disconnect, mute the microphone — are in [Getting your input back](/docs/input#getting-your-input-back). **Compact** is a one-line pill (fps · end-to-end ms · Mb/s, plus a loss flag when frames are being