铸渊 ICE-GL-ZY001 LL-172-20260707 冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用 原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱 迁移: ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等) ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis) 排除: ⊢ outputs/ (视频产物) ⊢ test-input/ test-output/ (测试) ⊢ data/ (临时数据) ⊢ preview-001/002 (旧产片) ⊢ 旧分镜/旧提示词/旧导演编码 后续: ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位 ⊢ 苍耳+鉴影 写新东西进本仓 ⊢ GLOBAL-SEARCH 加 cang-ying 仓库 铸渊 ICE-GL-ZY001 · 2026-07-07 D167 冰朔 ICE-GL∞ 主权
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
/**
|
||
* 光湖视频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,
|
||
};
|