| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- #!/usr/bin/env node
- /**
- * gen-edge-tts-doc.js — 从 `edge-tts --list-voices` 输出生成完整 TTS 语言/音色文档
- *
- * Usage:
- * edge-tts --list-voices > /tmp/voices.txt
- * node gen-edge-tts-doc.js /tmp/voices.txt > docs/tts-edge-languages.md
- *
- * 输出:Markdown 表格 + 中文方言分类 + 完整 75 语言列表
- */
- const fs = require('fs');
- const path = require('path');
- const inputFile = process.argv[2];
- if (!inputFile) {
- console.error('Usage: node gen-edge-tts-doc.js <voices.txt>');
- process.exit(1);
- }
- const raw = fs.readFileSync(inputFile, 'utf8');
- const lines = raw.split('\n').filter(Boolean);
- // 头两行是表头 + 分隔线,数据从第 3 行开始
- const dataLines = lines.slice(2);
- // 解析每行:ShortName \t Gender \t ContentCategories \t VoicePersonalities
- const voices = [];
- for (const line of dataLines) {
- const cols = line.trim().split(/\s{2,}/);
- if (cols.length < 2) continue;
- const shortName = cols[0];
- // ShortName 形如 zh-CN-XiaoxiaoNeural → locale=zh-CN, name=XiaoxiaoNeural, lang=zh
- const m = shortName.match(/^([a-z]{2,3})(?:[-]([A-Z]{2}))?[-]([A-Za-z]+)$/);
- if (!m) continue;
- const [, lang, region, name] = m;
- const locale = region ? `${lang}-${region}` : lang;
- voices.push({
- shortName,
- lang,
- region: region || '',
- locale,
- name,
- gender: cols[1] || '',
- contentCategories: cols[2] || '',
- personalities: cols[3] || '',
- });
- }
- // 按 locale 分组
- const byLocale = new Map();
- for (const v of voices) {
- if (!byLocale.has(v.locale)) byLocale.set(v.locale, []);
- byLocale.get(v.locale).push(v);
- }
- // 按语言聚合统计
- const langNames = {
- af: '南非荷兰语 Afrikaans', ar: '阿拉伯语 Arabic', am: '阿姆哈拉语 Amharic',
- az: '阿塞拜疆语 Azerbaijani', bg: '保加利亚语 Bulgarian', bn: '孟加拉语 Bengali',
- bs: '波斯尼亚语 Bosnian', ca: '加泰罗尼亚语 Catalan', cs: '捷克语 Czech',
- cy: '威尔士语 Welsh', da: '丹麦语 Danish', de: '德语 German',
- el: '希腊语 Greek', en: '英语 English', es: '西班牙语 Spanish',
- et: '爱沙尼亚语 Estonian', fa: '波斯语 Persian', fi: '芬兰语 Finnish',
- fil: '菲律宾语 Filipino', fr: '法语 French', ga: '爱尔兰语 Irish',
- gl: '加利西亚语 Galician', gu: '古吉拉特语 Gujarati', he: '希伯来语 Hebrew',
- hi: '印地语 Hindi', hr: '克罗地亚语 Croatian', hu: '匈牙利语 Hungarian',
- id: '印度尼西亚语 Indonesian', is: '冰岛语 Icelandic', it: '意大利语 Italian',
- iu: '因纽特语 Inuktitut', ja: '日语 Japanese', jv: '爪哇语 Javanese',
- ka: '格鲁吉亚语 Georgian', kk: '哈萨克语 Kazakh', km: '高棉语 Khmer',
- kn: '卡纳达语 Kannada', ko: '韩语 Korean', lo: '老挝语 Lao',
- lt: '立陶宛语 Lithuanian', lv: '拉脱维亚语 Latvian', mk: '马其顿语 Macedonian',
- ml: '马拉雅拉姆语 Malayalam', mn: '蒙古语 Mongolian', mr: '马拉地语 Marathi',
- ms: '马来语 Malay', mt: '马耳他语 Maltese', my: '缅甸语 Burmese',
- nb: '挪威语(书面) Norwegian Bokmål', ne: '尼泊尔语 Nepali', nl: '荷兰语 Dutch',
- pl: '波兰语 Polish', ps: '普什图语 Pashto', pt: '葡萄牙语 Portuguese',
- ro: '罗马尼亚语 Romanian', ru: '俄语 Russian', si: '僧伽罗语 Sinhala',
- sk: '斯洛伐克语 Slovak', sl: '斯洛文尼亚语 Slovenian', so: '索马里语 Somali',
- sq: '阿尔巴尼亚语 Albanian', sr: '塞尔维亚语 Serbian', su: '巽他语 Sundanese',
- sv: '瑞典语 Swedish', sw: '斯瓦希里语 Swahili', ta: '泰米尔语 Tamil',
- te: '泰卢固语 Telugu', th: '泰语 Thai', tr: '土耳其语 Turkish',
- uk: '乌克兰语 Ukrainian', ur: '乌尔都语 Urdu', uz: '乌兹别克语 Uzbek',
- vi: '越南语 Vietnamese', zh: '中文 Chinese', zu: '祖鲁语 Zulu',
- };
- const localesByLang = new Map();
- for (const [locale, arr] of byLocale.entries()) {
- const lang = arr[0].lang;
- if (!localesByLang.has(lang)) localesByLang.set(lang, []);
- localesByLang.get(lang).push(locale);
- }
- for (const [, arr] of localesByLang) arr.sort();
- // 中文方言单独分类
- const zhDialects = {
- 'zh-CN': '普通话(中国大陆)',
- 'zh-HK': '粤语(香港)',
- 'zh-TW': '国语(台湾)',
- 'zh-CN-sichuan': '四川话',
- 'zh-CN-liaoning': '东北话',
- 'zh-CN-shaanxi': '陕西方言',
- };
- const md = [];
- md.push('# Edge TTS 支持的语言 / 音色清单');
- md.push('');
- md.push(`> 自动生成自 edge-tts 7.2.8 (--list-voices)。共 **${voices.length} 个音色**,覆盖 **${localesByLang.size} 种语言**。`);
- md.push('');
- md.push('**项目当前使用:** `zh-CN-XiaoxiaoNeural`(中文温柔女声,在 `server/src/config/models.json` 的 `tts.defaultVoice` 配置)。');
- md.push('');
- md.push('**特性:** Edge TTS 是 Microsoft Edge 浏览器的"大声朗读"引擎,基于 Azure 神经网络,**完全免费**、**免 API Key**、单次最长 ~3000 字符、中文安全 1000 字。');
- md.push('');
- // ========== 中文方言(单独高亮) ==========
- md.push('## 中文方言(项目最常用)');
- md.push('');
- md.push('| Locale | 方言 | 音色数 | 常用音色 |');
- md.push('|--------|------|--------|---------|');
- for (const [locale, name] of Object.entries(zhDialects)) {
- const arr = byLocale.get(locale) || [];
- const sampleNames = arr.map(v => v.name).slice(0, 4).join(', ');
- md.push(`| ${locale} | ${name} | ${arr.length} | ${sampleNames}${arr.length > 4 ? '...' : ''} |`);
- }
- md.push('');
- // ========== 完整中文音色列表 ==========
- md.push('### 全部中文音色(14 个)');
- md.push('');
- md.push('| ShortName | 性别 | 风格 |');
- md.push('|-----------|------|------|');
- const zhVoices = voices.filter(v => v.lang === 'zh').sort((a, b) => a.shortName.localeCompare(b.shortName));
- for (const v of zhVoices) {
- md.push(`| \`${v.shortName}\` | ${v.gender} | ${v.personalities || v.contentCategories} |`);
- }
- md.push('');
- // ========== 75 种语言汇总 ==========
- md.push(`## 全部 ${localesByLang.size} 种语言汇总`);
- md.push('');
- md.push('按字母排序,展示每个 Locale 的音色数:');
- md.push('');
- md.push('| Locale | 语言 | 音色数 | 主要音色 |');
- md.push('|--------|------|--------|---------|');
- const sortedLangs = [...localesByLang.keys()].sort();
- for (const lang of sortedLangs) {
- const locales = localesByLang.get(lang);
- const langName = langNames[lang] || lang;
- for (const locale of locales) {
- const arr = byLocale.get(locale);
- const sampleNames = arr.map(v => v.name).slice(0, 3).join(', ');
- md.push(`| ${locale} | ${langName} | ${arr.length} | ${sampleNames}${arr.length > 3 ? '...' : ''} |`);
- }
- }
- md.push('');
- // ========== 使用示例 ==========
- md.push('## 切换音色 / 语言');
- md.push('');
- md.push('### 命令行测试');
- md.push('');
- md.push('```bash');
- md.push('# 中文女声(默认)');
- md.push('edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好世界" --write-media out.mp3');
- md.push('');
- md.push('# 粤语女声');
- md.push('edge-tts --voice zh-HK-HiuMaanNeural --text "你好,這是粵語測試" --write-media cantonese.mp3');
- md.push('');
- md.push('# 四川话');
- md.push('edge-tts --voice zh-CN-sichuan-YunxiNeural --text "今天天气好巴适" --write-media sc.mp3');
- md.push('');
- md.push('# 英语男声');
- md.push('edge-tts --voice en-US-GuyNeural --text "Hello, world." --write-media en.mp3');
- md.push('');
- md.push('# 日语女声');
- md.push('edge-tts --voice ja-JP-NanamiNeural --text "こんにちは" --write-media ja.mp3');
- md.push('');
- md.push('# 一次性列出全部语音');
- md.push('edge-tts --list-voices > voices.txt');
- md.push('```');
- md.push('');
- md.push('### 项目内修改默认音色');
- md.push('');
- md.push('编辑 `server/src/config/models.json`:');
- md.push('');
- md.push('```json');
- md.push('{');
- md.push(' "tts": {');
- md.push(' "defaultVendor": "edge",');
- md.push(' "defaultModel": "edge-tts",');
- md.push(' "defaultVoice": "zh-CN-XiaoxiaoNeural" // ← 改这里');
- md.push(' }');
- md.push('}');
- md.push('```');
- md.push('');
- md.push('或者环境变量覆盖(在 `server/.env`):');
- md.push('');
- md.push('```bash');
- md.push('EDGE_TTS_VOICE=zh-HK-HiuMaanNeural');
- md.push('```');
- md.push('');
- md.push('### API 调用时按音色生成');
- md.push('');
- md.push('前端 → 后端 `/api/tts/generate` 接口传 `voiceId` 字段:');
- md.push('');
- md.push('```json');
- md.push('{');
- md.push(' "text": "你好世界",');
- md.push(' "voiceId": "zh-CN-YunxiNeural"');
- md.push('}');
- md.push('```');
- md.push('');
- // ========== 前端统一音色 ID 映射 ==========
- md.push('## 前端统一音色 ID → 实际 Edge TTS 音色映射');
- md.push('');
- md.push('前端用 10 个统一 ID(`voice_01`~`voice_10`)展示,后端 `edge-tts.provider.ts:51-62` 的 `EDGE_VOICE_MAP` 把它们映射到实际 Edge 音色:');
- md.push('');
- md.push('| 前端 ID | 实际 Edge 音色 | 描述 |');
- md.push('|---------|----------------|------|');
- const map = [
- ['voice_01', 'zh-CN-XiaoxiaoNeural', '温柔女声'],
- ['voice_02', 'zh-CN-YunxiNeural', '磁性男声'],
- ['voice_03', 'zh-CN-XiaoyiNeural', '活泼女声'],
- ['voice_04', 'zh-CN-YunyangNeural', '知性女声(新闻)'],
- ['voice_05', 'zh-CN-YunjianNeural', '阳光男声'],
- ['voice_06', 'zh-CN-YunyangNeural', '沧桑男声'],
- ['voice_07', 'zh-CN-XiaoxiaoNeural', '甜美女声'],
- ['voice_08', 'zh-CN-YunxiNeural', '清朗男声'],
- ['voice_09', 'zh-CN-XiaoyiNeural', '亲切女声'],
- ['voice_10', 'zh-CN-XiaoshuangNeural', '稚嫩童声'],
- ];
- for (const [id, voice, desc] of map) {
- md.push(`| ${id} | \`${voice}\` | ${desc} |`);
- }
- md.push('');
- md.push('> 当前 10 个统一 ID 实际只用到 6 种 Edge 音色,语音方案日后续可补充粤语、英语等跨语言音色(增加 voiceId 11~20 等)。');
- md.push('');
- // ========== 已知限制 ==========
- md.push('## 已知限制');
- md.push('');
- md.push('- **文本长度:** 单次 ~3000 字符(中文约 1000 字),超出会被服务端拒绝。代码已自动按 1000 字分段拼接(`tts.service.ts` 的 `splitText`)。');
- md.push('- **语速控制:** `--rate` 参数是**后处理时间拉伸**,听感等同于播放器 `audio.playbackRate`(见 `docs/tts-speed-note.md` 或代码 commit dd1fea03)。');
- md.push('- **稳定性:** 依赖微软服务器,偶尔会限频(502/timeout),代码已加 retry + 多个 vendor fallback(`provider.registry.ts`)。');
- md.push('- **没商用授权:** 内部使用 OK,正式商用建议切阿里云 CosyVoice/Qwen-TTS。');
- md.push('');
- // ========== 维护 ==========
- md.push('## 如何更新本文档');
- md.push('');
- md.push('Edge TTS 升级可能新增/删除音色,重新生成:');
- md.push('');
- md.push('```bash');
- md.push('# 1. 服务器上导出最新列表');
- md.push('ssh root@8.159.134.106 "edge-tts --list-voices" > /tmp/voices.txt');
- md.push('');
- md.push('# 2. 本地跑脚本生成');
- md.push('node deploy-package/scripts/gen-edge-tts-doc.js /tmp/voices.txt > docs/tts-edge-languages.md');
- md.push('');
- md.push('# 3. 提交');
- md.push('git add docs/tts-edge-languages.md deploy-package/edge-voices-snapshot.txt');
- md.push('git commit -m "docs(tts): 更新 Edge TTS 音色清单"');
- md.push('```');
- md.push('');
- process.stdout.write(md.join('\n'));
|