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

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