Skip to content

Commit 185dfdd

Browse files
authored
feat(whisper): add dropdown preset keybinding selector for speech-to-text (#447)
* feat(whisper): add dropdown preset keybinding selector for speech-to-text Replace the single HotkeyRecorder + 'Set to Fn' button in Whisper hotkey settings with a dropdown offering preset keybinding options. This solves the problem where standalone modifier keys (Option, Command, etc.) could not be captured by the recorder since browser keydown events treat them as modifiers rather than keys. Preset options include: Fn (hold-to-talk), Option (hold-to-talk), F5, F6, Option+Space, Option+., Command+., Control+Space, Shift+Space. A 'Custom…' option in the dropdown reveals the HotkeyRecorder for advanced key combos. Backend changes generalize the native CGEventTap-based Fn speak-toggle watcher to support any standalone modifier key as a hold-to-talk trigger (Option/Alt keyCode 58, Command keyCode 55, etc.). The hotkey-hold-monitor.swift binary already handles flagsChanged events for modifier keys, so no native code changes were needed. All 9 locale files updated with new i18n keys (custom, holdToTalkHint) replacing the removed keys (setToFn, fnHint, fnHintBefore, fnHintAfter). * fix(whisper): trim dropdown to Fn/Option/Command with L/R variants, fix Custom auto-record - Replace the 9 preset list (F5, F6, combos) with the 3 hold-to-talk modifier families: Fn, Left/Right Option, Left/Right Command - Add right-side modifier key codes (61=RightOption, 54=RightCommand) to STANDALONE_MODIFIER_KEYCODES and parseHoldShortcutConfig key map - Update normalizeAccelerator to recognize left/right modifier tokens as modifiers when used in combos (e.g. LeftOption+Space → Alt+Space) - Update isStandaloneAlt/isStandaloneCmd detection for left/right variants - Format display: ⌥←/⌥→/⌘←/⌘→ with directional arrows for side info - Fix Custom option: blur the <select> when Custom is chosen so it stops intercepting keyboard events, and add autoRecord prop to HotkeyRecorder so it auto-starts recording and grabs focus on mount * fix(whisper): move whisperCustomMode useState above early return to fix hooks order The useState for whisperCustomMode was placed after the early return (!settings), causing React to throw 'Rendered more hooks than during the previous render' when settings loaded asynchronously. * fix(whisper): show HotkeyRecorder when Custom is selected from dropdown The recorder visibility was gated on whisperPresetValue which only reflects the stored hotkey — but selecting 'Custom…' doesn't change the stored value, so the recorder never appeared. Use whisperSelectValue instead, which accounts for the user's explicit Custom selection. * fix(whisper): eliminate dropdown flash when recording custom keybinding Keep whisperCustomMode=true until the stored hotkey actually updates via IPC instead of clearing it in the recorder's onChange (which fires before the async settings save completes). A 150ms timeout fallback handles the edge case where the recorded value equals the current stored value. * fix(whisper): move whisperCustomMode useEffect above early return The useEffect for clearing whisperCustomMode was placed after the component-level early return guard, causing the hooks order mismatch again. Derive whisperSpeakToggleHotkey from settings? before the guard and move the effect up with the other hooks. * fix(whisper): keep custom mode active until recorder saves, not on timer The previous 150ms timeout-based useEffect fired immediately when custom mode was entered, snapping the dropdown back before the user could record anything. Now custom mode only clears when the HotkeyRecorder's onChange resolves (i.e. after IPC save completes).
1 parent 78361e8 commit 185dfdd

14 files changed

Lines changed: 174 additions & 76 deletions

File tree

src/main/main.ts

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3043,6 +3043,7 @@ let hyperKeyMonitorRestartTimer: NodeJS.Timeout | null = null;
30433043
let hyperKeyMonitorEnabled = false;
30443044
let fnSpeakToggleLastPressedAt = 0;
30453045
let fnSpeakToggleIsPressed = false;
3046+
let fnSpeakToggleCurrentShortcut = 'Fn';
30463047
type LocalSpeakBackend = 'edge-tts' | 'system-say';
30473048
let edgeTtsConstructorResolved = false;
30483049
let edgeTtsConstructor: any | null = null;
@@ -6299,15 +6300,15 @@ function normalizeAccelerator(shortcut: string): string {
62996300
else normalizedModifiers.control = true;
63006301
continue;
63016302
}
6302-
if (token === 'cmd' || token === 'command' || token === 'meta' || token === 'super') {
6303+
if (token === 'cmd' || token === 'command' || token === 'meta' || token === 'super' || token === 'leftcmd' || token === 'leftcommand' || token === 'leftmeta' || token === 'rightcmd' || token === 'rightcommand' || token === 'rightmeta') {
63036304
normalizedModifiers.command = true;
63046305
continue;
63056306
}
63066307
if (token === 'ctrl' || token === 'control') {
63076308
normalizedModifiers.control = true;
63086309
continue;
63096310
}
6310-
if (token === 'alt' || token === 'option') {
6311+
if (token === 'alt' || token === 'option' || token === 'leftalt' || token === 'leftoption' || token === 'rightalt' || token === 'rightoption') {
63116312
normalizedModifiers.alt = true;
63126313
continue;
63136314
}
@@ -6405,6 +6406,29 @@ function isFnShortcut(shortcut: string): boolean {
64056406
return Boolean(config?.fn);
64066407
}
64076408

6409+
// Standalone modifier keys that need a native CGEventTap watcher
6410+
// instead of Electron's globalShortcut (which ignores bare modifiers).
6411+
const STANDALONE_MODIFIER_KEYCODES: Record<string, number> = {
6412+
alt: 58, option: 58, // Left Option
6413+
leftoption: 58, leftalt: 58, // Left Option (explicit)
6414+
rightoption: 61, rightalt: 61, // Right Option
6415+
command: 55, cmd: 55, meta: 55, // Left Command
6416+
leftcommand: 55, leftcmd: 55, leftmeta: 55, // Left Command (explicit)
6417+
rightcommand: 54, rightcmd: 54, rightmeta: 54, // Right Command
6418+
control: 59, ctrl: 59, // Left Control
6419+
shift: 56, // Left Shift
6420+
};
6421+
6422+
function isStandaloneModifierShortcut(shortcut: string): boolean {
6423+
const normalized = normalizeAccelerator(shortcut).trim().toLowerCase();
6424+
return normalized in STANDALONE_MODIFIER_KEYCODES;
6425+
}
6426+
6427+
function needsNativeHoldWatcher(shortcut: string): boolean {
6428+
if (!shortcut) return false;
6429+
return isFnOnlyShortcut(shortcut) || isFnShortcut(shortcut) || isStandaloneModifierShortcut(shortcut);
6430+
}
6431+
64086432
function parseHoldShortcutConfig(shortcut: string): {
64096433
keyCode: number;
64106434
cmd: boolean;
@@ -6433,16 +6457,32 @@ function parseHoldShortcutConfig(shortcut: string): {
64336457
home: 115, end: 119, pageup: 116, pagedown: 121,
64346458
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100,
64356459
f9: 101, f10: 109, f11: 103, f12: 111,
6460+
// Standalone modifier keys for CGEventTap monitoring
6461+
alt: 58, option: 58,
6462+
leftoption: 58, leftalt: 58,
6463+
rightoption: 61, rightalt: 61,
6464+
command: 55, cmd: 55, meta: 55,
6465+
leftcommand: 55, leftcmd: 55, leftmeta: 55,
6466+
rightcommand: 54, rightcmd: 54, rightmeta: 54,
6467+
control: 59, ctrl: 59,
6468+
shift: 56,
64366469
};
64376470
const keyCode = map[keyToken];
64386471
if (!Number.isFinite(keyCode)) return null;
64396472
const fnAsModifier = mods.has('fn') || mods.has('function');
6473+
// When the key token itself is a standalone modifier, set the corresponding
6474+
// flag so the Swift monitor checks that the modifier flag is active when
6475+
// the physical key is pressed (same pattern as the Fn key).
6476+
const isStandaloneAlt = keyToken === 'alt' || keyToken === 'option' || keyToken === 'leftoption' || keyToken === 'leftalt' || keyToken === 'rightoption' || keyToken === 'rightalt';
6477+
const isStandaloneCmd = keyToken === 'command' || keyToken === 'cmd' || keyToken === 'meta' || keyToken === 'leftcommand' || keyToken === 'leftcmd' || keyToken === 'leftmeta' || keyToken === 'rightcommand' || keyToken === 'rightcmd' || keyToken === 'rightmeta';
6478+
const isStandaloneCtrl = keyToken === 'control' || keyToken === 'ctrl';
6479+
const isStandaloneShift = keyToken === 'shift';
64406480
return {
64416481
keyCode,
6442-
cmd: mods.has('command') || mods.has('cmd') || mods.has('meta'),
6443-
ctrl: mods.has('control') || mods.has('ctrl'),
6444-
alt: mods.has('alt') || mods.has('option'),
6445-
shift: mods.has('shift'),
6482+
cmd: mods.has('command') || mods.has('cmd') || mods.has('meta') || isStandaloneCmd,
6483+
ctrl: mods.has('control') || mods.has('ctrl') || isStandaloneCtrl,
6484+
alt: mods.has('alt') || mods.has('option') || isStandaloneAlt,
6485+
shift: mods.has('shift') || isStandaloneShift,
64466486
fn: fnAsModifier || keyToken === 'fn' || keyToken === 'function',
64476487
};
64486488
}
@@ -6842,7 +6882,7 @@ function startFnCommandWatcher(commandId: string, shortcut: string): void {
68426882

68436883
function startFnSpeakToggleWatcher(): void {
68446884
if (fnSpeakToggleWatcherProcess || !fnSpeakToggleWatcherEnabled) return;
6845-
const config = parseHoldShortcutConfig('Fn');
6885+
const config = parseHoldShortcutConfig(fnSpeakToggleCurrentShortcut || 'Fn');
68466886
if (!config) return;
68476887
const binaryPath = ensureWhisperHoldWatcherBinary();
68486888
if (!binaryPath) return;
@@ -6951,11 +6991,11 @@ function startFnSpeakToggleWatcher(): void {
69516991
}
69526992

69536993
function syncFnSpeakToggleWatcher(hotkeys: Record<string, string>): void {
6954-
// Do not start the CGEventTap-based Fn watcher during onboarding.
6994+
// Do not start the CGEventTap-based watcher during onboarding.
69556995
// The tap requires Input Monitoring (and sometimes Accessibility) permission,
69566996
// which would trigger system dialogs before the user reaches the Grant Access step.
69576997
// Exception: fnWatcherOnboardingOverride is set when the user reaches the Dictation
6958-
// test step (step 4) so they can actually test the Fn key during setup.
6998+
// test step (step 4) so they can actually test the key during setup.
69596999
if (!loadSettings().hasSeenOnboarding && !fnWatcherOnboardingOverride) {
69607000
stopFnSpeakToggleWatcher();
69617001
return;
@@ -6966,12 +7006,21 @@ function syncFnSpeakToggleWatcher(hotkeys: Record<string, string>): void {
69667006
return;
69677007
}
69687008
const speakToggle = String(hotkeys?.['system-supercmd-whisper-speak-toggle'] || '').trim();
6969-
const shouldEnable = isFnOnlyShortcut(speakToggle);
7009+
const shouldEnable = needsNativeHoldWatcher(speakToggle);
69707010
if (!shouldEnable) {
7011+
fnSpeakToggleCurrentShortcut = '';
69717012
stopFnSpeakToggleWatcher();
69727013
return;
69737014
}
7015+
// If the shortcut changed, stop the existing watcher so it restarts with the new key.
7016+
const shortcutChanged = speakToggle !== fnSpeakToggleCurrentShortcut;
7017+
if (shortcutChanged && fnSpeakToggleWatcherProcess) {
7018+
try { fnSpeakToggleWatcherProcess.kill('SIGTERM'); } catch {}
7019+
fnSpeakToggleWatcherProcess = null;
7020+
fnSpeakToggleWatcherStdoutBuffer = '';
7021+
}
69747022
fnSpeakToggleWatcherEnabled = true;
7023+
fnSpeakToggleCurrentShortcut = speakToggle;
69757024
startFnSpeakToggleWatcher();
69767025
}
69777026

@@ -6981,7 +7030,7 @@ function syncFnCommandWatchers(hotkeys: Record<string, string>): void {
69817030
const shortcut = String(shortcutRaw || '').trim();
69827031
if (!shortcut) continue;
69837032
const normalized = normalizeAccelerator(shortcut);
6984-
const isFnSpeakToggle = commandId === 'system-supercmd-whisper-speak-toggle' && isFnOnlyShortcut(normalized);
7033+
const isFnSpeakToggle = commandId === 'system-supercmd-whisper-speak-toggle' && (isFnOnlyShortcut(normalized) || isStandaloneModifierShortcut(normalized));
69857034
if (isFnSpeakToggle) continue;
69867035
if (!isFnShortcut(normalized)) continue;
69877036
desired.set(commandId, normalized);
@@ -13072,6 +13121,9 @@ function registerCommandHotkeys(hotkeys: Record<string, string>): void {
1307213121
if (commandId === 'system-supercmd-whisper-speak-toggle' && isFnOnlyShortcut(normalizedShortcut)) {
1307313122
continue;
1307413123
}
13124+
if (commandId === 'system-supercmd-whisper-speak-toggle' && isStandaloneModifierShortcut(normalizedShortcut)) {
13125+
continue;
13126+
}
1307513127
if (isFnShortcut(normalizedShortcut)) {
1307613128
continue;
1307713129
}
@@ -14410,14 +14462,35 @@ app.whenReady().then(async () => {
1441014462

1441114463
const isFnSpeakToggle =
1441214464
commandId === 'system-supercmd-whisper-speak-toggle' &&
14413-
isFnOnlyShortcut(normalizedHotkey);
14465+
(isFnOnlyShortcut(normalizedHotkey) || isStandaloneModifierShortcut(normalizedHotkey));
1441414466
const isFnHotkey = isFnShortcut(normalizedHotkey);
1441514467
const isHyperHotkey = isHyperShortcut(normalizedHotkey);
1441614468

14469+
// Standalone modifier shortcuts (Option, Command, etc.) only work for
14470+
// the whisper speak-toggle (hold-to-talk) command, since they require
14471+
// the native CGEventTap watcher. Reject them for other commands.
14472+
if (isStandaloneModifierShortcut(normalizedHotkey) && !isFnSpeakToggle) {
14473+
// Attempt to restore old mapping if the new one failed.
14474+
if (oldHotkey) {
14475+
const normalizedOldHotkey = normalizeAccelerator(oldHotkey);
14476+
try {
14477+
const restored = globalShortcut.register(normalizedOldHotkey, async () => {
14478+
await runCommandById(commandId, 'hotkey');
14479+
});
14480+
if (restored) {
14481+
registeredHotkeys.set(normalizedOldHotkey, commandId);
14482+
}
14483+
} catch {}
14484+
}
14485+
return { success: false, error: 'unavailable' as const };
14486+
}
14487+
1441714488
// Register the new one
1441814489
try {
1441914490
let success = false;
1442014491
if (isFnSpeakToggle) {
14492+
// Standalone modifier or Fn-only shortcuts are handled by the
14493+
// native CGEventTap speak-toggle watcher, not Electron globalShortcut.
1442114494
success = true;
1442214495
} else if (isFnHotkey) {
1442314496
const fnConfig = parseHoldShortcutConfig(normalizedHotkey);

src/renderer/src/i18n/locales/de.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Whisper-Tastenkombinationen",
386386
"startStop": "Sprechen starten/stoppen",
387-
"setToFn": "Auf Fn setzen",
388-
"fnHint": "Verwende Auf Fn setzen, um die standardmaessige Push-to-Talk-Taste wiederherzustellen.",
389-
"fnHintBefore": "Use",
390-
"fnHintAfter": "to restore the default hold-to-talk key."
387+
"custom": "Benutzerdefiniert…",
388+
"holdToTalkHint": "Waehlen Sie eine Push-to-Talk-Taste. Halten zum Aufnehmen, loslassen zum Eingeben. Benutzen Sie Benutzerdefiniert fuer jede Tastenkombination."
391389
},
392390
"autoClose": {
393391
"title": "Auto-Close After Dictation",

src/renderer/src/i18n/locales/en.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Whisper Hotkeys",
386386
"startStop": "Start/Stop Speaking",
387-
"setToFn": "Set to Fn",
388-
"fnHint": "Use Set to Fn to restore the default hold-to-talk key.",
389-
"fnHintBefore": "Use",
390-
"fnHintAfter": "to restore the default hold-to-talk key."
387+
"custom": "Custom…",
388+
"holdToTalkHint": "Select a hold-to-talk key. Hold to record, release to type. Use Custom to record any key combo."
391389
},
392390
"autoClose": {
393391
"title": "Auto-Close After Dictation",

src/renderer/src/i18n/locales/es.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Atajos de Whisper",
386386
"startStop": "Iniciar/detener dictado",
387-
"setToFn": "Asignar a Fn",
388-
"fnHint": "Usa Asignar a Fn para restaurar la tecla predeterminada de pulsar para hablar.",
389-
"fnHintBefore": "Use",
390-
"fnHintAfter": "to restore the default hold-to-talk key."
387+
"custom": "Personalizado…",
388+
"holdToTalkHint": "Seleccione una tecla push-to-talk. Mantenga para grabar, suelte para escribir. Use Personalizado para grabar cualquier combinacion."
391389
},
392390
"autoClose": {
393391
"title": "Auto-Close After Dictation",

src/renderer/src/i18n/locales/fr.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Raccourcis Whisper",
386386
"startStop": "Demarrer/arreter la dictee",
387-
"setToFn": "Definir sur Fn",
388-
"fnHint": "Utilisez Definir sur Fn pour restaurer la touche push-to-talk par defaut.",
389-
"fnHintBefore": "Use",
390-
"fnHintAfter": "to restore the default hold-to-talk key."
387+
"custom": "Personnalise…",
388+
"holdToTalkHint": "Selectionnez une touche push-to-talk. Maintenez pour enregistrer, relachez pour saisir. Utilisez Personnalise pour enregistrer toute combinaison."
391389
},
392390
"autoClose": {
393391
"title": "Auto-Close After Dictation",

src/renderer/src/i18n/locales/it.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Scorciatoie di Whisper",
386386
"startStop": "Avvia/Ferma la dettatura",
387-
"setToFn": "Imposta su Fn",
388-
"fnHint": "Usa Imposta su Fn per ripristinare il tasto predefinito di registrazione.",
389-
"fnHintBefore": "Usa",
390-
"fnHintAfter": "per ripristinare il tasto predefinito di registrazione."
387+
"custom": "Personalizzato…",
388+
"holdToTalkHint": "Seleziona un tasto push-to-talk. Tieni premuto per registrare, rilascia per digitare. Usa Personalizzato per registrare qualsiasi combinazione."
391389
},
392390
"autoClose": {
393391
"title": "Chiusura automatica dopo la dettatura",

src/renderer/src/i18n/locales/ja.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Whisper ホットキー",
386386
"startStop": "発話の開始/停止",
387-
"setToFn": "Fn に設定",
388-
"fnHint": "「Fn に設定」を使用してデフォルトの押しっぱなし発話キーを復元します。",
389-
"fnHintBefore": "使用するには",
390-
"fnHintAfter": "でデフォルトの押しっぱなし発話キーを復元できます。"
387+
"custom": "カスタム…",
388+
"holdToTalkHint": "押しっぱなし発話キーを選択してください。押して録音、離して入力。カスタムで任意のキー組み合わせを録音できます。"
391389
},
392390
"autoClose": {
393391
"title": "ディクテーション後に自動で閉じる",

src/renderer/src/i18n/locales/ko.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Whisper 단축키",
386386
"startStop": "말하기 시작/중지",
387-
"setToFn": "Fn으로 설정",
388-
"fnHint": "기본 길게 눌러 말하기 키로 되돌리려면 Fn으로 설정을 사용하세요.",
389-
"fnHintBefore": "사용",
390-
"fnHintAfter": "하여 기본 길게 눌러 말하기 키로 복원하세요."
387+
"custom": "사용자 지정…",
388+
"holdToTalkHint": "길게 눌러 말하기 키를 선택하세요. 눌러서 녹음, 놓아서 입력. 사용자 지정으로 원하는 조합을 녹음할 수 있습니다."
391389
},
392390
"autoClose": {
393391
"title": "받아쓰기 후 자동 닫기",

src/renderer/src/i18n/locales/ru.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "Whisper Hotkeys",
386386
"startStop": "Start/Stop Speaking",
387-
"setToFn": "Set to Fn",
388-
"fnHint": "Use Set to Fn to restore the default hold-to-talk key.",
389-
"fnHintBefore": "Use",
390-
"fnHintAfter": "to restore the default hold-to-talk key."
387+
"custom": "Custom…",
388+
"holdToTalkHint": "Select a hold-to-talk key. Hold to record, release to type. Use Custom to record any key combo."
391389
},
392390
"autoClose": {
393391
"title": "Auto-Close After Dictation",

src/renderer/src/i18n/locales/zh-Hans.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,8 @@
384384
"hotkeys": {
385385
"title": "语音输入快捷键",
386386
"startStop": "开始/停止说话",
387-
"setToFn": "设为 Fn 键",
388-
"fnHint": "使用「设为 Fn 键」恢复默认的按住说话快捷键。",
389-
"fnHintBefore": "使用",
390-
"fnHintAfter": "可恢复默认的按住说话快捷键。"
387+
"custom": "自定义…",
388+
"holdToTalkHint": "选择按住说话的按键。按住录音,松开输入。使用自定义录制任意组合键。"
391389
},
392390
"autoClose": {
393391
"title": "听写后自动关闭",

0 commit comments

Comments
 (0)