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

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

147 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}
}