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

194 lines
5.4 KiB
Go

package voice
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gorilla/websocket"
)
// 火山引擎 V3 大模型流式语音识别(SeedASR / SAUC)客户端。协议见 voice/frame.go 与 voice-jarvis 记忆。
// 端点固定;鉴权走**新版控制台 API Key**(单个 X-Api-Key,见 frame.go)。
const asrEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
// ASRResult 是一次识别回传。Err 非空表示识别流出错/结束(此后 Results 关闭)。
type ASRResult struct {
Text string
Final bool
Err error
}
// ASRSession 是一路流式识别会话:PushAudio 喂 PCM、Results 出转写、Finish 收尾、Close 关闭。
type ASRSession struct {
conn *websocket.Conn
results chan ASRResult
}
// ---- 初始配置请求 JSON ----
type asrReq struct {
User asrUser `json:"user"`
Audio asrAudio `json:"audio"`
Request asrOptions `json:"request"`
}
type asrUser struct {
UID string `json:"uid"`
}
type asrAudio struct {
Format string `json:"format"`
Rate int `json:"rate"`
Bits int `json:"bits"`
Channel int `json:"channel"`
Codec string `json:"codec"`
}
type asrVAD struct {
VadEnable bool `json:"vad_enable"`
EndWindowSize int `json:"end_window_size"`
}
type asrOptions struct {
ModelName string `json:"model_name"`
Language string `json:"language"`
EnableITN bool `json:"enable_itn"`
EnablePunc bool `json:"enable_punc"`
ResultType string `json:"result_type"`
VAD asrVAD `json:"vad"`
}
// buildASRRequest 组初始配置 JSON。音频格式与客户端采集一致(PCM 16k 单声道 raw)。
// format="pcm" 是联调可调点(火山对 raw PCM 也接受 "raw")。
func buildASRRequest(uid string) []byte {
if uid == "" {
uid = "sundynix"
}
r := asrReq{
User: asrUser{UID: uid},
Audio: asrAudio{Format: "pcm", Rate: AudioSampleRate, Bits: AudioBits, Channel: AudioChannels, Codec: "raw"},
Request: asrOptions{
ModelName: "bigmodel", Language: "zh", EnableITN: true, EnablePunc: true,
ResultType: "0", VAD: asrVAD{VadEnable: true, EndWindowSize: 800},
},
}
b, _ := json.Marshal(r)
return b
}
func newConnectID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// StartASR 连火山流式识别、发初始配置帧,返回会话。读 goroutine 持续把结果推入 Results。
func StartASR(ctx context.Context, cfg Config, uid string) (*ASRSession, error) {
if !cfg.ASREnabled() {
return nil, fmt.Errorf("ASR 未配置")
}
hdr := http.Header{}
setVolcAuthHeaders(hdr, cfg.APIKey, cfg.ASRResourceID)
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, resp, err := dialer.DialContext(ctx, asrEndpoint, hdr)
if err != nil {
return nil, fmt.Errorf("连接火山 ASR 失败: %w%s", err, handshakeDetail(resp))
}
if err := conn.WriteMessage(websocket.BinaryMessage, jsonFrame(msgFullClientReq, flagNone, buildASRRequest(uid))); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 ASR 配置失败: %w", err)
}
s := &ASRSession{conn: conn, results: make(chan ASRResult, 16)}
go s.readLoop()
return s, nil
}
func (s *ASRSession) readLoop() {
defer close(s.results)
for {
mt, data, err := s.conn.ReadMessage()
if err != nil {
s.results <- ASRResult{Err: err}
return
}
if mt != websocket.BinaryMessage {
continue
}
msgType, payload, ok := parseServerFrame(data)
if !ok || len(payload) == 0 {
continue
}
if msgType == msgServerError {
s.results <- ASRResult{Err: fmt.Errorf("火山 ASR 错误: %s", strings.TrimSpace(string(payload)))}
return
}
if text, final, ok := parseASRResult(payload); ok {
s.results <- ASRResult{Text: text, Final: final}
}
}
}
// PushAudio 喂一帧 PCM。
func (s *ASRSession) PushAudio(pcm []byte) error {
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(pcm, false))
}
// Finish 发结束标记(空音频 + 最终帧),告知火山本轮说完。
func (s *ASRSession) Finish() error {
return s.conn.WriteMessage(websocket.BinaryMessage, audioFrame(nil, true))
}
// Results 返回识别结果流(部分/最终;出错或结束时推一条 Err 后关闭)。
func (s *ASRSession) Results() <-chan ASRResult { return s.results }
// Close 关闭底层连接(读 goroutine 随之退出)。
func (s *ASRSession) Close() { _ = s.conn.Close() }
// parseASRResult 从响应 JSON 提取转写文本 + 是否最终。空文本且非最终时 ok=false。
func parseASRResult(payload []byte) (text string, final bool, ok bool) {
var r struct {
Type string `json:"type"`
Result json.RawMessage `json:"result"`
}
if json.Unmarshal(payload, &r) != nil {
return "", false, false
}
final = r.Type == "final"
text = extractText(r.Result)
return text, final, text != "" || final
}
// extractText 从 result 里取转写文本;result 可为 [{text}]/{text}/"string"。
func extractText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var arr []struct {
Text string `json:"text"`
}
if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
var sb strings.Builder
for _, a := range arr {
sb.WriteString(a.Text)
}
if sb.Len() > 0 {
return sb.String()
}
}
var obj struct {
Text string `json:"text"`
}
if json.Unmarshal(raw, &obj) == nil && obj.Text != "" {
return obj.Text
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return ""
}