mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
test(engine): give the real-ffmpeg audio suites a timeout Windows can meet
`Tests on windows-latest` failed on two of the seven cases in audioMixer.grouping.test.ts -- both `Test timed out in 5000ms`, not an assertion. Every case in that suite mixes with REAL ffmpeg, and vitest's default 5s per test is not enough for that on a Windows runner: the same suite passes on macOS and ubuntu, and the job's own "Install FFmpeg" step logged a download-failure warning before falling back. A suite-level 60s covers all seven at once rather than seven per-test arguments. audioMixer.level.test.ts gets the same treatment. It has not failed yet, but it is the same real-ffmpeg shape one spawn slower away from it. Both files are this branch's, so this is the branch's own flake to fix; engine has no existing per-test timeout convention to follow, hence the suite argument plus a comment saying which platform forced it.
This commit is contained in:
@@ -103,294 +103,301 @@ const track = (id: string, end: number, volume = 1) => ({
|
|||||||
type: "audio" as const,
|
type: "audio" as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!HAS_FFMPEG)("mix level arithmetic", () => {
|
// Every case here mixes with REAL ffmpeg, and vitest's default 5s per test is not
|
||||||
afterEach(() => {
|
// enough for that on a Windows runner — two cases timed out there while passing
|
||||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
// everywhere else (PR #3363). The suite-level timeout covers all of them at once.
|
||||||
});
|
describe.skipIf(!HAS_FFMPEG)(
|
||||||
|
"mix level arithmetic",
|
||||||
it("keeps every track at its authored level regardless of how many there are", async () => {
|
() => {
|
||||||
// The property the compensation exists to hold: adding tracks must not
|
afterEach(() => {
|
||||||
// duck the ones already there. Mixing the SAME tone twice is the cleanest
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
// probe — two coherent copies sum to exactly +6.02 dB, so any residual
|
|
||||||
// normalisation shows up as a plain arithmetic miss rather than something
|
|
||||||
// that has to be teased out of unrelated material.
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-count-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-count-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
writeTone(join(projectDir, "a.wav"), 440, 2, 0.4);
|
|
||||||
writeTone(join(projectDir, "b.wav"), 440, 2, 0.4);
|
|
||||||
const oneUp = join(projectDir, `one-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
const twoUp = join(projectDir, `two-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
|
|
||||||
const one = await processCompositionAudio([track("a", 2)], projectDir, workDir, oneUp, 2);
|
|
||||||
const two = await processCompositionAudio(
|
|
||||||
[track("a", 2), track("b", 2)],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
twoUp,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
expect(one.success).toBe(true);
|
|
||||||
expect(two.success).toBe(true);
|
|
||||||
|
|
||||||
// Two coherent copies of one tone = +6.02 dB. If amix's 1/N ever survives
|
|
||||||
// the correction, this lands at 0 dB instead.
|
|
||||||
expect(meanVolumeDb(twoUp) - meanVolumeDb(oneUp)).toBeCloseTo(6.02, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not lift the survivors when a shorter track ends", async () => {
|
|
||||||
// amix with normalize=true rescales by the number of CURRENTLY ACTIVE
|
|
||||||
// inputs, so a track ending mid-composition would hand the remaining ones
|
|
||||||
// a level jump. `apad` to the full duration is what holds every input
|
|
||||||
// active for the whole graph and neutralises that — an invariant the
|
|
||||||
// filter string relies on without saying so.
|
|
||||||
//
|
|
||||||
// Measured without apad (spike, 2026-08-14): the tail runs +1.94 dB hot.
|
|
||||||
// Any group work that builds its own amix has to keep the padding, or
|
|
||||||
// inherit that bug one level down.
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-drop-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-drop-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
writeTone(join(projectDir, "short.wav"), 440, 1, 0.5);
|
|
||||||
writeTone(join(projectDir, "long.wav"), 880, 3, 0.5);
|
|
||||||
const together = join(projectDir, `both-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
const alone = join(projectDir, `alone-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
|
|
||||||
const both = await processCompositionAudio(
|
|
||||||
[track("short", 1), track("long", 3)],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
together,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
const solo = await processCompositionAudio([track("long", 3)], projectDir, workDir, alone, 3);
|
|
||||||
expect(both.success).toBe(true);
|
|
||||||
expect(solo.success).toBe(true);
|
|
||||||
|
|
||||||
// After 1.5 s only `long` is sounding. It must read the same whether or not
|
|
||||||
// a second track happened to end earlier.
|
|
||||||
const tailTogether = meanVolumeDb(together, 1.5, 3);
|
|
||||||
const tailAlone = meanVolumeDb(alone, 1.5, 3);
|
|
||||||
expect(Math.abs(tailTogether - tailAlone)).toBeLessThan(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The gate for group buses (plans/audio-mixer-groups.md §1).
|
|
||||||
*
|
|
||||||
* Grouping is routing, not processing: a group whose FX chain is empty must
|
|
||||||
* be a no-op on the mix. Enable this the moment `data-audio-group` routes
|
|
||||||
* through a nested amix — it is the definition of done for §1.3, and the
|
|
||||||
* only thing standing between a wrong gain correction and a silently loud
|
|
||||||
* export.
|
|
||||||
*
|
|
||||||
* Proven reachable in the spike: nesting with each amix compensated by ITS
|
|
||||||
* OWN input count nulls against the flat mix to -inf (sample-identical), as
|
|
||||||
* does `amix=normalize=0` with no correction at all.
|
|
||||||
*/
|
|
||||||
it("mixes a grouped composition at the same level as the ungrouped one", async () => {
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-level-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-level-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
writeTone(join(projectDir, "a.wav"), 440, 2, 0.4);
|
|
||||||
writeTone(join(projectDir, "b.wav"), 660, 2, 0.4);
|
|
||||||
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
const groupedOut = join(projectDir, `grouped-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
|
|
||||||
const flat = await processCompositionAudio(
|
|
||||||
[track("a", 2), track("b", 2)],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
flatOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const grouped = await processCompositionAudio(
|
|
||||||
[
|
|
||||||
{ ...track("a", 2), groupId: "voiceover" },
|
|
||||||
{ ...track("b", 2), groupId: "voiceover" },
|
|
||||||
],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
groupedOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
expect(flat.success).toBe(true);
|
|
||||||
expect(grouped.success).toBe(true);
|
|
||||||
|
|
||||||
// An empty group chain is pure routing — the export must read the same
|
|
||||||
// whether or not the two tones happened to share a group.
|
|
||||||
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(flatOut))).toBeLessThan(0.3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a group FX chain fully cutting its members leaves an ungrouped track untouched (routing isolation)", async () => {
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-fx-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-fx-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
writeTone(join(projectDir, "voice.wav"), 440, 2, 0.4);
|
|
||||||
writeTone(join(projectDir, "sfx.wav"), 880, 2, 0.4);
|
|
||||||
const mixedOut = join(projectDir, `mixed-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
const sfxAloneOut = join(projectDir, `sfx-alone-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
|
|
||||||
const groupChain = JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
nodes: [{ type: "gain", id: "g", params: { gain: -60 } }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mixed = await processCompositionAudio(
|
it("keeps every track at its authored level regardless of how many there are", async () => {
|
||||||
[{ ...track("voice", 2), groupId: "vo", groupFxChain: groupChain }, track("sfx", 2)],
|
// The property the compensation exists to hold: adding tracks must not
|
||||||
projectDir,
|
// duck the ones already there. Mixing the SAME tone twice is the cleanest
|
||||||
workDir,
|
// probe — two coherent copies sum to exactly +6.02 dB, so any residual
|
||||||
mixedOut,
|
// normalisation shows up as a plain arithmetic miss rather than something
|
||||||
2,
|
// that has to be teased out of unrelated material.
|
||||||
);
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-count-"));
|
||||||
const sfxAlone = await processCompositionAudio(
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-count-work-"));
|
||||||
[track("sfx", 2)],
|
tempDirs.push(projectDir, workDir);
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
sfxAloneOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
expect(mixed.success).toBe(true);
|
|
||||||
expect(sfxAlone.success).toBe(true);
|
|
||||||
|
|
||||||
// The voice group is cut ~60 dB — the mix should read close to sfx alone,
|
writeTone(join(projectDir, "a.wav"), 440, 2, 0.4);
|
||||||
// and the ungrouped sfx track's own processing is unaffected by the
|
writeTone(join(projectDir, "b.wav"), 440, 2, 0.4);
|
||||||
// group existing at all.
|
const oneUp = join(projectDir, `one-${MIXED_AUDIO_FILENAME}`);
|
||||||
expect(Math.abs(meanVolumeDb(mixedOut) - meanVolumeDb(sfxAloneOut))).toBeLessThan(0.5);
|
const twoUp = join(projectDir, `two-${MIXED_AUDIO_FILENAME}`);
|
||||||
});
|
|
||||||
|
|
||||||
it("a member's own volume envelope still applies inside a group", async () => {
|
const one = await processCompositionAudio([track("a", 2)], projectDir, workDir, oneUp, 2);
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-env-"));
|
const two = await processCompositionAudio(
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-env-work-"));
|
[track("a", 2), track("b", 2)],
|
||||||
tempDirs.push(projectDir, workDir);
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
twoUp,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(one.success).toBe(true);
|
||||||
|
expect(two.success).toBe(true);
|
||||||
|
|
||||||
writeTone(join(projectDir, "a.wav"), 440, 4, 0.5);
|
// Two coherent copies of one tone = +6.02 dB. If amix's 1/N ever survives
|
||||||
const groupedOut = join(projectDir, `grouped-${MIXED_AUDIO_FILENAME}`);
|
// the correction, this lands at 0 dB instead.
|
||||||
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
expect(meanVolumeDb(twoUp) - meanVolumeDb(oneUp)).toBeCloseTo(6.02, 0);
|
||||||
|
|
||||||
const withEnvelope = {
|
|
||||||
...track("a", 4),
|
|
||||||
volumeKeyframes: [
|
|
||||||
{ time: 0, volume: 1 },
|
|
||||||
{ time: 4, volume: 0 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const grouped = await processCompositionAudio(
|
|
||||||
[{ ...withEnvelope, groupId: "vo" }],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
groupedOut,
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
const flat = await processCompositionAudio([withEnvelope], projectDir, workDir, flatOut, 4);
|
|
||||||
expect(grouped.success).toBe(true);
|
|
||||||
expect(flat.success).toBe(true);
|
|
||||||
|
|
||||||
// The envelope fades to silent — the tail should read the same whether
|
|
||||||
// the track is grouped or not, proving member-level processing survives
|
|
||||||
// the group path unchanged.
|
|
||||||
const groupedTail = meanVolumeDb(groupedOut, 3, 4);
|
|
||||||
const flatTail = meanVolumeDb(flatOut, 3, 4);
|
|
||||||
expect(Math.abs(groupedTail - flatTail)).toBeLessThan(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Members sum at unity (normalize=0), so an over-unity sum used to hard-clip
|
|
||||||
// at ±1 in the 16-bit intermediate BEFORE the group's fader and FX chain ran
|
|
||||||
// — pulling the group down then operated on distortion. Every existing probe
|
|
||||||
// here sums to ≤ 0.8, which is exactly why nothing caught it; preview cannot
|
|
||||||
// reproduce it either, because its bus is float.
|
|
||||||
it("does not clip an over-unity member sum before the group fader", async () => {
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
// Two coherent copies of the same tone: 0.7 + 0.7 = 1.4, comfortably over.
|
|
||||||
// `writePeakTone`, not `writeTone` — ffmpeg's `sine` source is nowhere near
|
|
||||||
// full scale (its output peaks at ~0.125), so every tone in this file sits
|
|
||||||
// around -21 dBFS and NOTHING here can reach a clip no matter what gain is
|
|
||||||
// asked for. That is a large part of why this class of bug survived.
|
|
||||||
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
|
|
||||||
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
|
|
||||||
// The reference: the level that sum SHOULD reach once the group's 0.5
|
|
||||||
// fader has been applied — 1.4 × 0.5 = 0.7, one tone's worth.
|
|
||||||
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
|
|
||||||
|
|
||||||
const groupedOut = join(projectDir, `clip-grouped-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
const refOut = join(projectDir, `clip-ref-${MIXED_AUDIO_FILENAME}`);
|
|
||||||
|
|
||||||
const grouped = await processCompositionAudio(
|
|
||||||
[
|
|
||||||
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5 },
|
|
||||||
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5 },
|
|
||||||
],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
groupedOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const reference = await processCompositionAudio(
|
|
||||||
[track("ref", 2)],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
refOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
expect(grouped.success).toBe(true);
|
|
||||||
expect(reference.success).toBe(true);
|
|
||||||
|
|
||||||
// Both are ONE track into the outer mix, so the outer graph is identical
|
|
||||||
// and the levels are directly comparable. Clipped, the flat-topped sum
|
|
||||||
// reads well over a dB hot even after the fader halves it.
|
|
||||||
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same property, for a group that carries an FX CHAIN. That path runs the
|
|
||||||
// sum through applyAudioFxChain, whose writeWav clamps to ±1 and emits 16-bit
|
|
||||||
// — so the headroom the float sub-mix preserved was handed back before the
|
|
||||||
// fader, one step later than the original bug but with the same result.
|
|
||||||
// A transparent chain isolates the clamp from anything the effects do.
|
|
||||||
it("does not clip an over-unity sum before the fader when the group has FX", async () => {
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-"));
|
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-work-"));
|
|
||||||
tempDirs.push(projectDir, workDir);
|
|
||||||
|
|
||||||
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
|
|
||||||
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
|
|
||||||
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
|
|
||||||
|
|
||||||
// 0 dB gain: in the chain, doing nothing to the level.
|
|
||||||
const transparentChain = JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
nodes: [{ type: "gain", id: "g", params: { gain: 0 } }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedOut = join(projectDir, `clipfx-grouped-${MIXED_AUDIO_FILENAME}`);
|
it("does not lift the survivors when a shorter track ends", async () => {
|
||||||
const refOut = join(projectDir, `clipfx-ref-${MIXED_AUDIO_FILENAME}`);
|
// amix with normalize=true rescales by the number of CURRENTLY ACTIVE
|
||||||
|
// inputs, so a track ending mid-composition would hand the remaining ones
|
||||||
|
// a level jump. `apad` to the full duration is what holds every input
|
||||||
|
// active for the whole graph and neutralises that — an invariant the
|
||||||
|
// filter string relies on without saying so.
|
||||||
|
//
|
||||||
|
// Measured without apad (spike, 2026-08-14): the tail runs +1.94 dB hot.
|
||||||
|
// Any group work that builds its own amix has to keep the padding, or
|
||||||
|
// inherit that bug one level down.
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-drop-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-drop-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
const grouped = await processCompositionAudio(
|
writeTone(join(projectDir, "short.wav"), 440, 1, 0.5);
|
||||||
[
|
writeTone(join(projectDir, "long.wav"), 880, 3, 0.5);
|
||||||
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
|
const together = join(projectDir, `both-${MIXED_AUDIO_FILENAME}`);
|
||||||
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
|
const alone = join(projectDir, `alone-${MIXED_AUDIO_FILENAME}`);
|
||||||
],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
groupedOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const reference = await processCompositionAudio(
|
|
||||||
[track("ref", 2)],
|
|
||||||
projectDir,
|
|
||||||
workDir,
|
|
||||||
refOut,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
expect(grouped.success).toBe(true);
|
|
||||||
expect(reference.success).toBe(true);
|
|
||||||
|
|
||||||
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
|
const both = await processCompositionAudio(
|
||||||
});
|
[track("short", 1), track("long", 3)],
|
||||||
});
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
together,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const solo = await processCompositionAudio([track("long", 3)], projectDir, workDir, alone, 3);
|
||||||
|
expect(both.success).toBe(true);
|
||||||
|
expect(solo.success).toBe(true);
|
||||||
|
|
||||||
|
// After 1.5 s only `long` is sounding. It must read the same whether or not
|
||||||
|
// a second track happened to end earlier.
|
||||||
|
const tailTogether = meanVolumeDb(together, 1.5, 3);
|
||||||
|
const tailAlone = meanVolumeDb(alone, 1.5, 3);
|
||||||
|
expect(Math.abs(tailTogether - tailAlone)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gate for group buses (plans/audio-mixer-groups.md §1).
|
||||||
|
*
|
||||||
|
* Grouping is routing, not processing: a group whose FX chain is empty must
|
||||||
|
* be a no-op on the mix. Enable this the moment `data-audio-group` routes
|
||||||
|
* through a nested amix — it is the definition of done for §1.3, and the
|
||||||
|
* only thing standing between a wrong gain correction and a silently loud
|
||||||
|
* export.
|
||||||
|
*
|
||||||
|
* Proven reachable in the spike: nesting with each amix compensated by ITS
|
||||||
|
* OWN input count nulls against the flat mix to -inf (sample-identical), as
|
||||||
|
* does `amix=normalize=0` with no correction at all.
|
||||||
|
*/
|
||||||
|
it("mixes a grouped composition at the same level as the ungrouped one", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-level-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-level-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
writeTone(join(projectDir, "a.wav"), 440, 2, 0.4);
|
||||||
|
writeTone(join(projectDir, "b.wav"), 660, 2, 0.4);
|
||||||
|
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const groupedOut = join(projectDir, `grouped-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const flat = await processCompositionAudio(
|
||||||
|
[track("a", 2), track("b", 2)],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
flatOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const grouped = await processCompositionAudio(
|
||||||
|
[
|
||||||
|
{ ...track("a", 2), groupId: "voiceover" },
|
||||||
|
{ ...track("b", 2), groupId: "voiceover" },
|
||||||
|
],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
groupedOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(flat.success).toBe(true);
|
||||||
|
expect(grouped.success).toBe(true);
|
||||||
|
|
||||||
|
// An empty group chain is pure routing — the export must read the same
|
||||||
|
// whether or not the two tones happened to share a group.
|
||||||
|
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(flatOut))).toBeLessThan(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a group FX chain fully cutting its members leaves an ungrouped track untouched (routing isolation)", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-fx-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-fx-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
writeTone(join(projectDir, "voice.wav"), 440, 2, 0.4);
|
||||||
|
writeTone(join(projectDir, "sfx.wav"), 880, 2, 0.4);
|
||||||
|
const mixedOut = join(projectDir, `mixed-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const sfxAloneOut = join(projectDir, `sfx-alone-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const groupChain = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
nodes: [{ type: "gain", id: "g", params: { gain: -60 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixed = await processCompositionAudio(
|
||||||
|
[{ ...track("voice", 2), groupId: "vo", groupFxChain: groupChain }, track("sfx", 2)],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
mixedOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const sfxAlone = await processCompositionAudio(
|
||||||
|
[track("sfx", 2)],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
sfxAloneOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(mixed.success).toBe(true);
|
||||||
|
expect(sfxAlone.success).toBe(true);
|
||||||
|
|
||||||
|
// The voice group is cut ~60 dB — the mix should read close to sfx alone,
|
||||||
|
// and the ungrouped sfx track's own processing is unaffected by the
|
||||||
|
// group existing at all.
|
||||||
|
expect(Math.abs(meanVolumeDb(mixedOut) - meanVolumeDb(sfxAloneOut))).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a member's own volume envelope still applies inside a group", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-env-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-env-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
writeTone(join(projectDir, "a.wav"), 440, 4, 0.5);
|
||||||
|
const groupedOut = join(projectDir, `grouped-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const flatOut = join(projectDir, `flat-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const withEnvelope = {
|
||||||
|
...track("a", 4),
|
||||||
|
volumeKeyframes: [
|
||||||
|
{ time: 0, volume: 1 },
|
||||||
|
{ time: 4, volume: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = await processCompositionAudio(
|
||||||
|
[{ ...withEnvelope, groupId: "vo" }],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
groupedOut,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
const flat = await processCompositionAudio([withEnvelope], projectDir, workDir, flatOut, 4);
|
||||||
|
expect(grouped.success).toBe(true);
|
||||||
|
expect(flat.success).toBe(true);
|
||||||
|
|
||||||
|
// The envelope fades to silent — the tail should read the same whether
|
||||||
|
// the track is grouped or not, proving member-level processing survives
|
||||||
|
// the group path unchanged.
|
||||||
|
const groupedTail = meanVolumeDb(groupedOut, 3, 4);
|
||||||
|
const flatTail = meanVolumeDb(flatOut, 3, 4);
|
||||||
|
expect(Math.abs(groupedTail - flatTail)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Members sum at unity (normalize=0), so an over-unity sum used to hard-clip
|
||||||
|
// at ±1 in the 16-bit intermediate BEFORE the group's fader and FX chain ran
|
||||||
|
// — pulling the group down then operated on distortion. Every existing probe
|
||||||
|
// here sums to ≤ 0.8, which is exactly why nothing caught it; preview cannot
|
||||||
|
// reproduce it either, because its bus is float.
|
||||||
|
it("does not clip an over-unity member sum before the group fader", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clip-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
// Two coherent copies of the same tone: 0.7 + 0.7 = 1.4, comfortably over.
|
||||||
|
// `writePeakTone`, not `writeTone` — ffmpeg's `sine` source is nowhere near
|
||||||
|
// full scale (its output peaks at ~0.125), so every tone in this file sits
|
||||||
|
// around -21 dBFS and NOTHING here can reach a clip no matter what gain is
|
||||||
|
// asked for. That is a large part of why this class of bug survived.
|
||||||
|
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
|
||||||
|
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
|
||||||
|
// The reference: the level that sum SHOULD reach once the group's 0.5
|
||||||
|
// fader has been applied — 1.4 × 0.5 = 0.7, one tone's worth.
|
||||||
|
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
|
||||||
|
|
||||||
|
const groupedOut = join(projectDir, `clip-grouped-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const refOut = join(projectDir, `clip-ref-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const grouped = await processCompositionAudio(
|
||||||
|
[
|
||||||
|
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5 },
|
||||||
|
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5 },
|
||||||
|
],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
groupedOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const reference = await processCompositionAudio(
|
||||||
|
[track("ref", 2)],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
refOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(grouped.success).toBe(true);
|
||||||
|
expect(reference.success).toBe(true);
|
||||||
|
|
||||||
|
// Both are ONE track into the outer mix, so the outer graph is identical
|
||||||
|
// and the levels are directly comparable. Clipped, the flat-topped sum
|
||||||
|
// reads well over a dB hot even after the fader halves it.
|
||||||
|
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same property, for a group that carries an FX CHAIN. That path runs the
|
||||||
|
// sum through applyAudioFxChain, whose writeWav clamps to ±1 and emits 16-bit
|
||||||
|
// — so the headroom the float sub-mix preserved was handed back before the
|
||||||
|
// fader, one step later than the original bug but with the same result.
|
||||||
|
// A transparent chain isolates the clamp from anything the effects do.
|
||||||
|
it("does not clip an over-unity sum before the fader when the group has FX", async () => {
|
||||||
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-"));
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-grp-clipfx-work-"));
|
||||||
|
tempDirs.push(projectDir, workDir);
|
||||||
|
|
||||||
|
writePeakTone(join(projectDir, "a.wav"), 440, 2, 0.7);
|
||||||
|
writePeakTone(join(projectDir, "b.wav"), 440, 2, 0.7);
|
||||||
|
writePeakTone(join(projectDir, "ref.wav"), 440, 2, 0.7);
|
||||||
|
|
||||||
|
// 0 dB gain: in the chain, doing nothing to the level.
|
||||||
|
const transparentChain = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
nodes: [{ type: "gain", id: "g", params: { gain: 0 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedOut = join(projectDir, `clipfx-grouped-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
const refOut = join(projectDir, `clipfx-ref-${MIXED_AUDIO_FILENAME}`);
|
||||||
|
|
||||||
|
const grouped = await processCompositionAudio(
|
||||||
|
[
|
||||||
|
{ ...track("a", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
|
||||||
|
{ ...track("b", 2), groupId: "vo", groupVolume: 0.5, groupFxChain: transparentChain },
|
||||||
|
],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
groupedOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const reference = await processCompositionAudio(
|
||||||
|
[track("ref", 2)],
|
||||||
|
projectDir,
|
||||||
|
workDir,
|
||||||
|
refOut,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(grouped.success).toBe(true);
|
||||||
|
expect(reference.success).toBe(true);
|
||||||
|
|
||||||
|
expect(Math.abs(meanVolumeDb(groupedOut) - meanVolumeDb(refOut))).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|||||||
@@ -55,110 +55,117 @@ function firstAudibleSeconds(path: string): number {
|
|||||||
throw new Error(`No audible sample found in ${path}`);
|
throw new Error(`No audible sample found in ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe.skipIf(!HAS_FFMPEG)("processCompositionAudio levels", () => {
|
// Same real-ffmpeg exposure as audioMixer.grouping.test.ts, which timed out on a
|
||||||
afterEach(() => {
|
// Windows runner at vitest's default 5s. This one has not failed yet; it is one
|
||||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
// spawn slower away from it.
|
||||||
});
|
describe.skipIf(!HAS_FFMPEG)(
|
||||||
|
"processCompositionAudio levels",
|
||||||
|
() => {
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the level of a mono source in the stereo mix", async () => {
|
it("preserves the level of a mono source in the stereo mix", async () => {
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-mono-level-"));
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-mono-level-"));
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-mono-work-"));
|
const workDir = mkdtempSync(join(tmpdir(), "hf-mono-work-"));
|
||||||
tempDirs.push(projectDir, workDir);
|
tempDirs.push(projectDir, workDir);
|
||||||
const sourcePath = join(projectDir, "voice.wav");
|
const sourcePath = join(projectDir, "voice.wav");
|
||||||
const outputPath = join(projectDir, "audio.aac");
|
const outputPath = join(projectDir, "audio.aac");
|
||||||
const setup = spawnSync(
|
const setup = spawnSync(
|
||||||
getFfmpegBinary(),
|
getFfmpegBinary(),
|
||||||
[
|
[
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
"-v",
|
"-v",
|
||||||
"error",
|
"error",
|
||||||
"-f",
|
"-f",
|
||||||
"lavfi",
|
"lavfi",
|
||||||
"-i",
|
"-i",
|
||||||
"sine=frequency=1000:duration=1:sample_rate=48000",
|
"sine=frequency=1000:duration=1:sample_rate=48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"1",
|
"1",
|
||||||
"-c:a",
|
"-c:a",
|
||||||
"pcm_s16le",
|
"pcm_s16le",
|
||||||
sourcePath,
|
sourcePath,
|
||||||
],
|
],
|
||||||
{ encoding: "utf-8" },
|
{ encoding: "utf-8" },
|
||||||
);
|
);
|
||||||
expect(setup.status, setup.stderr).toBe(0);
|
expect(setup.status, setup.stderr).toBe(0);
|
||||||
|
|
||||||
const result = await processCompositionAudio(
|
const result = await processCompositionAudio(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "voice",
|
id: "voice",
|
||||||
src: "voice.wav",
|
src: "voice.wav",
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 1,
|
end: 1,
|
||||||
mediaStart: 0,
|
mediaStart: 0,
|
||||||
layer: 0,
|
layer: 0,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
type: "audio",
|
type: "audio",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
projectDir,
|
projectDir,
|
||||||
workDir,
|
workDir,
|
||||||
outputPath,
|
outputPath,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(meanVolumeDb(outputPath) - meanVolumeDb(sourcePath)).toBeGreaterThan(-0.3);
|
expect(meanVolumeDb(outputPath) - meanVolumeDb(sourcePath)).toBeGreaterThan(-0.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places a delayed track on its authored start, not one AAC frame later", async () => {
|
it("places a delayed track on its authored start, not one AAC frame later", async () => {
|
||||||
// The mix is AAC-encoded, and AAC encoders emit ~1024 priming samples. A
|
// The mix is AAC-encoded, and AAC encoders emit ~1024 priming samples. A
|
||||||
// raw ADTS container has nowhere to record that delay, so it decodes as
|
// raw ADTS container has nowhere to record that delay, so it decodes as
|
||||||
// real leading silence and drags the whole track 21.33 ms late against a
|
// real leading silence and drags the whole track 21.33 ms late against a
|
||||||
// frame-accurate video. MIXED_AUDIO_FILENAME picks a container that stores
|
// frame-accurate video. MIXED_AUDIO_FILENAME picks a container that stores
|
||||||
// the delay as an edit list instead; this asserts the artifact we actually
|
// the delay as an edit list instead; this asserts the artifact we actually
|
||||||
// ship lands on time.
|
// ship lands on time.
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-onset-"));
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-onset-"));
|
||||||
const workDir = mkdtempSync(join(tmpdir(), "hf-onset-work-"));
|
const workDir = mkdtempSync(join(tmpdir(), "hf-onset-work-"));
|
||||||
tempDirs.push(projectDir, workDir);
|
tempDirs.push(projectDir, workDir);
|
||||||
const sourcePath = join(projectDir, "tone.wav");
|
const sourcePath = join(projectDir, "tone.wav");
|
||||||
const outputPath = join(projectDir, MIXED_AUDIO_FILENAME);
|
const outputPath = join(projectDir, MIXED_AUDIO_FILENAME);
|
||||||
const setup = spawnSync(
|
const setup = spawnSync(
|
||||||
getFfmpegBinary(),
|
getFfmpegBinary(),
|
||||||
[
|
[
|
||||||
"-nostdin",
|
"-nostdin",
|
||||||
"-v",
|
"-v",
|
||||||
"error",
|
"error",
|
||||||
"-f",
|
"-f",
|
||||||
"lavfi",
|
"lavfi",
|
||||||
"-i",
|
"-i",
|
||||||
"sine=frequency=1000:duration=1:sample_rate=48000",
|
"sine=frequency=1000:duration=1:sample_rate=48000",
|
||||||
"-c:a",
|
"-c:a",
|
||||||
"pcm_s16le",
|
"pcm_s16le",
|
||||||
sourcePath,
|
sourcePath,
|
||||||
],
|
],
|
||||||
{ encoding: "utf-8" },
|
{ encoding: "utf-8" },
|
||||||
);
|
);
|
||||||
expect(setup.status, setup.stderr).toBe(0);
|
expect(setup.status, setup.stderr).toBe(0);
|
||||||
|
|
||||||
const result = await processCompositionAudio(
|
const result = await processCompositionAudio(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "tone",
|
id: "tone",
|
||||||
src: "tone.wav",
|
src: "tone.wav",
|
||||||
start: 2,
|
start: 2,
|
||||||
end: 3,
|
end: 3,
|
||||||
mediaStart: 0,
|
mediaStart: 0,
|
||||||
layer: 0,
|
layer: 0,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
type: "audio",
|
type: "audio",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
projectDir,
|
projectDir,
|
||||||
workDir,
|
workDir,
|
||||||
outputPath,
|
outputPath,
|
||||||
4,
|
4,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(firstAudibleSeconds(outputPath)).toBeCloseTo(2, 2);
|
expect(firstAudibleSeconds(outputPath)).toBeCloseTo(2, 2);
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user