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:
Kelvin Amoaba
2026-11-07 04:53:39 +00:00
committed by GitHub
parent 3d8a285132
commit b051519599
8 changed files with 341 additions and 245 deletions
@@ -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?.()
}
@@ -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)
})
})
+74
View File
@@ -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 }
}