diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 3a93df2c571..526eb1132c8 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -3,23 +3,31 @@ import { useAppStore } from '@/store' import { useAudioCapture } from '@/hooks/use-audio-capture' import { toast } from 'sonner' import { DictationIndicator } from './DictationIndicator' -import { captureInsertionTarget, type DictationInsertionTarget } from './dictation-insertion-target' -import { - createDictationTranscriptDelivery, - type DictationTranscriptDelivery -} from './dictation-transcript-delivery' +import { captureInsertionTarget } from './dictation-insertion-target' +import { createDictationTranscriptDelivery } from './dictation-transcript-delivery' import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped-sessions' import { translate } from '@/i18n/i18n' import { showDictationDisabledToast, showDictationNoModelToast, - showDictationStartErrorToast -} from './dictation-start-error-toast' + showCloudLimitToast, + showDictationStartErrorToast, + showMicrophoneDisconnectedToast, + showMicrophoneFallbackToast +} from './dictation-toasts' import { useHoldDictationGesture } from './use-hold-dictation-gesture' import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' import { publishDictationMeter } from './dictation-meter-store' -import { stopDictationAtAudioLimit } from './dictation-audio-limit-stop' import { useDictationSilenceStop } from './use-dictation-silence-stop' +import { useDictationSpeechEvents } from './use-dictation-speech-events' +import { + canStartDictation, + initialDictationSession, + isDictationUnderway, + transitionDictationSession, + type DictationSession, + type DictationSessionEvent +} from '../../../../shared/dictation-session-machine' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -38,33 +46,46 @@ export function DictationController() { getLastSpeechAt } = useAudioCapture(publishDictationMeter) - const dictationStateRef = useRef(dictationState) - dictationStateRef.current = dictationState - const dictationRunRef = useRef(0) - const insertionTargetRef = useRef(null) - const activeSessionIdRef = useRef(null) + const sessionRef = useRef(initialDictationSession) + // Inserts the current session's finals where dictation started. + const deliverRef = useRef<((text: string) => void) | null>(null) const stoppedSessionIdsRef = useRef(new Set()) const stoppedResolversRef = useRef(new Map void>()) - const stopRequestedDuringStartRef = useRef(false) - const finalTranscriptReceivedRef = useRef(false) - const erroredSessionIdsRef = useRef(new Set()) - const deliverTranscriptRef = useRef(null) // Push-to-talk restarts per utterance; report a missing preferred microphone once per preference. const micFallbackNotifiedForRef = useRef(null) - const stopDictationRef = useRef<(() => void) | null>(null) const [captureLive, setCaptureLive] = useState(false) + const dispatch = useCallback( + (event: DictationSessionEvent): DictationSession => { + const previous = sessionRef.current + const next = transitionDictationSession(previous, event) + sessionRef.current = next + if (next.sessionId === null) { + deliverRef.current = null + } + if (next.phase !== previous.phase) { + setDictationState(next.phase) + if (next.phase === 'idle') { + setPartialTranscript('') + } + } + return next + }, + [setDictationState, setPartialTranscript] + ) + const drainStoppedSession = useCallback((sessionId: string) => { void waitForStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef) }, []) const finishDictationSession = useCallback( - async (sessionId: string) => { - dictationStateRef.current = 'stopping' - setDictationState('stopping') + async ({ run, sessionId }: DictationSession) => { + if (sessionId === null) { + return + } await finishCapture() - // A speech error during the tail already tore this session down; a newer one may own the refs now. - if (activeSessionIdRef.current !== sessionId) { + // A speech error during the tail already tore this session down; a newer one may be current now. + if (sessionRef.current.run !== run) { return } try { @@ -74,8 +95,10 @@ export function DictationController() { } // Main stop completion can precede renderer final-text IPC; wait for this session’s stopped event before accepting the next run. await waitForStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef) - const sessionErrored = erroredSessionIdsRef.current.delete(sessionId) - if (!sessionErrored && !finalTranscriptReceivedRef.current && getCapturedChunkCount() > 0) { + if (sessionRef.current.run !== run) { + return + } + if (!sessionRef.current.heardFinalText && getCapturedChunkCount() > 0) { toast.message( translate( 'auto.components.dictation.DictationController.5d2c3e7ae3', @@ -83,21 +106,27 @@ export function DictationController() { ) ) } - insertionTargetRef.current = null - finalTranscriptReceivedRef.current = false - stopRequestedDuringStartRef.current = false - if (activeSessionIdRef.current === sessionId) { - activeSessionIdRef.current = null - } - dictationStateRef.current = 'idle' - setDictationState('idle') - setPartialTranscript('') + dispatch({ type: 'ended' }) }, - [setDictationState, setPartialTranscript, finishCapture, getCapturedChunkCount] + [dispatch, finishCapture, getCapturedChunkCount] ) + const stopDictation = useCallback(async () => { + const previous = sessionRef.current + const next = dispatch({ type: 'stop' }) + if (next === previous) { + return + } + if (previous.phase === 'starting') { + // Startup finishes once the engine is up, so the buffered words land. + void finishCapture({ preserveBufferedAudio: true }) + return + } + await finishDictationSession(next) + }, [dispatch, finishCapture, finishDictationSession]) + const startDictation = useCallback(async () => { - if (dictationStateRef.current !== 'idle') { + if (!canStartDictation(sessionRef.current.phase)) { return } @@ -112,19 +141,29 @@ export function DictationController() { return } - const runId = dictationRunRef.current + 1 - const sessionId = String(runId) - dictationRunRef.current = runId - activeSessionIdRef.current = sessionId - insertionTargetRef.current = captureInsertionTarget() - stopRequestedDuringStartRef.current = false - finalTranscriptReceivedRef.current = false - erroredSessionIdsRef.current.clear() - deliverTranscriptRef.current = createDictationTranscriptDelivery() - dictationStateRef.current = 'starting' - setDictationState('starting') + const { run, sessionId } = dispatch({ type: 'start' }) + if (sessionId === null) { + return + } + const isCurrent = (): boolean => sessionRef.current.run === run + const insertionTarget = captureInsertionTarget() + const deliver = createDictationTranscriptDelivery() + deliverRef.current = (text) => deliver(text, insertionTarget) setCaptureLive(false) + // A stop that arrived while starting is still this run's stop. + const stopQueued = (): boolean => isCurrent() && sessionRef.current.phase === 'stopping' + const releaseStart = async (engineStarted: boolean, captureStarted = true): Promise => { + discardBufferedAudio() + if (captureStarted) { + stopCapture() + } + if (engineStarted) { + await window.api.speech.stopDictation(sessionId).catch(() => undefined) + drainStoppedSession(sessionId) + } + } + let captureStarted = false try { @@ -136,190 +175,117 @@ export function DictationController() { microphoneDeviceId: preferredMicrophoneDeviceId, microphoneDeviceLabel: settings?.voice?.microphoneDeviceLabel ?? null, onCaptureLost: () => { - if (dictationRunRef.current !== runId) { - return + if (isCurrent()) { + showMicrophoneDisconnectedToast() + void stopDictation() } - toast.message( - translate( - 'auto.components.dictation.DictationController.micDisconnected', - 'Microphone disconnected. Dictation stopped.' - ) - ) - stopDictationRef.current?.() }, - onAudioLimitReached: () => - stopDictationAtAudioLimit(dictationRunRef, runId, stopDictationRef) + onAudioLimitReached: () => { + if (isCurrent()) { + showCloudLimitToast() + void stopDictation() + } + } }) captureStarted = true if (captureResult?.fellBackToDefaultMicrophone) { // Suppress fallback notices when a queued stop will tear down this startup capture. - if ( - !stopRequestedDuringStartRef.current && - micFallbackNotifiedForRef.current !== preferredMicrophoneDeviceId - ) { + if (!stopQueued() && micFallbackNotifiedForRef.current !== preferredMicrophoneDeviceId) { micFallbackNotifiedForRef.current = preferredMicrophoneDeviceId - toast.message( - translate( - 'auto.components.dictation.DictationController.micFallback', - 'Selected microphone unavailable. Using system default.' - ) - ) + showMicrophoneFallbackToast() } } else { micFallbackNotifiedForRef.current = null } - if (stopRequestedDuringStartRef.current) { + if (stopQueued()) { stopCapture({ preserveBufferedAudio: true }) } - if (dictationRunRef.current !== runId) { - discardBufferedAudio() - stopCapture() - insertionTargetRef.current = null + if (!isCurrent()) { + await releaseStart(false) return } setCaptureLive(true) await window.api.speech.startDictation(modelId, undefined, sessionId) - if (dictationRunRef.current !== runId) { - discardBufferedAudio() - insertionTargetRef.current = null - stopCapture() - await window.api.speech.stopDictation(sessionId).catch(() => undefined) - drainStoppedSession(sessionId) + if (!isCurrent()) { + await releaseStart(true) return } await flushBufferedAudio() - if (dictationRunRef.current !== runId) { - discardBufferedAudio() - insertionTargetRef.current = null - stopCapture() - await window.api.speech.stopDictation(sessionId).catch(() => undefined) - drainStoppedSession(sessionId) + if (!isCurrent()) { + await releaseStart(true) return } - if (stopRequestedDuringStartRef.current) { - await finishDictationSession(sessionId) + if (dispatch({ type: 'engine-ready' }).phase === 'stopping') { + await finishDictationSession(sessionRef.current) return } - - dictationStateRef.current = 'listening' - setDictationState('listening') recordFeatureInteraction('voice-dictation') } catch (err) { - if (dictationRunRef.current !== runId) { + if (!isCurrent()) { return } - await window.api.speech.stopDictation(sessionId).catch(() => undefined) - drainStoppedSession(sessionId) - if (captureStarted) { - stopCapture() + await releaseStart(true, captureStarted) + if (!isCurrent()) { + return } - discardBufferedAudio() const message = String(err) - insertionTargetRef.current = null - stopRequestedDuringStartRef.current = false - finalTranscriptReceivedRef.current = false - erroredSessionIdsRef.current.clear() - activeSessionIdRef.current = null - setPartialTranscript('') if (message.includes('dictation_canceled')) { - dictationStateRef.current = 'idle' - setDictationState('idle') + dispatch({ type: 'cancel' }) return } - dictationStateRef.current = 'error' - setDictationState('error') + dispatch({ type: 'fail' }) showDictationStartErrorToast(message) - dictationStateRef.current = 'idle' - setDictationState('idle') + dispatch({ type: 'ended' }) } }, [ settings, - setDictationState, + dispatch, startCapture, flushBufferedAudio, discardBufferedAudio, stopCapture, + stopDictation, finishDictationSession, drainStoppedSession, - setPartialTranscript, recordFeatureInteraction ]) - const stopDictation = useCallback(async () => { - if (dictationStateRef.current === 'starting') { - stopRequestedDuringStartRef.current = true - dictationStateRef.current = 'stopping' - setDictationState('stopping') - void finishCapture({ preserveBufferedAudio: true }) - return + const toggleDictation = useCallback(() => { + if (isDictationUnderway(sessionRef.current.phase)) { + void stopDictation() + } else { + void startDictation() } - - if (dictationStateRef.current !== 'listening') { - return - } - - const sessionId = activeSessionIdRef.current - if (!sessionId) { - return - } - await finishDictationSession(sessionId) - }, [finishDictationSession, setDictationState, finishCapture]) - - // Capture opens before stopDictation exists; bridge through a ref to avoid cyclic callback dependencies. - stopDictationRef.current = () => void stopDictation() + }, [startDictation, stopDictation]) // Toggle mode uses intercepted main IPC so the chord cannot reach xterm or system shortcuts. useEffect(() => { const mode = settings?.voice?.dictationMode ?? 'toggle' - if (mode !== 'toggle') { + if (mode !== 'toggle' || !settings?.voice?.enabled) { return } - - const handleKeyDown = (): void => { - if (!settings?.voice?.enabled || dictationStateRef.current === 'stopping') { - return - } - if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { - void stopDictation() - } else { - void startDictation() - } - } - - const cleanup = window.api.ui.onDictationKeyDown(handleKeyDown) - return cleanup - }, [settings?.voice?.dictationMode, settings?.voice?.enabled, startDictation, stopDictation]) + return window.api.ui.onDictationKeyDown(toggleDictation) + }, [settings?.voice?.dictationMode, settings?.voice?.enabled, toggleDictation]) useEffect(() => { - const canDictate = (): boolean => Boolean(settings?.voice?.enabled) + if (!settings?.voice?.enabled) { + return + } const handleControl = (event: Event): void => { - if (!canDictate() || dictationStateRef.current === 'stopping') { - return - } const action = (event as CustomEvent).detail if (action === 'start') { - if (dictationStateRef.current === 'idle') { - void startDictation() - } - return - } - if (action === 'stop') { - if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { - void stopDictation() - } - return - } - if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void startDictation() + } else if (action === 'stop') { void stopDictation() } else { - void startDictation() + toggleDictation() } } document.addEventListener(DICTATION_CONTROL_EVENT, handleControl) return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) - }, [settings?.voice?.enabled, startDictation, stopDictation]) + }, [settings?.voice?.enabled, startDictation, stopDictation, toggleDictation]) useDictationSilenceStop( dictationState === 'listening', @@ -329,72 +295,43 @@ export function DictationController() { ) useHoldDictationGesture({ - dictationStateRef, + canStartDictation: () => canStartDictation(sessionRef.current.phase), keybindings, settings, startDictation, stopDictation }) - useEffect(() => { - const cleanupPartial = window.api.speech.onPartialTranscript((data) => { - if (data.sessionId !== activeSessionIdRef.current) { - return - } - setPartialTranscript(data.text) - }) - - const cleanupFinal = window.api.speech.onFinalTranscript((data) => { - if (data.sessionId !== activeSessionIdRef.current || !data.text) { - return - } + useDictationSpeechEvents({ + currentSessionId: () => sessionRef.current.sessionId, + onPartial: setPartialTranscript, + onFinal: (text) => { setPartialTranscript('') - finalTranscriptReceivedRef.current = true - deliverTranscriptRef.current?.(data.text, insertionTargetRef.current) - }) - - const cleanupStopped = window.api.speech.onStopped((data) => { - recordStoppedSession(data.sessionId, stoppedSessionIdsRef, stoppedResolversRef) - }) - - const cleanupError = window.api.speech.onError((data) => { - if (data.sessionId !== activeSessionIdRef.current) { - return - } - const sessionId = data.sessionId - erroredSessionIdsRef.current.add(sessionId) - dictationRunRef.current += 1 - activeSessionIdRef.current = null + dispatch({ type: 'final-text' }) + deliverRef.current?.(text) + }, + onStopped: (sessionId) => + recordStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef), + onError: (sessionId, error) => { toast.error( translate( 'auto.components.dictation.DictationController.de136f1199', 'Speech error: {{value0}}', - { value0: data.error } + { value0: error } ) ) - dictationStateRef.current = 'stopping' - setDictationState('stopping') + const { run } = dispatch({ type: 'abandon' }) stopCapture() discardBufferedAudio() void (async () => { await window.api.speech.stopDictation(sessionId).catch(() => undefined) await waitForStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef) - insertionTargetRef.current = null - stopRequestedDuringStartRef.current = false - finalTranscriptReceivedRef.current = false - dictationStateRef.current = 'idle' - setDictationState('idle') - setPartialTranscript('') + if (sessionRef.current.run === run) { + dispatch({ type: 'ended' }) + } })() - }) - - return () => { - cleanupPartial() - cleanupFinal() - cleanupStopped() - cleanupError() } - }, [setPartialTranscript, setDictationState, stopCapture, discardBufferedAudio]) + }) return } diff --git a/src/renderer/src/components/dictation/dictation-audio-limit-stop.ts b/src/renderer/src/components/dictation/dictation-audio-limit-stop.ts deleted file mode 100644 index 7a07a68af87..00000000000 --- a/src/renderer/src/components/dictation/dictation-audio-limit-stop.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RefObject } from 'react' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' - -/** The host keeps what was said up to its cap, so stop normally to transcribe and insert it. */ -export function stopDictationAtAudioLimit( - dictationRunRef: RefObject, - runId: number, - stopDictationRef: RefObject<(() => void) | null> -): void { - if (dictationRunRef.current !== runId) { - return - } - toast.message( - translate( - 'auto.components.dictation.DictationController.cloudLimitReached', - 'Dictation stopped at the 10-minute cloud limit.' - ) - ) - stopDictationRef.current?.() -} diff --git a/src/renderer/src/components/dictation/dictation-start-error-toast.ts b/src/renderer/src/components/dictation/dictation-toasts.ts similarity index 77% rename from src/renderer/src/components/dictation/dictation-start-error-toast.ts rename to src/renderer/src/components/dictation/dictation-toasts.ts index 56a0868c879..42c4a21d44c 100644 --- a/src/renderer/src/components/dictation/dictation-start-error-toast.ts +++ b/src/renderer/src/components/dictation/dictation-toasts.ts @@ -35,6 +35,34 @@ export function showDictationDisabledToast(): void { ) } +export function showMicrophoneFallbackToast(): void { + toast.message( + translate( + 'auto.components.dictation.DictationController.micFallback', + 'Selected microphone unavailable. Using system default.' + ) + ) +} + +/** The host keeps what was said up to its cap, so the caller stops normally to insert it. */ +export function showCloudLimitToast(): void { + toast.message( + translate( + 'auto.components.dictation.DictationController.cloudLimitReached', + 'Dictation stopped at the 10-minute cloud limit.' + ) + ) +} + +export function showMicrophoneDisconnectedToast(): void { + toast.message( + translate( + 'auto.components.dictation.DictationController.micDisconnected', + 'Microphone disconnected. Dictation stopped.' + ) + ) +} + export function showDictationStartErrorToast(message: string): void { // A paired web client rejects with the host's bare code, which String(error) prefixes with "Error: ". const detail = compactIpcErrorMessage(message)?.replace(/^Error:\s*/, '') ?? '' diff --git a/src/renderer/src/components/dictation/use-dictation-speech-events.ts b/src/renderer/src/components/dictation/use-dictation-speech-events.ts new file mode 100644 index 00000000000..3fc07a10979 --- /dev/null +++ b/src/renderer/src/components/dictation/use-dictation-speech-events.ts @@ -0,0 +1,40 @@ +import { useEffect, useEffectEvent } from 'react' + +type DictationSpeechEventHandlers = { + /** Text events are dropped unless they belong to this session. */ + currentSessionId: () => string | null + onPartial: (text: string) => void + onFinal: (text: string) => void + onStopped: (sessionId: string) => void + onError: (sessionId: string, error: string) => void +} + +export function useDictationSpeechEvents(handlers: DictationSpeechEventHandlers): void { + const isCurrent = useEffectEvent((sessionId: string) => sessionId === handlers.currentSessionId()) + const onPartial = useEffectEvent(handlers.onPartial) + const onFinal = useEffectEvent(handlers.onFinal) + const onStopped = useEffectEvent(handlers.onStopped) + const onError = useEffectEvent(handlers.onError) + + useEffect(() => { + const cleanups = [ + window.api.speech.onPartialTranscript((data) => { + if (isCurrent(data.sessionId)) { + onPartial(data.text) + } + }), + window.api.speech.onFinalTranscript((data) => { + if (data.text && isCurrent(data.sessionId)) { + onFinal(data.text) + } + }), + window.api.speech.onStopped((data) => onStopped(data.sessionId)), + window.api.speech.onError((data) => { + if (isCurrent(data.sessionId)) { + onError(data.sessionId, data.error) + } + }) + ] + return () => cleanups.forEach((cleanup) => cleanup()) + }, []) +} diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx index 6d15d4a30e7..2c195fa8f15 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { createRef, type MutableRefObject, act } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DictationState } from '../../../../shared/speech-types' @@ -11,7 +11,7 @@ const originalUserAgent = navigator.userAgent let root: Root | null = null let container: HTMLDivElement | null = null -let dictationStateRef: MutableRefObject +let dictationState: DictationState let startDictation: ReturnType void>> let stopDictation: ReturnType void>> @@ -34,7 +34,7 @@ function holdSettings(): GlobalSettings { function Probe(): null { useHoldDictationGesture({ - dictationStateRef, + canStartDictation: () => dictationState === 'idle', keybindings: {}, settings: holdSettings(), startDictation, @@ -68,13 +68,12 @@ function beginHold(init: KeyboardEventInit = {}): void { act(() => { dispatchKeyDown({ key: 'e', code: 'KeyE', metaKey: true, ...init }) }) - dictationStateRef.current = 'listening' + dictationState = 'listening' } beforeEach(() => { setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)') - dictationStateRef = createRef() as MutableRefObject - dictationStateRef.current = 'idle' + dictationState = 'idle' startDictation = vi.fn<() => void>() stopDictation = vi.fn<() => void>() }) @@ -204,7 +203,7 @@ describe('useHoldDictationGesture', () => { await renderProbe() beginHold() - dictationStateRef.current = 'idle' + dictationState = 'idle' act(() => { dispatchKeyDown({ key: 'e', code: 'KeyE', metaKey: true, repeat: true }) diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts index b4f81691848..96d7e998117 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts @@ -1,12 +1,11 @@ -import { useEffect, useEffectEvent, useRef, type MutableRefObject } from 'react' +import { useEffect, useEffectEvent, useRef } from 'react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' -import type { DictationState } from '../../../../shared/speech-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type HoldDictationGestureOptions = { - dictationStateRef: MutableRefObject + canStartDictation: () => boolean keybindings: KeybindingOverrides settings: GlobalSettings | null startDictation: () => Promise | void @@ -104,7 +103,7 @@ function createHoldDictationReleaseMatcher(event: KeyboardEvent): HoldDictationR } export function useHoldDictationGesture({ - dictationStateRef, + canStartDictation, keybindings, settings, startDictation, @@ -116,6 +115,7 @@ export function useHoldDictationGesture({ // Settings and keybinding refreshes re-create these inputs; re-running the effect would end a held dictation. const start = useEffectEvent(() => void startDictation()) const stop = useEffectEvent(() => void stopDictation()) + const canStart = useEffectEvent(() => canStartDictation()) const matchesShortcut = useEffectEvent((e: KeyboardEvent) => keybindingMatchesAction('voice.dictation', e, getShortcutPlatform(), keybindings) ) @@ -149,7 +149,7 @@ export function useHoldDictationGesture({ } const hold = activeHoldRef.current // One dictation per press: key repeat after an automatic stop must not start another. - if (hold && !hold.startedDictation && dictationStateRef.current === 'idle') { + if (hold && !hold.startedDictation && canStart()) { hold.startedDictation = true start() } @@ -179,5 +179,5 @@ export function useHoldDictationGesture({ window.removeEventListener('blur', endHold) document.removeEventListener('visibilitychange', handleVisibilityChange) } - }, [mode, enabled, dictationStateRef]) + }, [mode, enabled]) } diff --git a/src/shared/dictation-session-machine.test.ts b/src/shared/dictation-session-machine.test.ts new file mode 100644 index 00000000000..829aef86632 --- /dev/null +++ b/src/shared/dictation-session-machine.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + initialDictationSession, + transitionDictationSession, + type DictationSession, + type DictationSessionEvent +} from './dictation-session-machine' + +function run(...events: DictationSessionEvent['type'][]): DictationSession { + return events.reduce( + (session, type) => transitionDictationSession(session, { type }), + initialDictationSession + ) +} + +describe('transitionDictationSession', () => { + it('keeps the session when stop arrives while starting, and finishes instead of listening', () => { + const started = run('start') + const stopped = run('start', 'stop', 'engine-ready') + expect(stopped).toMatchObject({ phase: 'stopping', sessionId: started.sessionId }) + }) + + it('drops the session on an engine error and refuses a new start until teardown ends', () => { + const listening = run('start', 'engine-ready') + const abandoned = transitionDictationSession(listening, { type: 'abandon' }) + expect(abandoned).toMatchObject({ phase: 'stopping', sessionId: null }) + expect(abandoned.run).not.toBe(listening.run) + expect(transitionDictationSession(abandoned, { type: 'start' })).toBe(abandoned) + + const restarted = run('start', 'engine-ready', 'abandon', 'ended', 'start') + expect(restarted.phase).toBe('starting') + expect(restarted.sessionId).not.toBe(listening.sessionId) + }) + + it('ignores stop once the session is already stopping', () => { + const stopping = run('start', 'engine-ready', 'stop') + expect(transitionDictationSession(stopping, { type: 'stop' })).toBe(stopping) + }) +}) diff --git a/src/shared/dictation-session-machine.ts b/src/shared/dictation-session-machine.ts new file mode 100644 index 00000000000..79c300c42db --- /dev/null +++ b/src/shared/dictation-session-machine.ts @@ -0,0 +1,74 @@ +import type { DictationState } from './speech-types' + +/** One dictation session's lifecycle, shared so every client follows the same start/stop/cancel rules. */ +export type DictationSession = { + phase: DictationState + /** Bumps on every start and every drop, so async work from an older run can tell it lost. */ + run: number + /** Speech events are accepted only for this id. */ + sessionId: string | null + heardFinalText: boolean +} + +export type DictationSessionEvent = + | { type: 'start' } + /** Keeps the session: a stop while starting finishes (and inserts the words) once the engine is up. */ + | { type: 'stop' } + | { type: 'engine-ready' } + | { type: 'final-text' } + /** The engine failed mid-session: drop the session while capture and the engine tear down. */ + | { type: 'abandon' } + /** Starting failed: drop the session and show the error. */ + | { type: 'fail' } + /** Drop the session without finishing it. */ + | { type: 'cancel' } + /** Teardown finished; back to idle. */ + | { type: 'ended' } + +export const initialDictationSession: DictationSession = { + phase: 'idle', + run: 0, + sessionId: null, + heardFinalText: false +} + +export function canStartDictation(phase: DictationState): boolean { + return phase === 'idle' +} + +export function isDictationUnderway(phase: DictationState): boolean { + return phase === 'starting' || phase === 'listening' +} + +/** Returns the same object when the event does not apply, so callers can skip its effects. */ +export function transitionDictationSession( + session: DictationSession, + event: DictationSessionEvent +): DictationSession { + switch (event.type) { + case 'start': { + if (!canStartDictation(session.phase)) { + return session + } + const run = session.run + 1 + return { phase: 'starting', run, sessionId: String(run), heardFinalText: false } + } + case 'stop': + return isDictationUnderway(session.phase) ? { ...session, phase: 'stopping' } : session + case 'engine-ready': + return session.phase === 'starting' ? { ...session, phase: 'listening' } : session + case 'final-text': + return session.heardFinalText ? session : { ...session, heardFinalText: true } + case 'abandon': + return dropSession(session, 'stopping') + case 'fail': + return dropSession(session, 'error') + case 'cancel': + case 'ended': + return dropSession(session, 'idle') + } +} + +function dropSession(session: DictationSession, phase: DictationState): DictationSession { + return { phase, run: session.run + 1, sessionId: null, heardFinalText: false } +}