From 4fca77a04753697beeed95247daa74536765512c Mon Sep 17 00:00:00 2001 From: 1600822305 <1600822305@qq.com> Date: Fri, 11 Apr 2025 19:03:02 +0800 Subject: [PATCH] xuf --- package.json | 2 +- src/main/index.ts | 2 +- .../components/DraggableVoiceCallWindow.tsx | 388 +++++++++++------- .../src/components/VoiceCallButton.tsx | 13 +- .../src/components/VoiceVisualizer.tsx | 90 ++-- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 3 +- src/renderer/src/pages/VoiceCallWindow.tsx | 343 ++++++++++++++++ .../src/pages/home/Inputbar/Inputbar.tsx | 70 +++- .../src/pages/home/Messages/Message.tsx | 35 +- .../settings/TTSSettings/TTSSettings.tsx | 8 + .../TTSSettings/VoiceCallSettings.tsx | 76 ++-- src/renderer/src/services/VoiceCallService.ts | 47 ++- src/renderer/src/services/tts/TTSService.ts | 6 + .../src/services/tts/TTSTextFilter.ts | 25 ++ src/renderer/src/store/settings.ts | 23 +- yarn.lock | 53 +-- 20 files changed, 884 insertions(+), 308 deletions(-) create mode 100644 src/renderer/src/pages/VoiceCallWindow.tsx diff --git a/package.json b/package.json index 99a536c1b8..60efc83444 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@types/adm-zip": "^0", "@types/diff": "^7", "@types/fs-extra": "^11", - "@types/lodash": "^4.17.5", + "@types/lodash": "^4.17.16", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", "@types/node": "^18.19.9", diff --git a/src/main/index.ts b/src/main/index.ts index d9f92fb348..1ca7d4a91e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,8 +7,8 @@ import Logger from 'electron-log' import { registerIpc } from './ipc' import { configManager } from './services/ConfigManager' -import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler' import mcpService from './services/MCPService' +import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler' import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' diff --git a/src/renderer/src/components/DraggableVoiceCallWindow.tsx b/src/renderer/src/components/DraggableVoiceCallWindow.tsx index bc46743a53..e67ee1b769 100644 --- a/src/renderer/src/components/DraggableVoiceCallWindow.tsx +++ b/src/renderer/src/components/DraggableVoiceCallWindow.tsx @@ -1,24 +1,40 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Button, Space, Tooltip } from 'antd'; import { AudioMutedOutlined, AudioOutlined, CloseOutlined, + DragOutlined, PauseCircleOutlined, PlayCircleOutlined, - SoundOutlined, - DragOutlined -} from '@ant-design/icons'; -import styled from 'styled-components'; -import { useTranslation } from 'react-i18next'; -import VoiceVisualizer from './VoiceVisualizer'; -import { VoiceCallService } from '../services/VoiceCallService'; + SoundOutlined +} from '@ant-design/icons' +import { Button, Space, Tooltip } from 'antd' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import styled from 'styled-components' + +import { VoiceCallService } from '../services/VoiceCallService' +import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings' +import VoiceVisualizer from './VoiceVisualizer' + +// 节流函数,限制函数调用频率 + +function throttle any>(func: T, delay: number): (...args: Parameters) => void { + let lastCall = 0 + return (...args: Parameters) => { + const now = Date.now() + if (now - lastCall >= delay) { + lastCall = now + func(...args) + } + } +} interface Props { - visible: boolean; - onClose: () => void; - position?: { x: number, y: number }; - onPositionChange?: (position: { x: number, y: number }) => void; + visible: boolean + onClose: () => void + position?: { x: number; y: number } + onPositionChange?: (position: { x: number; y: number }) => void } const DraggableVoiceCallWindow: React.FC = ({ @@ -27,32 +43,33 @@ const DraggableVoiceCallWindow: React.FC = ({ position = { x: 20, y: 20 }, onPositionChange }) => { - const { t } = useTranslation(); - const [isDragging, setIsDragging] = useState(false); - const [currentPosition, setCurrentPosition] = useState(position); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const containerRef = useRef(null); + const { t } = useTranslation() + const dispatch = useDispatch() + const [isDragging, setIsDragging] = useState(false) + const [currentPosition, setCurrentPosition] = useState(position) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const containerRef = useRef(null) // 语音通话状态 - const [transcript, setTranscript] = useState(''); - const [isListening, setIsListening] = useState(false); - const [isSpeaking, setIsSpeaking] = useState(false); - const [isRecording, setIsRecording] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [isPaused, setIsPaused] = useState(false); - const [isMuted, setIsMuted] = useState(false); + const [transcript, setTranscript] = useState('') + const [isListening, setIsListening] = useState(false) + const [isSpeaking, setIsSpeaking] = useState(false) + const [isRecording, setIsRecording] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [isMuted, setIsMuted] = useState(false) useEffect(() => { const startVoiceCall = async () => { try { // 显示加载中提示 - window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' }); + window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' }) // 预先初始化语音识别服务 try { - await VoiceCallService.initialize(); + await VoiceCallService.initialize() } catch (initError) { - console.warn('语音识别服务初始化警告:', initError); + console.warn('语音识别服务初始化警告:', initError) // 不抛出异常,允许程序继续运行 } @@ -63,187 +80,280 @@ const DraggableVoiceCallWindow: React.FC = ({ // 这里不设置response,因为响应会显示在聊天界面中 }, onListeningStateChange: setIsListening, - onSpeakingStateChange: setIsSpeaking, - }); + onSpeakingStateChange: setIsSpeaking + }) // 关闭加载中提示 - window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' }); + window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' }) } catch (error) { - console.error('Voice call error:', error); - window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' }); - onClose(); + console.error('Voice call error:', error) + window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' }) + onClose() } - }; + } // 添加TTS状态变化事件监听器 const handleTTSStateChange = (event: CustomEvent) => { - const { isPlaying } = event.detail; - console.log('TTS状态变化事件:', isPlaying); - setIsSpeaking(isPlaying); - }; + const { isPlaying } = event.detail + console.log('TTS状态变化事件:', isPlaying) + setIsSpeaking(isPlaying) + } if (visible) { - startVoiceCall(); + // 更新语音通话窗口状态 + dispatch(setIsVoiceCallActive(true)) + // 重置最后播放的消息ID,确保不会自动播放已有消息 + dispatch(setLastPlayedMessageId(null)) + // 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息 + dispatch(setSkipNextAutoTTS(true)) + startVoiceCall() // 添加事件监听器 - window.addEventListener('tts-state-change', handleTTSStateChange as EventListener); + window.addEventListener('tts-state-change', handleTTSStateChange as EventListener) } return () => { - VoiceCallService.endCall(); + // 更新语音通话窗口状态 + dispatch(setIsVoiceCallActive(false)) + VoiceCallService.endCall() // 移除事件监听器 - window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener); - }; - }, [visible, t]); + window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener) + } + }, [visible, t]) // 拖拽相关处理 const handleDragStart = (e: React.MouseEvent) => { if (containerRef.current) { - setIsDragging(true); - const rect = containerRef.current.getBoundingClientRect(); + e.preventDefault() // 防止默认行为 + e.stopPropagation() // 阻止事件冒泡 + + // 直接使用鼠标相对于屏幕的位置和窗口相对于屏幕的位置计算偏移 + setIsDragging(true) + + // 记录鼠标相对于窗口左上角的偏移量 setDragOffset({ - x: e.clientX - rect.left, - y: e.clientY - rect.top - }); - } - }; + x: e.clientX - currentPosition.x, + y: e.clientY - currentPosition.y + }) - const handleDrag = (e: MouseEvent) => { - if (isDragging) { - const newPosition = { - x: e.clientX - dragOffset.x, - y: e.clientY - dragOffset.y - }; - setCurrentPosition(newPosition); - onPositionChange?.(newPosition); + // 在开发环境下只输出一次关键日志 + if (process.env.NODE_ENV === 'development') { + console.log('开始拖拽 - 偏移量:', { x: e.clientX - currentPosition.x, y: e.clientY - currentPosition.y }) + } } - }; + } - const handleDragEnd = () => { - setIsDragging(false); - }; + // 使用useCallback包装并优化handleDrag函数 + const handleDragBase = useCallback( + (e: MouseEvent) => { + if (isDragging && containerRef.current) { + // 限制拖拽范围,防止窗口被拖出屏幕 + const windowWidth = window.innerWidth + const windowHeight = window.innerHeight + const containerWidth = containerRef.current.offsetWidth + const containerHeight = containerRef.current.offsetHeight + + // 计算新位置,确保窗口不会被拖出屏幕 + // 使用鼠标当前位置减去偏移量,得到窗口应该在的位置 + const newX = Math.min(Math.max(0, e.clientX - dragOffset.x), windowWidth - containerWidth) + const newY = Math.min(Math.max(0, e.clientY - dragOffset.y), windowHeight - containerHeight) + + const newPosition = { x: newX, y: newY } + + // 完全关闭拖拽中的日志输出 + // 如果需要调试,可以在这里添加日志代码 + + // 立即更新窗口位置,提高响应速度 + containerRef.current.style.left = `${newX}px` + containerRef.current.style.top = `${newY}px` + + // 更新状态 + setCurrentPosition(newPosition) + onPositionChange?.(newPosition) + } + }, + [isDragging, dragOffset, onPositionChange] + ) + + // 使用useMemo包装节流函数,避免重复创建 + const handleDrag = useMemo(() => throttle(handleDragBase, 16), [handleDragBase]) // 16ms 大约相当于 60fps + + const handleDragEnd = useCallback(() => { + setIsDragging(false) + // 完全关闭拖拽结束的日志输出 + }, []) + + // 添加键盘快捷键支持 + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + // 防止箭头键滚动页面 + e.preventDefault() + + // 移动步长 + const step = e.shiftKey ? 10 : 5 + + // 根据按键移动窗口 + let newX = currentPosition.x + let newY = currentPosition.y + + if (e.key === 'ArrowUp') newY -= step + if (e.key === 'ArrowDown') newY += step + if (e.key === 'ArrowLeft') newX -= step + if (e.key === 'ArrowRight') newX += step + + // 限制范围 + if (containerRef.current) { + const windowWidth = window.innerWidth + const windowHeight = window.innerHeight + const containerWidth = containerRef.current.offsetWidth + const containerHeight = containerRef.current.offsetHeight + + newX = Math.min(Math.max(0, newX), windowWidth - containerWidth) + newY = Math.min(Math.max(0, newY), windowHeight - containerHeight) + } + + const newPosition = { x: newX, y: newY } + setCurrentPosition(newPosition) + onPositionChange?.(newPosition) + } + }, + [currentPosition, onClose, onPositionChange] + ) useEffect(() => { if (isDragging) { - document.addEventListener('mousemove', handleDrag); - document.addEventListener('mouseup', handleDragEnd); + document.addEventListener('mousemove', handleDrag) + document.addEventListener('mouseup', handleDragEnd) } return () => { - document.removeEventListener('mousemove', handleDrag); - document.removeEventListener('mouseup', handleDragEnd); - }; - }, [isDragging]); + document.removeEventListener('mousemove', handleDrag) + document.removeEventListener('mouseup', handleDragEnd) + } + }, [isDragging, handleDrag, handleDragEnd]) + + // 添加键盘事件监听 + useEffect(() => { + if (visible) { + document.addEventListener('keydown', handleKeyDown) + } + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [visible, handleKeyDown]) // 语音通话相关处理 const toggleMute = () => { - setIsMuted(!isMuted); - VoiceCallService.setMuted(!isMuted); - }; + setIsMuted(!isMuted) + VoiceCallService.setMuted(!isMuted) + } const togglePause = () => { - const newPauseState = !isPaused; - setIsPaused(newPauseState); - VoiceCallService.setPaused(newPauseState); - }; + const newPauseState = !isPaused + setIsPaused(newPauseState) + VoiceCallService.setPaused(newPauseState) + } // 长按说话相关处理 const handleRecordStart = async (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); // 防止触摸事件的默认行为 + e.preventDefault() // 防止触摸事件的默认行为 - if (isProcessing || isPaused) return; + if (isProcessing || isPaused) return // 先清除之前的语音识别结果 - setTranscript(''); + setTranscript('') // 无论是否正在播放,都强制停止TTS - VoiceCallService.stopTTS(); - setIsSpeaking(false); + VoiceCallService.stopTTS() + setIsSpeaking(false) // 更新UI状态 - setIsRecording(true); - setIsProcessing(true); // 设置处理状态,防止重复点击 + setIsRecording(true) + setIsProcessing(true) // 设置处理状态,防止重复点击 // 开始录音 try { - await VoiceCallService.startRecording(); - console.log('开始录音'); - setIsProcessing(false); // 录音开始后取消处理状态 + await VoiceCallService.startRecording() + console.log('开始录音') + setIsProcessing(false) // 录音开始后取消处理状态 } catch (error) { - console.error('开始录音出错:', error); - window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' }); - setIsRecording(false); - setIsProcessing(false); + console.error('开始录音出错:', error) + window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' }) + setIsRecording(false) + setIsProcessing(false) } - }; + } const handleRecordEnd = async (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); // 防止触摸事件的默认行为 + e.preventDefault() // 防止触摸事件的默认行为 - if (!isRecording) return; + if (!isRecording) return // 立即更新UI状态 - setIsRecording(false); - setIsProcessing(true); + setIsRecording(false) + setIsProcessing(true) // 无论是否正在播放,都强制停止TTS - VoiceCallService.stopTTS(); - setIsSpeaking(false); + VoiceCallService.stopTTS() + setIsSpeaking(false) // 确保录音完全停止 try { // 传递 true 参数,表示将结果发送到聊天界面 - const success = await VoiceCallService.stopRecordingAndSendToChat(); - console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败'); + const success = await VoiceCallService.stopRecordingAndSendToChat() + console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败') if (success) { // 显示成功消息 - window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' }); + window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' }) } else { // 显示失败消息 - window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' }); + window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' }) } } catch (error) { - console.error('停止录音出错:', error); - window.message.error({ content: '停止录音出错', key: 'voice-call-error' }); + console.error('停止录音出错:', error) + window.message.error({ content: '停止录音出错', key: 'voice-call-error' }) } finally { // 无论成功与否,都确保在一定时间后重置处理状态 setTimeout(() => { - setIsProcessing(false); - }, 1000); // 增加延迟时间,确保有足够时间处理结果 + setIsProcessing(false) + }, 1000) // 增加延迟时间,确保有足够时间处理结果 } - }; + } // 处理鼠标/触摸离开按钮的情况 const handleRecordCancel = async (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); + e.preventDefault() if (isRecording) { // 立即更新UI状态 - setIsRecording(false); - setIsProcessing(true); + setIsRecording(false) + setIsProcessing(true) // 无论是否正在播放,都强制停止TTS - VoiceCallService.stopTTS(); - setIsSpeaking(false); + VoiceCallService.stopTTS() + setIsSpeaking(false) // 取消录音,不发送给AI try { - await VoiceCallService.cancelRecording(); - console.log('录音已取消'); + await VoiceCallService.cancelRecording() + console.log('录音已取消') // 清除输入文本 - setTranscript(''); + setTranscript('') } catch (error) { - console.error('取消录音出错:', error); + console.error('取消录音出错:', error) } finally { // 无论成功与否,都确保在一定时间后重置处理状态 setTimeout(() => { - setIsProcessing(false); - }, 1000); + setIsProcessing(false) + }, 1000) } } - }; + } - if (!visible) return null; + if (!visible) return null return ( = ({ left: `${currentPosition.x}px`, top: `${currentPosition.y}px`, position: 'fixed', - zIndex: 1000 - }} - > + zIndex: 1000, + transform: 'translate3d(0,0,0)', // 启用GPU加速,提高拖拽流畅度 + willChange: 'left, top' // 提示浏览器这些属性将会变化,优化渲染 + }}>
{t('voice_call.title')} @@ -295,7 +406,7 @@ const DraggableVoiceCallWindow: React.FC = ({ /> } onMouseDown={handleRecordStart} onMouseUp={handleRecordEnd} @@ -304,8 +415,7 @@ const DraggableVoiceCallWindow: React.FC = ({ onTouchEnd={handleRecordEnd} onTouchCancel={handleRecordCancel} size="large" - disabled={isProcessing || isPaused} - > + disabled={isProcessing || isPaused}> {isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')} @@ -313,8 +423,8 @@ const DraggableVoiceCallWindow: React.FC = ({ - ); -}; + ) +} // 样式组件 const Container = styled.div` @@ -325,7 +435,7 @@ const Container = styled.div` overflow: hidden; display: flex; flex-direction: column; -`; +` const Header = styled.div` padding: 8px 12px; @@ -335,25 +445,25 @@ const Header = styled.div` display: flex; align-items: center; cursor: move; -`; +` const CloseButton = styled.div` margin-left: auto; cursor: pointer; -`; +` const Content = styled.div` display: flex; flex-direction: column; gap: 10px; padding: 12px; -`; +` const VisualizerContainer = styled.div` display: flex; justify-content: space-between; height: 60px; -`; +` const TranscriptContainer = styled.div` flex: 1; @@ -364,25 +474,25 @@ const TranscriptContainer = styled.div` border-radius: 8px; padding: 8px; background-color: var(--color-background-2); -`; +` const TranscriptText = styled.div` margin-bottom: 8px; -`; +` const UserLabel = styled.span` font-weight: bold; color: var(--color-primary); -`; +` const ControlsContainer = styled.div` display: flex; justify-content: center; padding: 8px 0; -`; +` const RecordButton = styled(Button)` min-width: 120px; -`; +` -export default DraggableVoiceCallWindow; +export default DraggableVoiceCallWindow diff --git a/src/renderer/src/components/VoiceCallButton.tsx b/src/renderer/src/components/VoiceCallButton.tsx index ded74af2f8..c150fde280 100644 --- a/src/renderer/src/components/VoiceCallButton.tsx +++ b/src/renderer/src/components/VoiceCallButton.tsx @@ -2,8 +2,10 @@ import React, { useState } from 'react'; import { Button, Tooltip } from 'antd'; import { PhoneOutlined, LoadingOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; import { VoiceCallService } from '../services/VoiceCallService'; import DraggableVoiceCallWindow from './DraggableVoiceCallWindow'; +import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings'; interface Props { disabled?: boolean; @@ -12,6 +14,7 @@ interface Props { const VoiceCallButton: React.FC = ({ disabled = false, style }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); const [isWindowVisible, setIsWindowVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); const [windowPosition, setWindowPosition] = useState({ x: 20, y: 20 }); @@ -24,6 +27,11 @@ const VoiceCallButton: React.FC = ({ disabled = false, style }) => { // 初始化语音服务 await VoiceCallService.initialize(); setIsWindowVisible(true); + dispatch(setIsVoiceCallActive(true)); + // 重置最后播放的消息ID,确保不会自动播放已有消息 + dispatch(setLastPlayedMessageId(null)); + // 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息 + dispatch(setSkipNextAutoTTS(true)); } catch (error) { console.error('Failed to initialize voice call:', error); window.message.error(t('voice_call.initialization_failed')); @@ -45,7 +53,10 @@ const VoiceCallButton: React.FC = ({ disabled = false, style }) => { setIsWindowVisible(false)} + onClose={() => { + setIsWindowVisible(false); + dispatch(setIsVoiceCallActive(false)); + }} position={windowPosition} onPositionChange={setWindowPosition} /> diff --git a/src/renderer/src/components/VoiceVisualizer.tsx b/src/renderer/src/components/VoiceVisualizer.tsx index 31d5662c92..8b6a018e9c 100644 --- a/src/renderer/src/components/VoiceVisualizer.tsx +++ b/src/renderer/src/components/VoiceVisualizer.tsx @@ -1,74 +1,74 @@ -import React, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { useTranslation } from 'react-i18next'; +import React, { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' interface Props { - isActive: boolean; - type: 'input' | 'output'; + isActive: boolean + type: 'input' | 'output' } const VoiceVisualizer: React.FC = ({ isActive, type }) => { - const { t } = useTranslation(); - const canvasRef = useRef(null); - const animationRef = useRef(undefined); + const { t } = useTranslation() + const canvasRef = useRef(null) + const animationRef = useRef(undefined) useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const ctx = canvas.getContext('2d'); - if (!ctx) return; + const ctx = canvas.getContext('2d') + if (!ctx) return - const width = canvas.width; - const height = canvas.height; + const width = canvas.width + const height = canvas.height const drawVisualizer = () => { - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height) if (!isActive) { // 绘制静态波形 - ctx.beginPath(); - ctx.moveTo(0, height / 2); - ctx.lineTo(width, height / 2); - ctx.strokeStyle = type === 'input' ? 'var(--color-text-2)' : 'var(--color-primary)'; - ctx.lineWidth = 2; - ctx.stroke(); - return; + ctx.beginPath() + ctx.moveTo(0, height / 2) + ctx.lineTo(width, height / 2) + ctx.strokeStyle = type === 'input' ? 'var(--color-text-2)' : 'var(--color-primary)' + ctx.lineWidth = 2 + ctx.stroke() + return } // 绘制动态波形 - const barCount = 30; - const barWidth = width / barCount; - const color = type === 'input' ? 'var(--color-text-1)' : 'var(--color-primary)'; + const barCount = 30 + const barWidth = width / barCount + const color = type === 'input' ? 'var(--color-text-1)' : 'var(--color-primary)' for (let i = 0; i < barCount; i++) { - const barHeight = Math.random() * (height / 2) + 10; - const x = i * barWidth; - const y = height / 2 - barHeight / 2; + const barHeight = Math.random() * (height / 2) + 10 + const x = i * barWidth + const y = height / 2 - barHeight / 2 - ctx.fillStyle = color; - ctx.fillRect(x, y, barWidth - 2, barHeight); + ctx.fillStyle = color + ctx.fillRect(x, y, barWidth - 2, barHeight) } - animationRef.current = requestAnimationFrame(drawVisualizer); - }; + animationRef.current = requestAnimationFrame(drawVisualizer) + } - drawVisualizer(); + drawVisualizer() return () => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - }; - }, [isActive, type]); + } + }, [isActive, type]) return ( - ); -}; + ) +} const Container = styled.div<{ $type: 'input' | 'output' }>` display: flex; @@ -77,21 +77,17 @@ const Container = styled.div<{ $type: 'input' | 'output' }>` width: 45%; border-radius: 8px; padding: 10px; - background-color: ${props => - props.$type === 'input' - ? 'var(--color-background-3)' - : 'var(--color-primary-bg)' - }; -`; + background-color: ${(props) => (props.$type === 'input' ? 'var(--color-background-3)' : 'var(--color-primary-bg)')}; +` const Label = styled.div` margin-bottom: 8px; font-weight: bold; -`; +` const Canvas = styled.canvas` width: 100%; height: 50px; -`; +` -export default VoiceVisualizer; +export default VoiceVisualizer diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index bcf2d4194a..fb03bb0fbd 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1413,6 +1413,7 @@ "filter.markdown": "Filter Markdown", "filter.code_blocks": "Filter code blocks", "filter.html_tags": "Filter HTML tags", + "filter.emojis": "Filter emojis", "max_text_length": "Maximum text length", "test": "Test Speech", "help": "Text-to-speech functionality supports converting text to natural-sounding speech.", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index ce73ef4698..cd8552b5b9 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1421,7 +1421,8 @@ "mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.output_format": "[to be translated]:输出格式", "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": { "title": "音声認識", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 88ceb0562f..efdee14c81 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1421,7 +1421,8 @@ "mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.output_format": "[to be translated]:输出格式", "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": { "title": "[to be translated]:语音功能", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 516b237a7c..75d5e7f851 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1427,6 +1427,7 @@ "filter.markdown": "过滤Markdown标记", "filter.code_blocks": "过滤代码块", "filter.html_tags": "过滤HTML标签", + "filter.emojis": "过滤表情符号", "max_text_length": "最大文本长度", "test": "测试语音", "help": "语音合成功能支持将文本转换为自然语音。", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0fc2d1597a..1039a1415e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1421,7 +1421,8 @@ "mstts.voice": "[to be translated]:免费在线 TTS音色", "mstts.output_format": "[to be translated]:输出格式", "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": { "title": "[to be translated]:语音功能", diff --git a/src/renderer/src/pages/VoiceCallWindow.tsx b/src/renderer/src/pages/VoiceCallWindow.tsx new file mode 100644 index 0000000000..cebeb1750a --- /dev/null +++ b/src/renderer/src/pages/VoiceCallWindow.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; +import { Button, Space, Tooltip } from 'antd'; +import { + AudioMutedOutlined, + AudioOutlined, + CloseOutlined, + PauseCircleOutlined, + PlayCircleOutlined, + SoundOutlined +} from '@ant-design/icons'; +import { VoiceCallService } from '../services/VoiceCallService'; +import VoiceVisualizer from '../components/VoiceVisualizer'; +import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings'; + +/** + * 独立窗口的语音通话组件 + * 这个组件用于在独立窗口中显示语音通话界面 + */ +const VoiceCallWindow: React.FC = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + // 语音通话状态 + const [transcript, setTranscript] = useState(''); + const [isListening, setIsListening] = useState(false); + const [isSpeaking, setIsSpeaking] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [isMuted, setIsMuted] = useState(false); + + useEffect(() => { + const startVoiceCall = async () => { + try { + // 显示加载中提示 + window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' }); + + // 预先初始化语音识别服务 + try { + await VoiceCallService.initialize(); + } catch (initError) { + console.warn('语音识别服务初始化警告:', initError); + // 不抛出异常,允许程序继续运行 + } + + // 启动语音通话 + await VoiceCallService.startCall({ + onTranscript: (text) => setTranscript(text), + onResponse: (_) => { + // 这里不设置response,因为响应会显示在聊天界面中 + }, + onListeningStateChange: setIsListening, + onSpeakingStateChange: setIsSpeaking, + }); + + // 关闭加载中提示 + window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' }); + } catch (error) { + console.error('Voice call error:', error); + window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' }); + handleClose(); + } + }; + + // 添加TTS状态变化事件监听器 + const handleTTSStateChange = (event: CustomEvent) => { + const { isPlaying } = event.detail; + console.log('TTS状态变化事件:', isPlaying); + setIsSpeaking(isPlaying); + }; + + // 更新语音通话窗口状态 + dispatch(setIsVoiceCallActive(true)); + // 重置最后播放的消息ID,确保不会自动播放已有消息 + dispatch(setLastPlayedMessageId(null)); + // 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息 + dispatch(setSkipNextAutoTTS(true)); + + startVoiceCall(); + // 添加事件监听器 + window.addEventListener('tts-state-change', handleTTSStateChange as EventListener); + + return () => { + // 更新语音通话窗口状态 + dispatch(setIsVoiceCallActive(false)); + VoiceCallService.endCall(); + // 移除事件监听器 + window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener); + }; + }, [t, dispatch]); + + // 语音通话相关处理 + const toggleMute = () => { + setIsMuted(!isMuted); + VoiceCallService.setMuted(!isMuted); + }; + + const togglePause = () => { + const newPauseState = !isPaused; + setIsPaused(newPauseState); + VoiceCallService.setPaused(newPauseState); + }; + + // 关闭窗口 + const handleClose = () => { + window.close(); + }; + + // 长按说话相关处理 + const handleRecordStart = async (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); // 防止触摸事件的默认行为 + + if (isProcessing || isPaused) return; + + // 先清除之前的语音识别结果 + setTranscript(''); + + // 无论是否正在播放,都强制停止TTS + VoiceCallService.stopTTS(); + setIsSpeaking(false); + + // 更新UI状态 + setIsRecording(true); + setIsProcessing(true); // 设置处理状态,防止重复点击 + + // 开始录音 + try { + await VoiceCallService.startRecording(); + console.log('开始录音'); + setIsProcessing(false); // 录音开始后取消处理状态 + } catch (error) { + console.error('开始录音出错:', error); + window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' }); + setIsRecording(false); + setIsProcessing(false); + } + }; + + const handleRecordEnd = async (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); // 防止触摸事件的默认行为 + + if (!isRecording) return; + + // 立即更新UI状态 + setIsRecording(false); + setIsProcessing(true); + + // 无论是否正在播放,都强制停止TTS + VoiceCallService.stopTTS(); + setIsSpeaking(false); + + // 确保录音完全停止 + try { + // 传递 true 参数,表示将结果发送到聊天界面 + const success = await VoiceCallService.stopRecordingAndSendToChat(); + console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败'); + + if (success) { + // 显示成功消息 + window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' }); + } else { + // 显示失败消息 + window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' }); + } + } catch (error) { + console.error('停止录音出错:', error); + window.message.error({ content: '停止录音出错', key: 'voice-call-error' }); + } finally { + // 无论成功与否,都确保在一定时间后重置处理状态 + setTimeout(() => { + setIsProcessing(false); + }, 1000); // 增加延迟时间,确保有足够时间处理结果 + } + }; + + // 处理鼠标/触摸离开按钮的情况 + const handleRecordCancel = async (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + + if (isRecording) { + // 立即更新UI状态 + setIsRecording(false); + setIsProcessing(true); + + // 无论是否正在播放,都强制停止TTS + VoiceCallService.stopTTS(); + setIsSpeaking(false); + + // 取消录音,不发送给AI + try { + await VoiceCallService.cancelRecording(); + console.log('录音已取消'); + + // 清除输入文本 + setTranscript(''); + } catch (error) { + console.error('取消录音出错:', error); + } finally { + // 无论成功与否,都确保在一定时间后重置处理状态 + setTimeout(() => { + setIsProcessing(false); + }, 1000); + } + } + }; + + return ( + +
+ {t('voice_call.title')} + + + +
+ + + + + + + + + {transcript && ( + + {t('voice_call.you')}: {transcript} + + )} + + + + +