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

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

303 lines
16 KiB
Go
Raw Permalink 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
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"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),
// 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步):
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
//
// 三条口径(缺一不可):
//
// 1. **平台自带转写优先**:微信 iLink 的 `voice_item.text` 已经在 `Content` 里
// (真机已验证)⇒ **不调 ASR**(不重复付费)。判据用 `mediafetch.ChatText`,
// 因此 QQ 的 `[语音]` 占位标记不会被误当成「已有正文」。
// 2. **静默降级必须被识破**:语音链末端是**总是成功**的静默档位(空文本 + nil 错),
// 只看文本会把「没有可用的识别后端」当成一次成功的空转写 ⇒ 只认
// `voice.STTOutcome.Transcribed()`。
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
//
// 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后):
// 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。
// VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。
//
// 它刻意要 `STTOutcome` 而不是 `STTWithProvider`:后者在静默档位处返回「空文本 + nil
// 错」,「真转写 / 静默降级」无法区分,而**按原因分类的临时熔断**(未配置 / 鉴权失败 /
// 欠费 / 限流)必须能看见被静默掩盖的真实原因(切片 IM-ASR 的硬要求)。
type VoiceSTT interface {
STTOutcome(ctx context.Context, audio voice.Audio) voice.STTOutcome
}
// defaultVoiceSTTTimeout 是一次 IM 入站语音转写的默认超时。它刻意很短:入站流水线的
// 并发槽(`maxConcurrent`)要为这次调用一直举着,上游卡住不能拖死整条流水线。
const defaultVoiceSTTTimeout = 20 * time.Second
// 诚实提示文案:每一条都讲清「没转写成功」与**原因方向**,绝不假装、也绝不静默。
// 参考 `%s` = 原因短语(见下面的 voiceReason* 常量)。
const (
voiceNoticeFailed = "[语音消息:这条语音没有转写(原因方向:%s)。你可以把内容用文字发一遍。]"
voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]"
voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]"
voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]"
voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]"
)
// 原因短语(用户可见,绝不包含上游错误原文/密钥)。
const (
voiceReasonNotConfigured = "服务端未配置云端语音识别"
voiceReasonAuth = "云端语音识别鉴权失败(API key 被拒)"
voiceReasonQuota = "云端语音识别账户欠费或额度耗尽"
voiceReasonRate = "云端语音识别限流"
voiceReasonTimeout = "云端语音识别超时"
voiceReasonCanceled = "这次转写被取消(渠道断开或回合结束)"
voiceReasonSilent = "语音链路静默降级(没有可用的识别后端)"
voiceReasonFailed = "云端语音识别失败"
)
// 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 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)
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 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 turn
}
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)}
return turn
}
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
// 其余一律=诚实提示),返回这次转写的**三态结论**(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 voiceref.StatusFailed
}
out, ctxErr := b.callVoiceSTT(ctx, audio)
if out.Transcribed() {
// 真转写(文本可能为空:音频里没人说话,那是服务正常的空结果,不是故障)。
b.voiceCircuit.succeed()
if text := strings.TrimSpace(out.Text); text != "" {
msg.Content = text
b.voiceLogger().Info("inbound voice transcribed",
"platform", msg.Platform, "channel", msg.ChannelID,
"provider", out.Provider, "chars", utf8.RuneCountInString(text))
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 voiceref.StatusEmpty
}
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
if voice.CircuitWorthy(out.Cause) {
b.voiceCircuit.open(now)
}
reason, category := voiceFailureReason(out, ctxErr)
msg.Content = fmt.Sprintf(voiceNoticeFailed, reason)
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 取消同样立即
// 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。
//
// 它**不把上游错误原文交给调用方去展示**:错误只用于分类(`voiceFailureReason`),
// 用户可见的正文里永远只有原因方向,绝无上游回显的请求细节。
func (b *Bridge) callVoiceSTT(ctx context.Context, audio voice.Audio) (voice.STTOutcome, error) {
if b.voiceSTT == nil {
return voice.STTOutcome{Cause: voice.ErrNotConfigured}, nil
}
callCtx, cancel := context.WithTimeout(ctx, b.voiceSTTTimeout)
defer cancel()
out := b.voiceSTT.STTOutcome(callCtx, audio)
return out, callCtx.Err()
}
// voiceFailureReason 把「没转写成功」翻成一句原因方向 + 一个日志类别。判据只认哨兵链
// 与超时,**绝不匹配错误文本**;返回的原因短语里不含任何上游错误原文(可能回显请求细节)。
func voiceFailureReason(out voice.STTOutcome, ctxErr error) (string, string) {
switch {
case errors.Is(ctxErr, context.DeadlineExceeded):
return voiceReasonTimeout, "timeout"
case errors.Is(ctxErr, context.Canceled):
return voiceReasonCanceled, "canceled"
case errors.Is(out.Cause, voice.ErrAuthFailed):
return voiceReasonAuth, "auth_failed"
case errors.Is(out.Cause, voice.ErrQuotaExceeded):
return voiceReasonQuota, "quota_exceeded"
case errors.Is(out.Cause, voice.ErrRateLimited):
return voiceReasonRate, "rate_limited"
case errors.Is(out.Cause, voice.ErrNotConfigured):
return voiceReasonNotConfigured, "not_configured"
case out.Provider == voice.SilentProviderName:
return voiceReasonSilent, "silent_degraded"
default:
return voiceReasonFailed, "provider_failed"
}
}
// inboundVoiceTarget 选出这条消息要转写的那一条语音:优先**带字节**的第一条,没有
// 任何一条带字节时退回第一条(好让调用方按它的格式与引用说清「为什么没读到」)。
func inboundVoiceTarget(msg adapter.UnifiedMessage) (adapter.Media, bool) {
var first adapter.Media
found := false
for _, m := range msg.Media {
if !mediafetch.IsVoiceMedia(&msg, m) {
continue
}
if !found {
first, found = m, true
}
if len(m.Data) > 0 {
return m, true
}
}
return first, found
}
// voiceAudioOf 把一条语音媒体转成 STT 的输入。第二个返回值为 false 表示**格式不在
// 可转写白名单内**(与字节内联同一份判据 `mediafetch.AudioFormatOf`)——此时不调上游。
func voiceAudioOf(m adapter.Media) (voice.Audio, bool) {
format, ok := mediafetch.AudioFormatOf(m)
if !ok {
return voice.Audio{}, false
}
return voice.Audio{Format: format, Data: m.Data}, true
}
// hasVoiceMedia 报告消息是否携带语音类媒体(不论有没有字节)——它让「纯语音消息」
// 不再被判成「没有内容」而丢弃(与 `hasFileMedia` 同一条纪律)。
func hasVoiceMedia(msg adapter.UnifiedMessage) bool {
for _, m := range msg.Media {
if mediafetch.IsVoiceMedia(&msg, m) {
return true
}
}
return false
}
// hasUsableMedia 报告消息是否携带**任何**可用媒体(文件、图片或语音):入站丢空判据
// 据此放行「纯媒体消息」——纯文件、纯图片与纯语音都是它们自己的内容,绝不许静默丢
// (design/24 §4.4、design/06 §1.1、design/08 §4.1 的同一条纪律)。
func hasUsableMedia(msg adapter.UnifiedMessage) bool {
return hasFileMedia(msg) || hasImageMedia(msg) || hasVoiceMedia(msg)
}
// voiceSTTTimeout 把配置里的超时归一成有效值:≤0 时取 defaultVoiceSTTTimeout。归一放
// 在这里而不是调用方,使「0 表示默认」这条口径只有一份(切片 IM-ASR)。
func voiceSTTTimeout(configured time.Duration) time.Duration {
if configured <= 0 {
return defaultVoiceSTTTimeout
}
return configured
}
// voiceLogger 返回带 `services/channelbridge/voice` 来源的子功能日志器(AGENTS §10:
// 子功能日志必须可追溯;未装配时退回桥接器日志,绝不 panic)。
func (b *Bridge) voiceLogger() *slog.Logger {
if b.voiceLog != nil {
return b.voiceLog
}
return b.log
}