/** * 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 { 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 { 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, }, }); }