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

120 lines
3.5 KiB
Go

package handler
import (
"context"
"log"
"github.com/sundynix/sundynix-gateway/internal/voice"
"github.com/sundynix/sundynix-shared/contract"
)
// 语音事件下行(JARVIS_BRAIN_DESIGN.md P2 动作通道 + P3 主动播报):
// 服务端任意副本经 NATS sundynix.voice.event.<uid> 发事件,本会话订阅并落地——
// navigate → 转发客户端(界面跳转,客户端按白名单执行)
// announce → 对话流显示 + TTS 念出来;正在朗读则排队,播完再念(别抢麦)
// navigateViews 是允许下发给客户端的视图白名单(与桌面端 ViewKey 对齐)。
// 模型/工具给出未知视图时丢弃——动作通道宁可不动,不执行任意指令。
var navigateViews = map[string]bool{
"home": true, "studio": true, "kb": true, "runs": true, "report": true, "memory": true, "usage": true,
}
// onVoiceEvent 处理一条发给本用户的语音事件(NATS 订阅回调,会话生命周期内挂载)。
func (s *voiceSession) onVoiceEvent(ev *contract.VoiceEvent) {
switch ev.Action {
case "navigate":
if !navigateViews[ev.View] {
log.Printf("[voice] 丢弃未知视图的 navigate uid=%s view=%q", s.uid, ev.View)
return
}
s.send(voice.ServerMsg{Type: voice.ServerAction, Action: "navigate", View: ev.View, TaskID: ev.TaskID})
case "announce":
if ev.Text == "" {
return
}
s.announce(ev.Text)
}
}
// announce 主动播报一段话:正在朗读则入队(speak/上一条播报收尾时 flush),空闲则立刻念。
func (s *voiceSession) announce(text string) {
s.ttsMu.Lock()
busy := s.tts != nil
s.ttsMu.Unlock()
if busy {
s.annMu.Lock()
s.pendingAnn = append(s.pendingAnn, text)
s.annMu.Unlock()
return
}
go s.speakAnnounce(text)
}
// flushAnnounces 取出并播报排队的第一条(TTS 收尾处调用;逐条播,每条播完又会走到这里)。
func (s *voiceSession) flushAnnounces() {
s.annMu.Lock()
if len(s.pendingAnn) == 0 {
s.annMu.Unlock()
return
}
text := s.pendingAnn[0]
s.pendingAnn = s.pendingAnn[1:]
s.annMu.Unlock()
go s.speakAnnounce(text)
}
// speakAnnounce 把一段播报文本念出来:对话流先显示(announce 消息),再走与 speak() 同构的
// TTS 会话(登记到 s.tts → 客户端可打断;首帧 speaking、收尾 tts_end——客户端状态机零新分支)。
// TTS 未配置/启动失败则只显示文字(静默降级,播报不该弄挂会话)。
func (s *voiceSession) speakAnnounce(text string) {
s.send(voice.ServerMsg{Type: voice.ServerAnnounce, Text: text})
if !s.cfg.TTSEnabled() {
return
}
ctx, cancel := context.WithCancel(context.Background())
ts, err := voice.StartTTS(ctx, s.cfg)
if err != nil {
cancel()
log.Printf("[voice] 播报 TTS 启动失败 uid=%s: %v", s.uid, err)
return
}
// 登记为当前 TTS:barge-in/会话收尾都能掐掉它;已有别的在念(竞态)就放弃朗读只留文字。
s.ttsMu.Lock()
if s.tts != nil {
s.ttsMu.Unlock()
cancel()
ts.Close()
return
}
s.tts = ts
s.ttsCancel = cancel
s.ttsMu.Unlock()
_ = ts.Speak(text)
_ = ts.Finish()
first := true
for pcm := range ts.Audio() {
if first {
s.send(voice.ServerMsg{Type: voice.ServerSpeaking})
first = false
}
s.sendAudio(pcm)
}
if e := ts.Err(); e != nil {
log.Printf("[voice] 播报 TTS 出错 uid=%s: %v", s.uid, e)
}
s.send(voice.ServerMsg{Type: voice.ServerTTSEnd})
s.ttsMu.Lock()
if s.tts == ts {
s.tts = nil
s.ttsCancel = nil
}
s.ttsMu.Unlock()
ts.Close()
cancel()
s.flushAnnounces() // 队列里还有播报就接着念
}