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