#!/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(',')})`); }