mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(engine): preserve mono audio level (#2392)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user