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
+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;
}
}