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:
2026-06-24 13:14:01 +02:00
parent 65736df464
commit 2baefddbe4
5 changed files with 310 additions and 18 deletions

View File

@@ -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)
{