gen-edge-tts-doc.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env node
  2. /**
  3. * gen-edge-tts-doc.js — 从 `edge-tts --list-voices` 输出生成完整 TTS 语言/音色文档
  4. *
  5. * Usage:
  6. * edge-tts --list-voices > /tmp/voices.txt
  7. * node gen-edge-tts-doc.js /tmp/voices.txt > docs/tts-edge-languages.md
  8. *
  9. * 输出:Markdown 表格 + 中文方言分类 + 完整 75 语言列表
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. const inputFile = process.argv[2];
  14. if (!inputFile) {
  15. console.error('Usage: node gen-edge-tts-doc.js <voices.txt>');
  16. process.exit(1);
  17. }
  18. const raw = fs.readFileSync(inputFile, 'utf8');
  19. const lines = raw.split('\n').filter(Boolean);
  20. // 头两行是表头 + 分隔线,数据从第 3 行开始
  21. const dataLines = lines.slice(2);
  22. // 解析每行:ShortName \t Gender \t ContentCategories \t VoicePersonalities
  23. const voices = [];
  24. for (const line of dataLines) {
  25. const cols = line.trim().split(/\s{2,}/);
  26. if (cols.length < 2) continue;
  27. const shortName = cols[0];
  28. // ShortName 形如 zh-CN-XiaoxiaoNeural → locale=zh-CN, name=XiaoxiaoNeural, lang=zh
  29. const m = shortName.match(/^([a-z]{2,3})(?:[-]([A-Z]{2}))?[-]([A-Za-z]+)$/);
  30. if (!m) continue;
  31. const [, lang, region, name] = m;
  32. const locale = region ? `${lang}-${region}` : lang;
  33. voices.push({
  34. shortName,
  35. lang,
  36. region: region || '',
  37. locale,
  38. name,
  39. gender: cols[1] || '',
  40. contentCategories: cols[2] || '',
  41. personalities: cols[3] || '',
  42. });
  43. }
  44. // 按 locale 分组
  45. const byLocale = new Map();
  46. for (const v of voices) {
  47. if (!byLocale.has(v.locale)) byLocale.set(v.locale, []);
  48. byLocale.get(v.locale).push(v);
  49. }
  50. // 按语言聚合统计
  51. const langNames = {
  52. af: '南非荷兰语 Afrikaans', ar: '阿拉伯语 Arabic', am: '阿姆哈拉语 Amharic',
  53. az: '阿塞拜疆语 Azerbaijani', bg: '保加利亚语 Bulgarian', bn: '孟加拉语 Bengali',
  54. bs: '波斯尼亚语 Bosnian', ca: '加泰罗尼亚语 Catalan', cs: '捷克语 Czech',
  55. cy: '威尔士语 Welsh', da: '丹麦语 Danish', de: '德语 German',
  56. el: '希腊语 Greek', en: '英语 English', es: '西班牙语 Spanish',
  57. et: '爱沙尼亚语 Estonian', fa: '波斯语 Persian', fi: '芬兰语 Finnish',
  58. fil: '菲律宾语 Filipino', fr: '法语 French', ga: '爱尔兰语 Irish',
  59. gl: '加利西亚语 Galician', gu: '古吉拉特语 Gujarati', he: '希伯来语 Hebrew',
  60. hi: '印地语 Hindi', hr: '克罗地亚语 Croatian', hu: '匈牙利语 Hungarian',
  61. id: '印度尼西亚语 Indonesian', is: '冰岛语 Icelandic', it: '意大利语 Italian',
  62. iu: '因纽特语 Inuktitut', ja: '日语 Japanese', jv: '爪哇语 Javanese',
  63. ka: '格鲁吉亚语 Georgian', kk: '哈萨克语 Kazakh', km: '高棉语 Khmer',
  64. kn: '卡纳达语 Kannada', ko: '韩语 Korean', lo: '老挝语 Lao',
  65. lt: '立陶宛语 Lithuanian', lv: '拉脱维亚语 Latvian', mk: '马其顿语 Macedonian',
  66. ml: '马拉雅拉姆语 Malayalam', mn: '蒙古语 Mongolian', mr: '马拉地语 Marathi',
  67. ms: '马来语 Malay', mt: '马耳他语 Maltese', my: '缅甸语 Burmese',
  68. nb: '挪威语(书面) Norwegian Bokmål', ne: '尼泊尔语 Nepali', nl: '荷兰语 Dutch',
  69. pl: '波兰语 Polish', ps: '普什图语 Pashto', pt: '葡萄牙语 Portuguese',
  70. ro: '罗马尼亚语 Romanian', ru: '俄语 Russian', si: '僧伽罗语 Sinhala',
  71. sk: '斯洛伐克语 Slovak', sl: '斯洛文尼亚语 Slovenian', so: '索马里语 Somali',
  72. sq: '阿尔巴尼亚语 Albanian', sr: '塞尔维亚语 Serbian', su: '巽他语 Sundanese',
  73. sv: '瑞典语 Swedish', sw: '斯瓦希里语 Swahili', ta: '泰米尔语 Tamil',
  74. te: '泰卢固语 Telugu', th: '泰语 Thai', tr: '土耳其语 Turkish',
  75. uk: '乌克兰语 Ukrainian', ur: '乌尔都语 Urdu', uz: '乌兹别克语 Uzbek',
  76. vi: '越南语 Vietnamese', zh: '中文 Chinese', zu: '祖鲁语 Zulu',
  77. };
  78. const localesByLang = new Map();
  79. for (const [locale, arr] of byLocale.entries()) {
  80. const lang = arr[0].lang;
  81. if (!localesByLang.has(lang)) localesByLang.set(lang, []);
  82. localesByLang.get(lang).push(locale);
  83. }
  84. for (const [, arr] of localesByLang) arr.sort();
  85. // 中文方言单独分类
  86. const zhDialects = {
  87. 'zh-CN': '普通话(中国大陆)',
  88. 'zh-HK': '粤语(香港)',
  89. 'zh-TW': '国语(台湾)',
  90. 'zh-CN-sichuan': '四川话',
  91. 'zh-CN-liaoning': '东北话',
  92. 'zh-CN-shaanxi': '陕西方言',
  93. };
  94. const md = [];
  95. md.push('# Edge TTS 支持的语言 / 音色清单');
  96. md.push('');
  97. md.push(`> 自动生成自 edge-tts 7.2.8 (--list-voices)。共 **${voices.length} 个音色**,覆盖 **${localesByLang.size} 种语言**。`);
  98. md.push('');
  99. md.push('**项目当前使用:** `zh-CN-XiaoxiaoNeural`(中文温柔女声,在 `server/src/config/models.json` 的 `tts.defaultVoice` 配置)。');
  100. md.push('');
  101. md.push('**特性:** Edge TTS 是 Microsoft Edge 浏览器的"大声朗读"引擎,基于 Azure 神经网络,**完全免费**、**免 API Key**、单次最长 ~3000 字符、中文安全 1000 字。');
  102. md.push('');
  103. // ========== 中文方言(单独高亮) ==========
  104. md.push('## 中文方言(项目最常用)');
  105. md.push('');
  106. md.push('| Locale | 方言 | 音色数 | 常用音色 |');
  107. md.push('|--------|------|--------|---------|');
  108. for (const [locale, name] of Object.entries(zhDialects)) {
  109. const arr = byLocale.get(locale) || [];
  110. const sampleNames = arr.map(v => v.name).slice(0, 4).join(', ');
  111. md.push(`| ${locale} | ${name} | ${arr.length} | ${sampleNames}${arr.length > 4 ? '...' : ''} |`);
  112. }
  113. md.push('');
  114. // ========== 完整中文音色列表 ==========
  115. md.push('### 全部中文音色(14 个)');
  116. md.push('');
  117. md.push('| ShortName | 性别 | 风格 |');
  118. md.push('|-----------|------|------|');
  119. const zhVoices = voices.filter(v => v.lang === 'zh').sort((a, b) => a.shortName.localeCompare(b.shortName));
  120. for (const v of zhVoices) {
  121. md.push(`| \`${v.shortName}\` | ${v.gender} | ${v.personalities || v.contentCategories} |`);
  122. }
  123. md.push('');
  124. // ========== 75 种语言汇总 ==========
  125. md.push(`## 全部 ${localesByLang.size} 种语言汇总`);
  126. md.push('');
  127. md.push('按字母排序,展示每个 Locale 的音色数:');
  128. md.push('');
  129. md.push('| Locale | 语言 | 音色数 | 主要音色 |');
  130. md.push('|--------|------|--------|---------|');
  131. const sortedLangs = [...localesByLang.keys()].sort();
  132. for (const lang of sortedLangs) {
  133. const locales = localesByLang.get(lang);
  134. const langName = langNames[lang] || lang;
  135. for (const locale of locales) {
  136. const arr = byLocale.get(locale);
  137. const sampleNames = arr.map(v => v.name).slice(0, 3).join(', ');
  138. md.push(`| ${locale} | ${langName} | ${arr.length} | ${sampleNames}${arr.length > 3 ? '...' : ''} |`);
  139. }
  140. }
  141. md.push('');
  142. // ========== 使用示例 ==========
  143. md.push('## 切换音色 / 语言');
  144. md.push('');
  145. md.push('### 命令行测试');
  146. md.push('');
  147. md.push('```bash');
  148. md.push('# 中文女声(默认)');
  149. md.push('edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好世界" --write-media out.mp3');
  150. md.push('');
  151. md.push('# 粤语女声');
  152. md.push('edge-tts --voice zh-HK-HiuMaanNeural --text "你好,這是粵語測試" --write-media cantonese.mp3');
  153. md.push('');
  154. md.push('# 四川话');
  155. md.push('edge-tts --voice zh-CN-sichuan-YunxiNeural --text "今天天气好巴适" --write-media sc.mp3');
  156. md.push('');
  157. md.push('# 英语男声');
  158. md.push('edge-tts --voice en-US-GuyNeural --text "Hello, world." --write-media en.mp3');
  159. md.push('');
  160. md.push('# 日语女声');
  161. md.push('edge-tts --voice ja-JP-NanamiNeural --text "こんにちは" --write-media ja.mp3');
  162. md.push('');
  163. md.push('# 一次性列出全部语音');
  164. md.push('edge-tts --list-voices > voices.txt');
  165. md.push('```');
  166. md.push('');
  167. md.push('### 项目内修改默认音色');
  168. md.push('');
  169. md.push('编辑 `server/src/config/models.json`:');
  170. md.push('');
  171. md.push('```json');
  172. md.push('{');
  173. md.push(' "tts": {');
  174. md.push(' "defaultVendor": "edge",');
  175. md.push(' "defaultModel": "edge-tts",');
  176. md.push(' "defaultVoice": "zh-CN-XiaoxiaoNeural" // ← 改这里');
  177. md.push(' }');
  178. md.push('}');
  179. md.push('```');
  180. md.push('');
  181. md.push('或者环境变量覆盖(在 `server/.env`):');
  182. md.push('');
  183. md.push('```bash');
  184. md.push('EDGE_TTS_VOICE=zh-HK-HiuMaanNeural');
  185. md.push('```');
  186. md.push('');
  187. md.push('### API 调用时按音色生成');
  188. md.push('');
  189. md.push('前端 → 后端 `/api/tts/generate` 接口传 `voiceId` 字段:');
  190. md.push('');
  191. md.push('```json');
  192. md.push('{');
  193. md.push(' "text": "你好世界",');
  194. md.push(' "voiceId": "zh-CN-YunxiNeural"');
  195. md.push('}');
  196. md.push('```');
  197. md.push('');
  198. // ========== 前端统一音色 ID 映射 ==========
  199. md.push('## 前端统一音色 ID → 实际 Edge TTS 音色映射');
  200. md.push('');
  201. md.push('前端用 10 个统一 ID(`voice_01`~`voice_10`)展示,后端 `edge-tts.provider.ts:51-62` 的 `EDGE_VOICE_MAP` 把它们映射到实际 Edge 音色:');
  202. md.push('');
  203. md.push('| 前端 ID | 实际 Edge 音色 | 描述 |');
  204. md.push('|---------|----------------|------|');
  205. const map = [
  206. ['voice_01', 'zh-CN-XiaoxiaoNeural', '温柔女声'],
  207. ['voice_02', 'zh-CN-YunxiNeural', '磁性男声'],
  208. ['voice_03', 'zh-CN-XiaoyiNeural', '活泼女声'],
  209. ['voice_04', 'zh-CN-YunyangNeural', '知性女声(新闻)'],
  210. ['voice_05', 'zh-CN-YunjianNeural', '阳光男声'],
  211. ['voice_06', 'zh-CN-YunyangNeural', '沧桑男声'],
  212. ['voice_07', 'zh-CN-XiaoxiaoNeural', '甜美女声'],
  213. ['voice_08', 'zh-CN-YunxiNeural', '清朗男声'],
  214. ['voice_09', 'zh-CN-XiaoyiNeural', '亲切女声'],
  215. ['voice_10', 'zh-CN-XiaoshuangNeural', '稚嫩童声'],
  216. ];
  217. for (const [id, voice, desc] of map) {
  218. md.push(`| ${id} | \`${voice}\` | ${desc} |`);
  219. }
  220. md.push('');
  221. md.push('> 当前 10 个统一 ID 实际只用到 6 种 Edge 音色,语音方案日后续可补充粤语、英语等跨语言音色(增加 voiceId 11~20 等)。');
  222. md.push('');
  223. // ========== 已知限制 ==========
  224. md.push('## 已知限制');
  225. md.push('');
  226. md.push('- **文本长度:** 单次 ~3000 字符(中文约 1000 字),超出会被服务端拒绝。代码已自动按 1000 字分段拼接(`tts.service.ts` 的 `splitText`)。');
  227. md.push('- **语速控制:** `--rate` 参数是**后处理时间拉伸**,听感等同于播放器 `audio.playbackRate`(见 `docs/tts-speed-note.md` 或代码 commit dd1fea03)。');
  228. md.push('- **稳定性:** 依赖微软服务器,偶尔会限频(502/timeout),代码已加 retry + 多个 vendor fallback(`provider.registry.ts`)。');
  229. md.push('- **没商用授权:** 内部使用 OK,正式商用建议切阿里云 CosyVoice/Qwen-TTS。');
  230. md.push('');
  231. // ========== 维护 ==========
  232. md.push('## 如何更新本文档');
  233. md.push('');
  234. md.push('Edge TTS 升级可能新增/删除音色,重新生成:');
  235. md.push('');
  236. md.push('```bash');
  237. md.push('# 1. 服务器上导出最新列表');
  238. md.push('ssh root@8.159.134.106 "edge-tts --list-voices" > /tmp/voices.txt');
  239. md.push('');
  240. md.push('# 2. 本地跑脚本生成');
  241. md.push('node deploy-package/scripts/gen-edge-tts-doc.js /tmp/voices.txt > docs/tts-edge-languages.md');
  242. md.push('');
  243. md.push('# 3. 提交');
  244. md.push('git add docs/tts-edge-languages.md deploy-package/edge-voices-snapshot.txt');
  245. md.push('git commit -m "docs(tts): 更新 Edge TTS 音色清单"');
  246. md.push('```');
  247. md.push('');
  248. process.stdout.write(md.join('\n'));