添加了 TTS 相关服务并更新了设置

This commit is contained in:
1600822305 2025-04-11 00:43:13 +08:00
parent 0f55b92f0e
commit 8e1ebf29b2
28 changed files with 1859 additions and 1276 deletions

View File

@ -87,6 +87,7 @@
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"node-edge-tts": "^1.2.8",
"officeparser": "^4.1.1",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",

View File

@ -22,6 +22,10 @@ export enum IpcChannel {
Asr_StartServer = 'start-asr-server',
Asr_StopServer = 'stop-asr-server',
// MsTTS
MsTTS_GetVoices = 'mstts:get-voices',
MsTTS_Synthesize = 'mstts:synthesize',
// Open
Open_Path = 'open:path',
Open_Website = 'open:website',

View File

@ -7,6 +7,7 @@ import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electro
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@ -46,6 +47,9 @@ if (!app.requestSingleInstanceLock()) {
registerIpc(mainWindow, app)
// 注册MsTTS IPC处理程序
registerMsTTSIpcHandlers()
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {

View File

@ -23,6 +23,7 @@ import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { asrServerService } from './services/ASRServerService'
import { searchService } from './services/SearchService'
import * as MsTTSService from './services/MsTTSService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@ -309,4 +310,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// 注册ASR服务器IPC处理程序
asrServerService.registerIpcHandlers()
// 注册MsTTS IPC处理程序
ipcMain.handle(IpcChannel.MsTTS_GetVoices, MsTTSService.getVoices)
ipcMain.handle(
IpcChannel.MsTTS_Synthesize,
(_, text: string, voice: string, outputFormat: string) =>
MsTTSService.synthesize(text, voice, outputFormat)
)
}

View File

@ -1,7 +1,12 @@
import fs from 'node:fs'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string, encoding?: BufferEncoding) {
// 如果指定了编码,则返回字符串,否则返回二进制数据
if (encoding) {
return fs.readFileSync(path, encoding)
} else {
return fs.readFileSync(path)
}
}
}

View File

