feat(capability): 转码能力包(vendor 纯 Go AMR-NB 解码器 + 黄金向量逐样本对照 + 8 哨兵 + 注册面)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 13:02:28 +08:00
1 parent 89715d1813
commit 4a2d276881
100 files changed
+11319

No files matched your search

@@ -0,0 +1,190 @@
package transcode
import (
"context"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode/amrnb"
)
// 本文件实现 design/25 §3.1 的**唯一必需**那条路:`amr → wav`。
//
// 为什么必须:QQ OneBot v11 的语音是 AMR(`.amr`),浏览器**播不了**
// (Chrome 的 AMR 支持表为 No,只有 ChromeOS 支持 AMR-NB/WB),所以
// 「能点开听」在零转码下不可达(design/25 §3.2)。
//
// 解码器是 vendored 的 github.com/VoiceBlender/goamr-nb v1.0.0(Apache-2.0,
// 见 amrnb/VENDORED.md):纯 Go、CGO_ENABLED=0 可构建、非 amd64 走
// simd_noasm.go 兜底 ⇒ 交叉编译不破。
//
// 目标参数 = AMR-NB 的原生参数:**8 kHz、单声道、PCM16,不重采样、不变声道**
// (design/25 §3.1/§3.2:重采样到 16 kHz 只会把文件放大一倍且不增加任何信息)。
const (
// amrStorageHeaderBytes 是 AMR-NB 存储格式的魔数长度(`#!AMR\n`)。
amrStorageHeaderBytes = len(amrNBMagic)
// amrFrameMillis 是一帧的时长:AMR-NB 一帧固定 20 ms。
amrFrameMillis = 20
// amrSampleRate / amrChannels / amrFrameSamples 是 AMR-NB 的原生参数,
// 取自 vendored 解码器自己的常量,不在这里另抄一份。
amrSampleRate = amrnb.SampleRate
amrChannels = 1
amrFrameSamples = amrnb.FrameSamples
// amrPayloadNoCMR 是 octet-aligned 载荷首字节的 CMR = 15「no mode request」。
amrPayloadNoCMR = 0xF0
// amrToCFrameTypeShift / amrToCFrameTypeMask 抽出 ToC 字节里的帧类型(FT):
// ToC 布局为 F(bit7) FT(bits6-3) Q(bit2) P(bits1-0)(RFC 4867 §4.3.1)。
amrToCFrameTypeShift = 3
amrToCFrameTypeMask = 0x0F
// amrRTPHeaderBytes 是每帧送进解码器的 RTP 载荷头长度:CMR 1 字节 + ToC 1 字节。
amrRTPHeaderBytes = 2
// amrMaxSpeechBytes 是单帧语音位的上界:12.2 kbps 一帧 244 位 ⇒ 31 字节
// (RFC 4867 Table 1a)。用常量而非 `amrnb.Mode1220.Bytes()`:后者是方法调用,
// 不是常量表达式。
amrMaxSpeechBytes = 31
// amrFramePayloadBytes 是逐帧复用的载荷缓冲容量(不逐帧分配)。
amrFramePayloadBytes = amrRTPHeaderBytes + amrMaxSpeechBytes
)
// amrNBToWAV 是 `amr → wav` 这条转换路。它是**纯函数**:同样的输入产出同样的输出,
// 不读写外部状态、不发网络请求、不做缓存(design/25 §2.2)。
type amrNBToWAV struct{}
// From 返回源格式。
func (amrNBToWAV) From() Format { return FormatAMR }
// To 返回目标格式。
func (amrNBToWAV) To() Format { return FormatWAV }
// Transcode 把 AMR-NB 字节解成 8 kHz 单声道 PCM16 的 WAV。
//
// 三层上限里「格式相关」的两层在这里执行,且都在**解码前**:
// - 输入字节上限由 engine 执行(与格式无关,故不在本文件);
// - 时长上限:帧数 × 20 ms 是**可确证**的(不是估算),超限即 ErrInputTooLarge;
// - 产物字节上限:帧数 × 160 样本 × 2 字节 + 44 字节头是可**精确算出**的,
// 超限即 ErrOutputTooLarge,且此时一个样本都还没解(比「边解码边计数」更早、
// 更省内存,design/25 §5.1 允许「在解码前」)。
//
// 结构预检(魔数 / 帧长 / 截断 / 未知帧类型)同样在解码前完成:损坏的字节不会
// 解出一半再报错。ctx 在逐帧循环里被查,超时/取消立即返回,不泄漏 goroutine。
func (amrNBToWAV) Transcode(ctx context.Context, in []byte, limits Limits) (Result, error) {
frames, err := amrFrameCount(in)
if err != nil {
return Result{}, err
}
if duration := amrDuration(frames); duration > limits.MaxDuration {
return Result{}, fmt.Errorf("transcode: amr -> wav: %w: duration %s > %s",
ErrInputTooLarge, duration, limits.MaxDuration)
}
pcmBytes := frames * amrFrameSamples * wavPCMBytesPerSample
if total := wavHeaderBytes + pcmBytes; total > limits.MaxOutputBytes {
return Result{}, fmt.Errorf("transcode: amr -> wav: %w: %d bytes > %d",
ErrOutputTooLarge, total, limits.MaxOutputBytes)
}
return amrDecodeToWAV(ctx, in, frames, pcmBytes)
}
// amrDecodeToWAV 逐帧解码并**直接写进目标缓冲**(不是先攒 []int16 再复制一份
// WAV):峰值内存 = 输入 + 输出 ≈ 70 MB,与 design/25 §2.5 登记的口径一致。
// frames / pcmBytes 由调用方在解码前算定(故这里不再分配不可控的内存)。
func amrDecodeToWAV(ctx context.Context, in []byte, frames, pcmBytes int) (Result, error) {
hdr, err := wavHeader(pcmBytes, amrSampleRate, amrChannels)
if err != nil {
return Result{}, err
}
out := make([]byte, wavHeaderBytes+pcmBytes)
copy(out, hdr[:])
dec := amrnb.NewDecoder(amrnb.DecoderConfig{OctetAligned: true})
payload := make([]byte, 0, amrFramePayloadBytes)
off := wavHeaderBytes
decoded := 0
err = amrWalk(in, func(frame []byte) error {
if cerr := ctx.Err(); cerr != nil {
return cerr
}
// 存储帧(ToC + 语音位)= octet-aligned 载荷去掉 CMR 首字节,
// 因此补回 CMR 即可直接交给 vendored 解码器。
payload = append(payload[:0], amrPayloadNoCMR, frame[0])
payload = append(payload, frame[1:]...)
samples, derr := dec.Decode(payload)
if derr != nil {
return fmt.Errorf("transcode: amr frame %d: %w: %v", decoded, ErrDecodeFailed, derr)
}
if len(samples) != amrFrameSamples {
return fmt.Errorf("transcode: amr frame %d: %w: got %d samples, want %d",
decoded, ErrDecodeFailed, len(samples), amrFrameSamples)
}
writePCM16LE(out[off:], samples)
off += amrFrameSamples * wavPCMBytesPerSample
decoded++
return nil
})
if err != nil {
return Result{}, err
}
return Result{
Data: out,
Format: FormatWAV,
Source: FormatAMR,
SampleRate: amrSampleRate,
Channels: amrChannels,
DurationMs: amrDurationMs(frames),
Frames: frames,
}, nil
}
// amrWalk 逐帧走一遍 AMR-NB 存储格式(RFC 4867 §5):`#!AMR\n` 魔数,随后每帧
// 「1 字节 ToC + ceil(bits/8) 字节语音位」。它只分帧、只做结构检查,**不解码**,
// 因此可以安全用于「解码前」的帧数 / 时长 / 产物上限判定。
//
// visit 拿到的是含 ToC 的完整存储帧。visit 返回的错误经 %w 包裹上抛(保持
// ctx 超时的 errors.Is 链)。
func amrWalk(in []byte, visit func(frame []byte) error) error {
if len(in) < amrStorageHeaderBytes {
return fmt.Errorf("transcode: amr: %w: header truncated (%d bytes)", ErrDecodeFailed, len(in))
}
if string(in[:amrStorageHeaderBytes]) != amrNBMagic {
return fmt.Errorf("transcode: amr: %w: not an AMR-NB stream (`#!AMR\\n` magic missing)", ErrDecodeFailed)
}
body := in[amrStorageHeaderBytes:]
for index := 0; len(body) > 0; index++ {
ft := int(body[0]>>amrToCFrameTypeShift) & amrToCFrameTypeMask
size, ok := amrnb.FrameBytesByFT(ft)
if !ok {
return fmt.Errorf("transcode: amr frame %d: %w: reserved frame type %d", index, ErrDecodeFailed, ft)
}
if len(body) < 1+size {
return fmt.Errorf("transcode: amr frame %d: %w: truncated (need %d bytes, have %d)",
index, ErrDecodeFailed, 1+size, len(body))
}
if err := visit(body[:1+size]); err != nil {
return fmt.Errorf("transcode: amr: %w", err)
}
body = body[1+size:]
}
return nil
}
// amrFrameCount 数出存储流里的帧数(结构预检)。空流(只有魔数、没有帧)按
// 「坏的输入」处理:一条语音消息连一帧音频都没有,诚实报 ErrDecodeFailed
// 比交出一份 0 样本的 WAV 更不容易被误当成成功。
func amrFrameCount(in []byte) (int, error) {
frames := 0
if err := amrWalk(in, func([]byte) error { frames++; return nil }); err != nil {
return 0, err
}
if frames == 0 {
return 0, fmt.Errorf("transcode: amr: %w: no audio frames", ErrDecodeFailed)
}
return frames, nil
}
// amrDuration 由帧数**确证**时长(一帧 20 ms),绝不估算。
func amrDuration(frames int) time.Duration {
return time.Duration(frames*amrFrameMillis) * time.Millisecond
}
// amrDurationMs 是 amrDuration 的毫秒数形式(DTO 用)。
func amrDurationMs(frames int) int64 {
return int64(frames) * amrFrameMillis
}