Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions lib/ads/video/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ export const GOP_FRAMES = Math.round(
*/
const MUSIC_BED_GAIN = "-16dB";

/**
* Bring the finished mix to a normal listening level.
*
* A synthesised read comes back quiet and a five second spot has no room to
* ride the gain, so renders were landing around -30 dB mean: audible only if
* the viewer had already turned everything up for the programme, which is the
* one moment an advert must not ask them to. -16 LUFS is the level streaming
* platforms normalise to, so a spot arrives at the same loudness as whatever it
* interrupted rather than under it.
*
* The true-peak ceiling matters as much as the target. Without it, normalising
* a quiet source lifts its peaks into clipping, and a clipped voice is worse
* than a quiet one.
*/
const LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11";

export type Mp4EncodeOptions = {
/** printf-style pattern of the PNG frame sequence, e.g. `/tmp/x/f-%04d.png`. */
framePattern: string;
Expand Down Expand Up @@ -148,7 +164,7 @@ export function mp4Args(o: Mp4EncodeOptions): string[] {
`[1:a]apad,atrim=0:${seconds},asetpts=N/SR/TB[voice]`,
`[2:a]atrim=0:${seconds},asetpts=N/SR/TB,volume=${MUSIC_BED_GAIN},` +
`afade=t=in:st=0:d=${fade},afade=t=out:st=${(seconds - fade).toFixed(2)}:d=${fade}[bed]`,
`[voice][bed]amix=inputs=2:duration=first:normalize=0[a]`,
`[voice][bed]amix=inputs=2:duration=first:normalize=0,${LOUDNESS}[a]`,
].join(";"),
"-map", "0:v",
"-map", "[a]",
Expand All @@ -172,7 +188,7 @@ export function mp4Args(o: Mp4EncodeOptions): string[] {
// not extend it and the encode either kept the original 2.3s of speech or,
// once padded, dropped the stream entirely. An explicit duration is not a
// workaround — it is the thing actually being asserted.
args.push("-af", "apad");
args.push("-af", `apad,${LOUDNESS}`);
args.push("-c:a", "aac", "-profile:a", "aac_low", "-ar", String(AAC_SAMPLE_RATE), "-b:a", "128k", "-ac", "2");
} else {
args.push("-an");
Expand Down
58 changes: 56 additions & 2 deletions tests/ads-video-compose-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ describe("a music bed under the narration", () => {
expect(a).not.toContain("-filter_complex");
});

it("no bed leaves the narrated path exactly as it was", () => {
it("no bed keeps the narrated path on a simple filter, not the mixer", () => {
const a = mp4Args({
framePattern: "/tmp/f-%04d.png",
audioPath: "/tmp/narration.mp3",
Expand All @@ -482,6 +482,60 @@ describe("a music bed under the narration", () => {
videoKbps: 4000,
});
expect(a).not.toContain("-filter_complex");
expect(a[a.indexOf("-af") + 1]).toBe("apad");
// The pad still comes first; loudness is applied after it, once the track
// is the length of the picture.
expect(a[a.indexOf("-af") + 1]).toMatch(/^apad(,|$)/);
});
});

describe("loudness", () => {
const narrated = () =>
mp4Args({
framePattern: "/tmp/f-%04d.png",
audioPath: "/tmp/narration.mp3",
outPath: "/tmp/out.mp4",
profile: "master_1080p",
videoKbps: 4000,
});
const withBed = () =>
mp4Args({
framePattern: "/tmp/f-%04d.png",
audioPath: "/tmp/narration.mp3",
musicPath: "/tmp/bed.mp3",
outPath: "/tmp/out.mp4",
profile: "master_1080p",
videoKbps: 4000,
});

it("brings a narrated spot up to streaming level", () => {
// Renders were landing at -30 dB mean: audible only to someone who had
// already turned everything up, which is the one moment an advert must not
// ask them to.
expect(narrated().join(" ")).toContain("loudnorm=I=-16");
});

it("normalises the mix, not the voice before the bed joins it", () => {
const f = withBed().join(" ");
// Normalising the voice alone would move it relative to the bed and undo
// the -16 dB the bed was placed at.
expect(f).toMatch(/amix=[^;]*normalize=0,loudnorm=/);
});

it("caps true peak, so lifting a quiet source cannot clip it", () => {
// A clipped voice is worse than a quiet one.
expect(narrated().join(" ")).toContain("TP=-1.5");
expect(withBed().join(" ")).toContain("TP=-1.5");
});

it("a silent spot gains no audio filter", () => {
const a = mp4Args({
framePattern: "/tmp/f-%04d.png",
audioPath: null,
outPath: "/tmp/out.mp4",
profile: "master_1080p",
videoKbps: 4000,
});
expect(a).toContain("-an");
expect(a.join(" ")).not.toContain("loudnorm");
});
});