feat(voice): 语音消息落库 + 转码接入(切片 0020)

- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
AskaEth committed 2026-10-06 09:04:06 +08:00
1 parent 120d926cf8
commit ede00d9928
17 files changed
+2125 -31

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
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"
}
}