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

+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]
}