| import { useEffect, useRef } from 'react' |
| import { playAudioFile, edgeTts } from '../services/voice/edgeTTS.js' |
| import { getInitialSettings } from '../utils/settings/settings.js' |
| import type { RenderableMessage } from '../types/message.js' |
|
|
| |
| const CHINESE_VOICES: Record<string, string> = { |
| zh: 'zh-CN-XiaoxiaoNeural', |
| 'zh-CN': 'zh-CN-XiaoxiaoNeural', |
| 'zh-TW': 'zh-TW-HsiaoChenNeural', |
| 'zh-HK': 'zh-HK-HiuMaanNeural', |
| } |
|
|
| function resolveEdgeTTSVoice(language: string | undefined, explicitVoice: string | undefined): string { |
| if (explicitVoice) return explicitVoice |
| if (!language) return 'en-US-JennyNeural' |
| const base = language.split('-')[0].toLowerCase() |
| if (base === 'zh') return CHINESE_VOICES[language] || CHINESE_VOICES['zh-CN'] |
| if (base === 'ja') return 'ja-JP-NanamiNeural' |
| if (base === 'ko') return 'ko-KR-SunHiNeural' |
| if (base === 'fr') return 'fr-FR-DeniseNeural' |
| if (base === 'de') return 'de-DE-KatjaNeural' |
| if (base === 'es') return 'es-ES-ElviraNeural' |
| return 'en-US-JennyNeural' |
| } |
|
|
| export function useAutoTTS(messages: RenderableMessage[], isLoading?: boolean): void { |
| const triggeredIdsRef = useRef<Set<string>>(new Set()) |
| const pendingPlayRef = useRef<Promise<void> | null>(null) |
|
|
| |
| |
| |
| |
| const historyIdsRef = useRef<Set<string> | null>(null) |
| const wasLoadingRef = useRef<boolean>(false) |
|
|
| useEffect(() => { |
| const settings = getInitialSettings() |
| if (!settings.voiceAutoTTS || !settings.voiceEnabled) return |
|
|
| |
| |
| |
| |
| if (historyIdsRef.current && messages.length > 0) { |
| const isSessionSwitch = messages.every( |
| m => !historyIdsRef.current!.has(m.uuid), |
| ) |
| if (isSessionSwitch) { |
| historyIdsRef.current = null |
| wasLoadingRef.current = false |
| } |
| } |
|
|
| |
| |
| if (!historyIdsRef.current && !wasLoadingRef.current) { |
| if (isLoading || messages.length > 0) { |
| |
| |
| |
| |
| |
| |
| |
| |
| historyIdsRef.current = new Set(messages.map(m => m.uuid)) |
| } |
| } |
| wasLoadingRef.current = isLoading ?? false |
|
|
| const voice = resolveEdgeTTSVoice( |
| settings.voiceLanguage || settings.language, |
| settings.voiceTTSVoice, |
| ) |
|
|
| for (const msg of messages) { |
| if (triggeredIdsRef.current.has(msg.uuid)) continue |
| |
| if (historyIdsRef.current?.has(msg.uuid)) continue |
|
|
| triggeredIdsRef.current.add(msg.uuid) |
|
|
| if (msg.type !== 'assistant') continue |
| |
| |
| const textBlock = Array.isArray(msg.message.content) |
| ? msg.message.content.find((b: any) => b?.type === 'text') |
| : null |
| if (!textBlock?.text?.trim()) continue |
|
|
| |
| const text = textBlock.text |
| .replace(/https?:\/\/\S+/g, '') |
| .replace(/```[\s\S]*?```/g, '') |
| .replace(/(?<=^|[^*])\*{1,2}(?!\*)(.*?)\*{1,2}(?=[^*]|$)/g, '$1') |
| .replace(/(?<=^|[^_])\_{1,2}(?!_)(.*?)\_{1,2}(?=[^_]|$)/g, '$1') |
| .replace(/`([^`]+)`/g, '$1') |
| .replace(/~~(.*?)~~/g, '$1') |
| .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') |
| .replace(/^#{1,6}\s+/gm, '') |
| .replace(/^[>\|]\s*/gm, '') |
| .replace(/(?:^|\n)[-*+]\s+/g, ' ') |
| .replace(/\n{3,}/g, '\n\n') |
| .trim() |
|
|
| if (!text) continue |
|
|
| const run = async () => { |
| const result = await edgeTts({ text, voice }) |
| if (result.success && result.audioPath) { |
| await playAudioFile(result.audioPath) |
| } |
| } |
|
|
| if (pendingPlayRef.current) { |
| pendingPlayRef.current = pendingPlayRef.current.then(run, run).catch(() => {}) |
| } else { |
| pendingPlayRef.current = run().catch(() => {}) |
| } |
| } |
| }, [messages, isLoading]) |
| } |