/** * 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; } // 启动时一次性加载(缓存到 hot path) let MATRIX: MatrixFile | null = null; const LANG_MAP: Map = 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 = { '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; }