package voice import ( "context" "errors" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode" ) // 本文件是「**可播归一化**」的实现(design/25 §7.1 第 ④ 步):把一条入站语音的原始 // 字节变成一份**可以落盘、可以播放**的副本。 // // 位置与时机(design/24 §10.2 第 4 步 + design/25 §7.1): // - **闸门之后**:被黑白名单 / 最小打扰拦下的语音不产生任何字节与 CPU 开销; // - **转写之后**:ASR 用的是**原始字节**(转码产物对它无用且有害:AMR→WAV 放大 10 倍); // - **落盘之前**:这是唯一决定「副本是什么格式」的位置,与内容寻址同一处 ⇒ // 同字节去重 ⇒ 同一段语音只转一次; // - 只在**入站**发生,不在读端点(播放)发生 ⇒ 没有「读端点被轮询打爆」这个面。 // Prep 是一次可播归一化的输入。 type Prep struct { // Data 是入站拿到的原始音频字节(mediafetch 内联所得)。 Data []byte // Format 是源格式名(小写无点;与 mediafetch.AudioFormatOf 同一口径)。平台给了 // 文件名就按扩展名,只给 MIME 就按 MIME 归一 —— 「拿不到」时调用方不该走到这里。 Format string // DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。负数按「不知道」处理 // (渠道给了个无意义的值,我们既不采信也不编造)。 DurationMs int64 } // Copy 是可落盘、可播放的一份副本。 type Copy struct { // Data 是要落盘的字节(可播格式)。 Data []byte // Format 是落盘格式名(= 存储名的扩展名口径)。 Format string // DurationMs 是时长的**最好来源**:转码产物能确证时取它,否则沿用渠道如实给出的值; // 两者都没有就是 0(落库/DTO 里该键缺席,绝不估算)。 DurationMs int64 // Transcoded 报告这一份是不是**转码产物**(false = 源格式本就可播、原样落盘)。 // 它进日志与(将来的)DTO,使「为什么这条副本的格式和渠道给的不一样」可追溯。 Transcoded bool } // Prepare 把一条入站语音归一成可播副本(design/24 §10.14-3 的新口径:**不可播即转码; // 转码不可用才诚实提示**)。三态: // // ① 源格式可播 ⇒ 原样返回(Transcoded=false,零 CPU、零转码依赖); // ② 不可播且转码可用 ⇒ 返回转码产物(Transcoded=true,目标来自 TranscodeTarget); // ③ 不可播且转码不可用 ⇒ 返回错误(未接线 / 没这条路 / 越限 / 解码失败 / 忙 / 超时), // **绝不**返回一个播不了却当作可播的结果 —— 调用方据此不落副本、不打假 url。 // // 错误分类一律可 `errors.Is` 判定:本包的 ErrNoBytes / ErrNotPlayable / ErrNoTranscoder, // 或原样包裹的 `transcode` 哨兵。文案纪律照 design/25 §2.4:上游错误原文只进日志, // 绝不进用户可见正文(这里只包哨兵,不回显解码器文本)。 func Prepare(ctx context.Context, tr transcode.Transcoder, in Prep) (Copy, error) { if len(in.Data) == 0 { return Copy{}, fmt.Errorf("voice: prepare: %w", ErrNoBytes) } format := normalize(in.Format) duration := in.DurationMs if duration < 0 { duration = 0 } // ① 本就可播:不碰转码(design/25 §7.1 的「源格式可播 ⇒ 不转码、直接落原件」)。 if Playable(format) { return Copy{Data: in.Data, Format: format, DurationMs: duration}, nil } // ② / ③ 不可播:先问「这条路有没有」,再决定走转码还是诚实拒绝(设计 §4.4 的能力门, // 与 LiteLLM 的 supports_* 同范式:绝不「试一下就假成功」)。 target, ok := TranscodeTarget(format) if !ok { return Copy{}, errNotPlayable(format) } if tr == nil { return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w", format, transcode.FormatName(target), ErrNoTranscoder) } source := transcode.FormatOf(format) if !tr.Supports(source, target) { return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w", format, transcode.FormatName(target), transcode.ErrUnsupportedFormat) } res, err := tr.Transcode(ctx, transcode.Request{Data: in.Data, Source: source, Target: target}) if err != nil { // 原样包裹:调用方按 transcode 的哨兵分类(越限 / 解码失败 / 忙 / 超时…), // 本函数不吞、不改写原因。 return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s: %w", format, transcode.FormatName(target), err) } if len(res.Data) == 0 { // 转码器自称成功却没给字节:这是**目标侧构造失败**,绝不能当成一份空副本落盘 // (空文件播放不了,而且 documents 侧会按「空文件」拒掉 —— 两头都说不清)。 return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s produced no bytes: %w", format, transcode.FormatName(target), transcode.ErrEncodeFailed) } outFormat := transcode.FormatName(res.Format) if outFormat == "" { outFormat = transcode.FormatName(target) } if res.DurationMs > 0 { // 转码产物可从帧数**确证**时长 ⇒ 它比渠道给的更可信(渠道常常给不出)。 duration = res.DurationMs } return Copy{Data: res.Data, Format: outFormat, DurationMs: duration, Transcoded: true}, nil } // Category 把 Prepare 的错误归成一个**稳定的日志类别**(小写、无空格、不含任何上游 // 错误原文),供调用方按类记 WARN、按类排查,并在将来需要时细分客户端展示。 // // design/24 §10.12-9 说「三态之外的原因分类本期不下发 DTO,只进日志」——本函数就是 // 「只进日志」的那一份分类,因此它是**机器可读**的(脚本/面板可按它聚合)。 func Category(err error) string { switch { case err == nil: return "" case errors.Is(err, ErrNoBytes): return "no_bytes" case errors.Is(err, ErrNotPlayable): return "not_playable" case errors.Is(err, ErrNoTranscoder): return "transcode_not_wired" case errors.Is(err, transcode.ErrUnsupportedFormat): return "unsupported_format" case errors.Is(err, transcode.ErrInputTooLarge): return "input_too_large" case errors.Is(err, transcode.ErrOutputTooLarge): return "output_too_large" case errors.Is(err, transcode.ErrDecodeFailed): return "decode_failed" case errors.Is(err, transcode.ErrEncodeFailed): return "encode_failed" case errors.Is(err, transcode.ErrTimeout): return "timeout" case errors.Is(err, transcode.ErrBusy): return "busy" case errors.Is(err, transcode.ErrNoBackend): return "no_backend" default: return "failed" } }