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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user