Files
sundynix-agentix/sundynix-gateway/internal/voice/frame.go
T
Blizzard 8283bc542f fix(voice): 火山鉴权头精确大小写(X-Api-App-ID 免被 Go 规范化)
Go 的 Header.Set 会把 X-Api-App-ID 规范化成 X-Api-App-Id;改直接赋 map 保留精确
大小写,对齐官方 demo。鉴权方案已与参考实现(realtime_dialog demo)完全一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:59:58 +08:00

118 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.. = payloadJSON 或 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 用 serialJSONraw 音频用 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 的控制帧(如纯确认)
}