Stabilize playback state and cursor feedback

This commit is contained in:
2026-05-18 20:57:20 +02:00
parent 6e908037fb
commit 751ac5f62b
13 changed files with 580 additions and 82 deletions
+140 -14
View File
@@ -5,6 +5,8 @@
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() {
@@ -25,7 +27,9 @@ class SentenceQueueModule extends BaseModule {
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();
// Bind methods
this.bindMethods([
@@ -44,6 +48,8 @@ class SentenceQueueModule extends BaseModule {
'runTtsPreloadWithTimeout',
'cancelBlockingGeneration',
'cancelGenerationRequests',
'cancelBlockingAssetPreloads',
'cancelAssetPreloads',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
@@ -96,7 +102,12 @@ class SentenceQueueModule extends BaseModule {
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
this.cancelBlockingGeneration('user-fast-forward');
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;
@@ -159,6 +170,16 @@ class SentenceQueueModule extends BaseModule {
await this.waitForManualContinue(reason);
}
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);
// Prefetch far enough ahead that media pauses do not block TTS
@@ -189,6 +210,12 @@ class SentenceQueueModule extends BaseModule {
} catch (error) {
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;
@@ -334,8 +361,12 @@ class SentenceQueueModule extends BaseModule {
});
}
cancelBlockingGeneration(reason = 'cancelled') {
this.cancelGenerationRequests(reason, request => request.blocking === true);
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) {
@@ -358,6 +389,30 @@ class SentenceQueueModule extends BaseModule {
}
}
}
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
@@ -517,7 +572,7 @@ class SentenceQueueModule extends BaseModule {
const layoutText = metadata.layoutText || text;
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
const dropCapWidth = metadata.dropCap
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
? 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
@@ -598,7 +653,7 @@ class SentenceQueueModule extends BaseModule {
async preloadAssetsForItem(item = {}, context = {}) {
const audioManager = this.getModule('audio-manager');
if (!audioManager) return;
if (!audioManager) return { success: true, reason: 'audio_manager_unavailable' };
const tasks = [];
const type = String(item.type || item.kind || '').toLowerCase();
@@ -610,28 +665,82 @@ class SentenceQueueModule extends BaseModule {
}
const pending = tasks.filter(Boolean);
if (pending.length === 0) return;
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: context.sentenceId || item.id || null,
sentenceId,
assetType: type || 'cue'
}
}));
await Promise.all(pending);
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: 'asset-preload-complete',
sentenceId: context.sentenceId || item.id || null,
assetType: type || 'cue'
reason: result.success ? 'asset-preload-complete' : result.reason,
sentenceId,
assetType: type || 'cue',
degraded: !result.success
}
}));
return result;
}
shouldPauseAfterSentence(sentence) {
@@ -688,7 +797,7 @@ class SentenceQueueModule extends BaseModule {
async getPreparedSentence(item) {
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
if (pending) {
await pending.catch(() => null);
pending.catch(() => null);
}
return this.prepareSentence(item);
@@ -882,7 +991,7 @@ class SentenceQueueModule extends BaseModule {
return String(text).replace(dropCap, '').trimStart();
}
measureDropCapReservation(container, dropCapText, lineHeight) {
async measureDropCapReservation(container, dropCapText, lineHeight) {
if (!container || !dropCapText) {
return lineHeight * 1.34;
}
@@ -905,8 +1014,25 @@ class SentenceQueueModule extends BaseModule {
probeParagraph.appendChild(probe);
container.appendChild(probeParagraph);
const rect = probe.getBoundingClientRect();
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');