File size: 3,398 Bytes
0524ab0
 
 
 
 
de6d9e9
 
 
 
38c9372
de6d9e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfdb3c0
0524ab0
3596123
0524ab0
bfdb3c0
 
 
 
 
 
 
0524ab0
 
 
 
bfdb3c0
 
 
 
 
 
 
 
 
 
 
 
 
0524ab0
3596123
bfdb3c0
 
0524ab0
bfdb3c0
 
 
0524ab0
 
 
 
 
 
3596123
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
import { useEffect, useRef } from 'react'
import { speakWithEdgeTTS, playAudioFile } 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

    // Capture history snapshot on the transition from idle → active.
    // This fires once: when the user first submits a message in a fresh REPL,
    // or (importantly) never during a resume where isLoading stays false.
    if (!historyIdsRef.current && !wasLoadingRef.current && isLoading) {
      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
      const content = msg.message.content[0]
      if (content?.type !== 'text') continue
      if (!content.text.trim()) continue

      const text = content.text

      const run = async () => {
        const result = await speakWithEdgeTTS(text, {
          voice,
          pythonPath: settings.voiceTTSCommand || undefined,
        })
        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])
}