1462 lines
59 KiB
JavaScript
1462 lines
59 KiB
JavaScript
/**
|
|
* 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 prepared future queue items so the playback path can consume
|
|
// work that was already generated during lookahead.
|
|
this.prefetchingSpeech = new Map();
|
|
this.preparedSentenceCache = 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',
|
|
'prefetchWebGLBookPresentation',
|
|
'isWebGLBookPresentationPrepared',
|
|
'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<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.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 playback is already
|
|
// running, immediately start lookahead for the newly appended item.
|
|
if (!this.isProcessing) {
|
|
this.processNextSentence();
|
|
} else {
|
|
this.prefetchAhead(6, this.queueGeneration);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (!this.isWebGLBookPresentationPrepared(sentence)) {
|
|
await this.prefetchWebGLBookPresentation(sentence, {
|
|
queueGeneration,
|
|
queueIndex: 0,
|
|
immediate: true
|
|
});
|
|
}
|
|
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(6, 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<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();
|
|
}
|
|
|
|
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, options = {}) {
|
|
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 : {};
|
|
const blocking = options.blocking !== false;
|
|
|
|
try {
|
|
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
|
|
await this.preloadAssetsForItem(metadata, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
|
|
|
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, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
|
}
|
|
|
|
const ttsData = await this.prepareSpeechMetadata(text, {
|
|
sentenceId: id,
|
|
blockId: metadata.blockId ?? null,
|
|
turnId: metadata.turnId ?? null,
|
|
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
|
|
blocking
|
|
});
|
|
|
|
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<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
|
|
? 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' && !this.isChoiceAwaitingPlayer()) {
|
|
finish();
|
|
}
|
|
};
|
|
document.addEventListener('ui:command', onCommand);
|
|
});
|
|
}
|
|
|
|
getCacheKey(item) {
|
|
return `${item?.id || ''}:${item?.text || ''}`;
|
|
}
|
|
|
|
isChoiceAwaitingPlayer() {
|
|
if (this.inputMode !== 'choice') {
|
|
return false;
|
|
}
|
|
const choicePanel = document.getElementById('story_choices');
|
|
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
|
|
}
|
|
|
|
async getPreparedSentence(item) {
|
|
const cacheKey = this.getCacheKey(item);
|
|
const prepared = this.preparedSentenceCache.get(cacheKey);
|
|
if (prepared) {
|
|
this.preparedSentenceCache.delete(cacheKey);
|
|
return prepared;
|
|
}
|
|
|
|
const pending = this.prefetchingSpeech.get(cacheKey);
|
|
if (pending) {
|
|
const prefetched = await pending.catch(() => null);
|
|
if (prefetched) {
|
|
this.preparedSentenceCache.delete(cacheKey);
|
|
return prefetched;
|
|
}
|
|
}
|
|
|
|
return this.prepareSentence(item);
|
|
}
|
|
|
|
async prefetchWebGLBookPresentation(sentence, options = {}) {
|
|
if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null;
|
|
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|
|
|| document.body?.classList?.contains('webgl-mode');
|
|
if (!isWebGLMode) return null;
|
|
|
|
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
|
if (blockId == null) return null;
|
|
const bookPagination = this.getModule('book-pagination');
|
|
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
|
if (!bookPagination || !bookTextureRenderer) return null;
|
|
|
|
if (this.isWebGLBookPresentationPrepared(sentence)) {
|
|
return sentence.webglBookPresentation?.spread || null;
|
|
}
|
|
|
|
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
|
|
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
|
|
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
|
|
}
|
|
|
|
if (!options.immediate) {
|
|
await new Promise(resolve => {
|
|
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
|
|
scheduler(() => resolve(), { timeout: 80 });
|
|
});
|
|
}
|
|
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
|
|
|
const spread = typeof bookPagination.preparePendingBlock === 'function'
|
|
? await bookPagination.preparePendingBlock(sentence, {
|
|
activate: false,
|
|
publish: false,
|
|
includeUnrenderedHistory: true
|
|
})
|
|
: null;
|
|
if (!spread) return null;
|
|
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
|
|
|
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
|
|
bookTextureRenderer.prepareRevealBlock({
|
|
id: sentence.id,
|
|
blockId,
|
|
wordTimings: sentence.animation?.wordTimings || [],
|
|
cueTimings: sentence.animation?.cueTimings || [],
|
|
totalDuration: sentence.animation?.totalDuration || 0,
|
|
spread,
|
|
preloadOnly: true
|
|
}, { preloadOnly: true });
|
|
sentence.webglBookPresentation = {
|
|
prepared: true,
|
|
blockId,
|
|
spread
|
|
};
|
|
}
|
|
return spread;
|
|
}
|
|
|
|
isWebGLBookPresentationPrepared(sentence) {
|
|
const blockId = sentence?.blockId ?? sentence?.metadata?.blockId ?? null;
|
|
if (blockId == null) return false;
|
|
if (sentence?.webglBookPresentation?.prepared === true) return true;
|
|
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
|
return Boolean(bookTextureRenderer?.hasPreparedRevealBlock?.(blockId));
|
|
}
|
|
|
|
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
|
|
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
|
}
|
|
|
|
prefetchAhead(maxLookahead = 6, 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;
|
|
const prepared = await this.prepareSentence(nextItem, {
|
|
blocking: false,
|
|
prefetch: true,
|
|
queueIndex: index
|
|
});
|
|
if (queueGeneration !== this.queueGeneration) return null;
|
|
await this.prefetchWebGLBookPresentation(prepared, {
|
|
queueGeneration,
|
|
queueIndex: index
|
|
});
|
|
if (queueGeneration !== this.queueGeneration) return null;
|
|
this.preparedSentenceCache.set(nextCacheKey, prepared);
|
|
return prepared;
|
|
})()
|
|
.then((prepared) => {
|
|
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 prepared || 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();
|
|
}
|
|
|
|
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<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.queueGeneration += 1;
|
|
this.sentenceQueue = [];
|
|
this.isProcessing = false;
|
|
this.cancelGenerationRequests('sentence-queue-cleared');
|
|
this.cancelAssetPreloads('sentence-queue-cleared');
|
|
this.prefetchingSpeech.clear();
|
|
this.preparedSentenceCache.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;
|