diff --git a/packages/engine/src/services/audioMixer.order.test.ts b/packages/engine/src/services/audioMixer.order.test.ts new file mode 100644 index 0000000000..88be132bbb --- /dev/null +++ b/packages/engine/src/services/audioMixer.order.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { afterEach, expect, it, vi } from "vitest"; + +const { runFfmpegMock } = vi.hoisted(() => ({ runFfmpegMock: vi.fn() })); +vi.mock("../utils/runFfmpeg.js", async (importOriginal) => ({ + ...(await importOriginal()), + runFfmpeg: runFfmpegMock, +})); +vi.mock("../utils/ffprobe.js", () => ({ + extractAudioMetadata: vi.fn(async () => ({ durationSeconds: 1, channels: 2 })), +})); + +import { processCompositionAudio, type AudioElement } from "./audioMixer.js"; + +const dirs: string[] = []; +afterEach(() => { + runFfmpegMock.mockReset(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +it.each(["flat", "one bus", "two buses"])( + "keeps authored mix input order when preparations finish in reverse (%s)", + async (layout) => { + const dir = mkdtempSync(join(tmpdir(), "hf-audio-order-")); + dirs.push(dir); + const ids = + layout === "two buses" + ? ["first", "second", "third", "fourth"] + : ["first", "second", "third"]; + const elements: AudioElement[] = ids.map((id, index) => { + writeFileSync(join(dir, `${id}.wav`), "RIFF0000WAVE"); + return { + id, + src: `${id}.wav`, + type: "audio", + start: 0, + end: 1, + mediaStart: 0, + layer: 0, + volume: 1, + ...(layout === "flat" + ? {} + : { groupId: layout === "one bus" ? "bus" : `bus-${index % 2}`, groupVolume: 1 }), + }; + }); + const pending = new Map void>(); + const completed: string[] = []; + const mixes: string[][] = []; + let allStarted = () => {}; + const started = new Promise((resolve) => { + allStarted = resolve; + }); + runFfmpegMock.mockImplementation(async (args: string[]) => { + const out = args.at(-1)!; + if (out.endsWith("-trimmed.wav")) { + await new Promise((resolve) => { + pending.set(basename(out), resolve); + if (pending.size === elements.length) allStarted(); + }); + completed.push(basename(out)); + } else { + mixes.push(args.flatMap((arg, i) => (arg === "-i" ? [basename(args[i + 1]!)] : []))); + } + writeFileSync(out, "RIFF0000WAVE"); + return { success: true, durationMs: 1, stderr: "", exitCode: 0 }; + }); + const result = processCompositionAudio( + elements, + dir, + join(dir, "work"), + join(dir, "out.m4a"), + 1, + ); + await started; + const reversed = [...ids].reverse(); + for (const id of reversed) { + pending.get(`${id}-trimmed.wav`)!(); + await setImmediate(); + } + expect((await result).success).toBe(true); + expect(completed).toEqual(reversed.map((id) => `${id}-trimmed.wav`)); + if (layout === "two buses") { + expect(mixes).toEqual([ + ["first-trimmed.wav", "third-trimmed.wav"], + ["second-trimmed.wav", "fourth-trimmed.wav"], + ["group-bus-0-0.wav", "group-bus-1-1.wav"], + ]); + } else { + expect(mixes[0]).toEqual(["first-trimmed.wav", "second-trimmed.wav", "third-trimmed.wav"]); + } + }, +); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 2d6ebb9263..42c0b3cc82 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -1123,6 +1123,7 @@ export async function processCompositionAudio( ): Promise { const startMs = Date.now(); const tracks: AudioTrack[] = []; + const preparedTracks = new Array(elements.length); const failures: AudioProcessingFailure[] = []; // Grouped members are diverted here instead of `tracks` during element // processing, then summed into one processed track per group before the @@ -1154,7 +1155,7 @@ export async function processCompositionAudio( } await Promise.all( - elements.map(async (element) => { + elements.map(async (element, index) => { if (effectiveSignal.aborted) { failures.push({ stage: "cancelled", @@ -1386,24 +1387,7 @@ export async function processCompositionAudio( ...(tailSeconds > 0 ? { tailSeconds } : {}), }; - // A grouped member keeps every bit of its OWN processing above - // (FX, envelope) exactly like an ungrouped track — it just lands in - // the group's bucket instead of the flat list, to be summed into one - // processed bus below rather than mixed directly. - if (element.groupId) { - const bucket = groupTracks.get(element.groupId); - if (bucket) bucket.push(track); - else groupTracks.set(element.groupId, [track]); - if (!groupMeta.has(element.groupId)) { - groupMeta.set(element.groupId, { - ...(element.groupFxChain ? { fxChain: element.groupFxChain } : {}), - ...(element.groupAutomation ? { automation: element.groupAutomation } : {}), - volume: element.groupVolume ?? 1, - }); - } - } else { - tracks.push(track); - } + preparedTracks[index] = track; } catch (err: unknown) { // An FX failure is fatal for the whole mix. Every other failure mode // degrades gracefully — the track drops and siblings continue — but @@ -1436,6 +1420,27 @@ export async function processCompositionAudio( throw err; }); + // FFmpeg's floating-point sum depends on input order. Keep parallel prep, + // but assemble flat tracks, group members and groups in authored order. + elements.forEach((element, index) => { + const track = preparedTracks[index]; + if (!track) return; + if (element.groupId) { + const bucket = groupTracks.get(element.groupId); + if (bucket) bucket.push(track); + else groupTracks.set(element.groupId, [track]); + if (!groupMeta.has(element.groupId)) { + groupMeta.set(element.groupId, { + ...(element.groupFxChain ? { fxChain: element.groupFxChain } : {}), + ...(element.groupAutomation ? { automation: element.groupAutomation } : {}), + volume: element.groupVolume ?? 1, + }); + } + } else { + tracks.push(track); + } + }); + // Never turn a per-track preparation failure into a successful partial mix. // The producer only surfaces audio failures when `success` is false; mixing // the remaining tracks made the omitted cue indistinguishable from a valid