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.
This commit is contained in:
parent
34b94534cf
commit
65c9f03976
1 changed files with 18 additions and 3 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { spawn } from 'child_process';
|
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 { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { serverLogger } from '@/lib/server/logger';
|
import { serverLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -133,27 +133,42 @@ function spawnFfmpegToBuffer(args: string[], signal?: AbortSignal): Promise<Buff
|
||||||
* what the user listens to live, and aggressive low-bitrate encoding of pristine
|
* what the user listens to live, and aggressive low-bitrate encoding of pristine
|
||||||
* high-fidelity TTS (e.g. 44.1 kHz wav) introduces high-frequency artifacts that
|
* high-fidelity TTS (e.g. 44.1 kHz wav) introduces high-frequency artifacts that
|
||||||
* cause listening fatigue. The audiobook export still re-encodes to 64k for size.
|
* cause listening fatigue. The audiobook export still re-encodes to 64k for size.
|
||||||
|
*
|
||||||
|
* The mp3 is written to a seekable temp file rather than piped to stdout: VBR mp3
|
||||||
|
* stores its total-duration metadata in a Xing/Info header at the *start* of the
|
||||||
|
* file, which ffmpeg can only emit by seeking back after encoding finishes. On a
|
||||||
|
* pipe that seek is impossible, so a piped VBR mp3 carries no valid duration. The
|
||||||
|
* HTML5 <audio> 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<Buffer> {
|
export async function transcodeToMp3(buffer: Buffer, signal?: AbortSignal): Promise<Buffer> {
|
||||||
let workDir: string | null = null;
|
let workDir: string | null = null;
|
||||||
try {
|
try {
|
||||||
workDir = await mkdtemp(join(tmpdir(), 'openreader-tts-transcode-'));
|
workDir = await mkdtemp(join(tmpdir(), 'openreader-tts-transcode-'));
|
||||||
const inputPath = join(workDir, 'input');
|
const inputPath = join(workDir, 'input');
|
||||||
|
const outputPath = join(workDir, 'output.mp3');
|
||||||
await writeFile(inputPath, buffer);
|
await writeFile(inputPath, buffer);
|
||||||
|
|
||||||
return await spawnFfmpegToBuffer(
|
await spawnFfmpegToBuffer(
|
||||||
[
|
[
|
||||||
'-nostdin',
|
'-nostdin',
|
||||||
'-loglevel', 'error',
|
'-loglevel', 'error',
|
||||||
|
'-y',
|
||||||
'-i', inputPath,
|
'-i', inputPath,
|
||||||
'-vn',
|
'-vn',
|
||||||
'-c:a', 'libmp3lame',
|
'-c:a', 'libmp3lame',
|
||||||
'-q:a', '2',
|
'-q:a', '2',
|
||||||
|
'-write_xing', '1',
|
||||||
'-f', 'mp3',
|
'-f', 'mp3',
|
||||||
'pipe:1',
|
outputPath,
|
||||||
],
|
],
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return await readFile(outputPath);
|
||||||
} finally {
|
} finally {
|
||||||
if (workDir) {
|
if (workDir) {
|
||||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue