253 lines
8.8 KiB
Go
253 lines
8.8 KiB
Go
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。鉴权走 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, 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
|
||
}
|
||
}
|