561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
/**
|
||
* 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: '害怕担忧' }
|
||
];
|
||
}
|