tts.mjs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. // scripts/tts.mjs — 口播配音(provider: tts)
  2. //
  3. // ⚠️ 现状:平台暂无 TTS 通道。
  4. // 但本地 edge-tts(若已安装)可直接使用 —— probe 会优先探测本地。
  5. // 两条路都不可用时,明确抛出 ProviderUnavailableError + 三条替代方案。
  6. import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
  7. import { join } from 'node:path';
  8. import {
  9. parseArgs, readJSON, writeJSON, paths, ensureWork, log,
  10. } from '../lib/index.mjs';
  11. import { getProvider } from '../lib/providers.mjs';
  12. import { probe as ffprobe } from '../lib/ffmpeg.mjs';
  13. export async function synthesize(text, { outPath, voice } = {}) {
  14. const p = getProvider('tts');
  15. const dir = paths.audio;
  16. mkdirSync(dir, { recursive: true });
  17. const out = outPath || join(dir, 'voiceover.mp3');
  18. const r = await p.call({ text, outPath: out, voice });
  19. return r;
  20. }
  21. export async function main(argv = process.argv.slice(2)) {
  22. const { _, flags } = parseArgs(argv);
  23. if (flags.help || (!flags.text && !flags.from && !flags.probe)) {
  24. console.log(`
  25. fmode-mv tts — 口播配音(tts 通道)
  26. 用法:
  27. fmode-mv tts --probe # 探活(含本地 edge-tts 探测)
  28. fmode-mv tts --text "大家好,今天讲三件事。" --out work/audio/voiceover.mp3
  29. fmode-mv tts --from work/script.md # 整个口播稿合成
  30. 选项:
  31. --text "..." 直接给文本
  32. --from <file> 口播稿文件(纯文本/md)
  33. --voice <name> 音色(默认 zh-CN-XiaoxiaoNeural,edge-tts 音色名)
  34. --out <file> 输出文件(默认 work/audio/voiceover.mp3)
  35. --probe 只探活
  36. ⚠️ 现状:平台暂无 TTS 通道。替代方案:
  37. A) 本地 edge-tts(推荐,免费):pip install edge-tts 安装后本命令自动使用
  38. B) 对接 OpenAI 兼容 /v1/audio/speech:
  39. export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech
  40. C) 自备配音音频放到 work/audio/voiceover.mp3,用 --from 跳过本步
  41. `);
  42. return 0;
  43. }
  44. const p = getProvider('tts');
  45. const pr = await p.probe({});
  46. if (flags.probe) {
  47. log.step('TTS 通道探活');
  48. log.key('状态', pr.available ? '可用' : '不可用');
  49. log.key('提供方', pr.provider || '平台网关');
  50. log.info(pr.detail);
  51. return pr.available ? 0 : 3;
  52. }
  53. if (!pr.available) {
  54. throw Object.assign(new Error(`TTS 通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' });
  55. }
  56. ensureWork();
  57. let text = flags.text ? String(flags.text) : null;
  58. if (!text && flags.from) {
  59. if (!existsSync(String(flags.from))) throw new Error(`文件不存在:${flags.from}`);
  60. text = readFileSync(String(flags.from), 'utf8')
  61. .replace(/^#{1,6}\s+/gm, '')
  62. .replace(/[*_`>]/g, '')
  63. .trim();
  64. }
  65. if (!text) throw new Error('缺少文本:用 --text 或 --from');
  66. log.step(`合成配音(${pr.provider || '网关'})`);
  67. log.key('字数', `${text.length} 字`);
  68. log.key('音色', flags.voice || p.defaultVoice());
  69. const out = typeof flags.out === 'string' ? flags.out : join(paths.audio, 'voiceover.mp3');
  70. const r = await synthesize(text, { outPath: out, voice: flags.voice });
  71. const info = await ffprobe(r.outPath).catch(() => null);
  72. log.ok(`→ ${r.outPath}${info ? `(${info.duration.toFixed(1)}s)` : ''}`);
  73. writeJSON(join(paths.audio, 'voiceover.json'), {
  74. text,
  75. voice: r.voice,
  76. provider: r.provider,
  77. path: r.outPath,
  78. duration: info?.duration ?? null,
  79. _meta: { createdBy: 'fmode-mv/tts', generatedAt: new Date().toISOString() },
  80. });
  81. return 0;
  82. }
  83. if (import.meta.url === `file://${process.argv[1]}`) {
  84. main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
  85. const { reportUnavailable, log: L } = await import('../lib/index.mjs');
  86. if (e?.name === 'ProviderUnavailableError' || e?.hint) {
  87. reportUnavailable('tts', e);
  88. process.exit(3);
  89. }
  90. L.err(e.message);
  91. process.exit(1);
  92. });
  93. }
  94. export { readJSON, writeFileSync };