mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[eric] voice: text-landed paste cue on successful injection, lock cue when a tap latches hands-free
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user