Files

61 lines
1.8 KiB
TypeScript

import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { runFfmpeg, ffprobeDuration } from "../ffmpeg";
/**
* Concatenate per-segment MP3 buffers into one normalized episode MP3.
*
* Segments are re-encoded (not stream-copied) through a single libmp3lame pass
* with loudness normalization, which guarantees a uniform codec/bitrate and
* avoids the header/timestamp glitches that `-c copy` concat can produce.
*/
export async function stitchMp3(
segments: Buffer[]
): Promise<{ data: Buffer; durationSec: number | null }> {
if (segments.length === 0) throw new Error("No audio segments to stitch");
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "podcast-distribution-ai-"));
try {
const files: string[] = [];
for (let i = 0; i < segments.length; i++) {
const file = path.join(dir, `seg_${String(i).padStart(4, "0")}.mp3`);
await fs.writeFile(file, segments[i]);
files.push(file);
}
// concat demuxer list — forward slashes so it parses on Windows and Linux.
const listPath = path.join(dir, "list.txt");
const listBody = files
.map((f) => `file '${f.split(path.sep).join("/").replace(/'/g, "'\\''")}'`)
.join("\n");
await fs.writeFile(listPath, listBody);
const outPath = path.join(dir, "episode.mp3");
await runFfmpeg([
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
listPath,
"-af",
"loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
"-ar",
"44100",
outPath,
]);
const data = await fs.readFile(outPath);
const durationSec = await ffprobeDuration(outPath);
return { data, durationSec };
} finally {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
}