feat(voice): 火山双向流 TTS 客户端 + 下行接线(Phase 1 嘴巴)
回答 token 流 → 攒句器 → 火山双向 TTS → 音频帧回推客户端,端到端连续朗读打通。 - tts_frame.go: V3 事件族帧编解码(事件号+会话ID+gzip),与 ASR 简帧不同族; 从官方参考实现核实 generate_header/parse_response 字节布局;3 解析单测 - tts.go: seed-tts-2.0 双向流客户端 StartTTS(握手ConnectionStarted/SessionStarted) /Speak(逐句TaskRequest)/Finish/Audio()/Close,PCM 24k;新版 API Key 鉴权 - voice_tts.go: speak() 先订阅token流再建TTS(core NATS无持久,握手期攒句入pending 就绪补吐,不丢开头);音频泵首帧ServerSpeaking、收尾ServerTTSEnd;打断stopTTS - voice.go: barge_in→stopTTS;会话结束连带停TTS;final转写→go speak(taskID) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ func (h *Handler) VoiceStream(c *gin.Context) {
|
||||
sess.send(voice.ServerMsg{Type: voice.ServerReady})
|
||||
sess.run()
|
||||
sess.stopASR() // 连接结束,收掉在跑的识别会话
|
||||
sess.stopTTS() // 连带停掉在朗读的下行 TTS
|
||||
}
|
||||
|
||||
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
|
||||
@@ -72,6 +73,10 @@ type voiceSession struct {
|
||||
asr *voice.ASRSession
|
||||
asrCancel context.CancelFunc
|
||||
|
||||
ttsMu sync.Mutex // 护住当前下行 TTS 会话指针(打断/收尾从别的 goroutine 访问)
|
||||
tts *voice.TTSSession
|
||||
ttsCancel context.CancelFunc
|
||||
|
||||
pendingGraph string // 客户端 start 时带的画布编排图(语音触发既有编排),空则按转写现组
|
||||
lastFinal string // 最近一次已提交的最终转写,去重连发的重复 final
|
||||
}
|
||||
@@ -146,7 +151,7 @@ func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
|
||||
_ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true→提交任务)
|
||||
}
|
||||
case voice.ClientBargeIn:
|
||||
// 打断:停当前 TTS 播放(TTS 接入后处理)。
|
||||
s.stopTTS() // 打断:用户又开口,立刻掐掉正在朗读的 TTS
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -193,7 +198,8 @@ func (s *voiceSession) onFinalTranscript(text string) {
|
||||
}
|
||||
s.pendingGraph = "" // 画布图一次性消费,避免后续转写重复触发同图
|
||||
s.send(voice.ServerMsg{Type: voice.ServerTask, TaskID: taskID})
|
||||
// 下行(订阅 token 流→攒句→TTS→音频帧)在 TTS 步接上;此处先只回 task_id 供客户端切运行视图。
|
||||
// 下行:订阅该任务 token 流 → 攒句 → TTS → 音频帧回推。独立 goroutine 跑,不堵 ASR 结果流。
|
||||
go s.speak(taskID)
|
||||
}
|
||||
|
||||
// stopASR 收掉当前识别会话(幂等)。
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user