6fe0a58f1b
回答 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>
202 lines
5.9 KiB
Go
202 lines
5.9 KiB
Go
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]
|
||
}
|