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:
@@ -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 // 服务端错误
|
||||
)
|
||||
|
||||
// flags(byte1 低 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 为 JSON(gzip)。
|
||||
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 为 JSON(gzip)。
|
||||
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 PCM;JSON 事件=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]
|
||||
}
|
||||
Reference in New Issue
Block a user