采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染; 复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。 消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content; inbound.go 的丢空判据放宽为「无文本且无可用媒体」。 微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。 降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位; errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复); 无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。 独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。 kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。 设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。 变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。 诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz; 官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
240 lines
11 KiB
Go
240 lines
11 KiB
Go
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"
|
||
)
|
||
|
||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
|
||
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
|
||
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
|
||
//
|
||
// 三条口径(缺一不可):
|
||
//
|
||
// 1. **平台自带转写优先**:微信 iLink 的 `voice_item.text` 已经在 `Content` 里
|
||
// (真机已验证)⇒ **不调 ASR**(不重复付费)。判据用 `mediafetch.ChatText`,
|
||
// 因此 QQ 的 `[语音]` 占位标记不会被误当成「已有正文」。
|
||
// 2. **静默降级必须被识破**:语音链末端是**总是成功**的静默档位(空文本 + nil 错),
|
||
// 只看文本会把「没有可用的识别后端」当成一次成功的空转写 ⇒ 只认
|
||
// `voice.STTOutcome.Transcribed()`。
|
||
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
|
||
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
|
||
|
||
// 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 = "云端语音识别失败"
|
||
)
|
||
|
||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音
|
||
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。
|
||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||
target, found := inboundVoiceTarget(*msg)
|
||
if !found {
|
||
return
|
||
}
|
||
if mediafetch.ChatText(msg.Content) != "" {
|
||
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
|
||
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
|
||
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
|
||
return
|
||
}
|
||
audio, ok := voiceAudioOf(target)
|
||
if !ok {
|
||
msg.Content = voiceNoticeFormat
|
||
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
|
||
}
|
||
if len(audio.Data) == 0 {
|
||
msg.Content = voiceNoticeNoAudio
|
||
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
|
||
}
|
||
b.transcribeInboundVoice(ctx, msg, audio)
|
||
}
|
||
|
||
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
|
||
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。
|
||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) {
|
||
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
|
||
}
|
||
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
|
||
}
|
||
msg.Content = voiceNoticeEmpty
|
||
b.voiceLogger().Info("inbound voice transcribed to empty text",
|
||
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
|
||
return
|
||
}
|
||
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
|
||
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
|
||
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))
|
||
}
|
||
|
||
// 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)。
|
||
func hasUsableMedia(msg adapter.UnifiedMessage) bool {
|
||
return hasFileMedia(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
|
||
}
|