Files
Cyrene_v2/internal/services/channelbridge/outbound_voice.go
T
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00

135 lines
6.5 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 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/<name>` 走 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
}