diff --git a/sundynix-gateway/go.mod b/sundynix-gateway/go.mod index 571b87c..bc362c9 100644 --- a/sundynix-gateway/go.mod +++ b/sundynix-gateway/go.mod @@ -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 diff --git a/sundynix-gateway/go.sum b/sundynix-gateway/go.sum index c3f29ce..b3c3f0c 100644 --- a/sundynix-gateway/go.sum +++ b/sundynix-gateway/go.sum @@ -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= diff --git a/sundynix-gateway/internal/handler/voice.go b/sundynix-gateway/internal/handler/voice.go new file mode 100644 index 0000000..1f1b115 --- /dev/null +++ b/sundynix-gateway/internal/handler/voice.go @@ -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。鉴权走 AuthFromHeaderOrQuery(EventSource/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 +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 063d6dd..19e5eef 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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()) diff --git a/sundynix-gateway/internal/voice/protocol.go b/sundynix-gateway/internal/voice/protocol.go new file mode 100644 index 0000000..2198ead --- /dev/null +++ b/sundynix-gateway/internal/voice/protocol.go @@ -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"` + // transcript:ASR 转写(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 // 上行 ASR:16kHz + AudioBits = 16 // 16bit + AudioChannels = 1 // 单声道 +)