refactor(whisper): implement dynamic timeout configuration for alignment and decoding

Replace static timeout constants with functions that derive alignment and ffmpeg
decode timeouts from compute timeout config. This enables adaptive timeout
behavior based on runtime configuration, improving robustness under varying
resource constraints. Update error handling and internal APIs to propagate
timeout values accordingly.

Remove unused Node.js engine constraint from package.json for broader compatibility.
This commit is contained in:
Richard R 2026-05-21 19:29:03 -06:00
parent 7cdd89b26d
commit 661af71fa4
2 changed files with 32 additions and 14 deletions

View file

@ -10,6 +10,7 @@ import JSZip from 'jszip';
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts'; import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts';
import { getFFmpegPath } from '../runtime/ffmpeg'; import { getFFmpegPath } from '../runtime/ffmpeg';
import { getOnnxThreadsPerJob } from '../runtime/cpu-budget'; import { getOnnxThreadsPerJob } from '../runtime/cpu-budget';
import { getComputeTimeoutConfig } from '../runtime/timeout-config';
import { import {
mapWordsToSentenceOffsets, mapWordsToSentenceOffsets,
type WhisperWord, type WhisperWord,
@ -92,8 +93,10 @@ const alignmentCache = state.alignmentCache;
const alignmentInFlight = state.alignmentInFlight; const alignmentInFlight = state.alignmentInFlight;
const ALIGNMENT_CACHE_MAX_ENTRIES = 256; const ALIGNMENT_CACHE_MAX_ENTRIES = 256;
const MAX_DECODE_STEPS_CAP = 128; const MAX_DECODE_STEPS_CAP = 128;
const ALIGNMENT_TIMEOUT_MS = 25000; const ALIGNMENT_TIMEOUT_BUFFER_MS = 2_000;
const FFMPEG_DECODE_TIMEOUT_MS = 10000; const MIN_ALIGNMENT_TIMEOUT_MS = 5_000;
const MIN_FFMPEG_DECODE_TIMEOUT_MS = 10_000;
const MAX_FFMPEG_DECODE_TIMEOUT_MS = 60_000;
const SAMPLE_RATE = 16000; const SAMPLE_RATE = 16000;
const N_FFT = 400; const N_FFT = 400;
@ -284,7 +287,22 @@ function computeLogMelSpectrogram(audioSamples: Float32Array): ort.Tensor {
return new ort.Tensor('float32', flattened, [1, N_MELS, frameCount]); return new ort.Tensor('float32', flattened, [1, N_MELS, frameCount]);
} }
function getAlignmentTimeoutMs(): number {
const whisperTimeoutMs = getComputeTimeoutConfig().whisperTimeoutMs;
return Math.max(MIN_ALIGNMENT_TIMEOUT_MS, whisperTimeoutMs - ALIGNMENT_TIMEOUT_BUFFER_MS);
}
function getFfmpegDecodeTimeoutMs(): number {
const whisperTimeoutMs = getComputeTimeoutConfig().whisperTimeoutMs;
const halfBudgetMs = Math.floor(whisperTimeoutMs * 0.5);
return Math.max(
MIN_FFMPEG_DECODE_TIMEOUT_MS,
Math.min(MAX_FFMPEG_DECODE_TIMEOUT_MS, halfBudgetMs),
);
}
async function decodeToPcm16(inputPath: string, outputPath: string): Promise<void> { async function decodeToPcm16(inputPath: string, outputPath: string): Promise<void> {
const ffmpegDecodeTimeoutMs = getFfmpegDecodeTimeoutMs();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const ffmpeg = spawn(getFFmpegPath(), [ const ffmpeg = spawn(getFFmpegPath(), [
'-y', '-y',
@ -304,7 +322,7 @@ async function decodeToPcm16(inputPath: string, outputPath: string): Promise<voi
const timer = setTimeout(() => { const timer = setTimeout(() => {
timedOut = true; timedOut = true;
ffmpeg.kill('SIGKILL'); ffmpeg.kill('SIGKILL');
}, FFMPEG_DECODE_TIMEOUT_MS); }, ffmpegDecodeTimeoutMs);
ffmpeg.stderr.on('data', (data) => { ffmpeg.stderr.on('data', (data) => {
stderr += data.toString(); stderr += data.toString();
}); });
@ -317,7 +335,7 @@ async function decodeToPcm16(inputPath: string, outputPath: string): Promise<voi
ffmpeg.on('close', (code) => { ffmpeg.on('close', (code) => {
clearTimeout(timer); clearTimeout(timer);
if (timedOut) { if (timedOut) {
reject(new Error(`ffmpeg decode timed out after ${FFMPEG_DECODE_TIMEOUT_MS}ms`)); reject(new Error(`ffmpeg decode timed out after ${ffmpegDecodeTimeoutMs}ms`));
return; return;
} }
if (code === 0) { if (code === 0) {
@ -368,9 +386,9 @@ function computeAdaptiveDecodeStepLimit(maxDecodeSteps: number, textHint?: strin
return adaptive; return adaptive;
} }
function assertWithinDeadline(deadlineMs: number): void { function assertWithinDeadline(deadlineMs: number, timeoutMs: number): void {
if (Date.now() > deadlineMs) { if (Date.now() > deadlineMs) {
throw new Error(`Whisper alignment timed out after ${ALIGNMENT_TIMEOUT_MS}ms`); throw new Error(`Whisper alignment timed out after ${timeoutMs}ms`);
} }
} }
@ -657,8 +675,9 @@ async function runWhisperOnnx(
opts: WhisperAlignmentOptions, opts: WhisperAlignmentOptions,
numFrames: number, numFrames: number,
deadlineMs: number, deadlineMs: number,
timeoutMs: number,
): Promise<WhisperWord[]> { ): Promise<WhisperWord[]> {
assertWithinDeadline(deadlineMs); assertWithinDeadline(deadlineMs, timeoutMs);
const runtime = await getRuntime(); const runtime = await getRuntime();
const decodeStepLimit = computeAdaptiveDecodeStepLimit(runtime.maxDecodeSteps, opts.textHint); const decodeStepLimit = computeAdaptiveDecodeStepLimit(runtime.maxDecodeSteps, opts.textHint);
const mel = computeLogMelSpectrogram(audioSamples); const mel = computeLogMelSpectrogram(audioSamples);
@ -740,7 +759,7 @@ async function runWhisperOnnx(
...emptyPastFeeds, ...emptyPastFeeds,
}; };
try { try {
assertWithinDeadline(deadlineMs); assertWithinDeadline(deadlineMs, timeoutMs);
outputs = await runtime.decoderMerged.run(prefillFeeds, runtime.prefillFetches); outputs = await runtime.decoderMerged.run(prefillFeeds, runtime.prefillFetches);
} finally { } finally {
disposeTensor(prefillInputIds); disposeTensor(prefillInputIds);
@ -756,7 +775,7 @@ async function runWhisperOnnx(
} }
for (let step = 0; step < decodeStepLimit; step += 1) { for (let step = 0; step < decodeStepLimit; step += 1) {
assertWithinDeadline(deadlineMs); assertWithinDeadline(deadlineMs, timeoutMs);
if (!outputs) break; if (!outputs) break;
const logits = outputs.logits; const logits = outputs.logits;
const logitsData = logits.data as Float32Array; const logitsData = logits.data as Float32Array;
@ -791,7 +810,7 @@ async function runWhisperOnnx(
}; };
let nextOutputs: Record<string, ort.Tensor>; let nextOutputs: Record<string, ort.Tensor>;
try { try {
assertWithinDeadline(deadlineMs); assertWithinDeadline(deadlineMs, timeoutMs);
nextOutputs = await runtime.decoderWithPast.run(stepFeeds, runtime.stepFetches); nextOutputs = await runtime.decoderWithPast.run(stepFeeds, runtime.stepFetches);
} finally { } finally {
disposeTensor(stepInputIds); disposeTensor(stepInputIds);
@ -924,7 +943,8 @@ export async function alignAudioWithText(
state.pendingAlignments += 1; state.pendingAlignments += 1;
const run = (async (): Promise<TTSSentenceAlignment[]> => { const run = (async (): Promise<TTSSentenceAlignment[]> => {
const deadlineMs = Date.now() + ALIGNMENT_TIMEOUT_MS; const alignmentTimeoutMs = getAlignmentTimeoutMs();
const deadlineMs = Date.now() + alignmentTimeoutMs;
const previous = state.alignMutex; const previous = state.alignMutex;
let release!: () => void; let release!: () => void;
state.alignMutex = new Promise<void>((resolve) => { state.alignMutex = new Promise<void>((resolve) => {
@ -966,6 +986,7 @@ export async function alignAudioWithText(
{ ...opts, textHint: text }, { ...opts, textHint: text },
effectiveFrameCount, effectiveFrameCount,
deadlineMs, deadlineMs,
alignmentTimeoutMs,
); );
const alignment = mapWordsToSentenceOffsets(text, words); const alignment = mapWordsToSentenceOffsets(text, words);
const result: TTSSentenceAlignment[] = [alignment]; const result: TTSSentenceAlignment[] = [alignment];

View file

@ -2,9 +2,6 @@
"name": "openreader", "name": "openreader",
"version": "3.0.0", "version": "3.0.0",
"private": true, "private": true,
"engines": {
"node": "22.x"
},
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.33.4",
"scripts": { "scripts": {
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", "dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",