const VOICES_LOAD_TIMEOUT_MS = 2000; const PREVIEW_TIMEOUT_MS = 30000; const CJK_LANG_THRESHOLD = 0.3; type PlayBrowserTTSPreviewOptions = { text: string; voice?: string; rate?: number; voices?: SpeechSynthesisVoice[]; }; function createAbortError(): Error { const error = new Error('Browser TTS preview canceled'); error.name = 'AbortError'; return error; } function inferPreviewLang(text: string): string { const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const ratio = text.length > 0 ? cjkCount / text.length : 0; return ratio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; } export function isBrowserTTSAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError'; } /** Wait for browser voices to load, with a 2s timeout fallback. */ export async function ensureVoicesLoaded(): Promise { if (typeof window === 'undefined' || !window.speechSynthesis) { return []; } const initialVoices = window.speechSynthesis.getVoices(); if (initialVoices.length > 0) { return initialVoices; } return new Promise((resolve) => { let settled = false; let timeoutId: number | null = null; const cleanup = () => { window.speechSynthesis.removeEventListener('voiceschanged', handleVoicesChanged); if (timeoutId !== null) { window.clearTimeout(timeoutId); } }; const finish = () => { if (settled) return; settled = true; cleanup(); resolve(window.speechSynthesis.getVoices()); }; const handleVoicesChanged = () => { const voices = window.speechSynthesis.getVoices(); if (voices.length > 0) { finish(); } }; window.speechSynthesis.addEventListener('voiceschanged', handleVoicesChanged); timeoutId = window.setTimeout(finish, VOICES_LOAD_TIMEOUT_MS); }); } /** Resolve a browser voice by voiceURI, name, or lang, with language fallback by text. */ export function resolveBrowserVoice( voices: SpeechSynthesisVoice[], voiceNameOrLang: string, text: string, ): { voice: SpeechSynthesisVoice | null; lang: string } { const target = voiceNameOrLang.trim(); const matchedVoice = target && target !== 'default' ? voices.find( (voice) => voice.voiceURI === target || voice.name === target || voice.lang === target, ) || null : null; return { voice: matchedVoice, lang: matchedVoice?.lang || inferPreviewLang(text), }; } /** * Play a short browser-native TTS preview. * * Notes: * - Uses the global speechSynthesis queue, so it must cancel queued utterances * before starting a new preview. * - Resolves only after the utterance has started and then ended successfully. */ export function playBrowserTTSPreview(options: PlayBrowserTTSPreviewOptions): { promise: Promise; cancel: () => void; } { const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined; if (!synth) { return { promise: Promise.reject(new Error('Browser does not support Speech Synthesis API')), cancel: () => {}, }; } let settled = false; let started = false; let canceled = false; let timeoutId: number | null = null; let rejectPromise: ((reason?: unknown) => void) | null = null; const settleResolve = (resolve: () => void) => { if (settled) return; settled = true; if (timeoutId !== null) { window.clearTimeout(timeoutId); timeoutId = null; } resolve(); }; const settleReject = (reject: (reason?: unknown) => void, reason: unknown) => { if (settled) return; settled = true; if (timeoutId !== null) { window.clearTimeout(timeoutId); timeoutId = null; } reject(reason); }; const promise = new Promise((resolve, reject) => { rejectPromise = reject; const startPlayback = async () => { try { const voices = options.voices ?? (await ensureVoicesLoaded()); if (canceled) { settleReject(reject, createAbortError()); return; } if (voices.length === 0) { settleReject(reject, new Error('No browser TTS voices available')); return; } const utterance = new SpeechSynthesisUtterance(options.text); utterance.rate = options.rate ?? 1; const { voice, lang } = resolveBrowserVoice(voices, options.voice ?? '', options.text); if (voice) { utterance.voice = voice; } utterance.lang = lang; utterance.onstart = () => { started = true; }; utterance.onend = () => { if (!started) { settleReject(reject, new Error('Browser TTS preview ended before playback started')); return; } settleResolve(resolve); }; utterance.onerror = (event) => { if (canceled || event.error === 'canceled' || event.error === 'interrupted') { settleReject(reject, createAbortError()); return; } settleReject(reject, new Error(event.error)); }; timeoutId = window.setTimeout(() => { synth.cancel(); settleReject(reject, new Error('Browser TTS preview timed out')); }, PREVIEW_TIMEOUT_MS); synth.cancel(); if (canceled) { settleReject(reject, createAbortError()); return; } synth.speak(utterance); } catch (error) { settleReject(reject, error); } }; void startPlayback(); }); const cancel = () => { if (settled || canceled) return; canceled = true; synth.cancel(); if (rejectPromise) { settleReject(rejectPromise, createAbortError()); } }; return { promise, cancel }; }