[eric] voice: F5 mic key as the simple hotkey, remove the activation sounds

This commit is contained in:
ciregenz
2026-07-24 12:53:53 -07:00
parent 5c87b8aafc
commit e7084cc085
5 changed files with 22 additions and 57 deletions
+16 -10
View File
@@ -1793,18 +1793,22 @@ app.whenReady().then(async () => {
// focused, MAIN watches before-input-event (sees every keyDown/keyUp incl. modifiers, regardless of
// which element or webview has focus, and immune to macOS's letter-keyup-under-Cmd suppression at the
// DOM layer) and relays voice:hold-down / voice:hold-up; the renderer maps those to hold-vs-toggle.
const VOICE_COMBO = 'CommandOrControl+Shift+D';
// F5 is the mic/dictation key printed on Mac keyboards (the fn/globe key itself is invisible to
// Electron without a native event tap), so it's the simple primary; the old combo stays as backup.
const VOICE_COMBOS = ['F5', 'CommandOrControl+Shift+D'];
const registerVoiceShortcut = () => {
try {
if (!globalShortcut.isRegistered(VOICE_COMBO)) {
globalShortcut.register(VOICE_COMBO, () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('voice:toggle');
});
}
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
for (const combo of VOICE_COMBOS) {
try {
if (!globalShortcut.isRegistered(combo)) {
globalShortcut.register(combo, () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('voice:toggle');
});
}
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
}
};
registerVoiceShortcut();
app.on('browser-window-focus', () => { try { globalShortcut.unregister(VOICE_COMBO); } catch (_) {} });
app.on('browser-window-focus', () => { for (const combo of VOICE_COMBOS) { try { globalShortcut.unregister(combo); } catch (_) {} } });
app.on('browser-window-blur', registerVoiceShortcut);
// Every discrete combo press relays (autorepeat filtered); the renderer toggles record state.
@@ -1813,8 +1817,10 @@ app.whenReady().then(async () => {
// without a native event tap. Keyboard = press to start/stop; the mic buttons own true hold-to-talk.
const installVoiceHoldRelay = (contents) => {
contents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown' || input.isAutoRepeat) return;
const isD = (input.code === 'KeyD' || (input.key || '').toLowerCase() === 'd');
if (input.type === 'keyDown' && isD && (input.meta || input.control) && input.shift && !input.isAutoRepeat) {
const combo = (isD && (input.meta || input.control) && input.shift) || input.code === 'F5';
if (combo) {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('voice:hold-down');
event.preventDefault();
}
@@ -160,7 +160,7 @@ function DesktopSpawnPill({
>
<AddRounded sx={{ fontSize: 18 }} />
</Box>
<Tooltip title={recording ? 'Stop dictation' : preparing ? `Downloading voice model ${voicePct}%` : 'Dictate (Cmd+Shift+D)'} placement="top" arrow>
<Tooltip title={recording ? 'Stop dictation' : preparing ? `Downloading voice model ${voicePct}%` : 'Dictate (F5)'} placement="top" arrow>
<Box
role="button"
aria-label="Voice dictation"
@@ -104,7 +104,7 @@ const GeneralInterface: React.FC<{
<Box sx={inlineRowSx} {...settingSelectAttrs('voice_hold_to_talk', 'Dictation', 'Interface', 'Hold to talk, or tap to start and stop.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Dictation</Typography>
<Typography sx={descSx}>How the mic button works. Cmd+Shift+D always starts and stops.</Typography>
<Typography sx={descSx}>How the mic button works. The mic key (F5) starts and stops.</Typography>
</Box>
<ToggleButtonGroup
value={form.voice_hold_to_talk ?? true}
+4 -10
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { API_BASE } from '@/shared/config';
import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav';
import { injectAtFocus } from './injectAtFocus';
import { playCancel, playDone, playStart } from './voiceSounds';
export type VoiceState = 'idle' | 'recording' | 'transcribing' | 'preparing';
@@ -118,7 +117,6 @@ export function useVoiceDictation() {
node.connect(ctx.destination);
recRef.current = { ctx, stream, node, source, chunks };
setState('recording');
playStart();
// Warm the model the moment recording begins so transcription is instant on stop.
void window.openswarm?.voiceWarmup?.();
} catch (err) {
@@ -145,19 +143,15 @@ export function useVoiceDictation() {
setLastText(text);
// Land the text where the user's cursor is: focused field, then focused browser page, then
// the OS paste fallback (other apps). The floating bubble is just confirmation, not the output.
// Success is silent-visual: the text landing at the cursor IS the feedback (plus the done blip).
// Only the clipboard fallback still speaks, because the user has to act (paste) to get the text.
// Success is silent: the text landing at the cursor IS the feedback. Only the clipboard
// fallback still speaks, because the user has to act (paste) to get the text.
const target = injectAtFocus(text);
if (target) {
playDone();
} else {
if (!target) {
const inj = await window.openswarm?.voiceInject?.(text);
if (inj?.pasted) playDone();
else setFeedback({ tone: 'ok', icon: 'clipboard', text: `${text} (copied, press Cmd+V)`, at: Date.now() });
if (!inj?.pasted) setFeedback({ tone: 'ok', icon: 'clipboard', text: `${text} (copied, press Cmd+V)`, at: Date.now() });
}
setState('idle');
} else if (res?.ok && !res.text) {
playCancel();
setFeedback({ tone: 'warn', icon: 'info', text: "Didn't catch that. Try again.", at: Date.now() });
setState('idle');
} else if (res?.error === 'model-downloading' || res?.error === 'no-model') {
-35
View File
@@ -1,35 +0,0 @@
// Tiny WebAudio cues for dictation, generated in code (no bundled assets): a soft rising two-note on
// start, falling on cancel, and a quiet tick when text lands. Volumes stay whisper-level on purpose.
let ctx: AudioContext | null = null;
function audio(): AudioContext | null {
try {
if (!ctx) ctx = new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
}
}
function blip(freqs: Array<[number, number, number]>): void {
const ac = audio();
if (!ac) return;
const t0 = ac.currentTime;
for (const [freq, at, dur] of freqs) {
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, t0 + at);
gain.gain.linearRampToValueAtTime(0.055, t0 + at + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + at + dur);
osc.connect(gain).connect(ac.destination);
osc.start(t0 + at);
osc.stop(t0 + at + dur + 0.05);
}
}
export function playStart(): void { blip([[523, 0, 0.12], [784, 0.09, 0.16]]); }
export function playCancel(): void { blip([[784, 0, 0.1], [523, 0.08, 0.14]]); }
export function playDone(): void { blip([[1047, 0, 0.09]]); }