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
|
# `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 \
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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