From 4ee4a91a51081f2e94f4bbaf1b8520c7dea56e8a Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 17:27:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E7=81=AB=E5=B1=B1=20ASR=20?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=AF=86=E5=88=AB=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=20+=20=E4=BC=9A=E8=AF=9D=E6=8E=A5=E7=BA=BF(Phase=201=20?= =?UTF-8?q?=E8=80=B3=E6=9C=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit voice/asr.go: 连 wss://openspeech.bytedance.com/api/v3/sauc/bigmodel,新版 API Key 鉴权 (Authorization: Bearer + X-Api-Resource-Id + Connect-Id),发初始配置帧(bigmodel/zh/ITN/ 标点/VAD),PushAudio 流式喂 PCM、Finish 收尾;读 goroutine 解析响应(result 支持数组/对象/ 字符串,type=final 为最终)推入 Results 通道。 handler/voice.go 接线:onAudio→PushAudio、start→重开识别、end→Finish;起 goroutine 把 转写 send(transcript) 实时回推客户端。加 writeMu 串行化写(读循环与 ASR 结果 goroutine 都写同一 WS,gorilla 禁并发写)。连接结束 stopASR 收尾。 带单测(配置JSON字段/result三形态解析)。真识别需部署联调(要真连火山);API Key 是否 还需 X-Api-App-Key 联调若 401 再补。上行接线(final→SubmitTask)与 TTS 是下一步。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/internal/handler/voice.go | 82 ++++++-- sundynix-gateway/internal/voice/asr.go | 195 ++++++++++++++++++++ sundynix-gateway/internal/voice/asr_test.go | 53 ++++++ 3 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 sundynix-gateway/internal/voice/asr.go create mode 100644 sundynix-gateway/internal/voice/asr_test.go diff --git a/sundynix-gateway/internal/handler/voice.go b/sundynix-gateway/internal/handler/voice.go index 1f1b115..86267a3 100644 --- a/sundynix-gateway/internal/handler/voice.go +++ b/sundynix-gateway/internal/handler/voice.go @@ -1,9 +1,11 @@ package handler import ( + "context" "encoding/json" "log" "net/http" + "sync" "time" "github.com/gin-gonic/gin" @@ -48,27 +50,35 @@ func (h *Handler) VoiceStream(c *gin.Context) { sess := &voiceSession{conn: conn, uid: uid, cfg: cfg} sess.send(voice.ServerMsg{Type: voice.ServerReady}) sess.run() + sess.stopASR() // 连接结束,收掉在跑的识别会话 } // voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。 -// 上行(音频→ASR→转写→SubmitTask)与下行(token流→攒句→TTS→音频)将挂在这里(下一步)。 +// 上行 = 音频→ASR→转写(→下一步 SubmitTask);下行(token流→攒句→TTS→音频)将在 TTS 步接上。 type voiceSession struct { - conn *websocket.Conn - uid string - cfg voice.Config + conn *websocket.Conn + uid string + cfg voice.Config + writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化 + asr *voice.ASRSession + asrCancel context.CancelFunc } -// send 下发一条控制/事件消息(文本帧,JSON)。 +// send 下发一条控制/事件消息(文本帧,JSON)。并发安全。 func (s *voiceSession) send(m voice.ServerMsg) { b, _ := json.Marshal(m) + s.writeMu.Lock() + defer s.writeMu.Unlock() _ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait)) if err := s.conn.WriteMessage(websocket.TextMessage, b); err != nil { log.Printf("[voice] 写控制消息失败 uid=%s: %v", s.uid, err) } } -// sendAudio 下发一帧 TTS 音频(二进制帧)。 +// sendAudio 下发一帧 TTS 音频(二进制帧)。并发安全。 func (s *voiceSession) sendAudio(pcm []byte) { + s.writeMu.Lock() + defer s.writeMu.Unlock() _ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait)) if err := s.conn.WriteMessage(websocket.BinaryMessage, pcm); err != nil { log.Printf("[voice] 写音频失败 uid=%s: %v", s.uid, err) @@ -97,9 +107,16 @@ func (s *voiceSession) run() { } } -// onAudio 收到一帧上行音频。TODO(下一步):喂火山 ASR 客户端。 -func (s *voiceSession) onAudio(_ []byte) { - // ASR 客户端接入后:把音频帧 push 进识别流;识别结果经 send(transcript) 回推。 +// onAudio 收到一帧上行音频 → 喂火山 ASR。 +func (s *voiceSession) onAudio(pcm []byte) { + if s.asr == nil { + s.startASR() // 客户端没显式 start 就直接说话时,惰性开一路识别 + } + if s.asr != nil { + if err := s.asr.PushAudio(pcm); err != nil { + log.Printf("[voice] 喂 ASR 音频失败 uid=%s: %v", s.uid, err) + } + } } // onControl 处理客户端控制消息,返回 true 表示会话应结束。 @@ -107,11 +124,50 @@ func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) { switch m.Type { case voice.ClientBye: return true + case voice.ClientStart: + s.stopASR() + s.startASR() // 新一轮:重开识别 case voice.ClientEnd: - // TODO(下一步):ASR 收尾 → 拿最终转写 → 组 DSL/用 m.Graph → SubmitTask → 订阅 token 流 → 攒句 → TTS。 - s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音识别链路开发中(下一步接入火山 ASR)"}) - case voice.ClientStart, voice.ClientBargeIn: - // start:重置一轮;barge_in:停当前 TTS 播放(TTS 接入后处理)。 + if s.asr != nil { + _ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true) + } + // TODO(上行接线):拿到 Final 转写 → 组 DSL/用 m.Graph → SubmitTask → 回推 task。 + case voice.ClientBargeIn: + // 打断:停当前 TTS 播放(TTS 接入后处理)。 } return false } + +// startASR 开一路火山流式识别,并起 goroutine 把转写实时回推客户端。 +func (s *voiceSession) startASR() { + ctx, cancel := context.WithCancel(context.Background()) + asr, err := voice.StartASR(ctx, s.cfg, s.uid) + if err != nil { + cancel() + log.Printf("[voice] 启动 ASR 失败 uid=%s: %v", s.uid, err) + s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音识别启动失败"}) + return + } + s.asr = asr + s.asrCancel = cancel + go func() { + for r := range asr.Results() { + if r.Err != nil { + return // 识别流结束/出错 + } + s.send(voice.ServerMsg{Type: voice.ServerTranscript, Text: r.Text, Final: r.Final}) + } + }() +} + +// stopASR 收掉当前识别会话(幂等)。 +func (s *voiceSession) stopASR() { + if s.asr != nil { + s.asr.Close() + s.asr = nil + } + if s.asrCancel != nil { + s.asrCancel() + s.asrCancel = nil + } +} diff --git a/sundynix-gateway/internal/voice/asr.go b/sundynix-gateway/internal/voice/asr.go new file mode 100644 index 0000000..b797938 --- /dev/null +++ b/sundynix-gateway/internal/voice/asr.go @@ -0,0 +1,195 @@ +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**(Authorization: Bearer )。 + +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{} + hdr.Set("Authorization", "Bearer "+cfg.APIKey) // 新版 API Key 鉴权 + hdr.Set("X-Api-Resource-Id", cfg.ASRResourceID) + hdr.Set("X-Api-Connect-Id", newConnectID()) + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, _, err := dialer.DialContext(ctx, asrEndpoint, hdr) + if err != nil { + return nil, fmt.Errorf("连接火山 ASR 失败: %w", err) + } + 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 "" +} diff --git a/sundynix-gateway/internal/voice/asr_test.go b/sundynix-gateway/internal/voice/asr_test.go new file mode 100644 index 0000000..e6ce487 --- /dev/null +++ b/sundynix-gateway/internal/voice/asr_test.go @@ -0,0 +1,53 @@ +package voice + +import ( + "encoding/json" + "testing" +) + +// 初始配置 JSON 关键字段必须对(错了火山直接拒识别)。 +func TestBuildASRRequest(t *testing.T) { + var r asrReq + if err := json.Unmarshal(buildASRRequest("u1"), &r); err != nil { + t.Fatalf("配置 JSON 不合法: %v", err) + } + if r.User.UID != "u1" { + t.Fatalf("uid 应 u1,得 %q", r.User.UID) + } + if r.Audio.Rate != 16000 || r.Audio.Bits != 16 || r.Audio.Channel != 1 { + t.Fatalf("音频参数应 16k/16bit/单声道,得 %+v", r.Audio) + } + if r.Request.ModelName != "bigmodel" || !r.Request.VAD.VadEnable { + t.Fatalf("model/vad 配置错:%+v", r.Request) + } + // 空 uid 兜底 + var r2 asrReq + _ = json.Unmarshal(buildASRRequest(""), &r2) + if r2.User.UID == "" { + t.Fatal("空 uid 应兜底非空") + } +} + +// result 三种形态(数组/对象/字符串)都要能取出文本;final 由 type 决定。 +func TestParseASRResult(t *testing.T) { + cases := []struct { + payload string + wantText string + wantFinal bool + }{ + {`{"type":"interim","result":[{"text":"你好"}]}`, "你好", false}, + {`{"type":"final","result":[{"text":"你好"},{"text":"世界"}]}`, "你好世界", true}, + {`{"type":"final","result":{"text":"单对象"}}`, "单对象", true}, + {`{"type":"interim","result":"纯字符串"}`, "纯字符串", false}, + } + for _, c := range cases { + text, final, ok := parseASRResult([]byte(c.payload)) + if !ok || text != c.wantText || final != c.wantFinal { + t.Fatalf("parse(%s)=%q,%v,%v want %q,%v", c.payload, text, final, ok, c.wantText, c.wantFinal) + } + } + // 坏 JSON 不 panic、ok=false + if _, _, ok := parseASRResult([]byte("not json")); ok { + t.Fatal("坏 JSON 应 ok=false") + } +}