diff --git a/packages/producer/scripts/integration-tests.json b/packages/producer/scripts/integration-tests.json index 3a91aeccc2..1c5a9be944 100644 --- a/packages/producer/scripts/integration-tests.json +++ b/packages/producer/scripts/integration-tests.json @@ -28,6 +28,7 @@ "src/services/htmlCompiler.probeCount.test.ts", "src/services/htmlCompiler.variableMedia.test.ts", "src/services/render/audioPadTrim.integration.test.ts", + "src/services/render/audioTruePeak.test.ts", "src/services/render/deliveryRender.integration.test.ts", "src/services/render/firstFrameStartingState.integration.test.ts", "src/services/render/hlsRender.integration.test.ts", diff --git a/packages/producer/src/services/render/audioTruePeak.fakeFfmpeg.test.ts b/packages/producer/src/services/render/audioTruePeak.fakeFfmpeg.test.ts index 4d2ee4618c..790f711509 100644 --- a/packages/producer/src/services/render/audioTruePeak.fakeFfmpeg.test.ts +++ b/packages/producer/src/services/render/audioTruePeak.fakeFfmpeg.test.ts @@ -104,9 +104,14 @@ describe("a local render's audio pad/trim", () => { expect(result.success).toBe(true); expect(scanFilters()).toEqual([ "atrim=start_sample=0:end_sample=2880000,ebur128=peak=true", - "atrim=start_sample=2875200:end_sample=5760000,ebur128=peak=true", - "atrim=start_sample=5755200,ebur128=peak=true", + "atrim=start_sample=91200:end_sample=2976000,ebur128=peak=true", + "atrim=start_sample=91200,ebur128=peak=true", ]); + const seeks = vi + .mocked(runFfmpeg) + .mock.calls.filter(([args]) => args.some((arg) => arg.includes("ebur128"))) + .map(([args]) => (args[0] === "-ss" ? args[1] : null)); + expect(seeks).toEqual([null, "58", "118"]); }); }); diff --git a/packages/producer/src/services/render/audioTruePeak.test.ts b/packages/producer/src/services/render/audioTruePeak.test.ts index 0d5d4ccd31..8e3f45e8f9 100644 --- a/packages/producer/src/services/render/audioTruePeak.test.ts +++ b/packages/producer/src/services/render/audioTruePeak.test.ts @@ -74,6 +74,22 @@ describe.skipIf(!hasFfmpeg)("measureTruePeakDbfs in segments", () => { 60_000, ); + it.each([ + ["ends on a segment cut", CUT - BURST.length], + ["starts where the last segment's scan starts", 2 * CUT - BLOCK], + ])( + "reads an AAC peak that %s exactly as one whole-file pass", + async (_, at) => { + const wav = monoWav(`aac-burst-${at}`, at); + const aac = wav.replace(/\.wav$/, ".m4a"); + execFileSync(getFfmpegBinary(), ["-nostdin", "-v", "error", "-i", wav, "-c:a", "aac", aac]); + const [whole, segmented] = await bothWays(aac); + expect(whole).toBeGreaterThan(-10); + expect(segmented).toBe(whole); + }, + 60_000, + ); + it("reads silence as -inf", async () => { expect(await bothWays(monoWav("silence"))).toEqual([ Number.NEGATIVE_INFINITY, diff --git a/packages/producer/src/services/render/audioTruePeak.ts b/packages/producer/src/services/render/audioTruePeak.ts index 9eec1f9014..2a10923355 100644 --- a/packages/producer/src/services/render/audioTruePeak.ts +++ b/packages/producer/src/services/render/audioTruePeak.ts @@ -28,6 +28,7 @@ async function scanTruePeak( trim: string, timeoutMs: number, signal?: AbortSignal, + seekSeconds = 0, ): Promise { let firstBlock: number | undefined; let laterBlocks = Number.NEGATIVE_INFINITY; @@ -45,7 +46,21 @@ async function scanTruePeak( } }; const result = await runFfmpeg( - ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"], + [ + ...(seekSeconds > 0 ? ["-ss", String(seekSeconds)] : []), + ...[ + "-i", + audioPath, + "-map", + "0:a:0", + "-vn", + "-af", + `${trim}ebur128=peak=true`, + "-f", + "null", + "-", + ], + ], { signal, timeout: timeoutMs, onStderr: readLines }, ); if (!result.success) { @@ -87,12 +102,17 @@ export async function measureTruePeakDbfs( // A later segment starts one block early and drops that block: its oversampler starts // from mirrored history there, and the previous segment stops 16 samples short. const start = k === 0 ? 0 : cut(k) - block; - const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`; + // Decoding from the top costs a segment everything before it: seek to a whole second (an exact + // sample) one second early, so the AAC decoder has settled before the segment's first sample. + const seekSeconds = Math.max(0, Math.floor(start / sampleRate) - 1); + const skipped = seekSeconds * sampleRate; + const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1) - skipped}`; return scanTruePeak( audioPath, - `atrim=start_sample=${start}${end},`, + `atrim=start_sample=${start - skipped}${end},`, timeoutMs, segmentSignal, + seekSeconds, ); }), ).catch((error: unknown) => {