fp/services/our/src/tasks/analyzeAudio.ts
CJ_Clippy bdc4894e3e
Some checks are pending
ci / build (push) Waiting to run
ci / test (push) Waiting to run
add audio analysis
2025-09-01 22:49:27 -08:00

136 lines
3.8 KiB
TypeScript

/**
* Analyze the video's audio track using ITU-R BS.1770/EBU R128
* to extract Integrated Loudness (LUFS-I), Loudness Range (LRA),
* and True Peak (dBTP). This helps identify which VODs/streamers
* maintain consistent, listener-friendly audio setups.
*
* ## LUFS Reference
* Apple Music -16 LUFS
* YouTube -13 LUFS
* Spotify -14 LUFS
* Tidal -14 LUFS
*/
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client } from "../utils/s3";
import { getNanoSpawn } from "../utils/nanoSpawn";
import logger from "../utils/logger";
const prisma = new PrismaClient().$extends(withAccelerate());
const ffmpegBin = "ffmpeg";
interface Payload {
vodId: string;
}
interface AudioStats {
lufsIntegrated: number; // LUFS-I
lra: number; // Loudness Range
peak: number; // True Peak (expressed as dBTP ceiling, e.g. -0.1)
}
export async function runFFmpeg(args: string[]): Promise<string> {
const spawn = await getNanoSpawn();
try {
const result = await spawn(ffmpegBin, args, {
stdout: 'pipe',
stderr: 'pipe',
});
// success → return combined output
return result.output;
} catch (err: any) {
// failure → throw with stderr
throw new Error(err.output || err.message);
}
}
function parseEbur128(output: string): AudioStats {
const lines = output.split("\n").map((l) => l.trim());
let lufsIntegrated = NaN;
let lra = NaN;
let peak = NaN;
let inTruePeak = false;
for (const line of lines) {
// Integrated loudness
const iMatch = line.match(/^I:\s*(-?\d+(\.\d+)?) LUFS/);
if (iMatch) lufsIntegrated = parseFloat(iMatch[1]);
// Loudness range
const lraMatch = line.match(/^LRA:\s*(-?\d+(\.\d+)?)/);
if (lraMatch) lra = parseFloat(lraMatch[1]);
// True peak
if (line === "True peak:") inTruePeak = true;
if (inTruePeak) {
const pMatch = line.match(/Peak:\s*(-?\d+\.?\d*)\s*dBFS/);
if (pMatch) {
peak = parseFloat(pMatch[1]); // no negation
inTruePeak = false;
}
}
}
return { lufsIntegrated, lra, peak };
}
async function analyzeAudio(inputFile: string): Promise<AudioStats> {
const args = [
"-hide_banner",
"-i", inputFile,
"-filter_complex", "ebur128=peak=true:metadata=1:framelog=verbose",
"-f", "null",
"-",
];
const output = await runFFmpeg(args);
return parseEbur128(output);
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function main(payload: any, helpers: Helpers) {
assertPayload(payload);
const { vodId } = payload;
const vod = await prisma.vod.findFirstOrThrow({
where: { id: vodId },
});
if (vod.audioIntegratedLufs && vod.audioLoudnessRange && vod.audioTruePeak) {
logger.info(`Skipping analysis — vod ${vodId} already complete.`);
return;
}
if (!vod.sourceVideo) {
throw new Error(`vod ${vodId} is missing a sourceVideo.`);
}
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo);
logger.info(`videoFilePath=${videoFilePath}`);
const results = await analyzeAudio(videoFilePath);
logger.info(`results=${JSON.stringify(results)}`);
await prisma.vod.update({
where: { id: vodId },
data: {
audioIntegratedLufs: results.lufsIntegrated,
audioLoudnessRange: results.lra,
audioTruePeak: results.peak,
},
});
}