SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
3.2 KB · 93 lines typescript
Raw Blame History
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