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,110 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user