340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
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;
|
||
}
|
||
}
|