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:
@@ -1,9 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 := &voiceSession{conn: conn, uid: uid, cfg: cfg}
|
||||||
sess.send(voice.ServerMsg{Type: voice.ServerReady})
|
sess.send(voice.ServerMsg{Type: voice.ServerReady})
|
||||||
sess.run()
|
sess.run()
|
||||||
|
sess.stopASR() // 连接结束,收掉在跑的识别会话
|
||||||
}
|
}
|
||||||
|
|
||||||
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
|
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
|
||||||
// 上行(音频→ASR→转写→SubmitTask)与下行(token流→攒句→TTS→音频)将挂在这里(下一步)。
|
// 上行 = 音频→ASR→转写(→下一步 SubmitTask);下行(token流→攒句→TTS→音频)将在 TTS 步接上。
|
||||||
type voiceSession struct {
|
type voiceSession struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
uid string
|
uid string
|
||||||
cfg voice.Config
|
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) {
|
func (s *voiceSession) send(m voice.ServerMsg) {
|
||||||
b, _ := json.Marshal(m)
|
b, _ := json.Marshal(m)
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
||||||
if err := s.conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
if err := s.conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
||||||
log.Printf("[voice] 写控制消息失败 uid=%s: %v", s.uid, err)
|
log.Printf("[voice] 写控制消息失败 uid=%s: %v", s.uid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendAudio 下发一帧 TTS 音频(二进制帧)。
|
// sendAudio 下发一帧 TTS 音频(二进制帧)。并发安全。
|
||||||
func (s *voiceSession) sendAudio(pcm []byte) {
|
func (s *voiceSession) sendAudio(pcm []byte) {
|
||||||
|
s.writeMu.Lock()
|
||||||
|
defer s.writeMu.Unlock()
|
||||||
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
_ = s.conn.SetWriteDeadline(time.Now().Add(voiceWriteWait))
|
||||||
if err := s.conn.WriteMessage(websocket.BinaryMessage, pcm); err != nil {
|
if err := s.conn.WriteMessage(websocket.BinaryMessage, pcm); err != nil {
|
||||||
log.Printf("[voice] 写音频失败 uid=%s: %v", s.uid, err)
|
log.Printf("[voice] 写音频失败 uid=%s: %v", s.uid, err)
|
||||||
@@ -97,9 +107,16 @@ func (s *voiceSession) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// onAudio 收到一帧上行音频。TODO(下一步):喂火山 ASR 客户端。
|
// onAudio 收到一帧上行音频 → 喂火山 ASR。
|
||||||
func (s *voiceSession) onAudio(_ []byte) {
|
func (s *voiceSession) onAudio(pcm []byte) {
|
||||||
// ASR 客户端接入后:把音频帧 push 进识别流;识别结果经 send(transcript) 回推。
|
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 表示会话应结束。
|
// onControl 处理客户端控制消息,返回 true 表示会话应结束。
|
||||||
@@ -107,11 +124,50 @@ func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
|
|||||||
switch m.Type {
|
switch m.Type {
|
||||||
case voice.ClientBye:
|
case voice.ClientBye:
|
||||||
return true
|
return true
|
||||||
|
case voice.ClientStart:
|
||||||
|
s.stopASR()
|
||||||
|
s.startASR() // 新一轮:重开识别
|
||||||
case voice.ClientEnd:
|
case voice.ClientEnd:
|
||||||
// TODO(下一步):ASR 收尾 → 拿最终转写 → 组 DSL/用 m.Graph → SubmitTask → 订阅 token 流 → 攒句 → TTS。
|
if s.asr != nil {
|
||||||
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "语音识别链路开发中(下一步接入火山 ASR)"})
|
_ = s.asr.Finish() // 告知火山本轮说完,等最终转写(结果流里带 Final=true)
|
||||||
case voice.ClientStart, voice.ClientBargeIn:
|
}
|
||||||
// start:重置一轮;barge_in:停当前 TTS 播放(TTS 接入后处理)。
|
// TODO(上行接线):拿到 Final 转写 → 组 DSL/用 m.Graph → SubmitTask → 回推 task。
|
||||||
|
case voice.ClientBargeIn:
|
||||||
|
// 打断:停当前 TTS 播放(TTS 接入后处理)。
|
||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <APIKey>)。
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user