120 lines
3.5 KiB
Go
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() // 队列里还有播报就接着念
|
|
}
|