TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";34/** Minimal typing for the Web Speech API (not in lib.dom for every TS target). */5interface SpeechRecognitionLike {6 lang: string;7 continuous: boolean;8 interimResults: boolean;9 start(): void;10 stop(): void;11 abort(): void;12 onresult: ((ev: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }> }) => void) | null;13 onerror: ((ev: { error: string }) => void) | null;14 onend: (() => void) | null;15}16type SpeechRecognitionCtor = new () => SpeechRecognitionLike;1718function getCtor(): SpeechRecognitionCtor | null {19 if (typeof window === "undefined") return null;20 const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor };21 return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;22}2324/**25 * Voice dictation via `SpeechRecognition` / `webkitSpeechRecognition`. `supported` is false where the26 * API is missing (Firefox, some WebViews) so the microphone button can be hidden entirely.27 * `onText(final, interim)` receives the accumulated final transcript and the live interim text.28 */29export function useSpeechDictation(onText: (final: string, interim: string) => void, opts: { lang?: string } = {}) {30 const supported = React.useSyncExternalStore(31 () => () => {},32 () => getCtor() !== null,33 () => false,34 );35 const [listening, setListening] = React.useState(false);36 const [error, setError] = React.useState<string | null>(null);37 const recRef = React.useRef<SpeechRecognitionLike | null>(null);38 const finalRef = React.useRef("");39 const cbRef = React.useRef(onText);40 React.useEffect(() => {41 cbRef.current = onText;42 }, [onText]);4344 const stop = React.useCallback(() => {45 recRef.current?.stop();46 }, []);4748 const start = React.useCallback(() => {49 const Ctor = getCtor();50 if (!Ctor) return;51 const rec = new Ctor();52 rec.lang = opts.lang ?? (typeof navigator !== "undefined" ? navigator.language : "en-US");53 rec.continuous = true;54 rec.interimResults = true;55 finalRef.current = "";56 rec.onresult = (ev) => {57 let interim = "";58 for (let i = ev.resultIndex; i < ev.results.length; i++) {59 const r = ev.results[i];60 const t = r[0]?.transcript ?? "";61 if (r.isFinal) finalRef.current += (finalRef.current && !/\s$/.test(finalRef.current) ? " " : "") + t.trim();62 else interim += t;63 }64 cbRef.current(finalRef.current, interim);65 };66 rec.onerror = (ev) => {67 setError(ev.error === "not-allowed" ? "Microphone access was denied." : ev.error === "no-speech" ? null : `Dictation error: ${ev.error}`);68 };69 rec.onend = () => {70 setListening(false);71 recRef.current = null;72 };73 recRef.current = rec;74 setError(null);75 setListening(true);76 try {77 rec.start();78 } catch {79 setListening(false);80 recRef.current = null;81 }82 }, [opts.lang]);8384 const toggle = React.useCallback(() => {85 if (listening) stop();86 else start();87 }, [listening, start, stop]);8889 React.useEffect(() => () => recRef.current?.abort(), []);9091 return { supported, listening, error, start, stop, toggle };92}93