Files
Cyrene_v2/internal/services/gateway/session_voice.go
T
AskaEth 3a01b4e236
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 语音回合进编排+落库、G5 队列满带回身份、voice_output 归属(安卓 G5/C1/C3)
- G5:pending_full 增 clientMsgId/messageId(仅在可给出时出现、绝不新铸 id —— 队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判重复而永远进不来);另定向回发送者 {type:error,data:{code:chat_busy,...}}(与 REST 409 chat_busy 同码),广播帧照旧带同样的键供其它端对齐。

- C1:语音回合改走与文本同一条路径(编排器 Submit → 落库 → 流式 → 跨端同步),deviceId 走既有 withDeviceID、platform 走存储默认 web;未接编排器时保留旧直连路径。音频在回合结算处(persistAssistantReply → persistDocumentStatus → dispatchVoiceReply)合成并定向回原连接,回调各自 goroutine(TTS 延迟不拖住 pending_drain/session_idle);续跑 FIFO 与转向注入两条路径都带走回调 ⇒ 忙时排队/被注入的语音也不丢音频。

- C3:voice_output 增 sessionId/deviceId;三处主动加码(按最优方向):① voice_input 增可选 clientMsgId(语音也有幂等)② 不再拿 stopReason 顶替空正文当语音内容(空正文走 provider 降级信号)③ TTS 失败照旧回帧 + provider:silent(不静默)。

- 拆 chat_documents_test.go(510 行越界)→ 373 + 160,只搬不改(拆前后同 -run 过滤 PASS 集合逐条一致)。

- 主会话独立验收:隔离 worktree(HEAD + 本批次 11 文件)build/vet 绿、gateway -race 绿、全树 59 包(唯一红是既有负载 flake orchestration/TestSpawnTimeoutFromToolDefault)、linux+darwin 交叉编译绿;变异独立复现(pending_full 摘掉 clientMsgId ⇒ 3 条红)。

- 待办(已登记):Docs/api-client.md 与 api-reference.md 的帧/字段登记随 v0.59 一并落笔(本批次刻意未动 Docs,避免与契约批次打架)。
2026-10-04 17:59:28 +08:00

112 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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):否则一个陈旧的语音回调会被沿用到后面的文本回合上,
// 让一次文本问答无端合成一段音频。
func (o *Orchestrator) rememberVoiceTurn(sessionID string, gen uint64, sink VoiceReplyFunc) {
o.mu.Lock()
defer o.mu.Unlock()
if st := o.states[sessionID]; st != nil {
st.voiceTurn = voiceTurn{gen: gen, sinks: voiceSinksOf(sink)}
}
}
// addVoiceSinkLocked 把一条**被转向注入**的语音请求的收尾回调追加到当前回合上。
// 必须在 o.mu 下调用;代次不符(那条请求不属于本回合)时什么都不做。
func (o *Orchestrator) addVoiceSinkLocked(st *sessionState, gen uint64, sink VoiceReplyFunc) {
if st == nil || sink == nil || st.voiceTurn.gen != gen {
return
}
st.voiceTurn.sinks = append(st.voiceTurn.sinks, sink)
}
// takeVoiceTurn 取走与 gen 配对的所有语音收尾回调并清空记录。第二个返回值为
// false 表示代次已陈旧(中断/取代):迟到的结算不该回音频。
func (o *Orchestrator) takeVoiceTurn(sessionID string, gen uint64) ([]VoiceReplyFunc, bool) {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil || st.voiceTurn.gen != gen {
return nil, false
}
sinks := st.voiceTurn.sinks
st.voiceTurn = 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)
}
}