Parcourir la source

diag: TTS provider 选择加诊断日志 + getInitialNode 检查 exhausted

getInitialNode 之前只检查 enabled+breaker,漏了 exhausted 标志,
可能导致 preferred(bailian) 被错误接受或拒绝.补上 exhausted 检查并加日志.
MyFramework User il y a 2 mois
Parent
commit
79e91ad184
1 fichiers modifiés avec 106 ajouts et 2 suppressions
  1. 106 2
      server/src/modules/tts/tts.service.ts

+ 106 - 2
server/src/modules/tts/tts.service.ts

@@ -1156,9 +1156,11 @@ async function processAudioGeneration(
   const getInitialNode = (): ProviderNode<ITtsProvider> => {
   const getInitialNode = (): ProviderNode<ITtsProvider> => {
     if (preferredName) {
     if (preferredName) {
       const pref = registry.get(preferredName);
       const pref = registry.get(preferredName);
-      if (pref && pref.enabled && !pref.breaker.isOpen()) return pref;
+      // 诊断 #tts-provider-select: 记录为什么 preferred 被接受/拒绝
+      console.log(`🎯 [TTS Provider] preferred=${preferredName}, found=${!!pref}, enabled=${pref?.enabled}, breakerOpen=${pref?.breaker.isOpen()}, exhausted=${(pref as any)?.exhausted}`);
+      if (pref && pref.enabled && !pref.breaker.isOpen() && !(pref as any).exhausted) return pref;
       const next = registry.getNextAvailable(preferredName);
       const next = registry.getNextAvailable(preferredName);
-      if (next) return next;
+      if (next) { console.log(`🎯 [TTS Provider] preferred 不可用,回退到 ${next.provider.name}`); return next; }
     }
     }
     const primary = registry.getPrimary();
     const primary = registry.getPrimary();
     if (!primary) throw new Error('没有可用的 TTS Provider');
     if (!primary) throw new Error('没有可用的 TTS Provider');
@@ -1935,6 +1937,108 @@ export async function generatePreview(
   throw lastError || new Error('所有 TTS Provider 都已尝试');
   throw lastError || new Error('所有 TTS Provider 都已尝试');
 }
 }
 
 
+// ============ 同步合成(任意文本)============
+// 与 generatePreview 类似,但接受自定义文本和情感参数
+// 用于硬件 SDK、智能音箱、Coze/Dify Skill 等需要快速响应的场景
+// 限制:500 字以内的短文本
+export async function synthesizeSync(
+  text: string,
+  voiceId?: string,
+  voiceParams?: { speed?: number; pitch?: number; volume?: number },
+  emotion?: string
+): Promise<{ audioId: string; audioUrl: string; duration: number }> {
+  // 自动检测音色(如果未指定)
+  let finalVoiceId = voiceId;
+  if (!finalVoiceId) {
+    try {
+      const detected = detectBestVoiceAndEmotion(text);
+      finalVoiceId = detected?.voiceId || 'female-shaonv';
+    } catch {
+      finalVoiceId = 'female-shaonv';
+    }
+  }
+
+  const audioId = 'sync-' + uuidv4();
+  const audioDir = path.join(config.upload.dir, audioId);
+
+  if (!fs.existsSync(audioDir)) {
+    fs.mkdirSync(audioDir, { recursive: true });
+  }
+
+  const params = {
+    speed: voiceParams?.speed ?? 1.0,
+    pitch: voiceParams?.pitch ?? 0,
+    volume: voiceParams?.volume ?? 50,
+  };
+
+  console.log(`[Sync Synthesize] text=${text.length}字, voice=${finalVoiceId}, params=`, params);
+
+  const registry = getTtsRegistry();
+
+  // 优先使用配置的默认 Provider
+  const defaultVendor: string | undefined = (config.models as any).tts?.defaultVendor;
+  const startNode = defaultVendor ? registry.get(`${defaultVendor}-tts`) : undefined;
+  const candidates = startNode
+    ? [startNode, ...registry.listAvailable().filter(n => n !== startNode)]
+    : registry.listAvailable();
+
+  let lastError: Error | null = null;
+  for (const node of candidates) {
+    if (node.exhausted || node.breaker.isOpen()) continue;
+
+    const tts = node.provider;
+    const voiceName = mapToProviderVoice(finalVoiceId, tts.vendor);
+
+    if (tts.mode === 'mock') {
+      const mockPath = path.join(audioDir, 'sync.mp3');
+      fs.writeFileSync(mockPath, Buffer.alloc(1024));
+      return { audioId, audioUrl: `/uploads/${audioId}/sync.mp3`, duration: text.length / 4 };
+    }
+
+    try {
+      // 注入情感 Instruct
+      let instructToInject = getVoiceInstructById(finalVoiceId);
+      if (emotion && ['neutral', 'happy', 'sad', 'angry', 'fearful', 'surprised', 'disgusted'].includes(emotion)) {
+        const emotionText = `你说话的情感是${emotion}。`;
+        instructToInject = instructToInject
+          ? instructToInject.replace(/情感是\w+。?$/, '').replace(/。$/, '') + `,${emotionText}`
+          : emotionText;
+      }
+      const paramsToUse = instructToInject
+        ? { ...params, instructText: instructToInject }
+        : params;
+      if (instructToInject) {
+        console.log(`🎭 [Sync Instruct] ${finalVoiceId} → ${instructToInject}`);
+      }
+
+      const ext = tts.vendor.startsWith('minimax') ? 'mp3' : 'wav';
+      const outputPath = path.join(audioDir, `sync.${ext}`);
+      if (!fs.existsSync(outputPath)) {
+        fs.writeFileSync(outputPath, Buffer.alloc(0));
+      }
+      const actualPath = await node.breaker.call(() => tts.synthesize(text, voiceName, paramsToUse, outputPath));
+      const audioUrl = await storageService.uploadAudio(actualPath, audioId);
+
+      // 估算时长(按 4 字/秒 估算)
+      const duration = Math.max(0.5, text.length / 4);
+
+      console.log(`✅ [Sync Synthesize] ${tts.name} 生成成功`);
+      return { audioId, audioUrl, duration };
+    } catch (error: any) {
+      console.error(`❌ [Sync Synthesize] ${tts.name} 失败:`, error.message);
+      lastError = error;
+
+      if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => error.message?.toLowerCase().includes(k))) {
+        registry.markExhausted(tts.name, error.message, 4 * 60 * 60 * 1000);
+      }
+      continue;
+    }
+  }
+
+  console.error('❌ [Sync Synthesize] 所有 Provider 都失败');
+  throw lastError || new Error('所有 TTS Provider 都已尝试');
+}
+
 // ============ 初始化 ============
 // ============ 初始化 ============
 // 首次调用时初始化注册表并启动健康检查
 // 首次调用时初始化注册表并启动健康检查
 startTtsHealthCheck();
 startTtsHealthCheck();