From c056289d832351dc35256bda8b36188a77bc557a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 21 Aug 2026 00:17:50 -0400 Subject: [PATCH] fix(audio): renumber timestamps between apad and atrim in mixed branches (#3380) On FFmpeg 5.x through 8.0.x the samples `apad` appends carry timestamps the following `atrim` misreads. A delayed branch then sounds at t=0 instead of its offset and, once four or more branches are mixed, the last one disappears from the output entirely. No error is raised; the render succeeds with wrong audio. Reverting to `apad=whole_dur=` is not an option: #2769 moved off that form because some builds reject the option outright ("Error applying option 'whole_dur': Option not found"). Inserting `asetpts=N/SR/TB` between the pad and the trim rebuilds the timestamps from the sample count using only filters every build ships, so it fixes the misplacement without giving up the portability that change bought. Verified on FFmpeg 4.2.7, 7.0.2, an 8.x nightly and 8.1.1: the current form is wrong on the middle two, the new form is correct on all four. audioPadTrim.ts also pads with apad+atrim but has no adelay and is correct on every version tested, so it is left alone. Closes #3344 --- .../engine/src/services/audioMixer.test.ts | 58 ++++++++++++++++++- packages/engine/src/services/audioMixer.ts | 13 ++++- .../producer/src/services/audioExtractor.ts | 3 +- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 341c9130d..f1aed1cc6 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -296,7 +296,7 @@ describe("processCompositionAudio", () => { expect(filter).toContain("volume=0"); expect(filter).toContain("[mixed]volume=1[out]"); - expect(filter).toContain("apad,atrim=0:2"); + expect(filter).toContain("apad,asetpts=N/SR/TB,atrim=0:2"); expect(filter).not.toContain("whole_dur"); expect(filter).not.toContain("normalize="); expect(filter).not.toContain("weights="); @@ -464,7 +464,7 @@ describe("processCompositionAudio", () => { // 2 s clip + the 1.9 s tail 0.6 + size * 2.6 generates. expect(filter).toContain("atrim=0:3.9,"); // And still cut at the composition's end, so a tail cannot extend the video. - expect(filter).toContain("apad,atrim=0:8"); + expect(filter).toContain("apad,asetpts=N/SR/TB,atrim=0:8"); }); it("hands the volume envelope to the FX pass instead of ducking the file after it", async () => { @@ -1078,6 +1078,60 @@ describe("processCompositionAudio", () => { expect((filter?.match(/apad,/g) ?? []).length).toBe(trackCount); }); + it("renumbers timestamps between apad and atrim on every mixed branch", async () => { + // Regression: `apad` then `atrim` is the portable pad-to-length shape -- + // #2769 moved off `apad=whole_dur=` because some builds reject that option. + // But on FFmpeg 5.x-8.0.x the padded samples carry timestamps `atrim` + // misreads, so a delayed branch sounds at t=0 and, past three branches, the + // last one vanishes from the mix. `asetpts=N/SR/TB` between the two rebuilds + // the timestamps from the sample count and costs no portability, since all + // three filters exist in every build we support. + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "a.wav"), "stub"); + writeFileSync(join(baseDir, "b.wav"), "stub"); + + // Two branches, the second delayed: the shape that misplaced audio. + await processCompositionAudio( + [ + { + id: "a", + src: "a.wav", + start: 0, + end: 1, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + { + id: "b", + src: "b.wav", + start: 4, + end: 5, + mediaStart: 0, + layer: 1, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 8, + ); + + const filter = capturedFilterScripts.at(-1) ?? ""; + const branches = filter.match(/apad[^;]*/g) ?? []; + expect(branches).toHaveLength(2); + for (const branch of branches) { + expect(branch).toMatch(/^apad,asetpts=N\/SR\/TB,atrim=0:/); + } + // The portability constraint #2769 established still holds. + expect(filter).not.toContain("whole_dur"); + }); + it("retries with the current file-valued filter option when a nightly removes the legacy alias", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 2d856d1d7..6265ba0b9 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -709,8 +709,19 @@ async function mixAudioTracks( // can run over what follows but never past the end of the video. const trimDuration = track.end - track.start + (track.tailSeconds ?? 0); const volumeFilter = buildVolumeExpression(track, ignoreAutomation); + // `apad` then `atrim` is the portable pad-to-length shape: PR #2769 moved + // off `apad=whole_dur=` because some FFmpeg builds reject that option + // outright ("Error applying option 'whole_dur': Option not found"). + // But on FFmpeg 5.x through 8.0.x the samples `apad` appends carry + // timestamps the following `atrim` misreads, so a delayed branch lands at + // t=0 and, once four or more branches are mixed, the last one disappears + // entirely. `asetpts=N/SR/TB` renumbers the padded stream from the sample + // count before the trim reads it, which fixes the misplacement while + // keeping the filter set every build supports. Verified correct on 4.2.7, + // 7.0.2, an 8.x nightly and 8.1.1; the un-reset form is wrong on the + // middle two. filterParts.push( - `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`, + `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`, ); }); diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index ccddf3205..6e6f0fa65 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -211,8 +211,9 @@ async function mixTracks( const delayMs = Math.round(track.start * 1000); const trimDuration = track.duration > 0 ? track.duration : totalDuration; + // See audioMixer.ts for why asetpts sits between apad and atrim. filterParts.push( - `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,atrim=0:${totalDuration}[a${i}]`, + `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,asetpts=N/SR/TB,atrim=0:${totalDuration}[a${i}]`, ); });