package channelbridge // 本文件承载**出站语音**(design/25 §7.1;切片 M58):把循环结算出的 // `loop.OutboundVoice`(M57 的 speak 工具结论)折成两件东西 —— // // 1. 一条 `voice` 列事实(`voiceref.Ref`,source=tts):成功即 // `{source:"tts",status:"transcribed",name,mime,size,durationMs}`,降级即 // `{source:"tts",status:"failed",fallbackReason}`。它由 deliverReply 交给与入站 // **同一条**「先写列、再广播」的落库口径(persistVoiceFirstNotify),因此出站语音 // 的**第一帧**就带着 `voice`(M44 首帧纪律,入站已有先例)。 // 2. 一份出站 `adapter.Media`:把盘上那份副本读回字节挂到第一个气泡上,渠道才真的 // 发得出去音频(bubbles.go 的 outboundVoiceBubble)。 // // 三条口径: // - **绝不写半截**:副本三字段(name/mime/size)不齐就不写 `transcribed`,宁可降级成 // `failed` + 机器可读原因 —— 一个没有 mime 的 name 会让客户端拼出读不懂的对象。 // - **拿不到字节就不挂音频**:附件存储未接线、或副本读不出来时,正文照发文本、只留一条 // WARN。这与入站「没有字节就不落副本」是同一条诚实纪律的反方向。 // - **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志。 // // ⚠️ 出站 Media 的 URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权(gateway 的 // writeAuth),适配器没有、也不该有 owner 令牌,因此它只能拿**字节**,不能自取 URL。 import ( "os" "path/filepath" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // outboundVoiceFallbackCopyIncomplete 是本模块自己产生的降级码(speak 工具不会给出它): // 合成报告了成功,但副本事实不完整(缺 mime/size,或 name 不是合法的内容寻址名)。 // 这是上游缺陷、不该发生,因此记 ERROR;但写进列里的原因仍要机器可读 —— 客户端据此 // 与真实降级一样只挂小标,而不是去点一个播不出来的播放器。 const outboundVoiceFallbackCopyIncomplete = "copy_incomplete" // resultVoice 取出这一回合的出站语音结论;回合报错(res 为 nil)时没有结论。 func resultVoice(res *loop.Result) *loop.OutboundVoice { if res == nil { return nil } return res.Voice } // outboundVoiceRef 把出站语音结论折成一条 `voice` 列事实。第二个返回值报告「这一回合 // 要不要写 voice 列」—— false 表示这次回复就是纯文本,落库路径与引入本文件之前逐字相同。 // // 三种结局(与 loop.OutboundVoice 的注释一一对应): // - 未请求(`v == nil` 或 `!v.Requested`)⇒ 不写列,返回 false; // - 合成成功且有完整副本 ⇒ `transcribed` + 副本事实; // - 请求了但没有音频 ⇒ `failed` + 降级原因(来自 speak 工具,或本模块的 copy_incomplete)。 // // 「Requested 却既无副本又无原因」是上游契约被破坏的状态:不写列(写一个没有原因的 // failed 等于把「为什么」丢掉),但必须 WARN —— 静默吞掉会让这条语音彻底看不见。 func (b *Bridge) outboundVoiceRef(v *loop.OutboundVoice) (voiceref.Ref, bool) { if v == nil || !v.Requested { return voiceref.Ref{}, false } log := b.voiceLogger() if v.Name != "" { if v.Mime == "" || v.Size <= 0 || !voiceref.ValidMediaName(v.Name) { log.Error("outbound voice copy is incomplete, degraded to text", "name", v.Name, "mime", v.Mime, "size", v.Size, "duration_ms", v.DurationMs) return voiceref.Ref{ Source: voiceref.SourceTTS, Status: voiceref.StatusFailed, FallbackReason: outboundVoiceFallbackCopyIncomplete, }, true } ref := voiceref.Ref{ Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed, Name: v.Name, MIME: v.Mime, Size: v.Size, } // 时长只取上游如实给出且非负的值:Ref.Validate 拒绝负数,绝不为了「有个数」 // 而估算(与入站同一口径)。 if v.DurationMs > 0 { ref.DurationMs = v.DurationMs } return ref, true } if v.FallbackReason != "" { return voiceref.Ref{ Source: voiceref.SourceTTS, Status: voiceref.StatusFailed, FallbackReason: v.FallbackReason, }, true } log.Warn("outbound voice requested without copy or fallback reason, treated as text", "cost_ms", v.CostMs, "budget_ms", v.BudgetMs) return voiceref.Ref{}, false } // outboundVoiceMedia 把盘上那份副本读回字节,折成一个出站媒体项(design/25 §7.1)。 // 第二个返回值报告「挂不挂得上」:false 时调用方照发纯文本,绝不发一个空音频。 // // 目录与上限来自组合根注入的文档存储配置(与入站附件同一份 `documentsCfg`):副本就是 // `documents.SaveAudio` 落下的内容寻址文件,因此 `Dir()` + 存储名就是它的路径 —— 不需要 // (也没有)一条按名读回的存储 API。名字再校验一次是纵深防御:`filepath.Join` 前面绝不 // 允许出现未经验证的渠道/上游字符串。 func (b *Bridge) outboundVoiceMedia(msg adapter.UnifiedMessage, ref voiceref.Ref) (adapter.Media, bool) { if !ref.HasCopy() { return adapter.Media{}, false } log := b.voiceLogger() if !voiceref.ValidMediaName(ref.Name) { log.Error("outbound voice media skipped: illegal copy name", "name", ref.Name) return adapter.Media{}, false } store := b.inboundDocumentStore(msg) if store == nil || store.Dir() == "" { log.Warn("outbound voice media skipped: document store not wired", "platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name) return adapter.Media{}, false } data, err := os.ReadFile(filepath.Join(store.Dir(), ref.Name)) if err != nil { log.Warn("outbound voice media skipped: copy unreadable", "platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name, "error", err) return adapter.Media{}, false } if int64(len(data)) != ref.Size { // 列里的 size 与实际字节不一致:以实际字节为准(发出去的就是这些),但要说出来。 log.Warn("outbound voice copy size differs from column", "name", ref.Name, "column_size", ref.Size, "actual_size", len(data)) } return adapter.Media{ Kind: adapter.MediaVoice, Name: ref.Name, Size: int64(len(data)), MIMEType: ref.MIME, Data: data, }, true }