/** * SentenceQueueModule * Manages the preparation pipeline for sentences, including TTS generation */ import { BaseModule } from './base-module.js'; class SentenceQueueModule extends BaseModule { constructor() { super('sentence-queue', 'Sentence Queue'); // Dependencies this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager']; // Queue state this.sentenceQueue = []; this.isProcessing = false; this.onSentenceReadyCallback = null; // Cache for prefetched sentences this.preparedCache = new Map(); // Bind methods this.bindMethods([ 'initialize', 'addSentence', 'processNextSentence', 'setOnSentenceReady', 'completeSentence', 'prepareSentence', 'prepareLayout', 'extractWords', 'getDropCapText', 'extractDropCapText', 'calculateAnimationTiming' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { // Get dependencies const textBuffer = this.getModule('text-buffer'); if (!textBuffer) { console.error("SentenceQueue: TextBuffer dependency not found"); return false; } // Set up the text buffer to send sentences to this queue textBuffer.setOnSentenceReady((sentence, callback) => { this.addSentence(sentence, callback); }); this.reportProgress(100, "Sentence queue ready"); return true; } catch (error) { console.error("Error initializing Sentence Queue:", error); return false; } } /** * Set callback for when a sentence is ready for display * @param {Function} callback - Function to call with prepared sentence */ setOnSentenceReady(callback) { if (typeof callback === 'function') { this.onSentenceReadyCallback = callback; } } /** * Add a sentence to the queue * @param {string} sentence - Sentence to add * @param {Function} callback - Callback to call when sentence is processed */ addSentence(sentence, callback) { const queueItem = typeof sentence === 'object' && sentence !== null ? { ...sentence, callback } : { text: sentence, callback }; this.sentenceQueue.push({ ...queueItem, text: String(queueItem.text || '').trim() }); // Process the queue if not already processing if (!this.isProcessing) { this.processNextSentence(); } } /** * Process the next sentence in the queue */ async processNextSentence() { if (this.sentenceQueue.length === 0 || this.isProcessing) { return; } this.isProcessing = true; const item = this.sentenceQueue[0]; try { // Check if sentence is already in cache const cacheKey = `${item.id || ''}:${item.text}`; let sentence = this.preparedCache.get(cacheKey); if (!sentence) { // Prepare complete sentence object (TTS + layout in parallel) sentence = await this.prepareSentence(item); } else { console.log('SentenceQueue: Using cached sentence'); this.preparedCache.delete(cacheKey); } // Prefetch next sentence while current displays if (this.sentenceQueue.length > 1) { const nextItem = this.sentenceQueue[1]; const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`; if (!this.preparedCache.has(nextCacheKey)) { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id } })); console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id }); this.prepareSentence(nextItem) .then(prepared => { this.preparedCache.set(nextCacheKey, prepared); console.log('SentenceQueue: Prefetched next sentence'); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id } })); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id }); }) .catch(err => console.warn('SentenceQueue: Prefetch failed:', err)); } } else { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id } })); console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id }); } // Notify display handler with complete sentence if (this.onSentenceReadyCallback) { await new Promise(resolve => { sentence.onComplete = resolve; this.onSentenceReadyCallback(sentence, resolve); }); } // Remove from queue and continue this.sentenceQueue.shift(); if (item.callback) item.callback({ success: true }); } catch (error) { console.error("SentenceQueue: Error processing sentence:", error); if (item.callback) item.callback({ success: false, error }); } finally { this.isProcessing = false; if (this.sentenceQueue.length > 0) { this.processNextSentence(); } else { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'ready', reason: 'queue-empty' } })); document.dispatchEvent(new CustomEvent('tts:queue-empty', { detail: { reason: 'sentence-queue-empty' } })); console.log('Process state: ready', { reason: 'queue-empty' }); } } } /** * Prepare speech metadata for a sentence * @param {string} text - Text to prepare speech for * @returns {Promise} - Speech metadata object */ async prepareSpeechMetadata(text) { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory) { throw new Error("TTS dependencies not found"); } // Check if TTS is enabled via active handler const activeHandler = ttsFactory.getActiveHandler(); const isTtsEnabled = activeHandler !== null; // If TTS is disabled, estimate duration based on character count if (!isTtsEnabled) { return this.estimateSpeechDuration(text); } try { // Preload the speech to get metadata const result = await ttsFactory.preloadSpeech(text); if (!result.success) { console.warn("SentenceQueue: Speech preload failed, using estimated duration"); return this.estimateSpeechDuration(text); } // Create a speech metadata object return { text: text, duration: result.duration || this.estimateSpeechDuration(text).duration, handler: ttsFactory.getActiveHandler() ? ttsFactory.getActiveHandler().id : null, play: async () => { return ttsFactory.speak(text); }, stop: () => { return ttsFactory.stop(); }, isTtsEnabled: isTtsEnabled }; } catch (error) { console.error("Error preparing speech metadata:", error); return this.estimateSpeechDuration(text); } } /** * Estimate speech duration based on character count * @param {string} text - Text to estimate duration for * @returns {Object} - Speech metadata object with estimated duration */ estimateSpeechDuration(text) { // Average aloud narration is around 12 characters per second at 1x. const charactersPerSecond = 12; let speedMultiplier = 1.0; const ttsFactory = this.getModule('tts-factory'); if (ttsFactory) { speedMultiplier = Number.isFinite(ttsFactory.speed) ? Math.max(0.25, ttsFactory.speed) : 1.0; } // Calculate estimated duration in milliseconds const charCount = text.length; const durationSeconds = charCount / (charactersPerSecond * speedMultiplier); const durationMs = Math.max(durationSeconds * 1000, 800); return { text: text, duration: durationMs, handler: null, play: async () => ({ success: false, reason: 'tts_disabled' }), stop: () => true, isTtsEnabled: false, isEstimated: true }; } /** * Prepare a complete sentence object with TTS and layout * @param {string} text - Text to prepare * @returns {Promise} - Complete sentence object */ async prepareSentence(item) { const text = typeof item === 'string' ? item : item.text; const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const metadata = typeof item === 'object' && item !== null ? item : {}; try { if (metadata.type && metadata.type !== 'paragraph') { if (metadata.type === 'music') { const audioManager = this.getModule('audio-manager'); if (audioManager && typeof audioManager.playMusic === 'function') { audioManager.getAssetUrl('music', metadata.filename); } } return { id, kind: metadata.type, text: text || '', status: 'ready', metadata, tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false }, animation: { wordTimings: [], cueTimings: [], totalDuration: 0 }, element: null, onComplete: null }; } const audioManager = this.getModule('audio-manager'); if (audioManager && typeof audioManager.preloadMediaCues === 'function') { await audioManager.preloadMediaCues(metadata.cueMarkers || []); } // Prepare TTS and layout in parallel const [ttsData, layoutData] = await Promise.all([ this.prepareSpeechMetadata(text), this.prepareLayout(text, metadata) ]); // Calculate animation timing based on TTS duration const words = this.extractWords(layoutData.nodes); const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []); console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`); return { id, text, paragraphIndex: metadata.paragraphIndex ?? null, isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter), role: metadata.role || 'body', dropCap: Boolean(metadata.dropCap), addTopSpace: Boolean(metadata.addTopSpace), cueMarkers: metadata.cueMarkers || [], status: 'ready', layout: layoutData, tts: { duration: ttsData.duration, provider: ttsData.handler, audioData: ttsData.audioData || null, play: ttsData.play, stop: ttsData.stop, enabled: ttsData.isTtsEnabled }, animation: animation, element: null, onComplete: null }; } catch (error) { console.error('SentenceQueue: Error preparing sentence:', error); throw error; } } /** * Prepare layout for a sentence * @param {string} text - Text to prepare layout for * @returns {Promise} - Layout data */ async prepareLayout(text, metadata = {}) { const paragraphLayout = this.getModule('paragraph-layout'); if (!paragraphLayout) { throw new Error("ParagraphLayout module not found"); } try { if (document.fonts && document.fonts.ready) { await document.fonts.ready; } // Calculate layout with Knuth-Plass const storyElement = document.getElementById('story'); if (!storyElement) { throw new Error("Story container not found"); } // Get actual CSS values from the paragraph typography rule, not the // container. The measured font and rendered font must be identical. const containerWidth = storyElement.clientWidth; const probe = document.createElement('p'); probe.style.visibility = 'hidden'; probe.style.position = 'absolute'; probe.style.left = '-8000px'; probe.style.top = '-8000px'; storyElement.appendChild(probe); const computedStyle = window.getComputedStyle(probe); const fontSize = parseFloat(computedStyle.fontSize); const lineHeight = parseFloat(computedStyle.lineHeight); const fontFamily = computedStyle.fontFamily; probe.remove(); console.log(`SentenceQueue: Container metrics - width: ${containerWidth}px, fontSize: ${fontSize}px, lineHeight: ${lineHeight}px`); // Standard book indentation: no indent on the first chapter paragraph, // first-line indent on following paragraphs. const dropCapLines = metadata.dropCap ? 2 : 0; const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0; const indentWidth = (metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const layoutText = metadata.layoutText || text; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; // Measures are consumed in line order by the line breaker. const measures = metadata.dropCap ? [ Math.max(120, containerWidth - dropCapWidth), Math.max(120, containerWidth - dropCapWidth), containerWidth ] : [ Math.max(120, containerWidth - indentWidth), containerWidth, containerWidth ]; console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`); const layout = paragraphLayout.calculateLayout(layoutPlainText, { measures, fontSize: `${fontSize}px`, fontFamily, lineHeight: lineHeight / fontSize, lineHeightPx: lineHeight }); if (!layout) { throw new Error('Paragraph layout calculation failed'); } return { breaks: layout.breaks, nodes: layout.nodes, processedText: layout.processedText || text, sourceLayoutText: layoutText, measures, indentWidth, dropCap: Boolean(metadata.dropCap), dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '', dropCapLines, addTopSpace: Boolean(metadata.addTopSpace), role: metadata.role || 'body', fontSize: layout.fontSize, fontFamily: layout.fontFamily, lineHeight: layout.lineHeight, lineHeightPx: layout.lineHeightPx }; } catch (error) { console.error('SentenceQueue: Error preparing layout:', error); throw error; } } /** * Extract words from layout nodes * @param {Array} nodes - Layout nodes from Knuth-Plass algorithm * @returns {Array} - Array of words */ extractWords(nodes) { if (!nodes || !Array.isArray(nodes)) { return []; } return nodes .filter(node => node.type === 'box') .map(node => node.value || ''); } getDropCapText(text) { const plain = String(text || '').replace(/<[^>]+>/g, ''); const match = plain.match(/^([“"']?[A-Za-zÀ-ÖØ-öø-ÿ])/u); return match ? match[1] : ''; } extractDropCapText(text) { const dropCap = this.getDropCapText(text); if (!dropCap) return text; return String(text).replace(dropCap, '').trimStart(); } /** * Calculate animation timing based on TTS duration * @param {Array} words - Array of words to animate * @param {number} totalDuration - Total duration in milliseconds * @returns {Object} - Animation timing data */ calculateAnimationTiming(words, totalDuration, cueMarkers = []) { if (!words || words.length === 0) { return { wordTimings: [], cueTimings: [], totalDuration: 0 }; } const totalChars = words.reduce((sum, word) => sum + word.length, 0); if (totalChars === 0) { return { wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })), cueTimings: [], 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; }); const cueTimings = (cueMarkers || []).map(cue => { const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1)); const timing = wordTimings[wordIndex] || { delay: currentDelay }; return { ...cue, delay: timing.delay }; }); return { wordTimings, cueTimings, totalDuration: Math.round(currentDelay) }; } /** * Complete processing of a sentence * @param {Object} item - Queue item * @param {Object} result - Processing result */ completeSentence(item, result) { // Remove from queue this.sentenceQueue.shift(); // Call the original callback if (item.callback) { item.callback(result); } // Reset processing flag this.isProcessing = false; // Process next sentence if any if (this.sentenceQueue.length > 0) { this.processNextSentence(); } } } // Create the singleton instance const SentenceQueue = new SentenceQueueModule(); // Export the module export { SentenceQueue }; // Register with the module registry if (window.moduleRegistry) { window.moduleRegistry.register(SentenceQueue); } // Keep a reference in window for loader system window.SentenceQueue = SentenceQueue;