b6927247da
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)
管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
3.7 KiB
Go
130 lines
3.7 KiB
Go
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()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|