File size: 5,544 Bytes
0524ab0 346a09e 0524ab0 de6d9e9 38c9372 de6d9e9 bfdb3c0 0524ab0 3596123 0524ab0 bfdb3c0 0524ab0 9004edd bfdb3c0 0524ab0 3596123 bfdb3c0 0524ab0 bfdb3c0 5912f74 0524ab0 25861df 5912f74 25861df 0524ab0 3596123 52c09f9 0524ab0 3596123 0524ab0 bfdb3c0 0524ab0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | 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'
// Map language codes to Edge TTS Chinese voices
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)
// Snapshot of message IDs present when the conversation first becomes "active".
// isLoading goes true on the first user submit and stays false on resume.
// By waiting for isLoading=true we ensure the snapshot captures the correct
// boundary: everything before it is history (skip), everything after is new.
const historyIdsRef = useRef<Set<string> | null>(null)
const wasLoadingRef = useRef<boolean>(false)
useEffect(() => {
const settings = getInitialSettings()
if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
// Detect session switch (mid-session /resume): if we already took a
// history snapshot for a previous session but the incoming messages are
// entirely different (no overlapping UUIDs), reset and re-snapshot so the
// resumed session's history isn't re-spoken.
if (historyIdsRef.current && messages.length > 0) {
const isSessionSwitch = messages.every(
m => !historyIdsRef.current!.has(m.uuid),
)
if (isSessionSwitch) {
historyIdsRef.current = null
wasLoadingRef.current = false
}
}
// Capture history snapshot on the transition from idle → active, or on
// first render with pre-loaded messages (resume path).
if (!historyIdsRef.current && !wasLoadingRef.current) {
if (isLoading || messages.length > 0) {
// Normal path (isLoading=true): user just submitted their first
// message in a fresh session. Snapshot current messages so pre-loaded
// tool-call results / system messages are excluded from TTS.
//
// Resume path (messages.length>0, isLoading=false): messages were
// pre-loaded without a loading transition (e.g. /resume,
// --resume-session). Treat all displayed messages as history so they
// are not re-spoken. Only subsequent real-time replies will be spoken.
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
// Skip messages that were present before the conversation became active.
if (historyIdsRef.current?.has(msg.uuid)) continue
triggeredIdsRef.current.add(msg.uuid)
if (msg.type !== 'assistant') continue
// Search all content blocks for text — tool calls (e.g. WebSearch)
// may appear as the first block with text following
const textBlock = Array.isArray(msg.message.content)
? msg.message.content.find((b: any) => b?.type === 'text')
: null
if (!textBlock?.text?.trim()) continue
// Strip formatting before TTS: URLs, code blocks, markdown syntax
const text = textBlock.text
.replace(/https?:\/\/\S+/g, '') // URLs
.replace(/```[\s\S]*?```/g, '') // code blocks
.replace(/(?<=^|[^*])\*{1,2}(?!\*)(.*?)\*{1,2}(?=[^*]|$)/g, '$1') // *italic* **bold**
.replace(/(?<=^|[^_])\_{1,2}(?!_)(.*?)\_{1,2}(?=[^_]|$)/g, '$1') // _italic_ __bold__
.replace(/`([^`]+)`/g, '$1') // inline `code`
.replace(/~~(.*?)~~/g, '$1') // ~~strikethrough~~
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url)
.replace(/^#{1,6}\s+/gm, '') // # headings
.replace(/^[>\|]\s*/gm, '') // > blockquotes, | tables
.replace(/(?:^|\n)[-*+]\s+/g, ' ') // list markers
.replace(/\n{3,}/g, '\n\n') // collapse excess newlines
.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])
} |