|
|
@@ -1156,9 +1156,11 @@ async function processAudioGeneration(
|
|
|
const getInitialNode = (): ProviderNode<ITtsProvider> => {
|
|
|
if (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);
|
|
|
- if (next) return next;
|
|
|
+ if (next) { console.log(`🎯 [TTS Provider] preferred 不可用,回退到 ${next.provider.name}`); return next; }
|
|
|
}
|
|
|
const primary = registry.getPrimary();
|
|
|
if (!primary) throw new Error('没有可用的 TTS Provider');
|
|
|
@@ -1935,6 +1937,108 @@ export async function generatePreview(
|
|
|
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();
|