Files
sundynix-agentix/sundynix-gateway/internal/voice/frame.go
T
Blizzard c5397438d4 docs(voice): 记录联调结论——协议验通,剩服务开通(账号侧)
联调确认:鉴权/帧协议已被真火山完全接受。剩 grant not found=账号未开通对应服务。
- TTS 正确 Resource-Id 是 volc.service_type.10029(语音合成大模型),非 seed-tts-2.0(那是模型名)
- ASR 端点 sauc/bigmodel 认 volc.bigasr.sauc.duration(格式被识别)
- 两者均 grant not found → App 9604175735 需在语音控制台开通这两个大模型服务

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

119 lines
4.4 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)。
// 新版 API Key 走 X-Api-Access-KeyX-Api-App-Key 是网关固定常量,缺它 400 "app key not found"。
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 的控制帧(如纯确认)
}