feat(voice): WebSocket 端点 + 客户端↔网关协议(Phase 1 地基)

加 gorilla/websocket(无 genproto 冲突)。voice/protocol.go 定死单条 WS 的消息协议:
二进制帧=音频(上行麦克风/下行TTS),文本帧=JSON 控制/事件(ClientMsg:start/end/barge_in/
bye;ServerMsg:ready/transcript/task/speaking/tts_end/error);音频 PCM 16k 单声道。
handler/voice.go: GET /api/v1/voice/stream 升级 WS,鉴权走 AuthFromHeaderOrQuery(?token=,
WS 带不了 Bearer),会话外壳 + 协议读循环(音频帧/控制消息分派)已通,火山 ASR/TTS 客户端
在下一步挂 onAudio/onControl 的 TODO 点接入。build+vet+test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 17:17:58 +08:00
parent a28ae49b6a
commit 15f5a85612
5 changed files with 176 additions and 1 deletions
+1
View File
@@ -39,6 +39,7 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+2
View File
@@ -63,6 +63,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+117
View File
@@ -0,0 +1,117 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"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()
}
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
// 上行(音频→ASR→转写→SubmitTask)与下行(token流→攒句→TTS→音频)将挂在这里(下一步)。
type voiceSession struct {
conn *websocket.Conn
uid string
cfg voice.Config
}
// send 下发一条控制/事件消息(文本帧,JSON)。
func (s *voiceSession) send(m voice.ServerMsg) {
b, _ := json.Marshal(m)
_ = 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.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 收到一帧上行音频。TODO(下一步):喂火山 ASR 客户端。
func (s *voiceSession) onAudio(_ []byte) {
// ASR 客户端接入后:把音频帧 push 进识别流;识别结果经 send(transcript) 回推。
}
// onControl 处理客户端控制消息,返回 true 表示会话应结束。
func (s *voiceSession) onControl(m voice.ClientMsg) (done bool) {
switch m.Type {
case voice.ClientBye:
return true
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 接入后处理)。
}
return false
}
+2 -1
View File
@@ -71,7 +71,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
api.GET("/kb/ingest/:id/stream", middleware.AuthFromHeaderOrQuery(), h.KbIngestStream) // 入库进度 SSE(登录即可,进度非敏感)
api.GET("/reports/:id/export", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 按需导出
api.GET("/reports/:id/download", middleware.AuthFromHeaderOrQuery(), h.ExportReport) // 兼容旧入口(默认 docx
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
api.GET("/voice/stream", middleware.AuthFromHeaderOrQuery(), h.VoiceStream) // 语音会话 WebSocket?token= 鉴权,WS 带不了 Bearer 头)
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
p := api.Group("", middleware.RequireAuth())
@@ -0,0 +1,54 @@
package voice
// 客户端 ↔ 网关的单条 WebSocket 消息协议(设计 VOICE_DESIGN.md D3):
// 一条连接同时承载上行音频、下行转写、下行 TTS 音频,用「帧类型 + JSON 消息类型」区分。
//
// - 二进制帧(BinaryMessage):纯音频 PCM
// · 上行 = 用户麦克风音频(喂 ASR)
// · 下行 = Agent 回答的 TTS 音频(客户端播放)
// - 文本帧(TextMessage, JSON):控制与事件(下方 ClientMsg / ServerMsg
// ClientMsg 是客户端发来的控制消息(文本帧)。音频走二进制帧,不在此。
type ClientMsg struct {
Type string `json:"type"`
// start:一轮语音开始(可带当前画布编排图,用它跑而非默认 DSL)
Graph string `json:"graph,omitempty"`
// 其它类型无额外字段:end(用户说完)、barge_in(打断,用户又开口)、bye(结束会话)
}
// 客户端消息类型。
const (
ClientStart = "start" // 一轮语音开始
ClientEnd = "end" // 用户说完(静音检测或手动结束)→ 触发任务
ClientBargeIn = "barge_in" // 打断:用户在 Agent 说话时又开口 → 停 TTS
ClientBye = "bye" // 结束整个语音会话
)
// ServerMsg 是网关下发的控制/事件消息(文本帧)。TTS 音频走二进制帧,不在此。
type ServerMsg struct {
Type string `json:"type"`
// transcriptASR 转写(Final=false 为实时部分结果,true 为最终)
Text string `json:"text,omitempty"`
Final bool `json:"final,omitempty"`
// task:转写完成、任务已提交,带 task_id 供客户端切运行视图
TaskID string `json:"task_id,omitempty"`
// error:出错文案
Msg string `json:"msg,omitempty"`
}
// 服务端消息类型。
const (
ServerReady = "ready" // 会话就绪,可以开始说话
ServerTranscript = "transcript" // ASR 转写结果(部分/最终)
ServerTask = "task" // 任务已提交(带 task_id
ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至)
ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕
ServerError = "error" // 出错
)
// 音频格式(与火山 ASR/TTS 约定,客户端按此采集/播放)。
const (
AudioSampleRate = 16000 // 上行 ASR16kHz
AudioBits = 16 // 16bit
AudioChannels = 1 // 单声道
)