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 }