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])
}