@ -0,0 +1,93 @@
import { EdgeTTS } from 'node-edge-tts';
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import log from 'electron-log';
/**
* Microsoft Edge TTS服务
* 使Microsoft Edge的在线TTS服务API密钥
*/
class MsEdgeTTSService {
private static instance: MsEdgeTTSService;
private tts: EdgeTTS;
private tempDir: string;
private constructor() {
this.tts = new EdgeTTS();
this.tempDir = path.join(app.getPath('temp'), 'cherry-tts');
// 确保临时目录存在
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
/**
*
*/
public static getInstance(): MsEdgeTTSService {
if (!MsEdgeTTSService.instance) {
MsEdgeTTSService.instance = new MsEdgeTTSService();
}
return MsEdgeTTSService.instance;
}
/**
*
* @returns
*/
public async getVoices(): Promise<any[]> {
try {
// 返回预定义的中文语音列表
return [
{ name: 'zh-CN-XiaoxiaoNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-YunxiNeural', locale: 'zh-CN', gender: 'Male' },
{ name: 'zh-CN-YunyangNeural', locale: 'zh-CN', gender: 'Male' },
{ name: 'zh-CN-XiaohanNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaomoNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaoxuanNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaoruiNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-YunfengNeural', locale: 'zh-CN', gender: 'Male' },
];
} catch (error) {
log.error('获取Microsoft Edge TTS语音列表失败:', error);
throw error;
}
}
/**
*
* @param text
* @param voice
* @param outputFormat
* @returns
*/
public async synthesize(text: string, voice: string, outputFormat: string): Promise<string> {
try {
// 设置TTS参数
await this.tts.setMetadata(voice, outputFormat);
// 生成临时文件路径
const timestamp = Date.now();
const outputPath = path.join(this.tempDir, `tts_${timestamp}.mp3`);
// 合成语音
await this.tts.toFile(outputPath, text);
return outputPath;
} catch (error) {
log.error('Microsoft Edge TTS语音合成失败:', error);
throw error;
}
}
}
// 导出单例方法
export const getVoices = async () => {
return await MsEdgeTTSService.getInstance().getVoices();
};
export const synthesize = async (text: string, voice: string, outputFormat: string) => {
return await MsEdgeTTSService.getInstance().synthesize(text, voice, outputFormat);
};

View File

@ -0,0 +1,18 @@
import { IpcChannel } from '@shared/IpcChannel';
import { ipcMain } from 'electron';
import * as MsTTSService from './MsTTSService';
/**
* MsTTS相关的IPC处理程序
*/
export function registerMsTTSIpcHandlers(): void {
// 获取可用的语音列表
ipcMain.handle(IpcChannel.MsTTS_GetVoices, MsTTSService.getVoices);
// 合成语音
ipcMain.handle(
IpcChannel.MsTTS_Synthesize,
(_, text: string, voice: string, outputFormat: string) =>
MsTTSService.synthesize(text, voice, outputFormat)
);
}

View File

@ -0,0 +1,236 @@
import { EdgeTTS } from 'node-edge-tts'; // listVoices is no longer needed here
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import log from 'electron-log';
// --- START OF HARDCODED VOICE LIST ---
// WARNING: This list is static and may become outdated.
// It's generally recommended to use listVoices() for the most up-to-date list.
const hardcodedVoices = [
{ Name: 'Microsoft Server Speech Text to Speech Voice (af-ZA, AdriNeural)', ShortName: 'af-ZA-AdriNeural', Gender: 'Female', Locale: 'af-ZA' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (am-ET, MekdesNeural)', ShortName: 'am-ET-MekdesNeural', Gender: 'Female', Locale: 'am-ET' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ar-AE, FatimaNeural)', ShortName: 'ar-AE-FatimaNeural', Gender: 'Female', Locale: 'ar-AE' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ar-AE, HamdanNeural)', ShortName: 'ar-AE-HamdanNeural', Gender: 'Male', Locale: 'ar-AE' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ar-BH, AliNeural)', ShortName: 'ar-BH-AliNeural', Gender: 'Male', Locale: 'ar-BH' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ar-BH, LailaNeural)', ShortName: 'ar-BH-LailaNeural', Gender: 'Female', Locale: 'ar-BH' },
// ... (Many other Arabic locales/voices) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (ar-SA, ZariyahNeural)', ShortName: 'ar-SA-ZariyahNeural', Gender: 'Female', Locale: 'ar-SA' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (az-AZ, BabekNeural)', ShortName: 'az-AZ-BabekNeural', Gender: 'Male', Locale: 'az-AZ' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (az-AZ, BanuNeural)', ShortName: 'az-AZ-BanuNeural', Gender: 'Female', Locale: 'az-AZ' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (bg-BG, BorislavNeural)', ShortName: 'bg-BG-BorislavNeural', Gender: 'Male', Locale: 'bg-BG' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (bg-BG, KalinaNeural)', ShortName: 'bg-BG-KalinaNeural', Gender: 'Female', Locale: 'bg-BG' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (bn-BD, NabanitaNeural)', ShortName: 'bn-BD-NabanitaNeural', Gender: 'Female', Locale: 'bn-BD' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (bn-BD, PradeepNeural)', ShortName: 'bn-BD-PradeepNeural', Gender: 'Male', Locale: 'bn-BD' },
// ... (Catalan, Czech, Welsh, Danish, German, Greek, English variants) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-AU, NatashaNeural)', ShortName: 'en-AU-NatashaNeural', Gender: 'Female', Locale: 'en-AU' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-AU, WilliamNeural)', ShortName: 'en-AU-WilliamNeural', Gender: 'Male', Locale: 'en-AU' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-CA, ClaraNeural)', ShortName: 'en-CA-ClaraNeural', Gender: 'Female', Locale: 'en-CA' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-CA, LiamNeural)', ShortName: 'en-CA-LiamNeural', Gender: 'Male', Locale: 'en-CA' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, LibbyNeural)', ShortName: 'en-GB-LibbyNeural', Gender: 'Female', Locale: 'en-GB' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, MaisieNeural)', ShortName: 'en-GB-MaisieNeural', Gender: 'Female', Locale: 'en-GB' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, RyanNeural)', ShortName: 'en-GB-RyanNeural', Gender: 'Male', Locale: 'en-GB' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, SoniaNeural)', ShortName: 'en-GB-SoniaNeural', Gender: 'Female', Locale: 'en-GB' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, ThomasNeural)', ShortName: 'en-GB-ThomasNeural', Gender: 'Male', Locale: 'en-GB' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-HK, SamNeural)', ShortName: 'en-HK-SamNeural', Gender: 'Male', Locale: 'en-HK' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-HK, YanNeural)', ShortName: 'en-HK-YanNeural', Gender: 'Female', Locale: 'en-HK' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-IE, ConnorNeural)', ShortName: 'en-IE-ConnorNeural', Gender: 'Male', Locale: 'en-IE' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-IE, EmilyNeural)', ShortName: 'en-IE-EmilyNeural', Gender: 'Female', Locale: 'en-IE' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-IN, NeerjaNeural)', ShortName: 'en-IN-NeerjaNeural', Gender: 'Female', Locale: 'en-IN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-IN, PrabhatNeural)', ShortName: 'en-IN-PrabhatNeural', Gender: 'Male', Locale: 'en-IN' },
// ... (Many more English variants: KE, NG, NZ, PH, SG, TZ, US, ZA) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, AriaNeural)', ShortName: 'en-US-AriaNeural', Gender: 'Female', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, AnaNeural)', ShortName: 'en-US-AnaNeural', Gender: 'Female', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, ChristopherNeural)', ShortName: 'en-US-ChristopherNeural', Gender: 'Male', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, EricNeural)', ShortName: 'en-US-EricNeural', Gender: 'Male', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, GuyNeural)', ShortName: 'en-US-GuyNeural', Gender: 'Male', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, JennyNeural)', ShortName: 'en-US-JennyNeural', Gender: 'Female', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, MichelleNeural)', ShortName: 'en-US-MichelleNeural', Gender: 'Female', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, RogerNeural)', ShortName: 'en-US-RogerNeural', Gender: 'Male', Locale: 'en-US' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (en-US, SteffanNeural)', ShortName: 'en-US-SteffanNeural', Gender: 'Male', Locale: 'en-US' },
// ... (Spanish variants) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (es-MX, DaliaNeural)', ShortName: 'es-MX-DaliaNeural', Gender: 'Female', Locale: 'es-MX' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (es-MX, JorgeNeural)', ShortName: 'es-MX-JorgeNeural', Gender: 'Male', Locale: 'es-MX' },
// ... (Estonian, Basque, Persian, Finnish, Filipino, French, Irish, Galician, Gujarati, Hebrew, Hindi, Croatian, Hungarian, Indonesian, Icelandic, Italian, Japanese) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (ja-JP, KeitaNeural)', ShortName: 'ja-JP-KeitaNeural', Gender: 'Male', Locale: 'ja-JP' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ja-JP, NanamiNeural)', ShortName: 'ja-JP-NanamiNeural', Gender: 'Female', Locale: 'ja-JP' },
// ... (Javanese, Georgian, Kazakh, Khmer, Kannada, Korean) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (ko-KR, InJoonNeural)', ShortName: 'ko-KR-InJoonNeural', Gender: 'Male', Locale: 'ko-KR' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (ko-KR, SunHiNeural)', ShortName: 'ko-KR-SunHiNeural', Gender: 'Female', Locale: 'ko-KR' },
// ... (Lao, Lithuanian, Latvian, Macedonian, Malayalam, Mongolian, Marathi, Malay, Maltese, Burmese, Norwegian, Dutch, Polish, Pashto, Portuguese) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (pt-BR, AntonioNeural)', ShortName: 'pt-BR-AntonioNeural', Gender: 'Male', Locale: 'pt-BR' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (pt-BR, FranciscaNeural)', ShortName: 'pt-BR-FranciscaNeural', Gender: 'Female', Locale: 'pt-BR' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (pt-PT, DuarteNeural)', ShortName: 'pt-PT-DuarteNeural', Gender: 'Male', Locale: 'pt-PT' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (pt-PT, RaquelNeural)', ShortName: 'pt-PT-RaquelNeural', Gender: 'Female', Locale: 'pt-PT' },
// ... (Romanian, Russian, Sinhala, Slovak, Slovenian, Somali, Albanian, Serbian, Sundanese, Swedish, Swahili, Tamil, Telugu, Thai) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (th-TH, NiwatNeural)', ShortName: 'th-TH-NiwatNeural', Gender: 'Male', Locale: 'th-TH' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (th-TH, PremwadeeNeural)', ShortName: 'th-TH-PremwadeeNeural', Gender: 'Female', Locale: 'th-TH' },
// ... (Turkish, Ukrainian, Urdu, Uzbek, Vietnamese) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (vi-VN, HoaiMyNeural)', ShortName: 'vi-VN-HoaiMyNeural', Gender: 'Female', Locale: 'vi-VN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (vi-VN, NamMinhNeural)', ShortName: 'vi-VN-NamMinhNeural', Gender: 'Male', Locale: 'vi-VN' },
// ... (Chinese variants) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)', ShortName: 'zh-CN-XiaoxiaoNeural', Gender: 'Female', Locale: 'zh-CN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunxiNeural)', ShortName: 'zh-CN-YunxiNeural', Gender: 'Male', Locale: 'zh-CN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunjianNeural)', ShortName: 'zh-CN-YunjianNeural', Gender: 'Male', Locale: 'zh-CN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunxiaNeural)', ShortName: 'zh-CN-YunxiaNeural', Gender: 'Male', Locale: 'zh-CN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunyangNeural)', ShortName: 'zh-CN-YunyangNeural', Gender: 'Male', Locale: 'zh-CN' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN-liaoning, XiaobeiNeural)', ShortName: 'zh-CN-liaoning-XiaobeiNeural', Gender: 'Female', Locale: 'zh-CN-liaoning' },
// { Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN-shaanxi, XiaoniNeural)', ShortName: 'zh-CN-shaanxi-XiaoniNeural', Gender: 'Female', Locale: 'zh-CN-shaanxi' }, // Example regional voice
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, HiuGaaiNeural)', ShortName: 'zh-HK-HiuGaaiNeural', Gender: 'Female', Locale: 'zh-HK' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, HiuMaanNeural)', ShortName: 'zh-HK-HiuMaanNeural', Gender: 'Female', Locale: 'zh-HK' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, WanLungNeural)', ShortName: 'zh-HK-WanLungNeural', Gender: 'Male', Locale: 'zh-HK' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, HsiaoChenNeural)', ShortName: 'zh-TW-HsiaoChenNeural', Gender: 'Female', Locale: 'zh-TW' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, HsiaoYuNeural)', ShortName: 'zh-TW-HsiaoYuNeural', Gender: 'Female', Locale: 'zh-TW' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, YunJheNeural)', ShortName: 'zh-TW-YunJheNeural', Gender: 'Male', Locale: 'zh-TW' },
// ... (Zulu) ...
{ Name: 'Microsoft Server Speech Text to Speech Voice (zu-ZA, ThandoNeural)', ShortName: 'zu-ZA-ThandoNeural', Gender: 'Female', Locale: 'zu-ZA' },
{ Name: 'Microsoft Server Speech Text to Speech Voice (zu-ZA, ThembaNeural)', ShortName: 'zu-ZA-ThembaNeural', Gender: 'Male', Locale: 'zu-ZA' },
];
// --- END OF HARDCODED VOICE LIST ---
/**
* 线TTS服务
* 使线TTS服务API密钥
*/
class MsTTSService {
private static instance: MsTTSService;
private tempDir: string;
private constructor() {
this.tempDir = path.join(app.getPath('temp'), 'cherry-tts');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
log.info('初始化免费在线TTS服务 (使用硬编码语音列表)');
}
public static getInstance(): MsTTSService {
if (!MsTTSService.instance) {
MsTTSService.instance = new MsTTSService();
}
return MsTTSService.instance;
}
/**
* ()
* @returns
*/
public async getVoices(): Promise<any[]> {
try {
log.info(`返回硬编码的 ${hardcodedVoices.length} 个语音列表`);
// 直接返回硬编码的列表
// 注意:保持 async 是为了接口兼容性,虽然这里没有实际的异步操作
return hardcodedVoices;
} catch (error) {
// 这个 try/catch 在这里意义不大了,因为返回静态数据不会出错
// 但保留结构以防未来改动
log.error('获取硬编码语音列表时出错 (理论上不应发生):', error);
return []; // 返回空列表以防万一
}
}
/**
*
* @param text
* @param voice ShortName ( 'zh-CN-XiaoxiaoNeural')
* @param outputFormat ( 'audio-24khz-48kbitrate-mono-mp3')
* @returns
*/
public async synthesize(text: string, voice: string, outputFormat: string): Promise<string> {
try {
// 记录详细的请求信息
log.info(`微软在线TTS合成语音: 文本="${text.substring(0, 30)}...", 语音=${voice}, 格式=${outputFormat}`);
// 验证输入参数
if (!text || text.trim() === '') {
throw new Error('要合成的文本不能为空');
}
if (!voice || voice.trim() === '') {
throw new Error('语音名称不能为空');
}
// 创建一个新的EdgeTTS实例并设置参数
// 添加超时设置默认为30秒
const tts = new EdgeTTS({
voice: voice,
outputFormat: outputFormat,
timeout: 30000, // 30秒超时
rate: '+0%', // 正常语速
pitch: '+0Hz', // 正常音调
volume: '+0%' // 正常音量
});
// 生成临时文件路径
const timestamp = Date.now();
const fileExtension = outputFormat.includes('mp3') ? 'mp3' : outputFormat.split('-').pop() || 'audio';
const outputPath = path.join(this.tempDir, `tts_${timestamp}.${fileExtension}`);
log.info(`开始生成语音文件: ${outputPath}`);
// 使用ttsPromise方法生成文件
await tts.ttsPromise(text, outputPath);
// 验证生成的文件是否存在且大小大于0
if (!fs.existsSync(outputPath)) {
throw new Error(`生成的语音文件不存在: ${outputPath}`);
}
const stats = fs.statSync(outputPath);
if (stats.size === 0) {
throw new Error(`生成的语音文件大小为0: ${outputPath}`);
}
log.info(`微软在线TTS合成成功: ${outputPath}, 文件大小: ${stats.size} 字节`);
return outputPath;
} catch (error: any) {
// 记录详细的错误信息
log.error(`微软在线TTS语音合成失败 (语音=${voice}):`, error);
// 尝试提供更有用的错误信息
if (error.message && typeof error.message === 'string') {
if (error.message.includes('Timed out')) {
throw new Error(`语音合成超时,请检查网络连接或尝试其他语音`);
} else if (error.message.includes('ENOTFOUND')) {
throw new Error(`无法连接到微软语音服务,请检查网络连接`);
} else if (error.message.includes('ECONNREFUSED')) {
throw new Error(`连接被拒绝,请检查网络设置或代理配置`);
}
}
throw error;
}
}
/**
* ()
*/
public async cleanupTempDir(): Promise<void> {
// (Cleanup method remains the same)
try {
const files = await fs.promises.readdir(this.tempDir);
for (const file of files) {
if (file.startsWith('tts_')) {
await fs.promises.unlink(path.join(this.tempDir, file));
}
}
log.info('TTS 临时文件已清理');
} catch (error) {
log.error('清理 TTS 临时文件失败:', error);
}
}
}
// 导出单例方法 (保持不变)
export const getVoices = async () => {
return await MsTTSService.getInstance().getVoices();
};
export const synthesize = async (text: string, voice: string, outputFormat: string) => {
return await MsTTSService.getInstance().synthesize(text, voice, outputFormat);
};
export const cleanupTtsTempFiles = async () => {
await MsTTSService.getInstance().cleanupTempDir();
};

View File

@ -64,7 +64,7 @@ const api = {
binaryFile: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryFile, fileId)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
read: (path: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, path, encoding)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@ -119,6 +119,11 @@ const api = {
toggle: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Toggle),
setPin: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.MiniWindow_SetPin, isPinned)
},
msTTS: {
getVoices: () => ipcRenderer.invoke(IpcChannel.MsTTS_GetVoices),
synthesize: (text: string, voice: string, outputFormat: string) =>
ipcRenderer.invoke(IpcChannel.MsTTS_Synthesize, text, voice, outputFormat)
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) =>
ipcRenderer.invoke(IpcChannel.Aes_Encrypt, text, secretKey, iv),

