Document markup and improve choice tags

This commit is contained in:
2026-05-17 15:52:41 +02:00
parent c2fb27b6b8
commit 2c54498ee2
52 changed files with 3485 additions and 377 deletions
+134 -7
View File
@@ -4,6 +4,8 @@
*/
import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000;
class SentenceQueueModule extends BaseModule {
constructor() {
super('sentence-queue', 'Sentence Queue');
@@ -22,6 +24,8 @@ class SentenceQueueModule extends BaseModule {
this.inputMode = 'text';
this.lastContinueAt = 0;
this.pauseBeforeNextReason = null;
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
this.generationRequests = new Map();
// Bind methods
this.bindMethods([
@@ -34,6 +38,11 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'prepareSpeechMetadata',
'normalizeTtsText',
'runTtsPreloadWithTimeout',
'cancelBlockingGeneration',
'cancelGenerationRequests',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
@@ -86,6 +95,7 @@ class SentenceQueueModule extends BaseModule {
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
this.cancelBlockingGeneration('user-fast-forward');
}
});
return true;
@@ -200,13 +210,21 @@ class SentenceQueueModule extends BaseModule {
* @param {string} text - Text to prepare speech for
* @returns {Promise<Object>} - Speech metadata object
*/
async prepareSpeechMetadata(text) {
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;
@@ -218,20 +236,28 @@ class SentenceQueueModule extends BaseModule {
try {
// Preload the speech to get metadata
const result = await ttsFactory.preloadSpeech(text);
const result = await this.runTtsPreloadWithTimeout(ttsFactory, ttsText, context);
if (!result.success) {
console.warn("SentenceQueue: Speech preload failed, using estimated duration");
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: text,
text: ttsText,
duration: result.duration || this.estimateSpeechDuration(text).duration,
handler: ttsFactory.getActiveHandler() ? ttsFactory.getActiveHandler().id : null,
audioData: result.audioData || null,
play: async () => {
return ttsFactory.speak(text);
if (result.audioData && typeof ttsFactory.speakPreloaded === 'function') {
return ttsFactory.speakPreloaded(result);
}
return ttsFactory.speak(ttsText);
},
stop: () => {
return ttsFactory.stop();
@@ -243,6 +269,94 @@ class SentenceQueueModule extends BaseModule {
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
@@ -314,7 +428,12 @@ class SentenceQueueModule extends BaseModule {
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
}
const ttsData = await this.prepareSpeechMetadata(text);
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`);
@@ -557,7 +676,14 @@ class SentenceQueueModule extends BaseModule {
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
const promise = (this.isSpeechItem(nextItem)
? this.prepareSpeechMetadata(nextItem.text || '')
? 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 });
@@ -781,6 +907,7 @@ class SentenceQueueModule extends BaseModule {
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' }