Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

874 lines
31 KiB
TypeScript

/**
* Playback Engine - Unified state machine for lecture playback and live discussion
*
* Consumes Scene.actions[] directly via ActionEngine.
* No intermediate compile step — actions are executed as-is.
*
* State machine:
*
* start() pause()
* idle ──────────────────→ playing ──────────────→ paused
* ▲ ▲ │
* │ │ resume() │
* │ └───────────────────────┘
* │
* │ handleEndDiscussion()
* │ confirmDiscussion()
* │ / handleUserInterrupt()
* │ │
* │ ▼ pause()
* └──────────────────────── live ──────────────→ paused
* ▲ │
* │ resume / user msg │
* └────────────────────┘
*/
import type { Scene } from '@/lib/types/stage';
import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action';
import type {
EngineMode,
TopicState,
PlaybackEngineCallbacks,
PlaybackSnapshot,
TriggerEvent,
Effect,
} from './types';
import type { AudioPlayer } from '@/lib/utils/audio-player';
import { ActionEngine } from '@/lib/action/engine';
import {
resolvePlaybackCursor,
estimateSpeechDurationMs,
DISCUSSION_TRIGGER_DELAY_MS,
} from '@/lib/choreography';
import {
canJumpWithinReconstructablePrefix,
isWhiteboardPlaybackAction,
} from '@/lib/playback/action-navigation';
import { useCanvasStore } from '@/lib/store/canvas';
import { useSettingsStore } from '@/lib/store/settings';
import { isTTSProviderEnabled } from '@/lib/audio/provider-enablement';
import { createLogger } from '@/lib/logger';
const log = createLogger('PlaybackEngine');
/**
* If more than 30% of characters are CJK, treat the text as Chinese.
* Intentionally low: mixed Chinese text often contains punctuation,
* numbers, and short Latin fragments (e.g. "AI课堂").
*/
const CJK_LANG_THRESHOLD = 0.3;
export class PlaybackEngine {
private scenes: Scene[] = [];
private sceneIndex: number = 0;
private actionIndex: number = 0;
private mode: EngineMode = 'idle';
private consumedDiscussions: Set<string> = new Set();
// Discussion state save
private savedSceneIndex: number | null = null;
private savedActionIndex: number | null = null;
// Discussion topic state
private currentTopicState: TopicState | null = null;
// Dependencies
private audioPlayer: AudioPlayer;
private actionEngine: ActionEngine;
private callbacks: PlaybackEngineCallbacks;
// Scene identity (for snapshot validation)
private sceneId: string | undefined;
// Internal state
private currentTrigger: TriggerEvent | null = null;
private triggerDelayTimer: ReturnType<typeof setTimeout> | null = null;
// Reading-time timer for speech actions without pre-generated audio (TTS disabled)
private speechTimer: ReturnType<typeof setTimeout> | null = null;
private speechTimerStart: number = 0; // Date.now() when timer was scheduled
// Browser-native TTS state (Web Speech API)
private browserTTSActive: boolean = false;
private browserTTSChunks: string[] = []; // sentence-level chunks for sequential playback
private browserTTSChunkIndex: number = 0; // current chunk being spoken
private browserTTSPausedChunks: string[] = []; // remaining chunks saved on pause (for cancel+re-speak)
private speechTimerRemaining: number = 0; // remaining ms (set on pause)
private playbackGeneration: number = 0;
constructor(
scenes: Scene[],
actionEngine: ActionEngine,
audioPlayer: AudioPlayer,
callbacks: PlaybackEngineCallbacks = {},
) {
this.scenes = scenes;
this.sceneId = scenes[0]?.id;
this.actionEngine = actionEngine;
this.audioPlayer = audioPlayer;
this.callbacks = callbacks;
}
// ==================== Public API ====================
/** Get the current engine mode */
getMode(): EngineMode {
return this.mode;
}
/** Export a serializable playback snapshot */
getSnapshot(): PlaybackSnapshot {
return {
sceneIndex: this.sceneIndex,
actionIndex: this.actionIndex,
consumedDiscussions: [...this.consumedDiscussions],
sceneId: this.sceneId,
};
}
/** Restore playback position from a snapshot */
restoreFromSnapshot(snapshot: PlaybackSnapshot): void {
this.sceneIndex = snapshot.sceneIndex;
this.actionIndex = snapshot.actionIndex;
this.consumedDiscussions = new Set(snapshot.consumedDiscussions);
}
/** idle → playing (from beginning) */
start(): void {
if (this.mode !== 'idle') {
log.warn('Cannot start: not idle, current mode:', this.mode);
return;
}
this.sceneIndex = 0;
this.actionIndex = 0;
this.invalidatePlaybackGeneration();
this.setMode('playing');
this.processNext();
}
/** idle → playing (continue from current position, e.g. after discussion end) */
continuePlayback(): void {
if (this.mode !== 'idle') {
log.warn('Cannot continue: not idle, current mode:', this.mode);
return;
}
this.invalidatePlaybackGeneration();
this.setMode('playing');
this.processNext();
}
canJumpToAction(actionIndex: number): boolean {
const actions = this.scenes[0]?.actions ?? [];
return (
this.mode !== 'live' &&
canJumpWithinReconstructablePrefix(actions, this.actionIndex, actionIndex)
);
}
async jumpToAction(actionIndex: number, options: { autoplay?: boolean } = {}): Promise<boolean> {
const actions = this.scenes[0]?.actions ?? [];
if (!this.canJumpToAction(actionIndex)) return false;
const autoplay = options.autoplay ?? this.mode === 'playing';
const generation = this.invalidatePlaybackGeneration();
this.cancelActivePlaybackWork();
this.sceneIndex = 0;
this.actionIndex = 0;
this.savedSceneIndex = null;
this.savedActionIndex = null;
this.currentTopicState = null;
this.currentTrigger = null;
this.actionEngine.resetPlaybackVisualState();
for (let i = 0; i < actionIndex; i++) {
if (!this.isCurrentGeneration(generation)) return false;
const action = actions[i];
if (isWhiteboardPlaybackAction(action)) {
await this.actionEngine.execute(action, { silent: true });
}
}
if (!this.isCurrentGeneration(generation)) return false;
this.actionEngine.clearEffects();
this.sceneIndex = 0;
this.actionIndex = actionIndex;
this.callbacks.onProgress?.(this.getSnapshot());
if (autoplay) {
this.setMode('playing');
this.processNext(generation);
} else if (this.mode === 'playing' || this.mode === 'live') {
this.setMode('paused');
}
return true;
}
/** playing → paused | live → paused (abort SSE, truncate, topic pending) */
pause(): void {
if (this.mode === 'playing') {
this.invalidatePlaybackGeneration();
// Cancel pending timers
if (this.triggerDelayTimer) {
clearTimeout(this.triggerDelayTimer);
this.triggerDelayTimer = null;
}
if (this.speechTimer) {
// Save remaining time so resume() can reschedule
this.speechTimerRemaining = Math.max(
0,
this.speechTimerRemaining - (Date.now() - this.speechTimerStart),
);
clearTimeout(this.speechTimer);
this.speechTimer = null;
}
this.setMode('paused');
// Freeze TTS — but skip if waiting on ProactiveCard (no active speech)
if (!this.currentTrigger) {
if (this.browserTTSActive) {
// Cancel+re-speak pattern: save remaining chunks for resume.
// speechSynthesis.pause()/resume() is broken on Firefox, so we
// cancel now and re-speak from current chunk onward on resume.
this.browserTTSPausedChunks = this.browserTTSChunks.slice(this.browserTTSChunkIndex);
window.speechSynthesis?.cancel();
// Note: cancel fires onerror('canceled'), which we ignore (see playBrowserTTSChunk)
} else if (this.audioPlayer.isPlaying()) {
this.audioPlayer.pause();
}
}
} else if (this.mode === 'live') {
this.invalidatePlaybackGeneration();
this.setMode('paused');
this.currentTopicState = 'pending';
// Caller is responsible for aborting SSE
} else {
log.warn('Cannot pause: mode is', this.mode);
}
}
/** paused → playing (TTS resume) | paused (in discussion) → live */
resume(): void {
if (this.mode !== 'paused') {
log.warn('Cannot resume: not paused, mode is', this.mode);
return;
}
if (this.currentTopicState === 'pending') {
// Resume discussion → live
this.currentTopicState = 'active';
this.setMode('live');
} else if (this.currentTrigger) {
// Waiting on ProactiveCard — just resume mode, don't touch audio
this.setMode('playing');
} else {
// Resume lecture
this.setMode('playing');
if (this.browserTTSPausedChunks.length > 0) {
// Browser TTS was paused via cancel — re-speak remaining chunks
this.browserTTSActive = true;
this.browserTTSChunks = this.browserTTSPausedChunks;
this.browserTTSChunkIndex = 0;
this.browserTTSPausedChunks = [];
this.playBrowserTTSChunk(this.playbackGeneration);
} else if (this.audioPlayer.hasActiveAudio()) {
// Audio is paused — resume it; TTS onend will call processNext
const generation = this.playbackGeneration;
this.audioPlayer.onEnded(() => {
if (!this.isCurrentGeneration(generation)) return;
this.callbacks.onSpeechEnd?.();
if (this.mode === 'playing') {
this.processNext(generation);
}
});
this.audioPlayer.resume();
} else if (this.speechTimerRemaining > 0) {
// Reading timer was paused — reschedule with remaining time
const generation = this.playbackGeneration;
this.speechTimerStart = Date.now();
this.speechTimer = setTimeout(() => {
if (!this.isCurrentGeneration(generation)) return;
this.speechTimer = null;
this.speechTimerRemaining = 0;
this.callbacks.onSpeechEnd?.();
if (this.mode === 'playing') this.processNext(generation);
}, this.speechTimerRemaining);
} else {
// TTS finished while paused, continue to next event
this.processNext();
}
}
}
/** → idle */
stop(): void {
this.invalidatePlaybackGeneration();
// Set mode BEFORE stopping audio to prevent spurious processNext from
// synchronous onend callbacks (see handleUserInterrupt for details).
this.setMode('idle');
this.audioPlayer.stop();
this.cancelBrowserTTS();
this.actionEngine.clearEffects();
if (this.triggerDelayTimer) {
clearTimeout(this.triggerDelayTimer);
this.triggerDelayTimer = null;
}
if (this.speechTimer) {
clearTimeout(this.speechTimer);
this.speechTimer = null;
}
this.speechTimerRemaining = 0;
this.sceneIndex = 0;
this.actionIndex = 0;
this.savedSceneIndex = null;
this.savedActionIndex = null;
this.currentTopicState = null;
this.currentTrigger = null;
}
/** User clicks "Join" on ProactiveCard → save cursor → live */
confirmDiscussion(): void {
if (!this.currentTrigger) {
log.warn('confirmDiscussion called but no trigger');
return;
}
this.invalidatePlaybackGeneration();
// Mark consumed so it won't re-trigger on replay
this.consumedDiscussions.add(this.currentTrigger.id);
// Save lecture state — keep actionIndex as-is (past the discussion).
// Discussions are placed after all speech actions, so the preceding
// speech was already fully played; no need to replay it.
this.savedSceneIndex = this.sceneIndex;
this.savedActionIndex = this.actionIndex;
// Enter live mode
this.currentTopicState = 'active';
this.setMode('live');
// Notify callbacks
this.callbacks.onProactiveHide?.();
this.callbacks.onDiscussionConfirmed?.(
this.currentTrigger.question,
this.currentTrigger.prompt,
this.currentTrigger.agentId,
);
this.currentTrigger = null;
}
/** User clicks "Skip" on ProactiveCard → consumed → processNext */
skipDiscussion(): void {
if (this.currentTrigger) {
this.consumedDiscussions.add(this.currentTrigger.id);
this.currentTrigger = null;
}
const generation = this.invalidatePlaybackGeneration();
this.callbacks.onProactiveHide?.();
if (this.mode === 'playing') {
this.processNext(generation);
}
}
/** End discussion → restore lecture → idle (user clicks "start" to continue) */
handleEndDiscussion(): void {
this.invalidatePlaybackGeneration();
this.actionEngine.clearEffects();
this.currentTopicState = 'closed';
// Close whiteboard if it was open during the discussion
useCanvasStore.getState().setWhiteboardOpen(false);
this.callbacks.onDiscussionEnd?.();
// Restore lecture state
this.restoreSavedLectureState();
this.setMode('idle');
}
/**
* Exit live discussion mode after a request failure without treating it as a
* normal discussion end. The chat session stays retryable; this only restores
* the playback engine to a coherent non-live state.
*/
handleDiscussionError(): void {
const hasSavedLectureState = this.savedSceneIndex !== null && this.savedActionIndex !== null;
const isLiveTopic =
this.mode === 'live' || (this.mode === 'paused' && this.currentTopicState === 'pending');
if (!isLiveTopic && !hasSavedLectureState) {
return;
}
this.invalidatePlaybackGeneration();
this.actionEngine.clearEffects();
useCanvasStore.getState().setWhiteboardOpen(false);
this.currentTopicState = 'closed';
this.currentTrigger = null;
this.restoreSavedLectureState();
this.setMode('idle');
}
/** User sends a message during playback → interrupt → live mode */
handleUserInterrupt(text: string): void {
this.invalidatePlaybackGeneration();
if (this.mode === 'playing' || this.mode === 'paused') {
// Save lecture state BEFORE stopping audio — actionIndex was already
// incremented by processNext, so subtract 1 to replay the interrupted
// sentence when resuming. Guard against overwriting a previously saved
// position (e.g. live → paused → new message).
if (this.savedSceneIndex === null) {
this.savedSceneIndex = this.sceneIndex;
this.savedActionIndex = Math.max(0, this.actionIndex - 1);
}
// Cancel pending trigger delay
if (this.triggerDelayTimer) {
clearTimeout(this.triggerDelayTimer);
this.triggerDelayTimer = null;
}
}
// Set mode BEFORE stopping audio — speechSynthesis.cancel() may fire the
// onend callback synchronously, and the processNext guard checks
// `this.mode === 'playing'`. Setting mode first prevents a spurious
// processNext that would advance actionIndex past the interrupted speech.
this.currentTopicState = 'active';
this.setMode('live');
this.audioPlayer.stop();
this.cancelBrowserTTS();
this.callbacks.onUserInterrupt?.(text);
}
/** Whether all remaining actions have been consumed (no speech left to play) */
isExhausted(): boolean {
let si = this.sceneIndex;
let ai = this.actionIndex;
while (si < this.scenes.length) {
const actions = this.scenes[si].actions || [];
while (ai < actions.length) {
const action = actions[ai];
// Consumed discussions don't count as remaining work
if (action.type === 'discussion' && this.consumedDiscussions.has(action.id)) {
ai++;
continue;
}
return false;
}
si++;
ai = 0;
}
return true;
}
// ==================== Private ====================
private invalidatePlaybackGeneration(): number {
this.playbackGeneration += 1;
return this.playbackGeneration;
}
private isCurrentGeneration(generation: number): boolean {
return generation === this.playbackGeneration;
}
private cancelActivePlaybackWork(): void {
this.audioPlayer.stop();
this.cancelBrowserTTS();
this.actionEngine.clearEffects();
useCanvasStore.getState().pauseVideo();
if (this.triggerDelayTimer) {
clearTimeout(this.triggerDelayTimer);
this.triggerDelayTimer = null;
}
if (this.speechTimer) {
clearTimeout(this.speechTimer);
this.speechTimer = null;
}
this.speechTimerRemaining = 0;
this.callbacks.onProactiveHide?.();
}
private setMode(mode: EngineMode): void {
if (this.mode === mode) return;
this.mode = mode;
this.callbacks.onModeChange?.(mode);
}
private restoreSavedLectureState(): void {
if (this.savedSceneIndex !== null && this.savedActionIndex !== null) {
this.sceneIndex = this.savedSceneIndex;
this.actionIndex = this.savedActionIndex;
}
this.savedSceneIndex = null;
this.savedActionIndex = null;
}
/**
* Get the current action, or null if playback is complete.
* Advances sceneIndex automatically when a scene's actions are exhausted.
* A scene with no actions yields one synthetic dwell beat (so the slide still
* shows) instead of being skipped — see {@link resolvePlaybackCursor}.
*/
private getCurrentAction(): { action: Action; sceneId: string } | null {
const res = resolvePlaybackCursor(this.scenes, this.sceneIndex, this.actionIndex);
if (!res) return null;
this.sceneIndex = res.sceneIndex;
this.actionIndex = res.actionIndex;
return { action: res.action, sceneId: res.sceneId };
}
/**
* Core processing loop: consume the next action.
*/
private async processNext(generation: number = this.playbackGeneration): Promise<void> {
if (this.mode !== 'playing' || !this.isCurrentGeneration(generation)) return;
// Check for scene boundary (fire scene change callback at start of each new scene)
if (this.actionIndex === 0 && this.sceneIndex < this.scenes.length) {
const scene = this.scenes[this.sceneIndex];
this.actionEngine.clearEffects();
this.callbacks.onSceneChange?.(scene.id);
this.callbacks.onSpeakerChange?.('teacher');
}
const current = this.getCurrentAction();
if (!current) {
if (!this.isCurrentGeneration(generation)) return;
// All scenes complete
this.invalidatePlaybackGeneration();
this.actionEngine.clearEffects();
this.setMode('idle');
this.callbacks.onComplete?.();
return;
}
const { action } = current;
// Notify progress BEFORE advancing the cursor so the snapshot points at
// the current action. On restore the same action will be replayed — this
// is the desired behaviour for speech (user may have only heard half).
this.callbacks.onProgress?.(this.getSnapshot());
this.actionIndex++;
switch (action.type) {
case 'speech': {
const speechAction = action as SpeechAction;
this.callbacks.onSpeechStart?.(speechAction.text);
// onEnded → processNext; if paused, resume() will call processNext
this.audioPlayer.onEnded(() => {
if (!this.isCurrentGeneration(generation)) return;
this.callbacks.onSpeechEnd?.();
if (this.mode === 'playing') {
this.processNext(generation);
}
});
// Estimated reading time when no pre-generated audio (TTS disabled).
// The estimate (CJK vs word-based pace, 2s floor, speed-adjusted) lives
// in @/lib/choreography so the video exporter dwells identically.
// Cancelled on pause; resume() calls processNext directly.
const scheduleReadingTimer = () => {
if (!this.isCurrentGeneration(generation)) return;
const speed = this.callbacks.getPlaybackSpeed?.() ?? 1;
const readingMs = estimateSpeechDurationMs(speechAction.text, { speed });
this.speechTimerStart = Date.now();
this.speechTimerRemaining = readingMs;
this.speechTimer = setTimeout(() => {
if (!this.isCurrentGeneration(generation)) return;
this.speechTimer = null;
this.speechTimerRemaining = 0;
this.callbacks.onSpeechEnd?.();
if (this.mode === 'playing') this.processNext(generation);
}, readingMs);
};
// A speech line with no text (e.g. a freshly inserted blank slide's
// seeded clip, or one the user cleared) has nothing to synthesize —
// route it straight to the reading timer for a short dwell. Speaking an
// empty SpeechSynthesisUtterance doesn't reliably fire onend in Chromium,
// which would hang playback on that slide.
const hasText = !!speechAction.text.trim();
this.audioPlayer
.play(speechAction.audioId || '', speechAction.audioUrl)
.then((audioStarted) => {
if (!this.isCurrentGeneration(generation)) return;
if (!audioStarted) {
// No pre-generated audio — try browser-native TTS only when it is
// the selected provider AND actually enabled (opt-in, #665).
const settings = useSettingsStore.getState();
if (
hasText &&
settings.ttsEnabled &&
settings.ttsProviderId === 'browser-native-tts' &&
isTTSProviderEnabled(
'browser-native-tts',
settings.ttsProvidersConfig?.['browser-native-tts'],
) &&
typeof window !== 'undefined' &&
window.speechSynthesis
) {
this.playBrowserTTS(speechAction, generation);
} else {
scheduleReadingTimer();
}
}
})
.catch((err) => {
if (!this.isCurrentGeneration(generation)) return;
log.error('TTS error:', err);
scheduleReadingTimer();
});
break;
}
case 'spotlight':
case 'laser': {
// Fire-and-forget visual effects via ActionEngine
this.actionEngine.execute(action);
this.callbacks.onEffectFire?.({
kind: action.type,
targetId: action.elementId,
...(action.type === 'spotlight'
? { dimOpacity: action.dimOpacity }
: { color: action.color }),
} as Effect);
// Don't block — continue immediately (use queueMicrotask to avoid
// stack overflow from deep synchronous recursion when many consecutive
// spotlight/laser actions appear in sequence)
queueMicrotask(() => {
if (this.isCurrentGeneration(generation)) {
this.processNext(generation);
}
});
break;
}
case 'discussion': {
const discussionAction = action as DiscussionAction;
// Check if already consumed
if (this.consumedDiscussions.has(discussionAction.id)) {
this.processNext(generation);
return;
}
// Skip if the discussion's agent isn't in the user's selected list
if (
discussionAction.agentId &&
this.callbacks.isAgentSelected &&
!this.callbacks.isAgentSelected(discussionAction.agentId)
) {
this.consumedDiscussions.add(discussionAction.id);
this.processNext(generation);
return;
}
// 3s delay before showing ProactiveCard (allows previous speech to finish naturally)
const trigger: TriggerEvent = {
id: discussionAction.id,
question: discussionAction.topic,
prompt: discussionAction.prompt,
agentId: discussionAction.agentId,
};
this.triggerDelayTimer = setTimeout(() => {
if (!this.isCurrentGeneration(generation)) return;
this.triggerDelayTimer = null;
if (this.mode !== 'playing') return; // Cancelled if user paused/stopped
this.currentTrigger = trigger;
this.callbacks.onProactiveShow?.(trigger);
// Engine pauses here — user calls confirmDiscussion() or skipDiscussion()
}, DISCUSSION_TRIGGER_DELAY_MS);
break;
}
case 'play_video':
case 'wb_open':
case 'wb_draw_text':
case 'wb_draw_shape':
case 'wb_draw_chart':
case 'wb_draw_latex':
case 'wb_draw_table':
case 'wb_draw_line':
case 'wb_draw_code':
case 'wb_edit_code':
case 'wb_clear':
case 'wb_delete':
case 'wb_close':
case 'widget_highlight':
case 'widget_setState':
case 'widget_annotation':
case 'widget_reveal': {
// Synchronous actions — await completion, then continue
await this.actionEngine.execute(action);
if (!this.isCurrentGeneration(generation)) return;
if (this.mode === 'playing') {
this.processNext(generation);
}
break;
}
default:
// Unknown action, skip
this.processNext(generation);
break;
}
}
// ==================== Browser Native TTS ====================
/**
* Split text into sentence-level chunks for sequential playback.
* Chrome has a bug where utterances >~15s are silently cut off and onend
* never fires, causing the engine to hang. Chunking avoids this.
*/
private splitIntoChunks(text: string): string[] {
// Split on sentence-ending punctuation (Latin + CJK) and newlines
const chunks = text
.split(/(?<=[.!?。!?\n])\s*/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (chunks.length > 0) return chunks;
// Blank/whitespace text → no chunks (so playBrowserTTSChunk finishes cleanly
// instead of speaking an empty utterance that never fires onend). Otherwise
// the text had no sentence punctuation — speak it as one chunk.
return text.trim() ? [text] : [];
}
/**
* Play text using the Web Speech API (browser-native TTS).
* Splits text into sentence-level chunks to avoid Chrome's ~15s cutoff.
* Uses cancel+re-speak for pause/resume (Firefox compatibility).
*/
private playBrowserTTS(speechAction: SpeechAction, generation: number): void {
if (!this.isCurrentGeneration(generation)) return;
this.browserTTSChunks = this.splitIntoChunks(speechAction.text);
this.browserTTSChunkIndex = 0;
this.browserTTSPausedChunks = [];
this.browserTTSActive = true;
this.playBrowserTTSChunk(generation);
}
/** Speak the current chunk; on completion, advance to next or finish. */
private async playBrowserTTSChunk(generation: number): Promise<void> {
if (!this.isCurrentGeneration(generation)) return;
if (this.browserTTSChunkIndex >= this.browserTTSChunks.length) {
// All chunks done
this.browserTTSActive = false;
this.browserTTSChunks = [];
this.callbacks.onSpeechEnd?.();
if (this.mode === 'playing') this.processNext(generation);
return;
}
const settings = useSettingsStore.getState();
const chunkText = this.browserTTSChunks[this.browserTTSChunkIndex];
const utterance = new SpeechSynthesisUtterance(chunkText);
// Apply settings
const speed = this.callbacks.getPlaybackSpeed?.() ?? 1;
utterance.rate = (settings.ttsSpeed ?? 1) * speed;
utterance.volume = settings.ttsMuted ? 0 : (settings.ttsVolume ?? 1);
// Ensure voices are loaded (Chrome loads them asynchronously)
const voices = await this.ensureVoicesLoaded();
if (!this.isCurrentGeneration(generation)) return;
// Set voice: try user's configured voice, fall back to auto-detect language
let voiceFound = false;
if (settings.ttsVoice && settings.ttsVoice !== 'default') {
const voice = voices.find((v) => v.voiceURI === settings.ttsVoice);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
voiceFound = true;
}
}
if (!voiceFound) {
// No usable voice configured — detect text language so the browser
// auto-selects an appropriate voice.
const cjkRatio =
chunkText.length > 0
? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length
: 0;
utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US';
}
utterance.onend = () => {
if (!this.isCurrentGeneration(generation)) return;
this.browserTTSChunkIndex++;
if (this.mode === 'playing') {
this.playBrowserTTSChunk(generation); // next chunk
}
};
utterance.onerror = (event) => {
if (!this.isCurrentGeneration(generation)) return;
// 'canceled' is expected when stop/pause is called — not a real error
if (event.error !== 'canceled') {
log.warn('Browser TTS chunk error:', event.error);
// Skip failed chunk, try next
this.browserTTSChunkIndex++;
if (this.mode === 'playing') {
this.playBrowserTTSChunk(generation);
}
}
// On 'canceled': do nothing — pause handler already saved state
};
// Chrome bug workaround: cancel() before speak() to clear stale synthesis
// state that can produce garbled/broken audio output.
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
/**
* Wait for speechSynthesis voices to load (Chrome loads them asynchronously).
* Caches result so subsequent calls return immediately.
*/
private cachedVoices: SpeechSynthesisVoice[] | null = null;
private async ensureVoicesLoaded(): Promise<SpeechSynthesisVoice[]> {
if (this.cachedVoices && this.cachedVoices.length > 0) {
return this.cachedVoices;
}
let voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
this.cachedVoices = voices;
return voices;
}
// Chrome: voices load asynchronously — wait for the voiceschanged event
await new Promise<void>((resolve) => {
const onVoicesChanged = () => {
window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
resolve();
};
window.speechSynthesis.addEventListener('voiceschanged', onVoicesChanged);
// Timeout after 2s to avoid hanging
setTimeout(() => {
window.speechSynthesis.removeEventListener('voiceschanged', onVoicesChanged);
resolve();
}, 2000);
});
voices = window.speechSynthesis.getVoices();
this.cachedVoices = voices;
return voices;
}
/** Cancel any active browser-native TTS */
private cancelBrowserTTS(): void {
if (this.browserTTSActive) {
this.browserTTSActive = false;
this.browserTTSChunks = [];
this.browserTTSChunkIndex = 0;
this.browserTTSPausedChunks = [];
window.speechSynthesis?.cancel();
}
}
}