Initial clean project import
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import logger from '../log/main';
|
||||
import { Apps } from '../app/index';
|
||||
import { withoutUnsupportedProxy } from '../httpClient';
|
||||
|
||||
/**
|
||||
* 在线 TTS(千问 Qwen TTS)客户端
|
||||
*/
|
||||
|
||||
export interface CosyVoiceConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
rate?: number;
|
||||
volume?: number;
|
||||
pitch?: number;
|
||||
instruction?: string;
|
||||
language_hints?: string;
|
||||
language?: string;
|
||||
emotion?: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
export interface CosyVoiceResult {
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
const QWEN_TTS_URL = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
|
||||
|
||||
function getOutputFormat(outputPath: string, configFormat?: string): 'wav' | 'mp3' {
|
||||
const ext = path.extname(outputPath).toLowerCase();
|
||||
if (ext === '.wav') return 'wav';
|
||||
if (ext === '.mp3') return 'mp3';
|
||||
if ((configFormat || '').toLowerCase() === 'wav') return 'wav';
|
||||
return 'mp3';
|
||||
}
|
||||
|
||||
function detectAudioFormat(
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
fallback: 'wav' | 'mp3' = 'mp3'
|
||||
): 'wav' | 'mp3' {
|
||||
const normalizedType = (contentType || '').toLowerCase();
|
||||
if (normalizedType.includes('wav') || normalizedType.includes('wave')) return 'wav';
|
||||
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) return 'mp3';
|
||||
|
||||
if (buffer.length >= 12) {
|
||||
const riff = buffer.toString('ascii', 0, 4);
|
||||
const wave = buffer.toString('ascii', 8, 12);
|
||||
if (riff === 'RIFF' && wave === 'WAVE') return 'wav';
|
||||
}
|
||||
|
||||
if (buffer.length >= 3 && buffer.toString('ascii', 0, 3) === 'ID3') return 'mp3';
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function transcodeAudioFile(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
targetFormat: 'wav' | 'mp3'
|
||||
): Promise<void> {
|
||||
const args = targetFormat === 'wav'
|
||||
? ['-y', '-i', inputPath, '-c:a', 'pcm_s16le', outputPath]
|
||||
: ['-y', '-i', inputPath, '-c:a', 'libmp3lame', '-b:a', '128k', outputPath];
|
||||
|
||||
await Apps.spawnBinary('ffmpeg', args, {
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeAudioBufferAsTargetFormat(
|
||||
audioBuffer: Buffer,
|
||||
outputPath: string,
|
||||
targetFormat: 'wav' | 'mp3',
|
||||
contentType?: string
|
||||
): Promise<void> {
|
||||
const sourceFormat = detectAudioFormat(audioBuffer, contentType, targetFormat);
|
||||
if (sourceFormat === targetFormat) {
|
||||
fs.writeFileSync(outputPath, audioBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedOutput = path.parse(outputPath);
|
||||
const tempInputPath = path.join(
|
||||
parsedOutput.dir,
|
||||
`${parsedOutput.name}_source_${Date.now()}${Math.random().toString(36).slice(2, 8)}.${sourceFormat}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tempInputPath, audioBuffer);
|
||||
try {
|
||||
await transcodeAudioFile(tempInputPath, outputPath, targetFormat);
|
||||
} finally {
|
||||
try { fs.unlinkSync(tempInputPath); } catch (_error) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将长文本按自然断句分割为不超过 maxLen 的段落
|
||||
* qwen3-tts-vc 限制每次最多600(含 instructions),保守设为300
|
||||
* 普通 qwen3-tts 模型可适当放宽
|
||||
*/
|
||||
function splitTextForTTS(text: string, maxLen: number = 300): string[] {
|
||||
if (text.length <= maxLen) return [text];
|
||||
|
||||
const segments: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLen) {
|
||||
segments.push(remaining);
|
||||
break;
|
||||
}
|
||||
// 在 maxLen 范围内找最后一个自然断句点
|
||||
const chunk = remaining.slice(0, maxLen);
|
||||
let splitIdx = -1;
|
||||
// 优先按句号/问号/感叹号分割
|
||||
for (const sep of ['。', '!', '?', '!', '?', ';', ';', '\n']) {
|
||||
const idx = chunk.lastIndexOf(sep);
|
||||
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
|
||||
}
|
||||
// 其次按逗号/顿号分割
|
||||
if (splitIdx === -1) {
|
||||
for (const sep of [',', ',', '、', ':', ':']) {
|
||||
const idx = chunk.lastIndexOf(sep);
|
||||
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
|
||||
}
|
||||
}
|
||||
// 实在找不到就硬切
|
||||
if (splitIdx === -1) splitIdx = maxLen;
|
||||
|
||||
segments.push(remaining.slice(0, splitIdx).trim());
|
||||
remaining = remaining.slice(splitIdx).trim();
|
||||
}
|
||||
|
||||
return segments.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
export async function synthesizeSpeech(
|
||||
text: string,
|
||||
config: CosyVoiceConfig
|
||||
): Promise<CosyVoiceResult> {
|
||||
logger.info('[QwenTTS] 开始语音合成', {
|
||||
model: config.model,
|
||||
voice: config.voice,
|
||||
instruction: config.instruction,
|
||||
language: config.language,
|
||||
language_hints: config.language_hints,
|
||||
textLength: text.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// ✅ 关键修复:长文本自动分段(避免 "Range of input length should be [0, 600]" 错误)
|
||||
// VC 模型(声音克隆)的限制更严格,API 可能将 text + instructions 合并计算
|
||||
const instructionLen = config.instruction ? config.instruction.length : 0;
|
||||
const isVcModel = config.model?.includes('-vc') || config.model?.includes('_vc');
|
||||
// VC 模型更保守(200字),普通模型 300 字,减去 instruction 长度
|
||||
const baseMaxLen = isVcModel ? 200 : 300;
|
||||
const effectiveMaxLen = Math.max(100, baseMaxLen - instructionLen);
|
||||
const segments = splitTextForTTS(text, effectiveMaxLen);
|
||||
logger.info(`[QwenTTS] 文本分段: ${segments.length}段, 各段长度: [${segments.map(s => s.length).join(', ')}]`, {
|
||||
isVcModel, instructionLen, effectiveMaxLen
|
||||
});
|
||||
|
||||
// 准备输出路径
|
||||
let outputPath: string;
|
||||
if (config.outputPath) {
|
||||
outputPath = config.outputPath;
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
} else {
|
||||
const outputDir = path.join(app.getPath('userData'), 'temp', 'tts');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const defaultExt = (config.format || '').toLowerCase() === 'wav' ? 'wav' : 'mp3';
|
||||
outputPath = path.join(outputDir, `qwen_tts_${Date.now()}.${defaultExt}`);
|
||||
}
|
||||
|
||||
const outputFormat = getOutputFormat(outputPath, config.format);
|
||||
|
||||
// 如果只有一段,直接合成
|
||||
if (segments.length === 1) {
|
||||
return await synthesizeOneSegment(segments[0], config, outputPath);
|
||||
}
|
||||
|
||||
// 多段:逐段合成,然后拼接 WAV
|
||||
const segmentPaths: string[] = [];
|
||||
const parsedOutput = path.parse(outputPath);
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segPath = path.join(
|
||||
parsedOutput.dir,
|
||||
`${parsedOutput.name}_seg${i}${parsedOutput.ext || (outputFormat === 'wav' ? '.wav' : '.mp3')}`
|
||||
);
|
||||
logger.info(`[QwenTTS] 合成第 ${i + 1}/${segments.length} 段 (${segments[i].length}字)...`);
|
||||
const segResult = await synthesizeOneSegment(segments[i], config, segPath);
|
||||
if (!segResult.success) {
|
||||
return segResult; // 任一段失败则返回错误
|
||||
}
|
||||
segmentPaths.push(segPath);
|
||||
}
|
||||
|
||||
// 拼接所有段的 WAV 文件(简单拼接 PCM 数据)
|
||||
try {
|
||||
await concatAudioFiles(segmentPaths, outputPath, outputFormat);
|
||||
// 清理临时分段文件
|
||||
for (const p of segmentPaths) {
|
||||
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
|
||||
}
|
||||
logger.info('[QwenTTS] 多段音频拼接完成', { outputPath, segments: segments.length });
|
||||
} catch (concatError) {
|
||||
// 拼接失败则使用第一段
|
||||
logger.error('[QwenTTS] 音频拼接失败,使用第一段', concatError);
|
||||
if (segmentPaths.length > 0 && fs.existsSync(segmentPaths[0])) {
|
||||
fs.copyFileSync(segmentPaths[0], outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath: outputPath,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[QwenTTS] 语音合成失败', error.response?.data || error.message || error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || '语音合成失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成单段文本
|
||||
*/
|
||||
async function synthesizeOneSegment(
|
||||
text: string,
|
||||
config: CosyVoiceConfig,
|
||||
outputPath: string
|
||||
): Promise<CosyVoiceResult> {
|
||||
const instrLen = config.instruction ? config.instruction.length : 0;
|
||||
const outputFormat = getOutputFormat(outputPath, config.format);
|
||||
logger.info(`[QwenTTS] synthesizeOneSegment: textLen=${text.length}, instructionLen=${instrLen}, totalLen=${text.length + instrLen}, model=${config.model}`);
|
||||
|
||||
const response = await axios.post(
|
||||
QWEN_TTS_URL,
|
||||
{
|
||||
model: config.model,
|
||||
input: {
|
||||
text,
|
||||
voice: config.voice,
|
||||
...(mapLanguageType(config.language_hints || config.language)
|
||||
? { language_type: mapLanguageType(config.language_hints || config.language) }
|
||||
: {}),
|
||||
...(config.instruction ? { instructions: config.instruction, optimize_instructions: true } : {}),
|
||||
},
|
||||
},
|
||||
withoutUnsupportedProxy({
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
);
|
||||
|
||||
const audioUrl = response.data?.output?.audio?.url;
|
||||
const requestId = response.data?.request_id;
|
||||
if (!audioUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data?.message || '未获取到音频 URL',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
const audioResponse = await axios.get(audioUrl, withoutUnsupportedProxy({
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 600000,
|
||||
}));
|
||||
await writeAudioBufferAsTargetFormat(
|
||||
Buffer.from(audioResponse.data),
|
||||
outputPath,
|
||||
outputFormat,
|
||||
audioResponse.headers?.['content-type']
|
||||
);
|
||||
|
||||
logger.info('[QwenTTS] 单段音频已保存', { outputPath, requestId, textLen: text.length });
|
||||
return {
|
||||
success: true,
|
||||
audioPath: outputPath,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接多个 WAV 文件(假设格式相同:都是 PCM WAV)
|
||||
*/
|
||||
async function concatWavFiles(inputPaths: string[], outputPath: string): Promise<void> {
|
||||
if (inputPaths.length === 0) throw new Error('无输入文件');
|
||||
if (inputPaths.length === 1) {
|
||||
fs.copyFileSync(inputPaths[0], outputPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取第一个文件的 WAV 头(44字节)
|
||||
const firstFile = fs.readFileSync(inputPaths[0]);
|
||||
const headerSize = 44;
|
||||
const header = Buffer.from(firstFile.buffer, 0, headerSize);
|
||||
|
||||
// 收集所有 PCM 数据(跳过 WAV 头)
|
||||
const pcmBuffers: Buffer[] = [];
|
||||
let totalPcmSize = 0;
|
||||
for (const p of inputPaths) {
|
||||
const buf = fs.readFileSync(p);
|
||||
const pcm = buf.slice(headerSize);
|
||||
pcmBuffers.push(pcm);
|
||||
totalPcmSize += pcm.length;
|
||||
}
|
||||
|
||||
// 更新 WAV 头中的文件大小字段
|
||||
const newHeader = Buffer.from(header);
|
||||
newHeader.writeUInt32LE(totalPcmSize + headerSize - 8, 4); // ChunkSize
|
||||
newHeader.writeUInt32LE(totalPcmSize, 40); // Subchunk2Size
|
||||
|
||||
// 写入文件
|
||||
const outputBuf = Buffer.concat([newHeader, ...pcmBuffers]);
|
||||
fs.writeFileSync(outputPath, outputBuf);
|
||||
}
|
||||
|
||||
async function concatMp3Files(inputPaths: string[], outputPath: string): Promise<void> {
|
||||
const concatListPath = path.join(
|
||||
path.dirname(outputPath),
|
||||
`concat_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`
|
||||
);
|
||||
const concatList = inputPaths
|
||||
.map(filePath => {
|
||||
const normalized = filePath.replace(/\\/g, '/').replace(/'/g, "'\\''");
|
||||
return `file '${normalized}'`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(concatListPath, concatList, 'utf-8');
|
||||
try {
|
||||
await Apps.spawnBinary('ffmpeg', [
|
||||
'-y',
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', concatListPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
outputPath,
|
||||
], {
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
try { fs.unlinkSync(concatListPath); } catch (_error) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function concatAudioFiles(
|
||||
inputPaths: string[],
|
||||
outputPath: string,
|
||||
format: 'wav' | 'mp3'
|
||||
): Promise<void> {
|
||||
if (format === 'wav') {
|
||||
await concatWavFiles(inputPaths, outputPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await concatMp3Files(inputPaths, outputPath);
|
||||
}
|
||||
|
||||
export function getAvailableVoices(model: string): Array<{ id: string; name: string; desc: string }> {
|
||||
if (model.startsWith('qwen3-tts') || model.startsWith('qwen-tts')) {
|
||||
return [
|
||||
{ id: 'Cherry', name: '芊悦', desc: '普通话,女声,通用播报' },
|
||||
{ id: 'Serena', name: '苏瑶', desc: '普通话,女声,温柔自然' },
|
||||
{ id: 'Ethan', name: '晨煦', desc: '普通话,男声,稳重清晰' },
|
||||
{ id: 'Jada', name: '上海-阿珍', desc: '上海话,女声,方言播报' },
|
||||
{ id: 'Dylan', name: '北京-晓东', desc: '北京话,男声,方言播报' },
|
||||
{ id: 'Li', name: '南京-老李', desc: '南京话,男声,方言播报' },
|
||||
{ id: 'Marcus', name: '陕西-秦川', desc: '陕西话,男声,方言播报' },
|
||||
{ id: 'Roy', name: '闽南-阿杰', desc: '闽南语,男声,方言播报' },
|
||||
{ id: 'Peter', name: '天津-李彼得', desc: '天津话,男声,方言播报' },
|
||||
{ id: 'Sunny', name: '四川-晴儿', desc: '四川话,女声,方言播报' },
|
||||
{ id: 'Eric', name: '四川-程川', desc: '四川话,男声,方言播报' },
|
||||
{ id: 'Rocky', name: '粤语-阿强', desc: '粤语,男声,方言播报' },
|
||||
{ id: 'Kiki', name: '粤语-阿清', desc: '粤语,女声,方言播报' },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getInstructionForLanguage(language: string): string | undefined {
|
||||
if (!language || language === '中文' || language === '中文(普通话)') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dialectMap: Record<string, string> = {
|
||||
'上海话': '请用上海话表达。',
|
||||
'北京话': '请用北京话表达。',
|
||||
'四川话': '请用四川话表达。',
|
||||
'南京话': '请用南京话表达。',
|
||||
'陕西话': '请用陕西话表达。',
|
||||
'闽南话': '请用闽南话表达。',
|
||||
'天津话': '请用天津话表达。',
|
||||
'粤语': '请用粤语表达。',
|
||||
'广东话': '请用粤语表达。',
|
||||
};
|
||||
if (dialectMap[language]) {
|
||||
return dialectMap[language];
|
||||
}
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
'英语': 'Please speak in English.',
|
||||
'德语': 'Please speak in German.',
|
||||
'意大利语': 'Please speak in Italian.',
|
||||
'葡萄牙语': 'Please speak in Portuguese.',
|
||||
'西班牙语': 'Please speak in Spanish.',
|
||||
'日语': 'Please speak in Japanese.',
|
||||
'韩语': 'Please speak in Korean.',
|
||||
'法语': 'Please speak in French.',
|
||||
'俄语': 'Please speak in Russian.',
|
||||
};
|
||||
|
||||
return languageMap[language];
|
||||
}
|
||||
|
||||
function mapLanguageType(language?: string): string | undefined {
|
||||
const languageMap: Record<string, string> = {
|
||||
'中文': 'Chinese',
|
||||
'中文(普通话)': 'Chinese',
|
||||
'Chinese': 'Chinese',
|
||||
'英语': 'English',
|
||||
'English': 'English',
|
||||
'德语': 'German',
|
||||
'German': 'German',
|
||||
'意大利语': 'Italian',
|
||||
'Italian': 'Italian',
|
||||
'葡萄牙语': 'Portuguese',
|
||||
'Portuguese': 'Portuguese',
|
||||
'西班牙语': 'Spanish',
|
||||
'Spanish': 'Spanish',
|
||||
'日语': 'Japanese',
|
||||
'Japanese': 'Japanese',
|
||||
'韩语': 'Korean',
|
||||
'Korean': 'Korean',
|
||||
'法语': 'French',
|
||||
'French': 'French',
|
||||
'俄语': 'Russian',
|
||||
'Russian': 'Russian',
|
||||
};
|
||||
|
||||
return language ? languageMap[language] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import logger from '../log/main';
|
||||
import { getFFmpegPath, getFFprobePath } from '../shell';
|
||||
|
||||
const DASHSCOPE_API_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
const MIN_ENROLLMENT_SAMPLE_RATE = 16000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function ensureEnrollmentAudio(filePath: string): Promise<string> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const ffprobePath = await getFFprobePath();
|
||||
const ffmpegPath = await getFFmpegPath();
|
||||
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(ffprobePath, [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=sample_rate,channels',
|
||||
'-of', 'json',
|
||||
filePath,
|
||||
]);
|
||||
const probe = JSON.parse(stdout || '{}');
|
||||
const stream = probe?.streams?.[0] || {};
|
||||
sampleRate = parseInt(stream.sample_rate || '0', 10) || 0;
|
||||
channels = parseInt(stream.channels || '0', 10) || 0;
|
||||
} catch (error: any) {
|
||||
logger.warn('[Aliyun:Enrollment] ffprobe 检测失败,直接转码兜底', {
|
||||
filePath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const needsConvert = !sampleRate || sampleRate < MIN_ENROLLMENT_SAMPLE_RATE || channels !== 1 || path.extname(filePath).toLowerCase() !== '.wav';
|
||||
if (!needsConvert) {
|
||||
logger.info('[Aliyun:Enrollment] 音频格式符合要求,直接使用', { filePath, sampleRate, channels });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const parsed = path.parse(filePath);
|
||||
const normalizedPath = (p: string) => process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
|
||||
const outputPath = path.join(parsed.dir, `${parsed.name}_enroll_16k.wav`);
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 开始转码复刻音频', {
|
||||
input: filePath,
|
||||
output: outputPath,
|
||||
sourceSampleRate: sampleRate,
|
||||
sourceChannels: channels,
|
||||
targetSampleRate: MIN_ENROLLMENT_SAMPLE_RATE,
|
||||
targetChannels: 1,
|
||||
});
|
||||
|
||||
await execFileAsync(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', normalizedPath(filePath),
|
||||
'-vn',
|
||||
'-acodec', 'pcm_s16le',
|
||||
'-ar', String(MIN_ENROLLMENT_SAMPLE_RATE),
|
||||
'-ac', '1',
|
||||
normalizedPath(outputPath),
|
||||
]);
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 音频转码完成', { outputPath });
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阿里云 DashScope Voice Enrollment (声音复刻) 服务
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将本地音频文件转换为 Base64 Data URI
|
||||
* DashScope create_voice 接口支持 data:{mime_type};base64,{base64_str} 格式
|
||||
* 这完全绕过了需要公网URL的问题!
|
||||
*/
|
||||
export async function uploadFileToLease(apiKey: string, filePath: string): Promise<string> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const enrollmentAudioPath = await ensureEnrollmentAudio(filePath);
|
||||
const fileName = path.basename(enrollmentAudioPath);
|
||||
const ext = path.extname(enrollmentAudioPath).toLowerCase();
|
||||
|
||||
// 根据扩展名确定 MIME 类型
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mp3',
|
||||
'.m4a': 'audio/m4a',
|
||||
'.flac': 'audio/flac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.aac': 'audio/aac',
|
||||
'.wma': 'audio/wma',
|
||||
'.amr': 'audio/amr',
|
||||
'.pcm': 'audio/pcm'
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || 'audio/wav';
|
||||
|
||||
try {
|
||||
logger.info(`[Aliyun:Enrollment] 将音频文件转换为 Base64 Data URI: ${fileName}`);
|
||||
|
||||
// 读取文件并转换为 Base64
|
||||
const fileBuffer = fs.readFileSync(enrollmentAudioPath);
|
||||
const base64Str = fileBuffer.toString('base64');
|
||||
|
||||
// 构造 Data URI
|
||||
const dataUri = `data:${mimeType};base64,${base64Str}`;
|
||||
|
||||
logger.info(`[Aliyun:Enrollment] 文件转换成功, 大小: ${Math.round(fileBuffer.length / 1024)} KB, MIME: ${mimeType}`);
|
||||
|
||||
return dataUri;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] 文件转换失败', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 废弃旧的 applyLease 和 uploadToOss 函数
|
||||
// async function applyLease ...
|
||||
// async function uploadToOss ...
|
||||
|
||||
/*
|
||||
interface LeaseResponse {
|
||||
url: string; // 用于后续API调用的文件URL
|
||||
param: {
|
||||
url: string; // 上传地址
|
||||
method: string;
|
||||
headers: any;
|
||||
};
|
||||
upload_method: string;
|
||||
}
|
||||
|
||||
async function applyLease(apiKey: string, extension: string): Promise<LeaseResponse> {
|
||||
const url = `${DASHSCOPE_API_URL}/uploads`;
|
||||
const payload = {
|
||||
action: 'getPolicy', // 注意:DashScope不同服务的上传接口可能不同,这里使用通用的uploads/getPolicy ?
|
||||
// 修正:DashScope有一个通用的 `uploads` 接口用于申请 lease
|
||||
// 文档: POST https://dashscope.aliyuncs.com/api/v1/uploads
|
||||
model: 'voice-enrollment' // 指定模型
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
params: payload,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 响应结构:
|
||||
// {
|
||||
// "data": {
|
||||
// "url": "https://...",
|
||||
// "param": { ... },
|
||||
// "upload_method": "PUT" / "POST"
|
||||
// }
|
||||
// }
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
const data = response.data.data;
|
||||
logger.info('[Aliyun:Enrollment] Apply Lease 响应:', JSON.stringify(data));
|
||||
|
||||
// 适配 OSS PostObject 格式
|
||||
// https://help.aliyun.com/zh/oss/developer-reference/postobject
|
||||
if (data.policy && data.signature && data.upload_host) {
|
||||
// 构造文件名(使用随机UUID或保留原扩展名)
|
||||
// 上传后的 URL = upload_host + / + upload_dir + / + filename
|
||||
// 但是 DashScope 可能要求特定的 key 格式?
|
||||
// data.upload_dir 是前缀。
|
||||
// 我们需要自己生成 key。
|
||||
|
||||
return {
|
||||
url: '', // 这里的 URL 需要在上传后拼接,或者 DashScope 不需要?
|
||||
// 文档说 create_voice 需要 public url.
|
||||
// OSS PostObject 不会自动返回 URL。
|
||||
// 通常是 upload_host + '/' + key
|
||||
param: {
|
||||
url: data.upload_host,
|
||||
method: 'POST',
|
||||
headers: {}, // PostObject 不需要 Authorization header,签名在 body 里
|
||||
formData: {
|
||||
'key': data.upload_dir + '/${filename}', // 这是一个模板,实际上传时替换
|
||||
'policy': data.policy,
|
||||
'Signature': data.signature,
|
||||
'OSSAccessKeyId': data.oss_access_key_id,
|
||||
'x-oss-object-acl': data.x_oss_object_acl,
|
||||
'x-oss-forbid-overwrite': data.x_oss_forbid_overwrite,
|
||||
// 'file': ... (在 uploadToOss 中添加)
|
||||
},
|
||||
// 用于拼接最终 URL
|
||||
uploadDir: data.upload_dir,
|
||||
uploadHost: data.upload_host
|
||||
},
|
||||
upload_method: 'POST'
|
||||
} as LeaseResponse; // 临时通过 any 绕过类型检查,稍后修正 Interface
|
||||
}
|
||||
|
||||
// 兼容性处理:如果 param 不存在,可能是扁平结构,或者结构有所不同
|
||||
if (!data.param) {
|
||||
// 如果没有 param,看看有没有 url
|
||||
if (data.url) {
|
||||
// 构造一个伪 param
|
||||
return {
|
||||
url: data.url,
|
||||
upload_method: data.upload_method || 'POST', // 默认 POST?
|
||||
param: {
|
||||
url: data.url,
|
||||
method: data.upload_method || 'PUT', // 大多数预签名URL是PUT
|
||||
headers: data.headers || {} // 扁平结构可能有 headers
|
||||
}
|
||||
} as LeaseResponse;
|
||||
}
|
||||
}
|
||||
return data as LeaseResponse;
|
||||
} else {
|
||||
logger.error('[Aliyun:Enrollment] Apply Lease 响应异常:', JSON.stringify(response.data));
|
||||
throw new Error('申请上传凭证失败: ' + JSON.stringify(response.data));
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] Apply Lease 失败', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToOss(filePath: string, param: any): Promise<string> {
|
||||
const fileBuffer = fs.existsSync(filePath) ? fs.readFileSync(filePath) : null;
|
||||
if (!fileBuffer) throw new Error(`文件读取失败: ${filePath}`);
|
||||
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
try {
|
||||
const method = param.method || 'PUT';
|
||||
logger.info(`[Aliyun:Enrollment] 上传文件到临时存储 URL: ${param.url}`);
|
||||
|
||||
if (method.toUpperCase() === 'POST' && param.formData) {
|
||||
logger.info('[Aliyun:Enrollment] 使用 FormData (OSS PostObject) 上传');
|
||||
|
||||
// 使用 Node.js 全局 FormData (Node 18+)
|
||||
// 如果环境不支持,这里会报错。Electron 29 (Node 20) 应该支持。
|
||||
const FormDataClass = (global as any).FormData;
|
||||
if (!FormDataClass) {
|
||||
throw new Error('当前环境不支持全局 FormData,无法执行 POST 上传');
|
||||
}
|
||||
|
||||
const form = new FormDataClass();
|
||||
|
||||
// 构造 Key (FilePath in OSS)
|
||||
// 上面的 param.formData['key'] 是个模板: .../${filename}
|
||||
// 我们生成一个唯一文件名,或者直接用 fileName
|
||||
const finalKey = param.formData['key'].replace('${filename}', fileName);
|
||||
|
||||
// 必须按照顺序添加吗?OSS 对顺序通常不敏感,但 file 最好在最后
|
||||
form.append('key', finalKey);
|
||||
form.append('policy', param.formData['policy']);
|
||||
form.append('Signature', param.formData['Signature']);
|
||||
form.append('OSSAccessKeyId', param.formData['OSSAccessKeyId']);
|
||||
if (param.formData['x-oss-object-acl']) form.append('x-oss-object-acl', param.formData['x-oss-object-acl']);
|
||||
if (param.formData['x-oss-forbid-overwrite']) form.append('x-oss-forbid-overwrite', param.formData['x-oss-forbid-overwrite']);
|
||||
|
||||
// 添加文件
|
||||
// Node.js 的 FormData 需要 Blob 或 File。
|
||||
// 我们可以用 Blob (Node 18+)
|
||||
const BlobClass = (global as any).Blob;
|
||||
if (!BlobClass) {
|
||||
throw new Error('当前环境不支持全局 Blob');
|
||||
}
|
||||
const blob = new BlobClass([fileBuffer]);
|
||||
form.append('file', blob, fileName);
|
||||
|
||||
// 发送请求
|
||||
// axios 在 Node 中支持传入 FormData 实例,并自动设置 headers (Content-Type: multipart/form-data; boundary=...)
|
||||
await axios.post(param.url, form, {
|
||||
headers: {
|
||||
// 让 axios/FormData 自动计算 Content-Type
|
||||
// 'Content-Type': 'multipart/form-data' // 不要手动设置,否则丢失 boundary
|
||||
}
|
||||
});
|
||||
|
||||
// 更新 leaseData.url,因为 PostObject 不会返回,我们需要自己算
|
||||
// param 是引用传递吗?不是,这里是 uploadToOss。
|
||||
// 我们需要一种方式把 URL 传回去。
|
||||
// 这里我们无法修改外部的 leaseData。
|
||||
// 所以 uploadToOss 应该返回 url。
|
||||
|
||||
const objectUrl = `${param.uploadHost}/${finalKey}`;
|
||||
logger.info(`[Aliyun:Enrollment] OSS 上传成功, URL: ${objectUrl}`);
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
// PUT 逻辑保持不变
|
||||
else if (method.toUpperCase() === 'PUT') {
|
||||
logger.info(`[Aliyun:Enrollment] 上传 Method: ${method}, Headers:`, param.headers);
|
||||
await axios.put(param.url, fileBuffer, {
|
||||
headers: param.headers || {}
|
||||
});
|
||||
logger.info('[Aliyun:Enrollment] 上传 OSS 成功');
|
||||
return param.url.split('?')[0]; // 去除签名参数得到纯URL? 不一定,视情况而定
|
||||
} else {
|
||||
logger.warn(`[Aliyun:Enrollment] 未知上传方法: ${method}, 尝试 Generic Request`);
|
||||
await axios({
|
||||
method: method,
|
||||
url: param.url,
|
||||
headers: param.headers,
|
||||
data: fileBuffer
|
||||
});
|
||||
logger.info('[Aliyun:Enrollment] 上传 OSS 成功');
|
||||
return param.url.split('?')[0]; // 假设通用请求也返回带签名的URL
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// 详细错误日志
|
||||
const errInfo = {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
headers: error.response?.headers,
|
||||
message: error.message
|
||||
};
|
||||
logger.error('[Aliyun:Enrollment] 上传 OSS 失败', JSON.stringify(errInfo));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建复刻音色
|
||||
*/
|
||||
export async function createVoice(apiKey: string, audioUrl: string, prefix: string = 'custom_voice'): Promise<string> {
|
||||
const url = `${DASHSCOPE_API_URL}/services/audio/tts/customization`;
|
||||
|
||||
const payload = {
|
||||
model: 'qwen-voice-enrollment',
|
||||
input: {
|
||||
action: 'create',
|
||||
target_model: 'qwen3-tts-vc-2026-01-22',
|
||||
preferred_name: prefix,
|
||||
audio: {
|
||||
data: audioUrl
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('[Aliyun:Enrollment] 开始复刻音色', payload);
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const voiceId = data.output?.voice || data.output?.voice_id;
|
||||
if (voiceId) {
|
||||
logger.info('[Aliyun:Enrollment] 复刻成功', data.output);
|
||||
return voiceId;
|
||||
} else {
|
||||
throw new Error('复刻响应格式错误: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:Enrollment] 复刻失败', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import WebSocket from 'ws';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import logger from '../log/main';
|
||||
|
||||
/**
|
||||
* 阿里云 FunASR 语音识别 WebSocket 客户端
|
||||
* 文档: https://help.aliyun.com/zh/model-studio/fun-asr-realtime-python-sdk
|
||||
*/
|
||||
|
||||
export interface FunAsrConfig {
|
||||
apiKey: string;
|
||||
model: string; // qwen3-asr-flash | fun-asr-realtime | fun-asr
|
||||
format: string; // pcm | wav | mp3 | opus | speex | aac | amr
|
||||
sampleRate: number; // 音频采样率,支持 16000Hz
|
||||
language?: string[]; // 语言代码: zh, en, ja
|
||||
}
|
||||
|
||||
export interface AsrWord {
|
||||
text: string;
|
||||
startTime: number; // 毫秒
|
||||
endTime: number; // 毫秒
|
||||
}
|
||||
|
||||
export interface AsrSentence {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
words?: AsrWord[];
|
||||
}
|
||||
|
||||
export interface FunAsrResult {
|
||||
success: boolean;
|
||||
sentences?: AsrSentence[];
|
||||
fullText?: string;
|
||||
error?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FunASR 语音识别主函数
|
||||
*/
|
||||
export async function recognizeAudio(
|
||||
audioFilePath: string,
|
||||
config: FunAsrConfig
|
||||
): Promise<FunAsrResult> {
|
||||
const wsUrl = 'wss://dashscope.aliyuncs.com/api-ws/v1/inference';
|
||||
|
||||
logger.info('[FunASR] 开始语音识别', {
|
||||
model: config.model,
|
||||
format: config.format,
|
||||
sampleRate: config.sampleRate,
|
||||
audioFile: path.basename(audioFilePath)
|
||||
});
|
||||
|
||||
// 读取音频文件
|
||||
if (!fs.existsSync(audioFilePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频文件不存在: ${audioFilePath}`
|
||||
};
|
||||
}
|
||||
|
||||
const audioData = fs.readFileSync(audioFilePath);
|
||||
logger.info(`[FunASR] 音频文件大小: ${audioData.length} bytes`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'X-DashScope-DataInspection': 'enable'
|
||||
}
|
||||
});
|
||||
const sentences: AsrSentence[] = [];
|
||||
let requestId = '';
|
||||
let startTime = Date.now();
|
||||
let isFirstPackage = true;
|
||||
let taskId = '';
|
||||
let audioOffset = 0;
|
||||
const chunkSize = 32000; // 每次发送32KB,加速处理
|
||||
|
||||
// 发送下一个音频块的函数(快速批量模式)
|
||||
const sendNextChunk = () => {
|
||||
if (audioOffset >= audioData.length) {
|
||||
// 发送完成信号
|
||||
const finishMessage = {
|
||||
header: {
|
||||
action: 'finish-task',
|
||||
task_id: taskId
|
||||
},
|
||||
payload: {
|
||||
input: {} // 必需字段
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(finishMessage), (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送完成信号失败', err);
|
||||
} else {
|
||||
logger.info('[FunASR] 音频数据发送完成');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = audioData.slice(audioOffset, audioOffset + chunkSize);
|
||||
audioOffset += chunkSize;
|
||||
|
||||
// 发送二进制音频数据
|
||||
ws.send(chunk, (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送音频块失败', err);
|
||||
ws.close();
|
||||
} else {
|
||||
// 快速批量模式:5ms间隔(接近无延迟但避免缓冲区溢出)
|
||||
setImmediate(sendNextChunk);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.info('[FunASR] WebSocket 连接已建立');
|
||||
|
||||
// 生成任务ID
|
||||
taskId = generateTaskId();
|
||||
|
||||
// 第一步:发送配置消息(run-task指令)
|
||||
const configMessage = {
|
||||
header: {
|
||||
action: 'run-task',
|
||||
streaming: 'duplex',
|
||||
task_id: taskId
|
||||
},
|
||||
payload: {
|
||||
model: config.model,
|
||||
task: 'asr',
|
||||
task_group: 'audio',
|
||||
function: 'recognition',
|
||||
input: {}, // 必需字段,空对象
|
||||
parameters: {
|
||||
format: config.format,
|
||||
sample_rate: config.sampleRate,
|
||||
language_hints: config.language || ['zh', 'en'],
|
||||
punctuation_prediction_enabled: true,
|
||||
semantic_punctuation_enabled: false,
|
||||
max_sentence_silence: 300, // 🔧 修复:降低到300ms,让句子分割更细
|
||||
word_timestamp_enabled: true // 🔧 新增:启用词级时间戳
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(configMessage), (err) => {
|
||||
if (err) {
|
||||
logger.error('[FunASR] 发送配置失败', err);
|
||||
ws.close();
|
||||
reject(new Error(`发送配置失败: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[FunASR] 配置已发送,立即开始发送音频...');
|
||||
// 根据官方文档:start()后立即发送音频,不需要等待服务器响应
|
||||
sendNextChunk();
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
// 调试日志:打印服务器返回的完整消息结构
|
||||
logger.info('[FunASR] 收到消息:', JSON.stringify(message.header || {}));
|
||||
|
||||
if (message.header) {
|
||||
requestId = message.header.task_id || message.header.request_id || '';
|
||||
|
||||
// 检查服务器是否准备好接收音频
|
||||
// FunASR可能使用不同的事件名,在收到首包响应后就开始发送
|
||||
const event = message.header.event || message.header.action || '';
|
||||
|
||||
// 首包延迟统计(仅用于调试)
|
||||
if (isFirstPackage) {
|
||||
const firstPackageDelay = Date.now() - startTime;
|
||||
logger.info(`[FunASR] 收到首包结果,延迟: ${firstPackageDelay}ms, event: ${event}`);
|
||||
isFirstPackage = false;
|
||||
// 注意:音频发送已在配置发送后立即开始,这里不再重复触发
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if (message.header.status === 'failed') {
|
||||
const errorMsg = message.header.message || '识别失败';
|
||||
logger.error('[FunASR] 识别失败', message);
|
||||
ws.close();
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
requestId
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理识别结果
|
||||
if (message.payload && message.payload.output) {
|
||||
const output = message.payload.output;
|
||||
|
||||
// 中间结果
|
||||
if (output.sentence) {
|
||||
const sentence = output.sentence;
|
||||
|
||||
// 只处理已完成的句子
|
||||
if (sentence.end_time) {
|
||||
// 🔧 添加详细日志
|
||||
logger.info('[FunASR] 原始句子数据:', {
|
||||
text: sentence.text,
|
||||
begin_time: sentence.begin_time,
|
||||
end_time: sentence.end_time,
|
||||
完整数据: JSON.stringify(sentence)
|
||||
});
|
||||
|
||||
const asrSentence: AsrSentence = {
|
||||
text: sentence.text || '',
|
||||
startTime: sentence.begin_time || 0,
|
||||
endTime: sentence.end_time || 0
|
||||
};
|
||||
|
||||
// 添加词级时间戳
|
||||
if (sentence.words && Array.isArray(sentence.words)) {
|
||||
asrSentence.words = sentence.words.map((word: any) => ({
|
||||
text: word.text || '',
|
||||
startTime: word.begin_time || 0,
|
||||
endTime: word.end_time || 0
|
||||
}));
|
||||
}
|
||||
|
||||
sentences.push(asrSentence);
|
||||
logger.info(`[FunASR] 识别到句子: ${asrSentence.text} (${asrSentence.startTime} - ${asrSentence.endTime})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('[FunASR] 处理消息失败', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
logger.info(`[FunASR] WebSocket 连接已关闭,总耗时: ${totalDuration}ms`);
|
||||
|
||||
if (sentences.length === 0) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: '未识别到任何文本',
|
||||
requestId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并所有句子文本
|
||||
const fullText = sentences.map(s => s.text).join('');
|
||||
|
||||
logger.info(`[FunASR] 识别完成,共${sentences.length}个句子,总文本长度: ${fullText.length}`);
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
sentences,
|
||||
fullText,
|
||||
requestId
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('[FunASR] WebSocket 错误', error);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `WebSocket错误: ${error.message}`,
|
||||
requestId
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务ID
|
||||
*/
|
||||
function generateTaskId(): string {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测音频格式
|
||||
*/
|
||||
export function detectAudioFormat(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const formatMap: { [key: string]: string } = {
|
||||
'.wav': 'wav',
|
||||
'.mp3': 'mp3',
|
||||
'.pcm': 'pcm',
|
||||
'.opus': 'opus',
|
||||
'.aac': 'aac',
|
||||
'.amr': 'amr',
|
||||
'.speex': 'speex'
|
||||
};
|
||||
|
||||
return formatMap[ext] || 'wav';
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 阿里云语音服务模块入口
|
||||
* 整合 CosyVoice (TTS) 和 FunASR (ASR) 功能
|
||||
*/
|
||||
|
||||
import * as cosyvoice from './cosyvoice';
|
||||
import * as funasr from './funasr';
|
||||
import * as enrollment from './enrollment';
|
||||
import logger from '../log/main';
|
||||
|
||||
export {
|
||||
cosyvoice,
|
||||
funasr,
|
||||
enrollment
|
||||
};
|
||||
|
||||
// 导出主要函数
|
||||
export const {
|
||||
synthesizeSpeech,
|
||||
getAvailableVoices
|
||||
} = cosyvoice;
|
||||
|
||||
export const {
|
||||
recognizeAudio,
|
||||
detectAudioFormat
|
||||
} = funasr;
|
||||
|
||||
// 导出类型
|
||||
export type {
|
||||
CosyVoiceConfig,
|
||||
CosyVoiceResult
|
||||
} from './cosyvoice';
|
||||
|
||||
export type {
|
||||
FunAsrConfig,
|
||||
FunAsrResult,
|
||||
AsrSentence,
|
||||
AsrWord
|
||||
} from './funasr';
|
||||
|
||||
/**
|
||||
* 执行声音复刻流程
|
||||
*/
|
||||
export async function enrollVoice(params: {
|
||||
apiKey: string,
|
||||
audioPath: string,
|
||||
name: string
|
||||
}): Promise<{ success: boolean, voiceId?: string, error?: string }> {
|
||||
try {
|
||||
logger.info('[Aliyun] 开始执行声音复刻', { name: params.name, audioPath: params.audioPath });
|
||||
|
||||
// 1. 上传文件获取 URL
|
||||
const audioUrl = await enrollment.uploadFileToLease(params.apiKey, params.audioPath);
|
||||
|
||||
// 2. 创建音色
|
||||
// 使用 name 作为 prefix,但需过滤非法字符(仅允许数字、字母、下划线)
|
||||
// 阿里云要求 prefix: [a-zA-Z0-9_]{1,10}
|
||||
// 如果 name 有中文,替换为 custom
|
||||
let prefix = params.name.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
// 阿里云要求 prefix: [a-zA-Z0-9_]{1,10}
|
||||
// 如果处理后为空,或者还是太长,则进行截断或使用默认值
|
||||
if (!prefix) {
|
||||
prefix = 'custom';
|
||||
} else if (prefix.length > 10) {
|
||||
prefix = prefix.substring(0, 10);
|
||||
}
|
||||
|
||||
const voiceId = await enrollment.createVoice(params.apiKey, audioUrl, prefix);
|
||||
|
||||
return { success: true, voiceId };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun] 声音复刻失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import * as aliyunApi from './index';
|
||||
import { uploadToOss, getOssConfigFromProvider, OssConfig } from './oss';
|
||||
import { recognizeRecordedAudio } from './transcription';
|
||||
import logger from '../log/main';
|
||||
import { preCacheSystemVoices, getPreCachedVoicePath, isAllVoicesCached, clearPreCache } from './voicePreCache';
|
||||
import { detectAudioSegments, allocateTextToSegments } from '../audio/volumeDetect';
|
||||
|
||||
export function registerAliyunHandlers() {
|
||||
// CosyVoice TTS 合成
|
||||
ipcMain.handle('aliyun:cosyvoice:synthesize', async (event, params) => {
|
||||
try {
|
||||
// 如果指定了 saveToPreCache,自动设置输出路径到预缓存目录
|
||||
if (params.config.saveToPreCache) {
|
||||
const voiceId = params.config.saveToPreCache;
|
||||
const { getPreCacheDir } = await import('./voicePreCache');
|
||||
const path = await import('path');
|
||||
const cacheDir = getPreCacheDir();
|
||||
const outputPath = path.default.join(cacheDir, `${voiceId}.wav`);
|
||||
|
||||
logger.info('[Aliyun:IPC] 保存到预缓存目录', { voiceId, outputPath });
|
||||
|
||||
// 设置输出路径
|
||||
params.config.outputPath = outputPath;
|
||||
// 移除 saveToPreCache,避免传递给 API
|
||||
delete params.config.saveToPreCache;
|
||||
}
|
||||
|
||||
// 🆕 如果 provided language but not instruction,自动生成 instruction
|
||||
if (params.config.language && !params.config.instruction) {
|
||||
const instruction = aliyunApi.cosyvoice.getInstructionForLanguage(params.config.language);
|
||||
if (instruction) {
|
||||
params.config.instruction = instruction;
|
||||
logger.info('[Aliyun:IPC] 已为语言生成指令', {
|
||||
language: params.config.language,
|
||||
instruction
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await aliyunApi.cosyvoice.synthesizeSpeech(params.text, params.config);
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 合成失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// CosyVoice 声音复刻
|
||||
ipcMain.handle('aliyun:cosyvoice:enroll', async (event, params) => {
|
||||
try {
|
||||
// params: { apiKey, audioPath, name }
|
||||
return await aliyunApi.enrollVoice(params);
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 复刻失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// FunASR 语音识别(仅使用录音文件识别模式)
|
||||
ipcMain.handle('aliyun:funasr:recognize', async (event, params) => {
|
||||
try {
|
||||
// params: { audioPath, config: { apiKey, ossConfig } }
|
||||
logger.info('[Aliyun:IPC] 开始语音识别(录音文件识别模式)', { audioPath: params.audioPath });
|
||||
|
||||
const { audioPath, config } = params;
|
||||
|
||||
// 检查OSS配置(必需)
|
||||
logger.info('[Aliyun:IPC] 接收到的配置', {
|
||||
hasOssConfig: !!config.ossConfig,
|
||||
ossConfigKeys: config.ossConfig ? Object.keys(config.ossConfig) : [],
|
||||
ossConfig: config.ossConfig
|
||||
});
|
||||
|
||||
const ossConfig: OssConfig | null = config.ossConfig ? {
|
||||
accessKeyId: config.ossConfig.accessKeyId,
|
||||
accessKeySecret: config.ossConfig.accessKeySecret,
|
||||
bucket: config.ossConfig.bucket,
|
||||
region: config.ossConfig.region,
|
||||
endpoint: config.ossConfig.endpoint
|
||||
} : null;
|
||||
|
||||
// 必须配置OSS才能使用录音识别
|
||||
if (!ossConfig || !ossConfig.accessKeyId || !ossConfig.bucket) {
|
||||
logger.error('[Aliyun:IPC] OSS未配置,无法使用录音识别', { ossConfig });
|
||||
return {
|
||||
success: false,
|
||||
error: '请先在「模型」→「阿里云百炼」中配置OSS(用于快速语音识别)'
|
||||
};
|
||||
}
|
||||
|
||||
if (!ossConfig.region) {
|
||||
logger.error('[Aliyun:IPC] OSS Region 未配置', { ossConfig });
|
||||
return {
|
||||
success: false,
|
||||
error: '请在「模型」→「阿里云百炼」中配置 OSS Region(地域)'
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:IPC] 使用录音文件识别模式', {
|
||||
bucket: ossConfig.bucket,
|
||||
region: ossConfig.region,
|
||||
hasEndpoint: !!ossConfig.endpoint
|
||||
});
|
||||
|
||||
// 检查文件类型,如果是视频文件需要提取音频
|
||||
const path = await import('path');
|
||||
const ext = path.extname(audioPath).toLowerCase();
|
||||
let finalAudioPath = audioPath;
|
||||
|
||||
if (['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'].includes(ext)) {
|
||||
logger.info('[Aliyun:IPC] 检测到视频文件,需要提取音频', { ext, audioPath });
|
||||
try {
|
||||
const { extractAudioFromVideo } = await import('../ipAgent/funasr');
|
||||
const extractedAudioPath = await extractAudioFromVideo(audioPath);
|
||||
finalAudioPath = extractedAudioPath;
|
||||
logger.info('[Aliyun:IPC] 音频提取成功', { extractedAudioPath });
|
||||
} catch (extractError: any) {
|
||||
logger.warn('[Aliyun:IPC] 音频提取失败,继续尝试上传原文件', { error: extractError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 上传到OSS
|
||||
const uploadResult = await uploadToOss(finalAudioPath, ossConfig);
|
||||
if (!uploadResult.success || !uploadResult.url) {
|
||||
logger.error('[Aliyun:IPC] OSS上传失败', uploadResult.error);
|
||||
return { success: false, error: `OSS上传失败: ${uploadResult.error}` };
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:IPC] OSS上传成功', { url: uploadResult.url });
|
||||
|
||||
// 2. 调用录音识别API
|
||||
const result = await recognizeRecordedAudio(uploadResult.url, {
|
||||
apiKey: config.apiKey,
|
||||
model: config.model || 'qwen3-asr-flash-filetrans' // 🔧 修复:默认使用 qwen3-asr-flash-filetrans 模型
|
||||
});
|
||||
|
||||
// 转换结果格式以保持兼容性
|
||||
return {
|
||||
success: result.success,
|
||||
fullText: result.text,
|
||||
sentences: result.sentences?.map(s => ({
|
||||
text: s.text,
|
||||
startTime: s.beginTime,
|
||||
endTime: s.endTime,
|
||||
words: s.words?.map(w => ({
|
||||
text: w.text,
|
||||
startTime: w.beginTime,
|
||||
endTime: w.endTime
|
||||
}))
|
||||
})),
|
||||
error: result.error,
|
||||
requestId: result.taskId
|
||||
};
|
||||
} catch (error: any) {
|
||||
// 确保错误消息可序列化(避免循环引用)
|
||||
let errorMessage = '未知错误';
|
||||
if (error && typeof error === 'object') {
|
||||
errorMessage = error.message || error.toString();
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
}
|
||||
|
||||
logger.error('[Aliyun:IPC] 语音识别失败', { errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// FunASR 使用 URL 直接识别(无需上传OSS)
|
||||
ipcMain.handle('aliyun:funasr:recognizeFromUrl', async (event, params) => {
|
||||
try {
|
||||
// params: { audioUrl, apiKey }
|
||||
logger.info('[Aliyun:IPC] 开始语音识别(URL模式)', { audioUrl: params.audioUrl });
|
||||
|
||||
const { audioUrl, apiKey } = params;
|
||||
|
||||
if (!audioUrl || !apiKey) {
|
||||
return { success: false, error: '缺少必要参数:audioUrl 或 apiKey' };
|
||||
}
|
||||
|
||||
// 直接使用 URL 调用识别 API
|
||||
const result = await recognizeRecordedAudio(audioUrl, {
|
||||
apiKey,
|
||||
model: params.model || 'fun-asr' // 使用传递的模型,默认 fun-asr
|
||||
});
|
||||
|
||||
// 转换结果格式以保持兼容性
|
||||
return {
|
||||
success: result.success,
|
||||
fullText: result.text,
|
||||
sentences: result.sentences?.map(s => ({
|
||||
text: s.text,
|
||||
startTime: s.beginTime,
|
||||
endTime: s.endTime,
|
||||
words: s.words?.map(w => ({
|
||||
text: w.text,
|
||||
startTime: w.beginTime,
|
||||
endTime: w.endTime
|
||||
}))
|
||||
})),
|
||||
error: result.error,
|
||||
requestId: result.taskId
|
||||
};
|
||||
} catch (error: any) {
|
||||
let errorMessage = '未知错误';
|
||||
if (error && typeof error === 'object') {
|
||||
errorMessage = error.message || error.toString();
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
}
|
||||
|
||||
logger.error('[Aliyun:IPC] URL识别失败', { errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// 预缓存系统默认音色
|
||||
ipcMain.handle('aliyun:cosyvoice:preCacheSystemVoices', async (event, params) => {
|
||||
try {
|
||||
const { apiKey, force } = params;
|
||||
await preCacheSystemVoices(apiKey, force);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 预缓存失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 获取预缓存的音色文件路径
|
||||
ipcMain.handle('aliyun:cosyvoice:getPreCachedVoicePath', async (event, voiceId: string) => {
|
||||
try {
|
||||
const cachedPath = getPreCachedVoicePath(voiceId);
|
||||
return { success: true, path: cachedPath };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 获取预缓存路径失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 检查所有音色是否已缓存
|
||||
ipcMain.handle('aliyun:cosyvoice:isAllVoicesCached', async (event) => {
|
||||
try {
|
||||
const allCached = isAllVoicesCached();
|
||||
return { success: true, allCached };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 检查缓存状态失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 清除预缓存
|
||||
ipcMain.handle('aliyun:cosyvoice:clearPreCache', async (event) => {
|
||||
try {
|
||||
clearPreCache();
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:IPC] 清除预缓存失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 音量检测 - 检测音频中的声音片段
|
||||
ipcMain.handle('audio:detectVolume', async (event, params) => {
|
||||
try {
|
||||
// params: { audioPath, config?: { silenceThreshold, minSilenceDuration, minSoundDuration } }
|
||||
logger.info('[Audio:IPC] 开始音量检测', { audioPath: params.audioPath });
|
||||
|
||||
const result = await detectAudioSegments(params.audioPath, params.config || {});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[Audio:IPC] 音量检测失败', error);
|
||||
return {
|
||||
success: false,
|
||||
segments: [],
|
||||
totalDuration: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 音量检测 - 根据音频片段分配文本时间
|
||||
ipcMain.handle('audio:allocateText', async (event, params) => {
|
||||
try {
|
||||
// params: { text, segments }
|
||||
logger.info('[Audio:IPC] 开始文本时间分配', {
|
||||
textLength: params.text?.length,
|
||||
segmentCount: params.segments?.length
|
||||
});
|
||||
|
||||
// 🔧 验证segments数据完整性
|
||||
if (!params.segments || params.segments.length === 0) {
|
||||
logger.warn('[Audio:IPC] 警告:segments为空');
|
||||
return { success: false, error: 'segments数组为空' };
|
||||
}
|
||||
|
||||
// 检查第一个和最后一个segment的结构
|
||||
const firstSegment = params.segments[0];
|
||||
const lastSegment = params.segments[params.segments.length - 1];
|
||||
|
||||
if (!firstSegment || typeof firstSegment.startTime !== 'number' || typeof firstSegment.endTime !== 'number') {
|
||||
logger.error('[Audio:IPC] 第一个segment数据异常:', firstSegment);
|
||||
return { success: false, error: `第一个segment数据异常: ${JSON.stringify(firstSegment)}` };
|
||||
}
|
||||
|
||||
if (!lastSegment || typeof lastSegment.startTime !== 'number' || typeof lastSegment.endTime !== 'number') {
|
||||
logger.error('[Audio:IPC] 最后一个segment数据异常:', lastSegment);
|
||||
return { success: false, error: `最后一个segment数据异常: ${JSON.stringify(lastSegment)}` };
|
||||
}
|
||||
|
||||
logger.info('[Audio:IPC] segments验证通过,准备分配文本');
|
||||
|
||||
const result = allocateTextToSegments(params.text, params.segments);
|
||||
return { success: true, subtitles: result };
|
||||
} catch (error: any) {
|
||||
logger.error('[Audio:IPC] 文本时间分配失败', error);
|
||||
logger.error('[Audio:IPC] 完整错误堆栈:', error.stack);
|
||||
return { success: false, error: error.message || error.toString() };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 阿里云 OSS 文件上传模块
|
||||
* 使用官方 ali-oss SDK
|
||||
* 文档: https://help.aliyun.com/zh/oss/user-guide/simple-upload
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import logger from '../log/main';
|
||||
import { withoutUnsupportedProxy } from '../httpClient';
|
||||
|
||||
// 动态导入 ali-oss
|
||||
let OSS: any = null;
|
||||
|
||||
async function getOssClient() {
|
||||
if (!OSS) {
|
||||
try {
|
||||
// 尝试动态导入 ali-oss
|
||||
OSS = await import('ali-oss').then((m: any) => m.default || m);
|
||||
} catch (e) {
|
||||
logger.error('[Aliyun:OSS] ali-oss 模块未安装,使用 HTTP API');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return OSS;
|
||||
}
|
||||
|
||||
export interface OssConfig {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string; // 如 oss-cn-beijing
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OssUploadResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS
|
||||
*/
|
||||
export async function uploadToOss(
|
||||
filePath: string,
|
||||
config: OssConfig,
|
||||
objectKey?: string
|
||||
): Promise<OssUploadResult> {
|
||||
try {
|
||||
config = {
|
||||
accessKeyId: (config.accessKeyId || '').trim(),
|
||||
accessKeySecret: (config.accessKeySecret || '').trim(),
|
||||
bucket: (config.bucket || '').trim(),
|
||||
region: (config.region || '').trim(),
|
||||
endpoint: (config.endpoint || '').trim(),
|
||||
};
|
||||
|
||||
if (config.endpoint && !config.endpoint.startsWith('http')) {
|
||||
config.endpoint = `https://${config.endpoint}`;
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:OSS] 开始上传文件', { filePath, bucket: config.bucket, region: config.region });
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: `文件不存在: ${filePath}` };
|
||||
}
|
||||
|
||||
// 生成ObjectKey(如果未指定)
|
||||
if (!objectKey) {
|
||||
const ext = path.extname(filePath);
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
objectKey = `audio/${timestamp}_${random}${ext}`;
|
||||
}
|
||||
|
||||
// 尝试使用 ali-oss SDK
|
||||
const OssClass = await getOssClient();
|
||||
|
||||
if (OssClass) {
|
||||
const clientConfig: any = {
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: config.accessKeySecret,
|
||||
bucket: config.bucket,
|
||||
secure: true,
|
||||
};
|
||||
|
||||
if (config.endpoint) {
|
||||
clientConfig.endpoint = config.endpoint;
|
||||
} else if (config.region) {
|
||||
clientConfig.region = config.region;
|
||||
} else {
|
||||
return { success: false, error: '缺少 region 配置' };
|
||||
}
|
||||
|
||||
logger.info('[Aliyun:OSS] SDK clientConfig:', {
|
||||
bucket: clientConfig.bucket,
|
||||
bucketType: typeof clientConfig.bucket,
|
||||
region: clientConfig.region,
|
||||
endpoint: clientConfig.endpoint,
|
||||
});
|
||||
|
||||
logger.info('[Aliyun:OSS] 使用 SDK 上传', { objectKey, region: config.region, endpoint: config.endpoint });
|
||||
|
||||
const client = new OssClass(clientConfig);
|
||||
|
||||
try {
|
||||
const result = await client.put(objectKey, filePath);
|
||||
|
||||
if (result.res.status === 200) {
|
||||
// 生成签名 URL(有效期1小时),供 FunASR API 访问
|
||||
const signedUrl = client.signatureUrl(objectKey, {
|
||||
expires: 3600, // 1小时有效期
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
logger.info('[Aliyun:OSS] SDK上传成功,已生成签名URL', {
|
||||
objectKey,
|
||||
signedUrl: signedUrl.substring(0, 100) + '...' // 只记录部分URL
|
||||
});
|
||||
return { success: true, url: signedUrl };
|
||||
} else {
|
||||
logger.error('[Aliyun:OSS] SDK上传失败', result);
|
||||
throw new Error(`上传失败: HTTP ${result.res.status}`);
|
||||
}
|
||||
} catch (sdkError: any) {
|
||||
logger.warn('[Aliyun:OSS] SDK上传失败,降级到 HTTP API', { error: sdkError.message });
|
||||
// SDK 失败,降级到 HTTP API 方式
|
||||
return await uploadToOssViaHttp(filePath, config, objectKey);
|
||||
}
|
||||
} else {
|
||||
// ali-oss 未安装,使用 HTTP API 方式
|
||||
return await uploadToOssViaHttp(filePath, config, objectKey);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:OSS] 上传异常', error);
|
||||
|
||||
let errorMsg = error.message || '未知错误';
|
||||
if (error.code) {
|
||||
errorMsg = `${error.code}: ${error.message}`;
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTTP API 方式上传(备用方案)
|
||||
*/
|
||||
function generateSignedUrl(config: OssConfig, objectKey: string, expires: number): string {
|
||||
const expireTime = Math.floor(Date.now() / 1000) + expires;
|
||||
const resource = `/${config.bucket}/${objectKey}`;
|
||||
const stringToSign = `GET\n\n\n${expireTime}\n${resource}`;
|
||||
const hmac = crypto.createHmac('sha1', config.accessKeySecret);
|
||||
hmac.update(stringToSign);
|
||||
const signature = encodeURIComponent(hmac.digest('base64'));
|
||||
return `https://${config.bucket}.${config.region}.aliyuncs.com/${objectKey}?OSSAccessKeyId=${config.accessKeyId}&Expires=${expireTime}&Signature=${signature}`;
|
||||
}
|
||||
|
||||
async function uploadToOssViaHttp(
|
||||
filePath: string,
|
||||
config: OssConfig,
|
||||
objectKey: string
|
||||
): Promise<OssUploadResult> {
|
||||
try {
|
||||
logger.info('[Aliyun:OSS] 使用 HTTP API 上传');
|
||||
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
const fileSize = fileContent.length;
|
||||
|
||||
// 确定Content-Type
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
};
|
||||
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||
|
||||
// 生成Date
|
||||
const date = new Date().toUTCString();
|
||||
|
||||
// 构建URL
|
||||
const host = `${config.bucket}.${config.region}.aliyuncs.com`;
|
||||
const resource = `/${config.bucket}/${objectKey}`;
|
||||
|
||||
// 构建StringToSign (V1签名)
|
||||
const stringToSign = [
|
||||
'PUT',
|
||||
'', // Content-MD5 留空
|
||||
contentType,
|
||||
date,
|
||||
resource
|
||||
].join('\n');
|
||||
|
||||
// 计算HMAC-SHA1签名
|
||||
const hmac = crypto.createHmac('sha1', config.accessKeySecret);
|
||||
hmac.update(stringToSign);
|
||||
const signature = hmac.digest('base64');
|
||||
|
||||
// 发送上传请求
|
||||
const url = `https://${host}/${objectKey}`;
|
||||
|
||||
const response = await axios.put(url, fileContent, withoutUnsupportedProxy({
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Date': date,
|
||||
'Authorization': `OSS ${config.accessKeyId}:${signature}`,
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
timeout: 120000
|
||||
}));
|
||||
|
||||
if (response.status === 200) {
|
||||
const unsignedUrl = url;
|
||||
const signedUrl = generateSignedUrl(config, objectKey, 3600);
|
||||
logger.info('[Aliyun:OSS] HTTP API上传成功', { url: unsignedUrl, signedUrl: signedUrl.substring(0, 100) + '...' });
|
||||
return { success: true, url: signedUrl };
|
||||
} else {
|
||||
logger.error('[Aliyun:OSS] HTTP API上传失败', { status: response.status });
|
||||
return { success: false, error: `上传失败: HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Aliyun:OSS] HTTP API上传异常', error);
|
||||
|
||||
let errorMsg = error.message || '未知错误';
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data;
|
||||
if (typeof data === 'string' && data.includes('<Message>')) {
|
||||
const match = data.match(/<Message>(.*?)<\/Message>/);
|
||||
if (match) {
|
||||
errorMsg = match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置中获取OSS配置
|
||||
*/
|
||||
export function getOssConfigFromProvider(providerData: any): OssConfig | null {
|
||||
if (!providerData?.ossAccessKeyId || !providerData?.ossAccessKeySecret ||
|
||||
!providerData?.ossBucket || !providerData?.ossRegion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId: providerData.ossAccessKeyId,
|
||||
accessKeySecret: providerData.ossAccessKeySecret,
|
||||
bucket: providerData.ossBucket,
|
||||
region: providerData.ossRegion,
|
||||
endpoint: providerData.ossEndpoint
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
/**
|
||||
* 阿里云语音服务 - 渲染进程API
|
||||
*/
|
||||
|
||||
|
||||
export default {
|
||||
cosyvoice: {
|
||||
/**
|
||||
* 语音合成
|
||||
*/
|
||||
async synthesize(params: {
|
||||
text: string;
|
||||
config: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
format?: string;
|
||||
sampleRate?: number;
|
||||
saveToPreCache?: string; // 如果提供了voiceId,自动保存到预缓存目录
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:synthesize', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取可用音色列表
|
||||
*/
|
||||
async getVoices(model: string) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:getVoices', model);
|
||||
},
|
||||
|
||||
/**
|
||||
* 预缓存所有系统默认音色
|
||||
*/
|
||||
async preCacheSystemVoices(apiKey: string, force: boolean = false) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:preCacheSystemVoices', { apiKey, force });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预缓存的音色路径
|
||||
*/
|
||||
async getPreCachedVoicePath(voiceId: string) {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:getPreCachedVoicePath', voiceId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查所有音色是否已缓存
|
||||
*/
|
||||
async isAllVoicesCached() {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:isAllVoicesCached');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除预缓存
|
||||
*/
|
||||
async clearPreCache() {
|
||||
return await ipcRenderer.invoke('aliyun:cosyvoice:clearPreCache');
|
||||
}
|
||||
},
|
||||
|
||||
funasr: {
|
||||
/**
|
||||
* 语音识别(本地文件,自动上传到 OSS)
|
||||
*/
|
||||
async recognize(params: {
|
||||
audioPath: string;
|
||||
config: {
|
||||
apiKey: string;
|
||||
ossConfig?: {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
model?: string;
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:recognize', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 语音识别(直接使用 URL,无需上传 OSS)
|
||||
*/
|
||||
async recognizeFromUrl(params: {
|
||||
audioUrl: string;
|
||||
apiKey: string;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:recognizeFromUrl', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测音频格式
|
||||
*/
|
||||
async detectFormat(audioPath: string) {
|
||||
return await ipcRenderer.invoke('aliyun:funasr:detectFormat', audioPath);
|
||||
}
|
||||
},
|
||||
|
||||
audio: {
|
||||
/**
|
||||
* 检测音频中的声音片段(基于音量)
|
||||
*/
|
||||
async detectVolume(params: {
|
||||
audioPath: string;
|
||||
config?: {
|
||||
silenceThreshold?: number; // 静音阈值(dB),默认 -40dB
|
||||
minSilenceDuration?: number; // 最小静音时长(秒),默认 0.5s
|
||||
minSoundDuration?: number; // 最小有声时长(秒),默认 0.3s
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('audio:detectVolume', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据音频片段分配文本时间
|
||||
*/
|
||||
async allocateText(params: {
|
||||
text: string;
|
||||
segments: Array<{
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
}>;
|
||||
}) {
|
||||
return await ipcRenderer.invoke('audio:allocateText', params);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 阿里云 FunAudio 录音文件识别
|
||||
* 使用HTTP API异步调用,比实时识别更快
|
||||
* 文档: https://help.aliyun.com/zh/model-studio/funauidio-asr-recorded-speech-recognition-python-sdk
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import logger from '../log/main';
|
||||
|
||||
export interface TranscriptionConfig {
|
||||
apiKey: string;
|
||||
model?: string; // 默认 fun-asr
|
||||
}
|
||||
|
||||
export interface TranscriptionSentence {
|
||||
text: string;
|
||||
beginTime: number;
|
||||
endTime: number;
|
||||
words?: Array<{
|
||||
text: string;
|
||||
beginTime: number;
|
||||
endTime: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
success: boolean;
|
||||
text?: string;
|
||||
sentences?: TranscriptionSentence[];
|
||||
error?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||||
|
||||
/**
|
||||
* 验证 API Key 是否有效
|
||||
* @returns { valid: boolean, error?: string, models?: string[] }
|
||||
*/
|
||||
async function validateApiKey(apiKey: string): Promise<{ valid: boolean; error?: string; models?: string[] }> {
|
||||
try {
|
||||
logger.info('[FunAudio] 验证 API Key...');
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/services/audio/asr/models`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const models = response.data?.data?.map((m: any) => m.model_id) || [];
|
||||
logger.info('[FunAudio] ✅ API Key 有效', { availableModels: models });
|
||||
return { valid: true, models };
|
||||
}
|
||||
|
||||
return { valid: false, error: 'API 响应异常' };
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] ❌ API Key 验证失败', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
});
|
||||
return {
|
||||
valid: false,
|
||||
error: error.response?.data?.message || error.message || 'API Key 无效'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交异步转写任务
|
||||
*/
|
||||
async function submitTranscriptionTask(
|
||||
fileUrl: string, // 改为单数:只接受单个URL
|
||||
config: TranscriptionConfig
|
||||
): Promise<{ success: boolean; taskId?: string; error?: string }> {
|
||||
try {
|
||||
const model = config.model || 'qwen3-asr-flash-filetrans'; // 🔧 修复:默认使用 qwen3-asr-flash-filetrans 模型
|
||||
logger.info('[FunAudio] 提交转写任务', { fileUrl, model });
|
||||
|
||||
// 🔧 不同模型使用不同的参数格式
|
||||
const inputParam = model === 'qwen3-asr-flash-filetrans'
|
||||
? { file_url: fileUrl } // qwen3 模型使用 file_url(单数)
|
||||
: { file_urls: [fileUrl] }; // fun-asr 模型使用 file_urls(数组)
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/services/audio/asr/transcription`,
|
||||
{
|
||||
model: model,
|
||||
input: inputParam,
|
||||
parameters: {
|
||||
channel_id: [0], // 音频通道ID
|
||||
enable_itn: false // 是否启用逆文本归一化
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable' // 异步调用
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data?.output?.task_id) {
|
||||
logger.info('[FunAudio] 任务提交成功', { taskId: response.data.output.task_id });
|
||||
return { success: true, taskId: response.data.output.task_id };
|
||||
} else {
|
||||
logger.error('[FunAudio] 任务提交失败', response.data);
|
||||
return { success: false, error: response.data?.message || '任务提交失败' };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// 🔧 增强错误日志:记录完整的请求和响应信息
|
||||
logger.error('[FunAudio] 任务提交异常', {
|
||||
// HTTP 状态信息
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
|
||||
// 完整的错误响应体(这是关键!)
|
||||
responseData: error.response?.data,
|
||||
|
||||
// 请求信息(用于调试)
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
requestData: error.config?.data,
|
||||
requestHeaders: {
|
||||
Authorization: error.config?.headers?.Authorization ? '(已隐藏)' : undefined,
|
||||
'X-DashScope-Async': error.config?.headers?.['X-DashScope-Async']
|
||||
},
|
||||
|
||||
// 原始错误信息
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack
|
||||
});
|
||||
|
||||
// 尝试从响应中提取更详细的错误信息
|
||||
const responseData = error.response?.data;
|
||||
let errorMsg = '未知错误';
|
||||
|
||||
if (responseData) {
|
||||
// 阿里云可能返回的错误格式
|
||||
errorMsg = responseData.message
|
||||
|| responseData.error_message
|
||||
|| responseData.msg
|
||||
|| JSON.stringify(responseData);
|
||||
} else {
|
||||
errorMsg = error.message || '未知错误';
|
||||
}
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
*/
|
||||
async function queryTaskStatus(
|
||||
taskId: string,
|
||||
apiKey: string
|
||||
): Promise<{ status: string; output?: any; error?: string; code?: string }> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/tasks/${taskId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
// 🔧 添加完整响应日志(已注释以减少日志)
|
||||
// logger.info('[FunAudio] 任务查询响应', {
|
||||
// taskId,
|
||||
// responseStatus: response.status,
|
||||
// responseData: JSON.stringify(response.data, null, 2)
|
||||
// });
|
||||
|
||||
const status = response.data?.output?.task_status || 'UNKNOWN';
|
||||
const errorMessage = response.data?.output?.message || response.data?.message;
|
||||
const errorCode = response.data?.output?.code || response.data?.code;
|
||||
|
||||
return {
|
||||
status,
|
||||
output: response.data?.output,
|
||||
error: errorMessage,
|
||||
code: errorCode
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 查询任务状态异常', error);
|
||||
return { status: 'ERROR', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并解析转写结果
|
||||
*/
|
||||
async function downloadTranscriptionResult(resultUrl: string): Promise<any> {
|
||||
try {
|
||||
const response = await axios.get(resultUrl, { timeout: 30000 });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 下载结果失败', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待任务完成并获取结果
|
||||
*/
|
||||
async function waitForTaskCompletion(
|
||||
taskId: string,
|
||||
apiKey: string,
|
||||
maxWaitSeconds: number = 300
|
||||
): Promise<{ success: boolean; results?: any[]; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 2000; // 2秒轮询一次
|
||||
|
||||
while ((Date.now() - startTime) < maxWaitSeconds * 1000) {
|
||||
const { status, output, error } = await queryTaskStatus(taskId, apiKey);
|
||||
|
||||
// logger.info('[FunAudio] 任务状态', { taskId, status }); // 注释以减少日志
|
||||
|
||||
if (status === 'SUCCEEDED') {
|
||||
// 任务成功,获取结果
|
||||
logger.info('[FunAudio] 任务成功,获取结果', { output: JSON.stringify(output, null, 2) });
|
||||
|
||||
const result = output?.result; // 🔧 修复:使用单数 result
|
||||
const parsedResults = [];
|
||||
|
||||
if (result?.transcription_url) {
|
||||
const transcription = await downloadTranscriptionResult(result.transcription_url);
|
||||
if (transcription) {
|
||||
parsedResults.push(transcription);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[FunAudio] 解析结果完成', { parsedResultsCount: parsedResults.length });
|
||||
return { success: true, results: parsedResults };
|
||||
}
|
||||
|
||||
if (status === 'FAILED') {
|
||||
logger.error('[FunAudio] ❌ 任务失败详情', {
|
||||
taskId,
|
||||
status,
|
||||
error,
|
||||
output: JSON.stringify(output, null, 2)
|
||||
});
|
||||
return { success: false, error: error || '任务失败' };
|
||||
}
|
||||
|
||||
// 等待后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return { success: false, error: '任务超时' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别录音文件(主接口)
|
||||
* @param fileUrl 音频文件的公网URL
|
||||
* @param config 配置
|
||||
*/
|
||||
export async function recognizeRecordedAudio(
|
||||
fileUrl: string,
|
||||
config: TranscriptionConfig
|
||||
): Promise<TranscriptionResult> {
|
||||
try {
|
||||
logger.info('[FunAudio] 开始录音识别', { fileUrl, model: config.model });
|
||||
|
||||
// 0. 验证 API Key(暂时禁用,因为阿里云不支持该端点)
|
||||
// const validation = await validateApiKey(config.apiKey);
|
||||
// if (!validation.valid) {
|
||||
// logger.error('[FunAudio] ❌ API Key 验证失败,无法继续', { error: validation.error });
|
||||
// return { success: false, error: `API Key 无效: ${validation.error}` };
|
||||
// }
|
||||
|
||||
// 1. 提交任务
|
||||
const submitResult = await submitTranscriptionTask(fileUrl, config); // 传递单个URL,不再使用数组
|
||||
if (!submitResult.success || !submitResult.taskId) {
|
||||
return { success: false, error: submitResult.error || '任务提交失败' };
|
||||
}
|
||||
|
||||
// 2. 等待任务完成
|
||||
const waitResult = await waitForTaskCompletion(submitResult.taskId, config.apiKey, 300);
|
||||
if (!waitResult.success || !waitResult.results?.length) {
|
||||
return { success: false, error: waitResult.error || '未获取到识别结果', taskId: submitResult.taskId };
|
||||
}
|
||||
|
||||
// 3. 解析结果
|
||||
const transcription = waitResult.results[0];
|
||||
const sentences: TranscriptionSentence[] = [];
|
||||
let fullText = '';
|
||||
|
||||
if (transcription?.transcripts) {
|
||||
for (const transcript of transcription.transcripts) {
|
||||
if (transcript.sentences) {
|
||||
for (const sentence of transcript.sentences) {
|
||||
sentences.push({
|
||||
text: sentence.text || '',
|
||||
beginTime: sentence.begin_time || 0,
|
||||
endTime: sentence.end_time || 0,
|
||||
words: sentence.words?.map((w: any) => ({
|
||||
text: w.text || '',
|
||||
beginTime: w.begin_time || 0,
|
||||
endTime: w.end_time || 0
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transcript.text) {
|
||||
fullText += transcript.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[FunAudio] ✅ 识别完成', {
|
||||
sentenceCount: sentences.length,
|
||||
textLength: fullText.length
|
||||
});
|
||||
|
||||
// 🔧 修复:去除字幕时间轴重叠
|
||||
// 确保上一条字幕的 endTime 不会晚于下一条字幕的 beginTime
|
||||
const MIN_GAP_MS = 50; // 最小间隔 50 毫秒
|
||||
for (let i = 0; i < sentences.length - 1; i++) {
|
||||
const current = sentences[i];
|
||||
const next = sentences[i + 1];
|
||||
|
||||
if (current.endTime >= next.beginTime) {
|
||||
const oldEndTime = current.endTime;
|
||||
// 将当前字幕的结束时间调整为下一条字幕开始时间之前
|
||||
current.endTime = Math.max(current.beginTime + MIN_GAP_MS, next.beginTime - MIN_GAP_MS);
|
||||
// logger.info('[FunAudio] 修复时间轴重叠', { // 注释以减少日志
|
||||
// index: i,
|
||||
// text: current.text.substring(0, 20) + '...',
|
||||
// oldEndTime,
|
||||
// newEndTime: current.endTime,
|
||||
// nextBeginTime: next.beginTime
|
||||
// });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: fullText,
|
||||
sentences,
|
||||
taskId: submitResult.taskId
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[FunAudio] 识别异常', error);
|
||||
return { success: false, error: error.message || '未知错误' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { synthesizeSpeech } from './cosyvoice';
|
||||
import { Log } from '../log/main';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* 系统默认音色列表(与前端保持一致)
|
||||
*/
|
||||
const SYSTEM_VOICES = [
|
||||
{ id: 'sys_Cherry', title: '芊悦(普通话女声)', aliyunVoiceId: 'Cherry' },
|
||||
{ id: 'sys_Serena', title: '苏瑶(普通话女声)', aliyunVoiceId: 'Serena' },
|
||||
{ id: 'sys_Ethan', title: '晨煦(普通话男声)', aliyunVoiceId: 'Ethan' },
|
||||
{ id: 'sys_Chelsie', title: '千雪(二次元女声)', aliyunVoiceId: 'Chelsie' },
|
||||
{ id: 'sys_Momo', title: '茉兔(活泼女声)', aliyunVoiceId: 'Momo' },
|
||||
{ id: 'sys_Vivian', title: '十三(个性女声)', aliyunVoiceId: 'Vivian' },
|
||||
{ id: 'sys_Moon', title: '月白(率性男声)', aliyunVoiceId: 'Moon' },
|
||||
{ id: 'sys_Maia', title: '四月(知性女声)', aliyunVoiceId: 'Maia' },
|
||||
{ id: 'sys_Kai', title: '凯(沉浸男声)', aliyunVoiceId: 'Kai' },
|
||||
{ id: 'sys_Nofish', title: '不吃鱼(特色男声)', aliyunVoiceId: 'Nofish' },
|
||||
{ id: 'sys_Bella', title: '萌宝(萝莉女声)', aliyunVoiceId: 'Bella' },
|
||||
{ id: 'sys_Jennifer', title: '詹妮弗(美语女声)', aliyunVoiceId: 'Jennifer' },
|
||||
{ id: 'sys_Ryan', title: '甜茶(张力男声)', aliyunVoiceId: 'Ryan' },
|
||||
{ id: 'sys_Katerina', title: '卡捷琳娜(御姐女声)', aliyunVoiceId: 'Katerina' },
|
||||
{ id: 'sys_Aiden', title: '艾登(美语男声)', aliyunVoiceId: 'Aiden' },
|
||||
{ id: 'sys_Arthur', title: '徐大爷(故事男声)', aliyunVoiceId: 'Arthur' },
|
||||
{ id: 'sys_Bellona', title: '燕铮莺(洪亮女声)', aliyunVoiceId: 'Bellona' },
|
||||
{ id: 'sys_Bunny', title: '萌小姬(萌系女声)', aliyunVoiceId: 'Bunny' },
|
||||
{ id: 'sys_Mia', title: '乖小妹(温顺女声)', aliyunVoiceId: 'Mia' },
|
||||
{ id: 'sys_Mochi', title: '沙小弥(早慧童声)', aliyunVoiceId: 'Mochi' },
|
||||
{ id: 'sys_Neil', title: '阿闻(新闻男声)', aliyunVoiceId: 'Neil' },
|
||||
{ id: 'sys_Nini', title: '邻家妹妹(甜美女声)', aliyunVoiceId: 'Nini' },
|
||||
{ id: 'sys_Ebona', title: '诡婆婆(惊悚女声)', aliyunVoiceId: 'Ebona' },
|
||||
{ id: 'sys_Seren', title: '小婉(助眠女声)', aliyunVoiceId: 'Seren' },
|
||||
{ id: 'sys_Pip', title: '顽屁小孩(淘气童声)', aliyunVoiceId: 'Pip' },
|
||||
{ id: 'sys_Stella', title: '少女阿月(元气女声)', aliyunVoiceId: 'Stella' },
|
||||
{ id: 'sys_Vincent', title: '田叔(烟嗓男声)', aliyunVoiceId: 'Vincent' },
|
||||
{ id: 'sys_Radio_Gol', title: '拉迪奥·戈尔(足球解说)', aliyunVoiceId: 'Radio Gol' },
|
||||
{ id: 'sys_Jada', title: '上海-阿珍(上海话女声)', aliyunVoiceId: 'Jada' },
|
||||
{ id: 'sys_Dylan', title: '北京-晓东(北京话男声)', aliyunVoiceId: 'Dylan' },
|
||||
{ id: 'sys_Li', title: '南京-老李(南京话男声)', aliyunVoiceId: 'Li' },
|
||||
{ id: 'sys_Marcus', title: '陕西-秦川(陕西话男声)', aliyunVoiceId: 'Marcus' },
|
||||
{ id: 'sys_Roy', title: '闽南-阿杰(闽南语男声)', aliyunVoiceId: 'Roy' },
|
||||
{ id: 'sys_Peter', title: '天津-李彼得(天津话男声)', aliyunVoiceId: 'Peter' },
|
||||
{ id: 'sys_Sunny', title: '四川-晴儿(四川话女声)', aliyunVoiceId: 'Sunny' },
|
||||
{ id: 'sys_Eric', title: '四川-程川(四川话男声)', aliyunVoiceId: 'Eric' },
|
||||
{ id: 'sys_Rocky', title: '粤语-阿强(粤语男声)', aliyunVoiceId: 'Rocky' },
|
||||
{ id: 'sys_Kiki', title: '粤语-阿清(粤语女声)', aliyunVoiceId: 'Kiki' },
|
||||
{ id: 'sys_Bodega', title: '博德加(西语男声)', aliyunVoiceId: 'Bodega' },
|
||||
{ id: 'sys_Sonrisa', title: '索尼莎(西语女声)', aliyunVoiceId: 'Sonrisa' },
|
||||
{ id: 'sys_Alek', title: '阿列克(俄语男声)', aliyunVoiceId: 'Alek' },
|
||||
{ id: 'sys_Dolce', title: '多尔切(意语男声)', aliyunVoiceId: 'Dolce' },
|
||||
{ id: 'sys_Sohee', title: '素熙(韩语女声)', aliyunVoiceId: 'Sohee' },
|
||||
{ id: 'sys_Ono_Anna', title: '小野杏(日语女声)', aliyunVoiceId: 'Ono Anna' },
|
||||
{ id: 'sys_Lenn', title: '莱恩(德语男声)', aliyunVoiceId: 'Lenn' },
|
||||
{ id: 'sys_Emilien', title: '埃米尔安(法语男声)', aliyunVoiceId: 'Emilien' },
|
||||
{ id: 'sys_Andre', title: '安德雷(磁性男声)', aliyunVoiceId: 'Andre' },
|
||||
];
|
||||
|
||||
const LEGACY_SYSTEM_VOICE_FILES = [
|
||||
'sys_longanyang.mp3',
|
||||
'sys_longanhuan.mp3',
|
||||
'sys_longanrou_v3.mp3',
|
||||
'sys_longhan_v3.mp3',
|
||||
'sys_longhuhu_v3.mp3',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取预缓存目录路径
|
||||
*/
|
||||
export function getPreCacheDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const cacheDir = path.join(userDataPath, 'voice-preview-cache');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定音色的预缓存文件路径
|
||||
*/
|
||||
export function getPreCachedVoicePath(voiceId: string): string | null {
|
||||
const cacheDir = getPreCacheDir();
|
||||
const filePath = path.join(cacheDir, `${voiceId}.wav`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLegacyPreCacheFiles(): void {
|
||||
const cacheDir = getPreCacheDir();
|
||||
|
||||
for (const file of LEGACY_SYSTEM_VOICE_FILES) {
|
||||
const filePath = path.join(cacheDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
Log.info('voicePreCache.legacyCleared', { filePath });
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.legacyClearError', { filePath, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有默认音色是否已缓存
|
||||
*/
|
||||
export function isAllVoicesCached(): boolean {
|
||||
return SYSTEM_VOICES.every(voice => {
|
||||
const cachedPath = getPreCachedVoicePath(voice.id);
|
||||
return cachedPath !== null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预生成所有默认音色的试听音频
|
||||
* @param apiKey 阿里云API Key
|
||||
* @param force 是否强制重新生成(即使已存在)
|
||||
*/
|
||||
export async function preCacheSystemVoices(apiKey: string, force: boolean = false): Promise<void> {
|
||||
if (!apiKey) {
|
||||
Log.warn('voicePreCache.preCacheSystemVoices', '缺少阿里云API Key,跳过预缓存');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info('voicePreCache.preCacheSystemVoices.start', {
|
||||
totalVoices: SYSTEM_VOICES.length,
|
||||
force
|
||||
});
|
||||
|
||||
const cacheDir = getPreCacheDir();
|
||||
let cachedCount = 0;
|
||||
let generatedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const voice of SYSTEM_VOICES) {
|
||||
try {
|
||||
const filePath = path.join(cacheDir, `${voice.id}.wav`);
|
||||
|
||||
// 如果文件已存在且不强制重新生成,则跳过
|
||||
if (!force && fs.existsSync(filePath)) {
|
||||
cachedCount++;
|
||||
Log.info('voicePreCache.alreadyCached', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
filePath
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成试听文本
|
||||
const previewText = `你好,我是${voice.title.split('(')[0]},很高兴为您服务。`;
|
||||
|
||||
Log.info('voicePreCache.generating', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
text: previewText
|
||||
});
|
||||
|
||||
// 调用阿里云API生成音频
|
||||
const result = await synthesizeSpeech(previewText, {
|
||||
apiKey: apiKey,
|
||||
model: 'qwen3-tts-flash',
|
||||
voice: voice.aliyunVoiceId,
|
||||
format: 'wav',
|
||||
sampleRate: 24000,
|
||||
instruction: '请用自然、清晰、适合短视频配音的风格朗读。',
|
||||
outputPath: filePath
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
generatedCount++;
|
||||
Log.info('voicePreCache.generated', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
audioPath: result.audioPath,
|
||||
fileSize: fs.existsSync(result.audioPath) ? fs.statSync(result.audioPath).size : 0
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
Log.error('voicePreCache.generateFailed', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
|
||||
// 添加短暂延迟,避免API请求过快
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
} catch (error: any) {
|
||||
errorCount++;
|
||||
Log.error('voicePreCache.error', {
|
||||
voiceId: voice.id,
|
||||
title: voice.title,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Log.info('voicePreCache.preCacheSystemVoices.complete', {
|
||||
total: SYSTEM_VOICES.length,
|
||||
alreadyCached: cachedCount,
|
||||
generated: generatedCount,
|
||||
errors: errorCount
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有预缓存的音色文件
|
||||
*/
|
||||
export function clearPreCache(): void {
|
||||
const cacheDir = getPreCacheDir();
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
const files = fs.readdirSync(cacheDir);
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(cacheDir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
Log.info('voicePreCache.cleared', { filePath });
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.clearError', { filePath, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从应用资源目录复制预缓存文件
|
||||
* 用于首次启动或升级时,支持开发环境和打包后的环境
|
||||
*/
|
||||
export async function copyPreCacheFromResources(): Promise<boolean> {
|
||||
try {
|
||||
// 尝试找到应用内的预缓存资源目录
|
||||
const possibleResourcePaths = [
|
||||
// 打包后的应用(electron-builder extraResources)
|
||||
path.join(app.getAppPath(), '../voice-preview-cache'),
|
||||
path.join(app.getAppPath(), 'voice-preview-cache'),
|
||||
// 打包后的应用(asar 格式)
|
||||
path.join(app.getAppPath(), 'voice-preview-cache'),
|
||||
path.join(app.getAppPath().replace(/\.asar$/, '.asar.unpacked'), 'voice-preview-cache'),
|
||||
// 开发环境
|
||||
path.join(process.cwd(), 'electron/resources/voice-preview-cache'),
|
||||
// 备选路径
|
||||
path.join(process.resourcesPath, 'voice-preview-cache'),
|
||||
path.join(process.resourcesPath, '../voice-preview-cache'),
|
||||
];
|
||||
|
||||
Log.info('voicePreCache.searchingResources', {
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
cwd: process.cwd(),
|
||||
possiblePaths: possibleResourcePaths
|
||||
});
|
||||
|
||||
for (const resourcePath of possibleResourcePaths) {
|
||||
try {
|
||||
if (fs.existsSync(resourcePath)) {
|
||||
const files = SYSTEM_VOICES
|
||||
.map(voice => `${voice.id}.wav`)
|
||||
.filter(file => fs.existsSync(path.join(resourcePath, file)));
|
||||
|
||||
if (files.length === 0) {
|
||||
Log.info('voicePreCache.resourcePathEmpty', {
|
||||
resourcePath,
|
||||
allFiles: fs.readdirSync(resourcePath)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getPreCacheDir();
|
||||
let copiedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sourceFile = path.join(resourcePath, file);
|
||||
const targetFile = path.join(targetDir, file);
|
||||
|
||||
try {
|
||||
const existed = fs.existsSync(targetFile);
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
copiedCount++;
|
||||
Log.info(existed ? 'voicePreCache.fileUpdated' : 'voicePreCache.fileCopied', {
|
||||
file,
|
||||
sourceFile,
|
||||
targetFile,
|
||||
size: fs.statSync(sourceFile).size
|
||||
});
|
||||
} catch (copyError: any) {
|
||||
Log.error('voicePreCache.copyError', {
|
||||
file,
|
||||
error: copyError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (copiedCount > 0 || files.length > 0) {
|
||||
Log.info('voicePreCache.copyFromResourcesComplete', {
|
||||
resourcePath,
|
||||
totalFiles: files.length,
|
||||
copiedCount,
|
||||
targetDir
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (pathError: any) {
|
||||
Log.debug('voicePreCache.pathCheckError', {
|
||||
resourcePath,
|
||||
error: pathError.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 没有找到资源目录,返回 false
|
||||
Log.info('voicePreCache.noResourcesFound', {
|
||||
message: '未找到预缓存资源文件,将自动生成',
|
||||
possiblePaths: possibleResourcePaths
|
||||
});
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
Log.error('voicePreCache.copyFromResourcesError', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user