/** * 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', 'persistence-manager']; // Queue state this.sentenceQueue = []; this.isProcessing = false; this.onSentenceReadyCallback = null; // Cache for prefetched sentences this.preparedCache = new Map(); this.prefetchingCache = new Map(); this.activeImageWrap = null; this.autoplay = true; // Bind methods this.bindMethods([ 'initialize', 'addSentence', 'processNextSentence', 'setOnSentenceReady', 'completeSentence', 'getCacheKey', 'getPreparedSentence', 'prefetchAhead', 'isSpeechItem', 'getMediaPauseSeconds', 'readFirstFiniteNumber', 'waitForSkippableMediaPause', 'shouldAutoplay', 'waitForManualContinue', 'prepareSentence', 'prepareLayout', 'prepareImageLayout', 'extractWords', 'getDropCapText', 'extractDropCapText', 'calculateAnimationTiming', '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.addEventListener(document, 'preference-updated', (event) => { const { category, key, value } = event.detail || {}; if (category === 'app' && key === 'autoplay') { this.autoplay = value !== false; } }); 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 { const sentence = await this.getPreparedSentence(item); // Prefetch far enough ahead that media pauses do not block TTS // generation for the next spoken paragraph. this.prefetchAhead(); // Notify display handler with complete sentence if (this.onSentenceReadyCallback) { await new Promise(resolve => { sentence.onComplete = resolve; this.onSentenceReadyCallback(sentence, resolve); }); } const mediaPauseSeconds = this.getMediaPauseSeconds(sentence); if (mediaPauseSeconds > 0) { await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id); } if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) { await this.waitForManualContinue(sentence.id); } // 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 && !['paragraph', 'heading'].includes(metadata.type)) { if (metadata.type === 'music') { const audioManager = this.getModule('audio-manager'); if (audioManager && typeof audioManager.playMusic === 'function') { audioManager.getAssetUrl('music', metadata.filename); } } const imageLayout = metadata.type === 'image' ? await this.prepareImageLayout(metadata) : null; return { id, kind: metadata.type, text: text || '', turnId: metadata.turnId ?? null, status: 'ready', metadata: imageLayout ? { ...metadata, imageLayout } : 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, kind: metadata.type === 'heading' ? 'heading' : 'paragraph', text, turnId: metadata.turnId ?? null, paragraphIndex: metadata.paragraphIndex ?? null, isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter), role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : '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 isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading'; const dropCapLines = metadata.dropCap ? 2 : 0; const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0; const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; const layoutText = metadata.layoutText || text; const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText; const wrap = this.consumeImageWrap(); // Measures are consumed in line order by the line breaker. const wrappedWidth = wrap ? Math.max(120, containerWidth - wrap.width) : containerWidth; const imageLeftOffset = wrap && wrap.side !== 'right' ? wrap.width : 0; const imageRightOffset = wrap && wrap.side === 'right' ? wrap.width : 0; const measures = isHeading ? [containerWidth] : wrap && metadata.dropCap ? [ Math.max(120, wrappedWidth - dropCapWidth), Math.max(120, wrappedWidth - dropCapWidth), ...Array(Math.max(0, wrap.lines - dropCapLines)).fill(wrappedWidth), containerWidth ] : wrap ? [ Math.max(120, wrappedWidth - indentWidth), ...Array(Math.max(0, wrap.lines - 1)).fill(wrappedWidth), containerWidth ] : metadata.dropCap ? [ Math.max(120, containerWidth - dropCapWidth), Math.max(120, containerWidth - dropCapWidth), containerWidth ] : [ Math.max(120, containerWidth - indentWidth), containerWidth, containerWidth ]; const lineOffsets = isHeading ? [0] : wrap && metadata.dropCap ? [ imageLeftOffset + dropCapWidth, imageLeftOffset + dropCapWidth, ...Array(Math.max(0, wrap.lines - dropCapLines)).fill(imageLeftOffset), 0 ] : wrap ? [ imageLeftOffset + indentWidth, ...Array(Math.max(0, wrap.lines - 1)).fill(imageLeftOffset), 0 ] : metadata.dropCap ? [ dropCapWidth, dropCapWidth, 0 ] : [ indentWidth, 0, 0 ]; console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, imageRightOffset: ${imageRightOffset.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.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'); } if (wrap) { const usedLines = Math.max(0, (layout.breaks?.length || 1) - 1); const remainingLines = Math.max(0, wrap.lines - usedLines); this.activeImageWrap = remainingLines > 0 ? { ...wrap, lines: remainingLines } : null; } return { breaks: layout.breaks, nodes: layout.nodes, processedText: layout.processedText || text, sourceLayoutText: layoutText, measures, lineOffsets, indentWidth, imageWrap: wrap, dropCap: Boolean(metadata.dropCap), dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '', 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; } } 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 cacheKey = this.getCacheKey(item); const cached = this.preparedCache.get(cacheKey); if (cached) { console.log('SentenceQueue: Using cached sentence'); this.preparedCache.delete(cacheKey); return cached; } const pending = this.prefetchingCache.get(cacheKey); if (pending) { console.log('SentenceQueue: Awaiting active prefetch'); try { const prepared = await pending; return prepared || await this.prepareSentence(item); } finally { this.prefetchingCache.delete(cacheKey); this.preparedCache.delete(cacheKey); } } return this.prepareSentence(item); } prefetchAhead(maxLookahead = 4) { 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.preparedCache.has(nextCacheKey) || this.prefetchingCache.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 = this.prepareSentence(nextItem) .then(prepared => { this.preparedCache.set(nextCacheKey, prepared); console.log('SentenceQueue: Prefetched queued item', { 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 prepared; }) .catch(err => { console.warn('SentenceQueue: Prefetch failed:', err); return null; }) .finally(() => { this.prefetchingCache.delete(nextCacheKey); }); this.prefetchingCache.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); }); } async prepareImageLayout(metadata = {}) { const storyElement = document.getElementById('story'); if (!storyElement) { throw new Error("Story container not found"); } if (document.fonts && document.fonts.ready) { await document.fonts.ready; } 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 lineHeight = parseFloat(computedStyle.lineHeight) || 24; probe.remove(); const pageWidth = storyElement.clientWidth; const size = String(metadata.size || 'landscape').toLowerCase(); const aspect = size === 'portrait' ? (9 / 16) : size === 'square' ? 1 : (16 / 9); const imageGap = lineHeight * 0.9; const maxWidth = size === 'portrait' ? pageWidth * 0.5 : pageWidth; const naturalHeight = maxWidth / aspect; const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); const height = imageLineCount * lineHeight; const width = Math.min(maxWidth, height * aspect); const verticalMargin = lineHeight / 2; const lineCount = imageLineCount + 1; if (size === 'portrait') { this.activeImageWrap = { lines: lineCount, width: width + imageGap, imageWidth: width, gap: imageGap, height, lineHeight, side: metadata.floatSide || 'left' }; } return { size, aspect, width, height, gap: imageGap, lineCount, imageLineCount, lineHeight, verticalMargin, floatSide: metadata.floatSide || 'left', pageWidth }; } consumeImageWrap() { if (!this.activeImageWrap || this.activeImageWrap.lines <= 0) { this.activeImageWrap = null; return null; } const wrap = { ...this.activeImageWrap }; this.activeImageWrap = null; return wrap; } /** * 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(); } } clear() { this.sentenceQueue = []; this.isProcessing = false; this.preparedCache.clear(); this.activeImageWrap = 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;