2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
type BrowserSpeechRecognition = SpeechRecognition & {
|
|
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
|
|
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
|
|
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
|
|
};
|
|
|
|
type SpeechRecognitionCtor = new () => BrowserSpeechRecognition;
|
|
|
|
declare global {
|
|
interface Window {
|
|
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
|
}
|
|
}
|
|
|
|
export function isSpeechRecognitionSupported() {
|
|
return typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
|
}
|
|
|
|
function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
|
if (typeof window === 'undefined') return null;
|
|
return window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null;
|
|
}
|
|
|
|
export function createLiveSpeechRecognition({
|
|
onInterim,
|
|
onFinal,
|
|
onError,
|
|
}: {
|
|
onInterim: (text: string) => void;
|
|
onFinal: (text: string) => void;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const Ctor = getSpeechRecognitionCtor();
|
|
if (!Ctor) return null;
|
|
|
|
const recognition = new Ctor();
|
|
recognition.lang = 'zh-CN';
|
|
recognition.interimResults = true;
|
|
recognition.continuous = true;
|
|
recognition.maxAlternatives = 1;
|
|
|
|
let active = false;
|
|
let restarting = false;
|
|
let restartTimer: number | null = null;
|
|
|
|
const clearRestartTimer = () => {
|
|
if (restartTimer != null) {
|
|
window.clearTimeout(restartTimer);
|
|
restartTimer = null;
|
|
}
|
|
restarting = false;
|
|
};
|
|
|
|
recognition.onresult = (event) => {
|
|
let interim = '';
|
|
let finals = '';
|
|
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
const result = event.results[i];
|
|
const chunk = result[0]?.transcript ?? '';
|
|
if (result.isFinal) finals += chunk;
|
|
else interim += chunk;
|
|
}
|
|
if (finals) onFinal(finals);
|
|
onInterim(interim);
|
|
};
|
|
|
|
recognition.onerror = (event) => {
|
|
if (event.error === 'aborted' || event.error === 'no-speech') return;
|
|
if (event.error === 'not-allowed') {
|
|
onError('请在浏览器设置中允许麦克风');
|
|
return;
|
|
}
|
|
if (event.error === 'network') {
|
|
onError('语音识别网络异常,请检查连接');
|
|
return;
|
|
}
|
|
onError(`语音识别失败:${event.error}`);
|
|
};
|
|
|
|
recognition.onend = () => {
|
|
if (!active || restarting) return;
|
|
restarting = true;
|
|
restartTimer = window.setTimeout(() => {
|
|
restartTimer = null;
|
|
restarting = false;
|
|
if (!active) return;
|
|
try {
|
|
recognition.start();
|
|
} catch {
|
|
// ignore restart races
|
|
}
|
|
}, 120);
|
|
};
|
|
|
|
return {
|
|
start() {
|
|
clearRestartTimer();
|
|
active = true;
|
|
try {
|
|
recognition.start();
|
|
} catch (err) {
|
|
active = false;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (/already started/i.test(message)) {
|
|
window.setTimeout(() => {
|
|
if (!active) return;
|
|
try {
|
|
recognition.start();
|
|
} catch {
|
|
onError('无法启动语音识别,请关闭后重试');
|
|
}
|
|
}, 150);
|
|
return;
|
|
}
|
|
onError('无法启动语音识别,请重试');
|
|
}
|
|
},
|
|
stop() {
|
|
active = false;
|
|
clearRestartTimer();
|
|
recognition.stop();
|
|
},
|
|
abort() {
|
|
active = false;
|
|
clearRestartTimer();
|
|
recognition.abort();
|
|
},
|
|
};
|
|
}
|