Files
sundynix-agentix/sundynix-gateway/internal/voice/tts_frame_test.go
T
Blizzard 6fe0a58f1b 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>
2026-07-22 09:25:49 +08:00

111 lines
3.7 KiB
Go

package voice
import (
"bytes"
"encoding/binary"
"testing"
)
// serverFrame 按参考实现 parse_response 的布局手搓一个服务端帧,供解析测试。
// 布局:header(4) [+event4] + sessionIdLen(4) + sessionId + payloadLen(4) + payload。
func serverFrame(msgType, flags, serial, comp byte, event int32, sid string, payload []byte) []byte {
var b bytes.Buffer
b.WriteByte((0b0001 << 4) | 0b0001)
b.WriteByte((msgType << 4) | flags)
b.WriteByte((serial << 4) | comp)
b.WriteByte(0x00)
if flags&ttsFlagWithEvent != 0 {
_ = binary.Write(&b, binary.BigEndian, event)
}
_ = binary.Write(&b, binary.BigEndian, uint32(len(sid)))
b.WriteString(sid)
_ = binary.Write(&b, binary.BigEndian, uint32(len(payload)))
b.Write(payload)
return b.Bytes()
}
// 音频响应(SERVER_ACK + 事件 352 + raw payload)应被识别为音频、取到原始 PCM。
func TestParseTTSFrame_Audio(t *testing.T) {
pcm := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0xfe}
frame := serverFrame(ttsServerAck, ttsFlagWithEvent, ttsSerialNone, ttsCompNone, evTTSResponse, "sess-1", pcm)
r, err := parseTTSFrame(frame)
if err != nil {
t.Fatalf("解析音频帧失败: %v", err)
}
if !r.IsAudio {
t.Errorf("应识别为音频帧")
}
if r.Event != evTTSResponse {
t.Errorf("event=%d,期望 %d", r.Event, evTTSResponse)
}
if !bytes.Equal(r.Payload, pcm) {
t.Errorf("PCM 不一致:得 %v,期望 %v", r.Payload, pcm)
}
}
// gzip 压缩的 JSON 事件帧(如 SessionStarted)应被正确解压。
func TestParseTTSFrame_GzipJSON(t *testing.T) {
js := []byte(`{"event":"SessionStarted"}`)
frame := serverFrame(ttsServerFull, ttsFlagWithEvent, ttsSerialJSON, ttsCompGzip, evSessionStarted, "sess-1", gzipBytes(js))
r, err := parseTTSFrame(frame)
if err != nil {
t.Fatalf("解析 JSON 帧失败: %v", err)
}
if r.Event != evSessionStarted {
t.Errorf("event=%d,期望 %d", r.Event, evSessionStarted)
}
if !bytes.Equal(r.Payload, js) {
t.Errorf("gzip 解压后 JSON 不一致:得 %s", string(r.Payload))
}
}
// 错误帧(SERVER_ERROR):code(4) + payloadLen(4) + payload,无 event/session。
func TestParseTTSFrame_Error(t *testing.T) {
var b bytes.Buffer
b.WriteByte((0b0001 << 4) | 0b0001)
b.WriteByte((ttsServerErr << 4) | ttsFlagNone)
b.WriteByte((ttsSerialJSON << 4) | ttsCompNone)
b.WriteByte(0x00)
_ = binary.Write(&b, binary.BigEndian, uint32(45000001)) // code
msg := []byte(`{"error":"quota"}`)
_ = binary.Write(&b, binary.BigEndian, uint32(len(msg)))
b.Write(msg)
r, err := parseTTSFrame(b.Bytes())
if err != nil {
t.Fatalf("解析错误帧失败: %v", err)
}
if r.MsgType != ttsServerErr {
t.Errorf("msgType=%d,期望错误类型", r.MsgType)
}
if r.Code != 45000001 {
t.Errorf("code=%d,期望 45000001", r.Code)
}
if !bytes.Equal(r.Payload, msg) {
t.Errorf("错误体不一致:得 %s", string(r.Payload))
}
}
// 客户端会话级事件帧应带上事件号与会话 ID,且 payload 为 gzip。
func TestSessionEventFrame_RoundTrip(t *testing.T) {
frame := sessionEventFrame(evTaskRequest, "sess-x", []byte(`{"text":"你好"}`))
// 手工核对头两个字节 + 事件号 + 会话 ID 长度。
if frame[0] != ((0b0001<<4)|0b0001) || (frame[1]>>4) != ttsClientFull {
t.Fatalf("头字节不对: %08b %08b", frame[0], frame[1])
}
if frame[1]&0x0f&ttsFlagWithEvent == 0 {
t.Errorf("应带 withEvent flag")
}
ev := int32(binary.BigEndian.Uint32(frame[4:8]))
if ev != evTaskRequest {
t.Errorf("event=%d,期望 %d", ev, evTaskRequest)
}
sidLen := binary.BigEndian.Uint32(frame[8:12])
if int(sidLen) != len("sess-x") {
t.Errorf("sessionId 长度=%d,期望 %d", sidLen, len("sess-x"))
}
if got := string(frame[12 : 12+sidLen]); got != "sess-x" {
t.Errorf("sessionId=%q", got)
}
}