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 }