From bb9c73a9b70767981df2f42dfd63dab1c0bdce56 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 22 Jul 2026 09:53:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(voice):=20=E4=BF=AE=E6=AD=A3=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=20V3=20=E6=B5=81=E5=BC=8F=E7=AB=AF=E7=82=B9=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E5=A4=B4(=E7=9C=9F=E7=81=AB=E5=B1=B1=E8=81=94?= =?UTF-8?q?=E8=B0=83=E9=AA=8C=E9=80=9A=E5=8D=8F=E8=AE=AE=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 联调发现 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 --- sundynix-gateway/cmd/voicecheck/main.go | 11 ++++++ sundynix-gateway/cmd/voiceconfig/main.go | 1 + sundynix-gateway/internal/voice/asr.go | 10 +++--- sundynix-gateway/internal/voice/config.go | 3 +- sundynix-gateway/internal/voice/frame.go | 42 ++++++++++++++++++++++- sundynix-gateway/internal/voice/tts.go | 10 +++--- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/sundynix-gateway/cmd/voicecheck/main.go b/sundynix-gateway/cmd/voicecheck/main.go index e83bb3b..acc91dd 100644 --- a/sundynix-gateway/cmd/voicecheck/main.go +++ b/sundynix-gateway/cmd/voicecheck/main.go @@ -28,6 +28,7 @@ import ( func main() { cfg := voice.Config{ APIKey: os.Getenv("VOLC_API_KEY"), + AppID: os.Getenv("VOLC_APP_ID"), ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"), TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"), TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"), @@ -41,6 +42,16 @@ func main() { } fmt.Printf("配置:ASR-resource=%q TTS-resource=%q 音色=%q\n", cfg.ASRResourceID, cfg.TTSResourceID, cfg.TTSVoiceType) + // ---- 0. 握手探针:分别验 ASR / TTS 端点能否连上(区分"鉴权方案错"还是"某个 resource-id 错")---- + if cfg.ASREnabled() { + if asr, err := voice.StartASR(context.Background(), cfg, "probe"); err != nil { + fmt.Printf("[探针] ASR 握手 ❌ %v\n", err) + } else { + fmt.Println("[探针] ASR 握手 ✅(鉴权方案 + ASR resource-id 有效)") + asr.Close() + } + } + // ---- 1. TTS:合成 → 收音频 ---- fmt.Printf("\n[TTS] 合成:%q\n", text) pcm24k, err := runTTS(cfg, text) diff --git a/sundynix-gateway/cmd/voiceconfig/main.go b/sundynix-gateway/cmd/voiceconfig/main.go index 18c01b2..214bf4d 100644 --- a/sundynix-gateway/cmd/voiceconfig/main.go +++ b/sundynix-gateway/cmd/voiceconfig/main.go @@ -32,6 +32,7 @@ func main() { } cfg := voice.Config{ APIKey: os.Getenv("VOLC_API_KEY"), + AppID: os.Getenv("VOLC_APP_ID"), ASRResourceID: os.Getenv("VOLC_ASR_RESOURCE_ID"), TTSResourceID: os.Getenv("VOLC_TTS_RESOURCE_ID"), TTSVoiceType: os.Getenv("VOLC_TTS_VOICE"), diff --git a/sundynix-gateway/internal/voice/asr.go b/sundynix-gateway/internal/voice/asr.go index b797938..dcae15f 100644 --- a/sundynix-gateway/internal/voice/asr.go +++ b/sundynix-gateway/internal/voice/asr.go @@ -14,7 +14,7 @@ import ( ) // 火山引擎 V3 大模型流式语音识别(SeedASR / SAUC)客户端。协议见 voice/frame.go 与 voice-jarvis 记忆。 -// 端点固定;鉴权走**新版 API Key**(Authorization: Bearer )。 +// 端点固定;鉴权走**新版 API Key**(X-Api-Access-Key + 固定 App Key + App ID,见 frame.go)。 const asrEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" @@ -91,14 +91,12 @@ func StartASR(ctx context.Context, cfg Config, uid string) (*ASRSession, error) return nil, fmt.Errorf("ASR 未配置") } hdr := http.Header{} - hdr.Set("Authorization", "Bearer "+cfg.APIKey) // 新版 API Key 鉴权 - hdr.Set("X-Api-Resource-Id", cfg.ASRResourceID) - hdr.Set("X-Api-Connect-Id", newConnectID()) + setVolcAuthHeaders(hdr, cfg.APIKey, cfg.AppID, cfg.ASRResourceID) dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} - conn, _, err := dialer.DialContext(ctx, asrEndpoint, hdr) + conn, resp, err := dialer.DialContext(ctx, asrEndpoint, hdr) if err != nil { - return nil, fmt.Errorf("连接火山 ASR 失败: %w", err) + 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() diff --git a/sundynix-gateway/internal/voice/config.go b/sundynix-gateway/internal/voice/config.go index 671f914..ac03a1e 100644 --- a/sundynix-gateway/internal/voice/config.go +++ b/sundynix-gateway/internal/voice/config.go @@ -8,7 +8,8 @@ import "github.com/sundynix/sundynix-shared/secrets" // Authorization(Bearer )+ X-Api-Resource-Id(区分服务,ASR 与双向 TTS 各一个)。 // 端点/音频格式(PCM 16k 单声道等)由代码固定,不入用户配置。 type Config struct { - APIKey string `json:"api_key"` // 新版 API Key(Authorization: Bearer ,密文入库) + APIKey string `json:"api_key"` // 新版 API Key(X-Api-Access-Key,密文入库) + AppID string `json:"app_id"` // 火山 App ID(X-Api-App-ID,解析账号资源授权用;V3 流式端点需要) ASRResourceID string `json:"asr_resource_id"` // 流式语音识别 X-Api-Resource-Id TTSResourceID string `json:"tts_resource_id"` // 双向流式 TTS X-Api-Resource-Id TTSVoiceType string `json:"tts_voice_type"` // 音色(如 zh_male_… / BV700_streaming) diff --git a/sundynix-gateway/internal/voice/frame.go b/sundynix-gateway/internal/voice/frame.go index 21bd104..5d663f6 100644 --- a/sundynix-gateway/internal/voice/frame.go +++ b/sundynix-gateway/internal/voice/frame.go @@ -1,6 +1,46 @@ package voice -import "encoding/binary" +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 共用)。协议(从官方参考实现核实): // diff --git a/sundynix-gateway/internal/voice/tts.go b/sundynix-gateway/internal/voice/tts.go index e5da51d..0ac4efb 100644 --- a/sundynix-gateway/internal/voice/tts.go +++ b/sundynix-gateway/internal/voice/tts.go @@ -12,7 +12,7 @@ import ( ) // 火山「双向流式 TTS V3」客户端(seed-tts-2.0)。边推文字边收音频,配 LLM token 流做连续朗读。 -// 帧协议见 tts_frame.go;鉴权走**新版 API Key**(Authorization: Bearer )。 +// 帧协议见 tts_frame.go;鉴权走**新版 API Key**(X-Api-Access-Key + 固定 App Key + App ID,见 frame.go)。 // // 一轮朗读的生命周期:StartTTS(连接+StartSession) → Speak(逐句推文字/事件200) → Finish(FinishSession) // → 客户端 range Audio() 收音频直到 channel 关闭 → Close()。一个 TTSSession = 一次 Agent 回答。 @@ -58,14 +58,12 @@ func StartTTS(ctx context.Context, cfg Config) (*TTSSession, error) { return nil, fmt.Errorf("TTS 未配置") } hdr := http.Header{} - hdr.Set("Authorization", "Bearer "+cfg.APIKey) // 新版 API Key 鉴权 - hdr.Set("X-Api-Resource-Id", cfg.TTSResourceID) - hdr.Set("X-Api-Connect-Id", newConnectID()) + setVolcAuthHeaders(hdr, cfg.APIKey, cfg.AppID, cfg.TTSResourceID) dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} - conn, _, err := dialer.DialContext(ctx, ttsEndpoint, hdr) + conn, resp, err := dialer.DialContext(ctx, ttsEndpoint, hdr) if err != nil { - return nil, fmt.Errorf("连接火山 TTS 失败: %w", err) + return nil, fmt.Errorf("连接火山 TTS 失败: %w%s", err, handshakeDetail(resp)) } sid := newConnectID()