Files
voice-cat/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs
Talon 2baefddbe4 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>
2026-06-24 13:14:01 +02:00

575 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.ComponentModel;
using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Edits audio input settings: device selection, transmission mode (VAD/PTT/always-on),
/// VAD sensitivity, mic gain, and PTT key. Changes are applied live to the client for
/// immediate feedback; Cancel reverts them.
/// </summary>
public sealed class AudioSettingsForm : Form
{
private readonly VoiceCatClient _client;
private readonly VoiceSettings _settings;
private readonly uint _micStreamId;
// Aux-stream live-apply callbacks, supplied by MainForm (which owns the aux capture). Null
// when not connected — the controls still edit settings, just without live effect.
private readonly Action<bool>? _applyAuxEnabled;
private readonly Action<string?>? _applyAuxDevice;
private readonly Action<float>? _applyAuxGain;
// Snapshot of original values so Cancel can restore them
private readonly string? _origDeviceId;
private readonly VcInputMode _origMode;
private readonly int _origVadSlider;
private readonly int _origMicGain;
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;
private readonly ComboBox _cboDevice;
private readonly Button _btnRefresh;
private readonly RadioButton _radioVad;
private readonly RadioButton _radioPtt;
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;
private readonly CheckBox _chkNoiseReduction;
private readonly CheckBox _chkStereoMic;
private readonly CheckBox _chkAux;
private readonly Label _lblAuxDevice;
private readonly ComboBox _cboAuxDevice;
private readonly Button _btnAuxRefresh;
private readonly Label _lblAuxGain;
private readonly TrackBar _trkAuxGain;
private Keys _pttKey;
public AudioSettingsForm(VoiceCatClient client, VoiceSettings settings, uint micStreamId,
Action<bool>? applyAuxEnabled = null, Action<string?>? applyAuxDevice = null,
Action<float>? applyAuxGain = null)
{
_client = client;
_settings = settings;
_micStreamId = micStreamId;
_applyAuxEnabled = applyAuxEnabled;
_applyAuxDevice = applyAuxDevice;
_applyAuxGain = applyAuxGain;
_pttKey = (Keys)settings.PttKey;
_origDeviceId = settings.InputDeviceId;
_origMode = (VcInputMode)settings.InputMode;
_origVadSlider = settings.VadThresholdSlider;
_origMicGain = settings.MicGain;
_origMicNoiseReduction = settings.MicNoiseReduction;
_origStereoMic = settings.StereoMic;
_origPttKey = _pttKey;
_origSystemWidePtt = settings.SystemWidePtt;
_origAuxEnabled = settings.AuxEnabled;
_origAuxDeviceId = settings.AuxDeviceId;
_origAuxGain = settings.AuxGain;
Text = "Audio settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(420, 639);
// ── Device row ────────────────────────────────────────────────────────
var lblDevice = new Label
{
Text = "Input &device:",
Location = new Point(12, 16),
AutoSize = true,
};
_cboDevice = new ComboBox
{
Location = new Point(12, 36),
Width = 300,
DropDownStyle = ComboBoxStyle.DropDownList,
DisplayMember = "Name",
ValueMember = "Id",
AccessibleName = "Input device",
AccessibleDescription = "Select which microphone or audio input device to use.",
};
_cboDevice.SelectedIndexChanged += CboDevice_SelectedIndexChanged;
_btnRefresh = new Button
{
Text = "&Refresh",
Location = new Point(320, 34),
Size = new Size(80, 26),
};
_btnRefresh.Click += (_, _) => LoadDevices();
// ── Transmission mode ─────────────────────────────────────────────────
var lblMode = new Label
{
Text = "Transmission mode:",
Location = new Point(12, 76),
AutoSize = true,
};
_radioVad = new RadioButton
{
Text = "&Voice activation",
Location = new Point(20, 96),
AutoSize = true,
};
_radioVad.CheckedChanged += RadioMode_CheckedChanged;
_lblSensitivity = new Label
{
Text = "Sensitivity:",
Location = new Point(36, 122),
AutoSize = true,
};
_trkVad = new TrackBar
{
Location = new Point(36, 140),
Size = new Size(200, 45),
Minimum = 1,
Maximum = 100,
TickFrequency = 10,
SmallChange = 1,
LargeChange = 10,
Value = Math.Clamp(settings.VadThresholdSlider, 1, 100),
};
_trkVad.AccessibleName = "VAD sensitivity";
_trkVad.AccessibleDescription =
"Voice detection sensitivity. Higher = more sensitive. Range 1100.";
_trkVad.Scroll += TrkVad_Scroll;
_radioPtt = new RadioButton
{
Text = "&Push to talk",
Location = new Point(20, 192),
AutoSize = true,
};
_radioPtt.CheckedChanged += RadioMode_CheckedChanged;
_lblPttKey = new Label
{
Location = new Point(140, 194),
AutoSize = true,
};
_btnChangePtt = new Button
{
Text = "Change key...",
Location = new Point(200, 189),
Size = new Size(105, 26),
};
_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, 252),
AutoSize = true,
};
_radioAlwaysOn.CheckedChanged += RadioMode_CheckedChanged;
// ── Mic gain ──────────────────────────────────────────────────────────
var lblGain = new Label
{
Text = "Microphone &volume:",
Location = new Point(12, 296),
AutoSize = true,
};
_trkGain = new TrackBar
{
Location = new Point(12, 316),
Size = new Size(200, 45),
Minimum = 0,
Maximum = 400,
TickFrequency = 25,
SmallChange = 5,
LargeChange = 25,
Value = Math.Clamp(settings.MicGain, 0, 400),
};
_trkGain.AccessibleName = "Microphone volume";
_trkGain.AccessibleDescription =
"Boost a quiet microphone. 100 is unity gain; range 0400 percent.";
_trkGain.Scroll += TrkGain_Scroll;
// ── Mic noise reduction ───────────────────────────────────────────────
// Send-side RNNoise denoise of the mic stream. MIC-only (the core's NR runs before
// the input gain and VAD/PTT gate; aux/screen are excluded). One pass for all listeners.
_chkNoiseReduction = new CheckBox
{
Text = "Noise &reduction (RNNoise)",
Location = new Point(12, 368),
AutoSize = true,
Checked = settings.MicNoiseReduction,
AccessibleName = "Microphone noise reduction",
AccessibleDescription =
"RNNoise denoising of your microphone. Cleans your signal for everyone listening.",
};
_chkNoiseReduction.CheckedChanged += ChkNoiseReduction_CheckedChanged;
// ── Stereo microphone ─────────────────────────────────────────────────
// Opens the mic capture device in stereo (interleaved L/R) instead of mono. Real stereo
// only reaches the wire on a stereo channel; the core folds a stereo mic to mono on a mono
// channel. Toggling while connected restarts the capture device (vc_audio_restart) so the
// new channel count takes effect immediately.
_chkStereoMic = new CheckBox
{
Text = "&Stereo microphone",
Location = new Point(12, 392),
AutoSize = true,
Checked = settings.StereoMic,
AccessibleName = "Stereo microphone",
AccessibleDescription =
"Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.",
};
_chkStereoMic.CheckedChanged += ChkStereoMic_CheckedChanged;
// ── Aux input stream ──────────────────────────────────────────────────
// A second outgoing stream from another hardware input device (e.g. line-in / aux),
// captured client-side and fed to the core. Device + volume only — aux is always-on
// (the core never gates AUX_DEVICE on VAD/PTT).
_chkAux = new CheckBox
{
Text = "&Aux stream (second input device)",
Location = new Point(12, 432),
AutoSize = true,
Checked = settings.AuxEnabled,
AccessibleName = "Enable aux input stream",
AccessibleDescription =
"Transmit a second hardware input device alongside your microphone.",
};
_chkAux.CheckedChanged += ChkAux_CheckedChanged;
_lblAuxDevice = new Label
{
Text = "Aux d&evice:",
Location = new Point(12, 462),
AutoSize = true,
};
_cboAuxDevice = new ComboBox
{
Location = new Point(12, 482),
Width = 300,
DropDownStyle = ComboBoxStyle.DropDownList,
DisplayMember = "Name",
ValueMember = "Id",
AccessibleName = "Aux input device",
AccessibleDescription = "Select the second audio input device to transmit.",
};
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
_btnAuxRefresh = new Button
{
Text = "Re&fresh",
Location = new Point(320, 480),
Size = new Size(80, 26),
};
_btnAuxRefresh.Click += (_, _) => LoadAuxDevices();
_lblAuxGain = new Label
{
Text = "Aux vo&lume:",
Location = new Point(12, 518),
AutoSize = true,
};
_trkAuxGain = new TrackBar
{
Location = new Point(12, 538),
Size = new Size(200, 45),
Minimum = 0,
Maximum = 400,
TickFrequency = 25,
SmallChange = 5,
LargeChange = 25,
Value = Math.Clamp(settings.AuxGain, 0, 400),
};
_trkAuxGain.AccessibleName = "Aux volume";
_trkAuxGain.AccessibleDescription =
"Volume of the aux input stream. 100 is unity gain; range 0400 percent.";
_trkAuxGain.Scroll += TrkAuxGain_Scroll;
// ── OK / Cancel ───────────────────────────────────────────────────────
var btnOk = new Button
{
Text = "&OK",
DialogResult = DialogResult.OK,
Location = new Point(228, 600),
Size = new Size(80, 27),
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(316, 600),
Size = new Size(80, 27),
};
AcceptButton = btnOk;
CancelButton = btnCancel;
btnOk.Click += (_, _) =>
{
_settings.Save();
};
FormClosing += AudioSettingsForm_FormClosing;
Controls.AddRange([
lblDevice, _cboDevice, _btnRefresh,
lblMode, _radioVad, _lblSensitivity, _trkVad,
_radioPtt, _lblPttKey, _btnChangePtt, _chkSystemWidePtt, _radioAlwaysOn,
lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic,
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
btnOk, btnCancel,
]);
// Apply saved mode (fires RadioMode_CheckedChanged which sets visibility)
switch ((VcInputMode)settings.InputMode)
{
case VcInputMode.PushToTalk: _radioPtt.Checked = true; break;
case VcInputMode.AlwaysOn: _radioAlwaysOn.Checked = true; break;
default: _radioVad.Checked = true; break;
}
LoadDevices();
LoadAuxDevices();
UpdateAuxControlsEnabled();
}
private void LoadDevices()
{
string? currentId = (_cboDevice.SelectedItem as DeviceInfo)?.Id ?? _settings.InputDeviceId;
var devices = _client.ListDevices(VcDeviceKind.Input);
_cboDevice.SelectedIndexChanged -= CboDevice_SelectedIndexChanged;
_cboDevice.DataSource = new BindingList<DeviceInfo>(devices);
_cboDevice.SelectedIndexChanged += CboDevice_SelectedIndexChanged;
// Try to restore previous selection
int idx = -1;
if (currentId is not null)
idx = devices.FindIndex(d => d.Id == currentId);
if (idx < 0)
idx = devices.FindIndex(d => d.IsDefault);
_cboDevice.SelectedIndex = idx >= 0 ? idx : (devices.Count > 0 ? 0 : -1);
}
private void CboDevice_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_cboDevice.SelectedItem is not DeviceInfo dev) return;
_settings.InputDeviceId = dev.IsDefault ? null : dev.Id;
if (_micStreamId != 0)
_client.SetInputDevice(_micStreamId, dev.IsDefault ? null : dev.Id);
}
// ── Aux input stream ──────────────────────────────────────────────────────
private void LoadAuxDevices()
{
string? currentId = (_cboAuxDevice.SelectedItem as InputDeviceInfo)?.Id
?? _settings.AuxDeviceId;
var devices = InputDeviceEnumerator.List().ToList();
// Set the data source AND restore the selection while unsubscribed so neither the initial
// populate nor a Refresh fires a spurious device-change (which would restart the capture).
_cboAuxDevice.SelectedIndexChanged -= CboAuxDevice_SelectedIndexChanged;
_cboAuxDevice.DataSource = new BindingList<InputDeviceInfo>(devices);
int idx = -1;
if (currentId is not null)
idx = devices.FindIndex(d => d.Id == currentId);
if (idx < 0)
idx = devices.FindIndex(d => d.IsDefault);
_cboAuxDevice.SelectedIndex = idx >= 0 ? idx : (devices.Count > 0 ? 0 : -1);
_cboAuxDevice.SelectedIndexChanged += CboAuxDevice_SelectedIndexChanged;
// Persist the resolved selection (null = default) so Join Voice opens the same device.
if (_cboAuxDevice.SelectedItem is InputDeviceInfo dev)
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
}
private void ChkAux_CheckedChanged(object? sender, EventArgs e)
{
_settings.AuxEnabled = _chkAux.Checked;
UpdateAuxControlsEnabled();
_applyAuxEnabled?.Invoke(_chkAux.Checked);
}
private void CboAuxDevice_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_cboAuxDevice.SelectedItem is not InputDeviceInfo dev) return;
_settings.AuxDeviceId = dev.IsDefault ? null : dev.Id;
if (_chkAux.Checked)
_applyAuxDevice?.Invoke(dev.IsDefault ? null : dev.Id);
}
private void TrkAuxGain_Scroll(object? sender, EventArgs e)
{
_settings.AuxGain = _trkAuxGain.Value;
if (_chkAux.Checked)
_applyAuxGain?.Invoke(_trkAuxGain.Value / 100f);
}
private void UpdateAuxControlsEnabled()
{
bool on = _chkAux.Checked;
_lblAuxDevice.Enabled = on;
_cboAuxDevice.Enabled = on;
_btnAuxRefresh.Enabled = on;
_lblAuxGain.Enabled = on;
_trkAuxGain.Enabled = on;
}
private void RadioMode_CheckedChanged(object? sender, EventArgs e)
{
var mode = CurrentMode();
bool isVad = mode == VcInputMode.VoiceActivation;
bool isPtt = mode == VcInputMode.PushToTalk;
_lblSensitivity.Visible = isVad;
_trkVad.Visible = isVad;
_lblPttKey.Visible = isPtt;
_btnChangePtt.Visible = isPtt;
_chkSystemWidePtt.Visible = isPtt;
UpdatePttKeyLabel();
_settings.InputMode = (int)mode;
if (_micStreamId != 0)
{
_client.SetInputMode(mode);
if (isVad) _client.SetVadThreshold(VadThresholdFromSlider());
if (isPtt) _client.SetPushToTalk(false);
}
}
private void TrkVad_Scroll(object? sender, EventArgs e)
{
_settings.VadThresholdSlider = _trkVad.Value;
if (_micStreamId != 0 && CurrentMode() == VcInputMode.VoiceActivation)
_client.SetVadThreshold(VadThresholdFromSlider());
}
private void TrkGain_Scroll(object? sender, EventArgs e)
{
_settings.MicGain = _trkGain.Value;
if (_micStreamId != 0)
_client.SetInputGain(_trkGain.Value / 100f);
}
private void ChkNoiseReduction_CheckedChanged(object? sender, EventArgs e)
{
_settings.MicNoiseReduction = _chkNoiseReduction.Checked;
if (_micStreamId != 0)
_client.SetInputNoiseReduction(_chkNoiseReduction.Checked);
}
private void ChkStereoMic_CheckedChanged(object? sender, EventArgs e)
{
_settings.StereoMic = _chkStereoMic.Checked;
// Channel count only takes effect when the capture device (re)starts, so restart it live.
if (_micStreamId != 0)
{
_client.SetCaptureChannels(_micStreamId, _chkStereoMic.Checked ? 2u : 1u);
_client.AudioRestart();
}
}
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);
if (dlg.ShowDialog(this) == DialogResult.OK)
{
_pttKey = dlg.CapturedKey;
_settings.PttKey = (int)_pttKey;
UpdatePttKeyLabel();
}
}
private void AudioSettingsForm_FormClosing(object? sender, FormClosingEventArgs e)
{
if (DialogResult == DialogResult.OK) return;
// Cancel: restore originals to settings and live client
_settings.InputDeviceId = _origDeviceId;
_settings.InputMode = (int)_origMode;
_settings.VadThresholdSlider = _origVadSlider;
_settings.MicGain = _origMicGain;
_settings.MicNoiseReduction = _origMicNoiseReduction;
_settings.StereoMic = _origStereoMic;
_settings.PttKey = (int)_origPttKey;
_settings.SystemWidePtt = _origSystemWidePtt;
if (_micStreamId != 0)
{
_client.SetInputDevice(_micStreamId, _origDeviceId);
_client.SetInputMode(_origMode);
if (_origMode == VcInputMode.VoiceActivation)
_client.SetVadThreshold(0.1f * (1f - (_origVadSlider - 1f) / 99f));
_client.SetInputGain(_origMicGain / 100f);
_client.SetInputNoiseReduction(_origMicNoiseReduction);
// Restore capture channel count; restart the device only if it actually changed.
if (_origStereoMic != _chkStereoMic.Checked)
{
_client.SetCaptureChannels(_micStreamId, _origStereoMic ? 2u : 1u);
_client.AudioRestart();
}
}
// Aux: restore originals to settings and re-apply live (order: device + gain first so a
// re-enable starts with the right configuration).
_settings.AuxEnabled = _origAuxEnabled;
_settings.AuxDeviceId = _origAuxDeviceId;
_settings.AuxGain = _origAuxGain;
_applyAuxDevice?.Invoke(_origAuxDeviceId);
_applyAuxGain?.Invoke(_origAuxGain / 100f);
_applyAuxEnabled?.Invoke(_origAuxEnabled);
}
private void UpdatePttKeyLabel() =>
_lblPttKey.Text = $"({_pttKey})";
private VcInputMode CurrentMode() =>
_radioPtt.Checked ? VcInputMode.PushToTalk :
_radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
VcInputMode.VoiceActivation;
private float VadThresholdFromSlider() =>
0.1f * (1f - (_trkVad.Value - 1f) / 99f);
}