Initial clean project import

This commit is contained in:
cat-shark
2026-06-19 18:41:41 +08:00
commit a13b804c7a
1306 changed files with 220568 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
/**
* 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: '害怕担忧' }
];
}