File size: 5,850 Bytes
f56a29b | 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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
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<SpeechSynthesisVoice[]> {
if (typeof window === 'undefined' || !window.speechSynthesis) {
return [];
}
const initialVoices = window.speechSynthesis.getVoices();
if (initialVoices.length > 0) {
return initialVoices;
}
return new Promise<SpeechSynthesisVoice[]>((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<void>;
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<void>((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 };
}
|