# 当 52 个测试全绿，却解不出一段正弦波

> 纯 MoonBit OGG/Vorbis 解码器开发中，用 stb_vorbis 做交叉验证揪出 7 处规范偏差、外加一处解码流程坑的经历；以及后来换用真实文件验证时，又发现一处被自己「合理解释」掉的疏漏。

## 一、症状：一个「全绿」的失败

moonvorbis 是一个纯 MoonBit 实现的 OGG/Vorbis 解码器，不依赖任何 C 库，从字节流到 PCM 全部自己完成。写下这篇文章时，它有 52 个单元测试，全部通过；`moon check` 无警告；`moon fmt` 无改动。

然后我拿它解一个真实的 OGG 文件——用 Python 的 soundfile 生成的一段 440 Hz 正弦波，内容完全已知。

```
解码失败: missing setup framing flag
```

连 setup header 都没解析过去。

把 parse 层的问题勉强绕开之后，第二次运行给出的不是报错，而是更糟的东西：一段**饱和方波**。所有样本都被顶到 ±1.0，RMS 高达 1.02，而原信号的 RMS 应该是 0.283。

这两个失败分属不同层次。第一个是解析挂了，一目了然；第二个是解析全部通过、程序不报任何错、只是算出来的东西完全不对——这才是真正难查的那类。

## 二、为什么自造测试抓不到

关键问题在测试向量是从哪来的。

那些测试里的字节序列，是我照着 Vorbis 规范文档，按自己的理解手写出来的。于是形成了一个闭环：

```
我理解规范 → 写出测试向量 → 写出实现 → 测试通过 ✅
     ↑__________________________________________|
```

如果我对规范的理解是错的，那么**测试向量和实现是错在同一边的**——它们共享同一个错误前提，然后互相印证。测试跑得再多次，也只是在确认「我的实现和我的理解一致」，而这从来不是问题所在。

这不是覆盖率不够。52 个测试覆盖了 codebook、floor、residue、mapping、IMDCT 的每一条分支。问题是**参照系本身偏了**。

要发现这种偏差，需要一个独立于我理解的参照系。

## 三、方法：引入外部对照

三件事：

**1. 一个「可执行的规范」。** [stb_vorbis.c](https://github.com/nothings/stb) 是 public domain 的单文件 C 实现，被广泛使用和验证过。规范文档是散文，可以有多种读法；stb_vorbis 是同一份规范的一种**确定性读法**，可以直接对着看。

**2. 已知内容的输入。** 用 libvorbis（经 Python soundfile）生成正弦波 OGG。信号内容是我自己定的，就避免了「输出看起来像音频」这种模糊判据。

**3. 连续量而非布尔量做判据。** 不再问「解码成功了吗」，而是问「解出来的波形和原波形差多少」：

```python
a, _ = sf.read('decoded.wav', always_2d=True)
b, _ = sf.read('source.ogg',  always_2d=True)
corr = np.corrcoef(a[:, ch], b[:, ch])[0, 1]
rms  = np.sqrt(np.mean((a[:, ch] - b[:, ch]) ** 2))
```

相关系数能把「大致对但有系统偏差」「方向对但幅度错」「只是相位偏了」区分开，而布尔判据会把它们全部归为「跑通了」。

## 四、七处规范偏差，外加一处流程陷阱

以下每一条都是「读规范时理解偏了」。修复之前，这七条全部处于「测试通过」的状态——有的是因为测试断言的正是那个错误的理解，有的是因为根本没有测试覆盖到那条分支。**测试全绿只说明实现与理解自洽，不说明理解正确。**

这七个问题不是一次全发现的，而是顺着「解一次 → 崩一次 / 错一次 → 修一次 → 再解」的循环一个一个蹦出来的。按发现路径分，它们落进三类：

- **对照源码发现的**：stb_vorbis 是同一份规范的一种确定性读法，逐位对齐读出来的东西，位数一多一少立刻现形（第 1、2、3、6 条）。
- **相关系数量化出来的**：程序不崩、不报错，只是数值全错，靠连续量把「错」钉死（第 5 条）。
- **panic 直接暴露的**：越界这类会自己跳出来，但「为什么越界」要回头读源码才想通（第 4、7 条）。

第 8 条不算规范偏差——它发生在解码流程里，header 全对也会踩到，所以单独放在最后。

### 1. mapping 的 submaps 与 coupling_steps 各有 1 位标志位

`coupling_steps`（立体声耦合的对数）前面有一个 1 位 flag，置位才读 8 位；`submaps` 前面**也**有一个独立的 1 位 flag。

我原来的实现是靠推断：

```moonbit
// 错的：用 submaps 的数量去猜有没有 coupling
if submaps > 1 {
  coupling_steps = br.read_bits(8) + 1
  ...
}
```

这个启发式在多数立体声文件上「恰好」成立——因为立体声通常确实有多个 submap。但它不是规范。规范里这是两个互相独立的标志位，我就这么少读了一位，后面所有字段跟着错位。

```moonbit
// 对的：两个独立的 1 位 flag
let submaps = if br.read_bits(1) == 1 { br.read_bits(4) + 1 } else { 1 }

let mut coupling_steps = 0
if br.read_bits(1) == 1 {
  coupling_steps = br.read_bits(8) + 1
  ...
}
```

**这类「看起来合理的猜测」是最危险的**，因为它比错误更隐蔽：它在常见的输入上能工作。

这条的定位方式不太一样——不是读源码读出来的，而是**把 stb_vorbis 的 mapping 解析段和我的逐字段对齐**，比到 `submaps` 那一项时发现它的读取位置比我预期的早一位。两个实现的位流一旦错开，后续所有字段都会读出不同的值，这种「逐字段对表」很快就能把错位点夹出来。

### 2. `lookup1_values` 要向下取整，不是四舍五入

lookup type 1 的 codebook 有 `r` 个 lookup value，`r` 满足 `r^dim <= entries`。

我原来写的是「让 `r^dim` 最接近 `entries`」——当时觉得这样更精确，避免浮点误差：

```moonbit
// 错的
let lo = pow_int(r - 1, dim)
let hi = pow_int(r, dim)
if entries - lo < hi - entries { r - 1 } else { r }
```

规范要的是 floor。多出来的那个 r 会让 VQ 查找越界。

这条是对着 stb_vorbis 的 `lookup1_values` 读出来的：

```c
static int lookup1_values(int entries, int dim)
{
   int r = (int) floor(exp((float) log((float) entries) / dim));
   if ((int) floor(pow((float) r+1, dim)) <= entries)   // (int) cast for MinGW warning;
      ++r;                                              // floor() to avoid _ftol() when non-CRT
   ...
   return r;
}
```

它先在对数空间估算 `r = entries^(1/dim)`，如果 `(r+1)^dim` 仍然不超过 `entries` 再进一位——算的就是「最大的 r 使 `r^dim ≤ entries`」。注意函数名里那个 `floor` 不是随手起的：**向下取整是定义的一部分，不是数值精度的副产品**。我当初恰恰是把它当成精度问题处理的，才写出了「取最接近的」。散文规范里「找到最大的 r」这句话，我读丢了「最大」二字所隐含的那个上界约束。

### 3. ordered codebook 的码长每次游程后自增 1

ordered codebook 的码长用游程编码。我漏掉了规范伪码里的一行：**每处理完一个游程，`current_length` 递增 1**，而不是重新读 5 位。

```moonbit
// 错的：每个游程都重读 5 位
current_length = br.read_bits(5) + 1

// 对的
current_length += 1
```

stb_vorbis 里对应的是 `codeword_lengths` 那段的 `++current_length;`——对着源码看，这一行非常显眼，对着规范散文读，很容易滑过去。

### 4. lookup type 1 的 multiplicands 要按 base-r 预展开

这一条的症状是运行时 panic：

```
RuntimeError: unreachable at Codebook::decode_vq
```

原因：VQ 解码时按 `dim` 为步长从 `multiplicands` 里取系数，但 lookup type 1 在文件里存的是 `r` 个标量（`r^dim = entries`），每 `dim` 个标量组合成一个码字。必须在解析阶段就展开成 `entries × dim` 的二维表，否则索引会直接越界。

```moonbit
let dim = self.dimensions
let base = entry * dim          // 展开后：每个 entry 占 dim 个连续下标
for ii in 0..<nn {
  targets[ch][offset + ii] = book.multiplicands_f[base + ii]
}
```

### 5. Vorbis 的 32 位浮点数**不是 IEEE 754**

`min_value` 和 `delta_value` 是 32 位，但它们不是 IEEE 754 单精度。我按位重解释成了 IEEE，结果就是**幅度全错**——这正是那段饱和方波的来源。

Vorbis 用的是自定义格式：**1 位符号 + 10 位指数 + 21 位尾数**，值为 `mantissa × 2^(exponent - 788)`，指数没有偏移编码，且尾数不隐含前导 1。

```moonbit
/// Vorbis 专用的 32 位浮点解包：1 位符号 + 10 位指数 + 21 位尾数。
/// 值为 `mantissa * 2^(exponent - 788)`，与 IEEE 754 不同，不能按位重解释。
fn float32_unpack(x : UInt) -> Float {
  let mantissa = (x & 0x1fffffU).reinterpret_as_int()
  let sign = (x & 0x80000000U) != 0U
  let exp = ((x & 0x7fe00000U) >> 21).reinterpret_as_int()
  let res = if sign { Float::from_int(0 - mantissa) } else { Float::from_int(mantissa) }
  @math.scalbnf(res, exp - 788)
}
```

**注意这个错误的隐蔽性**：程序不崩溃、不报错、解出来的确实「是音频」，只是每个值是错的。除了一听就知道不对，只有算相关系数才能量化地看出它错得多彻底。

### 6. 音频 packet 的第 0 位是 packet type

Vorbis 的包分两类：头部包（identification / comment / setup）和音频包。音频包的**第一个 bit 是 packet type，必须为 0**，其后才是 mode number。

我原来直接从第 0 位读 mode number，等于每个音频包都少读了一位——后面 mode number、floor、residue 全部跟着偏，解出来的东西看着像音频但完全不是。

```moonbit
// 音频 packet 的第一个 bit 是 packet type（0 = audio，1 = header）
if br.read_bits(1) != 0 {
  return Err("not an audio packet")
}
let mode_number = br.read_bits(ilog(self.setup.modes.length() - 1))
```

stb_vorbis 里这一步在 `vorbis_decode_initial`，不在解码主体里：

```c
// check packet type
if (get_bits(f,1) != 0) {
   if (IS_PUSH_MODE(f))
      return error(f,VORBIS_bad_packet_type);
   while (EOP != get8_packet(f));
   goto retry;
}
```

**值得留意的是这个检查的位置**：它被放在「取 mode number、算窗边界」之前的第一步。我原先一直以为类型位是解码主体内部的事，所以翻源码时总在找数值处理的那几段，反而漏掉了最前面这几行。

### 7. residue 的 classifications 数组大小，与 type 2 的 target 长度

最后一个 panic：

```
RuntimeError: unreachable at Residue::decode (Array::set)
```

`classifications` 数组原本只分配了 `part_read` 个元素。但内层循环一次会消费 `classwords` 个（`classwords` 是 classbook 的维度），在 `pcount` 接近 `part_read` 时索引会越过末尾。需要留出 `part_read + classwords` 大小。

同时，type 2 的 residue 是 non-interleaved 的，系数缓冲的有效长度是 `n * 2`（即 `n2` 的两倍），不是 `n2`。

两条都是先被 panic 指出位置，再对着 stb_vorbis 的 `decode_residue` 反推出来的。

type 2 那条是直接读出来的，源码第一行就把两种情况分开了：

```c
unsigned int actual_size = rtype == 2 ? n*2 : n;
```

classifications 那条则修正了我的一个想当然。我原以为参考实现一定也把数组开大了，实际去看才发现它分配的就是 `part_read` 个元素——防越界靠的是**内层循环条件**：

```c
for (i=0; i < classwords && pcount < part_read; ++i, ++pcount) {
```

`pcount < part_read` 这个子句保证它绝不会写到末尾之外，所以数组不需要多留余量。我这边缺的是这个条件，于是选择了另一条等效路径：把数组开到 `part_read + classwords`。两种做法都能防住越界，但**「把数组开大」掩盖了「循环本该有界」这件事**——它让代码看起来是对的，而真正的约束被藏进了数组尺寸里。

**panic 只告诉你「这里越界了」，不告诉你「这里应该多大」——后者只能去源码里找。**

### 8. 块切换时的窗边界（解码流程陷阱）

修完上面 7 条之后，波形终于「是」那个正弦波了，但仍有系统性偏差。这第 8 条不在 header 解析层，而在解码流程里——严格说不算「规范偏差」：**长块与短块切换时，sin 窗的有效区域要按小块的尺寸收窄**。

```moonbit
let left_start = if blockflag == 1 && prev == 0 {
  (n - self.info.blocksize0) >> 2
} else { 0 }
let right_start = if blockflag == 1 && next == 0 {
  (3 * n - self.info.blocksize0) >> 2
} else { n2 }
let right_end = if blockflag == 1 && next == 0 {
  (3 * n + self.info.blocksize0) >> 2
} else { n }
```

这一条的教训是：**即使 header 全对，解码流程本身也可能有自己的坑**。相关系数在这里的作用体现得最明显——波形肉眼看着完全正常，但相关系数就是卡在 0.98 上不去，逼着我去找那个「还差一点」的地方。

## 五、修复后的验证

上面那套方法没有停留在一次性的手工比对。判据一旦确定，就可以固化成脚本——[`tools/verify.py`](../tools/verify.py) 从零跑完「生成已知信号 → 编码 OGG → 本解码器解码 → 计算相关系数」，不需要预置任何测试文件：

```bash
python tools/verify.py            # 单声道 440 Hz
python tools/verify.py --stereo   # 立体声，左 440 Hz / 右 554 Hz
python tools/verify.py --stereo --noise   # 宽带噪声
python tools/verify.py a.ogg b.ogg        # 拿现成的真实文件验证
```

三种输入的实际输出：

```
单声道 正弦波, 44100 Hz, 原始 44100 帧, 解码 44100 帧
  声道 0:  相关系数 0.9999   RMS 误差 0.0047   OK

立体声 正弦波, 44100 Hz, 原始 44100 帧, 解码 44100 帧
  声道 0:  相关系数 0.9999   RMS 误差 0.0047   OK
  声道 1:  相关系数 1.0000   RMS 误差 0.0041   OK

立体声 噪声, 44100 Hz, 原始 44100 帧, 解码 44100 帧
  声道 0:  相关系数 0.9970   RMS 误差 0.0208   OK
  声道 1:  相关系数 0.9972   RMS 误差 0.0210   OK
```

**噪声那一行比正弦波低，这是对的，不是退步。** 正弦波能量集中在单一频点，只有少数几个 residue 分区非零；宽带噪声会激励到几乎所有分区，任何一处的量化处理不够精确都会累积进误差。判据如实反映了「这段输入更难」——这正是用连续量的价值：它给出的是**程度**，而「成功」这个布尔值会把这三种情况全部抹平。

残余误差来自两处，都在预期内：Vorbis 本身是有损压缩（我对比的是解码结果而非原始波形），以及 float32 精度。

### 补记：真实文件又揪出一处

上面这份输出曾经写着「解码 44608 帧」而原始只有 44100 帧，我当时的解释是「编解码器固有的前后沿填充」，并让脚本按较短长度对齐后比较。**这个解释是错的。**

真实原因在 OGG 页头里：每个页有一个 64 位的 granule position，记录该页结束时流中合法的 PCM 帧总数。Vorbis 按块编码，最后一个 packet 解出的样本往往长于原始音频，**多出来的部分必须按 granule position 裁掉**——这是规范要求的步骤，不是可选的对齐技巧。我没有实现它。

之所以没被发现，是因为验证素材是我自己生成的：编码器和被测解码器出自同一份理解，它俩对「末尾该多长」有一致的错误，于是差值被当成了噪声。换成真实文件，这个盲区立刻塌了——同一份脚本，十个来自 arcade / pygame / MathJax 的 ogg 文件，**十个全部帧数不符**（下表列出其中五个）：

| 文件 | 末页 granule | 参考解码 | 本解码器 |
| --- | --- | --- | --- |
| `laser1.ogg` | 70895 | 70895 | 71744 |
| `phaseJump1.ogg` | 17920 | 17920 | 17984 |
| `rockHit2.ogg` | 74380 | 74380 | 74560 |
| `invalid_keypress.ogg` | 22050 | 22050 | 22464 |
| `house_lo.ogg` | 78331 | 78331 | 78336 |

末页 granule 与参考实现的帧数**逐个吻合**，差值则各不相同（849、64、180、414、5）——不是常量偏移，而正是每个文件各自尾部多解出的样本数。裁剪补上之后，十个文件帧数全部相等，相关系数 0.9970–0.9999。

这件事比前面那七处偏差更值得记：**它不是「规范读错了」，而是「少做了一步」，而且这个疏漏自带一个听起来很合理的解释。** 一个错误的结论，如果配上足够像样的理由，就比没有结论更危险——它会让你停止追问。

从 RMS 1.02 的饱和方波，到相关系数 0.9999——**这个数字才是「解码器真的工作了」的证据**，而不是「测试全绿」。

## 六、没有素材的分支，只能自己造

上面这套办法有个前提：**得有一份能走到那条分支上的素材**。修复之后，「支持列表」上写着 residue type 1/2，可实际上被真实文件走过的只有 type 2 的多声道交错路径——type 0、type 1，以及它们的多声道形式，一行都没被执行过。

这不是「没测到」那么简单。我去查了有没有现成的合规素材：

- Xiph 官方**不存在** Vorbis 测试向量。2003 年 Michael Smith 在邮件列表里的原话是：「We don't currently have any sort of generalised test suite for testing all the different parts of the specification - and there are no encoders in existence which can exercise every part.」
- libvorbis 自 1.0 起只用 floor 1 和 residue type 1/2。floor 0 只有 pre-1.0 的 beta 版用过。
- stb_vorbis 更直接：解析到 floor 0 就是一句 `return error(f, VORBIS_feature_not_supported)`。

也就是说，**这些分支在真实世界里从来没有被任何文件走到过**。没有素材，就没有对照；没有对照，就只能靠「我读规范读对了」的自信——而那正是前面 7 处偏差的成因。

于是写了 [`tools/vorbisgen.py`](../tools/vorbisgen.py)：按规范直接拼比特流，拼出来的流同时交给本解码器和 libvorbis。**两边都接受、且解出的 PCM 一致**才算读对。

### 一个反直觉的前提

第一版生成器造出的是「静音」流——音频 packet 里只写一个「floor 未使用」位。两个解码器都接受，帧数也一致，看起来像是成功了。

但它什么都没测到。因为 floor 未使用时，residue 的解码是被整个跳过的：stb_vorbis 把这类声道的 `do_not_decode` 置位，而通用路径的内层循环带 `if (!do_not_decode[j])` 守卫，全被跳过时**一个比特都不读**。

所以素材必须带真实的 floor 数据。而要让 floor 曲线非零，还得把 finalY 选得够大——曲线若为 0，residue 乘上去什么都不剩，通路写错了输出也一样。

### 结果

接上真实的 floor 与 residue 之后：

| 通路 | 修前 | 修后 |
| --- | --- | --- |
| residue type 1，单声道 | 相关系数 1.000000 | 1.000000 |
| residue type 1，多声道 | 0.937943 | 1.000000 |
| residue type 0 | 直接报错「not yet supported」 | 1.000000 |

多声道 type 1 那 0.94 是实打实的缺陷。stb_vorbis 的 `decode_residue` 在通用路径里，classbook 码字是**逐声道各读一个**、VQ 数据也逐声道写进各自的 buffer；我的实现每个 `(pass, pcount)` 只读一个码字，而且只写进 `targets[0]`。少读一半比特、只填一个声道。

它之所以一直没暴露，是因为真实立体声文件一律走 type 2 的交错分支。**「支持 type 1」这句话，在修之前是一句没有依据的话。**

type 0 则按交织语义实现：`step` 个码字按下标步进铺开，而不是像 type 1 那样顺序相接。这个区别只有在对素材上才看得出来——两者读的比特数完全相同，全零的 multiplicands 下输出也一样。

### 生成器自己也错了两次

写生成器是在写第二份实现，它同样会错，而且错得更隐蔽——它错了，两个解码器会一致地接受一份畸形流，看起来还是「通过」。

- **packed float 的尾数不带隐含的前导 1。** 值等于 `mantissa × 2^(exp − 788)`，mantissa 就是 21 位字段的整数值。我按 IEEE 的习惯只存了小数部分，于是 0.25 这种正好落在 2 的整数次幂上的值被编成 0，解出来也真的是 0——整段残差被静默抹平，输出静音，而两个解码器依旧一致。
- **residue 的解码长度是块长的一半，不是四分之一。** stb 里的 `n2 = n >> 1` 中，`n` 取的是完整块长。

第一个错误尤其值得记：它不报错、不越界，只是让数据变成常量零。**如果没有第三个参照物，这类错误可以一直待在「验证通过」里。**

### 两个实现说法不一致时，拿什么裁

residue 之后轮到 lattice codebook，也就是 `sequence_p` 置位的码本。两个参考实现给出的语义**不一样**：

- stb_vorbis 在解析码本时把 lookup 值**跨整张表连续累加**；解码时若 `sequence_p` 置位，还会再累加一次。
- libvorbis 的 `_book_unquantize` 里，`float last=0.f;` 声明在 `for(i...)` 循环**内部**——累加器每个 entry 归零；累加**只在建表时做一次**，解码时不再动。

两种读法在同一份数据上给出不同的量化表，只能二选一。而规范原文没有细到这一步，没有现成的第三方事实可查。

能裁决的是**覆盖面**：`sequence_p` 的主要用户是 floor 0，而 stb_vorbis 遇到 floor 0 直接 `return error(f, VORBIS_feature_not_supported)`，libvorbis 支持。也就是说 stb 这条路径同样**从来没被真实文件走过**——和 residue type 1 的多声道形式属于同一类：代码写了，但不在任何实际执行路径上。

按 libvorbis 的语义实现后，6 组 `sequence_p` 用例与 libvorbis 的相关系数全部 1.000000。

**「哪个实现更权威」在这里不是一个风格问题。** 两个实现都写过这段代码，只有一个被真实文件走过；证据不在代码里，在覆盖面里。

### 判据的分辨率也是判据的一部分

`sequence_p` 的回归扫描接好之后全绿。但「测试通过」本身不能说明测试**能失败**——于是反过来做了一次对照：把累加器提到 entry 循环外（即 stb 那种跨表连续累加），重新跑。

6 组 `sequence_p` 用例全部变红，8 组不相关的用例不受影响——测试确实能失败，也确实只对这件事敏感。

但红的方式值得记一笔：相关系数只掉到 **0.977**，离 0.99 的阈值不远。两种语义算出的量化值仍落在同一量级，波形整体形状相似，只是有系统性偏移——**相关系数对「数值整体差一点」这种错误并不锋利**。

所以另外补了一个断言精确位模式的单元测试。同样是这一步：先用错误语义确认它会红，再改回来。**判据能失败只是及格线，还得看它在多大的错误上才失败。**

同一个毛病也会长在别的地方。做演示动画时要给网页截图，我截了一张 `demo/index.html`，看到状态行写着「解码器已就绪」，就判定页面渲染正常——实际上那个页面的排版一直是塌的：拖放区用的是 `<label>`，而 `<label>` 默认 `inline`，那个带虚线边框的盒子被压成一窄条，和上面的副标题叠在一起。

**我当时的判据是「状态文字对不对」，而我要判断的是「页面画得对不对」——判据和结论之间根本没有关系。** 换成按像素找一下内容边界，一秒就能看出来。改法只是给 `#drop` 加一句 `display: block`。

这和前面 granule position 那次是同一类错误：不是判据不够严，是判据测的根本不是那件事。而它比解码器里的偏差更容易溜过去，因为「页面能跑」和「页面好看」在肉眼扫一眼时太像了。

### 多读一位，不会让输出变成垃圾

最后一块是 floor 0。素材造出来了，两个解码器也都不报错，帧数一致——但相关系数卡在 **0.98 上下**，而且我的输出整体只有 libvorbis 的约六十分之一。

0.98 这个数字比 0.5 更折磨人。它说明形状大体是对的，只是哪里差了一点；它也说明我面对的不是一个「没接上」的分支，而是一个**几乎对**的通路。

于是开始排除。第一个怀疑浮点精度——floor 0 的包络在 LSP 系数之间会掉到接近零，增益随之飙到几千，这种量级下精度应该很敏感。把频带映射改成逐位对齐 C 的 double 提升，重跑：**相关系数逐位相同**，一个比特都没变。假设否定。

第二个怀疑增益对 LSP 的扰动敏感，于是单独写了脚本测：把 LSP 全体加 1e-5，增益最大只变 1.0001 倍。假设又否定。

两次排除的价值不在结论，在于形式：**「改了没变化」才是排除假设的证据**，「精度应该不影响」不是。相关系数逐位相同这个结果，比任何推理都干脆。

回去读参考实现，问题一眼就出来了。`floor1_inverse1` 的第一件事是读那一位「floor 已用」标志；而 `floor0_inverse1` 的第一个读位就是幅度：

```c
int ampraw=oggpack_read(&vb->opb,info->ampbits);
if(ampraw>0){ /* also handles the -1 out of data case */
```

floor 0 **没有独立的标志位**——幅度字段兼作标志，读出 0 就是「本帧没有 floor 数据」。上一层 `mapping0_inverse` 也不管这件事，它只按 `inverse1` 返回不返回 NULL 来决定这个声道要不要解残差。

而我照 floor 1 的样子，给两种 floor 都先读了一位。生成器是照 libvorbis 的布局写的，没有这一位——**所以两个解码器从这一位开始读的就不是同一串比特了。**

关键在于，这种错位的表现方式：我的幅度读成 62 而不是 63，残差的起点晚一位，第四个 LSP 码字读到了残差的第一位。全都不报错，全都只让结果**偏一点**。两边仍然解出高度相似的波形，于是相关系数停在 0.98。

改完之后 9 组用例（奇数阶与偶数阶、单声道与多声道、不同采样率与 Bark 频带数、幅度为 0）相关系数全部 1.000000。原先那组 `order 3 / 2ch` 还会崩在 `main.mbt:16`——错位解析把数组读越界了，这个崩溃也一并消失。

同样做了反向对照：把多读的那一位植回去，8 组全部变红，相关系数**与修复前逐位一致**（0.987908 / 0.774597 / 0.983637 / 0.891749 / 0.641339 / 0.940305）。数值能精确复现，说明扫描抓住的确实是这一件事，修复也确实是这一件事。

`lsp_dim > 1` 的素材在数学上自身退化（增益会爆到 1e32），这条通路扫不到，于是 `decode_vq_set` 的「最后一个码字只用到部分分量」这个分支改由断言精确位模式的单元测试覆盖——同样先植入累加语义确认它会红。

**这一处和前面 7 处偏差是同一类：不报错、不越界，只让结果偏移一点。** 它比崩溃型的错误难查得多，因为它看起来像「快对了」。

### 这里的独立对照到底是什么

写生成器并没有解决「同一份理解」的问题——生成器和解码器仍然出自同一个人的同一次理解。真正提供独立性的是 **libvorbis**：生成器只是把素材造出来的手段，去掉的是「没有素材」这个借口，而不是「需要外部判据」这个要求。

这两件事很容易混为一谈，而混起来的后果是：你会觉得「我自己造了素材，所以我已经验证过了」。其实是——**我造了素材，所以我终于有资格去做验证了。**

## 七、可迁移的部分

几个不限于 Vorbis 的结论：

1. **自造测试有结构性盲区。** 如果测试向量的来源和实现的来源是同一个人的同一次理解，它们无法互相证伪。这不是覆盖率的锅。

2. **找一个「可执行的规范」。** 散文规范可以有多种读法，一份被广泛使用的参考实现只有一种。对着源码看，能发现对着文档读时滑过去的东西——比如那行 `++current_length`。

3. **用连续量做判据。** 「成功/失败」把「完全正确」和「方向对但幅度错 80%」归成了一类。相关系数、RMS 差值这类连续量，能把错误定位到具体是哪个环节。

4. **区分「panic 型」和「静默型」错误。** 越界、断言失败这类会自己跳出来，是幸运的；数值格式理解错、少读一个标志位这类不报错的，必须靠外部对照才能发现。**后者的危险程度远高于前者**——它们会在测试全绿的情况下顺利通过。

5. **当心「在常见输入上恰好成立」的启发式。** `if submaps > 1` 猜 coupling 那次最典型。这类代码比明显的 bug 更麻烦，因为它能通过你所有的测试。

6. **把判据固化成脚本，而不是一次性的手工比对。** 查出这 7 处偏差的过程里，每次修改后都要重新算一遍相关系数。手工做这件事意味着它只会发生在「我觉得可能有问题」的时候；写成脚本意味着它能在每次改动后自动跑一遍，且跑的是同一套判据。**一次性的验证会退化成回忆，可执行的验证才会退化成回归测试。**

7. **给差异一个解释之前，先证明这个解释是必然的。** 帧数对不上那次，我几乎是立刻就想到「编码器填充」，因为听起来合理，于是不再追问——直到换成真实文件，十个全部对不上，才回头去查 granule position。**一个被解释掉的差异和一个被验证过的差异，看起来是一样的，但前者只是你不再看它了。** 解释越顺口，越值得停下来算一遍：如果它是「固有的填充」，为什么多出的量在每个文件上都不同？

8. **「支持某特性」是关于代码路径的陈述，不是事实。** 写进 README 的支持列表，实际依据常常只是「我写了那段代码」。真正该问的是：**枚举所有分支，逐一问哪条有素材走到过。** 立体声 type 1 写了、编译了、在支持列表里躺了很久，从来没被执行过一次。

9. **自己造素材去掉的是「缺素材」，不是「缺判据」。** 生成器和解码器出自同一份理解，它无法自证。素材造出来之后，独立的对照仍然必须来自第二个实现。把这两件事混起来，就会得到「我自己造了素材，所以已经验证过了」这种双重自信。

10. **让数据变成常量零的错误，比让程序崩溃的错误危险得多。** 尾数打包错那次不报错、不越界，只是把一整段残差抹成 0，两个解码器还一致地接受。**这类错误唯一的出路是有第三个参照物。**

11. **「改了没变化」才是排除假设的证据。** 怀疑浮点精度那次，改动后相关系数逐位相同，才算把它真正排除；在那之前，「精度应该不影响」只是一句猜测。**用排除法时，判据必须是「逐位不变」这种硬结果，不能是「我觉得这儿不是原因」。**

12. **判据必须测的是你要下结论的那件事。** 要判断「页面画得对不对」，却去看状态文字对不对——两者之间没有关系，绿灯也就没有意义。自造测试全绿说明不了问题，本质上也是这个：它测的是「实现与自己的理解自洽」，而你要的结论是「这份理解本身是对的」。

## 八、现状

修复后，解码器支持：

- OGG 容器：页解析、CRC-32 校验、跨页 packet 组装、按 granule position 裁剪输出
- 三个 Vorbis 包头
- codebook（Huffman + VQ lookup type 1/2，含 `sequence_p`）、floor 0、floor 1、residue type 0/1/2
- 立体声耦合、IMDCT、含长短块切换的重叠相加
- 输出多声道 16-bit PCM WAV

至此 Vorbis I 的解码通路——floor 的两种形式、residue 的三种形式、codebook 的三种 lookup——全部走通。

验证覆盖：十个真实文件（arcade / pygame / MathJax 的音效素材，单声道与立体声）相关系数 0.9970–0.9999；生成的素材补齐了真实文件走不到的分支——granule 裁剪 12 组、residue type 0/1 单声道与多声道与 `sequence_p` 共 14 组、floor 0 的奇数阶与偶数阶与多声道与多采样率共 9 组，与 libvorbis 的相关系数均为 1.000000。另有 77 个单元测试。

除命令行外，也编译了 WebAssembly 版本，可以在浏览器里直接解码播放——见 [`demo/`](../demo/)。
演示动画由 `tools/make_demo_gif.py` 对 `demo/record.html` 逐帧截图合成，页面按
`?t=<毫秒>` 渲染流程中的某一刻，所以这段动画不含预置画面，且可复现。
