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

191 lines
8.0 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 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
}