Normalize every submission to mono 96 kbps MP3 so takes play everywhere regardless of the recording codec; falls back to storing the original file when ffmpeg is unavailable or fails. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
117 lines
2.6 KiB
TypeScript
117 lines
2.6 KiB
TypeScript
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<Mp3Result | null> {
|
|
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<boolean> {
|
|
// 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<typeof spawn>;
|
|
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));
|
|
});
|
|
}
|