From 6e1f554acb8d246813f4822bcb571d2fd409c0c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 02:30:15 +0000 Subject: [PATCH] fix(ads): master the audible companion, not just the picture The loudness pass reached the video track and stopped there. The audible companion is encoded from the raw narration rather than from the finished video, so it never inherited that mastering and kept the level the synthesised voice came back at. That is the half of the path most listeners actually get. A music or radio player asks /api/ads/stream for kind=audio, because a video creative has nowhere to go in a player showing album art, and the companion is the only file it ever plays. So the spot that was brought up to streaming level was the one nobody in an audio slot hears. Measured on a companion the worker rendered after the video fix shipped: -28.5 dB mean before, -19.6 after, peak -3.0. Same target and ceiling as the video track, and a test pins them together: two levels for one spot would mean the advert changed loudness when a listener moved between a video slot and an audio one. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ads/video/encode.ts | 9 +++++++++ tests/ads-video-compose-validate.test.ts | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/ads/video/encode.ts b/lib/ads/video/encode.ts index d4c89cd..54a3b21 100644 --- a/lib/ads/video/encode.ts +++ b/lib/ads/video/encode.ts @@ -336,6 +336,13 @@ export function posterArgs(inPath: string, outPath: string): string[] { * drift into describing different offers. A silent creative has no companion: * five seconds of silence would be recorded as an audio ad having played, and * the spec is explicit that silence never satisfies an audio slot. + * + * It carries the same mastering as the picture's own track. This is the file a + * music player asks for, and it is built from the raw narration rather than + * from the finished video, so without the filter here it keeps whatever level + * the voice came back at. That was the whole of the bug on this path: the + * video was brought up to streaming level while the audible spot, which is the + * only thing a radio or music listener ever gets, stayed near -29 dB. */ export function audioCompanionArgs(inPath: string, outPath: string): string[] { return [ @@ -344,6 +351,8 @@ export function audioCompanionArgs(inPath: string, outPath: string): string[] { "-i", inPath, "-vn", + "-af", + LOUDNESS, "-c:a", "aac", "-profile:a", diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts index afd27bd..91e6777 100644 --- a/tests/ads-video-compose-validate.test.ts +++ b/tests/ads-video-compose-validate.test.ts @@ -7,7 +7,7 @@ import { timeline, } from "@/lib/ads/video/compose"; import type { VideoDesignSnapshot } from "@/lib/ads/video/snapshot"; -import { AAC_LC_CODEC, avcCodecString, GOP_FRAMES, hlsArgs, mp4Args, multivariantPlaylist, posterArgs } from "@/lib/ads/video/encode"; +import { AAC_LC_CODEC, audioCompanionArgs, avcCodecString, GOP_FRAMES, hlsArgs, mp4Args, multivariantPlaylist, posterArgs } from "@/lib/ads/video/encode"; import { evaluateProbe, parseRational, @@ -538,4 +538,21 @@ describe("loudness", () => { expect(a).toContain("-an"); expect(a.join(" ")).not.toContain("loudnorm"); }); + + it("masters the audible companion too, not only the picture", () => { + // The companion is built from the raw narration, not from the finished + // video, so it does not inherit the video track's mastering. A music + // player asks for this file and nothing else, which makes it the only + // thing most listeners ever hear. + const a = audioCompanionArgs("/tmp/narration.mp3", "/tmp/audio.m4a"); + expect(a[a.indexOf("-af") + 1]).toBe("loudnorm=I=-16:TP=-1.5:LRA=11"); + }); + + it("gives the companion the same target and ceiling as the video", () => { + // Two levels for one spot would mean the advert changed loudness when a + // listener moved between a video slot and an audio one. + const companion = audioCompanionArgs("/tmp/narration.mp3", "/tmp/audio.m4a"); + const filter = companion[companion.indexOf("-af") + 1]; + expect(narrated().join(" ")).toContain(filter); + }); });