diff --git a/packages/core/stubs/audio-fx-runtime-entry.ts b/packages/core/stubs/audio-fx-runtime-entry.ts index 81f8e5f035..d6a78b9439 100644 --- a/packages/core/stubs/audio-fx-runtime-entry.ts +++ b/packages/core/stubs/audio-fx-runtime-entry.ts @@ -81,6 +81,11 @@ async function render( const chain: HfAudioFxChain = parseAudioFxChain(chainJson); const channels = Math.max(1, planes.length); const frames = planes[0]?.length ?? 0; + // Nothing to process, and `new OfflineAudioContext(ch, 0, rate)` throws — an + // error the render treats as fatal. applyAudioFxChain screens empty tracks + // out before they reach the browser; this is the same guard at the point the + // constructor would actually blow up. + if (frames === 0) return planes; const parsedAutomation = automationJson ? resolveAutomation(parseAutomation(automationJson), chain) : null; diff --git a/packages/engine/src/services/audioFxRender.test.ts b/packages/engine/src/services/audioFxRender.test.ts index 6b07f35c3a..553333ee85 100644 --- a/packages/engine/src/services/audioFxRender.test.ts +++ b/packages/engine/src/services/audioFxRender.test.ts @@ -253,3 +253,25 @@ describe("browser render", () => { expect(readWav(outPath).samples.length).toBeGreaterThan(0); }, 180_000); }); + +/** + * ffmpeg exits 0 and writes a structurally valid but EMPTY wav whenever a + * clip's trim starts past the end of its source (`-ss 10 -t 5` on a 2 s file). + * That track used to reach `new OfflineAudioContext(ch, 0, rate)`, which throws + * — and the error travels past the mixer's per-track failure collector, so one + * mis-set `data-media-start` took the whole render down. + */ +describe("an empty track", () => { + it("is handed back untouched rather than failing the render", async () => { + const input = join(dir, "empty.wav"); + writeWav(input, new Float32Array(0), SR); + const output = join(dir, "out.wav"); + + // Returns the input path, the same contract as a chain with nothing enabled + // — and without paying for a browser to decide it. + await expect( + applyAudioFxChain(input, chainOf("peaking"), output, { trackId: "t" }), + ).resolves.toBe(input); + expect(existsSync(output)).toBe(false); + }); +}); diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index 1d53156621..5e9f62c0d3 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -194,6 +194,14 @@ export async function applyAudioFxChain( const { samples, sampleRate, channels } = readWav(inputWav); const planes = deinterleave(samples, channels); + // An empty track has nothing to process — and an OfflineAudioContext of zero + // length throws, which is fatal for the WHOLE render rather than this track: + // the error travels past the mixer's per-track failure collector. ffmpeg + // writes an empty but structurally valid WAV whenever a clip's trim starts + // past the end of its source, so one mis-set `data-media-start` used to take + // the render down. Guarded here as well as in the runtime so an empty track + // never costs a browser. + if ((planes[0]?.length ?? 0) === 0) return inputWav; // Audio processing needs no GPU or special capture mode; a plain sandboxed // browser is enough, and the lease pool reuses one across tracks.