Files
sundynix-agentix/sundynix-gateway/internal/handler/voice.go
T

253 lines
8.8 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"
"strings"
"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, jname, jpersona := h.resolveJarvis(c.Request.Context(), uid)
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()
// 租户/会话在升级时(还握着 gin.Context)一并抓取,供 WS 读循环里提交任务复用共用关卡。
sess := &voiceSession{
conn: conn, uid: uid, cfg: cfg, h: h,
tenantID: tenantID(c), sessionID: sessionID(c),
jarvisName: jname, jarvisPersona: jpersona,
}
// 语音事件通道(JARVIS 动作/主动播报):会话生命周期内订阅本用户主题。
// navigate → 转发客户端执行;announce → 对话流显示 + TTS 念出来。
unsubEv, everr := h.bus.SubscribeVoiceEvent(uid, sess.onVoiceEvent)
if everr != nil {
log.Printf("[voice] 订阅语音事件失败 uid=%s: %v", uid, everr) // 降级:会话照常,仅无动作/播报
}
sess.send(voice.ServerMsg{Type: voice.ServerReady})
sess.run()
if unsubEv != nil {
_ = unsubEv()
}
sess.stopASR() // 连接结束,收掉在跑的识别会话
sess.stopTTS() // 连带停掉在朗读的下行 TTS
}
// voiceSession 是一次语音会话的外壳:持 WS 连接,跑协议循环。
// 上行 = 音频→ASR→转写→提交任务;下行(token流→攒句→TTS→音频)将在 TTS 步接上。
type voiceSession struct {
conn *websocket.Conn
h *Handler // 复用 preflightCore/launchCore 提交任务
uid string
tenantID string // 升级时抓取(读循环里无 gin.Context
sessionID string
cfg voice.Config
jarvisName string // 用户自定义助手名(空=默认 JARVIS)
jarvisPersona string // 用户为该助手设的语气人设(与主偏好记忆分开)
writeMu sync.Mutex // gorilla WS 不允许并发写:读循环与 ASR 结果 goroutine 都会 send,须串行化
asr *voice.ASRSession
asrCancel context.CancelFunc
ttsMu sync.Mutex // 护住当前下行 TTS 会话指针(打断/收尾从别的 goroutine 访问)
tts *voice.TTSSession
ttsCancel context.CancelFunc
pendingGraph string // 客户端 start 时带的画布编排图(语音触发既有编排),空则按转写现组
turnMu sync.Mutex // 护住一轮的转写累计 + 提交去重(ASR 结果 goroutine 与 ClientEnd 兜底 goroutine 都访问)
latestText string // 本轮最近一次转写(部分/最终);ClientEnd 时兜底用它提交
submitted bool // 本轮是否已提交——Final 与 ClientEnd 两条路径只落一次
annMu sync.Mutex // 护住主动播报队列(NATS 事件 goroutine 与 TTS 收尾 goroutine 都访问)
pendingAnn []string // 正在朗读时到达的播报先排队,本轮 TTS 播完再念(别抢麦)
}
// 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.pendingGraph = m.Graph // 客户端画布图(可空):本轮若有转写则语音触发它跑
s.turnMu.Lock()
s.latestText, s.submitted = "", false // 新一轮:清累计与提交标记
s.turnMu.Unlock()
s.stopASR()
s.startASR() // 新一轮:重开识别
case voice.ClientEnd:
if s.asr != nil {
_ = s.asr.Finish() // 告知火山本轮说完
}
// 火山流式 ASR 只在 VAD 静音时才发 Final;客户端显式 end(点停)时不能干等——
// 给一小段收尾时间让末尾部分结果到齐,再用"最新转写"兜底提交(trySubmit 去重,Final 先到就它先提交)。
// 500ms 是"够接住末尾 partial"与"别拖慢首字出声"的折中(此前 1.2s 白白吃掉一秒多时延)。
go func() {
time.Sleep(500 * time.Millisecond)
s.turnMu.Lock()
txt := s.latestText
s.turnMu.Unlock()
s.trySubmit(txt)
}()
case voice.ClientBargeIn:
s.stopTTS() // 打断:用户又开口,立刻掐掉正在朗读的 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})
if t := strings.TrimSpace(r.Text); t != "" {
s.turnMu.Lock()
s.latestText = r.Text // 累计最新转写,供 ClientEnd 兜底提交
s.turnMu.Unlock()
}
if r.Final {
s.trySubmit(r.Text) // VAD 检出句末 → 直接提交(与 ClientEnd 兜底二选一,去重)
}
}
}()
}
// trySubmit 本轮提交一次任务:Final 与 ClientEnd 兜底两条路径抢先,submitted 保证只落一次。
func (s *voiceSession) trySubmit(text string) {
txt := strings.TrimSpace(text)
s.turnMu.Lock()
if txt == "" || s.submitted {
s.turnMu.Unlock()
return
}
s.submitted = true
s.turnMu.Unlock()
taskID, err := s.submitVoiceTask(txt, s.pendingGraph)
if err != nil {
s.send(voice.ServerMsg{Type: voice.ServerError, Msg: "任务提交失败:" + err.Error()})
return
}
s.pendingGraph = "" // 画布图一次性消费,避免后续转写重复触发同图
s.send(voice.ServerMsg{Type: voice.ServerTask, TaskID: taskID})
// 下行:订阅该任务 token 流 → 攒句 → TTS → 音频帧回推。独立 goroutine 跑,不堵 ASR 结果流。
go s.speak(taskID)
}
// stopASR 收掉当前识别会话(幂等)。
func (s *voiceSession) stopASR() {
if s.asr != nil {
s.asr.Close()
s.asr = nil
}
if s.asrCancel != nil {
s.asrCancel()
s.asrCancel = nil
}
}