| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- #!/usr/bin/env node
- /**
- * build-lang-matrix-json.js — 把 alicloud-voices-matrix.json 转成 LANG_ROUTE 路由表
- */
- const fs = require('fs');
- const os = require('os');
- const path = require('path');
- // ── 1. 规范化语言字符串 ────────────────────────────────────
- function parseSimpleLang(p) {
- if (!p) return null;
- p = p.trim();
- if (!p) return null;
- if (p === '中文' || p === '普通话') return { base: '中文', dialect: '普通话' };
- if (p === '日语') return { base: '日语', dialect: '通用' };
- if (p === '韩语') return { base: '韩语', dialect: '通用' };
- if (p === '印尼语') return { base: '印尼语', dialect: '通用' };
- if (p === '美语' || p === '美式英语' || p === '美式英文' || p === '英语(美式)') return { base: '英语', dialect: '美式' };
- if (p === '英式英语' || p === '英式英文' || p === '英语(英式)') return { base: '英语', dialect: '英式' };
- if (p === '英语') return { base: '英语', dialect: '通用' };
- // 中文方言裸词
- const DIALECT = {
- '广东话': '粤语', '粤语': '粤语',
- '闽南话': '闽南', '闽南': '闽南',
- '东北话': '东北', '东北口音': '东北', '东北': '东北',
- '四川话': '四川', '四川': '四川',
- '山东话': '山东', '山东': '山东',
- '河南话': '河南', '河南': '河南',
- '湖南话': '湖南', '湖南': '湖南',
- '陕西话': '陕西', '陕西': '陕西',
- '安徽话': '安徽', '安徽': '安徽',
- };
- if (DIALECT[p]) return { base: '中文', dialect: DIALECT[p] };
- return null;
- }
- // 用 regex 全局匹配 "{base}(dialect1、dialect2)" 或 "{base}" 这两种 base
- // 支持的全角/半角括号。注意 raw 里用的是 "中文" "英文" 不是 "英语"
- const LANGBASE_GROUP_RE = /(中文|英文|日语|韩语|印尼语)[\((]([^\))]+)[\))]/g;
- // 裸 base(不在 group 里的:纯 "英文"、"美式英语" 等)
- const LANGBASE_BARE_RE = /(中文|英文|日语|韩语|印尼语|普通话|中文东北口音|美式英语|英式英语|美式英文|英式英文)(?=[\s,,、]|$)/g;
- const DIALECT_SEP_GLOBAL = /[、,,]/g;
- function normalizeLangString(s) {
- if (!s) return [];
- const cleaned = s.replace(/\s+/g, ' ').trim();
- const result = [];
- const seen = new Set();
- // 1. 先匹配 "base(dialect、dialect)" 这种 group
- const groupMatches = [...cleaned.matchAll(LANGBASE_GROUP_RE)];
- for (const m of groupMatches) {
- const base = m[1];
- const dialectText = m[2];
- for (const d of dialectText.split(DIALECT_SEP_GLOBAL)) {
- const trimmed = d.trim();
- if (!trimmed) continue;
- const lang = dialectToLang(base, trimmed);
- const key = `${lang.base}-${lang.dialect}`;
- if (!seen.has(key)) { seen.add(key); result.push(lang); }
- }
- }
- // 2. 再匹配剩余的纯 base(可能紧跟 group)
- let stripped = cleaned;
- for (const m of groupMatches) stripped = stripped.replace(m[0], ' ');
- const bareMatches = [...stripped.matchAll(LANGBASE_BARE_RE)];
- for (const m of bareMatches) {
- const base = m[1];
- const lang = baseToLang(base);
- const key = `${lang.base}-${lang.dialect}`;
- if (!seen.has(key)) { seen.add(key); result.push(lang); }
- }
- return result;
- }
- // 方言标准名(支持 raw 里的所有写法)
- const DIALECT_ALIAS = {
- '广东话': '粤语',
- '广东': '粤语',
- '东北话': '东北',
- '东北': '东北',
- '东北口音': '东北',
- '四川话': '四川',
- '四川': '四川',
- '山东话': '山东',
- '山东': '山东',
- '河南话': '河南',
- '河南': '河南',
- '湖南话': '湖南',
- '湖南': '湖南',
- '陕西话': '陕西',
- '陕西': '陕西',
- '安徽话': '安徽',
- '安徽': '安徽',
- '闽南话': '闽南',
- '闽南': '闽南',
- '普通话': '普通话',
- };
- function dialectToLang(base, dialect) {
- if (base === '中文') {
- const d = DIALECT_ALIAS[dialect] || dialect;
- return { base: '中文', dialect: d };
- }
- if (base === '英语') {
- const d = DIALECT_ALIAS[dialect] || dialect;
- return { base: '英语', dialect: d || '通用' };
- }
- return { base, dialect: dialect || '通用' };
- }
- function baseToLang(base) {
- if (base === '普通话') return { base: '中文', dialect: '普通话' };
- if (base === '中文东北口音') return { base: '中文', dialect: '东北' };
- if (['美式英语', '美式英文'].includes(base)) return { base: '英语', dialect: '美式' };
- if (['英式英语', '英式英文'].includes(base)) return { base: '英语', dialect: '英式' };
- if (base === '中文') return { base: '中文', dialect: '普通话' };
- if (base === '英文') return { base: '英语', dialect: '通用' };
- return { base, dialect: '通用' };
- }
- // ── 2. 标准化 key: 中文(粤) → "zh-YUE" ────────────────────────
- const ZH_DIALECT_TO_ISO = {
- '普通话': 'CMN', '粤语': 'YUE', '东北': 'NEN', '四川': 'SCN',
- '山东': 'SHD', '河南': 'HEN', '湖南': 'HNN', '陕西': 'SHX',
- '安徽': 'AHM', '闽南': 'MNN',
- };
- function langToKey(lang) {
- const { base, dialect } = lang;
- if (base === '中文') {
- const k = ZH_DIALECT_TO_ISO[dialect];
- return k ? `zh-${k}` : 'zh-MISC';
- }
- if (base === '英语') {
- if (dialect === '美式') return 'en-US';
- if (dialect === '英式') return 'en-GB';
- return 'en-MISC';
- }
- if (base === '日语') return 'ja-JP';
- if (base === '韩语') return 'ko-KR';
- if (base === '印尼语') return 'id-ID';
- // raw 字段作为兜底
- return `${base.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'misc'}-MISC`;
- }
- function langLabel(key, base, dialect) {
- if (base === '中文') return '中文(' + (dialect || '普通话') + ')';
- if (base === '英语') return '英语' + (dialect && dialect !== '通用' ? '(' + dialect + ')' : '');
- return base || key;
- }
- // ── 3. 推荐音色(从 docs/alicloud-tts-lang-voice-matrix.md §三 手动权威) ───────
- const DIALECT_INSTRUCTIONS = {
- // 普通话:不需要指令
- 'zh-CMN': undefined,
- // 方言:通过 instruction 控制 longanhuan_v3 的方言输出
- 'zh-YUE': '请用粤语表达。',
- 'zh-NEN': '请用东北话表达。',
- 'zh-SCN': '请用四川话表达。',
- 'zh-SHD': '请用山东话表达。',
- 'zh-HEN': '请用河南话表达。',
- 'zh-HNN': '请用湖南话表达。',
- 'zh-SHX': '请用陕西话表达。',
- 'zh-AHM': '请用安徽话表达。',
- 'zh-MNN': '请用闽南话表达。',
- // 外语:不需要指令(音色本身就说那种语言)
- 'en-US': undefined,
- 'en-GB': undefined,
- 'ja-JP': undefined,
- 'ko-KR': undefined,
- 'id-ID': undefined,
- };
- const RECOMMENDED = {
- 'zh-CMN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '中文(普通话)标准女声' },
- 'en-US': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longava', desc: '美式英语女声' },
- 'en-GB': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongluna', desc: '英式英语少女' },
- 'ja-JP': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongtomoka', desc: '日语女声' },
- 'ko-KR': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'sohee', desc: '韩语女声' },
- 'id-ID': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongindah', desc: '印尼女声' },
- };
- // ── 4. 主流程: 读 raw → 生成 routes ─────────────────────────────
- // 注: cosyvoice-v2 / v1 已淘汰,只保留 v3-flash / v3-plus / Qwen-TTS
- const DEPRECATED_MODELS = new Set(['cosyvoice-v2', 'cosyvoice-v1']);
- const rawPath = process.argv[2] || path.join(os.tmpdir(), 'alicloud-voices-matrix.json');
- const raw = JSON.parse(fs.readFileSync(rawPath, 'utf8'));
- const langSupported = new Map();
- const langVoices = new Map();
- for (const [model, voices] of Object.entries(raw)) {
- if (DEPRECATED_MODELS.has(model)) continue; // v2/v1 已淘汰,跳过
- for (const v of voices) {
- const langs = normalizeLangString(v.lang);
- for (const lang of langs) {
- const key = langToKey(lang);
- if (!langSupported.has(key)) langSupported.set(key, new Set());
- langSupported.get(key).add(model);
- if (!langVoices.has(key)) langVoices.set(key, []);
- langVoices.get(key).push({
- model, voice_id: v.voice_id, name: v.name, desc: v.desc, lang,
- });
- }
- }
- }
- // ── 5. 输出 JSON ─────────────────────────────────────────────
- const out = {
- version: '2026-07-12',
- note: '由 scripts/build-lang-matrix-json.js 从 alicloud 官方页面抓取数据后生成。不要手编。',
- recommended: Object.keys(RECOMMENDED),
- languages: {},
- };
- let totalKeys = 0;
- for (const [key, modelSet] of [...langSupported.entries()].sort()) {
- totalKeys++;
- // 取代表性 lang 用于 label
- const sample = langVoices.get(key)[0];
- const base = sample?.lang?.base || '中文';
- const dialect = sample?.lang?.dialect || '普通话';
- out.languages[key] = {
- label: langLabel(key, base, dialect),
- base,
- dialect,
- supportedModels: [...modelSet].sort(),
- voiceCount: (langVoices.get(key) || []).length,
- recommended: RECOMMENDED[key] || null,
- fallbackChain: RECOMMENDED[key]
- ? [RECOMMENDED[key].voice, 'longanhuan_v3']
- : ['longanhuan_v3'],
- sampleVoices: (langVoices.get(key) || []).slice(0, 30).map(v => ({
- voice_id: v.voice_id, name: v.name, model: v.model,
- })),
- };
- }
- const outPath = process.argv[3] || path.join(__dirname, '..', '..', 'server', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json');
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
- fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
- console.error(`写入 ${outPath}`);
- console.error(`共 ${totalKeys} 个 lang_key,${Object.keys(RECOMMENDED).length} 个推荐`);
- console.error();
- console.error('推荐列表 (UI 默认显示):');
- for (const k of out.recommended) {
- const l = out.languages[k];
- if (!l) { console.error(` ${k}: (not in matrix, will fallback)`); continue; }
- console.error(` ${k}: ${l.label} (${l.voiceCount} voices, models: ${l.supportedModels.join(',')})`);
- }
|