Checkpoint current interactive fiction state
This commit is contained in:
@@ -9,20 +9,29 @@ class SentenceQueueModule extends BaseModule {
|
||||
super('sentence-queue', 'Sentence Queue');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'tts-player'];
|
||||
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager'];
|
||||
|
||||
// Queue state
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.onSentenceReadyCallback = null;
|
||||
|
||||
|
||||
// Cache for prefetched sentences
|
||||
this.preparedCache = new Map();
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'addSentence',
|
||||
'processNextSentence',
|
||||
'setOnSentenceReady',
|
||||
'completeSentence'
|
||||
'completeSentence',
|
||||
'prepareSentence',
|
||||
'prepareLayout',
|
||||
'extractWords',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
'calculateAnimationTiming'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,9 +78,13 @@ class SentenceQueueModule extends BaseModule {
|
||||
* @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({
|
||||
text: sentence,
|
||||
callback: callback
|
||||
...queueItem,
|
||||
text: String(queueItem.text || '').trim()
|
||||
});
|
||||
|
||||
// Process the queue if not already processing
|
||||
@@ -87,36 +100,78 @@ class SentenceQueueModule extends BaseModule {
|
||||
if (this.sentenceQueue.length === 0 || this.isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.isProcessing = true;
|
||||
const item = this.sentenceQueue[0]; // Don't remove yet
|
||||
|
||||
const item = this.sentenceQueue[0];
|
||||
|
||||
try {
|
||||
// Get TTS Factory
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
|
||||
if (!ttsFactory) {
|
||||
console.error("SentenceQueue: TTSFactory dependency not found");
|
||||
this.completeSentence(item, { success: false, reason: 'no_tts_factory' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a speech metadata object
|
||||
const speechMetadata = await this.prepareSpeechMetadata(item.text);
|
||||
|
||||
// If we have a callback for ready sentences, call it with the metadata
|
||||
if (this.onSentenceReadyCallback) {
|
||||
this.onSentenceReadyCallback(item.text, speechMetadata, () => {
|
||||
// Remove from queue and process next
|
||||
this.completeSentence(item, { success: true });
|
||||
});
|
||||
// Check if sentence is already in cache
|
||||
const cacheKey = `${item.id || ''}:${item.text}`;
|
||||
let sentence = this.preparedCache.get(cacheKey);
|
||||
|
||||
if (!sentence) {
|
||||
// Prepare complete sentence object (TTS + layout in parallel)
|
||||
sentence = await this.prepareSentence(item);
|
||||
} else {
|
||||
// No callback, just complete
|
||||
this.completeSentence(item, { success: true });
|
||||
console.log('SentenceQueue: Using cached sentence');
|
||||
this.preparedCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// Prefetch next sentence while current displays
|
||||
if (this.sentenceQueue.length > 1) {
|
||||
const nextItem = this.sentenceQueue[1];
|
||||
const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`;
|
||||
if (!this.preparedCache.has(nextCacheKey)) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id });
|
||||
this.prepareSentence(nextItem)
|
||||
.then(prepared => {
|
||||
this.preparedCache.set(nextCacheKey, prepared);
|
||||
console.log('SentenceQueue: Prefetched next sentence');
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id });
|
||||
})
|
||||
.catch(err => console.warn('SentenceQueue: Prefetch failed:', err));
|
||||
}
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id });
|
||||
}
|
||||
|
||||
// Notify display handler with complete sentence
|
||||
if (this.onSentenceReadyCallback) {
|
||||
await new Promise(resolve => {
|
||||
sentence.onComplete = resolve;
|
||||
this.onSentenceReadyCallback(sentence, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from queue and continue
|
||||
this.sentenceQueue.shift();
|
||||
if (item.callback) item.callback({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error processing sentence:", error);
|
||||
this.completeSentence(item, { success: false, reason: error.message });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,14 +182,14 @@ class SentenceQueueModule extends BaseModule {
|
||||
*/
|
||||
async prepareSpeechMetadata(text) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
|
||||
if (!ttsFactory || !ttsPlayer) {
|
||||
|
||||
if (!ttsFactory) {
|
||||
throw new Error("TTS dependencies not found");
|
||||
}
|
||||
|
||||
// Check if TTS is enabled
|
||||
const isTtsEnabled = ttsPlayer.isEnabled();
|
||||
|
||||
// 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) {
|
||||
@@ -175,26 +230,19 @@ class SentenceQueueModule extends BaseModule {
|
||||
* @returns {Object} - Speech metadata object with estimated duration
|
||||
*/
|
||||
estimateSpeechDuration(text) {
|
||||
// Average reading speed is about 14-15 characters per second
|
||||
// We'll use a slightly slower rate for TTS
|
||||
// Average aloud narration is around 12 characters per second at 1x.
|
||||
const charactersPerSecond = 12;
|
||||
const ttsPlayer = this.getModule('tts-player');
|
||||
|
||||
// Get the current speed setting if available
|
||||
|
||||
let speedMultiplier = 1.0;
|
||||
if (ttsPlayer) {
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
// Get the current speed setting (typically 0.5-2.0)
|
||||
const speed = ttsFactory.speed || 1.0;
|
||||
speedMultiplier = speed;
|
||||
}
|
||||
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, 500); // Minimum 500ms
|
||||
const durationMs = Math.max(durationSeconds * 1000, 800);
|
||||
|
||||
return {
|
||||
text: text,
|
||||
@@ -207,6 +255,264 @@ class SentenceQueueModule extends BaseModule {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a complete sentence object with TTS and layout
|
||||
* @param {string} text - Text to prepare
|
||||
* @returns {Promise<Object>} - Complete sentence object
|
||||
*/
|
||||
async prepareSentence(item) {
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const metadata = typeof item === 'object' && item !== null ? item : {};
|
||||
|
||||
try {
|
||||
if (metadata.type && metadata.type !== 'paragraph') {
|
||||
if (metadata.type === 'music') {
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && typeof audioManager.playMusic === 'function') {
|
||||
audioManager.getAssetUrl('music', metadata.filename);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: metadata.type,
|
||||
text: text || '',
|
||||
status: 'ready',
|
||||
metadata,
|
||||
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
|
||||
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
|
||||
element: null,
|
||||
onComplete: null
|
||||
};
|
||||
}
|
||||
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
if (audioManager && typeof audioManager.preloadMediaCues === 'function') {
|
||||
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
|
||||
}
|
||||
|
||||
// Prepare TTS and layout in parallel
|
||||
const [ttsData, layoutData] = await Promise.all([
|
||||
this.prepareSpeechMetadata(text),
|
||||
this.prepareLayout(text, metadata)
|
||||
]);
|
||||
|
||||
// Calculate animation timing based on TTS duration
|
||||
const words = this.extractWords(layoutData.nodes);
|
||||
const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []);
|
||||
|
||||
console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||
role: metadata.role || 'body',
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
cueMarkers: metadata.cueMarkers || [],
|
||||
status: 'ready',
|
||||
layout: layoutData,
|
||||
tts: {
|
||||
duration: ttsData.duration,
|
||||
provider: ttsData.handler,
|
||||
audioData: ttsData.audioData || null,
|
||||
play: ttsData.play,
|
||||
stop: ttsData.stop,
|
||||
enabled: ttsData.isTtsEnabled
|
||||
},
|
||||
animation: animation,
|
||||
element: null,
|
||||
onComplete: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('SentenceQueue: Error preparing sentence:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare layout for a sentence
|
||||
* @param {string} text - Text to prepare layout for
|
||||
* @returns {Promise<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 dropCapLines = metadata.dropCap ? 2 : 0;
|
||||
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
|
||||
const indentWidth = (metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
|
||||
// Measures are consumed in line order by the line breaker.
|
||||
const measures = metadata.dropCap
|
||||
? [
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
containerWidth
|
||||
]
|
||||
: [
|
||||
Math.max(120, containerWidth - indentWidth),
|
||||
containerWidth,
|
||||
containerWidth
|
||||
];
|
||||
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
|
||||
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
|
||||
measures,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily,
|
||||
lineHeight: lineHeight / fontSize,
|
||||
lineHeightPx: lineHeight
|
||||
});
|
||||
|
||||
if (!layout) {
|
||||
throw new Error('Paragraph layout calculation failed');
|
||||
}
|
||||
|
||||
return {
|
||||
breaks: layout.breaks,
|
||||
nodes: layout.nodes,
|
||||
processedText: layout.processedText || text,
|
||||
sourceLayoutText: layoutText,
|
||||
measures,
|
||||
indentWidth,
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapLines,
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
role: metadata.role || 'body',
|
||||
fontSize: layout.fontSize,
|
||||
fontFamily: layout.fontFamily,
|
||||
lineHeight: layout.lineHeight,
|
||||
lineHeightPx: layout.lineHeightPx
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('SentenceQueue: Error preparing layout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract words from layout nodes
|
||||
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
|
||||
* @returns {Array<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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user