package loop // 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、 // 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。 // 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与 // 预算口径,而这两样只有循环持有。 import ( "context" "errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) // VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。 // 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」, // 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。 type VoiceSynth interface { // TTSWithProvider 合成文本并返回音频与**实际**提供者标识 // (降级后是备用的那个),供日志与可观测性使用。 TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) } // VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、 // 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用 // documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会 // import 循环,反向依赖构成测试期的 import cycle。 type VoiceRef struct { // Name 是服务端存储名(客户端凭它取回音频)。 Name string // MIME 是存储层字节嗅探所得,不是合成侧的声明。 MIME string // Size 是该副本的字节数。 Size int64 } // VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档 // 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经 // VoiceStoreFunc 适配即可接上(见该类型的用法说明)。 type VoiceStore interface { // SaveAudio 落盘一份音频副本并返回其交付描述。 SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) } // VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。 // 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents: // // loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) { // ref, err := docs.SaveAudio(ctx, name, data) // return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err // }) type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error) // SaveAudio 实现 VoiceStore。 func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) { return f(ctx, origName, data) } // voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 —— // 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告, // 让模型面与接线前逐字节一致。 func (l *Loop) voiceEnabled() bool { return l != nil && l.voiceSynth != nil && l.voiceStore != nil } // withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas // 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与 // 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里, // 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由 // 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下 // Policy.Allow 以「未知工具」拒绝,fail-closed。 func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec { spec := l.speakSpec() if spec == nil { return schemas } for i := range schemas { if schemas[i].Name == spec.Name { return schemas } } out := make([]model.ToolSpec, 0, len(schemas)+1) out = append(out, schemas...) return append(out, *spec) } // applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。 // 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」 // 发生在哪一批都算数。 // // 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的 // 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次 // 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮 // 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回 // 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖: // 后到的成功就是这条消息最终的语音。 func applyBatchVoice(res *Result, batch *toolRunResult) { if batch.voice == nil { return } if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" { return } res.Voice = batch.voice } // captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil // 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯 // 文本呈现 —— 与没有 speak 之前逐字节一致。 // // 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘, // 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后 // 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用 // 文本」。 func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice { var lastOK, lastFail *OutboundVoice n := len(calls) if len(outcomes) < n { n = len(outcomes) } for i := 0; i < n; i++ { if calls[i].Name != speakToolName || outcomes[i].Skipped { continue } if outcomes[i].Err == nil { lastOK = synthesizedVoice(outcomes[i].Result) continue } lastFail = degradedVoice(outcomes[i].Err) } if lastOK != nil { return lastOK } return lastFail } // synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。 // 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时 // 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。 func synthesizedVoice(result any) *OutboundVoice { m, ok := result.(map[string]any) if !ok { return nil } v := &OutboundVoice{Requested: true} v.Name, _ = m["name"].(string) v.Mime, _ = m["mime"].(string) v.Size, _ = m["size"].(int64) v.DurationMs, _ = m["durationMs"].(int64) v.CostMs, _ = m["costMs"].(int) v.BudgetMs, _ = m["budgetMs"].(int) return v } // degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码 // 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化 // 失败一律归 tts_failed,绝不把空原因码交给客户端。 func degradedVoice(err error) *OutboundVoice { v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed} var se *speakError if !errors.As(err, &se) { return v } if se.Code != "" { v.FallbackReason = se.Code } v.BudgetMs = se.BudgetMs v.CostMs = se.CostMs return v }