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); + }); });