Files
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面: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),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

71 lines
3.0 KiB
Go

package voice
import "context"
// 本文件承载一次链上 STT 的**完整诚实结果**(切片 IM-ASR)。既有的
// `STTWithProvider` 只报告「谁最后成功了」,而链路末端是**总是成功**的静默档位
// (空文本 + `err=nil`)——真实失败原因因此被吞掉。IM 入站语音要拿它做两件事:
//
// 1. **识破静默降级**:静默档位返回的「成功」不是转写成功,绝不能当成功;
// 2. **按原因分类**:未配置 / 鉴权失败 / 欠费 / 限流才值得**临时熔断**——否则每条
// 语音都会去撞一次付费墙、白等一次超时。
//
// 这里只**新增**报告面:STT/TTS 的既有语义与 `STTWithProvider` 的返回逐字不变,
// 既有调用方(WS 的 voice_input、`/audio/transcribe`)无需改动。
// STTOutcome 是一次链上 STT 的结果。
type STTOutcome struct {
// Text 是转写文本;空串表示这一趟没有拿到文字。
Text string
// Provider 是产出该结果的档位名(cloud / local / edge-tts / espeak /
// SilentProviderName)。空串表示链上没有任何 STT 方向可用。
Provider string
// Cause 是**静默档位掩盖的真实原因**:非 nil 表示这一趟**没有真的转写成功**
// (例如云端 401 / 欠费 / 限流,或整个链路未配置)。nil 且 Provider 不是静默档位
// 才是真转写——此时 Text 仍可能为空(音频里没人说话),那是**服务正常**的空结果。
Cause error
}
// Transcribed 报告这一趟是不是**真转写**(不是静默降级掩盖的成功)。文本本身可能为空
// (静音/噪声),那属于「服务正常但没识别出文字」,与「上游不可用」必须分开。
func (o STTOutcome) Transcribed() bool {
return o.Cause == nil && o.Provider != "" && o.Provider != SilentProviderName
}
// STTOutcome 在链上做一次 STT,并如实报告结果来源与(静默档位掩盖的)真实原因。
// 它与 STTWithProvider 走同一条降级顺序,只是**不把静默档位当成功**。
func (c *Chain) STTOutcome(ctx context.Context, audio Audio) STTOutcome {
if len(c.steps) == 0 {
return STTOutcome{Cause: ErrProviderFailed}
}
var lastErr error
for _, p := range c.steps {
stt := p.STT()
if stt == nil {
continue
}
text, err := stt.STT(ctx, audio)
if err != nil {
lastErr = err
continue
}
out := STTOutcome{Text: text, Provider: p.Name()}
if p.Name() != SilentProviderName {
return out
}
// 静默档位:它「成功」只说明链路没崩,真实原因看它前面那一级。
out.Cause = silenceCause(lastErr)
return out
}
return STTOutcome{Cause: silenceCause(lastErr)}
}
// silenceCause 把「静默档位为什么成功」翻译成一个可分类的原因:链上确实有过失败时
// 用那一级的原因;一次失败都没有(链上压根没有云端档位,或它被跳过)就是未配置。
func silenceCause(lastErr error) error {
if lastErr == nil {
return ErrNotConfigured
}
return lastErr
}