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

This commit is contained in:
1600822305 2025-04-12 12:04:27 +08:00
parent 42c38b73ce
commit 808e5ef076
14 changed files with 159 additions and 113 deletions

View File

@ -16,32 +16,35 @@ export function registerMsTTSIpcHandlers(): void {
) )
// 流式合成语音 // 流式合成语音
ipcMain.handle(IpcChannel.MsTTS_SynthesizeStream, async (event, requestId: string, text: string, voice: string, outputFormat: string) => { ipcMain.handle(
const window = BrowserWindow.fromWebContents(event.sender) IpcChannel.MsTTS_SynthesizeStream,
if (!window) return async (event, requestId: string, text: string, voice: string, outputFormat: string) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) return
try { try {
await MsTTSService.synthesizeStream( await MsTTSService.synthesizeStream(
text, text,
voice, voice,
outputFormat, outputFormat,
(chunk: Uint8Array) => { (chunk: Uint8Array) => {
// 发送音频数据块 // 发送音频数据块
if (!window.isDestroyed()) { if (!window.isDestroyed()) {
window.webContents.send(IpcChannel.MsTTS_StreamData, requestId, chunk) window.webContents.send(IpcChannel.MsTTS_StreamData, requestId, chunk)
}
},
() => {
// 发送流结束信号
if (!window.isDestroyed()) {
window.webContents.send(IpcChannel.MsTTS_StreamEnd, requestId)
}
} }
}, )
() => { return { success: true }
// 发送流结束信号 } catch (error) {
if (!window.isDestroyed()) { console.error('流式TTS合成失败:', error)
window.webContents.send(IpcChannel.MsTTS_StreamEnd, requestId) return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}
)
return { success: true }
} catch (error) {
console.error('流式TTS合成失败:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
} }
}) )
} }

View File

@ -1,10 +1,10 @@
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { MsEdgeTTS, OUTPUT_FORMAT } from 'edge-tts-node' // 新版支持流式的TTS库
import { app } from 'electron' import { app } from 'electron'
import log from 'electron-log' import log from 'electron-log'
import { EdgeTTS } from 'node-edge-tts' // 旧版TTS库 import { EdgeTTS } from 'node-edge-tts' // 旧版TTS库
import { MsEdgeTTS, OUTPUT_FORMAT } from 'edge-tts-node' // 新版支持流式的TTS库
// --- START OF HARDCODED VOICE LIST --- // --- START OF HARDCODED VOICE LIST ---
// WARNING: This list is static and may become outdated. // WARNING: This list is static and may become outdated.

View File