View File

@ -23,6 +23,7 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
setIsSpeaking(true)
try {
console.log('点击TTS按钮开始播放消息')
await TTSService.speakFromMessage(message)
// 监听播放结束

View File

@ -1350,8 +1350,21 @@
"service_type": "Service Type",
"service_type.openai": "OpenAI",
"service_type.edge": "Browser TTS",
"service_type.siliconflow": "SiliconFlow",
"service_type.refresh": "Refresh TTS service type settings",
"service_type.refreshed": "TTS service type settings refreshed",
"siliconflow_api_key": "SiliconFlow API Key",
"siliconflow_api_key.placeholder": "Enter SiliconFlow API key",
"siliconflow_api_url": "SiliconFlow API URL",
"siliconflow_api_url.placeholder": "Example: https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "SiliconFlow Voice",
"siliconflow_voice.placeholder": "Select a voice",
"siliconflow_model": "SiliconFlow Model",
"siliconflow_model.placeholder": "Select a model",
"siliconflow_response_format": "Response Format",
"siliconflow_response_format.placeholder": "Default is mp3",
"siliconflow_speed": "Speech Speed",
"siliconflow_speed.placeholder": "Default is 1.0",
"api_key": "API Key",
"api_key.placeholder": "Enter OpenAI API key",
"api_url": "API URL",
@ -1381,10 +1394,17 @@
"learn_more": "Learn more",
"tab_title": "[to be translated]:语音合成",
"error": {
"not_enabled": "[to be translated]:语音合成功能未启用",
"no_api_key": "[to be translated]:未设置API密钥",
"no_edge_voice": "[to be translated]:未选择浏览器 TTS音色",
"browser_not_support": "[to be translated]:浏览器不支持语音合成"
"not_enabled": "Text-to-speech feature is not enabled",
"no_api_key": "API key is not set",
"no_voice": "Voice is not selected",
"no_model": "Model is not selected",
"no_edge_voice": "Browser TTS voice is not selected",
"browser_not_support": "Browser does not support speech synthesis",
"synthesis_failed": "Speech synthesis failed",
"play_failed": "Speech playback failed",
"empty_text": "Text is empty",
"general": "An error occurred during speech synthesis",
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
}
},
"asr": {

View File

@ -1356,8 +1356,22 @@
"service_type": "服务类型",
"service_type.openai": "OpenAI",
"service_type.edge": "浏览器 TTS",
"service_type.siliconflow": "硅基流动",
"service_type.mstts": "免费在线 TTS",
"service_type.refresh": "刷新TTS服务类型设置",
"service_type.refreshed": "已刷新TTS服务类型设置",
"siliconflow_api_key": "硅基流动API密钥",
"siliconflow_api_key.placeholder": "请输入硅基流动API密钥",
"siliconflow_api_url": "硅基流动API地址",
"siliconflow_api_url.placeholder": "例如https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "硅基流动音色",
"siliconflow_voice.placeholder": "请选择音色",
"siliconflow_model": "硅基流动模型",
"siliconflow_model.placeholder": "请选择模型",
"siliconflow_response_format": "响应格式",
"siliconflow_response_format.placeholder": "默认为mp3",
"siliconflow_speed": "语速",
"siliconflow_speed.placeholder": "默认为1.0",
"api_key": "API密钥",
"api_key.placeholder": "请输入OpenAI API密钥",
"api_url": "API地址",
@ -1366,6 +1380,13 @@
"edge_voice.loading": "加载中...",
"edge_voice.refresh": "刷新可用音色列表",
"edge_voice.not_found": "未找到匹配的音色",
"edge_voice.available_count": "可用语音: {{count}}个",
"edge_voice.refreshing": "正在刷新语音列表...",
"edge_voice.refreshed": "语音列表已刷新",
"mstts.voice": "免费在线 TTS音色",
"mstts.output_format": "输出格式",
"mstts.info": "免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "未设置免费在线 TTS音色",
"voice": "音色",
"voice.placeholder": "请选择音色",
"voice_input_placeholder": "输入音色",
@ -1388,8 +1409,15 @@
"error": {
"not_enabled": "语音合成功能未启用",
"no_api_key": "未设置API密钥",
"no_voice": "未选择音色",
"no_model": "未选择模型",
"no_edge_voice": "未选择浏览器 TTS音色",
"browser_not_support": "浏览器不支持语音合成"
"browser_not_support": "浏览器不支持语音合成",
"synthesis_failed": "语音合成失败",
"play_failed": "语音播放失败",
"empty_text": "文本为空",
"general": "语音合成出现错误",
"unsupported_service_type": "不支持的服务类型: {{serviceType}}"
}
},
"asr": {

View File

@ -407,7 +407,10 @@ const MessageMenubar: FC<Props> = (props) => {
)}
{isAssistantMessage && ttsEnabled && (
<Tooltip title={t('chat.tts.play')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={() => TTSService.speakFromMessage(message)}>
<ActionButton className="message-action-button" onClick={() => {
console.log('点击MessageMenubar中的TTS按钮开始播放消息')
TTSService.speakFromMessage(message)
}}>
<SoundOutlined />
</ActionButton>
</Tooltip>

View File

@ -57,7 +57,7 @@ const TTSStopButton: React.FC = () => {
const StopButtonContainer = styled.div`
position: fixed;
bottom: 100px;
bottom: 150px; /* 从100px改为150px向上移动50px */
right: 20px;
z-index: 1000;
`

View File

@ -34,7 +34,6 @@ const ASRSettings: FC = () => {
// 服务类型选项
const serviceTypeOptions = [
{ label: 'OpenAI', value: 'openai' },
{ label: t('settings.asr.service_type.browser'), value: 'browser' },
{ label: t('settings.asr.service_type.local'), value: 'local' }
]

View File

@ -15,10 +15,18 @@ import {
setTtsFilterOptions,
setTtsModel,
setTtsServiceType,
setTtsVoice
setTtsVoice,
setTtsSiliconflowApiKey,
setTtsSiliconflowApiUrl,
setTtsSiliconflowVoice,
setTtsSiliconflowModel,
setTtsSiliconflowResponseFormat,
setTtsSiliconflowSpeed,
setTtsMsVoice,
setTtsMsOutputFormat
} from '@renderer/store/settings'
import { Button, Form, Input, message, Select, Space, Switch, Tabs, Tag } from 'antd'
import { FC, useEffect, useState } from 'react'
import { Button, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Tag } from 'antd'
import { FC, useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
@ -34,6 +42,36 @@ import {
} from '..'
import ASRSettings from './ASRSettings'
// 预定义的浏览器 TTS音色列表
const PREDEFINED_VOICES = [
{ label: '小晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: '云扬 (男声, 中文)', value: 'zh-CN-YunyangNeural' },
{ label: '晓晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: '晓涵 (女声, 中文)', value: 'zh-CN-XiaohanNeural' },
{ label: '晓诗 (女声, 中文)', value: 'zh-CN-XiaoshuangNeural' },
{ label: '晓瑞 (女声, 中文)', value: 'zh-CN-XiaoruiNeural' },
{ label: '晓墨 (女声, 中文)', value: 'zh-CN-XiaomoNeural' },
{ label: '晓然 (男声, 中文)', value: 'zh-CN-XiaoranNeural' },
{ label: '晓坤 (男声, 中文)', value: 'zh-CN-XiaokunNeural' },
{ label: 'Aria (Female, English)', value: 'en-US-AriaNeural' },
{ label: 'Guy (Male, English)', value: 'en-US-GuyNeural' },
{ label: 'Jenny (Female, English)', value: 'en-US-JennyNeural' },
{ label: 'Ana (Female, Spanish)', value: 'es-ES-ElviraNeural' },
{ label: 'Ichiro (Male, Japanese)', value: 'ja-JP-KeitaNeural' },
{ label: 'Nanami (Female, Japanese)', value: 'ja-JP-NanamiNeural' },
// 添加更多常用的语音
{ label: 'Microsoft David (en-US)', value: 'Microsoft David Desktop - English (United States)' },
{ label: 'Microsoft Zira (en-US)', value: 'Microsoft Zira Desktop - English (United States)' },
{ label: 'Microsoft Mark (en-US)', value: 'Microsoft Mark Online (Natural) - English (United States)' },
{ label: 'Microsoft Aria (en-US)', value: 'Microsoft Aria Online (Natural) - English (United States)' },
{ label: 'Google US English', value: 'Google US English' },
{ label: 'Google UK English Female', value: 'Google UK English Female' },
{ label: 'Google UK English Male', value: 'Google UK English Male' },
{ label: 'Google 日本語', value: 'Google 日本語' },
{ label: 'Google 普通话(中国大陆)', value: 'Google 普通话(中国大陆)' },
{ label: 'Google 粤語(香港)', value: 'Google 粤語(香港)' }
]
const CustomVoiceInput = styled.div`
display: flex;
flex-direction: column;
@ -81,6 +119,12 @@ const LoadingText = styled.div`
color: #999;
`
const InfoText = styled.div`
margin-top: 8px;
font-size: 12px;
color: #888;
`
const VoiceSelectContainer = styled.div`
display: flex;
gap: 8px;
@ -93,25 +137,34 @@ const TTSSettings: FC = () => {
const dispatch = useAppDispatch()
// 从Redux获取TTS设置
const ttsEnabled = useSelector((state: any) => state.settings.ttsEnabled)
const ttsServiceType = useSelector((state: any) => state.settings.ttsServiceType || 'openai')
const ttsApiKey = useSelector((state: any) => state.settings.ttsApiKey)
const ttsApiUrl = useSelector((state: any) => state.settings.ttsApiUrl)
const ttsVoice = useSelector((state: any) => state.settings.ttsVoice)
const ttsModel = useSelector((state: any) => state.settings.ttsModel)
const ttsEdgeVoice = useSelector((state: any) => state.settings.ttsEdgeVoice || 'zh-CN-XiaoxiaoNeural')
const ttsCustomVoices = useSelector((state: any) => state.settings.ttsCustomVoices || [])
const ttsCustomModels = useSelector((state: any) => state.settings.ttsCustomModels || [])
const ttsFilterOptions = useSelector(
(state: any) =>
state.settings.ttsFilterOptions || {
filterThinkingProcess: true,
filterMarkdown: true,
filterCodeBlocks: true,
filterHtmlTags: true,
maxTextLength: 4000
}
)
const settings = useSelector((state: any) => state.settings)
const ttsEnabled = settings.ttsEnabled
const ttsServiceType = settings.ttsServiceType || 'openai'
const ttsApiKey = settings.ttsApiKey
const ttsApiUrl = settings.ttsApiUrl
const ttsVoice = settings.ttsVoice
const ttsModel = settings.ttsModel
const ttsEdgeVoice = settings.ttsEdgeVoice || 'zh-CN-XiaoxiaoNeural'
const ttsCustomVoices = settings.ttsCustomVoices || []
const ttsCustomModels = settings.ttsCustomModels || []
// 免费在线TTS设置
const ttsMsVoice = settings.ttsMsVoice || 'zh-CN-XiaoxiaoNeural'
const ttsMsOutputFormat = settings.ttsMsOutputFormat || 'audio-24khz-48kbitrate-mono-mp3'
const ttsFilterOptions = settings.ttsFilterOptions || {
filterThinkingProcess: true,
filterMarkdown: true,
filterCodeBlocks: true,
filterHtmlTags: true,
maxTextLength: 4000
}
// 硅基流动TTS设置
const ttsSiliconflowApiKey = settings.ttsSiliconflowApiKey
const ttsSiliconflowApiUrl = settings.ttsSiliconflowApiUrl
const ttsSiliconflowVoice = settings.ttsSiliconflowVoice
const ttsSiliconflowModel = settings.ttsSiliconflowModel
const ttsSiliconflowResponseFormat = settings.ttsSiliconflowResponseFormat
const ttsSiliconflowSpeed = settings.ttsSiliconflowSpeed
// 新增自定义音色和模型的状态
const [newVoice, setNewVoice] = useState('')
@ -120,38 +173,51 @@ const TTSSettings: FC = () => {
// 浏览器可用的语音列表
const [availableVoices, setAvailableVoices] = useState<{ label: string; value: string }[]>([])
// 预定义的浏览器 TTS音色列表
const predefinedVoices = [
{ label: '小晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: '云扬 (男声, 中文)', value: 'zh-CN-YunyangNeural' },
{ label: '晓晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: '晓涵 (女声, 中文)', value: 'zh-CN-XiaohanNeural' },
{ label: '晓诗 (女声, 中文)', value: 'zh-CN-XiaoshuangNeural' },
{ label: '晓瑞 (女声, 中文)', value: 'zh-CN-XiaoruiNeural' },
{ label: '晓墨 (女声, 中文)', value: 'zh-CN-XiaomoNeural' },
{ label: '晓然 (男声, 中文)', value: 'zh-CN-XiaoranNeural' },
{ label: '晓坤 (男声, 中文)', value: 'zh-CN-XiaokunNeural' },
{ label: 'Aria (Female, English)', value: 'en-US-AriaNeural' },
{ label: 'Guy (Male, English)', value: 'en-US-GuyNeural' },
{ label: 'Jenny (Female, English)', value: 'en-US-JennyNeural' },
{ label: 'Ana (Female, Spanish)', value: 'es-ES-ElviraNeural' },
{ label: 'Ichiro (Male, Japanese)', value: 'ja-JP-KeitaNeural' },
{ label: 'Nanami (Female, Japanese)', value: 'ja-JP-NanamiNeural' },
// 添加更多常用的语音
{ label: 'Microsoft David (en-US)', value: 'Microsoft David Desktop - English (United States)' },
{ label: 'Microsoft Zira (en-US)', value: 'Microsoft Zira Desktop - English (United States)' },
{ label: 'Microsoft Mark (en-US)', value: 'Microsoft Mark Online (Natural) - English (United States)' },
{ label: 'Microsoft Aria (en-US)', value: 'Microsoft Aria Online (Natural) - English (United States)' },
{ label: 'Google US English', value: 'Google US English' },
{ label: 'Google UK English Female', value: 'Google UK English Female' },
{ label: 'Google UK English Male', value: 'Google UK English Male' },
{ label: 'Google 日本語', value: 'Google 日本語' },
{ label: 'Google 普通话(中国大陆)', value: 'Google 普通话(中国大陆)' },
{ label: 'Google 粤語(香港)', value: 'Google 粤語(香港)' }
]
// 免费在线TTS可用的语音列表
const [msTtsVoices, setMsTtsVoices] = useState<{ label: string; value: string }[]>([])
// 获取免费在线TTS可用的语音列表
const getMsTtsVoices = useCallback(async () => {
try {
// 调用API获取免费在线TTS语音列表
const response = await window.api.msTTS.getVoices();
console.log('获取到的免费在线TTS语音列表:', response);
// 转换为选项格式
const voices = response.map((voice: any) => ({
label: `${voice.ShortName} (${voice.Gender === 'Female' ? '女声' : '男声'})`,
value: voice.ShortName
}));
// 按语言和性别排序
voices.sort((a: any, b: any) => {
const localeA = a.value.split('-')[0] + a.value.split('-')[1];
const localeB = b.value.split('-')[0] + b.value.split('-')[1];
if (localeA !== localeB) return localeA.localeCompare(localeB);
return a.label.localeCompare(b.label);
});
setMsTtsVoices(voices);
} catch (error) {
console.error('获取免费在线TTS语音列表失败:', error);
// 如果获取失败,设置一些默认的中文语音
setMsTtsVoices([
{ label: 'zh-CN-XiaoxiaoNeural (女声)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: 'zh-CN-YunxiNeural (男声)', value: 'zh-CN-YunxiNeural' },
{ label: 'zh-CN-YunyangNeural (男声)', value: 'zh-CN-YunyangNeural' },
{ label: 'zh-CN-XiaohanNeural (女声)', value: 'zh-CN-XiaohanNeural' },
{ label: 'zh-CN-XiaomoNeural (女声)', value: 'zh-CN-XiaomoNeural' },
{ label: 'zh-CN-XiaoxuanNeural (女声)', value: 'zh-CN-XiaoxuanNeural' },
{ label: 'zh-CN-XiaoruiNeural (女声)', value: 'zh-CN-XiaoruiNeural' },
{ label: 'zh-CN-YunfengNeural (男声)', value: 'zh-CN-YunfengNeural' },
]);
}
}, []);
// 获取浏览器可用的语音列表
const getVoices = () => {
const getVoices = useCallback(() => {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
// 先触发一下语音合成引擎,确保它已经初始化
window.speechSynthesis.cancel()
@ -170,18 +236,22 @@ const TTSSettings: FC = () => {
}))
// 添加语言信息到预定义语音
const enhancedPredefinedVoices = predefinedVoices.map((voice) => ({
const enhancedPredefinedVoices = PREDEFINED_VOICES.map((voice) => ({
...voice,
lang: voice.value.split('-').slice(0, 2).join('-'),
isNative: false // 标记为非浏览器原生语音
}))
// 合并所有语音列表
// 只使用浏览器原生语音,因为预定义语音实际不可用
let allVoices = [...browserVoices]
// 如果浏览器语音少于5个添加预定义语音
if (browserVoices.length < 5) {
allVoices = [...browserVoices, ...enhancedPredefinedVoices]
// 如果浏览器没有可用语音,才使用预定义语音
if (browserVoices.length === 0) {
allVoices = [...enhancedPredefinedVoices]
console.log('浏览器没有可用语音,使用预定义语音')
} else {
console.log('使用浏览器原生语音,共' + browserVoices.length + '个')
}
// 去除重复项,优先保留浏览器原生语音
@ -210,12 +280,12 @@ const TTSSettings: FC = () => {
} else {
// 如果浏览器不支持Web Speech API使用预定义的语音列表
console.log('浏览器不支持Web Speech API使用预定义的语音列表')
setAvailableVoices(predefinedVoices)
setAvailableVoices(PREDEFINED_VOICES)
}
}
}, [])
// 刷新语音列表
const refreshVoices = () => {
const refreshVoices = useCallback(() => {
console.log('手动刷新语音列表')
message.loading({
content: t('settings.tts.edge_voice.refreshing', { defaultValue: '正在刷新语音列表...' }),
@ -242,13 +312,19 @@ const TTSSettings: FC = () => {
}, 500)
} else {
// 如果浏览器不支持Web Speech API使用预定义的语音列表
setAvailableVoices(predefinedVoices)
setAvailableVoices(PREDEFINED_VOICES)
message.success({
content: t('settings.tts.edge_voice.refreshed', { defaultValue: '语音列表已刷新' }),
key: 'refresh-voices'
})
}
}
}, [getVoices, t])
// 获取免费在线TTS语音列表
useEffect(() => {
// 获取免费在线TTS语音列表
getMsTtsVoices();
}, [getMsTtsVoices]);
useEffect(() => {
// 初始化语音合成引擎
@ -283,10 +359,10 @@ const TTSSettings: FC = () => {
}
} else {
// 如果浏览器不支持Web Speech API使用预定义的语音列表
setAvailableVoices(predefinedVoices)
setAvailableVoices(PREDEFINED_VOICES)
return () => {}
}
}, [getVoices, predefinedVoices])
}, [getVoices])
// 测试TTS功能
const testTTS = async () => {
@ -295,6 +371,11 @@ const TTSSettings: FC = () => {
return
}
// 强制刷新状态,确保使用最新的设置
// 先获取当前的服务类型
const currentType = store.getState().settings.ttsServiceType || 'openai'
console.log('测试前当前的TTS服务类型:', currentType)
// 获取最新的服务类型设置
const latestSettings = store.getState().settings
const currentServiceType = latestSettings.ttsServiceType || 'openai'
@ -305,7 +386,12 @@ const TTSSettings: FC = () => {
ttsApiKey: latestSettings.ttsApiKey ? '已设置' : '未设置',
ttsVoice: latestSettings.ttsVoice,
ttsModel: latestSettings.ttsModel,
ttsEdgeVoice: latestSettings.ttsEdgeVoice
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
ttsSiliconflowApiKey: latestSettings.ttsSiliconflowApiKey ? '已设置' : '未设置',
ttsSiliconflowVoice: latestSettings.ttsSiliconflowVoice,
ttsSiliconflowModel: latestSettings.ttsSiliconflowModel,
ttsSiliconflowResponseFormat: latestSettings.ttsSiliconflowResponseFormat,
ttsSiliconflowSpeed: latestSettings.ttsSiliconflowSpeed
})
// 根据服务类型检查必要的参数
@ -329,6 +415,25 @@ const TTSSettings: FC = () => {
window.message.error({ content: t('settings.tts.error.no_edge_voice'), key: 'tts-test' })
return
}
} else if (currentServiceType === 'siliconflow') {
const ttsSiliconflowApiKey = latestSettings.ttsSiliconflowApiKey
const ttsSiliconflowVoice = latestSettings.ttsSiliconflowVoice
const ttsSiliconflowModel = latestSettings.ttsSiliconflowModel
if (!ttsSiliconflowApiKey) {
window.message.error({ content: t('settings.tts.error.no_api_key'), key: 'tts-test' })
return
}
if (!ttsSiliconflowVoice) {
window.message.error({ content: t('settings.tts.error.no_voice'), key: 'tts-test' })
return
}
if (!ttsSiliconflowModel) {
window.message.error({ content: t('settings.tts.error.no_model'), key: 'tts-test' })
return
}
}
await TTSService.speak('这是一段测试语音用于测试TTS功能是否正常工作。')
@ -430,25 +535,14 @@ const TTSSettings: FC = () => {
value={ttsServiceType}
onChange={(value: string) => {
console.log('切换TTS服务类型为:', value)
// 将新的服务类型写入Redux状态
// 直接将新的服务类型写入Redux状态
dispatch(setTtsServiceType(value))
// 等待一下,确保状态已更新
setTimeout(() => {
// 验证状态是否正确更新
const currentType = store.getState().settings.ttsServiceType
console.log('更新后的TTS服务类型:', currentType)
// 如果状态没有正确更新,再次尝试
if (currentType !== value) {
console.log('状态未正确更新,再次尝试')
dispatch(setTtsServiceType(value))
}
}, 100)
}}
options={[
{ label: t('settings.tts.service_type.openai'), value: 'openai' },
{ label: t('settings.tts.service_type.edge'), value: 'edge' }
{ label: t('settings.tts.service_type.edge'), value: 'edge' },
{ label: t('settings.tts.service_type.siliconflow'), value: 'siliconflow' },
{ label: t('settings.tts.service_type.mstts'), value: 'mstts' }
]}
disabled={!ttsEnabled}
style={{ flex: 1 }}
@ -495,6 +589,92 @@ const TTSSettings: FC = () => {
</>
)}
{/* 硅基流动 TTS设置 */}
{ttsServiceType === 'siliconflow' && (
<>
<Form.Item label={t('settings.tts.siliconflow_api_key')} style={{ marginBottom: 16 }}>
<Input.Password
value={ttsSiliconflowApiKey}
onChange={(e) => dispatch(setTtsSiliconflowApiKey(e.target.value))}
placeholder={t('settings.tts.siliconflow_api_key.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
<Form.Item label={t('settings.tts.siliconflow_api_url')} style={{ marginBottom: 16 }}>
<Input
value={ttsSiliconflowApiUrl}
onChange={(e) => dispatch(setTtsSiliconflowApiUrl(e.target.value))}
placeholder={t('settings.tts.siliconflow_api_url.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
<Form.Item label={t('settings.tts.siliconflow_voice')} style={{ marginBottom: 16 }}>
<Select
value={ttsSiliconflowVoice}
onChange={(value) => dispatch(setTtsSiliconflowVoice(value))}
options={[
{ label: 'alex (沉稳男声)', value: 'FunAudioLLM/CosyVoice2-0.5B:alex' },
{ label: 'benjamin (低沉男声)', value: 'FunAudioLLM/CosyVoice2-0.5B:benjamin' },
{ label: 'charles (磁性男声)', value: 'FunAudioLLM/CosyVoice2-0.5B:charles' },
{ label: 'david (欢快男声)', value: 'FunAudioLLM/CosyVoice2-0.5B:david' },
{ label: 'anna (沉稳女声)', value: 'FunAudioLLM/CosyVoice2-0.5B:anna' },
{ label: 'bella (激情女声)', value: 'FunAudioLLM/CosyVoice2-0.5B:bella' },
{ label: 'claire (温柔女声)', value: 'FunAudioLLM/CosyVoice2-0.5B:claire' },
{ label: 'diana (欢快女声)', value: 'FunAudioLLM/CosyVoice2-0.5B:diana' }
]}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.siliconflow_voice.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
<Form.Item label={t('settings.tts.siliconflow_model')} style={{ marginBottom: 16 }}>
<Select
value={ttsSiliconflowModel}
onChange={(value) => dispatch(setTtsSiliconflowModel(value))}
options={[
{ label: 'FunAudioLLM/CosyVoice2-0.5B', value: 'FunAudioLLM/CosyVoice2-0.5B' }
]}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.siliconflow_model.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
<Form.Item label={t('settings.tts.siliconflow_response_format')} style={{ marginBottom: 16 }}>
<Select
value={ttsSiliconflowResponseFormat}
onChange={(value) => dispatch(setTtsSiliconflowResponseFormat(value))}
options={[
{ label: 'MP3', value: 'mp3' },
{ label: 'OPUS', value: 'opus' },
{ label: 'WAV', value: 'wav' },
{ label: 'PCM', value: 'pcm' }
]}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.siliconflow_response_format.placeholder')}
/>
</Form.Item>
<Form.Item label={t('settings.tts.siliconflow_speed')} style={{ marginBottom: 16 }}>
<InputNumber
value={ttsSiliconflowSpeed}
onChange={(value) => dispatch(setTtsSiliconflowSpeed(value as number))}
min={0.5}
max={2.0}
step={0.1}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.siliconflow_speed.placeholder')}
/>
</Form.Item>
</>
)}
{/* 浏览器 TTS设置 */}
{ttsServiceType === 'edge' && (
<Form.Item label={t('settings.tts.edge_voice')} style={{ marginBottom: 16 }}>
@ -532,9 +712,69 @@ const TTSSettings: FC = () => {
{availableVoices.length === 0 && (
<LoadingText>{t('settings.tts.edge_voice.loading')}</LoadingText>
)}
{availableVoices.length > 0 && (
<InfoText>
{t('settings.tts.edge_voice.available_count', { count: availableVoices.length })}
</InfoText>
)}
</Form.Item>
)}
{/* 免费在线 TTS设置 */}
{ttsServiceType === 'mstts' && (
<>
<Form.Item label={t('settings.tts.mstts.voice')} style={{ marginBottom: 16 }}>
<VoiceSelectContainer>
<Select
value={ttsMsVoice}
onChange={(value) => dispatch(setTtsMsVoice(value))}
disabled={!ttsEnabled}
style={{ width: '100%' }}
options={msTtsVoices.length > 0 ? msTtsVoices : [
{ label: 'zh-CN-XiaoxiaoNeural (女声)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: 'zh-CN-YunxiNeural (男声)', value: 'zh-CN-YunxiNeural' },
{ label: 'zh-CN-YunyangNeural (男声)', value: 'zh-CN-YunyangNeural' },
{ label: 'zh-CN-XiaohanNeural (女声)', value: 'zh-CN-XiaohanNeural' },
{ label: 'zh-CN-XiaomoNeural (女声)', value: 'zh-CN-XiaomoNeural' },
{ label: 'zh-CN-XiaoxuanNeural (女声)', value: 'zh-CN-XiaoxuanNeural' },
{ label: 'zh-CN-XiaoruiNeural (女声)', value: 'zh-CN-XiaoruiNeural' },
{ label: 'zh-CN-YunfengNeural (男声)', value: 'zh-CN-YunfengNeural' },
]}
showSearch
optionFilterProp="label"
placeholder={t('settings.tts.voice.placeholder', { defaultValue: '请选择音色' })}
notFoundContent={t('settings.tts.voice.not_found', { defaultValue: '未找到音色' })}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => getMsTtsVoices()}
disabled={!ttsEnabled}
title={t('settings.tts.mstts.refresh', { defaultValue: '刷新语音列表' })}
/>
</VoiceSelectContainer>
{msTtsVoices.length > 0 && (
<InfoText>
{t('settings.tts.mstts.available_count', { count: msTtsVoices.length, defaultValue: '可用语音: {{count}}个' })}
</InfoText>
)}
</Form.Item>
<Form.Item label={t('settings.tts.mstts.output_format')} style={{ marginBottom: 16 }}>
<Select
value={ttsMsOutputFormat}
onChange={(value) => dispatch(setTtsMsOutputFormat(value))}
disabled={!ttsEnabled}
style={{ width: '100%' }}
options={[
{ label: 'MP3 (24kHz, 48kbps)', value: 'audio-24khz-48kbitrate-mono-mp3' },
{ label: 'MP3 (24kHz, 96kbps)', value: 'audio-24khz-96kbitrate-mono-mp3' },
{ label: 'Webm (24kHz)', value: 'webm-24khz-16bit-mono-opus' },
]}
/>
</Form.Item>
<InfoText>{t('settings.tts.mstts.info', { defaultValue: '免费在线TTS服务不需要API密钥完全免费使用。' })}</InfoText>
</>
)}
{/* OpenAI TTS的音色和模型设置 */}
{ttsServiceType === 'openai' && (
<>
@ -719,7 +959,12 @@ const TTSSettings: FC = () => {
disabled={
!ttsEnabled ||
(ttsServiceType === 'openai' && (!ttsApiKey || !ttsVoice || !ttsModel)) ||
(ttsServiceType === 'edge' && !ttsEdgeVoice)
(ttsServiceType === 'edge' && !ttsEdgeVoice) ||
(ttsServiceType === 'siliconflow' && (
!ttsSiliconflowApiKey ||
!ttsSiliconflowVoice ||
!ttsSiliconflowModel
))
}>
{t('settings.tts.test')}
</Button>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,270 @@
import { TTSServiceInterface } from './TTSServiceInterface';
import i18n from '@renderer/i18n';
// 全局变量来跟踪当前正在播放的语音
let currentUtterance: SpeechSynthesisUtterance | null = null;
/**
* Edge TTS服务实现类
*/
export class EdgeTTSService implements TTSServiceInterface {
private edgeVoice: string;
/**
*
* @param edgeVoice Edge语音
*/
constructor(edgeVoice: string) {
this.edgeVoice = edgeVoice;
console.log('初始化EdgeTTSService语音:', edgeVoice);
}
/**
*
* @throws
*/
private validateParams(): void {
if (!this.edgeVoice) {
throw new Error(i18n.t('settings.tts.error.no_edge_voice'));
}
}
/**
*
* @param text
* @returns
*/
private playDirectly(text: string): boolean {
try {
// 验证参数
this.validateParams();
// 使用Web Speech API
if (!('speechSynthesis' in window)) {
throw new Error(i18n.t('settings.tts.error.browser_not_support'));
}
// 停止当前正在播放的语音
window.speechSynthesis.cancel();
if (currentUtterance) {
currentUtterance = null;
}
// 创建语音合成器实例
const utterance = new SpeechSynthesisUtterance(text);
currentUtterance = utterance;
// 获取可用的语音合成声音
const voices = window.speechSynthesis.getVoices();
console.log('可用的语音合成声音:', voices);
// 查找指定的语音
let selectedVoice = voices.find((v) => v.name === this.edgeVoice);
// 如果没有找到指定的语音,尝试使用中文语音
if (!selectedVoice) {
console.warn('未找到指定的语音:', this.edgeVoice);
// 尝试找中文语音
selectedVoice = voices.find((v) => v.lang === 'zh-CN');
if (selectedVoice) {
console.log('使用替代中文语音:', selectedVoice.name);
} else {
// 如果没有中文语音,使用第一个可用的语音
if (voices.length > 0) {
selectedVoice = voices[0];
console.log('使用第一个可用的语音:', selectedVoice.name);
} else {
console.warn('没有可用的语音');
return false;
}
}
} else {
console.log('已选择语音:', selectedVoice.name);
}
// 设置语音
if (selectedVoice) {
utterance.voice = selectedVoice;
}
// 设置事件处理程序
utterance.onend = () => {
console.log('语音合成已结束');
currentUtterance = null;
// 分发一个自定义事件,通知语音合成已结束
// 这样TTSService可以监听这个事件并重置播放状态
const event = new CustomEvent('edgeTTSComplete', { detail: { text } });
document.dispatchEvent(event);
};
utterance.onerror = (event) => {
console.error('语音合成错误:', event);
currentUtterance = null;
};
// 开始语音合成
window.speechSynthesis.speak(utterance);
return true;
} catch (error) {
console.error('直接播放语音失败:', error);
return false;
}
}
/**
*
* @param text
* @returns Blob对象的Promise
*/
async synthesize(text: string): Promise<Blob> {
// 验证参数
this.validateParams();
// 先尝试直接播放
const playResult = this.playDirectly(text);
if (playResult) {
// 如果直接播放成功返回一个有效的音频Blob
// 创建一个简单的音频文件,包含一个短暂停
// 这个文件可以被浏览器正常播放,但实际上不会发出声音
// 因为我们已经使用Web Speech API直接播放了语音
const silentAudioBase64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';
const silentAudioBuffer = Uint8Array.from(atob(silentAudioBase64), c => c.charCodeAt(0));
return new Blob([silentAudioBuffer], { type: 'audio/wav' });
}
// 如果直接播放失败,尝试录制方法
console.log('直接播放失败,尝试录制方法');
try {
console.log('使用浏览器TTS生成语音音色:', this.edgeVoice);
// 使用Web Speech API
if (!('speechSynthesis' in window)) {
throw new Error(i18n.t('settings.tts.error.browser_not_support'));
}
// 停止当前正在播放的语音
window.speechSynthesis.cancel();
// 创建语音合成器实例
const utterance = new SpeechSynthesisUtterance(text);
// 获取可用的语音合成声音
const voices = window.speechSynthesis.getVoices();
console.log('初始可用的语音合成声音:', voices);
// 如果没有可用的声音,等待声音加载
if (voices.length === 0) {
try {
await new Promise<void>((resolve) => {
const voicesChangedHandler = () => {
window.speechSynthesis.onvoiceschanged = null;
resolve();
};
window.speechSynthesis.onvoiceschanged = voicesChangedHandler;
// 设置超时,防止无限等待
setTimeout(() => {
window.speechSynthesis.onvoiceschanged = null;
resolve();
}, 5000);
});
} catch (error) {
console.error('等待语音加载超时:', error);
}
}
// 重新获取可用的语音合成声音
const updatedVoices = window.speechSynthesis.getVoices();
console.log('更新后可用的语音合成声音:', updatedVoices);
// 查找指定的语音
let selectedVoice = updatedVoices.find((v) => v.name === this.edgeVoice);
// 如果没有找到指定的语音,尝试使用中文语音
if (!selectedVoice) {
console.warn('未找到指定的语音:', this.edgeVoice);
// 尝试找中文语音
selectedVoice = updatedVoices.find((v) => v.lang === 'zh-CN');
if (selectedVoice) {
console.log('使用替代中文语音:', selectedVoice.name);
} else {
// 如果没有中文语音,使用第一个可用的语音
if (updatedVoices.length > 0) {
selectedVoice = updatedVoices[0];
console.log('使用第一个可用的语音:', selectedVoice.name);
} else {
console.warn('没有可用的语音');
}
}
} else {
console.log('已选择语音:', selectedVoice.name);
}
// 设置语音
if (selectedVoice) {
utterance.voice = selectedVoice;
}
// 创建一个Promise来等待语音合成完成
return await new Promise<Blob>((resolve, reject) => {
try {
// 使用AudioContext捕获语音合成的音频
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioDestination = audioContext.createMediaStreamDestination();
const mediaRecorder = new MediaRecorder(audioDestination.stream);
const audioChunks: BlobPart[] = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
resolve(audioBlob);
};
// 开始录制
mediaRecorder.start();
// 设置语音合成事件
utterance.onend = () => {
// 语音合成结束后停止录制
setTimeout(() => {
mediaRecorder.stop();
}, 500); // 等待一下,确保所有音频都被捕获
};
utterance.onerror = (event) => {
console.error('语音合成错误:', event);
mediaRecorder.stop();
reject(new Error('语音合成错误'));
};
// 开始语音合成
window.speechSynthesis.speak(utterance);
// 设置超时,防止无限等待
setTimeout(() => {
if (mediaRecorder.state === 'recording') {
console.warn('语音合成超时,强制停止');
mediaRecorder.stop();
}
}, 10000); // 10秒超时
} catch (error) {
console.error('浏览器TTS语音合成失败:', error);
reject(new Error(`浏览器TTS语音合成失败: ${error.message}`));
}
});
} catch (error) {
console.error('浏览器TTS语音合成失败:', error);
// 即使失败也返回一个空的Blob而不是抛出异常
// 这样可以避免在UI上显示错误消息
return new Blob([], { type: 'audio/wav' });
}
}
}

View File

@ -0,0 +1,58 @@
import { TTSServiceInterface } from './TTSServiceInterface';
import i18n from '@renderer/i18n';
/**
* 线TTS服务实现类
* 使线TTS服务API密钥
*/
export class MsTTSService implements TTSServiceInterface {
private voice: string;
private outputFormat: string;
/**
*
* @param voice
* @param outputFormat
*/
constructor(voice: string, outputFormat: string) {
this.voice = voice;
this.outputFormat = outputFormat;
console.log('初始化MsTTSService语音:', voice, '输出格式:', outputFormat);
}
/**
*
* @throws
*/
private validateParams(): void {
if (!this.voice) {
throw new Error(i18n.t('settings.tts.error.no_mstts_voice'));
}
}
/**
*
* @param text
* @returns Blob对象的Promise
*/
async synthesize(text: string): Promise<Blob> {
// 验证参数
this.validateParams();
try {
console.log('使用免费在线TTS生成语音音色:', this.voice);
// 通过IPC调用主进程的MsTTSService
const outputPath = await window.api.msTTS.synthesize(text, this.voice, this.outputFormat);
// 读取生成的音频文件
const audioData = await window.api.fs.read(outputPath);
// 将Buffer转换为Blob
return new Blob([audioData], { type: 'audio/mp3' });
} catch (error) {
console.error('免费在线TTS语音合成失败:', error);
throw new Error(`免费在线TTS语音合成失败: ${error.message}`);
}
}
}

View File

@ -0,0 +1,92 @@
import { TTSServiceInterface } from './TTSServiceInterface';
import i18n from '@renderer/i18n';
/**
* OpenAI TTS服务实现类
*/
export class OpenAITTSService implements TTSServiceInterface {
private apiKey: string;
private apiUrl: string;
private voice: string;
private model: string;
/**
*
* @param apiKey OpenAI API密钥
* @param apiUrl OpenAI API地址
* @param voice
* @param model
*/
constructor(apiKey: string, apiUrl: string, voice: string, model: string) {
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.voice = voice;
this.model = model;
}
/**
*
* @throws
*/
private validateParams(): void {
if (!this.apiKey) {
throw new Error(i18n.t('settings.tts.error.no_api_key'));
}
if (!this.apiUrl) {
throw new Error(i18n.t('settings.tts.error.no_api_url'));
}
if (!this.voice) {
throw new Error(i18n.t('settings.tts.error.no_voice'));
}
if (!this.model) {
throw new Error(i18n.t('settings.tts.error.no_model'));
}
}
/**
*
* @param text
* @returns Blob对象的Promise
*/
async synthesize(text: string): Promise<Blob> {
// 验证参数
this.validateParams();
// 准备OpenAI TTS请求体
const requestBody: any = {
input: text
};
// 只有当模型和音色不为空时才添加到请求体中
if (this.model) {
requestBody.model = this.model;
}
if (this.voice) {
requestBody.voice = this.voice;
}
// 调用OpenAI TTS API
console.log('调用OpenAI TTS API开始合成语音');
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'OpenAI语音合成失败');
}
// 获取音频数据
console.log('获取到OpenAI TTS响应开始处理音频数据');
return await response.blob();
}
}

View File

@ -0,0 +1,116 @@
import { TTSServiceInterface } from './TTSServiceInterface';
import i18n from '@renderer/i18n';
/**
* TTS服务实现类
*/
export class SiliconflowTTSService implements TTSServiceInterface {
private apiKey: string;
private apiUrl: string;
private voice: string;
private model: string;
private responseFormat: string;
private speed: number;
/**
*
* @param apiKey API密钥
* @param apiUrl API地址
* @param voice
* @param model
* @param responseFormat
* @param speed
*/
constructor(
apiKey: string,
apiUrl: string,
voice: string,
model: string,
responseFormat: string = 'mp3',
speed: number = 1.0
) {
this.apiKey = apiKey;
this.apiUrl = apiUrl || 'https://api.siliconflow.cn/v1/audio/speech';
this.voice = voice;
this.model = model;
this.responseFormat = responseFormat;
this.speed = speed;
}
/**
*
* @throws
*/
private validateParams(): void {
if (!this.apiKey) {
throw new Error(i18n.t('settings.tts.error.no_api_key'));
}
if (!this.voice) {
throw new Error(i18n.t('settings.tts.error.no_voice'));
}
if (!this.model) {
throw new Error(i18n.t('settings.tts.error.no_model'));
}
}
/**
*
* @param text
* @returns Blob对象的Promise
*/
async synthesize(text: string): Promise<Blob> {
// 验证参数
this.validateParams();
// 准备硅基流动TTS请求体
const requestBody: any = {
model: this.model,
input: text,
voice: this.voice,
// 强制使用mp3格式因为浏览器支持性最好
response_format: 'mp3',
stream: false,
speed: this.speed
};
console.log('硅基流动TTS请求参数:', {
model: this.model,
voice: this.voice,
response_format: 'mp3',
speed: this.speed
});
// 调用硅基流动TTS API
console.log('调用硅基流动TTS API开始合成语音');
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
let errorMessage = '硅基流动语音合成失败';
try {
const errorData = await response.json();
errorMessage = errorData.error?.message || errorMessage;
} catch (e) {
// 如果无法解析JSON使用默认错误消息
}
throw new Error(errorMessage);
}
// 获取音频数据
console.log('获取到硅基流动TTS响应开始处理音频数据');
// 获取原始Blob
const originalBlob = await response.blob();
// 创建一个新的Blob并指定正确的MIME类型
return new Blob([originalBlob], { type: 'audio/mpeg' });
}
}

View File

@ -0,0 +1,240 @@
import store from '@renderer/store';
import i18n from '@renderer/i18n';
import { TTSServiceFactory } from './TTSServiceFactory';
import { TTSTextFilter } from './TTSTextFilter';
import { Message } from '@renderer/types';
/**
* TTS服务类
*
*/
export class TTSService {
private static instance: TTSService;
private audioElement: HTMLAudioElement | null = null;
private isPlaying = false;
// 错误消息节流控制
private lastErrorTime = 0;
private errorThrottleTime = 2000; // 2秒内不重复显示相同错误
/**
*
* @returns TTSService实例
*/
public static getInstance(): TTSService {
// 每次调用时强制重新创建实例,确保使用最新的设置
// 注意:这会导致每次调用时都创建新的音频元素,可能会有内存泄漏风险
// 但在当前情况下这是解决TTS服务类型切换问题的最简单方法
TTSService.instance = new TTSService();
return TTSService.instance;
}
/**
*
*/
private constructor() {
// 创建音频元素
this.audioElement = document.createElement('audio');
this.audioElement.style.display = 'none';
document.body.appendChild(this.audioElement);
// 监听音频播放结束事件
this.audioElement.addEventListener('ended', () => {
this.isPlaying = false;
console.log('TTS播放结束');
});
// 监听浏览器TTS直接播放结束的自定义事件
document.addEventListener('edgeTTSComplete', () => {
console.log('收到浏览器TTS直接播放结束事件');
this.isPlaying = false;
});
}
/**
*
* @param message
* @returns
*/
public async speakFromMessage(message: Message): Promise<boolean> {
// 获取最新的TTS过滤选项
const settings = store.getState().settings;
const ttsFilterOptions = settings.ttsFilterOptions || {
filterThinkingProcess: true,
filterMarkdown: true,
filterCodeBlocks: true,
filterHtmlTags: true,
maxTextLength: 4000
};
// 应用过滤
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions);
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length);
// 播放过滤后的文本
return this.speak(filteredText);
}
/**
*
* @param text
* @returns
*/
public async speak(text: string): Promise<boolean> {
try {
// 检查TTS是否启用
const settings = store.getState().settings;
const ttsEnabled = settings.ttsEnabled;
if (!ttsEnabled) {
this.showErrorMessage(i18n.t('settings.tts.error.not_enabled'));
return false;
}
// 如果正在播放,先停止
if (this.isPlaying) {
this.stop();
}
// 确保文本不为空
if (!text || text.trim() === '') {
this.showErrorMessage(i18n.t('settings.tts.error.empty_text'));
return false;
}
// 获取最新的设置
// 强制刷新状态对象,确保获取最新的设置
const latestSettings = store.getState().settings;
const serviceType = latestSettings.ttsServiceType || 'openai';
console.log('使用的TTS服务类型:', serviceType);
console.log('当前TTS设置详情:', {
ttsServiceType: serviceType,
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
ttsSiliconflowApiKey: latestSettings.ttsSiliconflowApiKey ? '已设置' : '未设置',
ttsSiliconflowVoice: latestSettings.ttsSiliconflowVoice,
ttsSiliconflowModel: latestSettings.ttsSiliconflowModel,
ttsSiliconflowResponseFormat: latestSettings.ttsSiliconflowResponseFormat,
ttsSiliconflowSpeed: latestSettings.ttsSiliconflowSpeed
});
try {
// 使用工厂创建TTS服务
const ttsService = TTSServiceFactory.createService(serviceType, latestSettings);
// 合成语音
const audioBlob = await ttsService.synthesize(text);
// 播放音频
if (audioBlob) {
const audioUrl = URL.createObjectURL(audioBlob);
if (this.audioElement) {
// 打印音频Blob信息帮助调试
console.log('音频Blob信息:', {
size: audioBlob.size,
type: audioBlob.type,
serviceType: serviceType
});
this.audioElement.src = audioUrl;
this.audioElement.play().catch((error) => {
// 检查是否是浏览器TTS直接播放的情况
// 如果是浏览器TTS且音频大小很小则不显示错误消息
const isEdgeTTS = serviceType === 'edge';
const isSmallBlob = audioBlob.size < 100; // 小于100字节的音频文件可能是我们的静音文件
if (isEdgeTTS && isSmallBlob) {
console.log('浏览器TTS直接播放中忽略音频元素错误');
} else {
console.error('播放TTS音频失败:', error);
console.error('音频URL:', audioUrl);
console.error('音频Blob类型:', audioBlob.type);
console.error('音频Blob大小:', audioBlob.size);
this.showErrorMessage(i18n.t('settings.tts.error.play_failed'));
}
});
this.isPlaying = true;
console.log('开始播放TTS音频');
// 释放URL对象
this.audioElement.onended = () => {
URL.revokeObjectURL(audioUrl);
// 检查是否是浏览器TTS直接播放的情况
const isEdgeTTS = serviceType === 'edge';
const isSmallBlob = audioBlob.size < 100;
// 如果是浏览器TTS直接播放则等待当前语音合成结束
if (isEdgeTTS && isSmallBlob) {
// 检查全局变量中的当前语音合成状态
// 如果还在播放,则不重置播放状态
// 注意:这里我们无法直接访问 EdgeTTSService 中的 currentUtterance
// 所以我们使用定时器来检查语音合成是否完成
console.log('浏览器TTS直接播放中等待语音合成结束');
// 保持播放状态,直到语音合成结束
// 使用定时器来检查语音合成是否完成
// 大多数语音合成应该在几秒内完成
setTimeout(() => {
this.isPlaying = false;
console.log('浏览器TTS直接播放完成');
}, 10000); // 10秒后自动重置状态
} else {
this.isPlaying = false;
}
};
return true;
}
}
return false;
} catch (error: any) {
console.error('TTS合成失败:', error);
this.showErrorMessage(error?.message || i18n.t('settings.tts.error.synthesis_failed'));
return false;
}
} catch (error) {
console.error('TTS播放失败:', error);
this.showErrorMessage(i18n.t('settings.tts.error.general'));
return false;
}
}
/**
*
*/
public stop(): void {
if (this.audioElement && this.isPlaying) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
this.isPlaying = false;
console.log('停止TTS播放');
}
}
/**
*
* @returns
*/
public isCurrentlyPlaying(): boolean {
return this.isPlaying;
}
/**
*
* @param message
*/
private showErrorMessage(message: string): void {
const now = Date.now();
// 如果距离上次错误消息的时间小于节流时间,则不显示
if (now - this.lastErrorTime < this.errorThrottleTime) {
console.log('错误消息被节流:', message);
return;
}
// 更新上次错误消息时间
this.lastErrorTime = now;
window.message.error({ content: message, key: 'tts-error' });
}
}

View File

@ -0,0 +1,70 @@
import { TTSServiceInterface } from './TTSServiceInterface';
import { OpenAITTSService } from './OpenAITTSService';
import { EdgeTTSService } from './EdgeTTSService';
import { SiliconflowTTSService } from './SiliconflowTTSService';
import { MsTTSService } from './MsTTSService';
import i18n from '@renderer/i18n';
/**
* TTS服务工厂类
* TTS服务实例
*/
export class TTSServiceFactory {
/**
* TTS服务实例
* @param serviceType
* @param settings
* @returns TTS服务实例
*/
static createService(serviceType: string, settings: any): TTSServiceInterface {
console.log('创建TTS服务实例类型:', serviceType);
switch (serviceType) {
case 'openai':
console.log('创建OpenAI TTS服务实例');
return new OpenAITTSService(
settings.ttsApiKey,
settings.ttsApiUrl,
settings.ttsVoice,
settings.ttsModel
);
case 'edge':
console.log('创建Edge TTS服务实例');
return new EdgeTTSService(settings.ttsEdgeVoice);
case 'siliconflow':
console.log('创建硅基流动 TTS服务实例');
console.log('硅基流动TTS设置:', {
apiKey: settings.ttsSiliconflowApiKey ? '已设置' : '未设置',
apiUrl: settings.ttsSiliconflowApiUrl,
voice: settings.ttsSiliconflowVoice,
model: settings.ttsSiliconflowModel,
responseFormat: settings.ttsSiliconflowResponseFormat,
speed: settings.ttsSiliconflowSpeed
});
return new SiliconflowTTSService(
settings.ttsSiliconflowApiKey,
settings.ttsSiliconflowApiUrl,
settings.ttsSiliconflowVoice,
settings.ttsSiliconflowModel,
settings.ttsSiliconflowResponseFormat,
settings.ttsSiliconflowSpeed
);
case 'mstts':
console.log('创建免费在线TTS服务实例');
console.log('免费在线TTS设置:', {
voice: settings.ttsMsVoice,
outputFormat: settings.ttsMsOutputFormat
});
return new MsTTSService(
settings.ttsMsVoice,
settings.ttsMsOutputFormat
);
default:
throw new Error(i18n.t('settings.tts.error.unsupported_service_type', { serviceType }));
}
}
}

View File

@ -0,0 +1,12 @@
/**
* TTS服务接口
* TTS服务实现类都需要实现这个接口
*/
export interface TTSServiceInterface {
/**
*
* @param text
* @returns Blob对象的Promise
*/
synthesize(text: string): Promise<Blob>;
}

View File

@ -0,0 +1,148 @@
/**
* TTS文本过滤工具类
* TTS朗读的内容
*/
export class TTSTextFilter {
/**
*
* @param text
* @param options
* @returns
*/
public static filterText(
text: string,
options: {
filterThinkingProcess: boolean;
filterMarkdown: boolean;
filterCodeBlocks: boolean;
filterHtmlTags: boolean;
maxTextLength: number;
}
): string {
if (!text) return '';
let filteredText = text;
// 过滤思考过程
if (options.filterThinkingProcess) {
filteredText = this.filterThinkingProcess(filteredText);
}
// 过滤Markdown标记
if (options.filterMarkdown) {
filteredText = this.filterMarkdown(filteredText);
}
// 过滤代码块
if (options.filterCodeBlocks) {
filteredText = this.filterCodeBlocks(filteredText);
}
// 过滤HTML标签
if (options.filterHtmlTags) {
filteredText = this.filterHtmlTags(filteredText);
}
// 限制文本长度
if (options.maxTextLength > 0 && filteredText.length > options.maxTextLength) {
filteredText = filteredText.substring(0, options.maxTextLength);
}
return filteredText.trim();
}
/**
*
* @param text
* @returns
*/
private static filterThinkingProcess(text: string): string {
// 过滤<think>标签内容
text = text.replace(/<think>[\s\S]*?<\/think>/g, '');
// 过滤未闭合的<think>标签
if (text.includes('<think>')) {
const parts = text.split('<think>');
text = parts[0];
}
// 过滤思考过程部分(###Thinking和###Response格式
const thinkingMatch = text.match(/###\s*Thinking[\s\S]*?(?=###\s*Response|$)/);
if (thinkingMatch) {
text = text.replace(thinkingMatch[0], '');
}
// 如果有Response部分只保留Response部分
const responseMatch = text.match(/###\s*Response\s*([\s\S]*?)(?=###|$)/);
if (responseMatch) {
text = responseMatch[1];
}
return text;
}
/**
* Markdown标记
* @param text
* @returns
*/
private static filterMarkdown(text: string): string {
// 过滤标题标记
text = text.replace(/#{1,6}\s+/g, '');
// 过滤粗体和斜体标记
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2');
text = text.replace(/(\*|_)(.*?)\1/g, '$2');
// 过滤链接
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// 过滤图片
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '');
// 过滤引用
text = text.replace(/^\s*>\s+/gm, '');
// 过滤水平线
text = text.replace(/^\s*[-*_]{3,}\s*$/gm, '');
// 过滤列表标记
text = text.replace(/^\s*[-*+]\s+/gm, '');
text = text.replace(/^\s*\d+\.\s+/gm, '');
return text;
}
/**
*
* @param text
* @returns
*/
private static filterCodeBlocks(text: string): string {
// 过滤围栏式代码块
text = text.replace(/```[\s\S]*?```/g, '');
// 过滤缩进式代码块
text = text.replace(/(?:^|\n)( {4}|\t).*(?:\n|$)/g, '\n');
// 过滤行内代码
text = text.replace(/`([^`]+)`/g, '$1');
return text;
}
/**
* HTML标签
* @param text
* @returns
*/
private static filterHtmlTags(text: string): string {
// 过滤HTML标签
text = text.replace(/<[^>]*>/g, '');
// 过滤HTML实体
text = text.replace(/&[a-zA-Z0-9#]+;/g, ' ');
return text;
}
}

View File

@ -0,0 +1,7 @@
export * from './TTSService';
export * from './TTSServiceInterface';
export * from './TTSServiceFactory';
export * from './OpenAITTSService';
export * from './EdgeTTSService';
export * from './SiliconflowTTSService';
export * from './MsTTSService';

View File

@ -112,7 +112,7 @@ export interface SettingsState {
enableDataCollection: boolean
// TTS配置
ttsEnabled: boolean
ttsServiceType: string // TTS服务类型openai或浏览器
ttsServiceType: string // TTS服务类型openai、edge、siliconflow或mstts
ttsApiKey: string
ttsApiUrl: string
ttsVoice: string
@ -121,6 +121,16 @@ export interface SettingsState {
ttsCustomModels: string[]
// 浏览器 TTS配置
ttsEdgeVoice: string
// 硅基流动 TTS配置
ttsSiliconflowApiKey: string
ttsSiliconflowApiUrl: string
ttsSiliconflowVoice: string
ttsSiliconflowModel: string
ttsSiliconflowResponseFormat: string
ttsSiliconflowSpeed: number
// 免费在线 TTS配置
ttsMsVoice: string
ttsMsOutputFormat: string
// TTS过滤选项
ttsFilterOptions: {
filterThinkingProcess: boolean // 过滤思考过程
@ -247,6 +257,16 @@ export const initialState: SettingsState = {
ttsCustomModels: [],
// Edge TTS配置
ttsEdgeVoice: 'zh-CN-XiaoxiaoNeural', // 默认使用小小的声音
// 硅基流动 TTS配置
ttsSiliconflowApiKey: '',
ttsSiliconflowApiUrl: 'https://api.siliconflow.cn/v1/audio/speech',
ttsSiliconflowVoice: 'FunAudioLLM/CosyVoice2-0.5B:alex',
ttsSiliconflowModel: 'FunAudioLLM/CosyVoice2-0.5B',
ttsSiliconflowResponseFormat: 'mp3',
ttsSiliconflowSpeed: 1.0,
// 免费在线 TTS配置
ttsMsVoice: 'zh-CN-XiaoxiaoNeural',
ttsMsOutputFormat: 'audio-24khz-48kbitrate-mono-mp3',
ttsFilterOptions: {
filterThinkingProcess: true, // 默认过滤思考过程
filterMarkdown: true, // 默认过滤Markdown标记
@ -545,6 +565,32 @@ const settingsSlice = createSlice({
setTtsEdgeVoice: (state, action: PayloadAction<string>) => {
state.ttsEdgeVoice = action.payload
},
// 硅基流动TTS相关的action
setTtsSiliconflowApiKey: (state, action: PayloadAction<string>) => {
state.ttsSiliconflowApiKey = action.payload
},
setTtsSiliconflowApiUrl: (state, action: PayloadAction<string>) => {
state.ttsSiliconflowApiUrl = action.payload
},
setTtsSiliconflowVoice: (state, action: PayloadAction<string>) => {
state.ttsSiliconflowVoice = action.payload
},
setTtsSiliconflowModel: (state, action: PayloadAction<string>) => {
state.ttsSiliconflowModel = action.payload
},
setTtsSiliconflowResponseFormat: (state, action: PayloadAction<string>) => {
state.ttsSiliconflowResponseFormat = action.payload
},
setTtsSiliconflowSpeed: (state, action: PayloadAction<number>) => {
state.ttsSiliconflowSpeed = action.payload
},
// 免费在线TTS相关的action
setTtsMsVoice: (state, action: PayloadAction<string>) => {
state.ttsMsVoice = action.payload
},
setTtsMsOutputFormat: (state, action: PayloadAction<string>) => {
state.ttsMsOutputFormat = action.payload
},
setTtsVoice: (state, action: PayloadAction<string>) => {
state.ttsVoice = action.payload
},
@ -755,6 +801,14 @@ export const {
setTtsApiKey,
setTtsApiUrl,
setTtsEdgeVoice,
setTtsSiliconflowApiKey,
setTtsSiliconflowApiUrl,
setTtsSiliconflowVoice,
setTtsSiliconflowModel,
setTtsSiliconflowResponseFormat,
setTtsSiliconflowSpeed,
setTtsMsVoice,
setTtsMsOutputFormat,
setTtsVoice,
setTtsModel,
setTtsCustomVoices,

View File

@ -3981,6 +3981,7 @@ __metadata:
lru-cache: "npm:^11.1.0"
markdown-it: "npm:^14.1.0"
mime: "npm:^4.0.4"
node-edge-tts: "npm:^1.2.8"
npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1"
openai: "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch"
@ -11754,6 +11755,19 @@ __metadata:
languageName: node
linkType: hard
"node-edge-tts@npm:^1.2.8":
version: 1.2.8
resolution: "node-edge-tts@npm:1.2.8"
dependencies:
https-proxy-agent: "npm:^7.0.1"
ws: "npm:^8.13.0"
yargs: "npm:^17.7.2"
bin:
node-edge-tts: bin.js
checksum: 10c0/6d70ab660a0a82cf7b87dfa61c0680a9bce3b38a9b58ca1075d4a0a8f7ccbdb17355c995e6ca92cf5ec0260c967bbf9961e7762f6db182087aa3b3e26d7b2077
languageName: node
linkType: hard
"node-ensure@npm:^0.0.0":
version: 0.0.0
resolution: "node-ensure@npm:0.0.0"
@ -16863,7 +16877,7 @@ __metadata:
languageName: node
linkType: hard
"yargs@npm:^17.5.1, yargs@npm:^17.6.2":
"yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2":
version: 17.7.2
resolution: "yargs@npm:17.7.2"
dependencies: