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:
parent
1b422ba1c2
commit
962607b90e
3 changed files with 146 additions and 5 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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<T = undefined> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
117
src/lib/voiceover/transcode.ts
Normal file
117
src/lib/voiceover/transcode.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue