347 lines
13 KiB
Go
347 lines
13 KiB
Go
package transcode
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 黄金向量(design/25 §4.1 采纳 opencore-amrnb 为正确性基准、§8-T2 的验收判据)。
|
||
//
|
||
// 夹具怎么来的(可复现;生成命令原文,2026-10-05 在 Windows + ffmpeg 8.1.1 上执行):
|
||
//
|
||
// FF=/d/Bak/PROLIB/ffmpeg-8.1.1-full_build/bin/ffmpeg
|
||
// SIG="aevalsrc=0.6*sin(2*PI*440*t)*(0.6+0.4*sin(2*PI*3*t))+0.2*sin(2*PI*1200*t):s=8000:d=0.5"
|
||
// for br in 4750 5150 5900 6700 7400 7950 10200 12200; do
|
||
// $FF -f lavfi -i "$SIG" -ac 1 -c:a libopencore_amrnb -b:a $br -y mode$br.amr
|
||
// $FF -c:a libopencore_amrnb -i mode$br.amr -f s16le -ac 1 -ar 8000 -y mode$br.pcm
|
||
// done
|
||
// # mixed_modes.amr = 把 8 个文件按帧交错拼接(每帧自带 ToC,故混合模式合法流)
|
||
//
|
||
// 样例是**自造的合成信号**(三音正弦 + 幅度包络),不是任何真实语音;参考 PCM 由
|
||
// 本机 ffmpeg 的 `libopencore_amrnb` 解码器产出(`-f s16le -ar 8000 -ac 1`)。
|
||
// 夹具体积很小(AMR ≈3 KB + PCM ≈75 KB),因此**测试不需要装 ffmpeg**。
|
||
//
|
||
// 断言强度(**逐样本**,不是逐字节——原因见下):
|
||
//
|
||
// vendored 的 goamr-nb 是**部分移植**(其 dsp_decoder.go:13 自述
|
||
// Cb_gain_average 与自适应相位色散未移植),实测与 opencore-amrnb
|
||
// **逐字节不一致**。因此硬套「逐字节相等」会是一条永远红的假断言。
|
||
// 改成:帧数 / 样本数 / 时长**精确相等**(这一条仍是逐样本级的强断言),
|
||
// 再对全部样本逐个做「绝对差 ≤ goldenMaxAbsDiff」+ 全局 SNR / 相关系数 /
|
||
// 能量比三档下限。实测基线与阈值的余量记录在下面的常量旁。
|
||
const (
|
||
// goldenMinSNRDB 实测最差 16.0 dB(mode10200),阈值留 ~25% 余量。
|
||
goldenMinSNRDB = 12.0
|
||
// goldenMaxAbsDiff 实测最差 11192(mixed_modes),阈值取满量程的一半。
|
||
goldenMaxAbsDiff = 16384
|
||
// goldenMinCorrelation 实测最差 0.9874,阈值 0.97。
|
||
goldenMinCorrelation = 0.97
|
||
// goldenMaxRMSDrift 实测最差 |1−rms 比| = 0.021,阈值 0.05。
|
||
goldenMaxRMSDrift = 0.05
|
||
)
|
||
|
||
// goldenCases 是把夹具名映射到「人类可读说明」的表:8 个编码模式 + 1 个混合模式。
|
||
var goldenCases = []struct {
|
||
name string
|
||
what string
|
||
}{
|
||
{"mode4750", "AMR-NB 4.75 kbit/s(MR475)"},
|
||
{"mode5150", "AMR-NB 5.15 kbit/s(MR515)"},
|
||
{"mode5900", "AMR-NB 5.90 kbit/s(MR59)"},
|
||
{"mode6700", "AMR-NB 6.70 kbit/s(MR67)"},
|
||
{"mode7400", "AMR-NB 7.40 kbit/s(MR74)"},
|
||
{"mode7950", "AMR-NB 7.95 kbit/s(MR795)"},
|
||
{"mode10200", "AMR-NB 10.2 kbit/s(MR102)"},
|
||
{"mode12200", "AMR-NB 12.2 kbit/s(MR122)"},
|
||
{"mixed_modes", "逐帧交错的混合模式流"},
|
||
}
|
||
|
||
// TestGoldenAgainstOpenCORE 对每个夹具做一次完整转码,再逐样本与参考 PCM 比对。
|
||
func TestGoldenAgainstOpenCORE(t *testing.T) {
|
||
eng := NewEngine(DefaultLimits(), WithCodecs(DefaultCodecs()...))
|
||
if err := eng.RegistrationError(); err != nil {
|
||
t.Fatalf("codec registration: %v", err)
|
||
}
|
||
for _, tc := range goldenCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
src := readFixture(t, tc.name+".amr")
|
||
want := readFixture(t, tc.name+".pcm")
|
||
res, err := eng.Transcode(context.Background(), Request{
|
||
Data: src, Source: FormatAMR, Target: FormatWAV,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("transcode %s (%s): %v", tc.name, tc.what, err)
|
||
}
|
||
wantSamples := len(want) / wavPCMBytesPerSample
|
||
assertGoldenMeta(t, res, wantSamples)
|
||
got := wavDataSamples(t, res.Data)
|
||
if len(got) != wantSamples {
|
||
t.Fatalf("sample count: got %d, want %d (逐样本对照的前提)", len(got), wantSamples)
|
||
}
|
||
assertSamplesClose(t, got, int16Samples(want), tc.name)
|
||
})
|
||
}
|
||
}
|
||
|
||
// assertGoldenMeta 断言与参考 PCM 精确一致的那部分元信息:样本数、帧数、时长、
|
||
// 目标参数。这几项是**逐样本级**的强断言,不掺容差。
|
||
func assertGoldenMeta(t *testing.T, res Result, wantSamples int) {
|
||
t.Helper()
|
||
if res.Format != FormatWAV || res.Source != FormatAMR {
|
||
t.Errorf("formats: got %s -> %s, want amr -> wav", res.Source, res.Format)
|
||
}
|
||
if res.SampleRate != amrSampleRate || res.Channels != amrChannels {
|
||
t.Errorf("params: got %d Hz / %d ch, want %d Hz / %d ch",
|
||
res.SampleRate, res.Channels, amrSampleRate, amrChannels)
|
||
}
|
||
wantFrames := wantSamples / amrFrameSamples
|
||
if res.Frames != wantFrames {
|
||
t.Errorf("frames: got %d, want %d", res.Frames, wantFrames)
|
||
}
|
||
if wantMs := int64(wantFrames * amrFrameMillis); res.DurationMs != wantMs {
|
||
t.Errorf("duration: got %d ms, want %d ms", res.DurationMs, wantMs)
|
||
}
|
||
if len(res.Data) != wavHeaderBytes+wantSamples*wavPCMBytesPerSample {
|
||
t.Fatalf("wav size: got %d, want %d", len(res.Data), wavHeaderBytes+wantSamples*wavPCMBytesPerSample)
|
||
}
|
||
}
|
||
|
||
// assertSamplesClose 逐样本比对:绝对差上限 + SNR + 相关系数 + 能量比。
|
||
func assertSamplesClose(t *testing.T, got, want []int16, name string) {
|
||
t.Helper()
|
||
stat := comparePCM(got, want)
|
||
if stat.maxAbsDiff > goldenMaxAbsDiff {
|
||
t.Errorf("%s: max |diff| = %d > %d", name, stat.maxAbsDiff, goldenMaxAbsDiff)
|
||
}
|
||
if stat.snrDB < goldenMinSNRDB {
|
||
t.Errorf("%s: SNR = %.1f dB < %.1f dB", name, stat.snrDB, goldenMinSNRDB)
|
||
}
|
||
if stat.corr < goldenMinCorrelation {
|
||
t.Errorf("%s: correlation = %.5f < %.5f", name, stat.corr, goldenMinCorrelation)
|
||
}
|
||
if math.Abs(1-stat.rmsRatio) > goldenMaxRMSDrift {
|
||
t.Errorf("%s: rms ratio = %.3f, drift > %.3f", name, stat.rmsRatio, goldenMaxRMSDrift)
|
||
}
|
||
t.Logf("%s: frames=%d snr=%.1f dB max|d|=%d corr=%.5f rms_ratio=%.4f",
|
||
name, len(want)/amrFrameSamples, stat.snrDB, stat.maxAbsDiff, stat.corr, stat.rmsRatio)
|
||
}
|
||
|
||
// pcmStat 是一次比对的结果(逐样本统计量)。
|
||
type pcmStat struct {
|
||
snrDB float64
|
||
maxAbsDiff int
|
||
corr float64
|
||
rmsRatio float64
|
||
}
|
||
|
||
// comparePCM 逐样本统计 got 与 want 的差、SNR、相关系数与能量比。
|
||
func comparePCM(got, want []int16) pcmStat {
|
||
var sig, noise, dot, gotPower, wantPower float64
|
||
stat := pcmStat{}
|
||
for i := range got {
|
||
g, w := float64(got[i]), float64(want[i])
|
||
diff := g - w
|
||
if d := int(math.Abs(diff)); d > stat.maxAbsDiff {
|
||
stat.maxAbsDiff = d
|
||
}
|
||
sig += w * w
|
||
noise += diff * diff
|
||
dot += g * w
|
||
gotPower += g * g
|
||
wantPower += w * w
|
||
}
|
||
stat.snrDB = snrDB(sig, noise)
|
||
stat.corr = correlation(dot, gotPower, wantPower)
|
||
stat.rmsRatio = math.Sqrt(gotPower / wantPower)
|
||
return stat
|
||
}
|
||
|
||
// snrDB 算信号/噪声能量比(dB);噪声为零时返回 +Inf(完全一致就该是 +Inf)。
|
||
func snrDB(signal, noise float64) float64 {
|
||
if noise == 0 {
|
||
return math.Inf(1)
|
||
}
|
||
return 10 * math.Log10(signal/noise)
|
||
}
|
||
|
||
// correlation 算归一化相关系数;任一序列零能量时返回 0。
|
||
func correlation(dot, aPower, bPower float64) float64 {
|
||
if aPower == 0 || bPower == 0 {
|
||
return 0
|
||
}
|
||
return dot / math.Sqrt(aPower*bPower)
|
||
}
|
||
|
||
// readFixture 读一份夹具,并断言它非空(夹具被清空/截断时测试立刻红)。
|
||
func readFixture(t *testing.T, name string) []byte {
|
||
t.Helper()
|
||
data, err := os.ReadFile(filepath.Join("testdata", name))
|
||
if err != nil {
|
||
t.Fatalf("fixture %s: %v", name, err)
|
||
}
|
||
if len(data) == 0 {
|
||
t.Fatalf("fixture %s is empty", name)
|
||
}
|
||
return data
|
||
}
|
||
|
||
// wavDataSamples 从一份 WAV 里取出 PCM16 样本(只认本包写出的 44 字节头形态)。
|
||
func wavDataSamples(t *testing.T, wav []byte) []int16 {
|
||
t.Helper()
|
||
if len(wav) < wavHeaderBytes {
|
||
t.Fatalf("wav too short: %d bytes", len(wav))
|
||
}
|
||
if string(wav[wavOffRiffID:wavOffChunkSize]) != wavRiffID || string(wav[wavOffWaveID:wavOffFmtID]) != wavWaveID {
|
||
t.Fatalf("wav: bad RIFF/WAVE magic")
|
||
}
|
||
if string(wav[wavOffFmtID:wavOffFmtSize]) != wavFmtID || string(wav[wavOffDataID:wavOffDataSize]) != wavDataID {
|
||
t.Fatalf("wav: bad fmt/data chunk ids")
|
||
}
|
||
size := int(binary.LittleEndian.Uint32(wav[wavOffDataSize:]))
|
||
if size != len(wav)-wavHeaderBytes {
|
||
t.Fatalf("wav: data size %d != payload %d", size, len(wav)-wavHeaderBytes)
|
||
}
|
||
out := make([]int16, size/wavPCMBytesPerSample)
|
||
for i := range out {
|
||
out[i] = int16(binary.LittleEndian.Uint16(wav[wavHeaderBytes+i*wavPCMBytesPerSample:]))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// int16Samples 把小端 PCM16 字节转成样本。
|
||
func int16Samples(pcm []byte) []int16 {
|
||
out := make([]int16, len(pcm)/wavPCMBytesPerSample)
|
||
for i := range out {
|
||
out[i] = int16(binary.LittleEndian.Uint16(pcm[i*wavPCMBytesPerSample:]))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestAMRRejectsBrokenInput 覆盖 design/25 §8-T2 要求的边界样本:空文件、坏头、
|
||
// 截断帧、保留帧类型、只有魔数。它们一律 ErrDecodeFailed(「坏了」,与「不会」
|
||
// 的 ErrUnsupportedFormat 分开),且绝不返回半份结果。
|
||
func TestAMRRejectsBrokenInput(t *testing.T) {
|
||
codec := amrNBToWAV{}
|
||
good := readFixture(t, "mode12200.amr")
|
||
headerOnly := []byte(amrNBMagic)
|
||
badMagic := append([]byte("XXXXXX"), good[amrStorageHeaderBytes:]...)
|
||
truncated := good[:len(good)-1]
|
||
reservedFT := append([]byte(amrNBMagic), 0x48, 0x00) // FT=9(保留值)
|
||
|
||
cases := []struct {
|
||
name string
|
||
data []byte
|
||
}{
|
||
{"empty file", nil},
|
||
{"magic only", headerOnly},
|
||
{"bad magic", badMagic},
|
||
{"truncated last frame", truncated},
|
||
{"reserved frame type", reservedFT},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
res, err := codec.Transcode(context.Background(), tc.data, DefaultLimits())
|
||
if !errors.Is(err, ErrDecodeFailed) {
|
||
t.Fatalf("err = %v, want ErrDecodeFailed", err)
|
||
}
|
||
if len(res.Data) != 0 {
|
||
t.Fatalf("broken input produced %d bytes of output", len(res.Data))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAMRSilentFramesPinned 把「已知边界」钉住:SID(FT 8)与 no-data(FT 15)
|
||
// 帧在 vendored 解码器里产出 **160 样本静音**(错误隐藏/CNG 未移植,见
|
||
// amrnb/VENDORED.md)。这条测试不假装它做得好——只是把行为写死,避免悄悄漂移。
|
||
func TestAMRSilentFramesPinned(t *testing.T) {
|
||
codec := amrNBToWAV{}
|
||
// FT=15(no data):ToC = 15<<3 = 0x78,零字节载荷;两帧。
|
||
noData := append([]byte(amrNBMagic), 0x78, 0x78)
|
||
res, err := codec.Transcode(context.Background(), noData, DefaultLimits())
|
||
if err != nil {
|
||
t.Fatalf("no-data stream: %v", err)
|
||
}
|
||
if res.Frames != 2 || res.DurationMs != 2*amrFrameMillis {
|
||
t.Fatalf("no-data stream: frames=%d duration=%d", res.Frames, res.DurationMs)
|
||
}
|
||
samples := wavDataSamples(t, res.Data)
|
||
if len(samples) != 2*amrFrameSamples {
|
||
t.Fatalf("no-data stream: %d samples, want %d", len(samples), 2*amrFrameSamples)
|
||
}
|
||
for i, s := range samples {
|
||
if s != 0 {
|
||
t.Fatalf("no-data stream: sample %d = %d, want silence (CNG 未移植)", i, s)
|
||
}
|
||
}
|
||
// FT=8(SID):ToC = 8<<3 = 0x40 + 5 字节载荷 ⇒ 仍是一帧静音。
|
||
sid := append([]byte(amrNBMagic), 0x40, 0, 0, 0, 0, 0)
|
||
res, err = codec.Transcode(context.Background(), sid, DefaultLimits())
|
||
if err != nil {
|
||
t.Fatalf("sid stream: %v", err)
|
||
}
|
||
if res.Frames != 1 {
|
||
t.Fatalf("sid stream: frames=%d, want 1", res.Frames)
|
||
}
|
||
}
|
||
|
||
// TestAMRDeterministic 断言同一输入两次转码逐字节相同(纯函数契约)。
|
||
func TestAMRDeterministic(t *testing.T) {
|
||
codec := amrNBToWAV{}
|
||
in := readFixture(t, "mixed_modes.amr")
|
||
first, err := codec.Transcode(context.Background(), in, DefaultLimits())
|
||
if err != nil {
|
||
t.Fatalf("first pass: %v", err)
|
||
}
|
||
second, err := codec.Transcode(context.Background(), in, DefaultLimits())
|
||
if err != nil {
|
||
t.Fatalf("second pass: %v", err)
|
||
}
|
||
if len(first.Data) != len(second.Data) {
|
||
t.Fatalf("size drift: %d vs %d", len(first.Data), len(second.Data))
|
||
}
|
||
for i := range first.Data {
|
||
if first.Data[i] != second.Data[i] {
|
||
t.Fatalf("byte %d drifted: %d vs %d", i, first.Data[i], second.Data[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAMRLimitsRejectedBeforeDecode 断言「格式相关」的两层闸:可确证的时长与
|
||
// 精确可算的产物字节数都在**解码前**判定(因此报的是上限哨兵,而不是解到一半
|
||
// 才发现)。
|
||
func TestAMRLimitsRejectedBeforeDecode(t *testing.T) {
|
||
codec := amrNBToWAV{}
|
||
in := readFixture(t, "mode12200.amr") // 26 帧 = 520 ms
|
||
limits := DefaultLimits()
|
||
|
||
t.Run("duration over limit", func(t *testing.T) {
|
||
l := limits
|
||
l.MaxDuration = time.Duration(10*amrFrameMillis) * time.Millisecond
|
||
if _, err := codec.Transcode(context.Background(), in, l); !errors.Is(err, ErrInputTooLarge) {
|
||
t.Fatalf("err = %v, want ErrInputTooLarge", err)
|
||
}
|
||
})
|
||
t.Run("output over limit", func(t *testing.T) {
|
||
l := limits
|
||
l.MaxOutputBytes = wavHeaderBytes + 10*amrFrameSamples*wavPCMBytesPerSample
|
||
if _, err := codec.Transcode(context.Background(), in, l); !errors.Is(err, ErrOutputTooLarge) {
|
||
t.Fatalf("err = %v, want ErrOutputTooLarge", err)
|
||
}
|
||
})
|
||
t.Run("deadline respected", func(t *testing.T) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
||
defer cancel()
|
||
<-ctx.Done()
|
||
if _, err := codec.Transcode(ctx, in, limits); !errors.Is(err, context.DeadlineExceeded) {
|
||
t.Fatalf("err = %v, want context.DeadlineExceeded(由 engine 归为 ErrTimeout)", err)
|
||
}
|
||
})
|
||
}
|