Files
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

99 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}