engine.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. /**
  2. * Playback Engine - Unified state machine for lecture playback and live discussion
  3. *
  4. * Consumes Scene.actions[] directly via ActionEngine.
  5. * No intermediate compile step — actions are executed as-is.
  6. *
  7. * State machine:
  8. *
  9. * start() pause()
  10. * idle ──────────────────→ playing ──────────────→ paused
  11. * ▲ ▲ │
  12. * │ │ resume() │
  13. * │ └───────────────────────┘
  14. * │
  15. * │ handleEndDiscussion()
  16. * │ confirmDiscussion()
  17. * │ / handleUserInterrupt()
  18. * │ │
  19. * │ ▼ pause()
  20. * └──────────────────────── live ──────────────→ paused
  21. * ▲ │
  22. * │ resume / user msg │
  23. * └────────────────────┘
  24. */
  25. import type { Scene } from '@/lib/types/stage';
  26. import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action';
  27. import type {
  28. EngineMode,
  29. TopicState,
  30. PlaybackEngineCallbacks,
  31. PlaybackSnapshot,
  32. TriggerEvent,
  33. Effect,
  34. } from './types';
  35. import type { AudioPlayer } from '@/lib/utils/audio-player';
  36. import { ActionEngine } from '@/lib/action/engine';
  37. import { useCanvasStore } from '@/lib/store/canvas';
  38. import { useSettingsStore } from '@/lib/store/settings';
  39. import { createLogger } from '@/lib/logger';
  40. const log = createLogger('PlaybackEngine');
  41. /**
  42. * If more than 30% of characters are CJK, treat the text as Chinese.
  43. * Intentionally low: mixed Chinese text often contains punctuation,
  44. * numbers, and short Latin fragments (e.g. "AI课堂").
  45. */
  46. const CJK_LANG_THRESHOLD = 0.3;
  47. export class PlaybackEngine {
  48. private scenes: Scene[] = [];
  49. private sceneIndex: number = 0;
  50. private actionIndex: number = 0;
  51. private mode: EngineMode = 'idle';
  52. private consumedDiscussions: Set<string> = new Set();
  53. // Discussion state save
  54. private savedSceneIndex: number | null = null;
  55. private savedActionIndex: number | null = null;
  56. // Discussion topic state
  57. private currentTopicState: TopicState | null = null;
  58. // Dependencies
  59. private audioPlayer: AudioPlayer;
  60. private actionEngine: ActionEngine;
  61. private callbacks: PlaybackEngineCallbacks;
  62. // Scene identity (for snapshot validation)
  63. private sceneId: string | undefined;
  64. // Internal state
  65. private currentTrigger: TriggerEvent | null = null;
  66. private triggerDelayTimer: ReturnType<typeof setTimeout> | null = null;
  67. // Reading-time timer for speech actions without pre-generated audio (TTS disabled)
  68. private speechTimer: ReturnType<typeof setTimeout> | null = null;
  69. private speechTimerStart: number = 0; // Date.now() when timer was scheduled
  70. // Browser-native TTS state (Web Speech API)
  71. private browserTTSActive: boolean = false;
  72. private browserTTSChunks: string[] = []; // sentence-level chunks for sequential playback
  73. private browserTTSChunkIndex: number = 0; // current chunk being spoken
  74. private browserTTSPausedChunks: string[] = []; // remaining chunks saved on pause (for cancel+re-speak)
  75. private speechTimerRemaining: number = 0; // remaining ms (set on pause)
  76. constructor(
  77. scenes: Scene[],
  78. actionEngine: ActionEngine,
  79. audioPlayer: AudioPlayer,
  80. callbacks: PlaybackEngineCallbacks = {},
  81. ) {
  82. this.scenes = scenes;
  83. this.sceneId = scenes[0]?.id;
  84. this.actionEngine = actionEngine;
  85. this.audioPlayer = audioPlayer;
  86. this.callbacks = callbacks;
  87. }
  88. // ==================== Public API ====================
  89. /** Get the current engine mode */
  90. getMode(): EngineMode {
  91. return this.mode;
  92. }
  93. /** Export a serializable playback snapshot */
  94. getSnapshot(): PlaybackSnapshot {
  95. return {
  96. sceneIndex: this.sceneIndex,
  97. actionIndex: this.actionIndex,
  98. consumedDiscussions: [...this.consumedDiscussions],
  99. sceneId: this.sceneId,
  100. };
  101. }
  102. /** Restore playback position from a snapshot */
  103. restoreFromSnapshot(snapshot: PlaybackSnapshot): void {
  104. this.sceneIndex = snapshot.sceneIndex;
  105. this.actionIndex = snapshot.actionIndex;
  106. this.consumedDiscussions = new Set(snapshot.consumedDiscussions);
  107. }
  108. /** idle → playing (from beginning) */
  109. start(): void {
  110. if (this.mode !== 'idle') {
  111. log.warn('Cannot start: not idle, current mode:', this.mode);
  112. return;
  113. }
  114. this.sceneIndex = 0;
  115. this.actionIndex = 0;
  116. this.setMode('playing');
  117. this.processNext();
  118. }
  119. /** idle → playing (continue from current position, e.g. after discussion end) */
  120. continuePlayback(): void {
  121. if (this.mode !== 'idle') {
  122. log.warn('Cannot continue: not idle, current mode:', this.mode);
  123. return;
  124. }
  125. this.setMode('playing');
  126. this.processNext();
  127. }
  128. /** playing → paused | live → paused (abort SSE, truncate, topic pending) */
  129. pause(): void {
  130. if (this.mode === 'playing') {
  131. // Cancel pending timers
  132. if (this.triggerDelayTimer) {
  133. clearTimeout(this.triggerDelayTimer);
  134. this.triggerDelayTimer = null;
  135. }
  136. if (this.speechTimer) {
  137. // Save remaining time so resume() can reschedule
  138. this.speechTimerRemaining = Math.max(
  139. 0,
  140. this.speechTimerRemaining - (Date.now() - this.speechTimerStart),
  141. );
  142. clearTimeout(this.speechTimer);
  143. this.speechTimer = null;
  144. }
  145. this.setMode('paused');
  146. // Freeze TTS — but skip if waiting on ProactiveCard (no active speech)
  147. if (!this.currentTrigger) {
  148. if (this.browserTTSActive) {
  149. // Cancel+re-speak pattern: save remaining chunks for resume.
  150. // speechSynthesis.pause()/resume() is broken on Firefox, so we
  151. // cancel now and re-speak from current chunk onward on resume.
  152. this.browserTTSPausedChunks = this.browserTTSChunks.slice(this.browserTTSChunkIndex);
  153. window.speechSynthesis?.cancel();
  154. // Note: cancel fires onerror('canceled'), which we ignore (see playBrowserTTSChunk)
  155. } else if (this.audioPlayer.isPlaying()) {
  156. this.audioPlayer.pause();
  157. }
  158. }
  159. } else if (this.mode === 'live') {
  160. this.setMode('paused');
  161. this.currentTopicState = 'pending';
  162. // Caller is responsible for aborting SSE
  163. } else {
  164. log.warn('Cannot pause: mode is', this.mode);
  165. }
  166. }
  167. /** paused → playing (TTS resume) | paused (in discussion) → live */
  168. resume(): void {
  169. if (this.mode !== 'paused') {
  170. log.warn('Cannot resume: not paused, mode is', this.mode);
  171. return;
  172. }
  173. if (this.currentTopicState === 'pending') {
  174. // Resume discussion → live
  175. this.currentTopicState = 'active';
  176. this.setMode('live');
  177. } else if (this.currentTrigger) {
  178. // Waiting on ProactiveCard — just resume mode, don't touch audio
  179. this.setMode('playing');
  180. } else {
  181. // Resume lecture
  182. this.setMode('playing');
  183. if (this.browserTTSPausedChunks.length > 0) {
  184. // Browser TTS was paused via cancel — re-speak remaining chunks
  185. this.browserTTSActive = true;
  186. this.browserTTSChunks = this.browserTTSPausedChunks;
  187. this.browserTTSChunkIndex = 0;
  188. this.browserTTSPausedChunks = [];
  189. this.playBrowserTTSChunk();
  190. } else if (this.audioPlayer.hasActiveAudio()) {
  191. // Audio is paused — resume it; TTS onend will call processNext
  192. this.audioPlayer.resume();
  193. } else if (this.speechTimerRemaining > 0) {
  194. // Reading timer was paused — reschedule with remaining time
  195. this.speechTimerStart = Date.now();
  196. this.speechTimer = setTimeout(() => {
  197. this.speechTimer = null;
  198. this.speechTimerRemaining = 0;
  199. this.callbacks.onSpeechEnd?.();
  200. if (this.mode === 'playing') this.processNext();
  201. }, this.speechTimerRemaining);
  202. } else {
  203. // TTS finished while paused, continue to next event
  204. this.processNext();
  205. }
  206. }
  207. }
  208. /** → idle */
  209. stop(): void {
  210. // Set mode BEFORE stopping audio to prevent spurious processNext from
  211. // synchronous onend callbacks (see handleUserInterrupt for details).
  212. this.setMode('idle');
  213. this.audioPlayer.stop();
  214. this.cancelBrowserTTS();
  215. this.actionEngine.clearEffects();
  216. if (this.triggerDelayTimer) {
  217. clearTimeout(this.triggerDelayTimer);
  218. this.triggerDelayTimer = null;
  219. }
  220. if (this.speechTimer) {
  221. clearTimeout(this.speechTimer);
  222. this.speechTimer = null;
  223. }
  224. this.speechTimerRemaining = 0;
  225. this.sceneIndex = 0;
  226. this.actionIndex = 0;
  227. this.savedSceneIndex = null;
  228. this.savedActionIndex = null;
  229. this.currentTopicState = null;
  230. this.currentTrigger = null;
  231. }
  232. /** User clicks "Join" on ProactiveCard → save cursor → live */
  233. confirmDiscussion(): void {
  234. if (!this.currentTrigger) {
  235. log.warn('confirmDiscussion called but no trigger');
  236. return;
  237. }
  238. // Mark consumed so it won't re-trigger on replay
  239. this.consumedDiscussions.add(this.currentTrigger.id);
  240. // Save lecture state — keep actionIndex as-is (past the discussion).
  241. // Discussions are placed after all speech actions, so the preceding
  242. // speech was already fully played; no need to replay it.
  243. this.savedSceneIndex = this.sceneIndex;
  244. this.savedActionIndex = this.actionIndex;
  245. // Enter live mode
  246. this.currentTopicState = 'active';
  247. this.setMode('live');
  248. // Notify callbacks
  249. this.callbacks.onProactiveHide?.();
  250. this.callbacks.onDiscussionConfirmed?.(
  251. this.currentTrigger.question,
  252. this.currentTrigger.prompt,
  253. this.currentTrigger.agentId,
  254. );
  255. this.currentTrigger = null;
  256. }
  257. /** User clicks "Skip" on ProactiveCard → consumed → processNext */
  258. skipDiscussion(): void {
  259. if (this.currentTrigger) {
  260. this.consumedDiscussions.add(this.currentTrigger.id);
  261. this.currentTrigger = null;
  262. }
  263. this.callbacks.onProactiveHide?.();
  264. if (this.mode === 'playing') {
  265. this.processNext();
  266. }
  267. }
  268. /** End discussion → restore lecture → idle (user clicks "start" to continue) */
  269. handleEndDiscussion(): void {
  270. this.actionEngine.clearEffects();
  271. this.currentTopicState = 'closed';
  272. // Close whiteboard if it was open during the discussion
  273. useCanvasStore.getState().setWhiteboardOpen(false);
  274. this.callbacks.onDiscussionEnd?.();
  275. // Restore lecture state
  276. this.restoreSavedLectureState();
  277. this.setMode('idle');
  278. }
  279. /**
  280. * Exit live discussion mode after a request failure without treating it as a
  281. * normal discussion end. The chat session stays retryable; this only restores
  282. * the playback engine to a coherent non-live state.
  283. */
  284. handleDiscussionError(): void {
  285. const hasSavedLectureState = this.savedSceneIndex !== null && this.savedActionIndex !== null;
  286. const isLiveTopic =
  287. this.mode === 'live' || (this.mode === 'paused' && this.currentTopicState === 'pending');
  288. if (!isLiveTopic && !hasSavedLectureState) {
  289. return;
  290. }
  291. this.actionEngine.clearEffects();
  292. useCanvasStore.getState().setWhiteboardOpen(false);
  293. this.currentTopicState = 'closed';
  294. this.currentTrigger = null;
  295. this.restoreSavedLectureState();
  296. this.setMode('idle');
  297. }
  298. /** User sends a message during playback → interrupt → live mode */
  299. handleUserInterrupt(text: string): void {
  300. if (this.mode === 'playing' || this.mode === 'paused') {
  301. // Save lecture state BEFORE stopping audio — actionIndex was already
  302. // incremented by processNext, so subtract 1 to replay the interrupted
  303. // sentence when resuming. Guard against overwriting a previously saved
  304. // position (e.g. live → paused → new message).
  305. if (this.savedSceneIndex === null) {
  306. this.savedSceneIndex = this.sceneIndex;
  307. this.savedActionIndex = Math.max(0, this.actionIndex - 1);
  308. }
  309. // Cancel pending trigger delay
  310. if (this.triggerDelayTimer) {
  311. clearTimeout(this.triggerDelayTimer);
  312. this.triggerDelayTimer = null;
  313. }
  314. }
  315. // Set mode BEFORE stopping audio — speechSynthesis.cancel() may fire the
  316. // onend callback synchronously, and the processNext guard checks
  317. // `this.mode === 'playing'`. Setting mode first prevents a spurious
  318. // processNext that would advance actionIndex past the interrupted speech.
  319. this.currentTopicState = 'active';
  320. this.setMode('live');
  321. this.audioPlayer.stop();
  322. this.cancelBrowserTTS();
  323. this.callbacks.onUserInterrupt?.(text);
  324. }
  325. /** Whether all remaining actions have been consumed (no speech left to play) */
  326. isExhausted(): boolean {
  327. let si = this.sceneIndex;
  328. let ai = this.actionIndex;
  329. while (si < this.scenes.length) {
  330. const actions = this.scenes[si].actions || [];
  331. while (ai < actions.length) {
  332. const action = actions[ai];
  333. // Consumed discussions don't count as remaining work
  334. if (action.type === 'discussion' && this.consumedDiscussions.has(action.id)) {
  335. ai++;
  336. continue;
  337. }
  338. return false;
  339. }
  340. si++;
  341. ai = 0;
  342. }
  343. return true;
  344. }
  345. // ==================== Private ====================
  346. private setMode(mode: EngineMode): void {
  347. if (this.mode === mode) return;
  348. this.mode = mode;
  349. this.callbacks.onModeChange?.(mode);
  350. }
  351. private restoreSavedLectureState(): void {
  352. if (this.savedSceneIndex !== null && this.savedActionIndex !== null) {
  353. this.sceneIndex = this.savedSceneIndex;
  354. this.actionIndex = this.savedActionIndex;
  355. }
  356. this.savedSceneIndex = null;
  357. this.savedActionIndex = null;
  358. }
  359. /**
  360. * Get the current action, or null if playback is complete.
  361. * Advances sceneIndex automatically when a scene's actions are exhausted.
  362. */
  363. private getCurrentAction(): { action: Action; sceneId: string } | null {
  364. while (this.sceneIndex < this.scenes.length) {
  365. const scene = this.scenes[this.sceneIndex];
  366. const actions = scene.actions || [];
  367. if (this.actionIndex < actions.length) {
  368. return { action: actions[this.actionIndex], sceneId: scene.id };
  369. }
  370. // Move to next scene
  371. this.sceneIndex++;
  372. this.actionIndex = 0;
  373. }
  374. return null;
  375. }
  376. /**
  377. * Core processing loop: consume the next action.
  378. */
  379. private async processNext(): Promise<void> {
  380. if (this.mode !== 'playing') return;
  381. // Check for scene boundary (fire scene change callback at start of each new scene)
  382. if (this.actionIndex === 0 && this.sceneIndex < this.scenes.length) {
  383. const scene = this.scenes[this.sceneIndex];
  384. this.actionEngine.clearEffects();
  385. this.callbacks.onSceneChange?.(scene.id);
  386. this.callbacks.onSpeakerChange?.('teacher');
  387. }
  388. const current = this.getCurrentAction();
  389. if (!current) {
  390. // All scenes complete
  391. this.actionEngine.clearEffects();
  392. this.setMode('idle');
  393. this.callbacks.onComplete?.();
  394. return;
  395. }
  396. const { action } = current;
  397. // Notify progress BEFORE advancing the cursor so the snapshot points at
  398. // the current action. On restore the same action will be replayed — this
  399. // is the desired behaviour for speech (user may have only heard half).
  400. this.callbacks.onProgress?.(this.getSnapshot());
  401. this.actionIndex++;
  402. switch (action.type) {
  403. case 'speech': {
  404. const speechAction = action as SpeechAction;
  405. this.callbacks.onSpeechStart?.(speechAction.text);
  406. // onEnded → processNext; if paused, resume() will call processNext
  407. this.audioPlayer.onEnded(() => {
  408. this.callbacks.onSpeechEnd?.();
  409. if (this.mode === 'playing') {
  410. this.processNext();
  411. }
  412. });
  413. // Estimated reading time when no pre-generated audio (TTS disabled).
  414. // CJK text: ~150ms/char (one char ≈ one word).
  415. // Non-CJK text: ~240ms/word (≈250 WPM).
  416. // Min 2s. Cancelled on pause; resume() calls processNext directly.
  417. const scheduleReadingTimer = () => {
  418. const text = speechAction.text;
  419. const cjkCount = (
  420. text.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []
  421. ).length;
  422. const isCJK = cjkCount > text.length * 0.3;
  423. const speed = this.callbacks.getPlaybackSpeed?.() ?? 1;
  424. const rawMs = isCJK
  425. ? Math.max(2000, text.length * 150)
  426. : Math.max(2000, text.split(/\s+/).filter(Boolean).length * 240);
  427. const readingMs = rawMs / speed;
  428. this.speechTimerStart = Date.now();
  429. this.speechTimerRemaining = readingMs;
  430. this.speechTimer = setTimeout(() => {
  431. this.speechTimer = null;
  432. this.speechTimerRemaining = 0;
  433. this.callbacks.onSpeechEnd?.();
  434. if (this.mode === 'playing') this.processNext();
  435. }, readingMs);
  436. };
  437. this.audioPlayer
  438. .play(speechAction.audioId || '', speechAction.audioUrl)
  439. .then((audioStarted) => {
  440. if (!audioStarted) {
  441. // No pre-generated audio — try browser-native TTS if selected
  442. const settings = useSettingsStore.getState();
  443. if (
  444. settings.ttsEnabled &&
  445. settings.ttsProviderId === 'browser-native-tts' &&
  446. typeof window !== 'undefined' &&
  447. window.speechSynthesis
  448. ) {
  449. this.playBrowserTTS(speechAction);
  450. } else {
  451. scheduleReadingTimer();
  452. }
  453. }
  454. })
  455. .catch((err) => {
  456. log.error('TTS error:', err);
  457. scheduleReadingTimer();
  458. });
  459. break;
  460. }
  461. case 'spotlight':
  462. case 'laser': {
  463. // Fire-and-forget visual effects via ActionEngine
  464. this.actionEngine.execute(action);
  465. this.callbacks.onEffectFire?.({
  466. kind: action.type,
  467. targetId: action.elementId,
  468. ...(action.type === 'spotlight'
  469. ? { dimOpacity: action.dimOpacity }
  470. : { color: action.color }),
  471. } as Effect);
  472. // Don't block — continue immediately (use queueMicrotask to avoid
  473. // stack overflow from deep synchronous recursion when many consecutive
  474. // spotlight/laser actions appear in sequence)
  475. queueMicrotask(() => this.processNext());
  476. break;
  477. }
  478. case 'discussion': {
  479. const discussionAction = action as DiscussionAction;
  480. // Check if already consumed
  481. if (this.consumedDiscussions.has(discussionAction.id)) {
  482. this.processNext();
  483. return;
  484. }
  485. // Skip if the discussion's agent isn't in the user's selected list
  486. if (
  487. discussionAction.agentId &&
  488. this.callbacks.isAgentSelected &&
  489. !this.callbacks.isAgentSelected(discussionAction.agentId)
  490. ) {
  491. this.consumedDiscussions.add(discussionAction.id);
  492. this.processNext();
  493. return;
  494. }
  495. // 3s delay before showing ProactiveCard (allows previous speech to finish naturally)
  496. const trigger: TriggerEvent = {
  497. id: discussionAction.id,
  498. question: discussionAction.topic,
  499. prompt: discussionAction.prompt,
  500. agentId: discussionAction.agentId,
  501. };
  502. this.triggerDelayTimer = setTimeout(() => {
  503. this.triggerDelayTimer = null;
  504. if (this.mode !== 'playing') return; // Cancelled if user paused/stopped
  505. this.currentTrigger = trigger;
  506. this.callbacks.onProactiveShow?.(trigger);
  507. // Engine pauses here — user calls confirmDiscussion() or skipDiscussion()
  508. }, 3000);
  509. break;
  510. }
  511. case 'play_video':
  512. case 'wb_open':
  513. case 'wb_draw_text':
  514. case 'wb_draw_shape':
  515. case 'wb_draw_chart':
  516. case 'wb_draw_latex':
  517. case 'wb_draw_table':
  518. case 'wb_clear':
  519. case 'wb_delete':
  520. case 'wb_close': {
  521. // Synchronous whiteboard actions — await completion, then continue
  522. await this.actionEngine.execute(action);
  523. if (this.mode === 'playing') {
  524. this.processNext();
  525. }
  526. break;
  527. }
  528. default:
  529. // Unknown action, skip
  530. this.processNext();
  531. break;
  532. }
  533. }
  534. // ==================== Browser Native TTS ====================
  535. /**
  536. * Split text into sentence-level chunks for sequential playback.
  537. * Chrome has a bug where utterances >~15s are silently cut off and onend
  538. * never fires, causing the engine to hang. Chunking avoids this.
  539. */
  540. private splitIntoChunks(text: string): string[] {
  541. // Split on sentence-ending punctuation (Latin + CJK) and newlines
  542. const chunks = text
  543. .split(/(?<=[.!?。!?\n])\s*/)
  544. .map((s) => s.trim())
  545. .filter((s) => s.length > 0);
  546. // If splitting produced nothing (no punctuation), return the original text
  547. return chunks.length > 0 ? chunks : [text];
  548. }
  549. /**
  550. * Play text using the Web Speech API (browser-native TTS).
  551. * Splits text into sentence-level chunks to avoid Chrome's ~15s cutoff.
  552. * Uses cancel+re-speak for pause/resume (Firefox compatibility).
  553. */
  554. private playBrowserTTS(speechAction: SpeechAction): void {
  555. this.browserTTSChunks = this.splitIntoChunks(speechAction.text);
  556. this.browserTTSChunkIndex = 0;
  557. this.browserTTSPausedChunks = [];
  558. this.browserTTSActive = true;
  559. this.playBrowserTTSChunk();
  560. }
  561. /** Speak the current chunk; on completion, advance to next or finish. */
  562. private async playBrowserTTSChunk(): Promise<void> {
  563. if (this.browserTTSChunkIndex >= this.browserTTSChunks.length) {
  564. // All chunks done
  565. this.browserTTSActive = false;
  566. this.browserTTSChunks = [];
  567. this.callbacks.onSpeechEnd?.();
  568. if (this.mode === 'playing') this.processNext();
  569. return;
  570. }
  571. const settings = useSettingsStore.getState();
  572. const chunkText = this.browserTTSChunks[this.browserTTSChunkIndex];
  573. const utterance = new SpeechSynthesisUtterance(chunkText);
  574. // Apply settings
  575. const speed = this.callbacks.getPlaybackSpeed?.() ?? 1;
  576. utterance.rate = (settings.ttsSpeed ?? 1) * speed;
  577. utterance.volume = settings.ttsMuted ? 0 : (settings.ttsVolume ?? 1);
  578. // Ensure voices are loaded (Chrome loads them asynchronously)
  579. const voices = await this.ensureVoicesLoaded();
  580. // Set voice: try user's configured voice, fall back to auto-detect language
  581. let voiceFound = false;
  582. if (settings.ttsVoice && settings.ttsVoice !== 'default') {
  583. const voice = voices.find((v) => v.voiceURI === settings.ttsVoice);
  584. if (voice) {
  585. utterance.voice = voice;
  586. utterance.lang = voice.lang;
  587. voiceFound = true;
  588. }
  589. }
  590. if (!voiceFound) {
  591. // No usable voice configured — detect text language so the browser
  592. // auto-selects an appropriate voice.
  593. const cjkRatio =
  594. chunkText.length > 0
  595. ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length
  596. : 0;
  597. utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US';
  598. }
  599. utterance.onend = () => {
  600. this.browserTTSChunkIndex++;
  601. if (this.mode === 'playing') {
  602. this.playBrowserTTSChunk(); // next chunk
  603. }
  604. };
  605. utterance.onerror = (event) => {
  606. // 'canceled' is expected when stop/pause is called — not a real error
  607. if (event.error !== 'canceled') {
  608. log.warn('Browser TTS chunk error:', event.error);
  609. // Skip failed chunk, try next
  610. this.browserTTSChunkIndex++;
  611. if (this.mode === 'playing') {
  612. this.playBrowserTTSChunk();
  613. }
  614. }
  615. // On 'canceled': do nothing — pause handler already saved state
  616. };
  617. // Chrome bug workaround: cancel() before speak() to clear stale synthesis
  618. // state that can produce garbled/broken audio output.
  619. window.speechSynthesis.cancel();
  620. window.speechSynthesis.speak(utterance);
  621. }
  622. /**
  623. * Wait for speechSynthesis voices to load (Chrome loads them asynchronously).
  624. * Caches result so subsequent calls return immediately.
  625. */
  626. private cachedVoices: SpeechSynthesisVoice[] | null = null;
  627. private async ensureVoicesLoaded(): Promise<SpeechSynthesisVoice[]> {
  628. if (this.cachedVoices && this.cachedVoices.length > 0) {
  629. return this.cachedVoices;
  630. }
  631. let voices = window.speechSynthesis.getVoices();
  632. if (voices.length > 0) {
  633. this.cachedVoices = voices;
  634. return voices;
  635. }
  636. // Chrome: voices load asynchronously — wait for the voiceschanged event
  637. await new Promise<void>((resolve) => {
  638. const onVoicesChanged = () => {
  639. window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
  640. resolve();
  641. };
  642. window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged);
  643. // Timeout after 2s to avoid hanging
  644. setTimeout(() => {
  645. window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
  646. resolve();
  647. }, 2000);
  648. });
  649. voices = window.speechSynthesis.getVoices();
  650. this.cachedVoices = voices;
  651. return voices;
  652. }
  653. /** Cancel any active browser-native TTS */
  654. private cancelBrowserTTS(): void {
  655. if (this.browserTTSActive) {
  656. this.browserTTSActive = false;
  657. this.browserTTSChunks = [];
  658. this.browserTTSChunkIndex = 0;
  659. this.browserTTSPausedChunks = [];
  660. window.speechSynthesis?.cancel();
  661. }
  662. }
  663. }