Checkpoint current interactive fiction state
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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);
|
||||
const transitionDuration = `${duration}ms`;
|
||||
|
||||
word.style.transition = `opacity ${transitionDuration} linear, transform ${transitionDuration} ease-out`;
|
||||
word.style.visibility = 'visible';
|
||||
word.style.opacity = '1';
|
||||
word.style.transform = 'translateY(0)';
|
||||
}, 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.opacity = '1';
|
||||
word.style.transform = 'translateY(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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
Reference in New Issue
Block a user