Skip to content

Commit 8278af0

Browse files
committed
fix(engine): degrade volume automation gracefully instead of dropping audio
Defense in depth on top of the keyframe bound: the volume envelope is folded into an FFmpeg `volume` expression whose evaluator limits are build-dependent, so a future edge case (an even stricter ffmpeg build, an escaping quirk) could still fail the mix. Rather than let that drop the audio track entirely, retry the mix once without the automation so the track renders at its base volume — a missing fade beats missing audio. The degradation is surfaced via MixResult. This mirrors how NLEs render automation (a sparse keyframe set with sample- accurate interpolation — which `volume=...:eval=frame` already provides), so a sample-level PCM rewrite would add a large new subsystem without changing the result for timeline-driven fades.
1 parent bff2da5 commit 8278af0

2 files changed

Lines changed: 113 additions & 36 deletions

File tree

packages/engine/src/services/audioMixer.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,64 @@ describe("processCompositionAudio", () => {
163163
expect(filter).toMatch(/volume=if\(lt\(t\\,[0-9.]+\)\\,0\+/);
164164
});
165165

166+
it("falls back to a static-volume mix instead of dropping audio when the automated mix fails", async () => {
167+
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
168+
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
169+
tempDirs.push(baseDir, workDir);
170+
171+
writeFileSync(join(baseDir, "bgm.wav"), "stub");
172+
173+
// Simulate an ffmpeg build that rejects the automation expression: the
174+
// first mix attempt fails, the static-volume retry succeeds. (prepare =
175+
// call 0, automated mix = call 1, fallback mix = call 2.)
176+
runFfmpegMock
177+
.mockImplementationOnce(async () => ({
178+
success: true,
179+
durationMs: 1,
180+
stderr: "",
181+
exitCode: 0,
182+
}))
183+
.mockImplementationOnce(async () => ({
184+
success: false,
185+
durationMs: 1,
186+
stderr: "Error initializing filters",
187+
exitCode: 234,
188+
}));
189+
190+
const result = await processCompositionAudio(
191+
[
192+
{
193+
id: "bgm",
194+
src: "bgm.wav",
195+
start: 0,
196+
end: 5,
197+
mediaStart: 0,
198+
layer: 0,
199+
volume: 0.8,
200+
volumeKeyframes: [
201+
{ time: 0, volume: 0.8 },
202+
{ time: 5, volume: 0 },
203+
],
204+
type: "audio",
205+
},
206+
],
207+
baseDir,
208+
workDir,
209+
join(baseDir, "out.m4a"),
210+
5,
211+
);
212+
213+
expect(result.success).toBe(true);
214+
expect(result.tracksProcessed).toBe(1);
215+
expect(runFfmpegMock).toHaveBeenCalledTimes(3);
216+
217+
// The fallback mix omits the automation expression (base volume only).
218+
const fallbackArgs = runFfmpegMock.mock.calls[2]?.[0];
219+
const fallbackFilter = fallbackArgs[fallbackArgs.indexOf("-filter_complex") + 1];
220+
expect(fallbackFilter).not.toContain(":eval=frame");
221+
expect(fallbackFilter).toContain("volume=0.8");
222+
});
223+
166224
it("prepares percent-encoded non-Latin audio srcs from decoded filesystem paths", async () => {
167225
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
168226
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));

packages/engine/src/services/audioMixer.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ function simplifyVolumeKeyframes(
103103
return sampled;
104104
}
105105

106-
function buildVolumeExpression(track: AudioTrack): string {
106+
function buildVolumeExpression(track: AudioTrack, ignoreKeyframes = false): string {
107107
const trimDuration = track.end - track.start;
108108
const staticVolume = clampVolume(track.volume);
109-
const keyframes = (track.volumeKeyframes ?? [])
109+
const keyframes = (ignoreKeyframes ? [] : (track.volumeKeyframes ?? []))
110110
.filter((keyframe) => Number.isFinite(keyframe.time) && Number.isFinite(keyframe.volume))
111111
.map((keyframe) => ({
112112
time: Math.max(0, Math.min(trimDuration, keyframe.time - track.start)),
@@ -377,42 +377,58 @@ async function mixAudioTracks(
377377
const outputDir = dirname(outputPath);
378378
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
379379

380-
const inputs: string[] = [];
381-
const filterParts: string[] = [];
382-
383-
tracks.forEach((track, i) => {
384-
inputs.push("-i", track.srcPath);
385-
const delayMs = Math.round(track.start * 1000);
386-
const trimDuration = track.end - track.start;
387-
const volumeFilter = buildVolumeExpression(track);
388-
filterParts.push(
389-
`[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
390-
);
391-
});
392-
393-
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
394-
const weights = tracks.map(() => "1").join(" ");
395-
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0:normalize=0:weights='${weights}'[mixed]`;
396-
const postMixGainFilter = `[mixed]volume=${masterOutputGain}[out]`;
397-
const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";");
380+
const buildArgs = (ignoreAutomation: boolean): string[] => {
381+
const inputs: string[] = [];
382+
const filterParts: string[] = [];
383+
tracks.forEach((track, i) => {
384+
inputs.push("-i", track.srcPath);
385+
const delayMs = Math.round(track.start * 1000);
386+
const trimDuration = track.end - track.start;
387+
const volumeFilter = buildVolumeExpression(track, ignoreAutomation);
388+
filterParts.push(
389+
`[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
390+
);
391+
});
398392

399-
const args = [
400-
...inputs,
401-
"-filter_complex",
402-
fullFilter,
403-
"-map",
404-
"[out]",
405-
"-acodec",
406-
"aac",
407-
"-b:a",
408-
"192k",
409-
"-t",
410-
String(totalDuration),
411-
"-y",
412-
outputPath,
413-
];
393+
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
394+
const weights = tracks.map(() => "1").join(" ");
395+
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0:normalize=0:weights='${weights}'[mixed]`;
396+
const postMixGainFilter = `[mixed]volume=${masterOutputGain}[out]`;
397+
const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";");
398+
399+
return [
400+
...inputs,
401+
"-filter_complex",
402+
fullFilter,
403+
"-map",
404+
"[out]",
405+
"-acodec",
406+
"aac",
407+
"-b:a",
408+
"192k",
409+
"-t",
410+
String(totalDuration),
411+
"-y",
412+
outputPath,
413+
];
414+
};
414415

415-
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
416+
let result = await runFfmpeg(buildArgs(false), { signal, timeout: ffmpegProcessTimeout });
417+
418+
// Defense in depth: volume automation is folded into an FFmpeg `volume`
419+
// expression whose evaluator limits are build-dependent (see
420+
// MAX_VOLUME_SEGMENTS). If that ever fails the mix, retry once without the
421+
// automation so the track renders at its base volume rather than being
422+
// dropped from the output entirely — a missing fade beats missing audio.
423+
let degradedAutomation = false;
424+
const hasAutomation = tracks.some((track) => (track.volumeKeyframes?.length ?? 0) > 0);
425+
if (!result.success && !signal?.aborted && hasAutomation) {
426+
const retry = await runFfmpeg(buildArgs(true), { signal, timeout: ffmpegProcessTimeout });
427+
if (retry.success) {
428+
result = retry;
429+
degradedAutomation = true;
430+
}
431+
}
416432

417433
if (signal?.aborted) {
418434
return {
@@ -438,6 +454,9 @@ async function mixAudioTracks(
438454
outputPath,
439455
durationMs: result.durationMs,
440456
tracksProcessed: tracks.length,
457+
error: degradedAutomation
458+
? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
459+
: undefined,
441460
};
442461
}
443462

0 commit comments

Comments
 (0)