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
@@ -0,0 +1,201 @@
package voice
import (
"bytes"
"compress/gzip"
"encoding/binary"
"fmt"
"io"
)
// 火山「双向流式 TTS V3」二进制帧编解码。与 ASR(frame.go)是**不同族**协议:
// 这一族带「事件号 + 会话 ID + gzip payload」,是全双工事件协议(StartConnection/StartSession/
// TaskRequest/FinishSession…)。字节级布局从官方参考实现核实(generate_header / parse_response):
//
// byte0 = (version<<4)|headerSize version=1, headerSize=1(=4字节)
// byte1 = (msgType<<4)|flags flags 含 ttsFlagWithEvent 表示带事件号
// byte2 = (serialization<<4)|compression 请求一律 JSON+GZIP;音频响应为 raw
// byte3 = 0x00 保留
// [event 4B 大端] flags 带 withEvent 时存在
// [会话级事件]sessionIdLen 4B 大端 + sessionId
// payloadLen 4B 大端 + payload(gzip)
//
// 连接级事件(StartConnection=1 / FinishConnection=2)无会话 ID;会话级(100/200/102…)带会话 ID。
// 消息类型(byte1 高 4bit)。
const (
ttsClientFull byte = 0b0001 // 客户端完整请求(JSON
ttsClientAudio byte = 0b0010 // 客户端纯音频(TTS 上行不用;保留)
ttsServerFull byte = 0b1001 // 服务端完整响应
ttsServerAck byte = 0b1011 // 服务端 ACK(音频帧走这个)
ttsServerErr byte = 0b1111 // 服务端错误
)
// flagsbyte1 低 4bit)。
const (
ttsFlagNone byte = 0b0000
ttsFlagNegSeq byte = 0b0010 // 带序列号
ttsFlagWithEvent byte = 0b0100 // 带事件号
)
// 序列化 / 压缩(byte2)。
const (
ttsSerialNone byte = 0b0000
ttsSerialJSON byte = 0b0001
ttsCompNone byte = 0b0000
ttsCompGzip byte = 0b0001
)
// 事件号(客户端发 / 服务端回)。
const (
evStartConnection int32 = 1
evFinishConnection int32 = 2
evConnectionStarted int32 = 50
evConnectionFailed int32 = 51
evConnectionFinished int32 = 52
evStartSession int32 = 100
evFinishSession int32 = 102
evSessionStarted int32 = 150
evSessionFinished int32 = 152
evSessionFailed int32 = 153
evTaskRequest int32 = 200 // 客户端发文本(TTS 逐句推)
evTTSSentenceStart int32 = 350
evTTSSentenceEnd int32 = 351
evTTSResponse int32 = 352 // 服务端回音频帧
)
func gzipBytes(b []byte) []byte {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
_, _ = w.Write(b)
_ = w.Close()
return buf.Bytes()
}
func gunzip(b []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
func ttsHeader(msgType, flags, serial, comp byte) []byte {
return []byte{
(0b0001 << 4) | 0b0001, // version=1, headerSize=1
(msgType << 4) | (flags & 0x0F),
(serial << 4) | comp,
0x00,
}
}
// connEventFrame 组连接级事件帧(无会话 ID),payload 为 JSONgzip)。
func connEventFrame(event int32, jsonPayload []byte) []byte {
var b bytes.Buffer
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
_ = binary.Write(&b, binary.BigEndian, event)
pl := gzipBytes(jsonPayload)
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
b.Write(pl)
return b.Bytes()
}
// sessionEventFrame 组会话级事件帧(带会话 ID),payload 为 JSONgzip)。
func sessionEventFrame(event int32, sessionID string, jsonPayload []byte) []byte {
var b bytes.Buffer
b.Write(ttsHeader(ttsClientFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip))
_ = binary.Write(&b, binary.BigEndian, event)
_ = binary.Write(&b, binary.BigEndian, uint32(len(sessionID)))
b.WriteString(sessionID)
pl := gzipBytes(jsonPayload)
_ = binary.Write(&b, binary.BigEndian, uint32(len(pl)))
b.Write(pl)
return b.Bytes()
}
// ttsResponse 是解析后的服务端帧。
type ttsResponse struct {
MsgType byte
Event int32
Payload []byte // 音频帧=raw PCMJSON 事件=JSON 原文;错误=错误 JSON
Code uint32 // 错误码(MsgType==ttsServerErr 时)
IsAudio bool
}
// parseTTSFrame 解析服务端帧(对齐参考实现 parse_response)。
func parseTTSFrame(data []byte) (*ttsResponse, error) {
if len(data) < 4 {
return nil, fmt.Errorf("帧过短 %d", len(data))
}
headerSize := int(data[0] & 0x0F)
msgType := data[1] >> 4
flags := data[1] & 0x0F
comp := data[2] & 0x0F
off := headerSize * 4
if off > len(data) {
return nil, fmt.Errorf("头长越界")
}
res := &ttsResponse{MsgType: msgType}
if msgType == ttsServerErr {
if len(data) < off+8 {
return nil, fmt.Errorf("错误帧过短")
}
res.Code = binary.BigEndian.Uint32(data[off : off+4])
sz := binary.BigEndian.Uint32(data[off+4 : off+8])
res.Payload = clampTail(data, off+8, sz)
return res, nil
}
p := off
if flags&ttsFlagNegSeq != 0 { // 跳过序列号
p += 4
}
if flags&ttsFlagWithEvent != 0 {
if len(data) < p+4 {
return nil, fmt.Errorf("事件号越界")
}
res.Event = int32(binary.BigEndian.Uint32(data[p : p+4]))
p += 4
}
// 会话 ID(有符号长度;服务端正常响应都带)。
if len(data) >= p+4 {
sidLen := int32(binary.BigEndian.Uint32(data[p : p+4]))
p += 4
if sidLen > 0 && len(data) >= p+int(sidLen) {
p += int(sidLen)
}
}
if len(data) < p+4 {
return res, nil // 无 payload 的控制帧(如 SessionFinished 可能无体)
}
sz := binary.BigEndian.Uint32(data[p : p+4])
p += 4
payload := clampTail(data, p, sz)
// 音频响应(TTSResponse/SERVER_ACK 且非 JSON)→ raw PCM,按压缩位解压。
res.IsAudio = res.Event == evTTSResponse || msgType == ttsServerAck
if comp == ttsCompGzip && len(payload) > 0 {
if dec, err := gunzip(payload); err == nil {
payload = dec
}
}
res.Payload = payload
return res, nil
}
// clampTail 从 off 起最多取 sz 字节(防伪造长度越界)。
func clampTail(data []byte, off int, sz uint32) []byte {
if off > len(data) {
return nil
}
end := off + int(sz)
if end > len(data) || sz == 0 {
end = len(data)
}
return data[off:end]
}