采集面: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),对齐留待下次改动。
71 lines
3.0 KiB
Go
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
|
|
}
|