Merge branch 'audio/desktop-mute-and-aec-toggle' into audio/mic-latency-echo
This commit is contained in:
@@ -1014,6 +1014,12 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
||||
<property name="accelerator"><Control><Alt><Shift>s</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsShortcut">
|
||||
<property name="title">Mute or unmute your microphone (only while the stream sends one)</property>
|
||||
<property name="accelerator"><Control><Alt><Shift>v</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,12 +339,22 @@ pub struct MicStreamer {
|
||||
}
|
||||
|
||||
impl MicStreamer {
|
||||
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
|
||||
/// `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<NativeClient>,
|
||||
muted: Arc<std::sync::atomic::AtomicBool>,
|
||||
echo_cancel: bool,
|
||||
) -> Result<MicStreamer> {
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
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<u8>,
|
||||
/// The in-stream mute (B4), flipped by the session's chord. Read per callback.
|
||||
muted: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
/// `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<String> {
|
||||
fn mic_capture_target(echo_cancel: bool) -> Option<String> {
|
||||
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<String> {
|
||||
fn mic_thread(
|
||||
connector: &Arc<NativeClient>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
muted: Arc<std::sync::atomic::AtomicBool>,
|
||||
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<f32> = ud.ring.drain(..MIC_FRAME * MIC_CHANNELS).collect();
|
||||
|
||||
@@ -233,13 +233,23 @@ pub struct MicStreamer {
|
||||
}
|
||||
|
||||
impl MicStreamer {
|
||||
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
|
||||
/// `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<NativeClient>,
|
||||
muted: Arc<AtomicBool>,
|
||||
echo_cancel: bool,
|
||||
) -> Result<MicStreamer> {
|
||||
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<NativeClient>, stop: Arc<AtomicBool>) -> 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<NativeClient>,
|
||||
stop: Arc<AtomicBool>,
|
||||
muted: Arc<AtomicBool>,
|
||||
echo_cancel: bool,
|
||||
) -> Result<()> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.context("CoInitializeEx (MTA)")?;
|
||||
@@ -289,8 +313,9 @@ fn mic_thread(connector: &Arc<NativeClient>, stop: Arc<AtomicBool>) -> 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<NativeClient>, stop: Arc<AtomicBool>) -> 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<f32> = ring.drain(..MIC_FRAME).collect();
|
||||
|
||||
@@ -62,6 +62,8 @@ pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mic_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub echo_cancel: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub touch_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mouse_mode: Option<String>,
|
||||
@@ -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() {
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct SessionParams {
|
||||
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// 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<AtomicBool>,
|
||||
live: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
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<bool> {
|
||||
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<AtomicBool> {
|
||||
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<SessionEvent>,
|
||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// 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<SessionEvent>,
|
||||
frame_tx: async_channel::Sender<DecodedFrame>,
|
||||
stop: Arc<AtomicBool>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
@@ -48,6 +48,9 @@ struct Drawn {
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
/// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user