181 lines
7.1 KiB
TypeScript
181 lines
7.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;
|
|
// Logged (not just surfaced in the UI) because the friendly fallback
|
|
// message below deliberately can't describe every one of the API's
|
|
// ~10 error codes -- this is the fast path to finding out which one
|
|
// is actually firing when a user reports "it just stops".
|
|
console.error("SpeechRecognition error:", errorType);
|
|
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
|
|
: errorType === "network"
|
|
? // The browser's built-in speech recognition isn't local --
|
|
// it streams audio to the browser vendor's cloud service
|
|
// and gets text back. This fires when that round trip
|
|
// fails, most often a firewall/proxy blocking it rather
|
|
// than a real outage (nothing this app can route around).
|
|
"Couldn't reach the browser's speech recognition service -- this is usually a network/firewall restriction, not an app problem. Try a different network, or type instead."
|
|
: `Voice input stopped unexpectedly (${errorType}).`;
|
|
};
|
|
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 };
|
|
}
|