From ed1f38124b68b9ddecee70485330269054df153e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 16 Jul 2026 12:03:17 -0400 Subject: [PATCH] fix(engine): preserve mono audio level (#2392) --- .../src/services/audioMixer.level.test.ts | 78 +++++++++++++++++++ packages/engine/src/services/audioMixer.ts | 22 +++++- 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/engine/src/services/audioMixer.level.test.ts diff --git a/packages/engine/src/services/audioMixer.level.test.ts b/packages/engine/src/services/audioMixer.level.test.ts new file mode 100644 index 000000000..39395543b --- /dev/null +++ b/packages/engine/src/services/audioMixer.level.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 4f2122598..b3b218866 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -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 { + 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, ];