@ -33,7 +33,7 @@ const ASRButton: FC<Props> = ({ onTranscribed, disabled = false, style }) => {
try { try {
// 添加事件监听器监听服务器发送的stopped消息 // 添加事件监听器监听服务器发送的stopped消息
const originalCallback = ASRService.resultCallback const originalCallback = ASRService.resultCallback
const stopCallback = (text: string, isFinal?: boolean) => { const stopCallback = (text: string) => {
// 如果是空字符串,只重置状态,不调用原始回调 // 如果是空字符串,只重置状态,不调用原始回调
if (text === '') { if (text === '') {
setIsProcessing(false) setIsProcessing(false)

View File

@ -289,7 +289,9 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
} }
await VoiceCallService.startCall({ await VoiceCallService.startCall({
onTranscript: setTranscript, onTranscript: setTranscript,
onResponse: () => { /* 响应在聊天界面处理 */ }, onResponse: () => {
/* 响应在聊天界面处理 */
},
onListeningStateChange: setIsListening, onListeningStateChange: setIsListening,
onSpeakingStateChange: setIsSpeaking onSpeakingStateChange: setIsSpeaking
}) })
@ -402,7 +404,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
await VoiceCallService.cancelRecording() await VoiceCallService.cancelRecording()
setTranscript('') setTranscript('')
} catch (error) { } catch (error) {
console.error('取消录音出错:', error); console.error('取消录音出错:', error)
} finally { } finally {
setTimeout(() => setIsProcessing(false), 500) setTimeout(() => setIsProcessing(false), 500)
} }
@ -412,23 +414,31 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
) )
// --- 语音通话控制函数结束 --- // --- 语音通话控制函数结束 ---
// --- 快捷键相关函数 --- // --- 快捷键相关函数 ---
const getKeyDisplayName = (keyCode: string) => { const getKeyDisplayName = (keyCode: string) => {
const keyMap: Record<string, string> = { const keyMap: Record<string, string> = {
Space: '空格键', Enter: '回车键', ShiftLeft: '左Shift键', ShiftRight: '右Shift键', Space: '空格键',
ControlLeft: '左Ctrl键', ControlRight: '右Ctrl键', AltLeft: '左Alt键', AltRight: '右Alt键' Enter: '回车键',
ShiftLeft: '左Shift键',
ShiftRight: '右Shift键',
ControlLeft: '左Ctrl键',
ControlRight: '右Ctrl键',
AltLeft: '左Alt键',
AltRight: '右Alt键'
} }
return keyMap[keyCode] || keyCode return keyMap[keyCode] || keyCode
} }
const handleShortcutKeyChange = useCallback((e: KeyboardEvent) => { const handleShortcutKeyChange = useCallback(
e.preventDefault() (e: KeyboardEvent) => {
if (isRecordingShortcut) { e.preventDefault()
setTempShortcutKey(e.code) if (isRecordingShortcut) {
setIsRecordingShortcut(false) setTempShortcutKey(e.code)
} setIsRecordingShortcut(false)
}, [isRecordingShortcut]) }
},
[isRecordingShortcut]
)
const saveShortcutKey = useCallback(() => { const saveShortcutKey = useCallback(() => {
setShortcutKey(tempShortcutKey) setShortcutKey(tempShortcutKey)
@ -437,31 +447,42 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
}, [tempShortcutKey]) }, [tempShortcutKey])
// 现在可以安全地使用 handleRecordStart/End // 现在可以安全地使用 handleRecordStart/End
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback(
if (isRecordingShortcut) { (e: KeyboardEvent) => {
handleShortcutKeyChange(e) if (isRecordingShortcut) {
return handleShortcutKeyChange(e)
} return
if (e.code === shortcutKey && !isProcessing && !isPaused && visible && !isShortcutPressed) { }
e.preventDefault() if (e.code === shortcutKey && !isProcessing && !isPaused && visible && !isShortcutPressed) {
setIsShortcutPressed(true) e.preventDefault()
const mockEvent = new MouseEvent('mousedown') as unknown as React.MouseEvent // 类型断言 setIsShortcutPressed(true)
handleRecordStart(mockEvent) // 现在 handleRecordStart 已经定义 const mockEvent = new MouseEvent('mousedown') as unknown as React.MouseEvent // 类型断言
} handleRecordStart(mockEvent) // 现在 handleRecordStart 已经定义
}, [ }
shortcutKey, isProcessing, isPaused, visible, isShortcutPressed, },
handleRecordStart, // 依赖项 [
isRecordingShortcut, handleShortcutKeyChange shortcutKey,
]) isProcessing,
isPaused,
visible,
isShortcutPressed,
handleRecordStart, // 依赖项
isRecordingShortcut,
handleShortcutKeyChange
]
)
const handleKeyUp = useCallback((e: KeyboardEvent) => { const handleKeyUp = useCallback(
if (e.code === shortcutKey && isShortcutPressed && visible) { (e: KeyboardEvent) => {
e.preventDefault() if (e.code === shortcutKey && isShortcutPressed && visible) {
setIsShortcutPressed(false) e.preventDefault()
const mockEvent = new MouseEvent('mouseup') as unknown as React.MouseEvent // 类型断言 setIsShortcutPressed(false)
handleRecordEnd(mockEvent) // 现在 handleRecordEnd 已经定义 const mockEvent = new MouseEvent('mouseup') as unknown as React.MouseEvent // 类型断言
} handleRecordEnd(mockEvent) // 现在 handleRecordEnd 已经定义
}, [shortcutKey, isShortcutPressed, visible, handleRecordEnd]) // 依赖项 }
},
[shortcutKey, isShortcutPressed, visible, handleRecordEnd]
) // 依赖项
useEffect(() => { useEffect(() => {
const savedShortcut = localStorage.getItem('voiceCallShortcutKey') const savedShortcut = localStorage.getItem('voiceCallShortcutKey')
@ -483,7 +504,6 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
}, [visible, handleKeyDown, handleKeyUp]) }, [visible, handleKeyDown, handleKeyUp])
// --- 快捷键相关函数结束 --- // --- 快捷键相关函数结束 ---
// 如果不可见,直接返回 null // 如果不可见,直接返回 null
if (!visible) return null if (!visible) return null
@ -511,10 +531,14 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
<Content> <Content>
{isSettingsVisible && ( {isSettingsVisible && (
<SettingsPanel> {/* 使用 styled-component */} <SettingsPanel>
{' '}
{/* 使用 styled-component */}
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */} <SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
<Space> <Space>
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}> {/* 使用 styled-component */} <ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
{' '}
{/* 使用 styled-component */}
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)} {isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
</ShortcutKeyButton> </ShortcutKeyButton>
<Button type="primary" onClick={saveShortcutKey}> <Button type="primary" onClick={saveShortcutKey}>
@ -522,7 +546,9 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
</Button> </Button>
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button> <Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
</Space> </Space>
<SettingsTip> {/* 使用 styled-component */} <SettingsTip>
{' '}
{/* 使用 styled-component */}
{t('voice_call.shortcut_key_tip')} {t('voice_call.shortcut_key_tip')}
</SettingsTip> </SettingsTip>
</SettingsPanel> </SettingsPanel>

View File

@ -15,7 +15,12 @@
"initialization_failed": "Failed to initialize voice call", "initialization_failed": "Failed to initialize voice call",
"error": "Voice call error", "error": "Voice call error",
"initializing": "Initializing voice call...", "initializing": "Initializing voice call...",
"ready": "Voice call ready" "ready": "Voice call ready",
"shortcut_key_setting": "[to be translated]:语音识别快捷键设置",
"press_any_key": "[to be translated]:请按任意键...",
"save": "[to be translated]:保存",
"cancel": "[to be translated]:取消",
"shortcut_key_tip": "[to be translated]:按下此快捷键开始录音,松开快捷键结束录音并发送"
}, },
"agents": { "agents": {
"add.button": "Add to Assistant", "add.button": "Add to Assistant",

View File

@ -1421,8 +1421,7 @@
"mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.voice": "[to be translated]:免费在线 TTS音色",
"mstts.output_format": "[to be translated]:输出格式", "mstts.output_format": "[to be translated]:输出格式",
"mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。", "mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色", "error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色"
"filter.emojis": "[to be translated]:过滤表情符号"
}, },
"asr": { "asr": {
"title": "音声認識", "title": "音声認識",
@ -1544,7 +1543,12 @@
"initialization_failed": "[to be translated]:初始化语音通话失败", "initialization_failed": "[to be translated]:初始化语音通话失败",
"error": "[to be translated]:语音通话出错", "error": "[to be translated]:语音通话出错",
"initializing": "[to be translated]:正在初始化语音通话...", "initializing": "[to be translated]:正在初始化语音通话...",
"ready": "[to be translated]:语音通话已就绪" "ready": "[to be translated]:语音通话已就绪",
"shortcut_key_setting": "[to be translated]:语音识别快捷键设置",
"press_any_key": "[to be translated]:请按任意键...",
"save": "[to be translated]:保存",
"cancel": "[to be translated]:取消",
"shortcut_key_tip": "[to be translated]:按下此快捷键开始录音,松开快捷键结束录音并发送"
} }
} }
} }

