mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
添加了 TTS 相关服务并更新了设置
This commit is contained in:
parent
42ed1a4819
commit
5d57eb18ea
@ -8,7 +8,6 @@ import Logger from 'electron-log'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
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'
|
||||
@ -49,8 +48,8 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
// 注册MsTTS IPC处理程序
|
||||
registerMsTTSIpcHandlers()
|
||||
// 注意: MsTTS IPC处理程序已在ipc.ts中注册
|
||||
// 不需要再次调用registerMsTTSIpcHandlers()
|
||||
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
|
||||
@ -210,6 +210,7 @@ const StyledButton = styled(Button)`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none; /* 移除边框 */
|
||||
&.anticon,
|
||||
&.iconfont {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@ -2,11 +2,13 @@ import {
|
||||
AudioMutedOutlined,
|
||||
AudioOutlined,
|
||||
CloseOutlined,
|
||||
DownOutlined,
|
||||
DragOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SettingOutlined,
|
||||
SoundOutlined
|
||||
SoundOutlined,
|
||||
UpOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Space, Tooltip } from 'antd'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -178,6 +180,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
|
||||
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
|
||||
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
// --- 快捷键相关状态结束 ---
|
||||
|
||||
const isInitializedRef = useRef(false)
|
||||
@ -518,6 +521,12 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
<Header onMouseDown={handleDragStart}>
|
||||
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
|
||||
{t('voice_call.title')}
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="settings-button"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
@ -530,42 +539,46 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
</Header>
|
||||
|
||||
<Content>
|
||||
{isSettingsVisible && (
|
||||
<SettingsPanel>
|
||||
{' '}
|
||||
{/* 使用 styled-component */}
|
||||
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
|
||||
<Space>
|
||||
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isSettingsVisible && (
|
||||
<SettingsPanel>
|
||||
{' '}
|
||||
{/* 使用 styled-component */}
|
||||
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
|
||||
</ShortcutKeyButton>
|
||||
<Button type="primary" onClick={saveShortcutKey}>
|
||||
{t('voice_call.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
|
||||
</Space>
|
||||
<SettingsTip>
|
||||
{' '}
|
||||
{/* 使用 styled-component */}
|
||||
{t('voice_call.shortcut_key_tip')}
|
||||
</SettingsTip>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
<VisualizerContainer>
|
||||
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
|
||||
<VoiceVisualizer isActive={isSpeaking} type="output" />
|
||||
</VisualizerContainer>
|
||||
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
|
||||
<Space>
|
||||
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
|
||||
{' '}
|
||||
{/* 使用 styled-component */}
|
||||
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
|
||||
</ShortcutKeyButton>
|
||||
<Button type="primary" onClick={saveShortcutKey}>
|
||||
{t('voice_call.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
|
||||
</Space>
|
||||
<SettingsTip>
|
||||
{' '}
|
||||
{/* 使用 styled-component */}
|
||||
{t('voice_call.shortcut_key_tip')}
|
||||
</SettingsTip>
|
||||
</SettingsPanel>
|
||||
)}
|
||||
<VisualizerContainer>
|
||||
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
|
||||
<VoiceVisualizer isActive={isSpeaking} type="output" />
|
||||
</VisualizerContainer>
|
||||
|
||||
<TranscriptContainer>
|
||||
{transcript && (
|
||||
<TranscriptText>
|
||||
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
|
||||
</TranscriptText>
|
||||
)}
|
||||
{/* 可以在这里添加 AI 回复的显示 */}
|
||||
</TranscriptContainer>
|
||||
<TranscriptContainer>
|
||||
{transcript && (
|
||||
<TranscriptText>
|
||||
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
|
||||
</TranscriptText>
|
||||
)}
|
||||
{/* 可以在这里添加 AI 回复的显示 */}
|
||||
</TranscriptContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ControlsContainer>
|
||||
<Space>
|
||||
|
||||
@ -1,18 +1,37 @@
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TTSButtonProps {
|
||||
message: Message
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SegmentedPlaybackState {
|
||||
isSegmentedPlayback: boolean
|
||||
segments: {
|
||||
text: string
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}[]
|
||||
currentSegmentIndex: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
// 分段播放状态
|
||||
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
|
||||
isSegmentedPlayback: false,
|
||||
segments: [],
|
||||
currentSegmentIndex: 0,
|
||||
isPlaying: false
|
||||
})
|
||||
|
||||
// 添加TTS状态变化事件监听器
|
||||
useEffect(() => {
|
||||
@ -31,6 +50,22 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 监听分段播放状态变化
|
||||
useEffect(() => {
|
||||
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
|
||||
console.log('检测到分段播放状态更新:', event.detail)
|
||||
setSegmentedPlaybackState(event.detail)
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 初始化时检查TTS状态
|
||||
useEffect(() => {
|
||||
// 检查当前是否正在播放
|
||||
@ -38,7 +73,7 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
if (isCurrentlyPlaying !== isSpeaking) {
|
||||
setIsSpeaking(isCurrentlyPlaying)
|
||||
}
|
||||
}, [])
|
||||
}, [isSpeaking])
|
||||
|
||||
const handleTTS = useCallback(async () => {
|
||||
if (isSpeaking) {
|
||||
@ -57,16 +92,51 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
}
|
||||
}, [isSpeaking, message])
|
||||
|
||||
// 处理分段播放按钮点击 - 暂未使用,保留供未来扩展
|
||||
/* const handleSegmentedTTS = useCallback(async () => {
|
||||
try {
|
||||
console.log('点击分段TTS按钮,开始分段播放消息')
|
||||
// 使用修改后的speakFromMessage方法,传入segmented=true参数
|
||||
await TTSService.speakFromMessage(message, true)
|
||||
} catch (error) {
|
||||
console.error('Segmented TTS error:', error)
|
||||
}
|
||||
}, [message]) */
|
||||
|
||||
return (
|
||||
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
|
||||
<Button
|
||||
className={className}
|
||||
icon={<SoundOutlined />}
|
||||
onClick={handleTTS}
|
||||
type={isSpeaking ? 'primary' : 'default'}
|
||||
/>
|
||||
<TTSActionButton className={className} onClick={handleTTS}>
|
||||
<SoundOutlined style={{ color: isSpeaking ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</TTSActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const TTSActionButton = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
.anticon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
.anticon,
|
||||
.iconfont {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
export default TTSButton
|
||||
|
||||
96
src/renderer/src/components/TTSHighlightedText.tsx
Normal file
96
src/renderer/src/components/TTSHighlightedText.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { TextSegmenter } from '@renderer/services/tts/TextSegmenter'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TTSHighlightedTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface SegmentedPlaybackState {
|
||||
isSegmentedPlayback: boolean
|
||||
segments: {
|
||||
text: string
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}[]
|
||||
currentSegmentIndex: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const TTSHighlightedText: React.FC<TTSHighlightedTextProps> = ({ text }) => {
|
||||
const [segments, setSegments] = useState<string[]>([])
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(-1)
|
||||
// 播放状态变量,用于跟踪当前是否正在播放
|
||||
const [, setIsPlaying] = useState<boolean>(false)
|
||||
|
||||
// 初始化时分割文本
|
||||
useEffect(() => {
|
||||
const textSegments = TextSegmenter.splitIntoSentences(text)
|
||||
setSegments(textSegments)
|
||||
}, [text])
|
||||
|
||||
// 监听分段播放状态变化
|
||||
useEffect(() => {
|
||||
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
|
||||
const data = event.detail as SegmentedPlaybackState
|
||||
if (data.isSegmentedPlayback) {
|
||||
setCurrentSegmentIndex(data.currentSegmentIndex)
|
||||
setIsPlaying(data.isPlaying)
|
||||
} else {
|
||||
setCurrentSegmentIndex(-1)
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 处理段落点击
|
||||
const handleSegmentClick = (index: number) => {
|
||||
TTSService.playFromSegment(index)
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return <div>{text}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<TextContainer>
|
||||
{segments.map((segment, index) => (
|
||||
<TextSegment
|
||||
key={index}
|
||||
className={index === currentSegmentIndex ? 'active' : ''}
|
||||
onClick={() => handleSegmentClick(index)}>
|
||||
{segment}
|
||||
</TextSegment>
|
||||
))}
|
||||
</TextContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TextContainer = styled.div`
|
||||
display: inline;
|
||||
`
|
||||
|
||||
const TextSegment = styled.span`
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-bg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
`
|
||||
|
||||
export default TTSHighlightedText
|
||||
243
src/renderer/src/components/TTSProgressBar.tsx
Normal file
243
src/renderer/src/components/TTSProgressBar.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TTSProgressBarProps {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
interface TTSProgressState {
|
||||
isPlaying: boolean
|
||||
progress: number // 0-100
|
||||
currentTime: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
const TTSProgressBar: React.FC<TTSProgressBarProps> = ({ messageId }) => {
|
||||
const [progressState, setProgressState] = useState<TTSProgressState>({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
// 监听TTS进度更新事件
|
||||
useEffect(() => {
|
||||
const handleProgressUpdate = (event: CustomEvent) => {
|
||||
const { messageId: playingMessageId, isPlaying, progress, currentTime, duration } = event.detail
|
||||
|
||||
console.log('TTS进度更新事件:', {
|
||||
playingMessageId,
|
||||
currentMessageId: messageId,
|
||||
isPlaying,
|
||||
progress,
|
||||
currentTime,
|
||||
duration
|
||||
})
|
||||
|
||||
// 只有当前消息正在播放时才更新进度
|
||||
if (playingMessageId === messageId) {
|
||||
// 如果收到的是重置信号(duration为0),则强制设置为非播放状态
|
||||
if (duration === 0 && currentTime === 0 && progress === 0) {
|
||||
setProgressState({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
})
|
||||
} else {
|
||||
setProgressState({ isPlaying, progress, currentTime, duration })
|
||||
}
|
||||
} else if (progressState.isPlaying) {
|
||||
// 如果当前消息不是正在播放的消息,但状态显示正在播放,则重置状态
|
||||
setProgressState({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听TTS状态变化事件
|
||||
const handleStateChange = (event: CustomEvent) => {
|
||||
const { isPlaying } = event.detail
|
||||
|
||||
// 如果停止播放,重置进度条状态
|
||||
if (!isPlaying && progressState.isPlaying) {
|
||||
console.log('收到TTS停止播放事件,重置进度条')
|
||||
setProgressState({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-progress-update', handleProgressUpdate as EventListener)
|
||||
window.addEventListener('tts-state-change', handleStateChange as EventListener)
|
||||
|
||||
console.log('添加TTS进度更新事件监听器,消息ID:', messageId)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-progress-update', handleProgressUpdate as EventListener)
|
||||
window.removeEventListener('tts-state-change', handleStateChange as EventListener)
|
||||
console.log('移除TTS进度更新事件监听器,消息ID:', messageId)
|
||||
}
|
||||
}, [messageId, progressState.isPlaying])
|
||||
|
||||
// 如果没有播放,不显示进度条
|
||||
if (!progressState.isPlaying) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理进度条点击
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressState.isPlaying) return
|
||||
|
||||
// 如果是拖动结束的点击事件,忽略
|
||||
if (e.type === 'click' && e.detail === 0) return
|
||||
|
||||
const trackRect = e.currentTarget.getBoundingClientRect()
|
||||
const clickPosition = e.clientX - trackRect.left
|
||||
const trackWidth = trackRect.width
|
||||
const seekPercentage = (clickPosition / trackWidth) * 100
|
||||
const seekTime = (seekPercentage / 100) * progressState.duration
|
||||
|
||||
console.log(`进度条点击: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}秒`)
|
||||
|
||||
// 调用TTS服务的seek方法
|
||||
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
|
||||
TTSService.seek(seekTime)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理拖动
|
||||
const handleDrag = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressState.isPlaying) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // 阻止事件冒泡
|
||||
|
||||
// 记录开始拖动状态
|
||||
let isDragging = true
|
||||
|
||||
const trackRect = e.currentTarget.getBoundingClientRect()
|
||||
const trackWidth = trackRect.width
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
moveEvent.preventDefault()
|
||||
const dragPosition = Math.max(0, Math.min(moveEvent.clientX - trackRect.left, trackWidth))
|
||||
const seekPercentage = (dragPosition / trackWidth) * 100
|
||||
const seekTime = (seekPercentage / 100) * progressState.duration
|
||||
|
||||
// 更新本地状态以实时反映拖动位置
|
||||
setProgressState((prev) => ({
|
||||
...prev,
|
||||
progress: seekPercentage,
|
||||
currentTime: seekTime
|
||||
}))
|
||||
}
|
||||
|
||||
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
isDragging = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
const dragPosition = Math.max(0, Math.min(upEvent.clientX - trackRect.left, trackWidth))
|
||||
const seekPercentage = (dragPosition / trackWidth) * 100
|
||||
const seekTime = (seekPercentage / 100) * progressState.duration
|
||||
|
||||
console.log(`拖动结束: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}秒`)
|
||||
|
||||
// 调用TTS服务的seek方法
|
||||
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
|
||||
TTSService.seek(seekTime)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProgressBarContainer>
|
||||
<ProgressBarTrack onClick={handleTrackClick} onMouseDown={handleDrag}>
|
||||
<ProgressBarFill style={{ width: `${progressState.progress}%` }} />
|
||||
<ProgressBarHandle style={{ left: `${progressState.progress}%` }} />
|
||||
</ProgressBarTrack>
|
||||
<ProgressText>
|
||||
{formatTime(progressState.currentTime)} / {formatTime(progressState.duration)}
|
||||
</ProgressText>
|
||||
</ProgressBarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// 格式化时间为 mm:ss 格式
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const ProgressBarContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const ProgressBarTrack = styled.div`
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ProgressBarFill = styled.div`
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const ProgressBarHandle = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
${ProgressBarTrack}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const ProgressText = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
export default TTSProgressBar
|
||||
76
src/renderer/src/components/TTSSegmentedText.tsx
Normal file
76
src/renderer/src/components/TTSSegmentedText.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { Spin } from 'antd'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TTSSegmentedTextProps {
|
||||
segments: {
|
||||
text: string
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}[]
|
||||
currentSegmentIndex: number
|
||||
isPlaying: boolean
|
||||
onSegmentClick: (index: number) => void
|
||||
}
|
||||
|
||||
const TTSSegmentedText: React.FC<TTSSegmentedTextProps> = ({
|
||||
segments,
|
||||
currentSegmentIndex,
|
||||
// isPlaying, // 未使用的参数
|
||||
onSegmentClick
|
||||
}) => {
|
||||
if (!segments || segments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentedTextContainer>
|
||||
{segments.map((segment, index) => (
|
||||
<Segment
|
||||
key={index}
|
||||
className={`${index === currentSegmentIndex ? 'active' : ''}`}
|
||||
onClick={() => onSegmentClick(index)}>
|
||||
<SegmentText>{segment.text}</SegmentText>
|
||||
{segment.isLoading && <Spin size="small" className="segment-loading" />}
|
||||
</Segment>
|
||||
))}
|
||||
</SegmentedTextContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const SegmentedTextContainer = styled.div`
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const Segment = styled.div`
|
||||
padding: 5px;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-bg);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.segment-loading {
|
||||
margin-left: 5px;
|
||||
}
|
||||
`
|
||||
|
||||
const SegmentText = styled.span`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
export default TTSSegmentedText
|
||||
@ -30,10 +30,11 @@ const VoiceCallModal: React.FC<Props> = ({ visible, onClose }) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
// 使用useCallback包裹handleClose函数,避免useEffect依赖项变化
|
||||
const handleClose = React.useCallback(() => {
|
||||
VoiceCallService.endCall()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const startVoiceCall = async () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import ASRServerService from '@renderer/services/ASRServerService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
@ -19,7 +20,18 @@ import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const {
|
||||
proxyUrl,
|
||||
language,
|
||||
windowStyle,
|
||||
autoCheckUpdate,
|
||||
proxyMode,
|
||||
customCss,
|
||||
enableDataCollection,
|
||||
asrEnabled,
|
||||
asrServiceType,
|
||||
asrAutoStartServer
|
||||
} = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
@ -108,4 +120,18 @@ export function useAppInit() {
|
||||
useEffect(() => {
|
||||
enableDataCollection ? initAnalytics() : disableAnalytics()
|
||||
}, [enableDataCollection])
|
||||
|
||||
// 自动启动ASR服务器
|
||||
useEffect(() => {
|
||||
if (asrEnabled && asrServiceType === 'local' && asrAutoStartServer) {
|
||||
console.log('自动启动ASR服务器...')
|
||||
ASRServerService.startServer().then((success) => {
|
||||
if (success) {
|
||||
console.log('ASR服务器自动启动成功')
|
||||
} else {
|
||||
console.error('ASR服务器自动启动失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [asrEnabled, asrServiceType, asrAutoStartServer])
|
||||
}
|
||||
|
||||
@ -1422,7 +1422,15 @@
|
||||
"test": "Test Speech",
|
||||
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
|
||||
"learn_more": "Learn more",
|
||||
"tab_title": "[to be translated]:语音合成",
|
||||
"tab_title": "Text-to-Speech",
|
||||
"play": "Play speech",
|
||||
"stop": "Stop playback",
|
||||
"speak": "Play speech",
|
||||
"stop_global": "Stop all speech playback",
|
||||
"stopped": "Speech playback stopped",
|
||||
"segmented": "Segmented Playback",
|
||||
"segmented_play": "Segmented Playback",
|
||||
"segmented_playback": "Segmented Playback",
|
||||
"error": {
|
||||
"not_enabled": "Text-to-speech feature is not enabled",
|
||||
"no_api_key": "API key is not set",
|
||||
@ -1436,14 +1444,14 @@
|
||||
"general": "An error occurred during speech synthesis",
|
||||
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
|
||||
},
|
||||
"service_type.mstts": "[to be translated]:免费在线 TTS",
|
||||
"edge_voice.available_count": "[to be translated]:可用语音: {{count}}个",
|
||||
"edge_voice.refreshing": "[to be translated]:正在刷新语音列表...",
|
||||
"edge_voice.refreshed": "[to be translated]:语音列表已刷新",
|
||||
"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音色"
|
||||
"service_type.mstts": "Free Online TTS",
|
||||
"edge_voice.available_count": "Available voices: {{count}}",
|
||||
"edge_voice.refreshing": "Refreshing voice list...",
|
||||
"edge_voice.refreshed": "Voice list refreshed",
|
||||
"mstts.voice": "Free Online TTS Voice",
|
||||
"mstts.output_format": "Output Format",
|
||||
"mstts.info": "Free Online TTS service doesn't require an API key, completely free to use.",
|
||||
"error.no_mstts_voice": "Free Online TTS voice not set"
|
||||
},
|
||||
"asr": {
|
||||
"title": "Speech Recognition",
|
||||
|
||||
@ -1436,6 +1436,14 @@
|
||||
"test": "测试语音",
|
||||
"help": "语音合成功能支持将文本转换为自然语音。",
|
||||
"learn_more": "了解更多",
|
||||
"play": "播放语音",
|
||||
"stop": "停止播放",
|
||||
"speak": "播放语音",
|
||||
"stop_global": "停止所有语音播放",
|
||||
"stopped": "已停止语音播放",
|
||||
"segmented": "分段",
|
||||
"segmented_play": "分段播放",
|
||||
"segmented_playback": "分段播放",
|
||||
"error": {
|
||||
"not_enabled": "语音合成功能未启用",
|
||||
"no_api_key": "未设置API密钥",
|
||||
@ -1490,6 +1498,8 @@
|
||||
"success": "语音识别成功",
|
||||
"completed": "语音识别完成",
|
||||
"canceled": "已取消录音",
|
||||
"auto_start_server": "启动应用自动开启服务器",
|
||||
"auto_start_server.help": "启用后,应用启动时会自动开启语音识别服务器",
|
||||
"error": {
|
||||
"not_enabled": "语音识别功能未启用",
|
||||
"no_api_key": "未设置API密钥",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import TTSProgressBar from '@renderer/components/TTSProgressBar'
|
||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
@ -263,7 +264,7 @@ const MessageItem: FC<Props> = ({
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
@ -276,6 +277,11 @@ const MessageItem: FC<Props> = ({
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} model={model} />
|
||||
</MessageErrorBoundary>
|
||||
{isAssistantMessage && (
|
||||
<ProgressBarWrapper>
|
||||
<TTSProgressBar messageId={message.id} />
|
||||
</ProgressBarWrapper>
|
||||
)}
|
||||
{showMenubar && (
|
||||
<MessageFooter
|
||||
style={{
|
||||
@ -310,7 +316,12 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
|
||||
: undefined
|
||||
}
|
||||
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
const getContextMenuItems = (
|
||||
t: (key: string) => string,
|
||||
selectedQuoteText: string,
|
||||
selectedText: string,
|
||||
currentMessage?: Message
|
||||
) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
@ -325,6 +336,29 @@ const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: stri
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'speak',
|
||||
label: '朗读',
|
||||
onClick: () => {
|
||||
// 从选中的文本开始朗读后面的内容
|
||||
if (selectedText && currentMessage?.content) {
|
||||
// 找到选中文本在消息中的位置
|
||||
const startIndex = currentMessage.content.indexOf(selectedText)
|
||||
if (startIndex !== -1) {
|
||||
// 获取选中文本及其后面的所有内容
|
||||
const textToSpeak = currentMessage.content.substring(startIndex)
|
||||
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
|
||||
TTSService.speak(textToSpeak)
|
||||
})
|
||||
} else {
|
||||
// 如果找不到精确位置,则只朗读选中的文本
|
||||
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
|
||||
TTSService.speak(selectedText)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -381,4 +415,9 @@ const NewContextMessage = styled.div`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ProgressBarWrapper = styled.div`
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
`
|
||||
|
||||
export default memo(MessageItem)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
|
||||
import { isOpenAIWebSearch } from '@renderer/config/models'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
@ -6,7 +7,7 @@ import { getBriefInfo } from '@renderer/utils'
|
||||
import { withMessageThought } from '@renderer/utils/formats'
|
||||
import { Divider, Flex } from 'antd'
|
||||
import { clone } from 'lodash'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
@ -29,6 +30,23 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
const { t } = useTranslation()
|
||||
const message = withMessageThought(clone(_message))
|
||||
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
|
||||
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false)
|
||||
|
||||
// 监听分段播放状态变化
|
||||
useEffect(() => {
|
||||
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
|
||||
const { isSegmentedPlayback } = event.detail
|
||||
setIsSegmentedPlayback(isSegmentedPlayback)
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// HTML实体编码辅助函数
|
||||
const encodeHTML = (str: string) => {
|
||||
@ -205,7 +223,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
</Flex>
|
||||
<MessageThought message={message} />
|
||||
<MessageTools message={message} />
|
||||
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
|
||||
{isSegmentedPlayback ? (
|
||||
<TTSHighlightedText text={processedContent.replace(toolUseRegex, '')} />
|
||||
) : (
|
||||
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
|
||||
)}
|
||||
{message.metadata?.generateImage && <MessageImage message={message} />}
|
||||
{message.translatedContent && (
|
||||
<Fragment>
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
MenuOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SaveOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
TranslationOutlined
|
||||
} from '@ant-design/icons'
|
||||
@ -16,14 +15,13 @@ import { UploadOutlined } from '@ant-design/icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
// import TTSButton from '@renderer/components/TTSButton' // 暂时不使用
|
||||
import TTSButton from '@renderer/components/TTSButton'
|
||||
import { isReasoningModel } from '@renderer/config/models'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import type { Message, Model } from '@renderer/types'
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
@ -152,7 +150,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
// 解析编辑后的文本,提取图片 URL
|
||||
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
|
||||
const imageUrls: string[] = []
|
||||
let match
|
||||
let match: RegExpExecArray | null
|
||||
let content = editedText
|
||||
|
||||
while ((match = imageRegex.exec(editedText)) !== null) {
|
||||
@ -405,18 +403,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && ttsEnabled && (
|
||||
<Tooltip title={t('chat.tts.play')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={() => {
|
||||
console.log('点击MessageMenubar中的TTS按钮,开始播放消息')
|
||||
TTSService.speakFromMessage(message)
|
||||
}}>
|
||||
<SoundOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && ttsEnabled && <TTSButton message={message} className="message-action-button" />}
|
||||
{!isUserMessage && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
|
||||
@ -2,7 +2,14 @@ import { GlobalOutlined, InfoCircleOutlined, PlayCircleOutlined, StopOutlined }
|
||||
import ASRServerService from '@renderer/services/ASRServerService'
|
||||
import ASRService from '@renderer/services/ASRService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAsrApiKey, setAsrApiUrl, setAsrEnabled, setAsrModel, setAsrServiceType } from '@renderer/store/settings'
|
||||
import {
|
||||
setAsrApiKey,
|
||||
setAsrApiUrl,
|
||||
setAsrAutoStartServer,
|
||||
setAsrEnabled,
|
||||
setAsrModel,
|
||||
setAsrServiceType
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Form, Input, Select, Space, Switch } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,6 +29,7 @@ const ASRSettings: FC = () => {
|
||||
const asrApiKey = useSelector((state: any) => state.settings.asrApiKey)
|
||||
const asrApiUrl = useSelector((state: any) => state.settings.asrApiUrl)
|
||||
const asrModel = useSelector((state: any) => state.settings.asrModel || 'whisper-1')
|
||||
const asrAutoStartServer = useSelector((state: any) => state.settings.asrAutoStartServer)
|
||||
|
||||
// 检查服务器状态
|
||||
useEffect(() => {
|
||||
@ -178,6 +186,21 @@ const ASRSettings: FC = () => {
|
||||
</Button>
|
||||
|
||||
<BrowserTip>{t('settings.asr.local.browser_tip')}</BrowserTip>
|
||||
|
||||
{/* 启动应用自动开启服务器 */}
|
||||
<Form.Item style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={asrAutoStartServer}
|
||||
onChange={(checked) => dispatch(setAsrAutoStartServer(checked))}
|
||||
disabled={!asrEnabled}
|
||||
/>
|
||||
<span>{t('settings.asr.auto_start_server')}</span>
|
||||
<Tooltip title={t('settings.asr.auto_start_server.help')}>
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-3)' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@ -17,9 +17,10 @@ class TTSService {
|
||||
/**
|
||||
* 将文本转换为语音并播放
|
||||
* @param text 要转换的文本
|
||||
* @param segmented 是否使用分段播放
|
||||
*/
|
||||
speak = async (text: string): Promise<void> => {
|
||||
await this.service.speak(text)
|
||||
speak = async (text: string, segmented: boolean = false): Promise<void> => {
|
||||
await this.service.speak(text, segmented)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,9 +33,10 @@ class TTSService {
|
||||
/**
|
||||
* 从消息中提取文本并转换为语音
|
||||
* @param message 消息对象
|
||||
* @param segmented 是否使用分段播放
|
||||
*/
|
||||
speakFromMessage = async (message: Message): Promise<void> => {
|
||||
await this.service.speakFromMessage(message)
|
||||
speakFromMessage = async (message: Message, segmented: boolean = false): Promise<void> => {
|
||||
await this.service.speakFromMessage(message, segmented)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,6 +45,22 @@ class TTSService {
|
||||
isCurrentlyPlaying = (): boolean => {
|
||||
return this.service.isCurrentlyPlaying()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定段落开始播放
|
||||
* @param segmentIndex 段落索引
|
||||
*/
|
||||
playFromSegment = (segmentIndex: number): void => {
|
||||
this.service.playFromSegment(segmentIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定时间点
|
||||
* @param time 时间(秒)
|
||||
*/
|
||||
seek = (time: number): void => {
|
||||
this.service.seek(time)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
|
||||
@ -3,6 +3,7 @@ import store from '@renderer/store'
|
||||
import { setLastPlayedMessageId } from '@renderer/store/settings'
|
||||
import { Message } from '@renderer/types'
|
||||
|
||||
import { TextSegmenter } from './TextSegmenter'
|
||||
import { TTSServiceFactory } from './TTSServiceFactory'
|
||||
import { TTSTextFilter } from './TTSTextFilter'
|
||||
|
||||
@ -10,11 +11,27 @@ import { TTSTextFilter } from './TTSTextFilter'
|
||||
* TTS服务类
|
||||
* 用于处理文本到语音的转换
|
||||
*/
|
||||
// 音频段落接口
|
||||
interface AudioSegment {
|
||||
text: string // 段落文本
|
||||
audioBlob?: Blob // 对应的音频Blob
|
||||
audioUrl?: string // 音频URL
|
||||
isLoaded: boolean // 是否已加载
|
||||
isLoading: boolean // 是否正在加载
|
||||
}
|
||||
|
||||
export class TTSService {
|
||||
private static instance: TTSService
|
||||
private audioElement: HTMLAudioElement | null = null
|
||||
private isPlaying = false
|
||||
private playingServiceType: string | null = null
|
||||
private playingMessageId: string | null = null
|
||||
private progressUpdateInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// 分段播放相关属性
|
||||
private audioSegments: AudioSegment[] = []
|
||||
private currentSegmentIndex: number = 0
|
||||
private isSegmentedPlayback: boolean = false
|
||||
|
||||
// 错误消息节流控制
|
||||
private lastErrorTime = 0
|
||||
@ -76,9 +93,10 @@ export class TTSService {
|
||||
/**
|
||||
* 从消息中提取文本并播放
|
||||
* @param message 消息对象
|
||||
* @param segmented 是否使用分段播放
|
||||
* @returns 是否成功播放
|
||||
*/
|
||||
public async speakFromMessage(message: Message): Promise<boolean> {
|
||||
public async speakFromMessage(message: Message, segmented: boolean = false): Promise<boolean> {
|
||||
// 获取最新的TTS过滤选项
|
||||
const settings = store.getState().settings
|
||||
const ttsFilterOptions = settings.ttsFilterOptions || {
|
||||
@ -94,12 +112,15 @@ export class TTSService {
|
||||
dispatch(setLastPlayedMessageId(message.id))
|
||||
console.log('更新最后播放的消息ID:', message.id)
|
||||
|
||||
// 记录当前正在播放的消息ID
|
||||
this.playingMessageId = message.id
|
||||
|
||||
// 应用过滤
|
||||
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions)
|
||||
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length)
|
||||
|
||||
// 播放过滤后的文本
|
||||
return this.speak(filteredText)
|
||||
return this.speak(filteredText, segmented)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,9 +137,15 @@ export class TTSService {
|
||||
const event = new CustomEvent('tts-state-change', { detail: { isPlaying } })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
// 如果停止播放,清除服务类型
|
||||
// 如果开始播放,启动进度更新定时器
|
||||
if (isPlaying && this.audioElement) {
|
||||
this.startProgressUpdates()
|
||||
}
|
||||
|
||||
// 如果停止播放,清除服务类型和定时器
|
||||
if (!isPlaying) {
|
||||
this.playingServiceType = null
|
||||
this.stopProgressUpdates()
|
||||
|
||||
// 确保Web Speech API也停止
|
||||
if ('speechSynthesis' in window) {
|
||||
@ -131,9 +158,10 @@ export class TTSService {
|
||||
/**
|
||||
* 播放文本
|
||||
* @param text 要播放的文本
|
||||
* @param segmented 是否使用分段播放
|
||||
* @returns 是否成功播放
|
||||
*/
|
||||
public async speak(text: string): Promise<boolean> {
|
||||
public async speak(text: string, segmented: boolean = false): Promise<boolean> {
|
||||
try {
|
||||
// 检查TTS是否启用
|
||||
const settings = store.getState().settings
|
||||
@ -164,6 +192,15 @@ export class TTSService {
|
||||
console.log('使用的TTS服务类型:', serviceType)
|
||||
// 记录当前使用的服务类型
|
||||
this.playingServiceType = serviceType
|
||||
|
||||
// 设置分段播放模式
|
||||
this.isSegmentedPlayback = segmented
|
||||
|
||||
if (segmented) {
|
||||
// 分段播放模式
|
||||
return await this.speakSegmented(text, serviceType, latestSettings)
|
||||
}
|
||||
|
||||
console.log('当前TTS设置详情:', {
|
||||
ttsServiceType: serviceType,
|
||||
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
|
||||
@ -264,8 +301,19 @@ export class TTSService {
|
||||
console.log('停止Web Speech API播放')
|
||||
}
|
||||
|
||||
// 停止进度更新
|
||||
this.stopProgressUpdates()
|
||||
|
||||
// 更新状态并触发事件
|
||||
this.updatePlayingState(false)
|
||||
|
||||
// 清除正在播放的消息ID
|
||||
this.playingMessageId = null
|
||||
|
||||
// 发送一个最终的进度更新事件,确保进度条消失
|
||||
this.emitProgressUpdateEvent(0, 0, 0)
|
||||
|
||||
// 如果是分段播放模式,不清理资源,以便用户可以从其他段落继续播放
|
||||
}
|
||||
|
||||
/**
|
||||
@ -276,6 +324,334 @@ export class TTSService {
|
||||
return this.isPlaying
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定时间点
|
||||
* @param time 时间(秒)
|
||||
* @returns 是否成功跳转
|
||||
*/
|
||||
public seek(time: number): boolean {
|
||||
if (!this.audioElement || !this.isPlaying) {
|
||||
console.log('无法跳转,音频元素不存在或未在播放中')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保时间在有效范围内
|
||||
const duration = this.audioElement.duration || 0
|
||||
const validTime = Math.max(0, Math.min(time, duration))
|
||||
|
||||
console.log(`跳转到时间点: ${validTime.toFixed(2)}秒 / ${duration.toFixed(2)}秒`)
|
||||
this.audioElement.currentTime = validTime
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('跳转失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定段落开始播放
|
||||
* @param index 段落索引
|
||||
* @returns 是否成功开始播放
|
||||
*/
|
||||
public playFromSegment(index: number): boolean {
|
||||
console.log(`请求从段落 ${index} 开始播放`)
|
||||
|
||||
// 如果当前不是分段播放模式,则先将当前消息切换为分段播放模式
|
||||
if (!this.isSegmentedPlayback) {
|
||||
console.log('当前不是分段播放模式,无法从指定段落开始播放')
|
||||
return false
|
||||
}
|
||||
|
||||
if (index < 0 || index >= this.audioSegments.length) {
|
||||
console.log(`段落索引超出范围: ${index}, 总段落数: ${this.audioSegments.length}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果正在播放,先停止
|
||||
if (this.isPlaying) {
|
||||
console.log('停止当前播放')
|
||||
this.stop()
|
||||
}
|
||||
|
||||
console.log(`开始播放段落 ${index}: ${this.audioSegments[index].text.substring(0, 20)}...`)
|
||||
|
||||
// 开始播放指定段落
|
||||
return this.playSegment(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分段播放模式
|
||||
* @param text 要播放的文本
|
||||
* @param serviceType TTS服务类型
|
||||
* @param settings 设置
|
||||
* @returns 是否成功播放
|
||||
*/
|
||||
private async speakSegmented(text: string, serviceType: string, settings: any): Promise<boolean> {
|
||||
try {
|
||||
console.log('开始分段播放模式')
|
||||
|
||||
// 分割文本为句子
|
||||
const sentences = TextSegmenter.splitIntoSentences(text)
|
||||
console.log(`文本分割为 ${sentences.length} 个段落`)
|
||||
|
||||
if (sentences.length === 0) {
|
||||
console.log('没有有效段落,取消播放')
|
||||
return false
|
||||
}
|
||||
|
||||
// 创建音频段落数组
|
||||
this.audioSegments = sentences.map((sentence) => ({
|
||||
text: sentence,
|
||||
isLoaded: false,
|
||||
isLoading: false
|
||||
}))
|
||||
|
||||
// 设置分段播放模式
|
||||
this.isSegmentedPlayback = true
|
||||
|
||||
// 重置当前段落索引
|
||||
this.currentSegmentIndex = 0
|
||||
|
||||
// 触发分段播放事件
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
// 预加载所有段落,确保完整播放
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
// 使用setTimeout错开加载时间,避免同时发起过多请求
|
||||
setTimeout(() => {
|
||||
if (this.isSegmentedPlayback) {
|
||||
// 确保仍然在分段播放模式
|
||||
this.loadSegmentAudio(i, serviceType, settings)
|
||||
}
|
||||
}, i * 100) // 每100毫秒加载一个段落
|
||||
}
|
||||
|
||||
// 不自动开始播放,等待用户点击
|
||||
console.log('分段播放模式已准备就绪,等待用户点击')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('TTS分段播放失败:', error)
|
||||
this.showErrorMessage(error?.message || i18n.t('settings.tts.error.synthesis_failed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载段落音频
|
||||
* @param index 段落索引
|
||||
* @param serviceType TTS服务类型
|
||||
* @param settings 设置
|
||||
*/
|
||||
private async loadSegmentAudio(index: number, serviceType: string, settings: any): Promise<void> {
|
||||
if (index < 0 || index >= this.audioSegments.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const segment = this.audioSegments[index]
|
||||
|
||||
// 如果已加载或正在加载,则跳过
|
||||
if (segment.isLoaded || segment.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
// 标记为正在加载
|
||||
segment.isLoading = true
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
try {
|
||||
// 创建TTS服务
|
||||
const ttsService = TTSServiceFactory.createService(serviceType, settings)
|
||||
|
||||
// 合成音频
|
||||
const audioBlob = await ttsService.synthesize(segment.text)
|
||||
|
||||
// 创建音频URL
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
|
||||
// 更新段落信息
|
||||
segment.audioBlob = audioBlob
|
||||
segment.audioUrl = audioUrl
|
||||
segment.isLoaded = true
|
||||
segment.isLoading = false
|
||||
|
||||
// 触发事件
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
// 如果是当前播放的段落,且尚未开始播放,则开始播放
|
||||
if (index === this.currentSegmentIndex && this.isSegmentedPlayback && !this.isPlaying) {
|
||||
this.playSegment(index)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载段落音频失败 (索引: ${index}):`, error)
|
||||
segment.isLoading = false
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定段落
|
||||
* @param index 段落索引
|
||||
* @returns 是否成功开始播放
|
||||
*/
|
||||
private playSegment(index: number): boolean {
|
||||
if (index < 0 || index >= this.audioSegments.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const segment = this.audioSegments[index]
|
||||
|
||||
// 如果段落尚未加载完成,则等待加载
|
||||
if (!segment.isLoaded) {
|
||||
// 如果尚未开始加载,则开始加载
|
||||
if (!segment.isLoading) {
|
||||
const settings = store.getState().settings
|
||||
const serviceType = settings.ttsServiceType || 'openai'
|
||||
this.loadSegmentAudio(index, serviceType, settings)
|
||||
}
|
||||
return true // 返回true表示已开始处理,但尚未实际播放
|
||||
}
|
||||
|
||||
// 更新当前段落索引
|
||||
this.currentSegmentIndex = index
|
||||
|
||||
// 触发事件
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
// 播放音频
|
||||
if (this.audioElement && segment.audioUrl) {
|
||||
this.audioElement.src = segment.audioUrl
|
||||
this.audioElement.play().catch((error) => {
|
||||
console.error('播放段落音频失败:', error)
|
||||
})
|
||||
|
||||
// 更新播放状态
|
||||
this.updatePlayingState(true)
|
||||
|
||||
// 设置音频结束事件
|
||||
this.audioElement.onended = () => {
|
||||
// 播放下一个段落
|
||||
if (index < this.audioSegments.length - 1) {
|
||||
this.playSegment(index + 1)
|
||||
} else {
|
||||
// 所有段落播放完毕
|
||||
this.updatePlayingState(false)
|
||||
|
||||
// 清理资源
|
||||
this.cleanupSegmentedPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理分段播放资源
|
||||
*/
|
||||
private cleanupSegmentedPlayback(): void {
|
||||
// 释放所有音频URL
|
||||
for (const segment of this.audioSegments) {
|
||||
if (segment.audioUrl) {
|
||||
URL.revokeObjectURL(segment.audioUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
this.audioSegments = []
|
||||
this.currentSegmentIndex = 0
|
||||
this.isSegmentedPlayback = false
|
||||
|
||||
// 触发事件
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
console.log('分段播放已完成,资源已清理')
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动进度更新定时器
|
||||
*/
|
||||
private startProgressUpdates(): void {
|
||||
// 先停止现有的定时器
|
||||
this.stopProgressUpdates()
|
||||
|
||||
// 确保音频元素存在
|
||||
if (!this.audioElement) return
|
||||
|
||||
// 创建新的定时器,每100毫秒更新一次进度
|
||||
this.progressUpdateInterval = setInterval(() => {
|
||||
if (this.audioElement && this.isPlaying) {
|
||||
const currentTime = this.audioElement.currentTime
|
||||
const duration = this.audioElement.duration || 0
|
||||
|
||||
// 计算进度百分比
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
// 触发进度更新事件
|
||||
this.emitProgressUpdateEvent(currentTime, duration, progress)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止进度更新定时器
|
||||
*/
|
||||
private stopProgressUpdates(): void {
|
||||
if (this.progressUpdateInterval) {
|
||||
clearInterval(this.progressUpdateInterval)
|
||||
this.progressUpdateInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发进度更新事件
|
||||
* @param currentTime 当前播放时间(秒)
|
||||
* @param duration 总时长(秒)
|
||||
* @param progress 进度百分比(0-100)
|
||||
*/
|
||||
private emitProgressUpdateEvent(currentTime: number, duration: number, progress: number): void {
|
||||
// 创建事件数据
|
||||
const eventData = {
|
||||
messageId: this.playingMessageId,
|
||||
isPlaying: this.isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
progress
|
||||
}
|
||||
|
||||
console.log('发送TTS进度更新事件:', {
|
||||
messageId: this.playingMessageId,
|
||||
progress: Math.round(progress),
|
||||
currentTime: Math.round(currentTime),
|
||||
duration: Math.round(duration)
|
||||
})
|
||||
|
||||
// 触发事件
|
||||
window.dispatchEvent(new CustomEvent('tts-progress-update', { detail: eventData }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发分段播放事件
|
||||
*/
|
||||
private emitSegmentedPlaybackEvent(): void {
|
||||
// 创建事件数据
|
||||
const eventData = {
|
||||
isSegmentedPlayback: this.isSegmentedPlayback,
|
||||
segments: this.audioSegments.map((segment) => ({
|
||||
text: segment.text,
|
||||
isLoaded: segment.isLoaded,
|
||||
isLoading: segment.isLoading
|
||||
})),
|
||||
currentSegmentIndex: this.currentSegmentIndex,
|
||||
isPlaying: this.isPlaying
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
window.dispatchEvent(new CustomEvent('tts-segmented-playback-update', { detail: eventData }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误消息,并进行节流控制
|
||||
* @param message 错误消息
|
||||
|
||||
85
src/renderer/src/services/tts/TextSegmenter.ts
Normal file
85
src/renderer/src/services/tts/TextSegmenter.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 文本分段工具类
|
||||
* 用于将文本分割成句子或段落
|
||||
*/
|
||||
export class TextSegmenter {
|
||||
/**
|
||||
* 将文本分割成句子
|
||||
* @param text 要分割的文本
|
||||
* @returns 句子数组
|
||||
*/
|
||||
public static splitIntoSentences(text: string): string[] {
|
||||
if (!text || text.trim() === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
// 以句子级别的标点符号为主要分隔点,保证流畅性
|
||||
// 句号、问号、感叹号、分号作为主要分隔点
|
||||
const punctuationRegex = /([.;:?!。;:?!]+)/g
|
||||
|
||||
// 分割文本
|
||||
const parts = text.split(punctuationRegex)
|
||||
const segments: string[] = []
|
||||
|
||||
// 将标点符号与前面的文本组合
|
||||
for (let i = 0; i < parts.length - 1; i += 2) {
|
||||
const content = parts[i]
|
||||
const punctuation = parts[i + 1] || ''
|
||||
if (content.trim() || punctuation.trim()) {
|
||||
segments.push((content.trim() + punctuation.trim()).trim())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一个部分(如果有)
|
||||
if (parts.length % 2 !== 0 && parts[parts.length - 1].trim()) {
|
||||
segments.push(parts[parts.length - 1].trim())
|
||||
}
|
||||
|
||||
// 进一步处理空格和换行符
|
||||
const result = segments
|
||||
.filter((segment) => segment.trim().length > 0) // 过滤空的片段
|
||||
.flatMap((segment) => {
|
||||
// 如果片段过长,按换行符进一步分割
|
||||
if (segment.length > 100) {
|
||||
const subParts = segment.split(/([\n\r]+)/)
|
||||
const subSegments: string[] = []
|
||||
|
||||
for (let i = 0; i < subParts.length; i++) {
|
||||
const part = subParts[i].trim()
|
||||
if (part) {
|
||||
subSegments.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return subSegments.length > 0 ? subSegments : [segment]
|
||||
}
|
||||
|
||||
return [segment]
|
||||
})
|
||||
|
||||
// 合并过短的片段,保证流畅性
|
||||
const mergedResult: string[] = []
|
||||
let currentSegment = ''
|
||||
|
||||
for (const segment of result) {
|
||||
// 如果当前片段加上新片段仍然不超过100个字符,则合并
|
||||
if (currentSegment && currentSegment.length + segment.length < 100) {
|
||||
currentSegment += ' ' + segment
|
||||
} else {
|
||||
// 如果当前片段非空,则添加到结果中
|
||||
if (currentSegment) {
|
||||
mergedResult.push(currentSegment)
|
||||
}
|
||||
currentSegment = segment
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个片段
|
||||
if (currentSegment) {
|
||||
mergedResult.push(currentSegment)
|
||||
}
|
||||
|
||||
// 如果没有成功分割,则返回原文本作为一个句子
|
||||
return mergedResult.length > 0 ? mergedResult : [text]
|
||||
}
|
||||
}
|
||||
@ -145,6 +145,7 @@ export interface SettingsState {
|
||||
asrApiKey: string
|
||||
asrApiUrl: string
|
||||
asrModel: string
|
||||
asrAutoStartServer: boolean // 启动应用时自动启动ASR服务器
|
||||
// 语音通话配置
|
||||
voiceCallEnabled: boolean
|
||||
voiceCallModel: Model | null
|
||||
@ -286,6 +287,7 @@ export const initialState: SettingsState = {
|
||||
asrApiKey: '',
|
||||
asrApiUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
||||
asrModel: 'whisper-1',
|
||||
asrAutoStartServer: false, // 默认不自动启动ASR服务器
|
||||
// 语音通话配置
|
||||
voiceCallEnabled: true,
|
||||
voiceCallModel: null,
|
||||
@ -714,6 +716,9 @@ const settingsSlice = createSlice({
|
||||
setAsrModel: (state, action: PayloadAction<string>) => {
|
||||
state.asrModel = action.payload
|
||||
},
|
||||
setAsrAutoStartServer: (state, action: PayloadAction<boolean>) => {
|
||||
state.asrAutoStartServer = action.payload
|
||||
},
|
||||
setVoiceCallEnabled: (state, action: PayloadAction<boolean>) => {
|
||||
state.voiceCallEnabled = action.payload
|
||||
},
|
||||
@ -851,6 +856,7 @@ export const {
|
||||
setAsrApiKey,
|
||||
setAsrApiUrl,
|
||||
setAsrModel,
|
||||
setAsrAutoStartServer,
|
||||
setVoiceCallEnabled,
|
||||
setVoiceCallModel,
|
||||
setIsVoiceCallActive,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user