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. 发事件,本会话订阅并落地—— // 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() // 队列里还有播报就接着念 }