feat(voice): 语音消息落库 + 转码接入(切片 0020)

- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
AskaEth committed 2026-10-06 09:04:06 +08:00
1 parent 120d926cf8
commit ede00d9928
17 files changed
+2125 -31

No files matched your search

@@ -12,9 +12,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
// 语音**副本**的领域事实(来源 / 三态 / 副本引用):与上一行的 STT 链同名不同事,
// 因此这里必须显式起别名(design/24 §10)。
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1),
// 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步):
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
@@ -29,6 +33,9 @@ import (
// `voice.STTOutcome.Transcribed()`。
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
//
// 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后):
// 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。
// VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。
//
@@ -50,7 +57,7 @@ const (
voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]"
voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]"
voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]"
voiceNoticeFormat = "[语音消息:这条语音的音频格式不支持转写(需要先转码,当前未做)。]"
voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]"
)
// 原因短语(用户可见,绝不包含上游错误原文/密钥)。
@@ -65,47 +72,94 @@ const (
voiceReasonFailed = "云端语音识别失败"
)
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) {
// voiceTurn 是一条入站语音消息的**归一结论**(design/24 §10.2 第 2 步的产物)。它把
// 「这条消息来自一条语音 + 正文是怎么来的」这件事从归一步带到**闸门之后**的落副本步
// (inbound_voice_copy.go),中间不读任何全局状态 —— 因此并发入站的多条语音互不串味。
//
// 它同时是「模型侧标记」的来源事实:`Ref.Source` / `Ref.Status` 就是 design/24 §10.4
// 的两个闭集字段(渲染成独立 system 行的那一步在编排侧,本包不渲染提示词)。
type voiceTurn struct {
// Found 报告这条消息携带语音媒体(不论有没有字节)。false ⇒ 后面全部空操作。
Found bool
// Media 是被归一的那**一条**语音(`inboundVoiceTarget` 的产出:优先带字节的第一条)。
// 设计刻意只处理一条(design/24 §10.1 N7:同一条消息里的第 2..N 条语音既不转写也
// 不落盘),因此这里只有一条。
Media adapter.Media
// Format 是这条媒体的音频格式名(可转写门归一所得;空 = 认不出)。
Format string
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。
DurationMs int64
// Ref 是来源与转写三态的结论;副本字段(name/mime/size)由闸门之后的落盘步回填。
Ref voiceref.Ref
}
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`,同时返回这条
// 语音的来源与三态结论(`voiceTurn`)。没有语音媒体时是空操作(零值结论);任何走完本
// 函数的语音消息都会留下文本或一行诚实提示,且**正文与结论永远同源**(不会出现「正文是
// 转写文本、结论说没转写」这种自相矛盾)。
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) voiceTurn {
target, found := inboundVoiceTarget(*msg)
if !found {
return
return voiceTurn{}
}
turn := voiceTurn{Found: true, Media: target, DurationMs: channelVoiceDurationMs(target)}
if format, ok := mediafetch.AudioFormatOf(target); ok {
turn.Format = format
}
if mediafetch.ChatText(msg.Content) != "" {
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
// 结论照 design/24 §10.3 的已登记口径「status=transcribed(真转写或平台自带)」:
// 正文确是一段由渠道送达的文字,来源记 platform。
turn.Ref = voiceref.Ref{Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed}
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
return
if len(target.Data) > 0 {
// ⚠️ 设计未覆盖的情形(留痕,别当已定案):正文与**内联字节**同时存在 ——
// 平台自带转写的真实形态是「有文字但没字节」(微信按既有裁决不取字节),
// 因此这里更可能是「附了配文的语音」(例如 Telegram 的 audio + caption)。
// 服务端**无法**从结构上区分二者(需要适配器声明),故按已登记口径记 source,
// 但把这条不确定性留在日志里:设计上它是一个待裁决项,绝不静默。
b.voiceLogger().Warn("inbound voice has both chat text and inlined bytes; source recorded as platform",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
}
return turn
}
audio, ok := voiceAudioOf(target)
if !ok {
msg.Content = voiceNoticeFormat
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
b.voiceLogger().Warn("inbound voice not transcribed: audio format not transcribable",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
return
return turn
}
if len(audio.Data) == 0 {
msg.Content = voiceNoticeNoAudio
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
b.voiceLogger().Warn("inbound voice not transcribed: no audio bytes",
"platform", msg.Platform, "channel", msg.ChannelID,
"format", audio.Format, "mime", target.MIMEType, "size", target.Size)
return
return turn
}
b.transcribeInboundVoice(ctx, msg, audio)
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)}
return turn
}
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) {
// 其余一律=诚实提示),返回这次转写的**三态结论**(design/24 §10.4 的闭集):真转写
// ⇒ transcribed、服务正常的空结果 ⇒ empty、没拿到文本 ⇒ failed。熔断打开的短路路径
// 也在这里:**不再发起上游调用**,且结论同样是 failed(正文是那行熔断提示)。
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) voiceref.Status {
now := b.now()
if !b.voiceCircuit.allowed(now) {
left := b.voiceCircuit.retryAfter(now)
msg.Content = fmt.Sprintf(voiceNoticeCircuit, left.Truncate(time.Second))
b.voiceLogger().Warn("inbound voice not transcribed: circuit open",
"platform", msg.Platform, "channel", msg.ChannelID, "retry_after", left.String())
return
return voiceref.StatusFailed
}
out, ctxErr := b.callVoiceSTT(ctx, audio)
if out.Transcribed() {
@@ -116,12 +170,12 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
b.voiceLogger().Info("inbound voice transcribed",
"platform", msg.Platform, "channel", msg.ChannelID,
"provider", out.Provider, "chars", utf8.RuneCountInString(text))
return
return voiceref.StatusTranscribed
}
msg.Content = voiceNoticeEmpty
b.voiceLogger().Info("inbound voice transcribed to empty text",
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
return
return voiceref.StatusEmpty
}
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
@@ -133,8 +187,17 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
b.voiceLogger().Warn("inbound voice not transcribed",
"platform", msg.Platform, "channel", msg.ChannelID,
"provider", out.Provider, "category", category, "circuit", !b.voiceCircuit.allowed(now))
return voiceref.StatusFailed
}
// channelVoiceDurationMs 返回渠道**如实给出**的语音时长(毫秒),拿不到就是 0。
//
// ⚠️ 接线状态(诚实登记):适配器侧的字段(design/24 §10.9-6 的 `adapter.Media.DurationMs`)
// **尚未落地**,因此今天恒定返回 0 —— 也就是「时长未知」,与「渠道没给」的落库/DTO 形状
// 逐字相同(键缺席,绝不估)。它留在这里是为了让那一步只有**一处**要改(design/24 §10.4
// 的「渠道如实给出时才 >0」)。
func channelVoiceDurationMs(_ adapter.Media) int64 { return 0 }
// callVoiceSTT 调一次注入的 STT,并为这次调用套上**独立短超时**(父 ctx 取消同样立即
// 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。
//