build-lang-matrix-json.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. #!/usr/bin/env node
  2. /**
  3. * build-lang-matrix-json.js — 把 alicloud-voices-matrix.json 转成 LANG_ROUTE 路由表
  4. */
  5. const fs = require('fs');
  6. const os = require('os');
  7. const path = require('path');
  8. // ── 1. 规范化语言字符串 ────────────────────────────────────
  9. function parseSimpleLang(p) {
  10. if (!p) return null;
  11. p = p.trim();
  12. if (!p) return null;
  13. if (p === '中文' || p === '普通话') return { base: '中文', dialect: '普通话' };
  14. if (p === '日语') return { base: '日语', dialect: '通用' };
  15. if (p === '韩语') return { base: '韩语', dialect: '通用' };
  16. if (p === '印尼语') return { base: '印尼语', dialect: '通用' };
  17. if (p === '美语' || p === '美式英语' || p === '美式英文' || p === '英语(美式)') return { base: '英语', dialect: '美式' };
  18. if (p === '英式英语' || p === '英式英文' || p === '英语(英式)') return { base: '英语', dialect: '英式' };
  19. if (p === '英语') return { base: '英语', dialect: '通用' };
  20. // 中文方言裸词
  21. const DIALECT = {
  22. '广东话': '粤语', '粤语': '粤语',
  23. '闽南话': '闽南', '闽南': '闽南',
  24. '东北话': '东北', '东北口音': '东北', '东北': '东北',
  25. '四川话': '四川', '四川': '四川',
  26. '山东话': '山东', '山东': '山东',
  27. '河南话': '河南', '河南': '河南',
  28. '湖南话': '湖南', '湖南': '湖南',
  29. '陕西话': '陕西', '陕西': '陕西',
  30. '安徽话': '安徽', '安徽': '安徽',
  31. };
  32. if (DIALECT[p]) return { base: '中文', dialect: DIALECT[p] };
  33. return null;
  34. }
  35. // 用 regex 全局匹配 "{base}(dialect1、dialect2)" 或 "{base}" 这两种 base
  36. // 支持的全角/半角括号。注意 raw 里用的是 "中文" "英文" 不是 "英语"
  37. const LANGBASE_GROUP_RE = /(中文|英文|日语|韩语|印尼语)[\((]([^\))]+)[\))]/g;
  38. // 裸 base(不在 group 里的:纯 "英文"、"美式英语" 等)
  39. const LANGBASE_BARE_RE = /(中文|英文|日语|韩语|印尼语|普通话|中文东北口音|美式英语|英式英语|美式英文|英式英文)(?=[\s,,、]|$)/g;
  40. const DIALECT_SEP_GLOBAL = /[、,,]/g;
  41. function normalizeLangString(s) {
  42. if (!s) return [];
  43. const cleaned = s.replace(/\s+/g, ' ').trim();
  44. const result = [];
  45. const seen = new Set();
  46. // 1. 先匹配 "base(dialect、dialect)" 这种 group
  47. const groupMatches = [...cleaned.matchAll(LANGBASE_GROUP_RE)];
  48. for (const m of groupMatches) {
  49. const base = m[1];
  50. const dialectText = m[2];
  51. for (const d of dialectText.split(DIALECT_SEP_GLOBAL)) {
  52. const trimmed = d.trim();
  53. if (!trimmed) continue;
  54. const lang = dialectToLang(base, trimmed);
  55. const key = `${lang.base}-${lang.dialect}`;
  56. if (!seen.has(key)) { seen.add(key); result.push(lang); }
  57. }
  58. }
  59. // 2. 再匹配剩余的纯 base(可能紧跟 group)
  60. let stripped = cleaned;
  61. for (const m of groupMatches) stripped = stripped.replace(m[0], ' ');
  62. const bareMatches = [...stripped.matchAll(LANGBASE_BARE_RE)];
  63. for (const m of bareMatches) {
  64. const base = m[1];
  65. const lang = baseToLang(base);
  66. const key = `${lang.base}-${lang.dialect}`;
  67. if (!seen.has(key)) { seen.add(key); result.push(lang); }
  68. }
  69. return result;
  70. }
  71. // 方言标准名(支持 raw 里的所有写法)
  72. const DIALECT_ALIAS = {
  73. '广东话': '粤语',
  74. '广东': '粤语',
  75. '东北话': '东北',
  76. '东北': '东北',
  77. '东北口音': '东北',
  78. '四川话': '四川',
  79. '四川': '四川',
  80. '山东话': '山东',
  81. '山东': '山东',
  82. '河南话': '河南',
  83. '河南': '河南',
  84. '湖南话': '湖南',
  85. '湖南': '湖南',
  86. '陕西话': '陕西',
  87. '陕西': '陕西',
  88. '安徽话': '安徽',
  89. '安徽': '安徽',
  90. '闽南话': '闽南',
  91. '闽南': '闽南',
  92. '普通话': '普通话',
  93. };
  94. function dialectToLang(base, dialect) {
  95. if (base === '中文') {
  96. const d = DIALECT_ALIAS[dialect] || dialect;
  97. return { base: '中文', dialect: d };
  98. }
  99. if (base === '英语') {
  100. const d = DIALECT_ALIAS[dialect] || dialect;
  101. return { base: '英语', dialect: d || '通用' };
  102. }
  103. return { base, dialect: dialect || '通用' };
  104. }
  105. function baseToLang(base) {
  106. if (base === '普通话') return { base: '中文', dialect: '普通话' };
  107. if (base === '中文东北口音') return { base: '中文', dialect: '东北' };
  108. if (['美式英语', '美式英文'].includes(base)) return { base: '英语', dialect: '美式' };
  109. if (['英式英语', '英式英文'].includes(base)) return { base: '英语', dialect: '英式' };
  110. if (base === '中文') return { base: '中文', dialect: '普通话' };
  111. if (base === '英文') return { base: '英语', dialect: '通用' };
  112. return { base, dialect: '通用' };
  113. }
  114. // ── 2. 标准化 key: 中文(粤) → "zh-YUE" ────────────────────────
  115. const ZH_DIALECT_TO_ISO = {
  116. '普通话': 'CMN', '粤语': 'YUE', '东北': 'NEN', '四川': 'SCN',
  117. '山东': 'SHD', '河南': 'HEN', '湖南': 'HNN', '陕西': 'SHX',
  118. '安徽': 'AHM', '闽南': 'MNN',
  119. };
  120. function langToKey(lang) {
  121. const { base, dialect } = lang;
  122. if (base === '中文') {
  123. const k = ZH_DIALECT_TO_ISO[dialect];
  124. return k ? `zh-${k}` : 'zh-MISC';
  125. }
  126. if (base === '英语') {
  127. if (dialect === '美式') return 'en-US';
  128. if (dialect === '英式') return 'en-GB';
  129. return 'en-MISC';
  130. }
  131. if (base === '日语') return 'ja-JP';
  132. if (base === '韩语') return 'ko-KR';
  133. if (base === '印尼语') return 'id-ID';
  134. // raw 字段作为兜底
  135. return `${base.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'misc'}-MISC`;
  136. }
  137. function langLabel(key, base, dialect) {
  138. if (base === '中文') return '中文(' + (dialect || '普通话') + ')';
  139. if (base === '英语') return '英语' + (dialect && dialect !== '通用' ? '(' + dialect + ')' : '');
  140. return base || key;
  141. }
  142. // ── 3. 推荐音色(从 docs/alicloud-tts-lang-voice-matrix.md §三 手动权威) ───────
  143. const RECOMMENDED = {
  144. 'zh-CMN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '欢脱元气女,支持 9 种方言' },
  145. 'zh-YUE': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanyue', desc: '粤语标准女声' },
  146. 'zh-NEN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '东北话 → longanhuan_v3 兜底' },
  147. 'zh-SCN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '四川话兜底' },
  148. 'zh-SHD': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '山东话兜底' },
  149. 'zh-HEN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '河南话兜底' },
  150. 'zh-HNN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '湖南话兜底' },
  151. 'zh-SHX': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longshange', desc: '陕西话专用户(陕哥)' },
  152. 'zh-AHM': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '安徽话 → longanhuan_v3 兜底' },
  153. 'zh-MNN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanmin', desc: '闽南话专用户' },
  154. 'en-US': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longava', desc: '美式英语女声' },
  155. 'en-GB': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongluna', desc: '英语英伦少女' },
  156. 'ja-JP': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongtomoka', desc: '日语女声' },
  157. 'ko-KR': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'sohee', desc: '韩语女声' },
  158. 'id-ID': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongindah', desc: '印尼女声' },
  159. };
  160. // ── 4. 主流程: 读 raw → 生成 routes ─────────────────────────────
  161. // 注: cosyvoice-v2 / v1 已淘汰,只保留 v3-flash / v3-plus / Qwen-TTS
  162. const DEPRECATED_MODELS = new Set(['cosyvoice-v2', 'cosyvoice-v1']);
  163. const rawPath = process.argv[2] || path.join(os.tmpdir(), 'alicloud-voices-matrix.json');
  164. const raw = JSON.parse(fs.readFileSync(rawPath, 'utf8'));
  165. const langSupported = new Map();
  166. const langVoices = new Map();
  167. for (const [model, voices] of Object.entries(raw)) {
  168. if (DEPRECATED_MODELS.has(model)) continue; // v2/v1 已淘汰,跳过
  169. for (const v of voices) {
  170. const langs = normalizeLangString(v.lang);
  171. for (const lang of langs) {
  172. const key = langToKey(lang);
  173. if (!langSupported.has(key)) langSupported.set(key, new Set());
  174. langSupported.get(key).add(model);
  175. if (!langVoices.has(key)) langVoices.set(key, []);
  176. langVoices.get(key).push({
  177. model, voice_id: v.voice_id, name: v.name, desc: v.desc, lang,
  178. });
  179. }
  180. }
  181. }
  182. // ── 5. 输出 JSON ─────────────────────────────────────────────
  183. const out = {
  184. version: '2026-07-12',
  185. note: '由 scripts/build-lang-matrix-json.js 从 alicloud 官方页面抓取数据后生成。不要手编。',
  186. recommended: Object.keys(RECOMMENDED),
  187. languages: {},
  188. };
  189. let totalKeys = 0;
  190. for (const [key, modelSet] of [...langSupported.entries()].sort()) {
  191. totalKeys++;
  192. // 取代表性 lang 用于 label
  193. const sample = langVoices.get(key)[0];
  194. const base = sample?.lang?.base || '中文';
  195. const dialect = sample?.lang?.dialect || '普通话';
  196. out.languages[key] = {
  197. label: langLabel(key, base, dialect),
  198. base,
  199. dialect,
  200. supportedModels: [...modelSet].sort(),
  201. voiceCount: (langVoices.get(key) || []).length,
  202. recommended: RECOMMENDED[key] || null,
  203. fallbackChain: RECOMMENDED[key]
  204. ? [RECOMMENDED[key].voice, 'longanhuan_v3']
  205. : ['longanhuan_v3'],
  206. sampleVoices: (langVoices.get(key) || []).slice(0, 30).map(v => ({
  207. voice_id: v.voice_id, name: v.name, model: v.model,
  208. })),
  209. };
  210. }
  211. const outPath = process.argv[3] || path.join(__dirname, '..', '..', 'server', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json');
  212. fs.mkdirSync(path.dirname(outPath), { recursive: true });
  213. fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
  214. console.error(`写入 ${outPath}`);
  215. console.error(`共 ${totalKeys} 个 lang_key,${Object.keys(RECOMMENDED).length} 个推荐`);
  216. console.error();
  217. console.error('推荐列表 (UI 默认显示):');
  218. for (const k of out.recommended) {
  219. const l = out.languages[k];
  220. if (!l) { console.error(` ${k}: (not in matrix, will fallback)`); continue; }
  221. console.error(` ${k}: ${l.label} (${l.voiceCount} voices, models: ${l.supportedModels.join(',')})`);
  222. }