61 lines
1.8 KiB
TypeScript
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(), "podcastyes-"));
|
|
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(() => {});
|
|
}
|
|
}
|