mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
refactor(dictation): run the desktop dictation controller on a shared session state machine (#39284)
* refactor(dictation): run the desktop controller on a shared session state machine * test(dictation): assert session machine behaviour, not run numbering * refactor(dictation): allow a new start from the error phase * test(dictation): drop the error-restart case no desktop path depends on yet * refactor(dictation): derive stop-during-start from the phase and share the start teardown
This commit is contained in:
@@ -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<DictationInsertionTarget | null>(null)
|
||||
const activeSessionIdRef = useRef<string | null>(null)
|
||||
const sessionRef = useRef<DictationSession>(initialDictationSession)
|
||||
// Inserts the current session's finals where dictation started.
|
||||
const deliverRef = useRef<((text: string) => void) | null>(null)
|
||||
const stoppedSessionIdsRef = useRef(new Set<string>())
|
||||
const stoppedResolversRef = useRef(new Map<string, () => void>())
|
||||
const stopRequestedDuringStartRef = useRef(false)
|
||||
const finalTranscriptReceivedRef = useRef(false)
|
||||
const erroredSessionIdsRef = useRef(new Set<string>())
|
||||
const deliverTranscriptRef = useRef<DictationTranscriptDelivery | null>(null)
|
||||
// Push-to-talk restarts per utterance; report a missing preferred microphone once per preference.
|
||||
const micFallbackNotifiedForRef = useRef<string | null>(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<void> => {
|
||||
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<DictationControlAction>).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 <DictationIndicator captureLive={captureLive} />
|
||||
}
|
||||
|
||||
@@ -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<number>,
|
||||
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?.()
|
||||
}
|
||||
+28
@@ -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*/, '') ?? ''
|
||||
@@ -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())
|
||||
}, [])
|
||||
}
|
||||
@@ -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<DictationState>
|
||||
let dictationState: DictationState
|
||||
let startDictation: ReturnType<typeof vi.fn<() => void>>
|
||||
let stopDictation: ReturnType<typeof vi.fn<() => 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<DictationState>() as MutableRefObject<DictationState>
|
||||
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 })
|
||||
|
||||
@@ -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<DictationState>
|
||||
canStartDictation: () => boolean
|
||||
keybindings: KeybindingOverrides
|
||||
settings: GlobalSettings | null
|
||||
startDictation: () => Promise<void> | 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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user