Files
sundynix-agentix/sundynix-gateway/internal/handler/voice.go
T
Blizzard 4ee4a91a51 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>
2026-07-21 17:27:32 +08:00

174 lines
5.1 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 handler
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/sundynix/sundynix-gateway/internal/voice"
)
// 语音交互 WebSocket 端点(JARVIS)。一条连接承载上行音频 + 下行转写 + 下行 TTS 音频,
// 协议见 voice/protocol.go。鉴权走 AuthFromHeaderOrQueryEventSource/WS 带不了 Bearer 头,
// 用 ?token=)。本文件是会话外壳 + 客户端↔网关协议循环;火山 ASR/TTS 客户端在下一步接入。
var voiceUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// CheckOrigin 放行:鉴权已由 token 把关(跨源 WS 无法读响应,且我们不依赖 cookie)。
CheckOrigin: func(*http.Request) bool { return true },
}
const voiceWriteWait = 10 * time.Second
// VoiceStream: GET /api/v1/voice/stream —— 升级为 WebSocket 语音会话。
func (h *Handler) VoiceStream(c *gin.Context) {
uid := userID(c)
if uid == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
return
}
cfg := h.loadVoiceConfig(c.Request.Context())
if !cfg.ASREnabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "语音服务未配置(缺 API Key / ASR resource-id"})
return
}
conn, err := voiceUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[voice] 升级 WS 失败 uid=%s: %v", uid, err)
return
}
defer conn.Close()
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→音频)将在 TTS 步接上。
type voiceSession struct {
conn *websocket.Conn
uid string
cfg voice.Config
writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化
asr *voice.ASRSession
asrCancel context.CancelFunc
}
// 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 音频(二进制帧)。并发安全。
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)
}
}
// run 是协议读循环:二进制帧=上行音频,文本帧=控制消息。
func (s *voiceSession) run() {
for {
mt, data, err := s.conn.ReadMessage()
if err != nil {
return // 客户端断开 / 读错误
}
switch mt {
case websocket.BinaryMessage:
s.onAudio(data)
case websocket.TextMessage:
var m voice.ClientMsg
if json.Unmarshal(data, &m) != nil {
continue
}
if s.onControl(m) {
return // bye
}
}
}
}
// 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 表示会话应结束。
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:
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
}
}