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:
Blizzard
2026-07-22 09:25:49 +08:00
parent 302e1ebaff
commit 6fe0a58f1b
5 changed files with 634 additions and 2 deletions
+8 -2
View File
@@ -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 收掉当前识别会话(幂等)。