| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- /**
- * tts-lang-router.service.ts — 按语言找 TTS vendor / model / voiceId
- *
- * 输入: langKey (BCP-47 风格,如 zh-CMN、zh-YUE、en-US、ja-JP)
- * 输出: { vendor, model, voice } 三元组
- *
- * fallback 链(优先):
- * 1. 矩阵中匹配的"最佳"音色 (RECOMMENDED 字段)
- * 2. 同语言其他音色 (langVoices 列表)
- * 3. 同 vendor 其他模型 (matrix 数据里所有 model)
- * 4. 跨 vendor → Qwen-TTS (支持 11 语种)
- * 5. Edge 同语种 Neural (zh-HK / en-US / en-GB / ja-JP / ko-KR / id-ID)
- * 6. → 422 NotFound (绝不静默跨语言退化)
- */
- import fs from 'fs';
- import path from 'path';
- export type Vendor = 'bailian' | 'edge';
- export interface LangRoute {
- vendor: Vendor;
- model: string;
- voice: string;
- preferredName: string; // provider registry key, 例 'bailian-tts' / 'edge-tts'
- reason: string; // 选这条的 log 用
- }
- interface LangDef {
- label: string;
- base: string;
- dialect: string;
- supportedModels: string[];
- voiceCount: number;
- recommended: LangRoute | null;
- fallbackChain: string[];
- sampleVoices: Array<{ voice_id: string; name: string; model: string }>;
- }
- interface MatrixFile {
- version: string;
- recommended: string[];
- languages: Record<string, LangDef>;
- }
- // 启动时一次性加载(缓存到 hot path)
- let MATRIX: MatrixFile | null = null;
- const LANG_MAP: Map<string, LangDef> = new Map();
- function ensureLoaded(): void {
- if (MATRIX) return;
- // 编译后 dist/ 目录或源码 src/ 目录
- const candidates = [
- path.join(__dirname, '..', 'config', 'alicloud-tts-lang-voice-matrix.json'),
- path.join(__dirname, '..', '..', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
- path.join(process.cwd(), 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
- ];
- let raw: string | null = null;
- for (const p of candidates) {
- try { raw = fs.readFileSync(p, 'utf8'); break; } catch { /* continue */ }
- }
- if (!raw) {
- console.warn('[tts-lang-router] 矩阵 JSON 加载失败,所有语言查询都会 fallback');
- MATRIX = { version: '0', recommended: [], languages: {} };
- return;
- }
- MATRIX = JSON.parse(raw);
- for (const [key, def] of Object.entries(MATRIX.languages || {})) {
- LANG_MAP.set(key, def);
- }
- console.log(`[tts-lang-router] 加载 ${LANG_MAP.size} 个 lang_key,推荐 ${MATRIX.recommended?.length || 0} 个`);
- }
- // Edge TTS 同语种 fallback(用在矩阵都没匹配时)
- function edgeFallbackVoice(langKey: string): { vendor: Vendor; model: string; voice: string; reason: string } | null {
- const map: Record<string, string> = {
- 'zh-CMN': 'zh-CN-XiaoxiaoNeural',
- 'zh-YUE': 'zh-HK-HiuMaanNeural',
- 'zh-NEN': 'zh-CN-liaoning-XiaobeiNeural', // 东北方言
- 'en-US': 'en-US-JennyNeural',
- 'en-GB': 'en-GB-RyanNeural',
- 'ja-JP': 'ja-JP-NanamiNeural',
- 'ko-KR': 'ko-KR-SunHiNeural',
- 'id-ID': 'id-ID-ArdiNeural',
- };
- const voice = map[langKey];
- if (!voice) return null;
- return { vendor: 'edge', model: 'edge-tts', voice, reason: `Edge fallback for ${langKey}` };
- }
- // ─────────────────────────────────────────────────────────────
- // 公开 API
- // ─────────────────────────────────────────────────────────────
- /** 主入口: TTS 任务入队时必调 */
- export function pickTtsVendor(langKey?: string): LangRoute | null {
- ensureLoaded();
- if (!langKey) return null;
- const def = LANG_MAP.get(langKey);
- // 1. exact match: matrices 推荐音色
- if (def?.recommended) {
- return { ...def.recommended, preferredName: `${def.recommended.vendor}-tts` };
- }
- // 2. langKey 存在但无 recommended(实际数据但没给最佳)→ 取第一个 sampleVoice
- if (def && def.sampleVoices.length > 0) {
- const v = def.sampleVoices[0];
- return {
- vendor: 'bailian',
- model: v.model,
- voice: v.voice_id,
- preferredName: 'bailian-tts',
- reason: `langKey=${langKey} match, 取首个 sampleVoice ${v.voice_id}`,
- };
- }
- // 3. 跨语种 fallback:同 base
- if (def) {
- // 例如 zh-MNN 没有 fallback 链但有 langVoices,降级到 zh-CMN
- const baseKey = langKey.split('-')[0] + '-' + 'CMN';
- const baseDef = LANG_MAP.get(baseKey);
- if (baseDef?.recommended) {
- return { ...baseDef.recommended, preferredName: 'bailian-tts', reason: `langKey=${langKey} 非主流,降级到 ${baseKey}` };
- }
- }
- // 4. 最后 Edge 同语种 fallback
- const edge = edgeFallbackVoice(langKey);
- if (edge) {
- return { ...edge, preferredName: 'edge-tts' };
- }
- // 5. 完全不支持 → 返回 null(让 controller 抛 422)
- return null;
- }
- /** 给前端"选语言后展示可选音色" */
- export function listVoicesByLang(langKey: string, preferCheap = false): Array<{
- vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string;
- }> {
- ensureLoaded();
- const def = LANG_MAP.get(langKey);
- if (!def) return [];
- const out: Array<{ vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string }> = [];
- for (const sv of def.sampleVoices) {
- if (preferCheap && sv.model.includes('cosyvoice-v2')) continue;
- out.push({
- vendor: 'bailian',
- model: sv.model,
- voiceId: sv.voice_id,
- voiceName: sv.name || sv.voice_id,
- desc: def.label,
- });
- }
- return out;
- }
- /** 给前端"语言选择器"用 */
- export function listSupportedLanguages(): Array<{
- key: string; label: string; recommended: boolean; vendorHint?: string;
- }> {
- ensureLoaded();
- const recommended = new Set(MATRIX?.recommended || []);
- const out: Array<{ key: string; label: string; recommended: boolean; vendorHint?: string }> = [];
- for (const [key, def] of [...LANG_MAP.entries()].sort()) {
- out.push({
- key,
- label: def.label,
- recommended: recommended.has(key),
- vendorHint: def.recommended ? `${def.recommended.vendor}/${def.recommended.model}` : undefined,
- });
- }
- return out;
- }
- /** 校验:客户端传来 langKey 是否支持 */
- export function isLanguageSupported(langKey: string): boolean {
- ensureLoaded();
- if (LANG_MAP.has(langKey)) return true;
- // Edge fallback 也算"支持"(虽然音色不在矩阵)
- return edgeFallbackVoice(langKey) !== null;
- }
- /** 反向查询: 这个 voice 能不能说这种语言 */
- export function supportsLanguage(voiceId: string, langKey: string): boolean {
- ensureLoaded();
- if (!voiceId) return false;
- const def = LANG_MAP.get(langKey);
- if (!def) return false;
- return def.sampleVoices.some(v => v.voice_id === voiceId);
- }
- /** 调试用:获取整个矩阵 */
- export function _getMatrix(): MatrixFile | null {
- ensureLoaded();
- return MATRIX;
- }
|