/** * RunningHub TTS API 语音合成模块 * 文档: https://www.runninghub.cn/runninghub-api-doc-cn/api-279098421 */ import axios from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import { spawn } from 'child_process'; import { app } from 'electron'; import logger from '../log/main'; import { getFFmpegExecutablePath, resourceExists } from '../../lib/resource-path'; import { fetchElectronSystemConfig } from '../systemConfig'; const BASE_URL = 'https://www.runninghub.cn'; const WEBAPP_ID_V2 = '1965614643077070850'; // TTS V2 应用ID const WEBAPP_ID_V3 = '2028779949334728706'; // TTS V3 应用ID(中文最强) const DEFAULT_TTS_V2_NODES = { audio: '13', text: '14', emotion: '15', }; export interface RunningHubConfig { apiKey: string; audioFileName: string; // 克隆音频文件名(需先上传到RunningHub) emotion?: string; // 情感描述,如"害羞的"、"开心的" outputPath?: string; // 自定义输出路径 version?: 'v2' | 'v3'; // TTS版本:v2 或 v3(中文最强),默认v2 } const RUNNINGHUB_EMOTION_ALIASES: Record = { '开心': '开心的', '悲伤': '悲伤的', '愤怒': '愤怒的', '恐惧': '恐惧的', '惊讶': '惊讶的', '厌恶': '厌恶的', '害羞': '害羞的', '温柔': '温柔的', '激动': '激动的', '平静': '平静的', '自然': '自然、平静、克制的', }; function normalizeEmotionPrompt(emotion?: string): string | undefined { const value = String(emotion || '').trim(); if (!value || value === '无' || value.toLowerCase() === 'none') { return undefined; } return RUNNINGHUB_EMOTION_ALIASES[value] || value; } function normalizeSpeed(speed?: number): number { const parsed = Number(speed); if (!Number.isFinite(parsed) || parsed <= 0) { return 1; } return Math.min(2, Math.max(0.5, parsed)); } function getRunningHubErrorText(error: any): string { const data = error?.response?.data; if (!data) { return error?.message || ''; } if (typeof data === 'string') { return data; } const nested = data.data && typeof data.data === 'object' ? data.data : {}; return [ data.msg, data.message, data.errorMessage, data.error, data.code, nested.msg, nested.message, nested.errorMessage, nested.error, nested.code, ].filter(Boolean).join(' | ') || JSON.stringify(data); } function isFieldMismatchForNode(error: any, nodeId?: string, fieldName?: string): boolean { const text = getRunningHubErrorText(error); return text.includes('NODE_INFO_MISMATCH') && (!nodeId || text.includes(`nodeId=${nodeId}`)) && (!fieldName || text.includes(`fieldName=${fieldName}`)); } function buildAtempoFilterChain(speed: number): string { const filters: string[] = []; let remaining = speed; while (remaining > 2) { filters.push('atempo=2'); remaining /= 2; } while (remaining < 0.5) { filters.push('atempo=0.5'); remaining /= 0.5; } filters.push(`atempo=${remaining.toFixed(3)}`); return filters.join(','); } function runFFmpeg(ffmpegPath: string, args: string[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(ffmpegPath, args); let stderr = ''; proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); proc.on('close', (code) => { if (code === 0) { resolve(); return; } reject(new Error(stderr || `FFmpeg exited with code ${code}`)); }); proc.on('error', reject); }); } async function adjustAudioSpeedIfNeeded(filePath: string, speed?: number): Promise { const normalizedSpeed = normalizeSpeed(speed); if (Math.abs(normalizedSpeed - 1) < 0.01) { return filePath; } const ffmpegPath = getFFmpegExecutablePath(); if (!resourceExists(ffmpegPath)) { logger.warn('[RunningHub] FFmpeg missing, skip speed post-process', { filePath, normalizedSpeed, }); return filePath; } const ext = path.extname(filePath) || '.mp3'; const tempOutputPath = path.join( path.dirname(filePath), `${path.basename(filePath, ext)}_speed${ext}` ); await runFFmpeg(ffmpegPath, [ '-y', '-i', filePath, '-vn', '-filter:a', buildAtempoFilterChain(normalizedSpeed), tempOutputPath, ]); fs.rmSync(filePath, { force: true }); fs.renameSync(tempOutputPath, filePath); logger.info('[RunningHub] Speed post-process done', { filePath, normalizedSpeed }); return filePath; } export interface RunningHubResult { success: boolean; audioPath?: string; error?: string; taskId?: string; } /** * 发起TTS任务 */ async function submitTask( text: string, config: RunningHubConfig ): Promise<{ success: boolean; taskId?: string; error?: string }> { try { const sysCfg = await fetchElectronSystemConfig(); const version = config.version || 'v2'; const webappId = version === 'v3' ? (sysCfg.runninghub_tts_webapp_id_v3 || WEBAPP_ID_V3) : (sysCfg.runninghub_tts_webapp_id_v2 || WEBAPP_ID_V2); const v2Nodes = { audio: sysCfg.runninghub_tts_v2_audio_node || DEFAULT_TTS_V2_NODES.audio, text: sysCfg.runninghub_tts_v2_text_node || DEFAULT_TTS_V2_NODES.text, emotion: sysCfg.runninghub_tts_v2_emotion_node || DEFAULT_TTS_V2_NODES.emotion, }; const normalizedEmotion = normalizeEmotionPrompt(config.emotion); const normalizedSpeed = normalizeSpeed((config as any).speed); logger.info('[RunningHub] 提交TTS任务', { version, webappId, text: text.substring(0, 50), emotion: normalizedEmotion, speed: normalizedSpeed }); let nodeInfoList: any[]; if (version === 'v3') { // V3 节点配置(根据文档 2028779949334728706) nodeInfoList = [ { nodeId: "5", fieldName: "audio", fieldValue: config.audioFileName, description: "需要克隆的语音" }, { nodeId: "4", fieldName: "prompt", fieldValue: text, description: "文字" } ]; } else { // V2 节点配置(原有配置) nodeInfoList = [ { nodeId: v2Nodes.audio, fieldName: "audio", fieldValue: config.audioFileName, description: "上传克隆音频" }, { nodeId: v2Nodes.text, fieldName: "value", fieldValue: text, description: "上传语音文本" } ]; // V2 如果有情感描述,添加情感节点 if (v2Nodes.emotion && normalizedEmotion) { nodeInfoList.push({ nodeId: v2Nodes.emotion, fieldName: "value", fieldValue: normalizedEmotion || "", description: "情感描述" }); } } const postTask = (nodes: any[]) => axios.post( `${BASE_URL}/openapi/v2/run/ai-app/${webappId}`, { nodeInfoList: nodes, instanceType: version === 'v2' ? "default" : "plus", usePersonalQueue: "false" }, { headers: { 'Host': 'www.runninghub.cn', 'Authorization': `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, timeout: 30000 } ); let response; try { response = await postTask(nodeInfoList); } catch (error: any) { if (version === 'v2' && v2Nodes.emotion && isFieldMismatchForNode(error, v2Nodes.emotion, 'value')) { const retryNodeInfoList = nodeInfoList.filter(node => String(node.nodeId) !== String(v2Nodes.emotion)); logger.warn('[RunningHub] Emotion node value field mismatch, retry without emotion node', { emotionNode: v2Nodes.emotion, error: getRunningHubErrorText(error), }); response = await postTask(retryNodeInfoList); } else { throw error; } } // 兼容 V2 和 V3 的响应格式 let taskId = response.data?.taskId || response.data?.data?.taskId; let status = response.data?.status || response.data?.data?.taskStatus; const hasEmotionNode = nodeInfoList.some(node => String(node.nodeId) === String(v2Nodes.emotion)); if (!taskId && version === 'v2' && hasEmotionNode && isFieldMismatchForNode({ response: { data: response.data } }, v2Nodes.emotion, 'value')) { logger.warn('[RunningHub] Emotion node value field mismatch in response, retry without emotion node', { emotionNode: v2Nodes.emotion, error: getRunningHubErrorText({ response: { data: response.data } }), }); response = await postTask(nodeInfoList.filter(node => String(node.nodeId) !== String(v2Nodes.emotion))); taskId = response.data?.taskId || response.data?.data?.taskId; status = response.data?.status || response.data?.data?.taskStatus; } if (taskId) { logger.info('[RunningHub] 任务提交成功', { version, taskId, status }); return { success: true, taskId }; } else { logger.error('[RunningHub] 任务提交失败', response.data); return { success: false, error: response.data?.errorMessage || response.data?.msg || response.data?.message || '任务提交失败' }; } } catch (error: any) { logger.error('[RunningHub] 任务提交异常', error); const errorMsg = getRunningHubErrorText(error) || '未知错误'; return { success: false, error: errorMsg }; } } /** * 查询任务结果 */ async function queryTaskResult( taskId: string, apiKey: string ): Promise<{ success: boolean; status?: string; outputs?: any[]; error?: string }> { try { logger.info('[RunningHub] 查询任务状态', { taskId }); const response = await axios.post( `${BASE_URL}/openapi/v2/query`, { taskId, apiKey }, { headers: { 'Host': 'www.runninghub.cn', 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, timeout: 30000 } ); // 打印完整响应便于调试 logger.info('[RunningHub] 查询响应原始数据', JSON.stringify(response.data)); // 尝试从不同字段获取状态(兼容不同API版本) // 尝试从不同字段获取状态(兼容不同API版本) const data = response.data?.data || response.data; const status = data?.taskStatus || data?.status; const outputs = data?.outputs || data?.results; // API直接返回{status, results}格式,不需要检查code if (status) { return { success: true, status: status, outputs: outputs }; } else { return { success: false, error: response.data?.msg || response.data?.errorMessage || '查询失败' }; } } catch (error: any) { logger.error('[RunningHub] 查询任务异常', error); return { success: false, error: error.response?.data?.msg || error.message }; } } /** * 等待任务完成 */ async function waitForTaskCompletion( taskId: string, apiKey: string, maxWaitTime: number = 3600000, // 最长等待60分钟 pollInterval: number = 3000 // 每3秒轮询一次 ): Promise<{ success: boolean; outputs?: any[]; error?: string }> { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const result = await queryTaskResult(taskId, apiKey); if (!result.success) { return { success: false, error: result.error }; } logger.info('[RunningHub] 任务状态', { taskId, status: result.status, elapsed: Date.now() - startTime }); if (result.status === 'SUCCESS' || result.status === 'COMPLETED') { return { success: true, outputs: result.outputs }; } if (result.status === 'FAILED' || result.status === 'ERROR') { return { success: false, error: '任务执行失败' }; } // 等待后继续轮询 await new Promise(resolve => setTimeout(resolve, pollInterval)); } return { success: false, error: '任务超时' }; } /** * 下载音频文件 */ async function downloadAudio( url: string, outputPath: string ): Promise { try { const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 60000 }); // 确保输出目录存在 const outputDir = path.dirname(outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } fs.writeFileSync(outputPath, response.data); logger.info('[RunningHub] 音频下载成功', { outputPath }); return true; } catch (error: any) { logger.error('[RunningHub] 音频下载失败', error); return false; } } /** * RunningHub TTS 语音合成主函数 */ export async function synthesizeSpeech( text: string, config: RunningHubConfig ): Promise { logger.info('[RunningHub] 开始语音合成', { text: text.substring(0, 50), audioFileName: config.audioFileName, emotion: normalizeEmotionPrompt(config.emotion), speed: normalizeSpeed((config as any).speed) }); // 1. 提交任务 const submitResult = await submitTask(text, config); if (!submitResult.success || !submitResult.taskId) { return { success: false, error: submitResult.error || '任务提交失败' }; } const taskId = submitResult.taskId; // 2. 等待任务完成 const completionResult = await waitForTaskCompletion(taskId, config.apiKey); if (!completionResult.success) { return { success: false, error: completionResult.error, taskId }; } // 3. 获取音频URL const outputs = completionResult.outputs || []; let audioUrl: string | null = null; // 遍历outputs找到音频文件 for (const output of outputs) { // 使用url或fileUrl字段 const url = output.url || output.fileUrl; if (url && ( url.endsWith('.mp3') || url.endsWith('.wav') || url.endsWith('.flac') || // 添加flac支持 output.outputType === 'flac' || output.outputType === 'audio' || output.fileType === 'audio' )) { audioUrl = url; break; } } if (!audioUrl) { logger.error('[RunningHub] 未找到音频输出', { outputs }); return { success: false, error: '未找到音频输出', taskId }; } // 4. 下载音频 let outputPath: string; if (config.outputPath) { outputPath = config.outputPath; } else { const outputDir = path.join(app.getPath('userData'), 'temp', 'tts'); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const ext = path.extname(audioUrl) || '.mp3'; outputPath = path.join(outputDir, `runninghub_${Date.now()}${ext}`); } const downloaded = await downloadAudio(audioUrl, outputPath); if (!downloaded) { return { success: false, error: '音频下载失败', taskId }; } logger.info('[RunningHub] 语音合成完成', { outputPath, taskId }); outputPath = await adjustAudioSpeedIfNeeded(outputPath, (config as any).speed); return { success: true, audioPath: outputPath, taskId }; } /** * 获取可用的情感描述列表 */ export function getAvailableEmotions(): Array<{ id: string; name: string; desc: string }> { return [ { id: '', name: '无', desc: '不指定情感' }, { id: '开心的', name: '开心', desc: '愉快欢乐' }, { id: '悲伤的', name: '悲伤', desc: '伤心难过' }, { id: '愤怒的', name: '愤怒', desc: '生气恼怒' }, { id: '害羞的', name: '害羞', desc: '羞涩腼腆' }, { id: '温柔的', name: '温柔', desc: '轻柔亲切' }, { id: '激动的', name: '激动', desc: '激昂兴奋' }, { id: '平静的', name: '平静', desc: '冷静沉稳' }, { id: '惊讶的', name: '惊讶', desc: '吃惊意外' }, { id: '恐惧的', name: '恐惧', desc: '害怕担忧' } ]; }