fix(engine): preserve authored audio mix order (#5672)

This commit is contained in:
Miguel Ángel
2026-10-16 00:13:04 +00:00
committed by GitHub
parent 917ee5d313
commit 8e17ee9300
2 changed files with 119 additions and 19 deletions
@@ -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<typeof import("../utils/runFfmpeg.js")>()),
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<string, () => void>();
const completed: string[] = [];
const mixes: string[][] = [];
let allStarted = () => {};
const started = new Promise<void>((resolve) => {
allStarted = resolve;
});
runFfmpegMock.mockImplementation(async (args: string[]) => {
const out = args.at(-1)!;
if (out.endsWith("-trimmed.wav")) {
await new Promise<void>((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"]);
}
},
);
+24 -19
View File
@@ -1123,6 +1123,7 @@ export async function processCompositionAudio(
): Promise<MixResult> {
const startMs = Date.now();
const tracks: AudioTrack[] = [];
const preparedTracks = new Array<AudioTrack | undefined>(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