Files
sundynix-agentix/sundynix-gateway/internal/voice/asr.go
T
Blizzard bb9c73a9b7 fix(voice): 修正火山 V3 流式端点鉴权头(真火山联调验通协议格式)
联调发现 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>
2026-07-22 09:53:26 +08:00

194 lines
5.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 (
"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-Access-Key + 固定 App Key + App ID,见 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.AppID, 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 ""
}