/** * SentenceQueueModule * Manages the preparation pipeline for sentences, including TTS generation */ import { BaseModule } from './base-module.js'; const TTS_GENERATION_TIMEOUT_MS = 60000; const ASSET_PRELOAD_TIMEOUT_MS = 60000; const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; class SentenceQueueModule extends BaseModule { constructor() { super('sentence-queue', 'Sentence Queue'); // Dependencies this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager']; // Queue state this.sentenceQueue = []; this.isProcessing = false; this.onSentenceReadyCallback = null; // Cache in-flight TTS prefetches only. Layout belongs to the renderer. this.prefetchingSpeech = new Map(); this.autoplay = true; this.inputMode = 'text'; this.lastContinueAt = 0; this.pauseBeforeNextReason = null; this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS; this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS; this.generationRequests = new Map(); this.assetPreloadRequests = new Map(); this.queueGeneration = 0; // Bind methods this.bindMethods([ 'initialize', 'addSentence', 'processNextSentence', 'setOnSentenceReady', 'pauseBeforeNext', 'completeSentence', 'getCacheKey', 'getPreparedSentence', 'prefetchAhead', 'prepareSpeechMetadata', 'preloadAssetsForItem', 'normalizeTtsText', 'getConfiguredTtsGenerationTimeoutMs', 'normalizeTtsGenerationTimeoutMs', 'runTtsPreloadWithTimeout', 'cancelBlockingGeneration', 'cancelGenerationRequests', 'cancelBlockingAssetPreloads', 'cancelAssetPreloads', 'isSpeechItem', 'getMediaPauseSeconds', 'readFirstFiniteNumber', 'waitForSkippableMediaPause', 'shouldAutoplay', 'waitForManualContinue', 'prepareSentence', 'prepareLayout', 'extractWords', 'getDropCapText', 'extractDropCapText', 'calculateAnimationTiming', 'isCurrentQueueItem', 'clear' ]); } /** * 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"); const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && typeof persistenceManager.getPreference === 'function') { this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false; this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs(); } this.addEventListener(document, 'preference-updated', (event) => { const { category, key, value } = event.detail || {}; if (category === 'app' && key === 'autoplay') { this.autoplay = value !== false; } if (category === 'tts' && (key === 'preferred_handler' || key.endsWith('_timeout_ms'))) { this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs(); } }); this.addEventListener(document, 'story:input-mode', (event) => { this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text'; }); this.addEventListener(document, 'ui:command', (event) => { if (event.detail?.type === 'continue') { if (event.detail?.source !== 'display-clear') { this.lastContinueAt = performance.now(); } this.cancelBlockingGeneration('user-fast-forward', { minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS }); this.cancelBlockingAssetPreloads('user-fast-forward', { minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS }); } }); 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; } } pauseBeforeNext(reason = 'manual-pause') { this.pauseBeforeNextReason = reason; } /** * 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]; const queueGeneration = this.queueGeneration; try { if (this.pauseBeforeNextReason) { const reason = this.pauseBeforeNextReason; this.pauseBeforeNextReason = null; await this.waitForManualContinue(reason); if (!this.isCurrentQueueItem(item, queueGeneration)) return; } document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'waiting-generating', reason: 'preparing-next-block', sentenceId: item?.id || null, blockId: item?.blockId || null, kind: item?.kind || item?.type || 'paragraph' } })); const sentence = await this.getPreparedSentence(item); if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Prefetch far enough ahead that media pauses do not block TTS // generation for the next spoken paragraph. this.prefetchAhead(4, queueGeneration); if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Notify display handler with complete sentence if (this.onSentenceReadyCallback) { await new Promise(resolve => { sentence.onComplete = resolve; sentence.playbackStartedAt = performance.now(); this.onSentenceReadyCallback(sentence, resolve); }); if (!this.isCurrentQueueItem(item, queueGeneration)) return; } const mediaPauseSeconds = this.getMediaPauseSeconds(sentence); if (mediaPauseSeconds > 0) { await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id); if (!this.isCurrentQueueItem(item, queueGeneration)) return; } if (this.shouldPauseAfterSentence(sentence)) { await this.waitForManualContinue(sentence.id); if (!this.isCurrentQueueItem(item, queueGeneration)) return; } // Remove from queue and continue if (this.sentenceQueue[0] === item) { this.sentenceQueue.shift(); } if (item.callback) item.callback({ success: true }); } catch (error) { if (!this.isCurrentQueueItem(item, queueGeneration)) return; console.error("SentenceQueue: Error processing sentence:", error); const failedItem = this.sentenceQueue.shift(); console.warn('SentenceQueue: Dropped failed queue item so playback can continue', { sentenceId: failedItem?.id || item?.id || null, blockId: failedItem?.blockId || item?.blockId || null, 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, context = {}) { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory) { throw new Error("TTS dependencies not found"); } const ttsText = this.normalizeTtsText(text); if (!ttsText) { console.warn('SentenceQueue: Empty TTS text after normalization, using estimated silent timing', { sentenceId: context.sentenceId || null }); return this.estimateSpeechDuration(text); } // 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 this.runTtsPreloadWithTimeout(ttsFactory, ttsText, context); if (!result.success) { console.warn("SentenceQueue: Speech preload failed, using estimated duration", { reason: result.reason || 'unknown', sentenceId: context.sentenceId || null, textPreview: ttsText.slice(0, 80) }); return this.estimateSpeechDuration(text); } // Create a speech metadata object return { text: ttsText, duration: result.duration || this.estimateSpeechDuration(text).duration, handler: ttsFactory.getActiveHandler() ? ttsFactory.getActiveHandler().id : null, audioData: result.audioData || null, play: async () => { if (result.audioData && typeof ttsFactory.speakPreloaded === 'function') { return ttsFactory.speakPreloaded(result); } return ttsFactory.speak(ttsText); }, stop: () => { return ttsFactory.stop(); }, isTtsEnabled: isTtsEnabled }; } catch (error) { console.error("Error preparing speech metadata:", error); return this.estimateSpeechDuration(text); } } normalizeTtsText(text) { return String(text || '') .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ') .replace(/\s+/g, ' ') .trim(); } getConfiguredTtsGenerationTimeoutMs() { const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager || typeof persistenceManager.getPreference !== 'function') { return TTS_GENERATION_TIMEOUT_MS; } const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none'); const providerTimeout = preferredHandler && preferredHandler !== 'none' ? persistenceManager.getPreference('tts', `${preferredHandler}_timeout_ms`) : undefined; const genericTimeout = persistenceManager.getPreference('tts', 'generation_timeout_ms'); return this.normalizeTtsGenerationTimeoutMs(providerTimeout ?? genericTimeout ?? TTS_GENERATION_TIMEOUT_MS); } normalizeTtsGenerationTimeoutMs(value) { const timeout = Number(value); if (!Number.isFinite(timeout)) { return TTS_GENERATION_TIMEOUT_MS; } return Math.max(1000, Math.min(600000, Math.round(timeout))); } runTtsPreloadWithTimeout(ttsFactory, text, context = {}) { const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`; const controller = new AbortController(); const startedAt = performance.now(); const timeoutMs = this.getConfiguredTtsGenerationTimeoutMs(); return new Promise((resolve) => { let settled = false; const finish = (result) => { if (settled) return; settled = true; clearTimeout(timeoutId); this.generationRequests.delete(requestId); resolve(result); }; const timeoutId = setTimeout(() => { console.warn('SentenceQueue: TTS generation timed out; continuing without audio', { sentenceId, timeoutMs, textPreview: text.slice(0, 120) }); controller.abort('tts-generation-timeout'); finish({ success: false, reason: 'tts_generation_timeout', timedOut: true }); }, timeoutMs); this.generationRequests.set(requestId, { controller, sentenceId, blocking: context.blocking !== false, startedAt, textPreview: text.slice(0, 120), finish }); Promise.resolve(ttsFactory.preloadSpeech(text, { signal: controller.signal, ttsInstructions: Array.isArray(context.ttsInstructions) ? context.ttsInstructions : [] })) .then(result => finish(result || { success: false, reason: 'empty_tts_result' })) .catch(error => { if (controller.signal.aborted) { console.warn('SentenceQueue: TTS generation cancelled; continuing without audio', { sentenceId, reason: controller.signal.reason || 'aborted', elapsedMs: Math.round(performance.now() - startedAt) }); finish({ success: false, reason: 'tts_generation_aborted', error }); } else { console.warn('SentenceQueue: TTS generation failed; continuing without audio', { sentenceId, error }); finish({ success: false, reason: 'tts_generation_error', error }); } }); }); } cancelBlockingGeneration(reason = 'cancelled', options = {}) { const minWaitMs = Math.max(0, Number(options.minWaitMs || 0)); this.cancelGenerationRequests(reason, request => request.blocking === true && (performance.now() - request.startedAt) >= minWaitMs ); } cancelGenerationRequests(reason = 'cancelled', predicate = () => true) { for (const [requestId, request] of this.generationRequests.entries()) { if (!predicate(request)) continue; console.warn('SentenceQueue: Cancelling TTS generation request', { requestId, sentenceId: request.sentenceId, reason, elapsedMs: Math.round(performance.now() - request.startedAt), textPreview: request.textPreview }); try { request.controller.abort(reason); } catch (error) { console.warn('SentenceQueue: Failed to abort TTS generation request', { requestId, error }); } if (typeof request.finish === 'function') { request.finish({ success: false, reason: 'tts_generation_cancelled' }); } } } cancelBlockingAssetPreloads(reason = 'cancelled', options = {}) { const minWaitMs = Math.max(0, Number(options.minWaitMs || 0)); this.cancelAssetPreloads(reason, request => request.blocking === true && (performance.now() - request.startedAt) >= minWaitMs ); } cancelAssetPreloads(reason = 'cancelled', predicate = () => true) { for (const [requestId, request] of this.assetPreloadRequests.entries()) { if (!predicate(request)) continue; console.warn('SentenceQueue: Cancelling asset preload request', { requestId, sentenceId: request.sentenceId, reason, elapsedMs: Math.round(performance.now() - request.startedAt), assetType: request.assetType }); if (typeof request.finish === 'function') { request.finish({ success: false, reason: 'asset_preload_cancelled', cancelled: true }); } } } /** * 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) { const configuredSpeed = Number(ttsFactory.speed); speedMultiplier = Number.isFinite(configuredSpeed) ? Math.max(0.5, Math.min(2.0, configuredSpeed)) : 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 queue metadata. This module intentionally does not create layout: * live rendering and history rendering must go through the same renderer. */ 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 && !['paragraph', 'heading'].includes(metadata.type)) { await this.preloadAssetsForItem(metadata, { blocking: true, sentenceId: id }); return { id, kind: metadata.type, text: text || '', turnId: metadata.turnId ?? null, blockId: metadata.blockId ?? null, gameId: metadata.gameId ?? null, 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 this.preloadAssetsForItem({ type: 'paragraph', cueMarkers: metadata.cueMarkers || [] }, { blocking: true, sentenceId: id }); } const ttsData = await this.prepareSpeechMetadata(text, { sentenceId: id, blockId: metadata.blockId ?? null, turnId: metadata.turnId ?? null, ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [], blocking: true }); console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`); return { id, kind: metadata.type === 'heading' ? 'heading' : 'paragraph', text, turnId: metadata.turnId ?? null, blockId: metadata.blockId ?? null, gameId: metadata.gameId ?? null, paragraphIndex: metadata.paragraphIndex ?? null, layoutText: metadata.layoutText || text, glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [], ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [], isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter), role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'), dropCap: Boolean(metadata.dropCap), addTopSpace: Boolean(metadata.addTopSpace), cueMarkers: metadata.cueMarkers || [], deferredTags: Array.isArray(metadata.deferredTags) ? metadata.deferredTags : [], status: 'ready', tts: { duration: ttsData.duration, provider: ttsData.handler, audioData: ttsData.audioData || null, play: ttsData.play, stop: ttsData.stop, enabled: ttsData.isTtsEnabled }, animation: { wordTimings: [], cueTimings: [], totalDuration: 0 }, 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 isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading'; const dropCapLines = metadata.dropCap ? 2 : 0; const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const layoutText = metadata.layoutText || text; const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : ''; const dropCapWidth = metadata.dropCap ? await this.measureDropCapReservation(storyElement, dropCapText, lineHeight) : 0; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0 ? metadata.measures : isHeading ? [containerWidth] : metadata.dropCap ? [ Math.max(120, containerWidth - dropCapWidth), Math.max(120, containerWidth - dropCapWidth), containerWidth ] : [ Math.max(120, containerWidth - indentWidth), containerWidth, containerWidth ]; const lineOffsets = Array.isArray(metadata.lineOffsets) && metadata.lineOffsets.length > 0 ? metadata.lineOffsets : isHeading ? [0] : metadata.dropCap ? [ dropCapWidth, dropCapWidth, 0 ] : [ indentWidth, 0, 0 ]; console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`); const layoutOptions = { measures, fontSize: `${fontSize}px`, fontFamily, lineHeight: lineHeight / fontSize, lineHeightPx: lineHeight }; const layout = metadata.dropCap ? this.calculateDropCapLayout(paragraphLayout, layoutPlainText, measures, lineOffsets, layoutOptions) : paragraphLayout.calculateLayout(layoutPlainText, layoutOptions); if (!layout) { throw new Error('Paragraph layout calculation failed'); } return { breaks: layout.breaks, nodes: layout.nodes, lines: layout.lines || null, processedText: layout.processedText || text, sourceLayoutText: layoutText, glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [], measures, lineOffsets, indentWidth, imageWrap: metadata.imageWrap || null, dropCap: Boolean(metadata.dropCap), dropCapText, dropCapWidth, dropCapLines, addTopSpace: Boolean(metadata.addTopSpace), role: metadata.role || (isHeading ? 'chapter-heading' : 'body'), align: isHeading ? 'center' : 'justify', fontSize: layout.fontSize, fontFamily: layout.fontFamily, lineHeight: layout.lineHeight, lineHeightPx: layout.lineHeightPx }; } catch (error) { console.error('SentenceQueue: Error preparing layout:', error); throw error; } } async preloadAssetsForItem(item = {}, context = {}) { const audioManager = this.getModule('audio-manager'); if (!audioManager) return { success: true, reason: 'audio_manager_unavailable' }; const tasks = []; const type = String(item.type || item.kind || '').toLowerCase(); if (['image', 'music', 'sfx', 'sound'].includes(type) && typeof audioManager.preloadStructuredBlock === 'function') { tasks.push(audioManager.preloadStructuredBlock(item)); } if (Array.isArray(item.cueMarkers) && item.cueMarkers.length > 0 && typeof audioManager.preloadMediaCues === 'function') { tasks.push(audioManager.preloadMediaCues(item.cueMarkers)); } const pending = tasks.filter(Boolean); if (pending.length === 0) return { success: true, reason: 'no_assets' }; const state = context.blocking ? 'waiting-generating' : 'playing-generating'; const sentenceId = context.sentenceId || item.id || null; const requestId = `${sentenceId || 'asset'}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`; const startedAt = performance.now(); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state, reason: 'asset-preload-start', sentenceId, assetType: type || 'cue' } })); const result = await new Promise(resolve => { let settled = false; const finish = (value) => { if (settled) return; settled = true; clearTimeout(timeoutId); this.assetPreloadRequests.delete(requestId); resolve(value); }; const timeoutId = setTimeout(() => { console.warn('SentenceQueue: Asset preload timed out; continuing without confirmed asset', { sentenceId, timeoutMs: this.assetPreloadTimeoutMs, assetType: type || 'cue' }); finish({ success: false, reason: 'asset_preload_timeout', timedOut: true }); }, this.assetPreloadTimeoutMs); this.assetPreloadRequests.set(requestId, { blocking: context.blocking !== false, sentenceId, assetType: type || 'cue', startedAt, finish }); Promise.allSettled(pending) .then(results => { const failures = results.filter(entry => entry.status === 'rejected'); if (failures.length > 0) { console.warn('SentenceQueue: Some assets failed to preload; continuing without them', { sentenceId, assetType: type || 'cue', failures: failures.map(entry => entry.reason) }); finish({ success: false, reason: 'asset_preload_failed', failures }); return; } finish({ success: true, reason: 'asset_preload_complete' }); }) .catch(error => { console.warn('SentenceQueue: Asset preload failed unexpectedly; continuing', { sentenceId, assetType: type || 'cue', error }); finish({ success: false, reason: 'asset_preload_error', error }); }); }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: result.success ? 'asset-preload-complete' : result.reason, sentenceId, assetType: type || 'cue', degraded: !result.success } })); return result; } shouldPauseAfterSentence(sentence) { if (sentence.kind !== 'paragraph' || this.shouldAutoplay()) { return false; } if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) { return false; } return this.sentenceQueue.length > 1; } shouldAutoplay() { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && typeof persistenceManager.getPreference === 'function') { return persistenceManager.getPreference('app', 'autoplay', this.autoplay) !== false; } return this.autoplay !== false; } waitForManualContinue(sentenceId) { document.documentElement.dataset.skippablePause = 'true'; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'paused', reason: 'autoplay-disabled', sentenceId } })); return new Promise(resolve => { let resolved = false; const finish = () => { if (resolved) return; resolved = true; delete document.documentElement.dataset.skippablePause; document.removeEventListener('ui:command', onCommand); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'manual-continue', sentenceId } })); resolve(); }; const onCommand = (event) => { if (event.detail?.type === 'continue') { finish(); } }; document.addEventListener('ui:command', onCommand); }); } getCacheKey(item) { return `${item?.id || ''}:${item?.text || ''}`; } async getPreparedSentence(item) { const pending = this.prefetchingSpeech.get(this.getCacheKey(item)); if (pending) { pending.catch(() => null); } return this.prepareSentence(item); } isCurrentQueueItem(item, queueGeneration = this.queueGeneration) { return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; } prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) { if (this.sentenceQueue.length <= 1) { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id } })); console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }); return; } let started = 0; let spokenPrepared = 0; const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1); for (let index = 1; index < limit; index += 1) { const nextItem = this.sentenceQueue[index]; const nextCacheKey = this.getCacheKey(nextItem); if (this.prefetchingSpeech.has(nextCacheKey)) { if (this.isSpeechItem(nextItem)) spokenPrepared += 1; continue; } const state = this.isSpeechItem(nextItem) ? 'playing-generating' : 'playing-ready'; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state, reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index } })); console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index }); const promise = (async () => { if (queueGeneration !== this.queueGeneration) return null; await this.preloadAssetsForItem(nextItem, { sentenceId: nextItem.id, blocking: false, prefetch: true }); if (queueGeneration !== this.queueGeneration) return null; if (!this.isSpeechItem(nextItem)) { return null; } return this.prepareSpeechMetadata(nextItem.text || '', { sentenceId: nextItem.id, blockId: nextItem.blockId ?? null, turnId: nextItem.turnId ?? null, ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [], queueIndex: index, prefetch: true, blocking: false }); })() .then(() => { if (queueGeneration !== this.queueGeneration) return false; console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index } })); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }); return true; }) .catch(err => { console.warn('SentenceQueue: Prefetch failed:', err); return null; }) .finally(() => { this.prefetchingSpeech.delete(nextCacheKey); }); this.prefetchingSpeech.set(nextCacheKey, promise); started += 1; if (this.isSpeechItem(nextItem)) { spokenPrepared += 1; } if (spokenPrepared >= 1 && started >= 2) { break; } } if (started === 0) { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id } })); console.log('Process state: playing-ready', { reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id }); } } isSpeechItem(item) { const type = item?.type || 'paragraph'; return type === 'paragraph' || type === 'heading' || !['image', 'music'].includes(type); } getMediaPauseSeconds(sentence) { if (!sentence || !['image', 'music'].includes(sentence.kind)) { return 0; } const metadata = sentence.metadata || {}; const configuredPause = this.readFirstFiniteNumber( metadata.leadInSeconds, metadata.leadIn, metadata.pause, metadata.delay, 0 ); if (sentence.kind !== 'image') { return configuredPause; } const revealSeconds = Number(metadata.imageRevealSeconds || metadata.revealSeconds || 0.9); return Math.max(configuredPause, Number.isFinite(revealSeconds) ? revealSeconds : 0.9); } readFirstFiniteNumber(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number)) { return Math.max(0, number); } } return 0; } waitForSkippableMediaPause(seconds, kind = 'media', sentenceId = null) { const duration = Math.max(0, Number(seconds) || 0) * 1000; if (duration <= 0) return Promise.resolve(false); const startedAt = performance.now(); console.log(`SentenceQueue: Waiting ${seconds}s for ${kind} lead`, { sentenceId }); document.documentElement.dataset.skippablePause = 'true'; document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration, sentenceId } })); return new Promise(resolve => { let finished = false; let timeoutId = null; const finish = (skipped, source = null) => { if (finished) return; finished = true; clearTimeout(timeoutId); document.removeEventListener('ui:command', onCommand); delete document.documentElement.dataset.skippablePause; const elapsedMs = Math.round(performance.now() - startedAt); console.log(`SentenceQueue: ${kind} lead ${skipped ? 'skipped' : 'complete'}`, { sentenceId, elapsedMs, source }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}`, duration, elapsedMs, sentenceId } })); resolve(skipped); }; const onCommand = (event) => { if (event.detail?.type === 'continue') { finish(true, event.detail); } }; document.addEventListener('ui:command', onCommand); timeoutId = setTimeout(() => finish(false), duration); }); } /** * 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(); } async measureDropCapReservation(container, dropCapText, lineHeight) { if (!container || !dropCapText) { return lineHeight * 1.34; } const probeParagraph = document.createElement('p'); const probe = document.createElement('span'); Object.assign(probeParagraph.style, { position: 'absolute', visibility: 'hidden', left: '-8000px', top: '-8000px', margin: '0', padding: '0', lineHeight: `${lineHeight}px` }); probe.className = 'drop-cap story-drop-cap'; probe.textContent = dropCapText; probe.style.position = 'static'; probe.style.display = 'inline-block'; probeParagraph.appendChild(probe); container.appendChild(probeParagraph); const computed = window.getComputedStyle(probe); if (document.fonts && typeof document.fonts.load === 'function') { const fontDescriptor = [ computed.fontStyle, computed.fontVariant, computed.fontWeight, computed.fontSize, computed.fontFamily ].filter(Boolean).join(' '); try { await document.fonts.load(fontDescriptor, dropCapText); await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(resolve)); } catch (error) { console.warn('SentenceQueue: Drop-cap font load check failed; measuring current font state', error); } } const rect = probe.getBoundingClientRect(); let inkRight = 0; try { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (context) { context.font = [ computed.fontStyle, computed.fontVariant, computed.fontWeight, computed.fontSize, computed.fontFamily ].filter(Boolean).join(' '); const metrics = context.measureText(dropCapText); inkRight = Number.isFinite(metrics.actualBoundingBoxRight) && metrics.actualBoundingBoxRight > 0 ? metrics.actualBoundingBoxRight : (metrics.width || 0); } } catch (error) { console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error); } probeParagraph.remove(); const fallbackAdvance = Math.max( Number.isFinite(rect.width) && rect.width > 0 ? rect.width : 0, Number.isFinite(probe.offsetWidth) && probe.offsetWidth > 0 ? probe.offsetWidth : 0, Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0 ); const glyphAdvance = inkRight > 0 ? inkRight : (fallbackAdvance > 0 ? fallbackAdvance : lineHeight * 1.34); return glyphAdvance + this.measureNormalTextGap(container, lineHeight); } measureNormalTextGap(container, lineHeight) { const story = container?.closest?.('#story') || document.getElementById('story') || container; const computed = window.getComputedStyle(story); try { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (context) { context.font = [ computed.fontStyle, computed.fontVariant, computed.fontWeight, computed.fontSize, computed.fontFamily ].filter(Boolean).join(' '); const gap = context.measureText('\u2002').width; if (Number.isFinite(gap) && gap > 0) { return gap; } } } catch (error) { console.warn('SentenceQueue: Could not measure normal text gap', error); } return lineHeight / 2; } calculateDropCapLayout(paragraphLayout, text, measures, lineOffsets, layoutOptions) { const firstLineOptions = { ...layoutOptions, measures: [measures[0], Math.max(measures[0] * 20, 10000)], fontVariantCaps: 'all-small-caps', fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on' }; const firstLayout = paragraphLayout.calculateLayout(text, firstLineOptions); if (!firstLayout?.breaks || firstLayout.breaks.length < 2) { return paragraphLayout.calculateLayout(text, layoutOptions); } const firstLine = this.extractLayoutLine(firstLayout, 0, { measure: measures[0], offset: lineOffsets[0], styleClass: 'story-dropcap-first-line' }); const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position); const remainingLayout = paragraphLayout.calculateLayout(remainingText, { ...layoutOptions, measures: [measures[1], ...measures.slice(2)] }); const remainingLines = []; if (remainingLayout?.breaks?.length > 1) { for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) { remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, { measure: measures[Math.min(lineIndex + 1, measures.length - 1)], offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0, styleClass: '' })); } } const lines = [firstLine, ...remainingLines].filter(Boolean); return { breaks: this.breaksFromLines(lines), nodes: lines.flatMap(line => line.nodes), lines, originalText: text, processedText: text, width: layoutOptions.width, lineHeight: layoutOptions.lineHeight, lineHeightPx: layoutOptions.lineHeightPx, fontSize: layoutOptions.fontSize, fontFamily: layoutOptions.fontFamily }; } extractLayoutLine(layout, lineIndex, metadata = {}) { const startBreak = layout.breaks[lineIndex]; const endBreak = layout.breaks[lineIndex + 1]; if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) { return null; } const nodes = []; for (let index = startBreak.position; index <= endBreak.position; index += 1) { const node = layout.nodes[index]; if (!node) continue; if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) { continue; } const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000; if (node.type === 'penalty' && node.penalty <= forcedBreak) { continue; } nodes.push({ ...node }); } const endNode = layout.nodes[endBreak.position]; return { nodes, ratio: endBreak.ratio || 0, measure: metadata.measure, offset: metadata.offset || 0, styleClass: metadata.styleClass || '', hyphenated: endNode?.type === 'penalty' && endNode.penalty === 100 }; } extractRemainingLayoutText(layout, breakPosition) { if (!Array.isArray(layout.nodes)) return ''; const fragments = []; for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) { const node = layout.nodes[index]; if (!node) continue; if (node.type === 'box' || node.type === 'tag') { fragments.push(node.value || ''); } else if (node.type === 'glue' && node.width > 0) { fragments.push(' '); } else if (node.type === 'penalty' && node.penalty === 100) { fragments.push('|'); } } return fragments.join('').replace(/\s+/g, ' ').trimStart(); } breaksFromLines(lines) { const breaks = [{ position: 0, ratio: 0 }]; let position = 0; for (const line of lines) { position += Math.max(0, line.nodes.length - 1); breaks.push({ position, ratio: line.ratio || 0 }); position += 1; } return breaks; } /** * 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(); } } clear() { this.queueGeneration += 1; this.sentenceQueue = []; this.isProcessing = false; this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared'); this.prefetchingSpeech.clear(); this.pauseBeforeNextReason = null; document.dispatchEvent(new CustomEvent('tts:queue-empty', { detail: { reason: 'sentence-queue-cleared' } })); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'ready', reason: 'sentence-queue-cleared' } })); } } // 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;