// scripts/separate.mjs — 人声 / 伴奏分离 // // 两级策略: // 1) demucs(若已安装)—— 高质量,真·分离 // 2) 纯 ffmpeg 兜底 —— 中置/侧信号相位处理,只能做「近似」,务必如实告知用户 // · instrumental:L-R 侧信号,压低居中的人声 // · vocals :中置信号 (L+R)/2,突出居中人声 // 对立体声混音有效;单声道文件无法分离,会明确报错。 import { existsSync, mkdirSync } from 'node:fs'; import { join, basename } from 'node:path'; import { parseArgs, paths, ensureWork, log } from '../lib/index.mjs'; import { checkFfmpeg, checkDemucs, separateWithDemucs, separateByChannelPhase, probe, run, extractAudio, fileSize, humanSize, } from '../lib/ffmpeg.mjs'; export async function separate(input, { outDir, method = 'auto', mode = 'both' } = {}) { checkFfmpeg(); if (!existsSync(input)) throw new Error(`输入文件不存在:${input}`); const dir = outDir || paths.audio; mkdirSync(dir, { recursive: true }); const info = await probe(input); const stem = basename(input).replace(/\.[^.]+$/, ''); const demucs = checkDemucs(); const useDemucs = method === 'demucs' || (method === 'auto' && demucs.available); if (method === 'demucs' && !demucs.available) { throw new Error('指定了 --method demucs,但未检测到 demucs。安装:pip install demucs(体积较大,非必需)'); } if (useDemucs) { log.info(`使用 demucs 分离(高质量,可能耗时数分钟)…`); const out = await separateWithDemucs(input, dir); for (const [k, v] of Object.entries(out)) { if (existsSync(v)) log.ok(`${k}: ${v}(${humanSize(fileSize(v))})`); } return { method: 'demucs', ...out }; } // ---- ffmpeg 兜底 ---- log.warn('未检测到 demucs,使用 ffmpeg 相位兜底方案(近似分离,非真·分离)'); if (!info.channels || info.channels < 2) { throw new Error( `输入是单声道(${info.channels || 1}ch),ffmpeg 相位法无法分离。\n` + ' 请改用立体声源,或安装 demucs:pip install demucs' ); } const result = { method: 'ffmpeg-phase', input }; let src = input; // 统一先转 wav,避免编码问题 const wav = join(dir, `${stem}.src.wav`); await extractAudio(input, wav, { format: 'wav' }); src = wav; if (mode === 'both' || mode === 'instrumental') { result.instrumental = await separateByChannelPhase(src, { outDir: dir, mode: 'instrumental' }); log.ok(`伴奏(近似): ${result.instrumental}(${humanSize(fileSize(result.instrumental))})`); } if (mode === 'both' || mode === 'vocals') { result.vocals = await separateByChannelPhase(src, { outDir: dir, mode: 'vocals' }); log.ok(`人声(近似): ${result.vocals}(${humanSize(fileSize(result.vocals))})`); } result.note = 'ffmpeg 兜底分离为相位近似,人声/伴奏均可能残留串扰;正式出版建议用 demucs 或平台分离通道。'; return result; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); const input = flags.input || _[0]; if (flags.help || !input) { console.log(` fmode-mv separate — 人声 / 伴奏分离(本地) 用法: fmode-mv separate work/audio/music.mp3 fmode-mv separate work/audio/music.mp3 --mode instrumental fmode-mv separate work/audio/music.mp3 --method demucs # 高质量(需已安装 demucs) 选项: --input | 位置参数 输入音频 --out 输出目录(默认 work/audio) --mode both|vocals|instrumental 默认 both --method auto|demucs|ffmpeg 默认 auto(有 demucs 用 demucs,否则 ffmpeg 兜底) 现状说明: · demucs 未安装时走 ffmpeg 相位法,属「近似分离」,仅对立体声混音有效; · 需要高质量分离请安装 demucs(可选,非本技能依赖)。 `); return 0; } ensureWork(); log.step(`音轨分离:${input}`); const dm = checkDemucs(); log.key('demucs', dm.available ? `可用(${dm.version})` : '未安装 → 将用 ffmpeg 兜底'); const r = await separate(String(input), { outDir: typeof flags.out === 'string' ? flags.out : undefined, mode: flags.mode || 'both', method: flags.method || 'auto', }); if (r.note) log.warn(r.note); log.key('方式', r.method); return 0; } if (import.meta.url === `file://${process.argv[1]}`) { main().then((c) => process.exit(Number(c) || 0)).catch((e) => { console.error(`✗ ${e.message}`); process.exit(1); }); } export { run };