From 9b0dc11ee61940188c4988c8d2fc446083683a71 Mon Sep 17 00:00:00 2001 From: Mute Date: Mon, 10 Aug 2026 12:07:34 +0000 Subject: [PATCH] v0.3: PTT hotkey capture, mouse support, model params - Replace PTT key dropdown with live key-capture textbox - Use low-level keyboard hook for capture (distinguishes Left/Right Ctrl) - Support mouse buttons as PTT (Left/Right/Middle/X1/X2 via WH_MOUSE_LL) - Filter mouse clicks from capture, allow Left/Right Ctrl - Add Noise/Speed/NoiseW sliders with live labels, reinit on MouseUp - Stream audio via BufferedWaveProvider (no temp WAV files) - Add text input field with Speak button - Fix sentence terminator, UTF-8 marshaling, voice catalogue JSON --- Robovoice.App/MainForm.Designer.cs | 15 ++-- Robovoice.App/MainForm.cs | 98 ++++++++++++++++++---- Robovoice.App/PttHotkey.cs | 126 +++++++++++++++++++++++++---- 3 files changed, 201 insertions(+), 38 deletions(-) diff --git a/Robovoice.App/MainForm.Designer.cs b/Robovoice.App/MainForm.Designer.cs index ce17482..da2c5e7 100644 --- a/Robovoice.App/MainForm.Designer.cs +++ b/Robovoice.App/MainForm.Designer.cs @@ -6,7 +6,7 @@ partial class MainForm private System.ComponentModel.IContainer? components = null; private Label lblPttKey = null!; - private ComboBox cmbPttKey = null!; + private TextBox txtPttKey = null!; private Label lblVoice = null!; private ComboBox cmbVoice = null!; private Button btnTestVoice = null!; @@ -45,7 +45,7 @@ partial class MainForm components = new System.ComponentModel.Container(); lblPttKey = new Label(); - cmbPttKey = new ComboBox(); + txtPttKey = new TextBox(); lblVoice = new Label(); cmbVoice = new ComboBox(); btnTestVoice = new Button(); @@ -80,10 +80,11 @@ partial class MainForm lblPttKey.Size = new Size(60, 23); lblPttKey.TextAlign = ContentAlignment.MiddleLeft; - // cmbPttKey - cmbPttKey.Location = new Point(75, 12); - cmbPttKey.Size = new Size(80, 23); - cmbPttKey.DropDownStyle = ComboBoxStyle.DropDownList; + // txtPttKey + txtPttKey.Location = new Point(75, 12); + txtPttKey.Size = new Size(80, 23); + txtPttKey.ReadOnly = true; + txtPttKey.TextAlign = HorizontalAlignment.Center; // lblVoice lblVoice.Text = "Voice:"; @@ -249,7 +250,7 @@ partial class MainForm AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(824, 509); Controls.Add(lblPttKey); - Controls.Add(cmbPttKey); + Controls.Add(txtPttKey); Controls.Add(lblVoice); Controls.Add(cmbVoice); Controls.Add(btnTestVoice); diff --git a/Robovoice.App/MainForm.cs b/Robovoice.App/MainForm.cs index ce8237c..cb95566 100644 --- a/Robovoice.App/MainForm.cs +++ b/Robovoice.App/MainForm.cs @@ -31,11 +31,11 @@ internal sealed partial class MainForm : Form private async void OnLoad(object? sender, EventArgs e) { - PopulatePttKeys(); PopulateVoices(); PopulateOutputDevices(); - cmbPttKey.SelectedItem = Keys.F8; + _pttKey = Keys.F8; + txtPttKey.Text = KeyToDisplayString(_pttKey); if (cmbVoice.Items.Count > 0) cmbVoice.SelectedIndex = 0; @@ -45,7 +45,8 @@ internal sealed partial class MainForm : Form btnTestVoice.Click += OnTestVoice; btnManageVoices.Click += OnManageVoices; btnClearLog.Click += (_, _) => txtLog.Clear(); - cmbPttKey.SelectedIndexChanged += OnPttKeyChanged; + txtPttKey.Enter += OnPttKeyFocus; + txtPttKey.KeyDown += OnPttKeyDown; cmbOutput.SelectedIndexChanged += OnOutputChanged; cmbVoice.SelectedIndexChanged += OnVoiceChanged; @@ -61,14 +62,88 @@ internal sealed partial class MainForm : Form await InitializeEngineAsync(); } - private void PopulatePttKeys() + private Keys _pttKey = Keys.F8; + private bool _capturingPttKey; + private PttHotkey? _captureHook; + + private void OnPttKeyFocus(object? sender, EventArgs e) { - var keys = new[] + _capturingPttKey = true; + txtPttKey.Text = "Press a key..."; + txtPttKey.BackColor = Color.LightYellow; + + _captureHook?.Dispose(); + _captureHook = new PttHotkey(); + _captureHook.CaptureKeyPressed += OnCaptureKey; + _captureHook.InstallCaptureHook(); + } + + private void OnCaptureKey(Keys key) + { + if (!_capturingPttKey) return; + + if (key == Keys.Escape) { - Keys.F1, Keys.F2, Keys.F3, Keys.F4, Keys.F5, Keys.F6, - Keys.F7, Keys.F8, Keys.F9, Keys.F10, Keys.F11, Keys.F12, + CancelCapture(); + return; + } + + if (key is Keys.ShiftKey or Keys.Menu or Keys.LWin or Keys.RWin) + return; + + _pttKey = key; + _capturingPttKey = false; + txtPttKey.Text = KeyToDisplayString(_pttKey); + txtPttKey.BackColor = SystemColors.Window; + _captureHook?.Dispose(); + _captureHook = null; + SetupHotkey(); + } + + private void CancelCapture() + { + _capturingPttKey = false; + txtPttKey.Text = KeyToDisplayString(_pttKey); + txtPttKey.BackColor = SystemColors.Window; + _captureHook?.Dispose(); + _captureHook = null; + } + + private void OnPttKeyDown(object? sender, KeyEventArgs e) + { + if (_capturingPttKey && e.KeyCode == Keys.Escape) + { + CancelCapture(); + e.SuppressKeyPress = true; + } + } + + private static string KeyToDisplayString(Keys key) + { + return key switch + { + >= Keys.F1 and <= Keys.F12 => key.ToString(), + Keys.RControlKey => "Right Ctrl", + Keys.LControlKey => "Left Ctrl", + Keys.Space => "Space", + Keys.LButton => "Mouse Left", + Keys.RButton => "Mouse Right", + Keys.MButton => "Mouse Middle", + Keys.XButton1 => "Mouse X1", + Keys.XButton2 => "Mouse X2", + Keys.Oemtilde => "`", + Keys.CapsLock => "CapsLock", + Keys.NumLock => "NumLock", + Keys.Scroll => "ScrollLock", + Keys.Pause => "Pause", + Keys.Insert => "Insert", + Keys.Delete => "Delete", + Keys.Home => "Home", + Keys.End => "End", + Keys.PageUp => "PageUp", + Keys.PageDown => "PageDown", + _ => key.ToString(), }; - cmbPttKey.Items.AddRange(keys.Cast().ToArray()); } private void PopulateVoices() @@ -160,7 +235,7 @@ internal sealed partial class MainForm : Form private void SetupHotkey() { _pttHotkey?.Dispose(); - _pttHotkey = new PttHotkey { Key = (Keys)(cmbPttKey.SelectedItem ?? Keys.F8) }; + _pttHotkey = new PttHotkey { Key = _pttKey }; _pttHotkey.Pressed += OnPttPressed; _pttHotkey.Released += OnPttReleased; _pttHotkey.Install(); @@ -299,11 +374,6 @@ internal sealed partial class MainForm : Form } } - private void OnPttKeyChanged(object? sender, EventArgs e) - { - SetupHotkey(); - } - private void OnOutputChanged(object? sender, EventArgs e) { if (_orchestrator is not null) diff --git a/Robovoice.App/PttHotkey.cs b/Robovoice.App/PttHotkey.cs index 3e71e52..73a87a7 100644 --- a/Robovoice.App/PttHotkey.cs +++ b/Robovoice.App/PttHotkey.cs @@ -6,46 +6,81 @@ namespace Robovoice.App; internal sealed class PttHotkey : IDisposable { private const int WH_KEYBOARD_LL = 13; + private const int WH_MOUSE_LL = 14; private const int WM_KEYDOWN = 0x0100; private const int WM_KEYUP = 0x0101; private const int WM_SYSKEYDOWN = 0x0104; private const int WM_SYSKEYUP = 0x0105; + private const int WM_LBUTTONDOWN = 0x0201; + private const int WM_LBUTTONUP = 0x0202; + private const int WM_RBUTTONDOWN = 0x0204; + private const int WM_RBUTTONUP = 0x0205; + private const int WM_MBUTTONDOWN = 0x0207; + private const int WM_MBUTTONUP = 0x0208; + private const int WM_XBUTTONDOWN = 0x020B; + private const int WM_XBUTTONUP = 0x020C; - private readonly LowLevelKeyboardProc _proc; - private IntPtr _hook = IntPtr.Zero; + private readonly LowLevelKeyboardProc _kbProc; + private readonly LowLevelMouseProc _msProc; + private IntPtr _kbHook = IntPtr.Zero; + private IntPtr _msHook = IntPtr.Zero; private bool _isDown; private bool _disposed; public Keys Key { get; set; } = Keys.F8; + public static bool IsMouseButton(Keys key) => + key is Keys.LButton or Keys.RButton or Keys.MButton or Keys.XButton1 or Keys.XButton2; + public event EventHandler? Pressed; public event EventHandler? Released; + public event Action? CaptureKeyPressed; + + public void InstallCaptureHook() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_kbHook != IntPtr.Zero) return; + + IntPtr hModule = GetModuleHandle(null); + _kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hModule, 0); + } + public PttHotkey() { - _proc = HookCallback; + _kbProc = KeyboardCallback; + _msProc = MouseCallback; } public void Install() { ObjectDisposedException.ThrowIf(_disposed, this); - if (_hook != IntPtr.Zero) return; + if (_kbHook != IntPtr.Zero || _msHook != IntPtr.Zero) return; IntPtr hModule = GetModuleHandle(null); - _hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, hModule, 0); + + if (IsMouseButton(Key)) + _msHook = SetWindowsHookEx(WH_MOUSE_LL, _msProc, hModule, 0); + else + _kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, hModule, 0); } public void Uninstall() { - if (_hook != IntPtr.Zero) + if (_kbHook != IntPtr.Zero) { - UnhookWindowsHookEx(_hook); - _hook = IntPtr.Zero; - _isDown = false; + UnhookWindowsHookEx(_kbHook); + _kbHook = IntPtr.Zero; } + if (_msHook != IntPtr.Zero) + { + UnhookWindowsHookEx(_msHook); + _msHook = IntPtr.Zero; + } + _isDown = false; } - private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) + private IntPtr KeyboardCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { @@ -55,19 +90,72 @@ internal sealed class PttHotkey : IDisposable bool isDown = wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN; bool isUp = wParam == WM_KEYUP || wParam == WM_SYSKEYUP; - if (key == Key && isDown && !_isDown) + if (CaptureKeyPressed is not null && isDown) { - _isDown = true; - Pressed?.Invoke(this, EventArgs.Empty); + CaptureKeyPressed.Invoke(key); } - else if (key == Key && isUp && _isDown) + else if (key == Key) { - _isDown = false; - Released?.Invoke(this, EventArgs.Empty); + if (isDown && !_isDown) + { + _isDown = true; + Pressed?.Invoke(this, EventArgs.Empty); + } + else if (isUp && _isDown) + { + _isDown = false; + Released?.Invoke(this, EventArgs.Empty); + } } } - return CallNextHookEx(_hook, nCode, wParam, lParam); + return CallNextHookEx(_kbHook, nCode, wParam, lParam); + } + + private IntPtr MouseCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode >= 0) + { + Keys? button = null; + + switch ((int)wParam) + { + case WM_LBUTTONDOWN: button = Keys.LButton; break; + case WM_LBUTTONUP: button = Keys.LButton; break; + case WM_RBUTTONDOWN: button = Keys.RButton; break; + case WM_RBUTTONUP: button = Keys.RButton; break; + case WM_MBUTTONDOWN: button = Keys.MButton; break; + case WM_MBUTTONUP: button = Keys.MButton; break; + case WM_XBUTTONDOWN: + case WM_XBUTTONUP: + { + int xButton = Marshal.ReadInt32(lParam + 8) >> 16; + button = xButton == 1 ? Keys.XButton1 : Keys.XButton2; + break; + } + } + + if (button is Keys btn && btn == Key) + { + bool isDown = wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN + || wParam == WM_MBUTTONDOWN || wParam == WM_XBUTTONDOWN; + bool isUp = wParam == WM_LBUTTONUP || wParam == WM_RBUTTONUP + || wParam == WM_MBUTTONUP || wParam == WM_XBUTTONUP; + + if (isDown && !_isDown) + { + _isDown = true; + Pressed?.Invoke(this, EventArgs.Empty); + } + else if (isUp && _isDown) + { + _isDown = false; + Released?.Invoke(this, EventArgs.Empty); + } + } + } + + return CallNextHookEx(_msHook, nCode, wParam, lParam); } public void Dispose() @@ -78,10 +166,14 @@ internal sealed class PttHotkey : IDisposable } private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); + private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnhookWindowsHookEx(IntPtr hhk);