From 7ec3107b4ae6e7256b5f98a6088d8d4c51bf5e61 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:07:00 +0200 Subject: [PATCH] feat(client): the mic has an off switch, and echo cancellation has a switch too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Alt+Shift+V mutes and unmutes the microphone mid-stream — V for voice, since M and S were taken. The uplink keeps running while muted: `MicStreamer::spawn` takes a shared AtomicBool the capture callback reads every quantum, and a muted callback drains whole frames and sends nothing. Stopping the stream instead would have re-primed the device buffers and, on Linux, re-run source selection on every unmute — a second of glitch for a key people press mid-sentence. The sequence counter deliberately does NOT advance while muted, so the host sees one continuous sequence with a pause rather than a gap the size of the mute, which its de-jitter would try to conceal frame by frame (its 600 ms stale-flush covers the rest). The mute lives on SessionHandle as a MicControl with two flags, not one: `live` is raised by the pump only once the uplink is actually running, so a session with the mic off in Settings — or whose capture device wouldn't open — reports "nothing to mute", the chord says so in the log, and no indicator appears. Per session, never persisted. Muted state draws as a persistent "Microphone muted" badge in the stream's top-right corner, off `FrameCtx::mic_muted` rather than the stats text: it has to be there with the stats overlay Off, which is where most people leave it. The Detailed mic line still reads throughput, so it simply falls to zero — the badge is what answers "am I muted". Echo cancellation stops being an env-only lever. `Settings::echo_cancel` (default on, `#[serde(default)]` so every stored file loads with it on) now gates the same hooks PUNKTFUNK_NO_AEC gated: the echo-cancelled PipeWire source preference and WASAPI's Communications stream category. The env var still wins, one-way — it can only turn AEC off, never back on — and both `aec_enabled` helpers say so. The row ships in the GTK, WinUI and console settings, under the microphone toggle and greyed out while it is off, matching what Apple and Android shipped in wave 1. SettingsOverlay grows `echo_cancel` as a first-class field — apply, absorb, clear, is_empty — instead of riding the `extra` passthrough, where `clear_override("echo_cancel")` answered false. The JSON key is the one Apple and Android already write, so one catalog round-trips through all three. Co-Authored-By: Claude Fable 5 --- clients/linux/src/app.rs | 6 ++ clients/linux/src/ui_settings.rs | 31 +++++- clients/session/src/main.rs | 1 + clients/windows/src/app/help.rs | 4 + clients/windows/src/app/settings.rs | 33 ++++++- crates/pf-client-core/src/audio.rs | 50 ++++++++-- crates/pf-client-core/src/audio_wasapi.rs | 45 ++++++++- crates/pf-client-core/src/profiles.rs | 44 +++++++++ crates/pf-client-core/src/session.rs | 99 +++++++++++++++++++- crates/pf-client-core/src/trust.rs | 13 +++ crates/pf-console-ui/src/screens/settings.rs | 65 ++++++++++++- crates/pf-console-ui/src/skia_overlay.rs | 59 +++++++++++- crates/pf-console-ui/src/widgets.rs | 8 +- crates/pf-presenter/src/overlay.rs | 5 + crates/pf-presenter/src/run.rs | 31 +++++- docs-site/content/docs/client-settings.md | 15 ++- docs-site/content/docs/configuration.md | 1 + docs-site/content/docs/echo.md | 12 ++- docs-site/content/docs/input.md | 29 +++++- docs-site/content/docs/stats.md | 2 +- 20 files changed, 511 insertions(+), 42 deletions(-) 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