music.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. // scripts/music.mjs — AI 音乐生成(provider: music)
  2. //
  3. // ⚠️ 现状:平台 Suno 渠道未开通(实测 POST /suno/submit/music 返回 503 "Task request failed")。
  4. // 本脚本依然会「真试一次」——若平台后续开通,无需改代码即可跑通;
  5. // 失败则抛出带指引的 ProviderUnavailableError,绝不伪造音频文件。
  6. import { existsSync, writeFileSync } from 'node:fs';
  7. import { join } from 'node:path';
  8. import {
  9. parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, paths as P,
  10. } from '../lib/index.mjs';
  11. import { getProvider } from '../lib/providers.mjs';
  12. import { run } from '../lib/ffmpeg.mjs';
  13. /** 从 provider 返回结果里尽力提取音频 URL */
  14. function extractAudioUrls(result) {
  15. const urls = [];
  16. const walk = (o, depth = 0) => {
  17. if (!o || depth > 5) return;
  18. if (typeof o === 'string' && /^https?:\/\/.+\.(mp3|wav|m4a|flac)(\?.*)?$/i.test(o)) urls.push(o);
  19. else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1));
  20. else if (typeof o === 'object') {
  21. for (const [k, v] of Object.entries(o)) {
  22. if (/audio_url|stream_audio_url|source_audio_url|url|audio/i.test(k) && typeof v === 'string' && /^https?:/.test(v)) urls.push(v);
  23. else walk(v, depth + 1);
  24. }
  25. }
  26. };
  27. walk(result);
  28. return [...new Set(urls)];
  29. }
  30. export async function generateMusic({ prompt, lyrics, instrumental = false, title, outDir }) {
  31. const p = getProvider('music');
  32. ensureWork();
  33. const dir = outDir || P.audio;
  34. const { mkdirSync } = await import('node:fs');
  35. mkdirSync(dir, { recursive: true });
  36. log.info(`提交音乐生成(模型 ${p.defaultModel()})…`);
  37. const { taskId, result } = await p.call({ prompt, lyrics, instrumental, title });
  38. log.ok(`任务完成 taskId=${taskId}`);
  39. const urls = extractAudioUrls(result);
  40. if (!urls.length) {
  41. throw new Error(
  42. `音乐通道返回成功但未包含音频 URL。原始返回:\n${JSON.stringify(result).slice(0, 600)}`
  43. );
  44. }
  45. const files = [];
  46. for (let i = 0; i < urls.length; i++) {
  47. const buf = await download(urls[i]);
  48. const f = join(dir, urls.length > 1 ? `music-${i + 1}.mp3` : 'music.mp3');
  49. writeFileSync(f, buf);
  50. files.push({ path: f, url: urls[i], bytes: buf.length });
  51. log.ok(`已下载 ${f}(${Math.round(buf.length / 1024)}KB)`);
  52. }
  53. writeJSON(join(dir, 'music.json'), {
  54. taskId,
  55. title: title || '',
  56. model: p.defaultModel(),
  57. prompt,
  58. instrumental,
  59. files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
  60. raw: result,
  61. _meta: { createdBy: 'fmode-mv/music', generatedAt: new Date().toISOString() },
  62. });
  63. return { taskId, files, raw: result };
  64. }
  65. export async function main(argv = process.argv.slice(2)) {
  66. const { _, flags } = parseArgs(argv);
  67. if (flags.help || (!_.length && !flags.from)) {
  68. console.log(`
  69. fmode-mv music — AI 音乐生成(music 通道)
  70. 用法:
  71. fmode-mv music --from work/song.json # 用作曲填词的产物直接生成
  72. fmode-mv music "dreamy city pop, 92bpm, female vocal" --title "夜行"
  73. fmode-mv music --from work/song.json --instrumental # 只要伴奏
  74. 选项:
  75. --from <file> 歌曲 JSON(读取 stylePrompt / lyrics / title)
  76. --title "歌名"
  77. --instrumental 纯伴奏,不生成人声
  78. --model <name> 音乐模型版本(默认 chirp-v3-5)
  79. --out <dir> 输出目录(默认 work/audio)
  80. ⚠️ 现状:平台 Suno 渠道未开通。本命令会真实尝试一次;
  81. 若失败会明确告知如何切换通道或对接外部服务(不会伪造产物)。
  82. 用 fmode-mv providers 查看通道状态。
  83. `);
  84. return 0;
  85. }
  86. ensureWork();
  87. let prompt = _.join(' ').trim();
  88. let lyrics = null;
  89. let title = flags.title ? String(flags.title) : '';
  90. if (flags.from) {
  91. const song = readJSON(String(flags.from));
  92. if (!song) throw new Error(`未找到文件:${flags.from}`);
  93. prompt = prompt || song.stylePrompt || song.genre || '';
  94. lyrics = song.lyrics || null;
  95. title = title || song.title || '';
  96. log.step(`AI 音乐生成:《${title}》`);
  97. log.key('风格', prompt.slice(0, 80) + (prompt.length > 80 ? '…' : ''));
  98. log.key('歌词', `${String(lyrics || '').length} 字`);
  99. } else {
  100. log.step('AI 音乐生成');
  101. log.key('风格', prompt);
  102. }
  103. if (!prompt) throw new Error('缺少风格 prompt。用 --from work/song.json 或直接给风格描述。');
  104. const provider = getProvider('music');
  105. const probe = await provider.probe({ deep: false });
  106. if (probe.available === false) {
  107. log.warn(`通道探活:${probe.detail}`);
  108. }
  109. const res = await generateMusic({
  110. prompt,
  111. lyrics,
  112. instrumental: !!flags.instrumental,
  113. title,
  114. outDir: typeof flags.out === 'string' ? flags.out : undefined,
  115. });
  116. log.key('产物', res.files.map((f) => f.path).join(', '));
  117. return 0;
  118. }
  119. if (import.meta.url === `file://${process.argv[1]}`) {
  120. main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
  121. const { reportUnavailable, log: L } = await import('../lib/index.mjs');
  122. if (e?.name === 'ProviderUnavailableError') {
  123. reportUnavailable('music', e);
  124. process.exit(3);
  125. }
  126. L.err(e.message);
  127. process.exit(1);
  128. });
  129. }