View File

@ -1421,8 +1421,7 @@
"mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.voice": "[to be translated]:免费在线 TTS音色",
"mstts.output_format": "[to be translated]:输出格式", "mstts.output_format": "[to be translated]:输出格式",
"mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。", "mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色", "error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色"
"filter.emojis": "[to be translated]:过滤表情符号"
}, },
"voice": { "voice": {
"title": "[to be translated]:语音功能", "title": "[to be translated]:语音功能",
@ -1544,7 +1543,12 @@
"initialization_failed": "[to be translated]:初始化语音通话失败", "initialization_failed": "[to be translated]:初始化语音通话失败",
"error": "[to be translated]:语音通话出错", "error": "[to be translated]:语音通话出错",
"initializing": "[to be translated]:正在初始化语音通话...", "initializing": "[to be translated]:正在初始化语音通话...",
"ready": "[to be translated]:语音通话已就绪" "ready": "[to be translated]:语音通话已就绪",
"shortcut_key_setting": "[to be translated]:语音识别快捷键设置",
"press_any_key": "[to be translated]:请按任意键...",
"save": "[to be translated]:保存",
"cancel": "[to be translated]:取消",
"shortcut_key_tip": "[to be translated]:按下此快捷键开始录音,松开快捷键结束录音并发送"
} }
} }
} }

