Files
WYF-koubo/electron/mapi/aliyun/voicePreCache.ts
T
2026-06-20 18:37:46 +08:00

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(), '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;
}
}