Files
ai.interactive.fiction/public/js/sentence-queue-module.js
T

1133 lines
43 KiB
JavaScript

/**
* SentenceQueueModule
* Manages the preparation pipeline for sentences, including TTS generation
*/
import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000;
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.generationRequests = new Map();
// Bind methods
this.bindMethods([
'initialize',
'addSentence',
'processNextSentence',
'setOnSentenceReady',
'pauseBeforeNext',
'completeSentence',
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'prepareSpeechMetadata',
'normalizeTtsText',
'runTtsPreloadWithTimeout',
'cancelBlockingGeneration',
'cancelGenerationRequests',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
'waitForSkippableMediaPause',
'shouldAutoplay',
'waitForManualContinue',
'prepareSentence',
'prepareLayout',
'extractWords',
'getDropCapText',
'extractDropCapText',
'calculateAnimationTiming',
'clear'
]);
}
/**
* Initialize the module
* @returns {Promise<boolean>} - 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;
}
});
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') {
this.lastContinueAt = performance.now();
this.cancelBlockingGeneration('user-fast-forward');
}
});
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];
try {
if (this.pauseBeforeNextReason) {
const reason = this.pauseBeforeNextReason;
this.pauseBeforeNextReason = null;
await this.waitForManualContinue(reason);
}
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;
sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve);
});
}
const mediaPauseSeconds = this.getMediaPauseSeconds(sentence);
if (mediaPauseSeconds > 0) {
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
}
if (this.shouldPauseAfterSentence(sentence)) {
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<Object>} - 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();
}
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();
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: this.ttsGenerationTimeoutMs,
textPreview: text.slice(0, 120)
});
controller.abort('tts-generation-timeout');
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
}, this.ttsGenerationTimeoutMs);
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 }))
.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') {
this.cancelGenerationRequests(reason, request => request.blocking === true);
}
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' });
}
}
}
/**
* 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 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)) {
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 || '',
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 audioManager.preloadMediaCues(metadata.cueMarkers || []);
}
const ttsData = await this.prepareSpeechMetadata(text, {
sentenceId: id,
blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null,
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,
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<Object>} - 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
? 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,
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;
}
}
shouldPauseAfterSentence(sentence) {
if (sentence.kind !== 'paragraph' || this.shouldAutoplay()) {
return false;
}
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
return false;
}
if (this.sentenceQueue.length <= 1 && this.inputMode === 'choice') {
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) {
await pending.catch(() => null);
}
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.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 = (this.isSpeechItem(nextItem)
? this.prepareSpeechMetadata(nextItem.text || '', {
sentenceId: nextItem.id,
blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null,
queueIndex: index,
prefetch: true,
blocking: false
})
: Promise.resolve(null))
.then(() => {
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<string>} - 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();
}
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 rect = probe.getBoundingClientRect();
const computed = window.getComputedStyle(probe);
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 = Math.max(
metrics.width || 0,
metrics.actualBoundingBoxRight || 0
);
}
} catch (error) {
console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error);
}
probeParagraph.remove();
const measuredAdvance = 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,
inkRight
);
const glyphAdvance = measuredAdvance > 0 ? measuredAdvance : 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<string>} 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.cancelGenerationRequests('sentence-queue-cleared');
this.prefetchingSpeech.clear();
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;