package voice import ( "encoding/binary" "fmt" "io" "net/http" "strings" ) // volcAppKey 是火山语音网关 V3 端点(ASR/TTS/实时对话)**固定**的 App Key 常量——所有客户端 // 都用同一个值(官方 demo 硬编码),与账号无关;真正的账号鉴权走 X-Api-Access-Key(新版 API Key)。 // 缺这个头握手会被拒:HTTP 400 "app key not found in header or query"。 const volcAppKey = "PlgvMymc7f3tQnJ6" // setVolcAuthHeaders 按火山 V3 二进制流端点的鉴权规范挂头:固定 App Key + 账号 App ID + // 新版 API Key(Access Key) + 资源 ID + 连接 ID。**不是** Authorization: Bearer(那是 OpenAI // 兼容端点用的,流式二进制端点不认)。App ID 用于解析账号资源授权(缺它会 401 grant not found)。 func setVolcAuthHeaders(hdr http.Header, apiKey, appID, resourceID string) { // 直接赋 map(不走 Set)以**保留精确大小写**:Go 的 Header.Set 会把 "X-Api-App-ID" // 规范化成 "X-Api-App-Id",而火山网关按精确大小写匹配(官方 demo 用 X-Api-App-ID)。 hdr["X-Api-App-Key"] = []string{volcAppKey} hdr["X-Api-Access-Key"] = []string{apiKey} hdr["X-Api-Resource-Id"] = []string{resourceID} hdr["X-Api-Connect-Id"] = []string{newConnectID()} if appID != "" { hdr["X-Api-App-ID"] = []string{appID} } } // handshakeDetail 从失败的 WS 握手响应里榨出可诊断信息:HTTP 状态 + 火山排障用的 // X-Tt-Logid + 响应体(鉴权/权限/路径错都在这里能看出来)。resp 为 nil 时返回空串。 func handshakeDetail(resp *http.Response) string { if resp == nil { return "" } body := "" if resp.Body != nil { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) _ = resp.Body.Close() body = strings.TrimSpace(string(b)) } logid := resp.Header.Get("X-Tt-Logid") return fmt.Sprintf(" [HTTP %d logid=%s body=%q]", resp.StatusCode, logid, body) } // 火山语音 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 的控制帧(如纯确认) }