/** * 光湖视频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间距不足6dB,BGM再降×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, };