363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
/**
|
|
* 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',
|
|
'fastForward',
|
|
'stop'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - 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<void>} - 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;
|
|
|
|
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.isPlaying = false;
|
|
this.currentSentence = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play TTS audio for a sentence
|
|
* @param {Object} sentence - Sentence object with TTS data
|
|
* @returns {Promise<void>} - 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<void>} - 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
|
|
};
|
|
});
|
|
}
|
|
|
|
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 = 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.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<string>} 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');
|
|
|
|
const animQueue = this.getModule('animation-queue');
|
|
if (animQueue) {
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Complete all word animations immediately
|
|
if (this.currentSentence.element) {
|
|
const wordElements = this.currentSentence.element.querySelectorAll('.word');
|
|
wordElements.forEach(word => {
|
|
word.style.animation = 'none';
|
|
word.style.opacity = '1';
|
|
word.style.transform = 'translateY(0)';
|
|
word.style.clipPath = 'inset(0 0 0 0)';
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|