'use client'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { EMPTY_TRANSCRIPT, INITIAL_STATE, buildTranscript, getSpeechLogger, reducer, } from '../core'; const log = getSpeechLogger(); import { createWebSpeechEngine } from '../core/engine/webspeech'; import { useSpeechPrefs } from '../store/prefsStore'; import type { RecognitionEngine, Segment, UseSpeechRecognitionConfig, UseSpeechRecognitionReturn, } from '../types'; import { useMicLevel } from './useMicLevel'; import { useResolvedLanguage } from './useResolvedLanguage'; /** * Main entry point. With no config it uses the browser Web Speech API * and the persisted language from `useSpeechPrefs`. Pass a custom * `engine` to route through Deepgram / Whisper / custom WebSocket. */ export function useSpeechRecognition( config: UseSpeechRecognitionConfig = {}, ): UseSpeechRecognitionReturn { const prefs = useSpeechPrefs(); const language = useResolvedLanguage(config.language); const engine = useMemo( () => config.engine ?? createWebSpeechEngine(), [config.engine], ); const [state, dispatch] = useReducer(reducer, INITIAL_STATE); const [stream, setStream] = useState(null); const level = useMicLevel(stream); // Latest-callback refs so engine subscriptions never tear down on // every render — same trick the Chat reducer uses. const cbRef = useRef(config); cbRef.current = config; // Engine subscription lifecycle. useEffect(() => { log.engine.debug('subscribe', { engineId: engine.id }); const offs = [ engine.on('partial', (text, segmentId) => { log.engine.debug('partial', { len: text.length, segmentId }); dispatch({ type: 'PARTIAL', text, segmentId }); const seg: Segment = { id: segmentId, text, isFinal: false, startedAt: Date.now(), }; cbRef.current.onPartial?.(text, seg); }), engine.on('final', (text, segmentId, confidence) => { log.engine.info('final', { len: text.length, segmentId, confidence, }); dispatch({ type: 'FINAL', text, segmentId, confidence }); const seg: Segment = { id: segmentId, text, isFinal: true, confidence, startedAt: Date.now(), endedAt: Date.now(), }; cbRef.current.onFinal?.(text, seg); }), engine.on('error', (err) => { log.error.error('engine error', err); dispatch({ type: 'ERROR', error: err }); cbRef.current.onError?.(err); }), engine.on('state', (s) => { log.engine.debug('state', s); if (s === 'listening') { dispatch({ type: 'STARTED' }); cbRef.current.onStart?.(); setStream(engine.getStream?.() ?? null); } else if (s === 'closed') { dispatch({ type: 'STOPPED' }); cbRef.current.onStop?.(); setStream(null); } }), ]; return () => { offs.forEach((off) => off()); // Release the mic / socket if the consumer unmounts (or swaps // the engine) mid-session. Without this the MediaRecorder and // getUserMedia stream keep running headless after unmount. engine.abort(); }; }, [engine]); // AutoStop driven by silence + maxMs caps. `level` updates ~60fps, // so it must NOT live in the effect deps — otherwise the silence // interval is torn down and recreated every animation frame, which // both wastes CPU and resets the silence timer before it can fire. // The interval reads the freshest level through a ref instead. const silenceTimer = useRef(null); const maxTimer = useRef(null); const levelRef = useRef(level); levelRef.current = level; useEffect(() => { if (state.status !== 'listening') return undefined; const { silenceMs, maxMs, silenceThreshold = 0.02 } = config.autoStop ?? {}; if (maxMs) { maxTimer.current = window.setTimeout(() => { log.engine.debug('autoStop max duration hit'); void engine.stop(); }, maxMs); } let checkInterval: number | null = null; if (silenceMs) { checkInterval = window.setInterval(() => { if (levelRef.current < silenceThreshold) { if (silenceTimer.current == null) { silenceTimer.current = window.setTimeout(() => { log.engine.debug('autoStop silence detected'); void engine.stop(); }, silenceMs); } } else if (silenceTimer.current != null) { clearTimeout(silenceTimer.current); silenceTimer.current = null; } }, 200); } return () => { if (checkInterval != null) clearInterval(checkInterval); if (silenceTimer.current != null) clearTimeout(silenceTimer.current); silenceTimer.current = null; if (maxTimer.current != null) clearTimeout(maxTimer.current); maxTimer.current = null; }; }, [state.status, config.autoStop, engine]); const start = useCallback(async () => { if (state.status === 'listening' || state.status === 'starting') return; dispatch({ type: 'START' }); try { await engine.start({ language, interim: config.interim ?? true, deviceId: config.deviceId ?? prefs.deviceId ?? undefined, }); } catch (cause) { // engine already emitted 'error'; reducer caught it via subscription log.engine.warn('start threw', cause); } }, [engine, language, config.interim, config.deviceId, prefs.deviceId, state.status]); const stop = useCallback(async () => { if (state.status === 'idle' || state.status === 'stopping') return; dispatch({ type: 'STOP' }); await engine.stop(); }, [engine, state.status]); const abort = useCallback(() => { engine.abort(); dispatch({ type: 'ABORT' }); }, [engine]); const toggle = useCallback(async () => { if (state.status === 'listening' || state.status === 'starting') { await stop(); } else { await start(); } }, [state.status, start, stop]); const reset = useCallback(() => { dispatch({ type: 'RESET' }); }, []); const transcript = useMemo( () => (state.segments.length === 0 ? EMPTY_TRANSCRIPT : buildTranscript(state.segments)), [state.segments], ); return { status: state.status, isSupported: engine.isSupported, transcript, error: state.error, level, start, stop, abort, toggle, reset, }; }