From 65c9f039768605c33a50ac46c97e456b04bf28e1 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 9 Jun 2026 11:19:11 -0600 Subject: [PATCH] fix(tts): write mp3 to temp file to ensure accurate VBR duration metadata Switch mp3 transcoding to output a seekable temp file instead of piping to stdout. This allows ffmpeg to backpatch the Xing/Info header with correct total-duration metadata, which is required for HTML5 audio elements to handle VBR mp3 playback accurately. Prevents playback issues caused by missing or incorrect duration, such as premature or stalled `ended` events. --- src/lib/server/tts/audio-format.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib/server/tts/audio-format.ts b/src/lib/server/tts/audio-format.ts index 38be04f..7dd1008 100644 --- a/src/lib/server/tts/audio-format.ts +++ b/src/lib/server/tts/audio-format.ts @@ -1,5 +1,5 @@ import { spawn } from 'child_process'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { serverLogger } from '@/lib/server/logger'; @@ -133,27 +133,42 @@ function spawnFfmpegToBuffer(args: string[], signal?: AbortSignal): Promise element then extrapolates duration from the first frame's bitrate + * (correct for CBR, wrong for VBR), which makes the `ended` event fire at the + * wrong time — or stall without firing — and stops segment-to-segment playback. + * Writing to a real file lets ffmpeg backpatch the Xing header so duration is + * accurate and `ended` fires reliably. */ export async function transcodeToMp3(buffer: Buffer, signal?: AbortSignal): Promise { let workDir: string | null = null; try { workDir = await mkdtemp(join(tmpdir(), 'openreader-tts-transcode-')); const inputPath = join(workDir, 'input'); + const outputPath = join(workDir, 'output.mp3'); await writeFile(inputPath, buffer); - return await spawnFfmpegToBuffer( + await spawnFfmpegToBuffer( [ '-nostdin', '-loglevel', 'error', + '-y', '-i', inputPath, '-vn', '-c:a', 'libmp3lame', '-q:a', '2', + '-write_xing', '1', '-f', 'mp3', - 'pipe:1', + outputPath, ], signal, ); + + return await readFile(outputPath); } finally { if (workDir) { await rm(workDir, { recursive: true, force: true }).catch(() => {});