fix(engine): preserve mono audio level (#2392)

This commit is contained in:
Miguel Ángel
2026-07-16 12:03:17 -04:00
committed by GitHub
parent e846cd6004
commit ed1f38124b
2 changed files with 97 additions and 3 deletions
@@ -0,0 +1,78 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { processCompositionAudio } from "./audioMixer.js";
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
const tempDirs: string[] = [];
function meanVolumeDb(path: string): number {
const result = spawnSync(
getFfmpegBinary(),
["-nostdin", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-"],
{ encoding: "utf-8" },
);
const match = result.stderr.match(/mean_volume:\s*(-?[\d.]+) dB/);
if (result.status !== 0 || !match?.[1]) {
throw new Error(`Could not measure mean volume: ${result.stderr}`);
}
return Number(match[1]);
}
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 () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-mono-level-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-mono-work-"));
tempDirs.push(projectDir, workDir);
const sourcePath = join(projectDir, "voice.wav");
const outputPath = join(projectDir, "audio.aac");
const setup = spawnSync(
getFfmpegBinary(),
[
"-nostdin",
"-v",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=1000:duration=1:sample_rate=48000",
"-ac",
"1",
"-c:a",
"pcm_s16le",
sourcePath,
],
{ encoding: "utf-8" },
);
expect(setup.status, setup.stderr).toBe(0);
const result = await processCompositionAudio(
[
{
id: "voice",
src: "voice.wav",
start: 0,
end: 1,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
projectDir,
workDir,
outputPath,
1,
);
expect(result.success).toBe(true);
expect(meanVolumeDb(outputPath) - meanVolumeDb(sourcePath)).toBeGreaterThan(-0.3);
});
});
+19 -3
View File
@@ -61,6 +61,21 @@ const MAX_VOLUME_SEGMENTS = 32;
*/
const VOLUME_SIMPLIFY_EPSILON = 0.005;
// `-ac 2` uses FFmpeg's default mono-to-stereo rematrix, which attenuates a
// mono source by 3 dB. Explicitly map front-center into both stereo channels;
// native stereo sources have FL/FR and pass through unchanged.
const STEREO_CHANNEL_FILTER = "pan=stereo|FL=FL+FC|FR=FR+FC";
async function stereoOutputArgs(srcPath: string): Promise<string[]> {
try {
const { channels } = await extractAudioMetadata(srcPath);
if (channels === 1) return ["-af", STEREO_CHANNEL_FILTER];
} catch {
// Preserve the previous FFmpeg conversion path when metadata probing fails.
}
return ["-ac", "2"];
}
/**
* Reduce a sorted keyframe list to a perceptually-equivalent piecewise-linear
* envelope with a bounded segment count.
@@ -245,7 +260,8 @@ async function extractAudioFromVideo(
const args: string[] = ["-i", videoPath];
if (options?.startTime !== undefined) args.push("-ss", String(options.startTime));
if (options?.duration !== undefined) args.push("-t", String(options.duration));
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", "-ac", "2", "-y", outputPath);
const channelArgs = await stereoOutputArgs(videoPath);
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...channelArgs, "-y", outputPath);
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
@@ -280,6 +296,7 @@ async function prepareAudioTrack(
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const channelArgs = await stereoOutputArgs(srcPath);
const args = [
"-ss",
@@ -292,8 +309,7 @@ async function prepareAudioTrack(
"pcm_s16le",
"-ar",
"48000",
"-ac",
"2",
...channelArgs,
"-y",
outputPath,
];