// scripts/tts.mjs — 口播配音(provider: tts) // // ⚠️ 现状:平台暂无 TTS 通道。 // 但本地 edge-tts(若已安装)可直接使用 —— probe 会优先探测本地。 // 两条路都不可用时,明确抛出 ProviderUnavailableError + 三条替代方案。 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; import { probe as ffprobe } from '../lib/ffmpeg.mjs'; export async function synthesize(text, { outPath, voice } = {}) { const p = getProvider('tts'); const dir = paths.audio; mkdirSync(dir, { recursive: true }); const out = outPath || join(dir, 'voiceover.mp3'); const r = await p.call({ text, outPath: out, voice }); return r; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); if (flags.help || (!flags.text && !flags.from && !flags.probe)) { console.log(` fmode-mv tts — 口播配音(tts 通道) 用法: fmode-mv tts --probe # 探活(含本地 edge-tts 探测) fmode-mv tts --text "大家好,今天讲三件事。" --out work/audio/voiceover.mp3 fmode-mv tts --from work/script.md # 整个口播稿合成 选项: --text "..." 直接给文本 --from 口播稿文件(纯文本/md) --voice 音色(默认 zh-CN-XiaoxiaoNeural,edge-tts 音色名) --out 输出文件(默认 work/audio/voiceover.mp3) --probe 只探活 ⚠️ 现状:平台暂无 TTS 通道。替代方案: A) 本地 edge-tts(推荐,免费):pip install edge-tts 安装后本命令自动使用 B) 对接 OpenAI 兼容 /v1/audio/speech: export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech C) 自备配音音频放到 work/audio/voiceover.mp3,用 --from 跳过本步 `); return 0; } const p = getProvider('tts'); const pr = await p.probe({}); if (flags.probe) { log.step('TTS 通道探活'); log.key('状态', pr.available ? '可用' : '不可用'); log.key('提供方', pr.provider || '平台网关'); log.info(pr.detail); return pr.available ? 0 : 3; } if (!pr.available) { throw Object.assign(new Error(`TTS 通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' }); } ensureWork(); let text = flags.text ? String(flags.text) : null; if (!text && flags.from) { if (!existsSync(String(flags.from))) throw new Error(`文件不存在:${flags.from}`); text = readFileSync(String(flags.from), 'utf8') .replace(/^#{1,6}\s+/gm, '') .replace(/[*_`>]/g, '') .trim(); } if (!text) throw new Error('缺少文本:用 --text 或 --from'); log.step(`合成配音(${pr.provider || '网关'})`); log.key('字数', `${text.length} 字`); log.key('音色', flags.voice || p.defaultVoice()); const out = typeof flags.out === 'string' ? flags.out : join(paths.audio, 'voiceover.mp3'); const r = await synthesize(text, { outPath: out, voice: flags.voice }); const info = await ffprobe(r.outPath).catch(() => null); log.ok(`→ ${r.outPath}${info ? `(${info.duration.toFixed(1)}s)` : ''}`); writeJSON(join(paths.audio, 'voiceover.json'), { text, voice: r.voice, provider: r.provider, path: r.outPath, duration: info?.duration ?? null, _meta: { createdBy: 'fmode-mv/tts', generatedAt: new Date().toISOString() }, }); 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' || e?.hint) { reportUnavailable('tts', e); process.exit(3); } L.err(e.message); process.exit(1); }); } export { readJSON, writeFileSync };