package gateway import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件归属协同项 C1 的「语音回合收尾」:把一次语音回合的结果交回语音层, // 由它合成音频并**定向**回发起这条语音的连接。 // // 语音回合现在与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步), // 因此音频回复不能还在提交那一刻同步等待 —— 回合是异步完成的(可能排队、 // 可能被中断)。编排器唯一知道「这个回合真正产出了什么」的地方是回合结算处 // (completeTurn,与 S3.5 回写文档结论同一收尾点),所以收尾回调在那里触发。 // // 与 session_reject.go 同一纪律:编排器对传输层一无所知,「怎么把帧送回那根 // 连接」由 WS 边界在 ctx 上给出。 // VoiceReplyFunc 是语音回合的收尾回调:回合结算后,编排器把该回合的结果交给 // 它(res 可能为 nil —— 被取消或出错的回合没有结果)。 type VoiceReplyFunc func(res *loop.Result) // voiceReplyKey 是携带语音收尾回调的 context 键。它从 WS 帧边界 // (voice.go 的 runVoiceTurn)引入编排器,与 deviceID / 幂等键同一种机制。 type voiceReplyKey struct{} // withVoiceReplySink 返回携带语音收尾回调的 context。nil 回调是空操作:文本 // 回合(以及未接线传输层的提交)在 ctx 上不留任何痕迹。 func withVoiceReplySink(ctx context.Context, sink VoiceReplyFunc) context.Context { if sink == nil { return ctx } return context.WithValue(ctx, voiceReplyKey{}, sink) } // voiceReplySinkFrom 返回 ctx 携带的语音收尾回调,未设置时为 nil。 func voiceReplySinkFrom(ctx context.Context) VoiceReplyFunc { sink, _ := ctx.Value(voiceReplyKey{}).(VoiceReplyFunc) return sink } // voiceTurn 配对一条**路**与它的语音收尾回调(按世代号配对,与 turnDocuments // 同理):被中断 / 已失效的路在结算时不该替别人回音频。 // // sinks 是切片,因为一个回合可能同时有**多条**语音请求在等音频:忙时入 pending // 的语音消息可能被转向注入(design/15 §2.4,见 session_steer.go 的 consumePending) // 而由同一个回合作答 —— 它的音频必须由这个回合结算时一并送回,否则那条请求会 // 静默地收不到声音(音频回复不能丢)。 type voiceTurn struct { gen uint64 sinks []VoiceReplyFunc } // voiceSinksOf 把单个回调收成切片(nil 回调 → nil 切片)。 func voiceSinksOf(sink VoiceReplyFunc) []VoiceReplyFunc { if sink == nil { return nil } return []VoiceReplyFunc{sink} } // rememberVoiceTurn 记下**本路**的语音收尾回调。**每条新路都必须调用它一次** // (纯文本回合记 nil):否则一个陈旧的语音回调会被沿用到后面的文本回合上, // 让一次文本问答无端合成一段音频。 // // 它按路配对(design/15 §2.5.9):并行时两路各自持有自己的回调,一路结算 // 绝不会替另一路回音频。路已失效(被打断清空)时什么都不做。 func (o *Orchestrator) rememberVoiceTurn(sessionID string, gen uint64, sink VoiceReplyFunc) { o.mu.Lock() defer o.mu.Unlock() if leg := o.legLocked(o.states[sessionID], gen); leg != nil { leg.voice = voiceTurn{gen: gen, sinks: voiceSinksOf(sink)} } } // addVoiceSinkLocked 把一条**被转向注入**的语音请求的收尾回调追加到该路(gen // 所指的那一条)上。必须在 o.mu 下调用;该路已失效时什么都不做。 func (o *Orchestrator) addVoiceSinkLocked(st *sessionState, gen uint64, sink VoiceReplyFunc) { if sink == nil { return } leg := o.legLocked(st, gen) if leg == nil { return } leg.voice.sinks = append(leg.voice.sinks, sink) } // takeVoiceTurn 取走**本路**的全部语音收尾回调并清空记录。第二个返回值为 false // 表示这条路已失效(被中断清空 / 被取代):迟到的结算不该回音频。 func (o *Orchestrator) takeVoiceTurn(sessionID string, gen uint64) ([]VoiceReplyFunc, bool) { o.mu.Lock() defer o.mu.Unlock() leg := o.legLocked(o.states[sessionID], gen) if leg == nil { return nil, false } sinks := leg.voice.sinks leg.voice = voiceTurn{} return sinks, true } // dispatchVoiceReply 在回合结算时把结果交回语音层(协同 C1)。不是语音回合时 // 是空操作;被取代的陈旧回合同样什么都不做。 // // 每个回调在自己的 goroutine 上跑:TTS 是网络调用,绝不许它拖住编排器的状态迁移 // (助手回复此刻已经落库,音频是它之后的增强;pending 续跑与 session_idle // 不该等一段语音合成)。 func (o *Orchestrator) dispatchVoiceReply(sessionID string, gen uint64, res *loop.Result) { sinks, ok := o.takeVoiceTurn(sessionID, gen) if !ok { return } for _, sink := range sinks { go sink(res) } }