Organize/hooks/use-speech-recognition.ts

169 lines
6.1 KiB
TypeScript

"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<SpeechRecognitionResultLike>;
}
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<SpeechRecognitionLike | null>(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 };
}