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(() => {}); } }