diff --git a/src/renderer/src/services/ASRServerService.ts b/src/renderer/src/services/ASRServerService.ts index 506715a4e8..3e105bcf4f 100644 --- a/src/renderer/src/services/ASRServerService.ts +++ b/src/renderer/src/services/ASRServerService.ts @@ -1,129 +1,753 @@ -import i18n from '@renderer/i18n' +import { AudioOutlined, PlusOutlined, ReloadOutlined, SoundOutlined } from '@ant-design/icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import TTSService from '@renderer/services/TTSService' +import store, { useAppDispatch } from '@renderer/store' +import { + addTtsCustomModel, + addTtsCustomVoice, + removeTtsCustomModel, + removeTtsCustomVoice, + resetTtsCustomValues, + setTtsApiKey, + setTtsApiUrl, + setTtsEdgeVoice, + setTtsEnabled, + setTtsFilterOptions, + setTtsModel, + setTtsServiceType, + setTtsVoice +} from '@renderer/store/settings' +import { Button, Form, Input, message, Select, Space, Switch, Tabs, Tag } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import styled from 'styled-components' -// 使用window.electron而不是直接导入electron模块 -// 这样可以避免__dirname不可用的问题 +import { + SettingContainer, + SettingDivider, + SettingGroup, + SettingHelpText, + SettingRow, + SettingRowTitle, + SettingTitle +} from '..' +import ASRSettings from './ASRSettings' -class ASRServerService { - private serverProcess: any = null - private isServerRunning = false +const CustomVoiceInput = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` - /** - * 启动ASR服务器 - * @returns Promise 是否成功启动 - */ - startServer = async (): Promise => { - if (this.isServerRunning) { - console.log('[ASRServerService] 服务器已经在运行中') - window.message.info({ content: i18n.t('settings.asr.server.already_running'), key: 'asr-server' }) - return true - } +const TagsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + margin-bottom: 16px; +` - try { - console.log('[ASRServerService] 正在启动ASR服务器...') - window.message.loading({ content: i18n.t('settings.asr.server.starting'), key: 'asr-server' }) +const EmptyText = styled.div` + color: rgba(0, 0, 0, 0.45); + padding: 4px 0; +` - // 使用IPC调用主进程启动服务器 - const result = await window.api.asrServer.startServer() +const InputGroup = styled.div` + display: flex; + gap: 8px; + margin-bottom: 8px; +` - if (result.success) { - this.isServerRunning = true - this.serverProcess = result.pid - console.log('[ASRServerService] ASR服务器启动成功,PID:', result.pid) - window.message.success({ content: i18n.t('settings.asr.server.started'), key: 'asr-server' }) - return true - } else { - console.error('[ASRServerService] ASR服务器启动失败:', result.error) - window.message.error({ - content: i18n.t('settings.asr.server.start_failed') + ': ' + result.error, - key: 'asr-server' - }) - return false +const FlexContainer = styled.div` + display: flex; + gap: 8px; +` + +const FilterOptionItem = styled.div` + margin-bottom: 16px; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +` + +const LengthLabel = styled.span` + margin-right: 8px; +` + +const LoadingText = styled.div` + margin-top: 8px; + color: #999; +` + +const VoiceSelectContainer = styled.div` + display: flex; + gap: 8px; + margin-bottom: 8px; +` + +const TTSSettings: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + 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 } - } catch (error) { - console.error('[ASRServerService] 启动ASR服务器时出错:', error) - window.message.error({ - content: i18n.t('settings.asr.server.start_failed') + ': ' + (error as Error).message, - key: 'asr-server' - }) - return false - } - } + ) - /** - * 停止ASR服务器 - * @returns Promise 是否成功停止 - */ - stopServer = async (): Promise => { - if (!this.isServerRunning || !this.serverProcess) { - console.log('[ASRServerService] 服务器未运行') - window.message.info({ content: i18n.t('settings.asr.server.not_running'), key: 'asr-server' }) - return true - } + // 新增自定义音色和模型的状态 + const [newVoice, setNewVoice] = useState('') + const [newModel, setNewModel] = useState('') - try { - console.log('[ASRServerService] 正在停止ASR服务器...') - window.message.loading({ content: i18n.t('settings.asr.server.stopping'), key: 'asr-server' }) + // 浏览器可用的语音列表 + const [availableVoices, setAvailableVoices] = useState<{ label: string; value: string }[]>([]) - // 使用IPC调用主进程停止服务器 - const result = await window.api.asrServer.stopServer(this.serverProcess) + // 预定义的浏览器 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 粤語(香港)' } + ] - if (result.success) { - this.isServerRunning = false - this.serverProcess = null - console.log('[ASRServerService] ASR服务器已停止') - window.message.success({ content: i18n.t('settings.asr.server.stopped'), key: 'asr-server' }) - return true - } else { - console.error('[ASRServerService] ASR服务器停止失败:', result.error) - window.message.error({ - content: i18n.t('settings.asr.server.stop_failed') + ': ' + result.error, - key: 'asr-server' - }) - return false + // 获取浏览器可用的语音列表 + const getVoices = () => { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + // 先触发一下语音合成引擎,确保它已经初始化 + window.speechSynthesis.cancel() + + // 获取浏览器可用的语音列表 + const voices = window.speechSynthesis.getVoices() + console.log('获取到的语音列表:', voices) + console.log('语音列表长度:', voices.length) + + // 转换浏览器语音列表为选项格式 + const browserVoices = voices.map((voice) => ({ + label: `${voice.name} (${voice.lang})${voice.default ? ' - 默认' : ''}`, + value: voice.name, + lang: voice.lang, + isNative: true // 标记为浏览器原生语音 + })) + + // 添加语言信息到预定义语音 + const enhancedPredefinedVoices = predefinedVoices.map((voice) => ({ + ...voice, + lang: voice.value.split('-').slice(0, 2).join('-'), + isNative: false // 标记为非浏览器原生语音 + })) + + // 合并所有语音列表 + let allVoices = [...browserVoices] + + // 如果浏览器语音少于5个,添加预定义语音 + if (browserVoices.length < 5) { + allVoices = [...browserVoices, ...enhancedPredefinedVoices] } - } catch (error) { - console.error('[ASRServerService] 停止ASR服务器时出错:', error) - window.message.error({ - content: i18n.t('settings.asr.server.stop_failed') + ': ' + (error as Error).message, - key: 'asr-server' + + // 去除重复项,优先保留浏览器原生语音 + const uniqueVoices = allVoices.filter((voice, index, self) => { + const firstIndex = self.findIndex((v) => v.value === voice.value) + // 如果是原生语音或者是第一次出现,则保留 + return voice.isNative || firstIndex === index }) - return false + + // 按语言分组并排序 + const groupedVoices = uniqueVoices.sort((a, b) => { + // 先按语言排序 + if (a.lang !== b.lang) { + return a.lang.localeCompare(b.lang) + } + // 同语言下,原生语音优先 + if (a.isNative !== b.isNative) { + return a.isNative ? -1 : 1 + } + // 最后按名称排序 + return a.label.localeCompare(b.label) + }) + + setAvailableVoices(groupedVoices) + console.log('设置可用语音列表:', groupedVoices) + } else { + // 如果浏览器不支持Web Speech API,使用预定义的语音列表 + console.log('浏览器不支持Web Speech API,使用预定义的语音列表') + setAvailableVoices(predefinedVoices) } } - /** - * 检查ASR服务器是否正在运行 - * @returns boolean 是否正在运行 - */ - isRunning = (): boolean => { - return this.isServerRunning + // 刷新语音列表 + const refreshVoices = () => { + console.log('手动刷新语音列表') + message.loading({ + content: t('settings.tts.edge_voice.refreshing', { defaultValue: '正在刷新语音列表...' }), + key: 'refresh-voices' + }) + + // 先清空当前列表 + setAvailableVoices([]) + + // 强制重新加载语音列表 + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + window.speechSynthesis.cancel() + + // 尝试多次获取语音列表 + setTimeout(() => { + getVoices() + setTimeout(() => { + getVoices() + message.success({ + content: t('settings.tts.edge_voice.refreshed', { defaultValue: '语音列表已刷新' }), + key: 'refresh-voices' + }) + }, 1000) + }, 500) + } else { + // 如果浏览器不支持Web Speech API,使用预定义的语音列表 + setAvailableVoices(predefinedVoices) + message.success({ + content: t('settings.tts.edge_voice.refreshed', { defaultValue: '语音列表已刷新' }), + key: 'refresh-voices' + }) + } } - /** - * 获取ASR服务器网页URL - * @returns string 网页URL - */ - getServerUrl = (): string => { - return 'http://localhost:8080' + useEffect(() => { + // 初始化语音合成引擎 + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + // 触发语音合成引擎初始化 + window.speechSynthesis.cancel() + + // 设置voiceschanged事件处理程序 + const voicesChangedHandler = () => { + console.log('检测到voiceschanged事件,重新获取语音列表') + getVoices() + } + + // 添加事件监听器 + window.speechSynthesis.onvoiceschanged = voicesChangedHandler + + // 立即获取可用的语音 + getVoices() + + // 创建多个定时器,在不同时间点尝试获取语音列表 + // 这是因为不同浏览器加载语音列表的时间不同 + const timers = [ + setTimeout(() => getVoices(), 500), + setTimeout(() => getVoices(), 1000), + setTimeout(() => getVoices(), 2000) + ] + + return () => { + // 清理事件监听器和定时器 + window.speechSynthesis.onvoiceschanged = null + timers.forEach((timer) => clearTimeout(timer)) + } + } else { + // 如果浏览器不支持Web Speech API,使用预定义的语音列表 + setAvailableVoices(predefinedVoices) + return () => {} + } + }, [getVoices, predefinedVoices]) + + // 测试TTS功能 + const testTTS = async () => { + if (!ttsEnabled) { + window.message.error({ content: t('settings.tts.error.not_enabled'), key: 'tts-test' }) + return + } + + // 获取最新的服务类型设置 + const latestSettings = store.getState().settings + const currentServiceType = latestSettings.ttsServiceType || 'openai' + console.log('测试TTS时使用的服务类型:', currentServiceType) + console.log('测试时完整TTS设置:', { + ttsEnabled: latestSettings.ttsEnabled, + ttsServiceType: latestSettings.ttsServiceType, + ttsApiKey: latestSettings.ttsApiKey ? '已设置' : '未设置', + ttsVoice: latestSettings.ttsVoice, + ttsModel: latestSettings.ttsModel, + ttsEdgeVoice: latestSettings.ttsEdgeVoice + }) + + // 根据服务类型检查必要的参数 + if (currentServiceType === 'openai') { + if (!ttsApiKey) { + window.message.error({ content: t('settings.tts.error.no_api_key'), key: 'tts-test' }) + return + } + + if (!ttsVoice) { + window.message.error({ content: t('settings.tts.error.no_voice'), key: 'tts-test' }) + return + } + + if (!ttsModel) { + window.message.error({ content: t('settings.tts.error.no_model'), key: 'tts-test' }) + return + } + } else if (currentServiceType === 'edge') { + if (!ttsEdgeVoice) { + window.message.error({ content: t('settings.tts.error.no_edge_voice'), key: 'tts-test' }) + return + } + } + + await TTSService.speak('这是一段测试语音,用于测试TTS功能是否正常工作。') } - /** - * 获取ASR服务器文件路径 - * @returns string 服务器文件路径 - */ - getServerFilePath = (): string => { - // 使用相对路径,因为window.electron.app.getAppPath()不可用 - return process.env.NODE_ENV === 'development' - ? 'src/renderer/src/assets/asr-server/server.js' - : 'public/asr-server/server.js' + // 添加自定义音色 + const handleAddVoice = () => { + if (!newVoice) { + window.message.error({ content: '请输入音色', key: 'add-voice' }) + return + } + + // 确保添加的是字符串 + const voiceStr = typeof newVoice === 'string' ? newVoice : String(newVoice) + dispatch(addTtsCustomVoice(voiceStr)) + setNewVoice('') } - /** - * 打开ASR服务器网页 - */ - openServerPage = (): void => { - window.open(this.getServerUrl(), '_blank') + // 添加自定义模型 + const handleAddModel = () => { + if (!newModel) { + window.message.error({ content: '请输入模型', key: 'add-model' }) + return + } + + // 确保添加的是字符串 + const modelStr = typeof newModel === 'string' ? newModel : String(newModel) + dispatch(addTtsCustomModel(modelStr)) + setNewModel('') } + + // 删除自定义音色 + const handleRemoveVoice = (voice: string) => { + // 确保删除的是字符串 + const voiceStr = typeof voice === 'string' ? voice : String(voice) + dispatch(removeTtsCustomVoice(voiceStr)) + } + + // 删除自定义模型 + const handleRemoveModel = (model: string) => { + // 确保删除的是字符串 + const modelStr = typeof model === 'string' ? model : String(model) + dispatch(removeTtsCustomModel(modelStr)) + } + + return ( + + + + + {t('settings.voice.title')} + + + + + {t('settings.tts.tab_title')} + + ), + children: ( +
+ + + {t('settings.tts.enable')} + dispatch(setTtsEnabled(checked))} /> + + {t('settings.tts.enable.help')} + + + {/* 重置按钮 */} + + + {t('settings.tts.reset_title')} + + + {t('settings.tts.reset_help')} + + + {t('settings.tts.api_settings')} +
+ {/* TTS服务类型选择 */} + + + dispatch(setTtsApiUrl(e.target.value))} + placeholder={t('settings.tts.api_url.placeholder')} + disabled={!ttsEnabled} + /> + + + )} + + {/* 浏览器 TTS设置 */} + {ttsServiceType === 'edge' && ( + + + dispatch(setTtsVoice(value))} + options={ttsCustomVoices.map((voice: any) => { + // 确保voice是字符串 + const voiceStr = typeof voice === 'string' ? voice : String(voice) + return { label: voiceStr, value: voiceStr } + })} + disabled={!ttsEnabled} + style={{ width: '100%' }} + placeholder={t('settings.tts.voice.placeholder')} + showSearch + optionFilterProp="label" + allowClear + /> + + + {/* 自定义音色列表 */} + + {ttsCustomVoices && ttsCustomVoices.length > 0 ? ( + ttsCustomVoices.map((voice: any, index: number) => { + // 确保voice是字符串 + const voiceStr = typeof voice === 'string' ? voice : String(voice) + return ( + handleRemoveVoice(voiceStr)} + style={{ padding: '4px 8px' }}> + {voiceStr} + + ) + }) + ) : ( + {t('settings.tts.voice_empty')} + )} + + + {/* 添加自定义音色 */} + + + setNewVoice(e.target.value)} + disabled={!ttsEnabled} + style={{ flex: 1 }} + /> + + + + + {/* 模型选择 */} + + setNewModel(e.target.value)} + disabled={!ttsEnabled} + style={{ flex: 1 }} + /> + + + + + )} + + {/* TTS过滤选项 */} + + + dispatch(setTtsFilterOptions({ filterThinkingProcess: checked }))} + disabled={!ttsEnabled} + />{' '} + {t('settings.tts.filter.thinking_process')} + + + dispatch(setTtsFilterOptions({ filterMarkdown: checked }))} + disabled={!ttsEnabled} + />{' '} + {t('settings.tts.filter.markdown')} + + + dispatch(setTtsFilterOptions({ filterCodeBlocks: checked }))} + disabled={!ttsEnabled} + />{' '} + {t('settings.tts.filter.code_blocks')} + + + dispatch(setTtsFilterOptions({ filterHtmlTags: checked }))} + disabled={!ttsEnabled} + />{' '} + {t('settings.tts.filter.html_tags')} + + + {t('settings.tts.max_text_length')}: +