feat(windows): system-wide push-to-talk via Raw Input
PTT was focus-scoped (WinForms KeyDown/KeyUp) so it died the moment the window lost focus. Add an optional system-wide path using the Raw Input API (RegisterRawInputDevices + WM_INPUT with RIDEV_INPUTSINK) instead of a WH_KEYBOARD_LL low-level hook -- the latter is the keylogger pattern AV heuristics flag, which is worse for our unsigned MinGW binary. Raw Input involves no DLL injection or global hook and passes keys through. - New VoiceCat.App/Native/RawInput.cs: P/Invoke + structs; register the keyboard sink, decode WM_INPUT to vkey/up-down, GetAsyncKeyState helper. - MainForm overrides OnHandleCreated/OnHandleDestroyed/WndProc to manage the sink and route WM_INPUT to PTT; gates the focus-scoped KeyDown/KeyUp off when system-wide is on; makes the Deactivate force-release conditional; adds a GetAsyncKeyState watchdog on the pump timer so a missed key-up (RDP/lock-screen) cannot leave PTT stuck. - VoiceSettings.SystemWidePtt (default ON) + system-wide checkbox in the Audio settings PTT section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ public sealed class AudioSettingsForm : Form
|
||||
private readonly bool _origMicNoiseReduction;
|
||||
private readonly bool _origStereoMic;
|
||||
private readonly Keys _origPttKey;
|
||||
private readonly bool _origSystemWidePtt;
|
||||
private readonly bool _origAuxEnabled;
|
||||
private readonly string? _origAuxDeviceId;
|
||||
private readonly int _origAuxGain;
|
||||
@@ -41,6 +42,7 @@ public sealed class AudioSettingsForm : Form
|
||||
private readonly RadioButton _radioAlwaysOn;
|
||||
private readonly Label _lblPttKey;
|
||||
private readonly Button _btnChangePtt;
|
||||
private readonly CheckBox _chkSystemWidePtt;
|
||||
private readonly Label _lblSensitivity;
|
||||
private readonly TrackBar _trkVad;
|
||||
private readonly TrackBar _trkGain;
|
||||
@@ -74,6 +76,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_origMicNoiseReduction = settings.MicNoiseReduction;
|
||||
_origStereoMic = settings.StereoMic;
|
||||
_origPttKey = _pttKey;
|
||||
_origSystemWidePtt = settings.SystemWidePtt;
|
||||
_origAuxEnabled = settings.AuxEnabled;
|
||||
_origAuxDeviceId = settings.AuxDeviceId;
|
||||
_origAuxGain = settings.AuxGain;
|
||||
@@ -84,7 +87,7 @@ public sealed class AudioSettingsForm : Form
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(420, 611);
|
||||
ClientSize = new Size(420, 639);
|
||||
|
||||
// ── Device row ────────────────────────────────────────────────────────
|
||||
var lblDevice = new Label
|
||||
@@ -174,10 +177,25 @@ public sealed class AudioSettingsForm : Form
|
||||
};
|
||||
_btnChangePtt.Click += BtnChangePtt_Click;
|
||||
|
||||
// Indented PTT sub-option: observe the key system-wide (Raw Input) so it works while
|
||||
// another app is focused. Visible only in PTT mode (RadioMode_CheckedChanged).
|
||||
_chkSystemWidePtt = new CheckBox
|
||||
{
|
||||
Text = "Wor&ks in the background (system-wide)",
|
||||
Location = new Point(36, 218),
|
||||
AutoSize = true,
|
||||
Checked = settings.SystemWidePtt,
|
||||
AccessibleName = "System-wide push-to-talk",
|
||||
AccessibleDescription =
|
||||
"Let push-to-talk work while another application is focused. Uses the Windows Raw " +
|
||||
"Input API, not a keyboard hook.",
|
||||
};
|
||||
_chkSystemWidePtt.CheckedChanged += ChkSystemWidePtt_CheckedChanged;
|
||||
|
||||
_radioAlwaysOn = new RadioButton
|
||||
{
|
||||
Text = "A&lways on",
|
||||
Location = new Point(20, 224),
|
||||
Location = new Point(20, 252),
|
||||
AutoSize = true,
|
||||
};
|
||||
_radioAlwaysOn.CheckedChanged += RadioMode_CheckedChanged;
|
||||
@@ -186,12 +204,12 @@ public sealed class AudioSettingsForm : Form
|
||||
var lblGain = new Label
|
||||
{
|
||||
Text = "Microphone &volume:",
|
||||
Location = new Point(12, 268),
|
||||
Location = new Point(12, 296),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkGain = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 288),
|
||||
Location = new Point(12, 316),
|
||||
Size = new Size(200, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 400,
|
||||
@@ -211,7 +229,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_chkNoiseReduction = new CheckBox
|
||||
{
|
||||
Text = "Noise &reduction (RNNoise)",
|
||||
Location = new Point(12, 340),
|
||||
Location = new Point(12, 368),
|
||||
AutoSize = true,
|
||||
Checked = settings.MicNoiseReduction,
|
||||
AccessibleName = "Microphone noise reduction",
|
||||
@@ -228,7 +246,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_chkStereoMic = new CheckBox
|
||||
{
|
||||
Text = "&Stereo microphone",
|
||||
Location = new Point(12, 364),
|
||||
Location = new Point(12, 392),
|
||||
AutoSize = true,
|
||||
Checked = settings.StereoMic,
|
||||
AccessibleName = "Stereo microphone",
|
||||
@@ -244,7 +262,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_chkAux = new CheckBox
|
||||
{
|
||||
Text = "&Aux stream (second input device)",
|
||||
Location = new Point(12, 404),
|
||||
Location = new Point(12, 432),
|
||||
AutoSize = true,
|
||||
Checked = settings.AuxEnabled,
|
||||
AccessibleName = "Enable aux input stream",
|
||||
@@ -256,12 +274,12 @@ public sealed class AudioSettingsForm : Form
|
||||
_lblAuxDevice = new Label
|
||||
{
|
||||
Text = "Aux d&evice:",
|
||||
Location = new Point(12, 434),
|
||||
Location = new Point(12, 462),
|
||||
AutoSize = true,
|
||||
};
|
||||
_cboAuxDevice = new ComboBox
|
||||
{
|
||||
Location = new Point(12, 454),
|
||||
Location = new Point(12, 482),
|
||||
Width = 300,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
DisplayMember = "Name",
|
||||
@@ -274,7 +292,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_btnAuxRefresh = new Button
|
||||
{
|
||||
Text = "Re&fresh",
|
||||
Location = new Point(320, 452),
|
||||
Location = new Point(320, 480),
|
||||
Size = new Size(80, 26),
|
||||
};
|
||||
_btnAuxRefresh.Click += (_, _) => LoadAuxDevices();
|
||||
@@ -282,12 +300,12 @@ public sealed class AudioSettingsForm : Form
|
||||
_lblAuxGain = new Label
|
||||
{
|
||||
Text = "Aux vo&lume:",
|
||||
Location = new Point(12, 490),
|
||||
Location = new Point(12, 518),
|
||||
AutoSize = true,
|
||||
};
|
||||
_trkAuxGain = new TrackBar
|
||||
{
|
||||
Location = new Point(12, 510),
|
||||
Location = new Point(12, 538),
|
||||
Size = new Size(200, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 400,
|
||||
@@ -306,14 +324,14 @@ public sealed class AudioSettingsForm : Form
|
||||
{
|
||||
Text = "&OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(228, 572),
|
||||
Location = new Point(228, 600),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(316, 572),
|
||||
Location = new Point(316, 600),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
|
||||
@@ -329,7 +347,7 @@ public sealed class AudioSettingsForm : Form
|
||||
Controls.AddRange([
|
||||
lblDevice, _cboDevice, _btnRefresh,
|
||||
lblMode, _radioVad, _lblSensitivity, _trkVad,
|
||||
_radioPtt, _lblPttKey, _btnChangePtt, _radioAlwaysOn,
|
||||
_radioPtt, _lblPttKey, _btnChangePtt, _chkSystemWidePtt, _radioAlwaysOn,
|
||||
lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic,
|
||||
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
|
||||
btnOk, btnCancel,
|
||||
@@ -443,6 +461,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_trkVad.Visible = isVad;
|
||||
_lblPttKey.Visible = isPtt;
|
||||
_btnChangePtt.Visible = isPtt;
|
||||
_chkSystemWidePtt.Visible = isPtt;
|
||||
UpdatePttKeyLabel();
|
||||
|
||||
_settings.InputMode = (int)mode;
|
||||
@@ -486,6 +505,11 @@ public sealed class AudioSettingsForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
private void ChkSystemWidePtt_CheckedChanged(object? sender, EventArgs e) =>
|
||||
// No live effect here — MainForm reads SystemWidePtt after the dialog closes and
|
||||
// registers/unregisters the Raw Input keyboard sink accordingly.
|
||||
_settings.SystemWidePtt = _chkSystemWidePtt.Checked;
|
||||
|
||||
private void BtnChangePtt_Click(object? sender, EventArgs e)
|
||||
{
|
||||
using var dlg = new PttKeyCaptureDialog(_pttKey);
|
||||
@@ -509,6 +533,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_settings.MicNoiseReduction = _origMicNoiseReduction;
|
||||
_settings.StereoMic = _origStereoMic;
|
||||
_settings.PttKey = (int)_origPttKey;
|
||||
_settings.SystemWidePtt = _origSystemWidePtt;
|
||||
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using VoiceCat.App.Audio;
|
||||
using VoiceCat.App.Models;
|
||||
using VoiceCat.App.Native;
|
||||
using VoiceCat.App.Notifications;
|
||||
using VoiceCat.Interop;
|
||||
|
||||
@@ -32,6 +33,7 @@ public partial class MainForm : Form
|
||||
private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream
|
||||
private Keys _pttKey = Keys.F8;
|
||||
private bool _pttEngaged; // guards the PTT cue against key-repeat
|
||||
private bool _rawInputRegistered; // true while the system-wide PTT keyboard sink is active
|
||||
private bool _serverMuted;
|
||||
private bool _serverDeafened;
|
||||
|
||||
@@ -56,6 +58,7 @@ public partial class MainForm : Form
|
||||
_client.EventReceived += OnEvent;
|
||||
_client.LevelChanged += OnLevelChanged;
|
||||
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
|
||||
_pumpTimer.Tick += (_, _) => PttWatchdog();
|
||||
|
||||
_ownPermissions = SafeGetPermissions();
|
||||
BuildMenus();
|
||||
@@ -88,13 +91,18 @@ public partial class MainForm : Form
|
||||
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
|
||||
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
|
||||
|
||||
// PTT and global hotkeys (focus-scoped — work only while this form has focus)
|
||||
// PTT and global hotkeys. The mute/deafen/screen-share toggles (MainForm_HotkeyDown) are
|
||||
// always focus-scoped. PTT is focus-scoped via KeyDown/KeyUp when system-wide PTT is OFF;
|
||||
// when ON, the WM_INPUT path (WndProc + Raw Input) owns PTT for both focused and unfocused
|
||||
// cases and the KeyDown/KeyUp handlers early-return.
|
||||
KeyDown += MainForm_KeyDown;
|
||||
KeyDown += MainForm_HotkeyDown;
|
||||
KeyUp += MainForm_KeyUp;
|
||||
Deactivate += (_, _) =>
|
||||
{
|
||||
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
||||
// With system-wide PTT we WANT transmission to continue while unfocused, so don't
|
||||
// release on deactivate — the Raw Input key-up (and PttWatchdog) handle release.
|
||||
if (!_voiceSettings.SystemWidePtt && _micStreamId != 0) _client.SetPushToTalk(false);
|
||||
};
|
||||
|
||||
ApplyPersistedVoiceSettings();
|
||||
@@ -185,6 +193,7 @@ public partial class MainForm : Form
|
||||
});
|
||||
dlg.ShowDialog(this);
|
||||
_pttKey = (Keys)_voiceSettings.PttKey;
|
||||
ApplySystemWidePtt(); // the system-wide toggle may have changed
|
||||
};
|
||||
settingsMenu.DropDownItems.Add(miAudio);
|
||||
var miNotifications = new ToolStripMenuItem("&Notifications...");
|
||||
@@ -861,6 +870,7 @@ public partial class MainForm : Form
|
||||
|
||||
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
|
||||
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
if (ActiveControl is TextBox or RichTextBox) return;
|
||||
@@ -903,6 +913,7 @@ public partial class MainForm : Form
|
||||
|
||||
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
|
||||
if (e.KeyCode != _pttKey || _micStreamId == 0) return;
|
||||
_client.SetPushToTalk(false);
|
||||
@@ -910,6 +921,92 @@ public partial class MainForm : Form
|
||||
e.Handled = e.SuppressKeyPress = true;
|
||||
}
|
||||
|
||||
// ── System-wide PTT (Raw Input / WM_INPUT) ────────────────────────────────
|
||||
// When the user enables "system-wide" PTT we observe the PTT key via the Raw Input API so it
|
||||
// works while another app is focused. See VoiceCat.App.Native.RawInput for why this is used in
|
||||
// preference to a low-level keyboard hook (antivirus keylogger heuristics).
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
ApplySystemWidePtt();
|
||||
}
|
||||
|
||||
protected override void OnHandleDestroyed(EventArgs e)
|
||||
{
|
||||
if (_rawInputRegistered)
|
||||
{
|
||||
RawInput.UnregisterKeyboardSink();
|
||||
_rawInputRegistered = false;
|
||||
}
|
||||
base.OnHandleDestroyed(e);
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
if (m.Msg == RawInput.WM_INPUT && _voiceSettings.SystemWidePtt)
|
||||
HandleRawInput(m.LParam);
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
/// <summary>Register or tear down the background keyboard sink to match the current
|
||||
/// <see cref="VoiceSettings.SystemWidePtt"/> setting. Safe to call repeatedly.</summary>
|
||||
private void ApplySystemWidePtt()
|
||||
{
|
||||
if (!IsHandleCreated) return; // OnHandleCreated will (re)apply once the handle exists
|
||||
bool want = _voiceSettings.SystemWidePtt;
|
||||
if (want && !_rawInputRegistered)
|
||||
{
|
||||
_rawInputRegistered = RawInput.RegisterKeyboardSink(Handle);
|
||||
}
|
||||
else if (!want && _rawInputRegistered)
|
||||
{
|
||||
RawInput.UnregisterKeyboardSink();
|
||||
_rawInputRegistered = false;
|
||||
// Release any PTT that was held via Raw Input so it can't stick after switching modes.
|
||||
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRawInput(IntPtr lParam)
|
||||
{
|
||||
if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk || _micStreamId == 0)
|
||||
return;
|
||||
if (!RawInput.TryParseKey(lParam, out ushort vkey, out bool keyUp)) return;
|
||||
if (vkey != (ushort)_pttKey) return;
|
||||
|
||||
if (keyUp)
|
||||
{
|
||||
_client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Key-down. Don't transmit while typing into our OWN text fields — matches the
|
||||
// focus-scoped guard. When VoiceCat is in the background ContainsFocus is false, so the
|
||||
// key still transmits (the whole point of system-wide PTT).
|
||||
if (ContainsFocus && ActiveControl is TextBox or RichTextBox) return;
|
||||
_client.SetPushToTalk(true);
|
||||
if (!_pttEngaged) // first key-down only, not auto-repeat
|
||||
{
|
||||
_pttEngaged = true;
|
||||
_feedback.PlaySound(SoundEvent.Ptt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Watchdog (driven by the pump timer) that releases system-wide PTT if the key-up
|
||||
/// was never observed — e.g. across an RDP or lock-screen focus switch — so PTT can't stick.</summary>
|
||||
private void PttWatchdog()
|
||||
{
|
||||
if (!_pttEngaged || !_voiceSettings.SystemWidePtt || _micStreamId == 0) return;
|
||||
if (!RawInput.IsKeyDown((int)_pttKey))
|
||||
{
|
||||
_client.SetPushToTalk(false);
|
||||
_pttEngaged = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel navigation ────────────────────────────────────────────────────
|
||||
|
||||
private void TvChannels_DoubleClick(object? sender, EventArgs e)
|
||||
|
||||
Reference in New Issue
Block a user