feat(voice): 火山 ASR 流式识别客户端 + 会话接线(Phase 1 耳朵)

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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 17:27:32 +08:00
parent b526b21dee
commit 4ee4a91a51
3 changed files with 317 additions and 13 deletions
+69 -13
View File
@@ -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
}
}