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 { 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 { 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(), 'packaging/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; } }