[eric] voice: text-landed paste cue on successful injection, lock cue when a tap latches hands-free

This commit is contained in:
ciregenz
2026-08-05 21:14:22 -07:00
parent 99d8ef8314
commit 85659c71e7
4 changed files with 44 additions and 11 deletions
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useAppSelector } from '@/shared/hooks';
import { useVoiceDictation } from './useVoiceDictation';
import { playVoiceCue } from './voiceCues';
import { VoiceContext } from './voiceContext';
import VoiceOverlay from './VoiceOverlay';
@@ -29,13 +30,17 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode
stateRef.current = state;
const heldRef = useRef(false);
// A quick tap releases before start() flips state to 'recording': the session latches hands-free
// (Wispr's lock). The lock cue lands once recording is actually live, confirming the latch.
const latchedRef = useRef(false);
const pressStart = useCallback((): void => {
if (holdMode) {
// A TAP while recording must stop: the quick tap's press-end fires before the async start
// flips state to 'recording', so without this the mic could be started by a click but never
// stopped by one.
if (stateRef.current === 'recording') { heldRef.current = false; void stop(); return; }
if (stateRef.current === 'idle') { heldRef.current = true; void start(true); }
if (stateRef.current === 'idle') { heldRef.current = true; latchedRef.current = false; void start(true); }
} else {
toggle();
}
@@ -45,9 +50,20 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode
if (holdMode && heldRef.current) {
heldRef.current = false;
if (stateRef.current === 'recording') void stop();
else latchedRef.current = true;
}
}, [holdMode, stop]);
useEffect(() => {
if (state === 'recording' && latchedRef.current) {
latchedRef.current = false;
const t = setTimeout(() => playVoiceCue('lock'), 160);
return () => clearTimeout(t);
}
if (state === 'idle') latchedRef.current = false;
return undefined;
}, [state]);
// Keyboard hotkey channels, matched to what the source can actually see:
// voice:hold-down/up come ONLY from main's native uiohook tap (real global key-up, so the keyboard
// gets the same hold-vs-toggle press semantics as the mic buttons); voice:toggle comes from the
@@ -223,9 +223,12 @@ export function useVoiceDictation() {
// 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) {
if (target) {
playVoiceCue('paste');
} else {
const inj = await window.openswarm?.voiceInject?.(text);
if (!inj?.pasted) setFeedback({ tone: 'ok', icon: 'clipboard', text: `${text} (copied, press Cmd+V)`, at: Date.now() });
if (inj?.pasted) playVoiceCue('paste');
else setFeedback({ tone: 'ok', icon: 'clipboard', text: `${text} (copied, press Cmd+V)`, at: Date.now() });
}
setState('idle');
} else if (res?.ok && !res.text) {
File diff suppressed because one or more lines are too long
+18 -7
View File
@@ -1,24 +1,35 @@
import { VOICE_CUE_START, VOICE_CUE_STOP } from './voiceCueSounds';
import { VOICE_CUE_START, VOICE_CUE_STOP, VOICE_CUE_PASTE, VOICE_CUE_LOCK } from './voiceCueSounds';
// Eric picked these from Google's Material product sound set after a bake-off against Wispr Flow's
// real cues; the synth versions never survived an ear test. Files are embedded data URIs (see
// voiceCueSounds.ts), pre-instantiated so playback is instant on the press.
// voiceCueSounds.ts), pre-instantiated so playback is instant on the press. The grammar mirrors
// Wispr's three-beat: tap in, tap out, and a rising completion the moment the text actually lands;
// lock marks a hands-free latch.
const CUE_VOLUME = 0.35;
const p_players: Record<'start' | 'stop', HTMLAudioElement | null> = { start: null, stop: null };
type CueKind = 'start' | 'stop' | 'paste' | 'lock';
function player(kind: 'start' | 'stop'): HTMLAudioElement {
const SOURCES: Record<CueKind, string> = {
start: VOICE_CUE_START,
stop: VOICE_CUE_STOP,
paste: VOICE_CUE_PASTE,
lock: VOICE_CUE_LOCK,
};
const p_players: Partial<Record<CueKind, HTMLAudioElement>> = {};
function player(kind: CueKind): HTMLAudioElement {
let a = p_players[kind];
if (!a) {
a = new Audio(kind === 'start' ? VOICE_CUE_START : VOICE_CUE_STOP);
a.volume = CUE_VOLUME;
a = new Audio(SOURCES[kind]);
a.volume = kind === 'paste' ? CUE_VOLUME * 0.8 : CUE_VOLUME;
p_players[kind] = a;
}
return a;
}
export function playVoiceCue(kind: 'start' | 'stop'): void {
export function playVoiceCue(kind: CueKind): void {
try {
const a = player(kind);
a.currentTime = 0;