mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
添加了 TTS 相关服务并更新了设置
This commit is contained in:
parent
0f55b92f0e
commit
8e1ebf29b2
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/main/services/MsEdgeTTSService.ts
Normal file
93
src/main/services/MsEdgeTTSService.ts
Normal 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);
|
||||
};
|
||||
18
src/main/services/MsTTSIpcHandler.ts
Normal file
18
src/main/services/MsTTSIpcHandler.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
236
src/main/services/MsTTSService.ts
Normal file
236
src/main/services/MsTTSService.ts
Normal 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();
|
||||
};
|
||||
@ -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),
|
||||
|
||||
@ -23,6 +23,7 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
|
||||
setIsSpeaking(true)
|
||||
try {
|
||||
console.log('点击TTS按钮,开始播放消息')
|
||||
await TTSService.speakFromMessage(message)
|
||||
|
||||
// 监听播放结束
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
`
|
||||
|
||||
@ -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' }
|
||||
]
|
||||
|
||||
|
||||
@ -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
270
src/renderer/src/services/tts/EdgeTTSService.ts
Normal file
270
src/renderer/src/services/tts/EdgeTTSService.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/renderer/src/services/tts/MsTTSService.ts
Normal file
58
src/renderer/src/services/tts/MsTTSService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/renderer/src/services/tts/OpenAITTSService.ts
Normal file
92
src/renderer/src/services/tts/OpenAITTSService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
116
src/renderer/src/services/tts/SiliconflowTTSService.ts
Normal file
116
src/renderer/src/services/tts/SiliconflowTTSService.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
240
src/renderer/src/services/tts/TTSService.ts
Normal file
240
src/renderer/src/services/tts/TTSService.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
70
src/renderer/src/services/tts/TTSServiceFactory.ts
Normal file
70
src/renderer/src/services/tts/TTSServiceFactory.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/services/tts/TTSServiceInterface.ts
Normal file
12
src/renderer/src/services/tts/TTSServiceInterface.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* TTS服务接口
|
||||
* 所有TTS服务实现类都需要实现这个接口
|
||||
*/
|
||||
export interface TTSServiceInterface {
|
||||
/**
|
||||
* 合成语音
|
||||
* @param text 要合成的文本
|
||||
* @returns 返回音频Blob对象的Promise
|
||||
*/
|
||||
synthesize(text: string): Promise<Blob>;
|
||||
}
|
||||
148
src/renderer/src/services/tts/TTSTextFilter.ts
Normal file
148
src/renderer/src/services/tts/TTSTextFilter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/renderer/src/services/tts/index.ts
Normal file
7
src/renderer/src/services/tts/index.ts
Normal 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';
|
||||
@ -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,
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user