Files
Blizzard d6c1a787f4 fix(voice): 改用火山「新版控制台」API Key 鉴权(ASR 握手真机验通)
一直误用旧版 5 头方案(X-Api-App-Key+Access-Key+App-ID),火山始终 401 grant not found。
用户提供官方文档「新版本控制台」证实:新版只需**单个 X-Api-Key** 头。改对后真机联调:

- frame.go setVolcAuthHeaders: X-Api-Key + X-Api-Resource-Id + X-Api-Request-Id + X-Api-Sequence:-1
  (删旧 App-Key/Access-Key/App-ID/Connect-Id;去 appID 参数)
- asr/tts 调用点同步;config.go 删 Config.AppID;voicecheck/voiceconfig 删 VOLC_APP_ID
- 删临时诊断工具 voiceprobe

真机结果:ASR 握手(volc.bigasr.sauc.duration 有效);TTS 用 seed-tts-2.0 会话正常建立
(不再 resource mismatch),但暂无音频返回→双向 TTS 事件/payload 待调(下一步)。

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

112 lines
3.9 KiB
Go
Raw Permalink 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"
)
// setVolcAuthHeaders 按火山「新版控制台」API Key 鉴权挂头(文档 6561/1354869「新版本控制台」表)。
// 新版只需**单个 X-Api-Key**(控制台的 APP Key)——**不用**旧版的 X-Api-App-Key+X-Api-Access-Key+App-ID
// 也不是 Authorization: Bearer。此前误用旧 5 头方案,火山始终 401 "grant not found in SaaS storage"。
// - X-Api-Key = 控制台 API Key(用户那把 key)
// - X-Api-Resource-Id= 资源 ID(如 volc.bigasr.sauc.duration / volc.seedasr.sauc.duration / volc.service_type.10029
// - X-Api-Request-Id = 随机 UUID
// - X-Api-Sequence = 固定 "-1"
func setVolcAuthHeaders(hdr http.Header, apiKey, resourceID string) {
hdr["X-Api-Key"] = []string{apiKey}
hdr["X-Api-Resource-Id"] = []string{resourceID}
hdr["X-Api-Request-Id"] = []string{newConnectID()}
hdr["X-Api-Sequence"] = []string{"-1"}
}
// 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 的控制帧(如纯确认)
}