mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 22:10:21 +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 { registerIpc } from './ipc'
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import mcpService from './services/MCPService'
|
import mcpService from './services/MCPService'
|
||||||
import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler'
|
|
||||||
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
|
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
|
||||||
import { registerShortcuts } from './services/ShortcutService'
|
import { registerShortcuts } from './services/ShortcutService'
|
||||||
import { TrayService } from './services/TrayService'
|
import { TrayService } from './services/TrayService'
|
||||||
@ -49,8 +48,8 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
|
|
||||||
registerIpc(mainWindow, app)
|
registerIpc(mainWindow, app)
|
||||||
|
|
||||||
// 注册MsTTS IPC处理程序
|
// 注意: MsTTS IPC处理程序已在ipc.ts中注册
|
||||||
registerMsTTSIpcHandlers()
|
// 不需要再次调用registerMsTTSIpcHandlers()
|
||||||
|
|
||||||
replaceDevtoolsFont(mainWindow)
|
replaceDevtoolsFont(mainWindow)
|
||||||
|
|
||||||
|
|||||||
@ -210,6 +210,7 @@ const StyledButton = styled(Button)`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
border: none; /* 移除边框 */
|
||||||
&.anticon,
|
&.anticon,
|
||||||
&.iconfont {
|
&.iconfont {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import {
|
|||||||
AudioMutedOutlined,
|
AudioMutedOutlined,
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
|
DownOutlined,
|
||||||
DragOutlined,
|
DragOutlined,
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
SoundOutlined
|
SoundOutlined,
|
||||||
|
UpOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Button, Space, Tooltip } from 'antd'
|
import { Button, Space, Tooltip } from 'antd'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@ -178,6 +180,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
|||||||
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
|
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
|
||||||
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
|
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
|
||||||
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
|
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
// --- 快捷键相关状态结束 ---
|
// --- 快捷键相关状态结束 ---
|
||||||
|
|
||||||
const isInitializedRef = useRef(false)
|
const isInitializedRef = useRef(false)
|
||||||
@ -518,6 +521,12 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
|||||||
<Header onMouseDown={handleDragStart}>
|
<Header onMouseDown={handleDragStart}>
|
||||||
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
|
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
|
||||||
{t('voice_call.title')}
|
{t('voice_call.title')}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="settings-button"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
@ -530,42 +539,46 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
|||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Content>
|
<Content>
|
||||||
{isSettingsVisible && (
|
{!isCollapsed && (
|
||||||
<SettingsPanel>
|
<>
|
||||||
{' '}
|
{isSettingsVisible && (
|
||||||
{/* 使用 styled-component */}
|
<SettingsPanel>
|
||||||
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
|
|
||||||
<Space>
|
|
||||||
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
|
|
||||||
{' '}
|
{' '}
|
||||||
{/* 使用 styled-component */}
|
{/* 使用 styled-component */}
|
||||||
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
|
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
|
||||||
</ShortcutKeyButton>
|
<Space>
|
||||||
<Button type="primary" onClick={saveShortcutKey}>
|
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
|
||||||
{t('voice_call.save')}
|
{' '}
|
||||||
</Button>
|
{/* 使用 styled-component */}
|
||||||
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
|
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
|
||||||
</Space>
|
</ShortcutKeyButton>
|
||||||
<SettingsTip>
|
<Button type="primary" onClick={saveShortcutKey}>
|
||||||
{' '}
|
{t('voice_call.save')}
|
||||||
{/* 使用 styled-component */}
|
</Button>
|
||||||
{t('voice_call.shortcut_key_tip')}
|
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
|
||||||
</SettingsTip>
|
</Space>
|
||||||
</SettingsPanel>
|
<SettingsTip>
|
||||||
)}
|
{' '}
|
||||||
<VisualizerContainer>
|
{/* 使用 styled-component */}
|
||||||
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
|
{t('voice_call.shortcut_key_tip')}
|
||||||
<VoiceVisualizer isActive={isSpeaking} type="output" />
|
</SettingsTip>
|
||||||
</VisualizerContainer>
|
</SettingsPanel>
|
||||||
|
)}
|
||||||
|
<VisualizerContainer>
|
||||||
|
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
|
||||||
|
<VoiceVisualizer isActive={isSpeaking} type="output" />
|
||||||
|
</VisualizerContainer>
|
||||||
|
|
||||||
<TranscriptContainer>
|
<TranscriptContainer>
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<TranscriptText>
|
<TranscriptText>
|
||||||
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
|
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
|
||||||
</TranscriptText>
|
</TranscriptText>
|
||||||
)}
|
)}
|
||||||
{/* 可以在这里添加 AI 回复的显示 */}
|
{/* 可以在这里添加 AI 回复的显示 */}
|
||||||
</TranscriptContainer>
|
</TranscriptContainer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ControlsContainer>
|
<ControlsContainer>
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@ -1,18 +1,37 @@
|
|||||||
import { SoundOutlined } from '@ant-design/icons'
|
import { SoundOutlined } from '@ant-design/icons'
|
||||||
import TTSService from '@renderer/services/TTSService'
|
import TTSService from '@renderer/services/TTSService'
|
||||||
import { Message } from '@renderer/types'
|
import { Message } from '@renderer/types'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface TTSButtonProps {
|
interface TTSButtonProps {
|
||||||
message: Message
|
message: Message
|
||||||
className?: string
|
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 TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||||
|
// 分段播放状态
|
||||||
|
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
|
||||||
|
isSegmentedPlayback: false,
|
||||||
|
segments: [],
|
||||||
|
currentSegmentIndex: 0,
|
||||||
|
isPlaying: false
|
||||||
|
})
|
||||||
|
|
||||||
// 添加TTS状态变化事件监听器
|
// 添加TTS状态变化事件监听器
|
||||||
useEffect(() => {
|
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状态
|
// 初始化时检查TTS状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检查当前是否正在播放
|
// 检查当前是否正在播放
|
||||||
@ -38,7 +73,7 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
|||||||
if (isCurrentlyPlaying !== isSpeaking) {
|
if (isCurrentlyPlaying !== isSpeaking) {
|
||||||
setIsSpeaking(isCurrentlyPlaying)
|
setIsSpeaking(isCurrentlyPlaying)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isSpeaking])
|
||||||
|
|
||||||
const handleTTS = useCallback(async () => {
|
const handleTTS = useCallback(async () => {
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
@ -57,16 +92,51 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
|||||||
}
|
}
|
||||||
}, [isSpeaking, message])
|
}, [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 (
|
return (
|
||||||
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
|
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
|
||||||
<Button
|
<TTSActionButton className={className} onClick={handleTTS}>
|
||||||
className={className}
|
<SoundOutlined style={{ color: isSpeaking ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||||
icon={<SoundOutlined />}
|
</TTSActionButton>
|
||||||
onClick={handleTTS}
|
|
||||||
type={isSpeaking ? 'primary' : 'default'}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</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
|
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 [isRecording, setIsRecording] = useState(false)
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
const handleClose = () => {
|
// 使用useCallback包裹handleClose函数,避免useEffect依赖项变化
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
VoiceCallService.endCall()
|
VoiceCallService.endCall()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}, [onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startVoiceCall = async () => {
|
const startVoiceCall = async () => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
|
|||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
|
import ASRServerService from '@renderer/services/ASRServerService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||||
@ -19,7 +20,18 @@ import useUpdateHandler from './useUpdateHandler'
|
|||||||
|
|
||||||
export function useAppInit() {
|
export function useAppInit() {
|
||||||
const dispatch = useAppDispatch()
|
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 { minappShow } = useRuntime()
|
||||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||||
@ -108,4 +120,18 @@ export function useAppInit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
enableDataCollection ? initAnalytics() : disableAnalytics()
|
enableDataCollection ? initAnalytics() : disableAnalytics()
|
||||||
}, [enableDataCollection])
|
}, [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",
|
"test": "Test Speech",
|
||||||
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
|
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
|
||||||
"learn_more": "Learn more",
|
"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": {
|
"error": {
|
||||||
"not_enabled": "Text-to-speech feature is not enabled",
|
"not_enabled": "Text-to-speech feature is not enabled",
|
||||||
"no_api_key": "API key is not set",
|
"no_api_key": "API key is not set",
|
||||||
@ -1436,14 +1444,14 @@
|
|||||||
"general": "An error occurred during speech synthesis",
|
"general": "An error occurred during speech synthesis",
|
||||||
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
|
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
|
||||||
},
|
},
|
||||||
"service_type.mstts": "[to be translated]:免费在线 TTS",
|
"service_type.mstts": "Free Online TTS",
|
||||||
"edge_voice.available_count": "[to be translated]:可用语音: {{count}}个",
|
"edge_voice.available_count": "Available voices: {{count}}",
|
||||||
"edge_voice.refreshing": "[to be translated]:正在刷新语音列表...",
|
"edge_voice.refreshing": "Refreshing voice list...",
|
||||||
"edge_voice.refreshed": "[to be translated]:语音列表已刷新",
|
"edge_voice.refreshed": "Voice list refreshed",
|
||||||
"mstts.voice": "[to be translated]:免费在线 TTS音色",
|
"mstts.voice": "Free Online TTS Voice",
|
||||||
"mstts.output_format": "[to be translated]:输出格式",
|
"mstts.output_format": "Output Format",
|
||||||
"mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥,完全免费使用。",
|
"mstts.info": "Free Online TTS service doesn't require an API key, completely free to use.",
|
||||||
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色"
|
"error.no_mstts_voice": "Free Online TTS voice not set"
|
||||||
},
|
},
|
||||||
"asr": {
|
"asr": {
|
||||||
"title": "Speech Recognition",
|
"title": "Speech Recognition",
|
||||||
|
|||||||
@ -1436,6 +1436,14 @@
|
|||||||
"test": "测试语音",
|
"test": "测试语音",
|
||||||
"help": "语音合成功能支持将文本转换为自然语音。",
|
"help": "语音合成功能支持将文本转换为自然语音。",
|
||||||
"learn_more": "了解更多",
|
"learn_more": "了解更多",
|
||||||
|
"play": "播放语音",
|
||||||
|
"stop": "停止播放",
|
||||||
|
"speak": "播放语音",
|
||||||
|
"stop_global": "停止所有语音播放",
|
||||||
|
"stopped": "已停止语音播放",
|
||||||
|
"segmented": "分段",
|
||||||
|
"segmented_play": "分段播放",
|
||||||
|
"segmented_playback": "分段播放",
|
||||||
"error": {
|
"error": {
|
||||||
"not_enabled": "语音合成功能未启用",
|
"not_enabled": "语音合成功能未启用",
|
||||||
"no_api_key": "未设置API密钥",
|
"no_api_key": "未设置API密钥",
|
||||||
@ -1490,6 +1498,8 @@
|
|||||||
"success": "语音识别成功",
|
"success": "语音识别成功",
|
||||||
"completed": "语音识别完成",
|
"completed": "语音识别完成",
|
||||||
"canceled": "已取消录音",
|
"canceled": "已取消录音",
|
||||||
|
"auto_start_server": "启动应用自动开启服务器",
|
||||||
|
"auto_start_server.help": "启用后,应用启动时会自动开启语音识别服务器",
|
||||||
"error": {
|
"error": {
|
||||||
"not_enabled": "语音识别功能未启用",
|
"not_enabled": "语音识别功能未启用",
|
||||||
"no_api_key": "未设置API密钥",
|
"no_api_key": "未设置API密钥",
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import TTSProgressBar from '@renderer/components/TTSProgressBar'
|
||||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useModel } from '@renderer/hooks/useModel'
|
import { useModel } from '@renderer/hooks/useModel'
|
||||||
@ -263,7 +264,7 @@ const MessageItem: FC<Props> = ({
|
|||||||
{contextMenuPosition && (
|
{contextMenuPosition && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
|
||||||
open={true}
|
open={true}
|
||||||
trigger={['contextMenu']}>
|
trigger={['contextMenu']}>
|
||||||
<div />
|
<div />
|
||||||
@ -276,6 +277,11 @@ const MessageItem: FC<Props> = ({
|
|||||||
<MessageErrorBoundary>
|
<MessageErrorBoundary>
|
||||||
<MessageContent message={message} model={model} />
|
<MessageContent message={message} model={model} />
|
||||||
</MessageErrorBoundary>
|
</MessageErrorBoundary>
|
||||||
|
{isAssistantMessage && (
|
||||||
|
<ProgressBarWrapper>
|
||||||
|
<TTSProgressBar messageId={message.id} />
|
||||||
|
</ProgressBarWrapper>
|
||||||
|
)}
|
||||||
{showMenubar && (
|
{showMenubar && (
|
||||||
<MessageFooter
|
<MessageFooter
|
||||||
style={{
|
style={{
|
||||||
@ -310,7 +316,12 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
|
|||||||
: undefined
|
: 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',
|
key: 'copy',
|
||||||
label: t('common.copy'),
|
label: t('common.copy'),
|
||||||
@ -325,6 +336,29 @@ const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: stri
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
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;
|
cursor: pointer;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const ProgressBarWrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 10px;
|
||||||
|
`
|
||||||
|
|
||||||
export default memo(MessageItem)
|
export default memo(MessageItem)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||||
|
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
|
||||||
import { isOpenAIWebSearch } from '@renderer/config/models'
|
import { isOpenAIWebSearch } from '@renderer/config/models'
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import { Message, Model } from '@renderer/types'
|
import { Message, Model } from '@renderer/types'
|
||||||
@ -6,7 +7,7 @@ import { getBriefInfo } from '@renderer/utils'
|
|||||||
import { withMessageThought } from '@renderer/utils/formats'
|
import { withMessageThought } from '@renderer/utils/formats'
|
||||||
import { Divider, Flex } from 'antd'
|
import { Divider, Flex } from 'antd'
|
||||||
import { clone } from 'lodash'
|
import { clone } from 'lodash'
|
||||||
import React, { Fragment, useMemo } from 'react'
|
import React, { Fragment, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import BarLoader from 'react-spinners/BarLoader'
|
import BarLoader from 'react-spinners/BarLoader'
|
||||||
import BeatLoader from 'react-spinners/BeatLoader'
|
import BeatLoader from 'react-spinners/BeatLoader'
|
||||||
@ -29,6 +30,23 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const message = withMessageThought(clone(_message))
|
const message = withMessageThought(clone(_message))
|
||||||
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
|
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实体编码辅助函数
|
// HTML实体编码辅助函数
|
||||||
const encodeHTML = (str: string) => {
|
const encodeHTML = (str: string) => {
|
||||||
@ -205,7 +223,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
<MessageThought message={message} />
|
<MessageThought message={message} />
|
||||||
<MessageTools 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.metadata?.generateImage && <MessageImage message={message} />}
|
||||||
{message.translatedContent && (
|
{message.translatedContent && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
SoundOutlined,
|
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
TranslationOutlined
|
TranslationOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
@ -16,14 +15,13 @@ import { UploadOutlined } from '@ant-design/icons'
|
|||||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
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 { isReasoningModel } from '@renderer/config/models'
|
||||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
|
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
|
||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
import TTSService from '@renderer/services/TTSService'
|
|
||||||
import { RootState } from '@renderer/store'
|
import { RootState } from '@renderer/store'
|
||||||
import type { Message, Model } from '@renderer/types'
|
import type { Message, Model } from '@renderer/types'
|
||||||
import type { Assistant, Topic } from '@renderer/types'
|
import type { Assistant, Topic } from '@renderer/types'
|
||||||
@ -152,7 +150,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
// 解析编辑后的文本,提取图片 URL
|
// 解析编辑后的文本,提取图片 URL
|
||||||
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
|
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
|
||||||
const imageUrls: string[] = []
|
const imageUrls: string[] = []
|
||||||
let match
|
let match: RegExpExecArray | null
|
||||||
let content = editedText
|
let content = editedText
|
||||||
|
|
||||||
while ((match = imageRegex.exec(editedText)) !== null) {
|
while ((match = imageRegex.exec(editedText)) !== null) {
|
||||||
@ -405,18 +403,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{isAssistantMessage && ttsEnabled && (
|
{isAssistantMessage && ttsEnabled && <TTSButton message={message} className="message-action-button" />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{!isUserMessage && (
|
{!isUserMessage && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
|
|||||||
@ -2,7 +2,14 @@ import { GlobalOutlined, InfoCircleOutlined, PlayCircleOutlined, StopOutlined }
|
|||||||
import ASRServerService from '@renderer/services/ASRServerService'
|
import ASRServerService from '@renderer/services/ASRServerService'
|
||||||
import ASRService from '@renderer/services/ASRService'
|
import ASRService from '@renderer/services/ASRService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
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 { Button, Form, Input, Select, Space, Switch } from 'antd'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -22,6 +29,7 @@ const ASRSettings: FC = () => {
|
|||||||
const asrApiKey = useSelector((state: any) => state.settings.asrApiKey)
|
const asrApiKey = useSelector((state: any) => state.settings.asrApiKey)
|
||||||
const asrApiUrl = useSelector((state: any) => state.settings.asrApiUrl)
|
const asrApiUrl = useSelector((state: any) => state.settings.asrApiUrl)
|
||||||
const asrModel = useSelector((state: any) => state.settings.asrModel || 'whisper-1')
|
const asrModel = useSelector((state: any) => state.settings.asrModel || 'whisper-1')
|
||||||
|
const asrAutoStartServer = useSelector((state: any) => state.settings.asrAutoStartServer)
|
||||||
|
|
||||||
// 检查服务器状态
|
// 检查服务器状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -178,6 +186,21 @@ const ASRSettings: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<BrowserTip>{t('settings.asr.local.browser_tip')}</BrowserTip>
|
<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>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -17,9 +17,10 @@ class TTSService {
|
|||||||
/**
|
/**
|
||||||
* 将文本转换为语音并播放
|
* 将文本转换为语音并播放
|
||||||
* @param text 要转换的文本
|
* @param text 要转换的文本
|
||||||
|
* @param segmented 是否使用分段播放
|
||||||
*/
|
*/
|
||||||
speak = async (text: string): Promise<void> => {
|
speak = async (text: string, segmented: boolean = false): Promise<void> => {
|
||||||
await this.service.speak(text)
|
await this.service.speak(text, segmented)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,9 +33,10 @@ class TTSService {
|
|||||||
/**
|
/**
|
||||||
* 从消息中提取文本并转换为语音
|
* 从消息中提取文本并转换为语音
|
||||||
* @param message 消息对象
|
* @param message 消息对象
|
||||||
|
* @param segmented 是否使用分段播放
|
||||||
*/
|
*/
|
||||||
speakFromMessage = async (message: Message): Promise<void> => {
|
speakFromMessage = async (message: Message, segmented: boolean = false): Promise<void> => {
|
||||||
await this.service.speakFromMessage(message)
|
await this.service.speakFromMessage(message, segmented)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,6 +45,22 @@ class TTSService {
|
|||||||
isCurrentlyPlaying = (): boolean => {
|
isCurrentlyPlaying = (): boolean => {
|
||||||
return this.service.isCurrentlyPlaying()
|
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 { setLastPlayedMessageId } from '@renderer/store/settings'
|
||||||
import { Message } from '@renderer/types'
|
import { Message } from '@renderer/types'
|
||||||
|
|
||||||
|
import { TextSegmenter } from './TextSegmenter'
|
||||||
import { TTSServiceFactory } from './TTSServiceFactory'
|
import { TTSServiceFactory } from './TTSServiceFactory'
|
||||||
import { TTSTextFilter } from './TTSTextFilter'
|
import { TTSTextFilter } from './TTSTextFilter'
|
||||||
|
|
||||||
@ -10,11 +11,27 @@ import { TTSTextFilter } from './TTSTextFilter'
|
|||||||
* TTS服务类
|
* TTS服务类
|
||||||
* 用于处理文本到语音的转换
|
* 用于处理文本到语音的转换
|
||||||
*/
|
*/
|
||||||
|
// 音频段落接口
|
||||||
|
interface AudioSegment {
|
||||||
|
text: string // 段落文本
|
||||||
|
audioBlob?: Blob // 对应的音频Blob
|
||||||
|
audioUrl?: string // 音频URL
|
||||||
|
isLoaded: boolean // 是否已加载
|
||||||
|
isLoading: boolean // 是否正在加载
|
||||||
|
}
|
||||||
|
|
||||||
export class TTSService {
|
export class TTSService {
|
||||||
private static instance: TTSService
|
private static instance: TTSService
|
||||||
private audioElement: HTMLAudioElement | null = null
|
private audioElement: HTMLAudioElement | null = null
|
||||||
private isPlaying = false
|
private isPlaying = false
|
||||||
private playingServiceType: string | null = null
|
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
|
private lastErrorTime = 0
|
||||||
@ -76,9 +93,10 @@ export class TTSService {
|
|||||||
/**
|
/**
|
||||||
* 从消息中提取文本并播放
|
* 从消息中提取文本并播放
|
||||||
* @param message 消息对象
|
* @param message 消息对象
|
||||||
|
* @param segmented 是否使用分段播放
|
||||||
* @returns 是否成功播放
|
* @returns 是否成功播放
|
||||||
*/
|
*/
|
||||||
public async speakFromMessage(message: Message): Promise<boolean> {
|
public async speakFromMessage(message: Message, segmented: boolean = false): Promise<boolean> {
|
||||||
// 获取最新的TTS过滤选项
|
// 获取最新的TTS过滤选项
|
||||||
const settings = store.getState().settings
|
const settings = store.getState().settings
|
||||||
const ttsFilterOptions = settings.ttsFilterOptions || {
|
const ttsFilterOptions = settings.ttsFilterOptions || {
|
||||||
@ -94,12 +112,15 @@ export class TTSService {
|
|||||||
dispatch(setLastPlayedMessageId(message.id))
|
dispatch(setLastPlayedMessageId(message.id))
|
||||||
console.log('更新最后播放的消息ID:', message.id)
|
console.log('更新最后播放的消息ID:', message.id)
|
||||||
|
|
||||||
|
// 记录当前正在播放的消息ID
|
||||||
|
this.playingMessageId = message.id
|
||||||
|
|
||||||
// 应用过滤
|
// 应用过滤
|
||||||
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions)
|
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions)
|
||||||
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length)
|
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 } })
|
const event = new CustomEvent('tts-state-change', { detail: { isPlaying } })
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
|
|
||||||
// 如果停止播放,清除服务类型
|
// 如果开始播放,启动进度更新定时器
|
||||||
|
if (isPlaying && this.audioElement) {
|
||||||
|
this.startProgressUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果停止播放,清除服务类型和定时器
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
this.playingServiceType = null
|
this.playingServiceType = null
|
||||||
|
this.stopProgressUpdates()
|
||||||
|
|
||||||
// 确保Web Speech API也停止
|
// 确保Web Speech API也停止
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
@ -131,9 +158,10 @@ export class TTSService {
|
|||||||
/**
|
/**
|
||||||
* 播放文本
|
* 播放文本
|
||||||
* @param text 要播放的文本
|
* @param text 要播放的文本
|
||||||
|
* @param segmented 是否使用分段播放
|
||||||
* @returns 是否成功播放
|
* @returns 是否成功播放
|
||||||
*/
|
*/
|
||||||
public async speak(text: string): Promise<boolean> {
|
public async speak(text: string, segmented: boolean = false): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// 检查TTS是否启用
|
// 检查TTS是否启用
|
||||||
const settings = store.getState().settings
|
const settings = store.getState().settings
|
||||||
@ -164,6 +192,15 @@ export class TTSService {
|
|||||||
console.log('使用的TTS服务类型:', serviceType)
|
console.log('使用的TTS服务类型:', serviceType)
|
||||||
// 记录当前使用的服务类型
|
// 记录当前使用的服务类型
|
||||||
this.playingServiceType = serviceType
|
this.playingServiceType = serviceType
|
||||||
|
|
||||||
|
// 设置分段播放模式
|
||||||
|
this.isSegmentedPlayback = segmented
|
||||||
|
|
||||||
|
if (segmented) {
|
||||||
|
// 分段播放模式
|
||||||
|
return await this.speakSegmented(text, serviceType, latestSettings)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('当前TTS设置详情:', {
|
console.log('当前TTS设置详情:', {
|
||||||
ttsServiceType: serviceType,
|
ttsServiceType: serviceType,
|
||||||
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
|
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
|
||||||
@ -264,8 +301,19 @@ export class TTSService {
|
|||||||
console.log('停止Web Speech API播放')
|
console.log('停止Web Speech API播放')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停止进度更新
|
||||||
|
this.stopProgressUpdates()
|
||||||
|
|
||||||
// 更新状态并触发事件
|
// 更新状态并触发事件
|
||||||
this.updatePlayingState(false)
|
this.updatePlayingState(false)
|
||||||
|
|
||||||
|
// 清除正在播放的消息ID
|
||||||
|
this.playingMessageId = null
|
||||||
|
|
||||||
|
// 发送一个最终的进度更新事件,确保进度条消失
|
||||||
|
this.emitProgressUpdateEvent(0, 0, 0)
|
||||||
|
|
||||||
|
// 如果是分段播放模式,不清理资源,以便用户可以从其他段落继续播放
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -276,6 +324,334 @@ export class TTSService {
|
|||||||
return this.isPlaying
|
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 错误消息
|
* @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
|
asrApiKey: string
|
||||||
asrApiUrl: string
|
asrApiUrl: string
|
||||||
asrModel: string
|
asrModel: string
|
||||||
|
asrAutoStartServer: boolean // 启动应用时自动启动ASR服务器
|
||||||
// 语音通话配置
|
// 语音通话配置
|
||||||
voiceCallEnabled: boolean
|
voiceCallEnabled: boolean
|
||||||
voiceCallModel: Model | null
|
voiceCallModel: Model | null
|
||||||
@ -286,6 +287,7 @@ export const initialState: SettingsState = {
|
|||||||
asrApiKey: '',
|
asrApiKey: '',
|
||||||
asrApiUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
asrApiUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
||||||
asrModel: 'whisper-1',
|
asrModel: 'whisper-1',
|
||||||
|
asrAutoStartServer: false, // 默认不自动启动ASR服务器
|
||||||
// 语音通话配置
|
// 语音通话配置
|
||||||
voiceCallEnabled: true,
|
voiceCallEnabled: true,
|
||||||
voiceCallModel: null,
|
voiceCallModel: null,
|
||||||
@ -714,6 +716,9 @@ const settingsSlice = createSlice({
|
|||||||
setAsrModel: (state, action: PayloadAction<string>) => {
|
setAsrModel: (state, action: PayloadAction<string>) => {
|
||||||
state.asrModel = action.payload
|
state.asrModel = action.payload
|
||||||
},
|
},
|
||||||
|
setAsrAutoStartServer: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.asrAutoStartServer = action.payload
|
||||||
|
},
|
||||||
setVoiceCallEnabled: (state, action: PayloadAction<boolean>) => {
|
setVoiceCallEnabled: (state, action: PayloadAction<boolean>) => {
|
||||||
state.voiceCallEnabled = action.payload
|
state.voiceCallEnabled = action.payload
|
||||||
},
|
},
|
||||||
@ -851,6 +856,7 @@ export const {
|
|||||||
setAsrApiKey,
|
setAsrApiKey,
|
||||||
setAsrApiUrl,
|
setAsrApiUrl,
|
||||||
setAsrModel,
|
setAsrModel,
|
||||||
|
setAsrAutoStartServer,
|
||||||
setVoiceCallEnabled,
|
setVoiceCallEnabled,
|
||||||
setVoiceCallModel,
|
setVoiceCallModel,
|
||||||
setIsVoiceCallActive,
|
setIsVoiceCallActive,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user