cang-ying/engines/audio-stem-splitter.js

346 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/**
* 光湖视频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,
};