bb9c73a9b7
联调发现 Authorization: Bearer 不被 openspeech V3 二进制流端点接受。改用官方规范头: - X-Api-App-Key: 固定常量 PlgvMymc7f3tQnJ6(所有客户端同值,缺它 400 "app key not found") - X-Api-Access-Key: 新版 API Key(账号鉴权) - X-Api-App-ID: 账号 App ID(解析资源授权,缺它 401 "grant not found") - X-Api-Resource-Id / X-Api-Connect-Id 改动:Config 加 AppID 字段;frame.go setVolcAuthHeaders 统一挂头 + handshakeDetail 榨取握手失败的 HTTP 状态/logid/body(联调可诊断);asr/tts 共用;voicecheck 加握手探针 分别验 ASR/TTS 端点;voiceconfig/voicecheck 读 VOLC_APP_ID。 现状:协议格式已被真火山接受(过了 400 格式错,进到账号/资源授权查询)。剩 App ID + 资源开通确认(账号侧,联调补)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
4.0 KiB
Go
116 lines
4.0 KiB
Go
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) {
|
||
hdr.Set("X-Api-App-Key", volcAppKey)
|
||
if appID != "" {
|
||
hdr.Set("X-Api-App-ID", appID)
|
||
}
|
||
hdr.Set("X-Api-Access-Key", apiKey)
|
||
hdr.Set("X-Api-Resource-Id", resourceID)
|
||
hdr.Set("X-Api-Connect-Id", newConnectID())
|
||
}
|
||
|
||
// 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 的控制帧(如纯确认)
|
||
}
|