browser-tts-preview.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. 'use client';
  2. const VOICES_LOAD_TIMEOUT_MS = 2000;
  3. const PREVIEW_TIMEOUT_MS = 30000;
  4. const CJK_LANG_THRESHOLD = 0.3;
  5. type PlayBrowserTTSPreviewOptions = {
  6. text: string;
  7. voice?: string;
  8. rate?: number;
  9. voices?: SpeechSynthesisVoice[];
  10. };
  11. function createAbortError(): Error {
  12. const error = new Error('Browser TTS preview canceled');
  13. error.name = 'AbortError';
  14. return error;
  15. }
  16. function inferPreviewLang(text: string): string {
  17. const cjkCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
  18. const ratio = text.length > 0 ? cjkCount / text.length : 0;
  19. return ratio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US';
  20. }
  21. export function isBrowserTTSAbortError(error: unknown): boolean {
  22. return error instanceof Error && error.name === 'AbortError';
  23. }
  24. /** Wait for browser voices to load, with a 2s timeout fallback. */
  25. export async function ensureVoicesLoaded(): Promise<SpeechSynthesisVoice[]> {
  26. if (typeof window === 'undefined' || !window.speechSynthesis) {
  27. return [];
  28. }
  29. const initialVoices = window.speechSynthesis.getVoices();
  30. if (initialVoices.length > 0) {
  31. return initialVoices;
  32. }
  33. return new Promise<SpeechSynthesisVoice[]>((resolve) => {
  34. let settled = false;
  35. let timeoutId: number | null = null;
  36. const cleanup = () => {
  37. window.speechSynthesis.removeEventListener('voiceschanged', handleVoicesChanged);
  38. if (timeoutId !== null) {
  39. window.clearTimeout(timeoutId);
  40. }
  41. };
  42. const finish = () => {
  43. if (settled) return;
  44. settled = true;
  45. cleanup();
  46. resolve(window.speechSynthesis.getVoices());
  47. };
  48. const handleVoicesChanged = () => {
  49. const voices = window.speechSynthesis.getVoices();
  50. if (voices.length > 0) {
  51. finish();
  52. }
  53. };
  54. window.speechSynthesis.addEventListener('voiceschanged', handleVoicesChanged);
  55. timeoutId = window.setTimeout(finish, VOICES_LOAD_TIMEOUT_MS);
  56. });
  57. }
  58. /** Resolve a browser voice by voiceURI, name, or lang, with language fallback by text. */
  59. export function resolveBrowserVoice(
  60. voices: SpeechSynthesisVoice[],
  61. voiceNameOrLang: string,
  62. text: string,
  63. ): { voice: SpeechSynthesisVoice | null; lang: string } {
  64. const target = voiceNameOrLang.trim();
  65. const matchedVoice =
  66. target && target !== 'default'
  67. ? voices.find(
  68. (voice) => voice.voiceURI === target || voice.name === target || voice.lang === target,
  69. ) || null
  70. : null;
  71. return {
  72. voice: matchedVoice,
  73. lang: matchedVoice?.lang || inferPreviewLang(text),
  74. };
  75. }
  76. /**
  77. * Play a short browser-native TTS preview.
  78. *
  79. * Notes:
  80. * - Uses the global speechSynthesis queue, so it must cancel queued utterances
  81. * before starting a new preview.
  82. * - Resolves only after the utterance has started and then ended successfully.
  83. */
  84. export function playBrowserTTSPreview(options: PlayBrowserTTSPreviewOptions): {
  85. promise: Promise<void>;
  86. cancel: () => void;
  87. } {
  88. const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined;
  89. if (!synth) {
  90. return {
  91. promise: Promise.reject(new Error('Browser does not support Speech Synthesis API')),
  92. cancel: () => {},
  93. };
  94. }
  95. let settled = false;
  96. let started = false;
  97. let canceled = false;
  98. let timeoutId: number | null = null;
  99. let rejectPromise: ((reason?: unknown) => void) | null = null;
  100. const settleResolve = (resolve: () => void) => {
  101. if (settled) return;
  102. settled = true;
  103. if (timeoutId !== null) {
  104. window.clearTimeout(timeoutId);
  105. timeoutId = null;
  106. }
  107. resolve();
  108. };
  109. const settleReject = (reject: (reason?: unknown) => void, reason: unknown) => {
  110. if (settled) return;
  111. settled = true;
  112. if (timeoutId !== null) {
  113. window.clearTimeout(timeoutId);
  114. timeoutId = null;
  115. }
  116. reject(reason);
  117. };
  118. const promise = new Promise<void>((resolve, reject) => {
  119. rejectPromise = reject;
  120. const startPlayback = async () => {
  121. try {
  122. const voices = options.voices ?? (await ensureVoicesLoaded());
  123. if (canceled) {
  124. settleReject(reject, createAbortError());
  125. return;
  126. }
  127. if (voices.length === 0) {
  128. settleReject(reject, new Error('No browser TTS voices available'));
  129. return;
  130. }
  131. const utterance = new SpeechSynthesisUtterance(options.text);
  132. utterance.rate = options.rate ?? 1;
  133. const { voice, lang } = resolveBrowserVoice(voices, options.voice ?? '', options.text);
  134. if (voice) {
  135. utterance.voice = voice;
  136. }
  137. utterance.lang = lang;
  138. utterance.onstart = () => {
  139. started = true;
  140. };
  141. utterance.onend = () => {
  142. if (!started) {
  143. settleReject(reject, new Error('Browser TTS preview ended before playback started'));
  144. return;
  145. }
  146. settleResolve(resolve);
  147. };
  148. utterance.onerror = (event) => {
  149. if (canceled || event.error === 'canceled' || event.error === 'interrupted') {
  150. settleReject(reject, createAbortError());
  151. return;
  152. }
  153. settleReject(reject, new Error(event.error));
  154. };
  155. timeoutId = window.setTimeout(() => {
  156. synth.cancel();
  157. settleReject(reject, new Error('Browser TTS preview timed out'));
  158. }, PREVIEW_TIMEOUT_MS);
  159. synth.cancel();
  160. if (canceled) {
  161. settleReject(reject, createAbortError());
  162. return;
  163. }
  164. synth.speak(utterance);
  165. } catch (error) {
  166. settleReject(reject, error);
  167. }
  168. };
  169. void startPlayback();
  170. });
  171. const cancel = () => {
  172. if (settled || canceled) return;
  173. canceled = true;
  174. synth.cancel();
  175. if (rejectPromise) {
  176. settleReject(rejectPromise, createAbortError());
  177. }
  178. };
  179. return { promise, cancel };
  180. }