// scripts/music.mjs — AI 音乐生成(provider: music) // // ⚠️ 现状:平台 Suno 渠道未开通(实测 POST /suno/submit/music 返回 503 "Task request failed")。 // 本脚本依然会「真试一次」——若平台后续开通,无需改代码即可跑通; // 失败则抛出带指引的 ProviderUnavailableError,绝不伪造音频文件。 import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, paths as P, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; import { run } from '../lib/ffmpeg.mjs'; /** 从 provider 返回结果里尽力提取音频 URL */ function extractAudioUrls(result) { const urls = []; const walk = (o, depth = 0) => { if (!o || depth > 5) return; if (typeof o === 'string' && /^https?:\/\/.+\.(mp3|wav|m4a|flac)(\?.*)?$/i.test(o)) urls.push(o); else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1)); else if (typeof o === 'object') { for (const [k, v] of Object.entries(o)) { if (/audio_url|stream_audio_url|source_audio_url|url|audio/i.test(k) && typeof v === 'string' && /^https?:/.test(v)) urls.push(v); else walk(v, depth + 1); } } }; walk(result); return [...new Set(urls)]; } export async function generateMusic({ prompt, lyrics, instrumental = false, title, outDir }) { const p = getProvider('music'); ensureWork(); const dir = outDir || P.audio; const { mkdirSync } = await import('node:fs'); mkdirSync(dir, { recursive: true }); log.info(`提交音乐生成(模型 ${p.defaultModel()})…`); const { taskId, result } = await p.call({ prompt, lyrics, instrumental, title }); log.ok(`任务完成 taskId=${taskId}`); const urls = extractAudioUrls(result); if (!urls.length) { throw new Error( `音乐通道返回成功但未包含音频 URL。原始返回:\n${JSON.stringify(result).slice(0, 600)}` ); } const files = []; for (let i = 0; i < urls.length; i++) { const buf = await download(urls[i]); const f = join(dir, urls.length > 1 ? `music-${i + 1}.mp3` : 'music.mp3'); writeFileSync(f, buf); files.push({ path: f, url: urls[i], bytes: buf.length }); log.ok(`已下载 ${f}(${Math.round(buf.length / 1024)}KB)`); } writeJSON(join(dir, 'music.json'), { taskId, title: title || '', model: p.defaultModel(), prompt, instrumental, files: files.map((f) => ({ path: f.path, bytes: f.bytes })), raw: result, _meta: { createdBy: 'fmode-mv/music', generatedAt: new Date().toISOString() }, }); return { taskId, files, raw: result }; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); if (flags.help || (!_.length && !flags.from)) { console.log(` fmode-mv music — AI 音乐生成(music 通道) 用法: fmode-mv music --from work/song.json # 用作曲填词的产物直接生成 fmode-mv music "dreamy city pop, 92bpm, female vocal" --title "夜行" fmode-mv music --from work/song.json --instrumental # 只要伴奏 选项: --from 歌曲 JSON(读取 stylePrompt / lyrics / title) --title "歌名" --instrumental 纯伴奏,不生成人声 --model 音乐模型版本(默认 chirp-v3-5) --out 输出目录(默认 work/audio) ⚠️ 现状:平台 Suno 渠道未开通。本命令会真实尝试一次; 若失败会明确告知如何切换通道或对接外部服务(不会伪造产物)。 用 fmode-mv providers 查看通道状态。 `); return 0; } ensureWork(); let prompt = _.join(' ').trim(); let lyrics = null; let title = flags.title ? String(flags.title) : ''; if (flags.from) { const song = readJSON(String(flags.from)); if (!song) throw new Error(`未找到文件:${flags.from}`); prompt = prompt || song.stylePrompt || song.genre || ''; lyrics = song.lyrics || null; title = title || song.title || ''; log.step(`AI 音乐生成:《${title}》`); log.key('风格', prompt.slice(0, 80) + (prompt.length > 80 ? '…' : '')); log.key('歌词', `${String(lyrics || '').length} 字`); } else { log.step('AI 音乐生成'); log.key('风格', prompt); } if (!prompt) throw new Error('缺少风格 prompt。用 --from work/song.json 或直接给风格描述。'); const provider = getProvider('music'); const probe = await provider.probe({ deep: false }); if (probe.available === false) { log.warn(`通道探活:${probe.detail}`); } const res = await generateMusic({ prompt, lyrics, instrumental: !!flags.instrumental, title, outDir: typeof flags.out === 'string' ? flags.out : undefined, }); log.key('产物', res.files.map((f) => f.path).join(', ')); return 0; } if (import.meta.url === `file://${process.argv[1]}`) { main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => { const { reportUnavailable, log: L } = await import('../lib/index.mjs'); if (e?.name === 'ProviderUnavailableError') { reportUnavailable('music', e); process.exit(3); } L.err(e.message); process.exit(1); }); }