"use client"; import * as React from "react"; /** Minimal typing for the Web Speech API (not in lib.dom for every TS target). */ interface SpeechRecognitionLike { lang: string; continuous: boolean; interimResults: boolean; start(): void; stop(): void; abort(): void; onresult: ((ev: { resultIndex: number; results: ArrayLike & { isFinal: boolean }> }) => void) | null; onerror: ((ev: { error: string }) => void) | null; onend: (() => void) | null; } type SpeechRecognitionCtor = new () => SpeechRecognitionLike; function getCtor(): SpeechRecognitionCtor | null { if (typeof window === "undefined") return null; const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor }; return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } /** * Voice dictation via `SpeechRecognition` / `webkitSpeechRecognition`. `supported` is false where the * API is missing (Firefox, some WebViews) so the microphone button can be hidden entirely. * `onText(final, interim)` receives the accumulated final transcript and the live interim text. */ export function useSpeechDictation(onText: (final: string, interim: string) => void, opts: { lang?: string } = {}) { const supported = React.useSyncExternalStore( () => () => {}, () => getCtor() !== null, () => false, ); const [listening, setListening] = React.useState(false); const [error, setError] = React.useState(null); const recRef = React.useRef(null); const finalRef = React.useRef(""); const cbRef = React.useRef(onText); React.useEffect(() => { cbRef.current = onText; }, [onText]); const stop = React.useCallback(() => { recRef.current?.stop(); }, []); const start = React.useCallback(() => { const Ctor = getCtor(); if (!Ctor) return; const rec = new Ctor(); rec.lang = opts.lang ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"); rec.continuous = true; rec.interimResults = true; finalRef.current = ""; rec.onresult = (ev) => { let interim = ""; for (let i = ev.resultIndex; i < ev.results.length; i++) { const r = ev.results[i]; const t = r[0]?.transcript ?? ""; if (r.isFinal) finalRef.current += (finalRef.current && !/\s$/.test(finalRef.current) ? " " : "") + t.trim(); else interim += t; } cbRef.current(finalRef.current, interim); }; rec.onerror = (ev) => { setError(ev.error === "not-allowed" ? "Microphone access was denied." : ev.error === "no-speech" ? null : `Dictation error: ${ev.error}`); }; rec.onend = () => { setListening(false); recRef.current = null; }; recRef.current = rec; setError(null); setListening(true); try { rec.start(); } catch { setListening(false); recRef.current = null; } }, [opts.lang]); const toggle = React.useCallback(() => { if (listening) stop(); else start(); }, [listening, start, stop]); React.useEffect(() => () => recRef.current?.abort(), []); return { supported, listening, error, start, stop, toggle }; }