Files
ai.interactive.fiction/public/js/playback-coordinator-module.js
T

370 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',
'completeSentenceVisual',
'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.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.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 0 0 0)';
});
}
/**
* 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
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;