/** * Playback Coordinator Module * Synchronizes text animation with TTS audio playback to ensure exact timing match */ import { BaseModule } from './base-module.js'; class PlaybackCoordinatorModule extends BaseModule { constructor() { super('playback-coordinator', 'Playback Coordinator'); // Module dependencies this.dependencies = ['animation-queue', 'tts-factory']; // Current playback state this.isPlaying = false; this.currentSentence = null; // Bind methods this.bindMethods([ 'play', 'calculateWordTimings', 'animateWords', 'waitForAudioStart', 'completeSentenceVisual', 'accelerateActiveWordAnimations', 'fastForward', 'stop' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(50, "Initializing Playback Coordinator"); // Verify dependencies const animQueue = this.getModule('animation-queue'); const ttsFactory = this.getModule('tts-factory'); if (!animQueue || !ttsFactory) { console.error("PlaybackCoordinator: Missing required dependencies"); return false; } this.reportProgress(100, "Playback Coordinator ready"); return true; } catch (error) { console.error("Error initializing Playback Coordinator:", error); return false; } } /** * Play a sentence with synchronized animation + TTS * @param {Object} sentence - Prepared sentence object with layout, TTS, and animation data * @returns {Promise} - Resolves when playback completes */ async play(sentence) { if (this.isPlaying) { console.warn('PlaybackCoordinator: Already playing, canceling previous'); await this.stop(); } this.isPlaying = true; this.currentSentence = sentence; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'playback-start', sentenceId: sentence?.id ?? null } })); try { // Start TTS first, then begin text animation when the audio element // confirms playback has started. Sentence preparation/prefetching is // handled by SentenceQueue and can still run while this sentence plays. const ttsPromise = this.playTTS(sentence); await this.waitForAudioStart(sentence, ttsPromise); const animPromise = this.animateWords(sentence); // Wait for both to complete await Promise.all([ttsPromise, animPromise]); console.log(`PlaybackCoordinator: Completed sentence ${sentence.id}`); } catch (error) { console.error('PlaybackCoordinator: Error during playback:', error); throw error; } finally { this.completeSentenceVisual(sentence); this.isPlaying = false; this.currentSentence = null; } } completeSentenceVisual(sentence) { if (!sentence?.element) return; sentence.element.dataset.playbackComplete = 'true'; sentence.element.querySelectorAll('.word').forEach(word => { word.onanimationend = null; word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'none'; }); } accelerateActiveWordAnimations(sentence) { if (!sentence?.element) return; sentence.element.querySelectorAll('.word').forEach(word => { if (typeof word.getAnimations !== 'function') return; word.getAnimations() .filter(animation => animation.playState === 'running' || animation.playState === 'pending') .forEach(animation => { try { if (animation.effect && typeof animation.effect.getTiming === 'function' && typeof animation.effect.updateTiming === 'function') { const timing = animation.effect.getTiming(); const elapsed = Number(animation.currentTime || 0); const duration = Math.max(1, Number(timing.duration || 1)); const remaining = Math.max(0, duration - elapsed); const targetRemaining = 24; if (remaining > targetRemaining) { const playbackRate = Math.min(80, Math.max(1, remaining / targetRemaining)); if (typeof animation.updatePlaybackRate === 'function') { animation.updatePlaybackRate(playbackRate); } else { animation.playbackRate = playbackRate; } } } } catch (error) { console.warn('PlaybackCoordinator: Could not accelerate active word animation', error); } }); }); } /** * Play TTS audio for a sentence * @param {Object} sentence - Sentence object with TTS data * @returns {Promise} - Resolves when TTS completes */ async playTTS(sentence) { if (!sentence.tts || !sentence.tts.enabled) { // TTS disabled, return immediately return Promise.resolve(); } try { document.dispatchEvent(new CustomEvent('tts:playback-start', { detail: { sentenceId: sentence.id } })); if (typeof sentence.tts.play === 'function') { await sentence.tts.play(); } else { console.warn('PlaybackCoordinator: TTS play function not available'); } } catch (error) { console.error('PlaybackCoordinator: TTS playback error:', error); // Don't throw - allow animation to continue } finally { document.dispatchEvent(new CustomEvent('tts:playback-end', { detail: { sentenceId: sentence.id } })); } } async waitForAudioStart(sentence, ttsPromise) { if (!sentence.tts || !sentence.tts.enabled) { return; } return new Promise((resolve) => { let settled = false; const cleanup = () => { document.removeEventListener('tts:audio-started', onStarted); document.removeEventListener('tts:playback-end', onEnded); clearTimeout(timeout); }; const finish = (reason) => { if (settled) { return; } settled = true; cleanup(); console.log(`PlaybackCoordinator: Animation start released (${reason}) for ${sentence.id}`); resolve(); }; const onStarted = () => finish('audio-started'); const onEnded = (event) => { if (!event.detail || event.detail.sentenceId === sentence.id) { finish('tts-ended-before-start'); } }; const timeout = setTimeout(() => finish('audio-start-timeout'), 1500); document.addEventListener('tts:audio-started', onStarted, { once: true }); document.addEventListener('tts:playback-end', onEnded); Promise.resolve(ttsPromise).then(() => finish('tts-promise-resolved')).catch(() => finish('tts-promise-rejected')); }); } /** * Animate words using calculated timings * @param {Object} sentence - Sentence object with animation data and DOM element * @returns {Promise} - Resolves when animation completes */ async animateWords(sentence) { if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) { console.error('PlaybackCoordinator: Missing animation data'); return Promise.resolve(); } const animQueue = this.getModule('animation-queue'); if (!animQueue) { console.error('PlaybackCoordinator: Animation queue not available'); return Promise.resolve(); } const wordElements = sentence.element.querySelectorAll('.word'); let wordTimings = sentence.animation.wordTimings; let cueTimings = sentence.animation.cueTimings || []; if (wordElements.length !== wordTimings.length) { console.info(`PlaybackCoordinator: Word count mismatch (DOM: ${wordElements.length}, timings: ${wordTimings.length}); recalculating timings from rendered words`); const renderedWords = Array.from(wordElements).map(word => word.textContent || ''); const duration = sentence.tts?.duration || sentence.animation.totalDuration || 0; wordTimings = this.calculateWordTimings(renderedWords, duration).wordTimings; cueTimings = cueTimings.map(cue => { const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1)); return { ...cue, delay: (wordTimings[wordIndex] || { delay: duration }).delay }; }); } document.dispatchEvent(new CustomEvent('book-texture:reveal-block', { detail: { id: sentence.id, blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null, wordTimings, cueTimings, totalDuration: sentence.animation.totalDuration || 0 } })); return new Promise((resolve) => { const totalDuration = wordTimings.length > 0 ? Math.max(...wordTimings.map(timing => timing.delay + timing.duration)) : 0; console.log(`PlaybackCoordinator: Animating ${wordTimings.length} words over ${totalDuration}ms`); if (wordTimings.length > 0) { console.log(` First word delay: ${wordTimings[0].delay}ms, Last word delay: ${wordTimings[wordTimings.length-1].delay}ms`); } // Schedule each word animation wordTimings.forEach((timing, i) => { if (i < wordElements.length) { animQueue.schedule(() => { const word = wordElements[i]; const duration = animQueue.isFastForwarding && animQueue.isFastForwarding() ? Math.min(24, Math.max(8, Math.round((timing.duration || 0) * 0.035))) : Math.max(0, timing.duration || 0); word.style.transition = 'none'; word.style.animation = 'none'; word.style.visibility = 'visible'; word.style.opacity = '1'; word.style.transform = 'translateY(0)'; word.style.clipPath = 'inset(0 100% 0 0)'; word.onanimationend = () => { word.style.clipPath = 'none'; word.onanimationend = null; }; word.style.animation = `wordReveal ${duration}ms linear forwards`; }, timing.delay); } }); cueTimings.forEach(cue => { animQueue.schedule(() => { document.dispatchEvent(new CustomEvent('story:media-cue', { detail: { sentenceId: sentence.id, ...cue } })); }, cue.delay || 0); }); // Schedule completion callback animQueue.schedule(() => { resolve(); }, totalDuration + 100); // Small buffer }); } /** * Calculate word-level timing to match total TTS duration * This is a utility method that can be called by SentenceQueue during preparation * @param {Array} words - Array of words to animate * @param {number} totalDuration - Total duration in milliseconds * @returns {Object} - Object with wordTimings array and totalDuration */ calculateWordTimings(words, totalDuration) { if (!words || words.length === 0) { return { wordTimings: [], totalDuration: 0 }; } // Calculate characters per word const totalChars = words.reduce((sum, word) => sum + word.length, 0); if (totalChars === 0) { // Edge case: all empty words return { wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })), totalDuration: 0 }; } const msPerChar = totalDuration / totalChars; let currentDelay = 0; const wordTimings = words.map(word => { const duration = word.length * msPerChar; const timing = { word: word, delay: currentDelay, duration: duration }; currentDelay += duration; return timing; }); return { wordTimings, totalDuration: Math.round(currentDelay) }; } /** * Fast forward current playback * Completes all animations immediately and stops TTS */ async fastForward() { if (!this.isPlaying || !this.currentSentence) { return; } console.log('PlaybackCoordinator: Fast forwarding'); this.accelerateActiveWordAnimations(this.currentSentence); document.dispatchEvent(new CustomEvent('book-texture:fast-forward', { detail: { id: this.currentSentence?.id, blockId: this.currentSentence?.blockId ?? this.currentSentence?.metadata?.blockId ?? null } })); const animQueue = this.getModule('animation-queue'); if (animQueue) { if (typeof animQueue.fastForwardSequential === 'function') { animQueue.fastForwardSequential(320); } else { animQueue.fastForward(); } } const ttsFactory = this.getModule('tts-factory'); if (ttsFactory && typeof ttsFactory.fadeOut === 'function') { await ttsFactory.fadeOut(1000); } else if (this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') { await new Promise(resolve => { setTimeout(() => { this.currentSentence.tts.stop(); resolve(); }, 1000); }); } if (!animQueue || typeof animQueue.fastForwardSequential !== 'function') { this.completeSentenceVisual(this.currentSentence); } } /** * Stop current playback */ async stop() { if (!this.isPlaying) { return; } console.log('PlaybackCoordinator: Stopping'); // Stop TTS if (this.currentSentence && this.currentSentence.tts && typeof this.currentSentence.tts.stop === 'function') { this.currentSentence.tts.stop(); } // Clear animation queue const animQueue = this.getModule('animation-queue'); if (animQueue) { animQueue.clearAll(); } this.isPlaying = false; this.currentSentence = null; document.dispatchEvent(new CustomEvent('tts:playback-end', { detail: { reason: 'playback-coordinator-stop' } })); } } // Create the singleton instance const PlaybackCoordinator = new PlaybackCoordinatorModule(); // Export the module export { PlaybackCoordinator }; // Register with the module registry if (window.moduleRegistry) { window.moduleRegistry.register(PlaybackCoordinator); } // Keep a reference in window for loader system window.PlaybackCoordinator = PlaybackCoordinator;