From 962607b90ed2c74a9f514b9990f5843348dded37 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 7 Sep 2026 04:37:10 -0400 Subject: [PATCH] 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 --- Dockerfile | 3 +- src/app/(frontend)/voiceover/actions.ts | 31 ++++++- src/lib/voiceover/transcode.ts | 117 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/lib/voiceover/transcode.ts diff --git a/Dockerfile b/Dockerfile index 6e84c3b..6c096bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -134,7 +134,8 @@ RUN bun run build # `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config # tsconfig path aliases that plain Node type-stripping cannot). 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 ENV NODE_ENV=production \ diff --git a/src/app/(frontend)/voiceover/actions.ts b/src/app/(frontend)/voiceover/actions.ts index 620d215..e5675d1 100644 --- a/src/app/(frontend)/voiceover/actions.ts +++ b/src/app/(frontend)/voiceover/actions.ts @@ -8,6 +8,7 @@ import { broadcastToUser } from "@/lib/realtime/bus"; import { notifyUser } from "@/lib/notifications"; import hasRoles from "@/utils/access-control/hasRoles"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { transcodeToMp3 } from "@/lib/voiceover/transcode"; import type { VoiceScript, VoiceSubmission } from "@/payload-types"; interface ActionResult { @@ -121,6 +122,28 @@ export async function submitVoiceRecording( 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({ collection: "voice-submissions", data: { @@ -130,10 +153,10 @@ export async function submitVoiceRecording( description, }, file: { - data: Buffer.from(await file.arrayBuffer()), - mimetype, - name: file.name, - size: file.size, + data: audio.buffer, + mimetype: audio.mimetype, + name: audio.name, + size: audio.size, }, overrideAccess: true, depth: 0, diff --git a/src/lib/voiceover/transcode.ts b/src/lib/voiceover/transcode.ts new file mode 100644 index 0000000..197142a --- /dev/null +++ b/src/lib/voiceover/transcode.ts @@ -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 { + 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)); + }); +}