package handler import ( "context" "log" "github.com/sundynix/sundynix-gateway/internal/voice" ) // 语音下行接线:任务 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端。 // 复用既有 token 流(bus.SubscribeTokens,与 SSE/录像器同一路 fan-out),一行编排不改: // 语音只是给回答"配了个嘴"。 // speak 为一次任务的回答做流式朗读。独立 goroutine 调用(onFinalTranscript 里 go 起)。 // // 关键次序:**先订阅 token 流,再建 TTS 会话**。core NATS 无持久化、订阅晚于产出就丢开头 token, // 而 TTS 握手(StartConnection→StartSession 两个往返)要几百毫秒——这期间攒下的句子先入 pending, // TTS 就绪后补吐,保证第一句不丢。 func (s *voiceSession) speak(taskID string) { if !s.cfg.TTSEnabled() { return // 没配 TTS:只回转写 + 任务,无语音朗读 } sb := voice.NewSentenceBuffer() var pending []string // TTS 未就绪前攒下的句子 ready := false finished := false // push 把一句吐给 TTS;未就绪则先入 pending。全程在 ttsMu 下,与就绪补吐/打断互斥。 push := func(sentence string) { s.ttsMu.Lock() if ready && s.tts != nil { _ = s.tts.Speak(sentence) } else { pending = append(pending, sentence) } s.ttsMu.Unlock() } unsub, err := s.h.bus.SubscribeTokens(taskID, func(tok []byte) { // 打字机:每个 token 一到就转发给客户端显示(早于音频,LLM 首 token 即刻反馈)。 s.send(voice.ServerMsg{Type: voice.ServerReply, Text: string(tok)}) // 同时攒句喂 TTS(成句即合成,音频随后跟上)。 for _, sentence := range sb.Push(string(tok)) { push(sentence) } }, func() { if tail := sb.Flush(); tail != "" { push(tail) } s.ttsMu.Lock() finished = true if ready && s.tts != nil { _ = s.tts.Finish() // 文字推完,等服务端吐完剩余音频 } s.ttsMu.Unlock() }, ) if err != nil { log.Printf("[voice] 订阅 token 流失败 task=%s: %v", taskID, err) return } // 建 TTS 会话(含握手)。失败也要给客户端一个 tts_end,别让它干等。 ctx, cancel := context.WithCancel(context.Background()) ts, err := voice.StartTTS(ctx, s.cfg) if err != nil { cancel() _ = unsub() log.Printf("[voice] 启动 TTS 失败 uid=%s: %v", s.uid, err) s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音合成启动失败"}) s.send(voice.ServerMsg{Type: voice.ServerTTSEnd}) return } // 就绪:登记会话、补吐 pending,若 token 流已结束则立刻收尾。 s.ttsMu.Lock() s.tts = ts s.ttsCancel = cancel ready = true for _, sentence := range pending { _ = ts.Speak(sentence) } pending = nil if finished { _ = ts.Finish() } s.ttsMu.Unlock() // 音频泵:TTS 音频帧 → 客户端。首帧发 speaking,channel 关闭(收尾/打断/出错)后发 tts_end。 first := true for pcm := range ts.Audio() { if first { s.send(voice.ServerMsg{Type: voice.ServerSpeaking}) first = false } s.sendAudio(pcm) } if e := ts.Err(); e != nil { log.Printf("[voice] TTS 出错 uid=%s: %v", s.uid, e) } s.send(voice.ServerMsg{Type: voice.ServerTTSEnd}) _ = unsub() s.ttsMu.Lock() if s.tts == ts { // 未被打断替换才清(打断已置空并 Close) s.tts = nil s.ttsCancel = nil } s.ttsMu.Unlock() ts.Close() cancel() s.flushAnnounces() // 朗读期间排队的主动播报,本轮播完接着念(别抢麦) } // stopTTS 掐掉当前下行 TTS(打断 / 会话结束)。幂等。Close 后 Audio 关闭 → 音频泵自然收尾。 func (s *voiceSession) stopTTS() { s.ttsMu.Lock() ts, cancel := s.tts, s.ttsCancel s.tts, s.ttsCancel = nil, nil s.ttsMu.Unlock() if ts != nil { ts.Close() } if cancel != nil { cancel() } }