View File

@ -1421,8 +1421,7 @@
"mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.voice": "[to be translated]:免费在线 TTS音色",
"mstts.output_format": "[to be translated]:输出格式", "mstts.output_format": "[to be translated]:输出格式",
"mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。", "mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色", "error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色"
"filter.emojis": "[to be translated]:过滤表情符号"
}, },
"voice": { "voice": {
"title": "[to be translated]:语音功能", "title": "[to be translated]:语音功能",
@ -1544,7 +1543,12 @@
"initialization_failed": "[to be translated]:初始化语音通话失败", "initialization_failed": "[to be translated]:初始化语音通话失败",
"error": "[to be translated]:语音通话出错", "error": "[to be translated]:语音通话出错",
"initializing": "[to be translated]:正在初始化语音通话...", "initializing": "[to be translated]:正在初始化语音通话...",
"ready": "[to be translated]:语音通话已就绪" "ready": "[to be translated]:语音通话已就绪",
"shortcut_key_setting": "[to be translated]:语音识别快捷键设置",
"press_any_key": "[to be translated]:请按任意键...",
"save": "[to be translated]:保存",
"cancel": "[to be translated]:取消",
"shortcut_key_tip": "[to be translated]:按下此快捷键开始录音,松开快捷键结束录音并发送"
} }
} }
} }

View File

