- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
99 lines
4.6 KiB
Go
99 lines
4.6 KiB
Go
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)
|
||
}
|