tts-lang-router.service.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. /**
  2. * tts-lang-router.service.ts — 按语言找 TTS vendor / model / voiceId
  3. *
  4. * 输入: langKey (BCP-47 风格,如 zh-CMN、zh-YUE、en-US、ja-JP)
  5. * 输出: { vendor, model, voice } 三元组
  6. *
  7. * fallback 链(优先):
  8. * 1. 矩阵中匹配的"最佳"音色 (RECOMMENDED 字段)
  9. * 2. 同语言其他音色 (langVoices 列表)
  10. * 3. 同 vendor 其他模型 (matrix 数据里所有 model)
  11. * 4. 跨 vendor → Qwen-TTS (支持 11 语种)
  12. * 5. Edge 同语种 Neural (zh-HK / en-US / en-GB / ja-JP / ko-KR / id-ID)
  13. * 6. → 422 NotFound (绝不静默跨语言退化)
  14. */
  15. import fs from 'fs';
  16. import path from 'path';
  17. export type Vendor = 'bailian' | 'edge';
  18. export interface LangRoute {
  19. vendor: Vendor;
  20. model: string;
  21. voice: string;
  22. preferredName: string; // provider registry key, 例 'bailian-tts' / 'edge-tts'
  23. reason: string; // 选这条的 log 用
  24. }
  25. interface LangDef {
  26. label: string;
  27. base: string;
  28. dialect: string;
  29. supportedModels: string[];
  30. voiceCount: number;
  31. recommended: LangRoute | null;
  32. fallbackChain: string[];
  33. sampleVoices: Array<{ voice_id: string; name: string; model: string }>;
  34. }
  35. interface MatrixFile {
  36. version: string;
  37. recommended: string[];
  38. languages: Record<string, LangDef>;
  39. }
  40. // 启动时一次性加载(缓存到 hot path)
  41. let MATRIX: MatrixFile | null = null;
  42. const LANG_MAP: Map<string, LangDef> = new Map();
  43. function ensureLoaded(): void {
  44. if (MATRIX) return;
  45. // 编译后 dist/ 目录或源码 src/ 目录
  46. const candidates = [
  47. path.join(__dirname, '..', 'config', 'alicloud-tts-lang-voice-matrix.json'),
  48. path.join(__dirname, '..', '..', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
  49. path.join(process.cwd(), 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
  50. ];
  51. let raw: string | null = null;
  52. for (const p of candidates) {
  53. try { raw = fs.readFileSync(p, 'utf8'); break; } catch { /* continue */ }
  54. }
  55. if (!raw) {
  56. console.warn('[tts-lang-router] 矩阵 JSON 加载失败,所有语言查询都会 fallback');
  57. MATRIX = { version: '0', recommended: [], languages: {} };
  58. return;
  59. }
  60. MATRIX = JSON.parse(raw);
  61. for (const [key, def] of Object.entries(MATRIX.languages || {})) {
  62. LANG_MAP.set(key, def);
  63. }
  64. console.log(`[tts-lang-router] 加载 ${LANG_MAP.size} 个 lang_key,推荐 ${MATRIX.recommended?.length || 0} 个`);
  65. }
  66. // Edge TTS 同语种 fallback(用在矩阵都没匹配时)
  67. function edgeFallbackVoice(langKey: string): { vendor: Vendor; model: string; voice: string; reason: string } | null {
  68. const map: Record<string, string> = {
  69. 'zh-CMN': 'zh-CN-XiaoxiaoNeural',
  70. 'zh-YUE': 'zh-HK-HiuMaanNeural',
  71. 'zh-NEN': 'zh-CN-liaoning-XiaobeiNeural', // 东北方言
  72. 'en-US': 'en-US-JennyNeural',
  73. 'en-GB': 'en-GB-RyanNeural',
  74. 'ja-JP': 'ja-JP-NanamiNeural',
  75. 'ko-KR': 'ko-KR-SunHiNeural',
  76. 'id-ID': 'id-ID-ArdiNeural',
  77. };
  78. const voice = map[langKey];
  79. if (!voice) return null;
  80. return { vendor: 'edge', model: 'edge-tts', voice, reason: `Edge fallback for ${langKey}` };
  81. }
  82. // ─────────────────────────────────────────────────────────────
  83. // 公开 API
  84. // ─────────────────────────────────────────────────────────────
  85. /** 主入口: TTS 任务入队时必调 */
  86. export function pickTtsVendor(langKey?: string): LangRoute | null {
  87. ensureLoaded();
  88. if (!langKey) return null;
  89. const def = LANG_MAP.get(langKey);
  90. // 1. exact match: matrices 推荐音色
  91. if (def?.recommended) {
  92. return { ...def.recommended, preferredName: `${def.recommended.vendor}-tts` };
  93. }
  94. // 2. langKey 存在但无 recommended(实际数据但没给最佳)→ 取第一个 sampleVoice
  95. if (def && def.sampleVoices.length > 0) {
  96. const v = def.sampleVoices[0];
  97. return {
  98. vendor: 'bailian',
  99. model: v.model,
  100. voice: v.voice_id,
  101. preferredName: 'bailian-tts',
  102. reason: `langKey=${langKey} match, 取首个 sampleVoice ${v.voice_id}`,
  103. };
  104. }
  105. // 3. 跨语种 fallback:同 base
  106. if (def) {
  107. // 例如 zh-MNN 没有 fallback 链但有 langVoices,降级到 zh-CMN
  108. const baseKey = langKey.split('-')[0] + '-' + 'CMN';
  109. const baseDef = LANG_MAP.get(baseKey);
  110. if (baseDef?.recommended) {
  111. return { ...baseDef.recommended, preferredName: 'bailian-tts', reason: `langKey=${langKey} 非主流,降级到 ${baseKey}` };
  112. }
  113. }
  114. // 4. 最后 Edge 同语种 fallback
  115. const edge = edgeFallbackVoice(langKey);
  116. if (edge) {
  117. return { ...edge, preferredName: 'edge-tts' };
  118. }
  119. // 5. 完全不支持 → 返回 null(让 controller 抛 422)
  120. return null;
  121. }
  122. /** 给前端"选语言后展示可选音色" */
  123. export function listVoicesByLang(langKey: string, preferCheap = false): Array<{
  124. vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string;
  125. }> {
  126. ensureLoaded();
  127. const def = LANG_MAP.get(langKey);
  128. if (!def) return [];
  129. const out: Array<{ vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string }> = [];
  130. for (const sv of def.sampleVoices) {
  131. if (preferCheap && sv.model.includes('cosyvoice-v2')) continue;
  132. out.push({
  133. vendor: 'bailian',
  134. model: sv.model,
  135. voiceId: sv.voice_id,
  136. voiceName: sv.name || sv.voice_id,
  137. desc: def.label,
  138. });
  139. }
  140. return out;
  141. }
  142. /** 给前端"语言选择器"用 */
  143. export function listSupportedLanguages(): Array<{
  144. key: string; label: string; recommended: boolean; vendorHint?: string;
  145. }> {
  146. ensureLoaded();
  147. const recommended = new Set(MATRIX?.recommended || []);
  148. const out: Array<{ key: string; label: string; recommended: boolean; vendorHint?: string }> = [];
  149. for (const [key, def] of [...LANG_MAP.entries()].sort()) {
  150. out.push({
  151. key,
  152. label: def.label,
  153. recommended: recommended.has(key),
  154. vendorHint: def.recommended ? `${def.recommended.vendor}/${def.recommended.model}` : undefined,
  155. });
  156. }
  157. return out;
  158. }
  159. /** 校验:客户端传来 langKey 是否支持 */
  160. export function isLanguageSupported(langKey: string): boolean {
  161. ensureLoaded();
  162. if (LANG_MAP.has(langKey)) return true;
  163. // Edge fallback 也算"支持"(虽然音色不在矩阵)
  164. return edgeFallbackVoice(langKey) !== null;
  165. }
  166. /** 反向查询: 这个 voice 能不能说这种语言 */
  167. export function supportsLanguage(voiceId: string, langKey: string): boolean {
  168. ensureLoaded();
  169. if (!voiceId) return false;
  170. const def = LANG_MAP.get(langKey);
  171. if (!def) return false;
  172. return def.sampleVoices.some(v => v.voice_id === voiceId);
  173. }
  174. /** 调试用:获取整个矩阵 */
  175. export function _getMatrix(): MatrixFile | null {
  176. ensureLoaded();
  177. return MATRIX;
  178. }