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
+
+
+
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