Stabilize playback state and cursor feedback
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user