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

+98
View File
@@ -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)
}
+133
View File
@@ -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)
}
}
}
+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"
}
}
+296
View File
@@ -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]
}
+174
View File
@@ -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
}
+211
View File
@@ -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 不该是空串")
}
}