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:
1 parent
120d926cf8
commit
ede00d9928
17 files changed
+2125
-31
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// 本文件承载「**能不能播**」与「该转成什么」这两条**格式级**判据(design/24 §10.6、
|
||||
// design/25 §3.1/§3.2)。
|
||||
//
|
||||
// 🔴 判据纪律:可播与否必须是**格式级结论**(格式名 → 已知支持表),**绝不**靠「扩展名
|
||||
// 看起来像音频」,也绝不靠「试着播一下」。设计里的同一条话(design/24 §10.14-3)说的是
|
||||
// 同一件事:宣称能播就要有依据,播不了就照实提示。
|
||||
|
||||
// playableFormats 是「浏览器与常见原生播放器**能直接播**」的格式集合,取值口径与
|
||||
// mediafetch.AudioFormatOf 归一出的格式名同一份(小写、无点)。
|
||||
//
|
||||
// 依据 = design/25 §3.2 的 MDN《Audio codecs》支持表(规范/文档级结论):
|
||||
// wav(PCM 基线)、mp3、ogg/oga/opus、m4a/aac(MP4 家族)、flac、webm 均可播。
|
||||
//
|
||||
// `amr` / `amr-wb` **刻意不在**集合内:Chrome 的 AMR 支持为 **No**(只有 ChromeOS 支持
|
||||
// AMR-NB/WB)⇒ QQ OneBot v11 的语音在 Web 上点不开,必须转码(design/24 §10.14-2 的
|
||||
// 核实结论,唯一必需的那条路 = `amr → wav`)。
|
||||
//
|
||||
// ⚠️ **诚实边界(别当已实测)**:上表是规范/文档级结论,**不是**本项目在目标客户端上
|
||||
// 的逐浏览器实测(design/25 §3.2 已登记同一条「未确证」)。实测不符则以实测为准,
|
||||
// 照实提示、不假装。对本函数而言,判错的后果是**有界的**:判「可播」⇒ 原样落副本,
|
||||
// 客户端播不了会照实提示;判「不可播」⇒ 走转码,没有转码路就直接不落副本。
|
||||
var playableFormats = map[string]bool{
|
||||
"wav": true,
|
||||
"mp3": true,
|
||||
"ogg": true,
|
||||
"oga": true,
|
||||
"opus": true,
|
||||
"m4a": true,
|
||||
"aac": true,
|
||||
"flac": true,
|
||||
"webm": true,
|
||||
}
|
||||
|
||||
// transcodeTargets 是「不可播格式 → 该转成的目标格式」的唯一登记点(design/25 §3.1:
|
||||
// 本期只发布 `amr → wav` 一条路;新增一条路 = 这里加一行 + transcode 包加一个注册项)。
|
||||
//
|
||||
// amr-wb 也登记目标(同族、目标同样是 wav),但**本期没有实现**:让 `transcode` 层如实
|
||||
// 回答「注册表里没有这条路」(design/25 §3.3 把它后置),比在这里假称「我们不会做」
|
||||
// 更能说清问题。silk 刻意**不登记**:design/24 §10.14-1 的既有裁决是微信语音不取字节,
|
||||
// 且纯 Go SILK 解码器生态不成熟(design/25 §3.3)。
|
||||
var transcodeTargets = map[string]transcode.Format{
|
||||
"amr": transcode.FormatWAV,
|
||||
"amr-wb": transcode.FormatWAV,
|
||||
}
|
||||
|
||||
// Playable 报告某个格式名(小写、无点)的字节能否**直接被播放器消费**。格式不认识
|
||||
// ⇒ false(deny-by-default:不认识的类型绝不当成可播)。
|
||||
func Playable(format string) bool {
|
||||
return playableFormats[normalize(format)]
|
||||
}
|
||||
|
||||
// TranscodeTarget 给出「该格式要落可播副本时应当转成什么」。ok=false 表示**没有登记的
|
||||
// 目标**(格式可播、或这条路我们不做)——调用方据此如实提示,绝不自己发明一条路。
|
||||
func TranscodeTarget(format string) (transcode.Format, bool) {
|
||||
target, ok := transcodeTargets[normalize(format)]
|
||||
return target, ok
|
||||
}
|
||||
|
||||
// NeedsTranscode 报告这个格式要落副本是否**必须先转码**(= 不可播且登记了目标)。
|
||||
// 它刻意不等价于 `!Playable`:不可播但没登记目标的格式(如 silk)属于「做不了」,
|
||||
// 走 ErrNotPlayable,而不是走一次注定失败的转码。
|
||||
func NeedsTranscode(format string) bool {
|
||||
if Playable(format) {
|
||||
return false
|
||||
}
|
||||
_, ok := TranscodeTarget(format)
|
||||
return ok
|
||||
}
|
||||
|
||||
// PlayableFormats 返回当前认定为可播的格式名(**发现**用;顺序无保证,调用方不得依赖
|
||||
// 顺序)。它存在的理由是让「这份表是活的」有据可查(面板/日志能列出判据面)。
|
||||
func PlayableFormats() []string {
|
||||
out := make([]string, 0, len(playableFormats))
|
||||
for f := range playableFormats {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalize 把格式名归一成小写、去空白、去开头的点(`.amr` → `amr`)。归一只有这一处,
|
||||
// 与 `transcode.FormatOf` 同口径(design/25 §6.2:三处格式名必须同源)。
|
||||
func normalize(format string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), "."))
|
||||
}
|
||||
|
||||
// errNotPlayable 给 ErrNotPlayable 附上格式,便于日志与测试按 errors.Is + 文案双判。
|
||||
func errNotPlayable(format string) error {
|
||||
return fmt.Errorf("voice: prepare: format %q: %w", format, ErrNotPlayable)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// TestPlayableIsFormatLevelVerdict 把「能不能播」的判据钉成**格式级**结论表(design/25
|
||||
// §3.2 的 MDN 支持表)。AMR 是唯一必须转码的常见入站格式(Chrome/Firefox 的 AMR 支持为
|
||||
// No,只有 ChromeOS 支持)——若有人把 amr 也标成可播,「能点开听」这条验收会静默失效
|
||||
// (Web 上点开播不了,而服务端以为自己给了一份能播的副本)。
|
||||
func TestPlayableIsFormatLevelVerdict(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"wav", true, "<audio> 的基线格式"},
|
||||
{"mp3", true, "KOOK 语音的实际格式"},
|
||||
{"ogg", true, "Telegram 语音(Ogg/Opus)"},
|
||||
{"oga", true, "Ogg 的同义扩展名"},
|
||||
{"opus", true, "裸 Opus(按 Ogg 家族放行)"},
|
||||
{"m4a", true, "MP4/AAC 家族"},
|
||||
{"aac", true, "AAC 家族"},
|
||||
{"flac", true, "无损容器"},
|
||||
{"webm", true, "WebM/Opus"},
|
||||
{"amr", false, "Chrome 的 AMR 支持为 No(唯一必需的转码路 amr→wav 的来源)"},
|
||||
{"amr-wb", false, "同族不可播,且本期没有转码实现(后置 P2)"},
|
||||
{"silk", false, "不在音频白名单内(微信按既有裁决不取字节)"},
|
||||
{"", false, "空格式:deny-by-default"},
|
||||
{"xyz", false, "不认识的格式:deny-by-default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format+"/"+tc.why, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := Playable(tc.format); got != tc.want {
|
||||
t.Fatalf("Playable(%q) = %v, want %v(%s)", tc.format, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayableNormalizesFormatName 钉住归一:大小写、首尾空白、开头的点都要吃得下
|
||||
// (渠道给的扩展名/MIME 归一后可能带点或大写),否则「可播」会在同一格式上给出两个答案。
|
||||
func TestPlayableNormalizesFormatName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{"WAV", " wav ", ".wav", ".WAV"} {
|
||||
if !Playable(in) {
|
||||
t.Fatalf("Playable(%q) = false, want true(归一后是 wav)", in)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"AMR", " amr ", ".AMR"} {
|
||||
if Playable(in) {
|
||||
t.Fatalf("Playable(%q) = true, want false(归一后是 amr,不可播)", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscodeTargetIsRegisteredOnlyForAMR 钉住「哪些不可播格式有登记的转码目标」:
|
||||
// 本期只发布 `amr → wav`(design/25 §3.1)。可播格式**不是**转码目标(白转一圈没收益),
|
||||
// silk 也**没有**目标(design/25 §3.3:不引入纯 Go SILK 解码器)。
|
||||
func TestTranscodeTargetIsRegisteredOnlyForAMR(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want transcode.Format
|
||||
ok bool
|
||||
}{
|
||||
{"amr", transcode.FormatWAV, true},
|
||||
{"amr-wb", transcode.FormatWAV, true},
|
||||
{"wav", "", false},
|
||||
{"mp3", "", false},
|
||||
{"silk", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := TranscodeTarget(tc.format)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("TranscodeTarget(%q) = (%q, %v), want (%q, %v)",
|
||||
tc.format, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode 钉住一条容易混的边界:
|
||||
// NeedsTranscode 不是 `!Playable`。silk 不可播但**没登记目标** ⇒ false(做不了,
|
||||
// 走 ErrNotPlayable),而不是「要转码」(那会去问一条注定不存在的路)。
|
||||
func TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want bool
|
||||
}{
|
||||
{"amr", true},
|
||||
{"amr-wb", true},
|
||||
{"wav", false},
|
||||
{"mp3", false},
|
||||
{"silk", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NeedsTranscode(tc.format); got != tc.want {
|
||||
t.Fatalf("NeedsTranscode(%q) = %v, want %v", tc.format, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayableFormatsMatchesDesignTable 把「可播集合」的**字面成员**钉死(discovery 面
|
||||
// 的作者容易顺手加一项而没有依据)。design/25 §3.2 的支持表 + design/24 §10.6 的音频
|
||||
// 白名单(10 项扩展名)⇒ 可播名集合恰好是这 9 个(ogg/oga/opus 三名同容器)。
|
||||
func TestPlayableFormatsMatchesDesignTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{"aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm"}
|
||||
got := PlayableFormats()
|
||||
sort.Strings(got)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("PlayableFormats() = %v(%d 项), want %v(%d 项)", got, len(got), want, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("PlayableFormats() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// fakeTranscoder 是 Prepare 的能力门与转码调用的替身:它记录是否被调用、被问了哪条路,
|
||||
// 让「可播格式**绝不**碰转码」这类纪律成为可断言的事实(而不是靠读代码相信)。
|
||||
type fakeTranscoder struct {
|
||||
// routes 是「源>目标」的可用集合;nil 表示按 DefaultCodecs 的口径只认 amr>wav。
|
||||
routes map[string]bool
|
||||
result transcode.Result
|
||||
err error
|
||||
calls int
|
||||
lastReq transcode.Request
|
||||
}
|
||||
|
||||
func routeKey(src, dst transcode.Format) string {
|
||||
return transcode.FormatName(src) + ">" + transcode.FormatName(dst)
|
||||
}
|
||||
|
||||
func newFakeTranscoder() *fakeTranscoder {
|
||||
return &fakeTranscoder{routes: map[string]bool{routeKey(transcode.FormatAMR, transcode.FormatWAV): true}}
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
return transcode.Result{}, f.err
|
||||
}
|
||||
res := f.result
|
||||
res.Source = req.Source
|
||||
if res.Format == "" {
|
||||
res.Format = req.Target
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Supports(src, dst transcode.Format) bool {
|
||||
return f.routes[routeKey(src, dst)]
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Formats() []transcode.Format {
|
||||
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
|
||||
}
|
||||
|
||||
// TestPrepareKeepsPlayableBytesAsIs 覆盖三态之①:源格式可播 ⇒ **原样落盘**,一个字节都
|
||||
// 不改、也不碰转码(design/25 §7.1:不可播才转码;可播却转一圈等于白烧 CPU 还把音质
|
||||
// 往下拽)。
|
||||
func TestPrepareKeepsPlayableBytesAsIs(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
in := []byte("RIFF....WAVEfmt ")
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: in, Format: "WAV", DurationMs: 1500})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got.Data, in) {
|
||||
t.Fatalf("字节被改动了:%q", got.Data)
|
||||
}
|
||||
if got.Format != "wav" || got.Transcoded {
|
||||
t.Fatalf("产物 = %+v, want 原样 wav / Transcoded=false", got)
|
||||
}
|
||||
if got.DurationMs != 1500 {
|
||||
t.Fatalf("时长 = %d, want 1500(渠道如实给出的值)", got.DurationMs)
|
||||
}
|
||||
if tr.calls != 0 {
|
||||
t.Fatalf("转码被调用 %d 次, want 0(可播格式绝不转码)", tr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareTranscodesNonPlayableFormat 覆盖三态之②:不可播且转码可用 ⇒ 落**可播副本**,
|
||||
// 并且时长优先取转码产物**可确证**的值(渠道常给不出;转码按帧数算得出来)。
|
||||
func TestPrepareTranscodesNonPlayableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV, DurationMs: 500}
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n..."), Format: "amr"})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if string(got.Data) != "RIFF-out" || got.Format != "wav" || !got.Transcoded {
|
||||
t.Fatalf("产物 = %+v, want 转码产物 wav/Transcoded=true", got)
|
||||
}
|
||||
if got.DurationMs != 500 {
|
||||
t.Fatalf("时长 = %d, want 500(转码产物可确证)", got.DurationMs)
|
||||
}
|
||||
if tr.calls != 1 {
|
||||
t.Fatalf("转码调用次数 = %d, want 1", tr.calls)
|
||||
}
|
||||
if tr.lastReq.Source != transcode.FormatAMR || tr.lastReq.Target != transcode.FormatWAV {
|
||||
t.Fatalf("请求 = %+v, want amr→wav", tr.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreparePrefersTranscodedDuration 覆盖时长来源的优先级:转码产物给不出时长时,
|
||||
// **沿用渠道如实给出的值**(绝不因为转码成功就把已知的时长抹成 0)。
|
||||
func TestPreparePrefersTranscodedDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV}
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr", DurationMs: 3200})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if got.DurationMs != 3200 {
|
||||
t.Fatalf("时长 = %d, want 3200(转码没给出 ⇒ 沿用渠道值)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareNegativeDurationIsTreatedAsUnknown 钉住「渠道给了个无意义的值」的处理:
|
||||
// 负数按**不知道**处理(0),既不采信也不编造 —— 落库前 Ref.Validate 会拒绝负数。
|
||||
func TestPrepareNegativeDurationIsTreatedAsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Prepare(context.Background(), nil, Prep{Data: []byte("RIFF"), Format: "wav", DurationMs: -5})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if got.DurationMs != 0 {
|
||||
t.Fatalf("时长 = %d, want 0(负值 = 不知道)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// prepareFailureCases 是「不可播且转码不可用」的全部失败面(design/25 §2.4 / §5.4):
|
||||
// 每一条都要求「有错、可判类、无字节」。表放在用例之外,好让用例本体只表达纪律。
|
||||
var prepareFailureCases = []struct {
|
||||
name string
|
||||
tr transcode.Transcoder
|
||||
in Prep
|
||||
wantErr error
|
||||
category string
|
||||
}{
|
||||
{
|
||||
name: "没有字节", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: nil, Format: "wav"},
|
||||
wantErr: ErrNoBytes,
|
||||
category: "no_bytes",
|
||||
},
|
||||
{
|
||||
name: "不可播且没有登记目标(silk)", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("x"), Format: "silk"},
|
||||
wantErr: ErrNotPlayable,
|
||||
category: "not_playable",
|
||||
},
|
||||
{
|
||||
name: "不可播且格式不认识", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("x"), Format: ""},
|
||||
wantErr: ErrNotPlayable,
|
||||
category: "not_playable",
|
||||
},
|
||||
{
|
||||
name: "转码能力未接线(amr 但组合根没注入)", tr: nil,
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: ErrNoTranscoder,
|
||||
category: "transcode_not_wired",
|
||||
},
|
||||
{
|
||||
name: "注册表里没有这条路(amr-wb 本期未实现)",
|
||||
tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("#!AMR-WB\n"), Format: "amr-wb"},
|
||||
wantErr: transcode.ErrUnsupportedFormat,
|
||||
category: "unsupported_format",
|
||||
},
|
||||
{
|
||||
name: "输入越限", tr: failingTranscoder(transcode.ErrInputTooLarge),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrInputTooLarge,
|
||||
category: "input_too_large",
|
||||
},
|
||||
{
|
||||
name: "产物越限", tr: failingTranscoder(transcode.ErrOutputTooLarge),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrOutputTooLarge,
|
||||
category: "output_too_large",
|
||||
},
|
||||
{
|
||||
name: "解码失败(字节损坏)", tr: failingTranscoder(transcode.ErrDecodeFailed),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrDecodeFailed,
|
||||
category: "decode_failed",
|
||||
},
|
||||
{
|
||||
name: "并发槽满(快速失败,不排队)", tr: failingTranscoder(transcode.ErrBusy),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrBusy,
|
||||
category: "busy",
|
||||
},
|
||||
{
|
||||
name: "超时", tr: failingTranscoder(transcode.ErrTimeout),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrTimeout,
|
||||
category: "timeout",
|
||||
},
|
||||
}
|
||||
|
||||
// TestPrepareFailsHonestly 覆盖三态之③与全部失败分类(design/25 §2.4 / §5.4):
|
||||
// **不可播且转码不可用**时绝不返回一份播不了却当作可播的结果;错误必须可 errors.Is
|
||||
// 判定、可归类(Category 是「只进日志」的那份机器可读分类,design/24 §10.12-9)。
|
||||
func TestPrepareFailsHonestly(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range prepareFailureCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Prepare(context.Background(), tc.tr, tc.in)
|
||||
if err == nil {
|
||||
t.Fatalf("Prepare(%+v) = %+v, want 错误(绝不返回播不了却当作可播的结果)", tc.in, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("错误 = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if cat := Category(err); cat != tc.category {
|
||||
t.Fatalf("Category(%v) = %q, want %q", err, cat, tc.category)
|
||||
}
|
||||
if len(got.Data) != 0 {
|
||||
t.Fatalf("失败时仍返回了字节:%q", got.Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// failingTranscoder 返回一个自称有 amr→wav 这条路、但一转就报指定哨兵的替身。
|
||||
func failingTranscoder(err error) *fakeTranscoder {
|
||||
f := newFakeTranscoder()
|
||||
f.err = err
|
||||
return f
|
||||
}
|
||||
|
||||
// TestPrepareRejectsEmptyTranscodeOutput 覆盖「转码器自称成功却没给字节」:那是目标侧
|
||||
// 构造失败,绝不能当成一份空副本落盘(空文件播放不了,documents 侧也会按空文件拒掉)。
|
||||
func TestPrepareRejectsEmptyTranscodeOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Format: transcode.FormatWAV}
|
||||
_, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr"})
|
||||
if !errors.Is(err, transcode.ErrEncodeFailed) {
|
||||
t.Fatalf("错误 = %v, want transcode.ErrEncodeFailed", err)
|
||||
}
|
||||
if cat := Category(err); cat != "encode_failed" {
|
||||
t.Fatalf("Category = %q, want encode_failed", cat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCategoryOfNilIsEmpty 钉住 Category(nil) 的空串:调用方按它决定「要不要记一条
|
||||
// 失败日志」,因此成功必须是空串(否则每次成功转码都会刷一条 WARN)。
|
||||
func TestCategoryOfNilIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if cat := Category(nil); cat != "" {
|
||||
t.Fatalf("Category(nil) = %q, want 空串", cat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareWithRealTranscodeEngine 是**真走**用例(design/25 §8-T 的「复用既有能力」
|
||||
// 验收):用真实 `transcode.Engine` + `DefaultCodecs()` 把一份真 AMR-NB 夹具走通
|
||||
// `amr → wav`,断言产物是 RIFF/WAVE 且时长**可确证**。
|
||||
//
|
||||
// 夹具取自 transcode 包自己的 testdata(同一份,绝不再造一份):它是 opencore-amrnb
|
||||
// 编出的 0.5 秒合成信号,因此本用例不需要装 ffmpeg。若该目录被搬走,本用例会**红**而不是
|
||||
// 静默跳过(「跳过」会让这条唯一的真走证据消失而没人发现)。
|
||||
func TestPrepareWithRealTranscodeEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
const fixture = "../../capability/transcode/testdata/mode12200.amr"
|
||||
data, err := os.ReadFile(fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("读夹具 %s: %v(真走用例依赖 transcode 包自己的 testdata)", fixture, err)
|
||||
}
|
||||
tr := transcode.NewEngine(transcode.DefaultLimits(),
|
||||
transcode.WithCodecs(transcode.DefaultCodecs()...))
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: data, Format: "amr"})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare(真 amr 夹具): %v", err)
|
||||
}
|
||||
if !got.Transcoded || got.Format != "wav" {
|
||||
t.Fatalf("产物 = %+v, want wav 转码产物", got)
|
||||
}
|
||||
if len(got.Data) < 44 || string(got.Data[:4]) != "RIFF" || string(got.Data[8:12]) != "WAVE" {
|
||||
t.Fatalf("产物不是 RIFF/WAVE:前 12 字节 = %q", head(got.Data, 12))
|
||||
}
|
||||
if got.DurationMs <= 0 {
|
||||
t.Fatalf("时长 = %d, want >0(AMR 帧数可确证)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// head 返回 data 的前 n 字节(越界时返回全部),只用于失败信息。
|
||||
func head(data []byte, n int) []byte {
|
||||
if len(data) < n {
|
||||
return data
|
||||
}
|
||||
return data[:n]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package voice 承载**语音副本**(回放侧)的领域事实与可播归一化(design/24 §10、
|
||||
// design/25 §7.1)。它回答三个问题:
|
||||
//
|
||||
// - 这条消息**来自一条语音吗**、它的正文是哪来的(`Ref.Source`);
|
||||
// - 那次转写**成功了没有**(`Ref.Status`,三态闭集);
|
||||
// - 盘上**有没有一份能播的副本**(`Ref.Name`;没有就整键缺席,绝不给假 url)。
|
||||
//
|
||||
// ⚠️ 与 `internal/runtime/multimodal/voice` 的分工(**别混**):那个包是**转写/合成**
|
||||
// 的提供方链(STT/TTS,design/06);本包是「消息里那条语音的**来源事实 + 可播副本**」
|
||||
// (design/24 §10)。两者同名不同事,进口时务必看清路径。
|
||||
//
|
||||
// 依赖刻意极轻(stdlib + `internal/capability/transcode`):`history` 与
|
||||
// `channelbridge` 都要引用它,而 `documents` 已经 import `history` —— 本包一旦 import
|
||||
// `documents` 就会让「history → voice」成环。因此这里**不引用** `documents.Ref`,
|
||||
// 副本事实用本包自有的 `Copy` 承载,由调用方负责映射(channelbridge 一处)。
|
||||
package voice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Source 是转写文本的**来源**(design/24 §10.4 的闭集)。它只描述「这段文字是怎么来的」,
|
||||
// **绝不**描述说话人是谁(身份仍由 channel 层 actor 元数据 + 白名单决定)。
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceASR 表示服务端语音识别所得(含云端 DashScope 那条链)。
|
||||
SourceASR Source = "asr"
|
||||
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
|
||||
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
|
||||
SourcePlatform Source = "platform"
|
||||
)
|
||||
|
||||
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
|
||||
// 文案分支**:正文可能是转写文本,也可能是一行中文诚实提示,两者只能靠 status 区分。
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
|
||||
StatusTranscribed Status = "transcribed"
|
||||
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
|
||||
// 的空结果,**不是**故障。
|
||||
StatusEmpty Status = "empty"
|
||||
// StatusFailed 表示没拿到转写文本(正文是那行中文诚实提示)。
|
||||
StatusFailed Status = "failed"
|
||||
)
|
||||
|
||||
// 本包自有的哨兵(转码失败的分类一律复用 `transcode` 的哨兵,见 prepare.go)。
|
||||
var (
|
||||
// ErrBadRef 报告 voice 列 / DTO 的内容不是合法的语音事实(乱码、非法 JSON、
|
||||
// 来源或状态不在闭集内、副本字段「同生共死」被破坏)。
|
||||
ErrBadRef = errors.New("voice: malformed voice ref")
|
||||
// ErrNoBytes 报告要归一化的那条语音**没有字节**(渠道只给了平台内部引用,或
|
||||
// 代取失败)——没有字节就没有可落盘的副本,调用方据此走诚实路径。
|
||||
ErrNoBytes = errors.New("voice: no audio bytes")
|
||||
// ErrNotPlayable 报告源格式**不可播**且**没有已知的转码目标**(或格式不认识):
|
||||
// 这条路我们做不了,调用方据此不落副本、不打假 url。
|
||||
ErrNotPlayable = errors.New("voice: source format is not playable")
|
||||
// ErrNoTranscoder 报告转码能力**未接线**(组合根没有把 transcode 交给本模块)。
|
||||
// 它与「格式不支持」刻意分开:一个是运维缺口,一个是能力边界,日志与排查路径不同。
|
||||
ErrNoTranscoder = errors.New("voice: transcode capability not wired")
|
||||
)
|
||||
|
||||
// Ref 是一条语音消息的**落库与 DTO 事实**(design/24 §10.4):它就是
|
||||
// `chat_messages.voice` 列里的 JSON **单对象**,同时是历史 DTO 里 `voice` 对象
|
||||
// **减去 `url`** 的那份数据 —— `url` 是唯一的派生字段,由服务端按 `Name` 一处拼出
|
||||
// (路径是服务端契约,绝不由客户端拼)。
|
||||
//
|
||||
// 字段顺序刻意与 design/24 §10.4 的 JSON 示例一致(编码产物逐字可比对)。
|
||||
type Ref struct {
|
||||
// Source 是转写文本从哪来(闭集,见 Source 常量)。
|
||||
Source Source `json:"source"`
|
||||
// Status 是转写结果三态(闭集,见 Status 常量)。
|
||||
Status Status `json:"status"`
|
||||
// Name 是音频副本的服务端存储名(内容寻址:32 位小写十六进制 + 音频白名单扩展名)。
|
||||
// 空串 = **没有副本**(例:微信按既有裁决不取字节)。它与 MIME/Size 三者同生共死。
|
||||
Name string `json:"name,omitempty"`
|
||||
// MIME 是**保存时成对裁决所得**的下发 MIME(`audio/ogg` 等),不是客户端声明。
|
||||
MIME string `json:"mime,omitempty"`
|
||||
// Size 是副本的字节数。
|
||||
Size int64 `json:"size,omitempty"`
|
||||
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
|
||||
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
}
|
||||
|
||||
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
|
||||
// 它是「非语音消息」与「语音消息」的分界:false ⇒ `voice` 键缺席,客户端不显示 🎤。
|
||||
func (r Ref) IsVoice() bool {
|
||||
return validSource(r.Source) && validStatus(r.Status)
|
||||
}
|
||||
|
||||
// HasCopy 报告盘上是否有一份可回放的副本(= Name 非空)。name/mime/size 三者同生共死,
|
||||
// 因此 Name 是这三者的代表。
|
||||
func (r Ref) HasCopy() bool { return r.Name != "" }
|
||||
|
||||
// Validate 检查这条事实是否自洽:两个闭集成员合法、副本三个字段**同生共死**、时长非负。
|
||||
// 破坏规则的写**报错**而不是就地裁剪 —— 悄悄补一个空的 mime 或砍掉 size,会让库里
|
||||
// 记着的东西与客户端读到的东西对不上(正是本列要消灭的静默)。
|
||||
func (r Ref) Validate() error {
|
||||
if !validSource(r.Source) {
|
||||
return fmt.Errorf("%w: source %q", ErrBadRef, r.Source)
|
||||
}
|
||||
if !validStatus(r.Status) {
|
||||
return fmt.Errorf("%w: status %q", ErrBadRef, r.Status)
|
||||
}
|
||||
if r.DurationMs < 0 {
|
||||
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
|
||||
}
|
||||
switch {
|
||||
case r.Name == "" && (r.MIME != "" || r.Size != 0):
|
||||
return fmt.Errorf("%w: mime/size without name", ErrBadRef)
|
||||
case r.Name != "" && r.MIME == "":
|
||||
return fmt.Errorf("%w: name without mime", ErrBadRef)
|
||||
case r.Name != "" && r.Size <= 0:
|
||||
return fmt.Errorf("%w: name without size", ErrBadRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
|
||||
func (r Ref) IsZero() bool {
|
||||
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
|
||||
r.Size == 0 && r.DurationMs == 0
|
||||
}
|
||||
|
||||
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
|
||||
// `voice` 键缺席),与 document 列对「没有附件」的口径一致。
|
||||
//
|
||||
// 非零但自洽性被破坏时报错:写进一列**读不回来**的 JSON,等于让客户端永远看不到这条
|
||||
// 语音(静默坏掉),所以这里 fail loud。
|
||||
func Encode(r Ref) (string, error) {
|
||||
if r.IsZero() {
|
||||
return "", nil
|
||||
}
|
||||
if err := r.Validate(); err != nil {
|
||||
return "", fmt.Errorf("voice: encode ref: %w", err)
|
||||
}
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("voice: encode ref: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Decode 解码落库的语音事实。空串(这条消息不是语音)返回零值;坏内容返回包装了
|
||||
// ErrBadRef 的错误,由调用方**可见地**报告 —— 解析失败绝不静默当成「不是语音」。
|
||||
//
|
||||
// 它只接受**单对象**形状:本列自 0020 起从诞生就是单对象(design/24 §10.4),没有
|
||||
// 需要兼容的旧形状(与 document 列的「早期单对象 → 数组」演进史不同)。
|
||||
func Decode(raw string) (Ref, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return Ref{}, nil
|
||||
}
|
||||
var r Ref
|
||||
if err := json.Unmarshal([]byte(trimmed), &r); err != nil {
|
||||
return Ref{}, fmt.Errorf("voice: decode ref: %w", ErrBadRef)
|
||||
}
|
||||
if err := r.Validate(); err != nil {
|
||||
return Ref{}, fmt.Errorf("voice: decode ref: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
|
||||
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
|
||||
|
||||
func validStatus(s Status) bool {
|
||||
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeDecodeRoundTrip 覆盖落库形状的核心往返:编码 → 解码逐字段还原。
|
||||
// 三种真实形态各一条:有副本的 asr 真转写、无副本的 platform 转写(微信按既有裁决
|
||||
// 不取字节)、无副本的失败(正文是那行中文诚实提示)。
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
}{
|
||||
{"asr 真转写 + 可播副本", Ref{
|
||||
Source: SourceASR, Status: StatusTranscribed,
|
||||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||||
Size: 12345, DurationMs: 3200,
|
||||
}},
|
||||
{"platform 自带转写 + 无副本", Ref{
|
||||
Source: SourcePlatform, Status: StatusTranscribed,
|
||||
}},
|
||||
{"转写失败 + 无副本(但原件仍可能落盘)", Ref{
|
||||
Source: SourceASR, Status: StatusFailed, Name: "ffeeddccbbaa99887766554433221100.wav",
|
||||
MIME: "audio/wav", Size: 160044,
|
||||
}},
|
||||
{"转写为空 + 有副本", Ref{
|
||||
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
|
||||
MIME: "audio/mpeg", Size: 4321,
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(tc.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
got, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", raw, err)
|
||||
}
|
||||
if got != tc.ref {
|
||||
t.Fatalf("往返 = %+v, want %+v", got, tc.ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeJSONShapeMatchesDesign 把**字面 JSON** 钉在设计上(design/24 §10.4 的示例):
|
||||
// 字段顺序、键名、以及 durationMs 缺席(0 ⇒ omitempty)都必须逐字如此。
|
||||
//
|
||||
// 为什么需要字面量钉桩:这份 JSON 会落进 chat_messages.voice 列,是客户端与历史 DTO
|
||||
// 共同的形状。字段顺序/键名漂移不会让任何单测变红(往返测试两侧同时漂移仍然相等),
|
||||
// 只有把**值**钉死才能挡住(design/24 §9.7 的同一条测试质量教训)。
|
||||
func TestEncodeJSONShapeMatchesDesign(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(Ref{
|
||||
Source: SourceASR, Status: StatusTranscribed,
|
||||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||||
Size: 12345, DurationMs: 3200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
want := `{"source":"asr","status":"transcribed","name":"a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",` +
|
||||
`"mime":"audio/ogg","size":12345,"durationMs":3200}`
|
||||
if raw != want {
|
||||
t.Fatalf("落库 JSON = %s\n want %s", raw, want)
|
||||
}
|
||||
|
||||
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死),durationMs 缺席(= 0)。
|
||||
raw, err = Encode(Ref{Source: SourcePlatform, Status: StatusTranscribed})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(no copy): %v", err)
|
||||
}
|
||||
if raw != `{"source":"platform","status":"transcribed"}` {
|
||||
t.Fatalf("无副本 JSON = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeZeroRefIsEmptyColumn 钉住「不是语音消息」的落库形状:零值编码成**空串**,
|
||||
// 于是列里空串 ⇒ DTO 里 voice 键缺席(不是 {} 也不是 null)。
|
||||
func TestEncodeZeroRefIsEmptyColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(Ref{})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(零值): %v", err)
|
||||
}
|
||||
if raw != "" {
|
||||
t.Fatalf("零值编码 = %q, want 空串", raw)
|
||||
}
|
||||
got, err := Decode("")
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(\"\"): %v", err)
|
||||
}
|
||||
if !got.IsZero() {
|
||||
t.Fatalf("Decode(\"\") = %+v, want 零值", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeRejectsBrokenRef 覆盖「同生共死」与闭集两条纪律的**写侧**执行:坏形状必须在
|
||||
// 写库前报错,绝不悄悄补字段(那会让库里记着的东西与客户端读到的对不上)。
|
||||
func TestEncodeRejectsBrokenRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
}{
|
||||
{"来源不在闭集", Ref{Source: "cloud", Status: StatusTranscribed}},
|
||||
{"状态不在闭集", Ref{Source: SourceASR, Status: "ok"}},
|
||||
{"来源为空", Ref{Status: StatusTranscribed}},
|
||||
{"状态为空", Ref{Source: SourceASR}},
|
||||
{"有 mime 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, MIME: "audio/ogg"}},
|
||||
{"有 size 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, Size: 12}},
|
||||
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
|
||||
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
|
||||
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(tc.ref)
|
||||
if err == nil {
|
||||
t.Fatalf("Encode(%+v) = %q, want 错误(坏形状绝不入库)", tc.ref, raw)
|
||||
}
|
||||
if !errors.Is(err, ErrBadRef) {
|
||||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeRejectsGarbage 覆盖读侧:坏列内容**报错**而不是当成「不是语音」。
|
||||
// 静默当成空 = 客户端永远看不到这条语音,而库里明明有引用(正是要消灭的静默)。
|
||||
func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"截断的 JSON", `{"source":"asr"`},
|
||||
{"数组形状(本列从诞生就是单对象)", `[{"source":"asr","status":"transcribed"}]`},
|
||||
{"字面量 null", `null`},
|
||||
{"空对象(两个闭集字段都缺)", `{}`},
|
||||
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
|
||||
{"闭集外的状态", `{"source":"asr","status":"done"}`},
|
||||
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Decode(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("Decode(%s) = %+v, want 错误", tc.raw, got)
|
||||
}
|
||||
if !errors.Is(err, ErrBadRef) {
|
||||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
|
||||
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
|
||||
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
|
||||
func TestRefIsVoiceAndHasCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
isVoice bool
|
||||
hasCopy bool
|
||||
}{
|
||||
{"零值(不是语音消息)", Ref{}, false, false},
|
||||
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
|
||||
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
|
||||
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.ref.IsVoice(); got != tc.isVoice {
|
||||
t.Fatalf("IsVoice = %v, want %v", got, tc.isVoice)
|
||||
}
|
||||
if got := tc.ref.HasCopy(); got != tc.hasCopy {
|
||||
t.Fatalf("HasCopy = %v, want %v", got, tc.hasCopy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeTrimsWhitespace 钉住宽容度只到「首尾空白」为止:列里带换行的内容(人手工
|
||||
// 改过、或将来某处写入时多了换行)仍可读回,坏内容仍然报错。
|
||||
func TestDecodeTrimsWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := "\n {\"source\":\"asr\",\"status\":\"transcribed\"}\t \n"
|
||||
got, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", raw, err)
|
||||
}
|
||||
if got.Source != SourceASR || got.Status != StatusTranscribed {
|
||||
t.Fatalf("Decode = %+v", got)
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
t.Fatal("用例前提坏了:raw 不该是空串")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user