"use client"; import { useCallback, useEffect, useRef, useState } from "react"; // The Web Speech API isn't part of TypeScript's built-in DOM types, so // this declares just enough of the shape this hook actually touches // (both the standard and Chrome/Safari's webkit-prefixed constructor // implement the same interface). interface SpeechRecognitionResultLike { isFinal: boolean; [index: number]: { transcript: string }; } interface SpeechRecognitionEventLike extends Event { resultIndex: number; results: ArrayLike; } interface SpeechRecognitionErrorEventLike extends Event { error: string; } interface SpeechRecognitionLike extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; start(): void; stop(): void; onresult: ((event: SpeechRecognitionEventLike) => void) | null; onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null; onend: (() => void) | null; } type SpeechRecognitionConstructor = new () => SpeechRecognitionLike; function getSpeechRecognitionConstructor(): SpeechRecognitionConstructor | null { if (typeof window === "undefined") return null; const w = window as unknown as { SpeechRecognition?: SpeechRecognitionConstructor; webkitSpeechRecognition?: SpeechRecognitionConstructor; }; return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } /** * Thin wrapper around the browser's native SpeechRecognition API. * `supported` reflects whether it exists at all -- notably absent in * Firefox, inconsistent on some mobile browsers -- so callers can hide * the mic affordance entirely rather than show a button that just fails. * * Recognizes continuously while listening; each finalized phrase is * reported via `onFinalResult` as it resolves (not batched up until * `stop()` -- the API has no such mode), so by the time the caller stops * it, everything spoken has already arrived. */ export function useSpeechRecognition({ onFinalResult, onError, }: { onFinalResult: (transcript: string) => void; onError?: (message: string) => void; }) { const [listening, setListening] = useState(false); const [supported, setSupported] = useState(false); const recognitionRef = useRef(null); // True only while the user has deliberately asked to stop -- lets // `onend` (below) tell "the user clicked stop" apart from "the browser // ended the session on its own", which it can do well before that (a // few seconds of silence is enough on some browsers, even with // `continuous = true`). Without that distinction, a spontaneous end // right after starting looked exactly like a single click both // starting *and* immediately stopping recording. const userStoppedRef = useRef(false); // Kept in a ref so `start` doesn't need to change identity (and doesn't // need to be recreated) just because the caller's callback did. Synced // in an effect rather than during render -- refs aren't meant to be // written outside of effects/event handlers. const onFinalResultRef = useRef(onFinalResult); useEffect(() => { onFinalResultRef.current = onFinalResult; }, [onFinalResult]); const onErrorRef = useRef(onError); useEffect(() => { onErrorRef.current = onError; }, [onError]); // `start` restarts itself (see onend below) on a spontaneous end, but a // useCallback can't cleanly reference its own not-yet-assigned const // from inside its own body -- routed through a ref instead, assigned // once start's identity is known. const startRef = useRef<() => void>(() => {}); useEffect(() => { setSupported(getSpeechRecognitionConstructor() !== null); }, []); const start = useCallback(() => { const Ctor = getSpeechRecognitionConstructor(); if (!Ctor) return; userStoppedRef.current = false; // Scoped to this one recognition instance/attempt -- a restart (see // onend below) gets a fresh recognition object and a fresh flag, not // whatever an earlier attempt left behind. let erroredMessage: string | null = null; const recognition = new Ctor(); recognition.continuous = true; recognition.interimResults = false; recognition.lang = typeof navigator !== "undefined" ? navigator.language : "en-US"; recognition.onresult = (event) => { let finalText = ""; for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; if (result.isFinal) finalText += result[0].transcript; } if (finalText.trim()) onFinalResultRef.current(finalText.trim()); }; recognition.onerror = (event) => { const errorType = event.error; erroredMessage = errorType === "not-allowed" || errorType === "service-not-allowed" ? "Microphone access was blocked." : errorType === "audio-capture" ? "No microphone was found." : errorType === "no-speech" ? null // silence isn't an error worth surfacing -- just let it end/restart : "Voice input stopped unexpectedly."; }; recognition.onend = () => { if (userStoppedRef.current) { setListening(false); return; } if (erroredMessage) { setListening(false); onErrorRef.current?.(erroredMessage); return; } // Ended on its own while the user still wants to be listening -- // pick the mic back up transparently rather than flipping the // button back to "Mic", which is what made this look like a // double-click in the first place. startRef.current(); }; recognitionRef.current = recognition; recognition.start(); setListening(true); }, []); useEffect(() => { startRef.current = start; }, [start]); const stop = useCallback(() => { userStoppedRef.current = true; recognitionRef.current?.stop(); setListening(false); }, []); // Stop listening if the component unmounts (dialog closed, etc.) -- // otherwise the browser keeps the mic hot and events fire into a // detached callback. useEffect(() => { return () => { recognitionRef.current?.stop(); }; }, []); return { supported, listening, start, stop }; }