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