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
+468
View File
@@ -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;
}
+377
View File
@@ -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;
}
}
+305
View File
@@ -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';
}
+75
View File
@@ -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 };
}
}
+319
View File
@@ -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() };
}
});
}
+267
View File
@@ -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
};
}
+131
View File
@@ -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);
}
}
};
+360
View File
@@ -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 || '未知错误' };
}
}
+339
View File
@@ -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;
}
}