separate.mjs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. // scripts/separate.mjs — 人声 / 伴奏分离
  2. //
  3. // 两级策略:
  4. // 1) demucs(若已安装)—— 高质量,真·分离
  5. // 2) 纯 ffmpeg 兜底 —— 中置/侧信号相位处理,只能做「近似」,务必如实告知用户
  6. // · instrumental:L-R 侧信号,压低居中的人声
  7. // · vocals :中置信号 (L+R)/2,突出居中人声
  8. // 对立体声混音有效;单声道文件无法分离,会明确报错。
  9. import { existsSync, mkdirSync } from 'node:fs';
  10. import { join, basename } from 'node:path';
  11. import { parseArgs, paths, ensureWork, log } from '../lib/index.mjs';
  12. import {
  13. checkFfmpeg, checkDemucs, separateWithDemucs, separateByChannelPhase, probe, run, extractAudio, fileSize, humanSize,
  14. } from '../lib/ffmpeg.mjs';
  15. export async function separate(input, { outDir, method = 'auto', mode = 'both' } = {}) {
  16. checkFfmpeg();
  17. if (!existsSync(input)) throw new Error(`输入文件不存在:${input}`);
  18. const dir = outDir || paths.audio;
  19. mkdirSync(dir, { recursive: true });
  20. const info = await probe(input);
  21. const stem = basename(input).replace(/\.[^.]+$/, '');
  22. const demucs = checkDemucs();
  23. const useDemucs = method === 'demucs' || (method === 'auto' && demucs.available);
  24. if (method === 'demucs' && !demucs.available) {
  25. throw new Error('指定了 --method demucs,但未检测到 demucs。安装:pip install demucs(体积较大,非必需)');
  26. }
  27. if (useDemucs) {
  28. log.info(`使用 demucs 分离(高质量,可能耗时数分钟)…`);
  29. const out = await separateWithDemucs(input, dir);
  30. for (const [k, v] of Object.entries(out)) {
  31. if (existsSync(v)) log.ok(`${k}: ${v}(${humanSize(fileSize(v))})`);
  32. }
  33. return { method: 'demucs', ...out };
  34. }
  35. // ---- ffmpeg 兜底 ----
  36. log.warn('未检测到 demucs,使用 ffmpeg 相位兜底方案(近似分离,非真·分离)');
  37. if (!info.channels || info.channels < 2) {
  38. throw new Error(
  39. `输入是单声道(${info.channels || 1}ch),ffmpeg 相位法无法分离。\n` +
  40. ' 请改用立体声源,或安装 demucs:pip install demucs'
  41. );
  42. }
  43. const result = { method: 'ffmpeg-phase', input };
  44. let src = input;
  45. // 统一先转 wav,避免编码问题
  46. const wav = join(dir, `${stem}.src.wav`);
  47. await extractAudio(input, wav, { format: 'wav' });
  48. src = wav;
  49. if (mode === 'both' || mode === 'instrumental') {
  50. result.instrumental = await separateByChannelPhase(src, { outDir: dir, mode: 'instrumental' });
  51. log.ok(`伴奏(近似): ${result.instrumental}(${humanSize(fileSize(result.instrumental))})`);
  52. }
  53. if (mode === 'both' || mode === 'vocals') {
  54. result.vocals = await separateByChannelPhase(src, { outDir: dir, mode: 'vocals' });
  55. log.ok(`人声(近似): ${result.vocals}(${humanSize(fileSize(result.vocals))})`);
  56. }
  57. result.note = 'ffmpeg 兜底分离为相位近似,人声/伴奏均可能残留串扰;正式出版建议用 demucs 或平台分离通道。';
  58. return result;
  59. }
  60. export async function main(argv = process.argv.slice(2)) {
  61. const { _, flags } = parseArgs(argv);
  62. const input = flags.input || _[0];
  63. if (flags.help || !input) {
  64. console.log(`
  65. fmode-mv separate — 人声 / 伴奏分离(本地)
  66. 用法:
  67. fmode-mv separate work/audio/music.mp3
  68. fmode-mv separate work/audio/music.mp3 --mode instrumental
  69. fmode-mv separate work/audio/music.mp3 --method demucs # 高质量(需已安装 demucs)
  70. 选项:
  71. --input <file> | 位置参数 输入音频
  72. --out <dir> 输出目录(默认 work/audio)
  73. --mode both|vocals|instrumental 默认 both
  74. --method auto|demucs|ffmpeg 默认 auto(有 demucs 用 demucs,否则 ffmpeg 兜底)
  75. 现状说明:
  76. · demucs 未安装时走 ffmpeg 相位法,属「近似分离」,仅对立体声混音有效;
  77. · 需要高质量分离请安装 demucs(可选,非本技能依赖)。
  78. `);
  79. return 0;
  80. }
  81. ensureWork();
  82. log.step(`音轨分离:${input}`);
  83. const dm = checkDemucs();
  84. log.key('demucs', dm.available ? `可用(${dm.version})` : '未安装 → 将用 ffmpeg 兜底');
  85. const r = await separate(String(input), {
  86. outDir: typeof flags.out === 'string' ? flags.out : undefined,
  87. mode: flags.mode || 'both',
  88. method: flags.method || 'auto',
  89. });
  90. if (r.note) log.warn(r.note);
  91. log.key('方式', r.method);
  92. return 0;
  93. }
  94. if (import.meta.url === `file://${process.argv[1]}`) {
  95. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  96. console.error(`✗ ${e.message}`);
  97. process.exit(1);
  98. });
  99. }
  100. export { run };