1
0
Fork 0

feat(voiceover): transcode submissions to mp3 via ffmpeg

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>
This commit is contained in:
Jason Fraley 2026-09-07 04:37:10 -04:00
parent 1b422ba1c2
commit 962607b90e
3 changed files with 146 additions and 5 deletions

View file

@ -134,7 +134,8 @@ RUN bun run build
# `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config # `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config
# tsconfig path aliases that plain Node type-stripping cannot). # tsconfig path aliases that plain Node type-stripping cannot).
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat wget # ffmpeg: voiceover submissions are transcoded to MP3 in submitVoiceRecording.
RUN apk add --no-cache libc6-compat wget ffmpeg
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

View file

@ -8,6 +8,7 @@ import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { transcodeToMp3 } from "@/lib/voiceover/transcode";
import type { VoiceScript, VoiceSubmission } from "@/payload-types"; import type { VoiceScript, VoiceSubmission } from "@/payload-types";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
@ -121,6 +122,28 @@ export async function submitVoiceRecording(
return { success: false, error: "Audio recordings must be 9 MB or smaller." }; return { success: false, error: "Audio recordings must be 9 MB or smaller." };
} }
const originalBuffer = Buffer.from(await file.arrayBuffer());
// Normalize every submission to MP3 (mono 96 kbps) so stored takes play
// everywhere regardless of which codec the browser recorded with. If the
// transcode fails (no ffmpeg binary, unsupported input, timeout) the
// original file is stored untouched instead of rejecting the submission.
const mp3 = await transcodeToMp3(originalBuffer, file.name);
if (mp3) {
payload.logger.info(
`Voiceover submission for script ${scriptId}: converted ${file.name} (${originalBuffer.length} bytes) to ${mp3.name} (${mp3.size} bytes)`,
);
} else {
payload.logger.warn(
`Voiceover submission for script ${scriptId}: MP3 conversion failed, storing original file ${file.name}`,
);
}
const audio = mp3 ?? {
buffer: originalBuffer,
name: file.name,
mimetype,
size: originalBuffer.length,
};
const submission = await payload.create({ const submission = await payload.create({
collection: "voice-submissions", collection: "voice-submissions",
data: { data: {
@ -130,10 +153,10 @@ export async function submitVoiceRecording(
description, description,
}, },
file: { file: {
data: Buffer.from(await file.arrayBuffer()), data: audio.buffer,
mimetype, mimetype: audio.mimetype,
name: file.name, name: audio.name,
size: file.size, size: audio.size,
}, },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,

View file

@ -0,0 +1,117 @@
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));
});
}