- 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 测试(含变异打红验证时序)
135 lines
6.5 KiB
Go
135 lines
6.5 KiB
Go
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
|
||
}
|