@ -77,7 +77,8 @@ let _files: FileType[] = []
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => { const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
const [asrCurrentText, setAsrCurrentText] = useState('') // 用于存储语音识别的中间结果,不直接显示在输入框中
const [, setAsrCurrentText] = useState('')
const [inputFocus, setInputFocus] = useState(false) const [inputFocus, setInputFocus] = useState(false)
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id) const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id)
const { const {

View File

@ -155,13 +155,7 @@ const MessageItem: FC<Props> = ({
useEffect(() => { useEffect(() => {
// 如果是最后一条助手消息且消息状态为成功且不是正在生成中且TTS已启用 // 如果是最后一条助手消息且消息状态为成功且不是正在生成中且TTS已启用
// 注意只有在语音通话窗口打开时才自动播放TTS // 注意只有在语音通话窗口打开时才自动播放TTS
if ( if (isLastMessage && isAssistantMessage && message.status === 'success' && !generating && ttsEnabled) {
isLastMessage &&
isAssistantMessage &&
message.status === 'success' &&
!generating &&
ttsEnabled
) {
// 如果语音通话窗口没有打开则不自动播放TTS // 如果语音通话窗口没有打开则不自动播放TTS
if (!isVoiceCallActive) { if (!isVoiceCallActive) {
console.log('不自动播放TTS因为语音通话窗口没有打开:', isVoiceCallActive) console.log('不自动播放TTS因为语音通话窗口没有打开:', isVoiceCallActive)

View File

@ -170,7 +170,8 @@ class ASRService {
// 直接调用回调函数 // 直接调用回调函数
this.resultCallback(data.data.text, true) this.resultCallback(data.data.text, true)
window.message.success({ content: i18n.t('settings.asr.success'), key: 'asr-processing' }) window.message.success({ content: i18n.t('settings.asr.success'), key: 'asr-processing' })
} else if (this.isRecording) { // 只在录音中才处理中间结果 } else if (this.isRecording) {
// 只在录音中才处理中间结果
// 非最终结果,也调用回调,但标记为非最终 // 非最终结果,也调用回调,但标记为非最终
console.log('[ASRService] 收到中间结果,调用回调函数,文本:', data.data.text) console.log('[ASRService] 收到中间结果,调用回调函数,文本:', data.data.text)
this.resultCallback(data.data.text, false) this.resultCallback(data.data.text, false)

View File

@ -55,10 +55,8 @@ export class AudioStreamProcessor {
try { try {
// 解码音频数据 // 解码音频数据
// 将SharedArrayBuffer转换为ArrayBuffer // 将SharedArrayBuffer转换为ArrayBuffer
const arrayBuffer = chunk.buffer instanceof SharedArrayBuffer const arrayBuffer = chunk.buffer instanceof SharedArrayBuffer ? new Uint8Array(chunk.buffer).buffer : chunk.buffer
? new Uint8Array(chunk.buffer).buffer const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer as ArrayBuffer)
: chunk.buffer
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
// 调用回调函数 // 调用回调函数
if (this.onAudioBuffer) { if (this.onAudioBuffer) {
@ -78,7 +76,7 @@ export class AudioStreamProcessor {
public async finish(): Promise<void> { public async finish(): Promise<void> {
// 等待队列处理完成 // 等待队列处理完成
while (this.audioQueue.length > 0) { while (this.audioQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
} }
// 关闭音频上下文 // 关闭音频上下文

View File

@ -63,13 +63,13 @@ export class TTSService {
// 只有在使用EdgeTTS且标记为正在播放时才检查 // 只有在使用EdgeTTS且标记为正在播放时才检查
if (this.isPlaying && this.playingServiceType === 'edge') { if (this.isPlaying && this.playingServiceType === 'edge') {
// 检查是否还在播放 // 检查是否还在播放
const isSpeaking = window.speechSynthesis.speaking; const isSpeaking = window.speechSynthesis.speaking
if (!isSpeaking) { if (!isSpeaking) {
console.log('检测到speechSynthesis不再播放更新状态') console.log('检测到speechSynthesis不再播放更新状态')
this.updatePlayingState(false); this.updatePlayingState(false)
} }
} }
}, 500); // 每500毫秒检查一次 }, 500) // 每500毫秒检查一次
} }
} }
@ -109,7 +109,7 @@ export class TTSService {
private updatePlayingState(isPlaying: boolean): void { private updatePlayingState(isPlaying: boolean): void {
// 只有状态变化时才更新和触发事件 // 只有状态变化时才更新和触发事件
if (this.isPlaying !== isPlaying) { if (this.isPlaying !== isPlaying) {
this.isPlaying = isPlaying; this.isPlaying = isPlaying
console.log(`TTS播放状态更新: ${isPlaying ? '开始播放' : '停止播放'}`) console.log(`TTS播放状态更新: ${isPlaying ? '开始播放' : '停止播放'}`)
// 触发自定义事件通知其他组件TTS状态变化 // 触发自定义事件通知其他组件TTS状态变化
@ -118,11 +118,11 @@ export class TTSService {
// 如果停止播放,清除服务类型 // 如果停止播放,清除服务类型
if (!isPlaying) { if (!isPlaying) {
this.playingServiceType = null; this.playingServiceType = null
// 确保Web Speech API也停止 // 确保Web Speech API也停止
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel()
} }
} }
} }
@ -148,7 +148,7 @@ export class TTSService {
if (this.isPlaying) { if (this.isPlaying) {
this.stop() this.stop()
// 添加短暂延迟,确保上一个播放完全停止 // 添加短暂延迟,确保上一个播放完全停止
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
} }
// 确保文本不为空 // 确保文本不为空

View File

@ -19,5 +19,11 @@ export interface TTSServiceInterface {
* @param onError * @param onError
* @returns ID * @returns ID
*/ */
synthesizeStream?(text: string, onStart: () => void, onData: (audioChunk: AudioBuffer) => void, onEnd: () => void, onError: (error: Error) => void): Promise<string> synthesizeStream?(
text: string,
onStart: () => void,
onData: (audioChunk: AudioBuffer) => void,
onEnd: () => void,
onError: (error: Error) => void
): Promise<string>
} }