diff --git a/sundynix-gateway/internal/voice/frame.go b/sundynix-gateway/internal/voice/frame.go new file mode 100644 index 0000000..21bd104 --- /dev/null +++ b/sundynix-gateway/internal/voice/frame.go @@ -0,0 +1,75 @@ +package voice + +import "encoding/binary" + +// 火山语音 WebSocket 二进制帧编解码(ASR / TTS 共用)。协议(从官方参考实现核实): +// +// byte0 = 0x11 版本(0001) + 头长(0001,=4字节) +// byte1 = (msgType<<4) | flags +// byte2 = (serialization<<4) | compression +// byte3 = 0x00 保留 +// byte4..7 = uint32 大端 payload 长度 +// byte8.. = payload(JSON 或 raw 音频) +// +// 服务端响应帧含 4 字节序列号,故 payload 从第 12 字节起(header4 + seq4 + size4)。 + +// 消息类型(高 4bit)。 +const ( + msgFullClientReq byte = 0x01 // 初始配置请求(JSON) + msgAudioReq byte = 0x02 // 音频帧 + msgServerResp byte = 0x09 // 服务端识别/合成结果 + msgServerError byte = 0x0F // 服务端错误 +) + +// flags(低 4bit)。 +const ( + flagNone byte = 0x00 + flagLast byte = 0x02 // 最终/结束帧(最后一帧音频、或收尾) +) + +// 序列化方式(byte2 高 4bit)。 +const ( + serialRaw byte = 0x00 // raw(音频帧) + serialJSON byte = 0x01 // JSON(配置/文本) +) + +const compNone byte = 0x00 // 不压缩 + +// encodeFrame 组装一帧。JSON payload 用 serialJSON,raw 音频用 serialRaw。 +func encodeFrame(msgType, flags, serialization byte, payload []byte) []byte { + buf := make([]byte, 8+len(payload)) + buf[0] = 0x11 + buf[1] = (msgType << 4) | (flags & 0x0F) + buf[2] = (serialization << 4) | compNone + buf[3] = 0x00 + binary.BigEndian.PutUint32(buf[4:8], uint32(len(payload))) + copy(buf[8:], payload) + return buf +} + +// jsonFrame 组装一帧 JSON 消息(如 ASR 初始配置、TTS 文本)。 +func jsonFrame(msgType, flags byte, payload []byte) []byte { + return encodeFrame(msgType, flags, serialJSON, payload) +} + +// audioFrame 组装一帧 raw 音频;last=true 时打最终标记。 +func audioFrame(pcm []byte, last bool) []byte { + flags := flagNone + if last { + flags = flagLast + } + return encodeFrame(msgAudioReq, flags, serialRaw, pcm) +} + +// parseServerFrame 解析服务端帧:返回消息类型 + payload。响应含 4B 序列号 → payload 从第 12 字节起。 +// 帧不足以解析时 ok=false。 +func parseServerFrame(data []byte) (msgType byte, payload []byte, ok bool) { + if len(data) < 4 { + return 0, nil, false + } + msgType = (data[1] >> 4) & 0x0F + if len(data) >= 12 { + return msgType, data[12:], true + } + return msgType, nil, true // 无 payload 的控制帧(如纯确认) +} diff --git a/sundynix-gateway/internal/voice/frame_test.go b/sundynix-gateway/internal/voice/frame_test.go new file mode 100644 index 0000000..4b26921 --- /dev/null +++ b/sundynix-gateway/internal/voice/frame_test.go @@ -0,0 +1,62 @@ +package voice + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// 帧头布局必须精确匹配火山协议(错一个 bit 服务端就拒)。 +func TestEncodeFrame_HeaderLayout(t *testing.T) { + f := jsonFrame(msgFullClientReq, flagNone, []byte(`{"a":1}`)) + if f[0] != 0x11 { + t.Fatalf("byte0 应 0x11,得 %#x", f[0]) + } + if f[1] != (msgFullClientReq<<4)|flagNone { // 0x10 + t.Fatalf("byte1 应 0x10,得 %#x", f[1]) + } + if f[2] != (serialJSON<<4)|compNone { // 0x10 + t.Fatalf("byte2(JSON) 应 0x10,得 %#x", f[2]) + } + if got := binary.BigEndian.Uint32(f[4:8]); got != 7 { + t.Fatalf("payload 长度应 7,得 %d", got) + } + if !bytes.Equal(f[8:], []byte(`{"a":1}`)) { + t.Fatalf("payload 错:%s", f[8:]) + } +} + +// 音频帧:raw 序列化 + 最终标记。 +func TestAudioFrame_LastFlag(t *testing.T) { + f := audioFrame([]byte{1, 2, 3}, true) + if f[1] != (msgAudioReq<<4)|flagLast { // 0x22 + t.Fatalf("音频最终帧 byte1 应 0x22,得 %#x", f[1]) + } + if f[2] != (serialRaw<<4)|compNone { // 0x00 + t.Fatalf("音频 byte2 应 0x00(raw),得 %#x", f[2]) + } + non := audioFrame([]byte{1}, false) + if non[1]&0x0F != flagNone { + t.Fatalf("非最终帧 flags 应 0,得 %#x", non[1]&0x0F) + } +} + +// 服务端帧解析:跳 12 字节头(含序列号)取 payload;识别结果类型 0x09。 +func TestParseServerFrame(t *testing.T) { + // 造一个响应帧:header(4) + seq(4) + size(4) + payload + body := []byte(`{"type":"final"}`) + resp := make([]byte, 12+len(body)) + resp[1] = (msgServerResp << 4) // 0x90 + copy(resp[12:], body) + mt, payload, ok := parseServerFrame(resp) + if !ok || mt != msgServerResp { + t.Fatalf("应解析出 msgServerResp,得 mt=%#x ok=%v", mt, ok) + } + if !bytes.Equal(payload, body) { + t.Fatalf("payload 应从第 12 字节起,得 %s", payload) + } + // 过短帧不 panic + if _, _, ok := parseServerFrame([]byte{0x11}); ok { + t.Fatal("过短帧应 ok=false") + } +}