import { spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; export const MP3_MIMETYPE = "audio/mpeg"; /** Voice-appropriate MP3 settings: mono, 44.1 kHz, 96 kbps. */ const FFMPEG_ARGS = [ "-hide_banner", "-loglevel", "error", "-y", "-i", "input", "-vn", "-ac", "1", "-ar", "44100", "-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3", "output.mp3", ]; const FFMPEG_TIMEOUT_MS = 30_000; export interface Mp3Result { buffer: Buffer; name: string; mimetype: string; size: number; } /** * Convert an uploaded audio recording (WebM/Opus, MP4/AAC, etc.) to MP3 using * the system ffmpeg binary. Returns null when ffmpeg is unavailable or fails, * so callers can fall back to storing the original file untouched. */ export async function transcodeToMp3( buffer: Buffer, originalName: string, ): Promise { let dir: string | null = null; try { dir = await mkdtemp(path.join(tmpdir(), "voiceover-")); const inputPath = path.join(dir, "input"); const outputPath = path.join(dir, "output.mp3"); await writeFile(inputPath, buffer); const ok = await runFfmpeg(inputPath, outputPath); if (!ok) { return null; } const mp3 = await readFile(outputPath); if (mp3.length === 0) { return null; } return { buffer: mp3, name: `${stripExtension(originalName)}.mp3`, mimetype: MP3_MIMETYPE, size: mp3.length, }; } catch { return null; } finally { if (dir) { await rm(dir, { recursive: true, force: true }); } } } function stripExtension(name: string): string { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(0, dot) : name; } function runFfmpeg(inputPath: string, outputPath: string): Promise { // Rewrite the two path placeholders into the real temp paths. const args = FFMPEG_ARGS.map((arg) => arg === "input" ? inputPath : arg === "output.mp3" ? outputPath : arg, ); return new Promise((resolve) => { let child: ReturnType; try { child = spawn("ffmpeg", args, { stdio: "ignore" }); } catch { resolve(false); return; } let settled = false; const finish = (result: boolean) => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } }; const timer = setTimeout(() => { child.kill("SIGKILL"); finish(false); }, FFMPEG_TIMEOUT_MS); child.on("error", () => finish(false)); child.on("close", (code) => finish(code === 0)); }); }