- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
147 lines
6.6 KiB
Go
147 lines
6.6 KiB
Go
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"
|
||
}
|
||
}
|