cang-ying/engines/audio-stem-splitter.js
Guanghu Domestic Migration a2fa7d57d8 chore: import sanitized domestic snapshot for REPO-005
Source snapshot: 23037fa3a2e9b0597162735755e92fc763bf4d94
2026-07-17 15:55:48 +08:00

346 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光湖视频AI系统 · 音频分轨引擎
* D140 · 铸渊 ICE-GL-ZY001 · 2026-06-23
*
* 将混合音频分离为三轨: 对白(voice) / 背景音乐(bgm) / 音效(sfx)
* 基于 FFmpeg 音频滤波链 + 频率/动态范围分析
*
* 工作原理:
* 1. 对白轨: 人声频率范围(80Hz-8kHz)带通 + 动态范围压缩
* 2. BGM轨: 低频段(20-250Hz) + 高频段(8kHz+)的中低能量部分
* 3. SFX轨: 高频瞬态(打击类) + 非人声中频
*
* 输出三轨独立WAV供 video-editor.js 混音使用
*
* 使用方式:
* const { splitAudioStems, analyzeAudioLevels } = require('./audio-stem-splitter');
* const stems = await splitAudioStems('input.mp4', './stems/');
* // stems.voice, stems.bgm, stems.sfx → 独立WAV文件路径
*
* 铸渊 ICE-GL-ZY001 · D140
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// ==================== 核心分轨 ====================
/**
* 将音视频文件分离为三轨: 对白 / BGM / 音效
*
* @param {string} inputPath - 输入文件(视频或音频)
* @param {string} outputDir - 输出目录
* @param {object} [opts]
* @param {number} [opts.voiceLow=80] - 人声低频截止 Hz
* @param {number} [opts.voiceHigh=8000] - 人声高频截止 Hz
* @param {number} [opts.bgmLow=20] - BGM低频 Hz
* @param {number} [opts.bgmHigh=250] - BGM低频截止 Hz
* @param {number} [opts.sfxThreshold] - SFX瞬态检测阈值(dB)
* @returns {Promise<{voice: string, bgm: string, sfx: string, raw: string, meta: object}>}
*/
async function splitAudioStems(inputPath, outputDir, opts = {}) {
if (!fs.existsSync(inputPath)) {
throw new Error(`输入文件不存在: ${inputPath}`);
}
// 确保输出目录
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const baseName = path.basename(inputPath, path.extname(inputPath));
const rawWav = path.join(outputDir, `${baseName}_raw.wav`);
const voiceWav = path.join(outputDir, `${baseName}_voice.wav`);
const bgmWav = path.join(outputDir, `${baseName}_bgm.wav`);
const sfxWav = path.join(outputDir, `${baseName}_sfx.wav`);
const metaJson = path.join(outputDir, `${baseName}_stems_meta.json`);
console.log(`[StemSplitter] 输入: ${path.basename(inputPath)}`);
console.log(`[StemSplitter] 输出: ${outputDir}`);
// Step 1: 提取原始音频
console.log('[StemSplitter] 1/4 提取原始音频...');
execSync(
`ffmpeg -y -i "${inputPath}" -vn -acodec pcm_s16le -ar 48000 -ac 2 "${rawWav}" 2>/dev/null`,
{ timeout: 60000 }
);
if (!fs.existsSync(rawWav)) {
throw new Error('原始音频提取失败');
}
const duration = probeDuration(rawWav);
console.log(` 原始音频: ${duration.toFixed(1)}s`);
// Step 2: 分离对白轨(人声频段带通 + 动态增强)
console.log('[StemSplitter] 2/4 分离对白轨...');
extractVoiceTrack(rawWav, voiceWav, opts);
// Step 3: 分离BGM轨低频 + 宽频带中低能量)
console.log('[StemSplitter] 3/4 分离BGM轨...');
extractBgmTrack(rawWav, bgmWav, opts);
// Step 4: 分离SFX轨高频瞬态 + 残余中频)
console.log('[StemSplitter] 4/4 分离音效轨...');
extractSfxTrack(rawWav, sfxWav, voiceWav, bgmWav, opts);
// 生成元数据
const meta = {
input: inputPath,
duration: duration,
tracks: {
voice: { file: voiceWav, duration: probeDuration(voiceWav) },
bgm: { file: bgmWav, duration: probeDuration(bgmWav) },
sfx: { file: sfxWav, duration: probeDuration(sfxWav) },
},
createdAt: new Date().toISOString(),
};
fs.writeFileSync(metaJson, JSON.stringify(meta, null, 2));
console.log(`[StemSplitter] ✅ 分轨完成`);
console.log(` 对白: ${path.basename(voiceWav)} (${meta.tracks.voice.duration.toFixed(1)}s)`);
console.log(` BGM: ${path.basename(bgmWav)} (${meta.tracks.bgm.duration.toFixed(1)}s)`);
console.log(` 音效: ${path.basename(sfxWav)} (${meta.tracks.sfx.duration.toFixed(1)}s)`);
return {
voice: voiceWav,
bgm: bgmWav,
sfx: sfxWav,
raw: rawWav,
meta,
};
}
// ==================== 对白轨提取 ====================
function extractVoiceTrack(inputWav, outputWav, opts = {}) {
const voiceLow = opts.voiceLow || 80;
const voiceHigh = opts.voiceHigh || 8000;
// 滤波链:
// 1. highpass=f=voiceLow — 去除低频噪声
// 2. lowpass=f=voiceHigh — 限制人声高频范围
// 3. compand — 动态范围压缩,增强人声
// 4. afftdn=nr=10 — 降噪
const filter = [
`highpass=f=${voiceLow}`,
`lowpass=f=${voiceHigh}`,
`compand=attacks=0:points=-80/-90|-45/-15|-27/-9|0/-7|20/-7`,
`afftdn=nr=10:nf=-25`,
].join(',');
execSync(
`ffmpeg -y -i "${inputWav}" -af "${filter}" -acodec pcm_s16le -ar 48000 -ac 2 "${outputWav}" 2>/dev/null`,
{ timeout: 120000 }
);
if (!fs.existsSync(outputWav)) {
throw new Error('对白轨提取失败');
}
}
// ==================== BGM轨提取 ====================
function extractBgmTrack(inputWav, outputWav, opts = {}) {
const bgmLow = opts.bgmLow || 20;
const bgmHigh = opts.bgmHigh || 250;
// BGM特征: 低频持续 + 宽频带中低能量
// 滤波链:
// 1. lowpass=f=bgmHigh — 截取低频段(鼓/贝斯/低频氛围)
// 2. bass=g=3 — 增强低频
// 3. compand — 压缩动态范围使BGM更平稳
// 4. volume=0.7 — 稍微降低混音时通常BGM低于人声
const filter = [
`lowpass=f=${bgmHigh}`,
`bass=g=3:f=${bgmLow}:w=80`,
`compand=attacks=1:decays=1:points=-80/-90|-45/-20|0/-12|20/-12`,
`volume=0.7`,
].join(',');
execSync(
`ffmpeg -y -i "${inputWav}" -af "${filter}" -acodec pcm_s16le -ar 48000 -ac 2 "${outputWav}" 2>/dev/null`,
{ timeout: 120000 }
);
if (!fs.existsSync(outputWav)) {
throw new Error('BGM轨提取失败');
}
}
// ==================== SFX轨提取 ====================
function extractSfxTrack(inputWav, outputWav, voiceWav, bgmWav, opts = {}) {
// SFX = 原始音频 - 对白 - BGM
// 方法: 用 sidechaincompress 或直接用频段提取
//
// 滤波链:
// 1. highpass=f=2000 — 取高频段(打击/碰撞/环境音效多在高频)
// 2. 用原始音频减去voice和bgm的频段
// 实际实现: 取高频 + 中频瞬态部分
const filter = [
`highpass=f=1500`, // 高频段
`treble=g=2:f=4000`, // 高频增强
`compand=attacks=0:decays=0.3:points=-80/-90|-30/-30|0/-5|20/-5`, // 快速响应瞬态
`volume=0.8`,
`silenceremove=start_periods=0:start_threshold=-50dB:start_silence=0.1:stop_threshold=-50dB:stop_silence=0.05`,
].join(',');
execSync(
`ffmpeg -y -i "${inputWav}" -af "${filter}" -acodec pcm_s16le -ar 48000 -ac 2 "${outputWav}" 2>/dev/null`,
{ timeout: 120000 }
);
if (!fs.existsSync(outputWav)) {
throw new Error('音效轨提取失败');
}
}
// ==================== 音频电平分析 ====================
/**
* 分析音频文件的电平信息RMS、峰值、LUFS近似值
* 用于自动混音决策
*
* @param {string} audioPath
* @returns {{rms: number, peak: number, duration: number, channels: number}}
*/
function analyzeAudioLevels(audioPath) {
if (!fs.existsSync(audioPath)) {
throw new Error(`音频文件不存在: ${audioPath}`);
}
// 使用 ffprobe 获取音频信息
const probeOut = execSync(
`ffprobe -v quiet -show_entries format=duration:stream=channels,sample_rate -of json "${audioPath}"`,
{ encoding: 'utf8', timeout: 5000 }
);
const probeData = JSON.parse(probeOut);
const duration = parseFloat(probeData.format?.duration || 0);
const channels = probeData.streams?.[0]?.channels || 2;
// 使用 ffmpeg volumedetect 获取电平
const volOut = execSync(
`ffmpeg -i "${audioPath}" -af volumedetect -f null /dev/null 2>&1 | grep -E "mean_volume|max_volume"`,
{ encoding: 'utf8', timeout: 30000 }
);
const meanMatch = volOut.match(/mean_volume:\s*(-?\d+\.?\d*)\s*dB/);
const maxMatch = volOut.match(/max_volume:\s*(-?\d+\.?\d*)\s*dB/);
const rms = meanMatch ? parseFloat(meanMatch[1]) : -20;
const peak = maxMatch ? parseFloat(maxMatch[1]) : -3;
return {
rms, // RMS电平 (dB)
peak, // 峰值电平 (dB)
duration, // 时长 (秒)
channels, // 声道数
sampleRate: probeData.streams?.[0]?.sample_rate || 48000,
};
}
// ==================== 自动混音建议 ====================
/**
* 根据三轨电平分析,生成混音参数建议
*
* @param {string} voiceWav
* @param {string} bgmWav
* @param {string} sfxWav
* @returns {{voiceVolume: number, bgmVolume: number, sfxVolume: number, reasoning: string[]}}
*/
function suggestMixLevels(voiceWav, bgmWav, sfxWav) {
const reasoning = [];
// 分析各轨电平
const voiceLevel = analyzeAudioLevels(voiceWav);
const bgmLevel = bgmWav && fs.existsSync(bgmWav) ? analyzeAudioLevels(bgmWav) : null;
const sfxLevel = sfxWav && fs.existsSync(sfxWav) ? analyzeAudioLevels(sfxWav) : null;
// 目标: 对白 > BGM > SFX一般情况
// 对白 RMS 目标: -16 ~ -12 dB
// BGM RMS 目标: -24 ~ -20 dB (比对白低8dB左右)
// SFX RMS 目标: -20 ~ -15 dB
let voiceVolume = 1.0;
let bgmVolume = 0.4;
let sfxVolume = 0.6;
// 根据对白RMS调整
const voiceTarget = -14;
if (voiceLevel.rms < voiceTarget - 3) {
voiceVolume = Math.min(1.5, (voiceTarget - voiceLevel.rms) / 6 + 1.0);
reasoning.push(`对白RMS偏低(${voiceLevel.rms}dB),增益×${voiceVolume.toFixed(2)}`);
} else if (voiceLevel.rms > voiceTarget + 3) {
voiceVolume = Math.max(0.5, 1.0 - (voiceLevel.rms - voiceTarget) / 10);
reasoning.push(`对白RMS偏高(${voiceLevel.rms}dB),衰减×${voiceVolume.toFixed(2)}`);
}
// 根据BGM电平调整
if (bgmLevel) {
const bgmTarget = -22;
const bgmDiff = bgmLevel.rms - bgmTarget;
if (Math.abs(bgmDiff) > 3) {
bgmVolume = Math.max(0.15, Math.min(0.8, 0.4 * Math.pow(10, -bgmDiff / 20)));
reasoning.push(`BGM电平调整: RMS=${bgmLevel.rms}dB → 音量×${bgmVolume.toFixed(2)}`);
}
}
// 根据SFX电平调整
if (sfxLevel) {
const sfxTarget = -18;
const sfxDiff = sfxLevel.rms - sfxTarget;
if (Math.abs(sfxDiff) > 3) {
sfxVolume = Math.max(0.2, Math.min(1.0, 0.6 * Math.pow(10, -sfxDiff / 20)));
reasoning.push(`SFX电平调整: RMS=${sfxLevel.rms}dB → 音量×${sfxVolume.toFixed(2)}`);
}
}
// 确保对白比BGM高至少6dB
if (bgmLevel && voiceLevel.rms + 20 * Math.log10(voiceVolume) <
bgmLevel.rms + 20 * Math.log10(bgmVolume) + 6) {
bgmVolume *= 0.7;
reasoning.push(`对白-BGM间距不足6dBBGM再降×0.7`);
}
return {
voiceVolume: Math.round(voiceVolume * 100) / 100,
bgmVolume: Math.round(bgmVolume * 100) / 100,
sfxVolume: Math.round(sfxVolume * 100) / 100,
voiceLevel,
bgmLevel,
sfxLevel,
reasoning,
};
}
// ==================== 工具函数 ====================
function probeDuration(filePath) {
try {
const out = execSync(
`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`,
{ encoding: 'utf8', timeout: 5000 }
);
return parseFloat(out.trim()) || 0;
} catch (_) {
return 0;
}
}
// ==================== 导出 ====================
module.exports = {
splitAudioStems,
extractVoiceTrack,
extractBgmTrack,
extractSfxTrack,
analyzeAudioLevels,
suggestMixLevels,
probeDuration,
};