feat(voice): 火山语音 WS 二进制帧编解码(ASR/TTS 共用核心)
从官方参考实现核实的 V3 协议帧格式,港到 Go:byte0=0x11、byte1=(msgType<<4)|flags、 byte2=序列化<<4|压缩、byte3=0x00、大端 uint32 payload 长度、payload;服务端响应含 4B 序列号故 payload 从第 12 字节起。encodeFrame/jsonFrame/audioFrame/parseServerFrame + msgType(0x01 config/0x02 音频/0x09 结果/0x0F 错误)与 flags(0x02 最终)常量。 3 个单测钉死头布局/音频最终帧/响应解析(确定性,不依赖网络)。 ASR 初始配置 JSON、WS 连接与流式识别(接 onAudio)是下一步网络层实现。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 的控制帧(如纯确认)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user