mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-26 19:44:51 +02:00
[eric] voice: F5 mic key as the simple hotkey, remove the activation sounds
This commit is contained in:
+16
-10
@@ -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}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]]); }
|
||||
Reference in New Issue
Block a user