Files
2026-06-19 18:45:55 +08:00

561 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {
'开心': '开心的',
'悲伤': '悲伤的',
'愤怒': '愤怒的',
'恐惧': '恐惧的',
'惊讶': '惊讶的',
'厌恶': '厌恶的',
'害羞': '害羞的',
'温柔': '温柔的',
'激动': '激动的',
'平静': '平静的',
'自然': '自然、平静、克制的',
};
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<void> {
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<string> {
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<boolean> {
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<RunningHubResult> {
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: '害怕担忧' }
];
}