Spaces:
Sleeping
Sleeping
File size: 4,797 Bytes
535a98d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | import { useCallback, useEffect, useRef, useState } from "react";
// Thin wrapper around the Web Speech API. Chrome/Edge expose
// `webkitSpeechRecognition`; Safari/Firefox don't — we no-op gracefully.
type SRCtor = new () => SpeechRecognitionLike;
interface SpeechRecognitionLike {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onerror: ((e: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
}
interface SpeechRecognitionEventLike {
results: {
length: number;
[index: number]: {
isFinal: boolean;
length: number;
[index: number]: { transcript: string; confidence: number };
};
};
}
function getRecognitionCtor(): SRCtor | null {
if (typeof window === "undefined") return null;
const w = window as unknown as {
SpeechRecognition?: SRCtor;
webkitSpeechRecognition?: SRCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export interface VoiceCapture {
transcript: string;
confidence: number;
}
const WINDOW_MS = 3500;
export function useVoice() {
const [supported] = useState(() => getRecognitionCtor() !== null);
const [listening, setListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const recRef = useRef<SpeechRecognitionLike | null>(null);
const resolveRef = useRef<((v: VoiceCapture) => void) | null>(null);
const rejectRef = useRef<((err: Error) => void) | null>(null);
const bestRef = useRef<VoiceCapture>({ transcript: "", confidence: 0 });
const timerRef = useRef<number | null>(null);
const cleanup = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
const rec = recRef.current;
if (rec) {
try {
rec.abort();
} catch {
// ignore — some browsers throw if already stopped
}
rec.onresult = null;
rec.onerror = null;
rec.onend = null;
}
recRef.current = null;
resolveRef.current = null;
rejectRef.current = null;
setListening(false);
}, []);
// Unmount teardown: reject any in-flight promise so `await voice.capture()`
// in a parent component doesn't hang forever when the tree unmounts mid-listen.
useEffect(
() => () => {
const rj = rejectRef.current;
cleanup();
if (rj) rj(new Error("unmounted"));
},
[cleanup]
);
const capture = useCallback((): Promise<VoiceCapture> => {
const Ctor = getRecognitionCtor();
if (!Ctor) {
return Promise.reject(new Error("Speech recognition not supported"));
}
if (recRef.current) {
return Promise.reject(new Error("Already listening"));
}
return new Promise<VoiceCapture>((resolve, reject) => {
const rec = new Ctor();
rec.lang = navigator.language || "en-US";
rec.continuous = false;
rec.interimResults = false;
rec.maxAlternatives = 1;
bestRef.current = { transcript: "", confidence: 0 };
resolveRef.current = resolve;
rejectRef.current = reject;
recRef.current = rec;
setError(null);
rec.onresult = (e) => {
for (let i = 0; i < e.results.length; i++) {
const res = e.results[i];
if (!res.isFinal) continue;
const alt = res[0];
if (alt && alt.transcript.trim().length > 0) {
if (alt.confidence > bestRef.current.confidence) {
bestRef.current = {
transcript: alt.transcript.trim(),
confidence: alt.confidence,
};
}
}
}
};
rec.onerror = (e) => {
const msg = e.error || "recognition error";
setError(msg);
const rj = rejectRef.current;
cleanup();
if (rj) rj(new Error(msg));
};
rec.onend = () => {
const rs = resolveRef.current;
const best = bestRef.current;
cleanup();
if (rs) rs(best);
};
try {
rec.start();
setListening(true);
timerRef.current = window.setTimeout(() => {
try {
rec.stop();
} catch {
// onend will still fire
}
}, WINDOW_MS);
} catch (err) {
const msg = err instanceof Error ? err.message : "failed to start";
setError(msg);
cleanup();
reject(new Error(msg));
}
});
}, [cleanup]);
const cancel = useCallback(() => {
const rj = rejectRef.current;
cleanup();
if (rj) rj(new Error("cancelled"));
}, [cleanup]);
return { supported, listening, error, capture, cancel };
}
|