Skip to content
Published on

MP3 用 VBR 编码会让字幕点击错位 — 用实测看 CBR 与 VBR 的区别

分享
Authors

症状

语言学习播客带有字幕,点击某一行,播放位置会跳到那句话的位置。然后收到了这样的反馈:

点击字幕中间应该跳到那句话的位置,但没有。一两次还好,多跳几次就不同步了。

最先怀疑的是界面和数据。我逐集测量了保存的区间时间(start/end)与实际声音是否一致——一致。也没有哪一集的点击失效。问题出在 音频文件本身

ffmpeg -i in.wav -c:a libmp3lame -q:a 4 out.mp3    # ← 之前是这样编码的

-q:a 4VBR(可变比特率)。这一行是如何变成"多跳几次就错位"的,就是这篇文章的内容。

MP3 内部如何把时间换成字节位置

浏览器里的 audio.currentTime = 83.2,最终必须变成"从文件的第几个字节开始读"。MP3 没有时间到字节的对照表,只是帧按时间顺序排列,所以播放器会做两件事之一。

CBR(固定比特率) 靠计算就够了。每一帧大小相同,字节位置与时间成正比。

字节位置 = 时间 ÷ 总时长 × 总字节数

VBR(可变比特率) 下帧的大小各不相同,这个比例就失效了。因此编码器会在文件第一帧写入 Xing 头,里面放一张 TOC(table of contents)。TOC 长 100 字节——把文件按播放时间分成 100 格、每格 1%,每格起始的字节位置用 0~255 近似记录。

也就是说,对 VBR 文件播放器只知道"这个时间大概在这一格附近"。四分钟的一集里 一格覆盖 2.4 秒。在一句话只有两三秒的集里,偏这么多就整句跳到别的行去了。

实测 1 — 同一集里跳转位置的误差

只靠推理不算证据,所以我用真实文件测了。方法是照搬播放器的计算。

  1. 定一个目标时间 T(总时长的 10%、20%、…、90%)。
  2. VBR 就读 Xing TOC,估算 T 所在格的字节位置;CBR 就用比例式计算。
  3. ffprobe -show_entries packet=pos,pts_time 找出 那个字节位置上实际的帧的时间
  4. 两者之差就是"点击后实际跳到的位置的误差"。
格式时长10%~90% 九个点的误差(秒)最大
#13CBR118.3s−0.04, −0.04, −0.03, −0.02, −0.01, −0.01, 0.00, 0.01, 0.020.04s
#869VBR113.6s0.22, 0.20, −0.08, −0.07, −0.35, −0.48, −0.81, −0.28, −0.820.82s
#980VBR144.8s−0.06, 0.25, −0.18, −0.63, −0.07, −0.23, 0.13, −0.92, −0.910.92s

能看出三点。

  • CBR 无论点哪里都在 0.04 秒以内,人察觉不到。
  • VBR 最多偏 0.9 秒。在句子边界处会听到 上一句的结尾
  • VBR 的误差 越靠文件后面越大:70%~90% 处集中出现 −0.8~−0.9 秒。这与"一两次还好,多跳几次就错位"的反馈完全吻合——一开始点前面,越到后来点的越靠后。

实测 2 — 只看文件如何区分格式

从文件名或扩展名看不出 CBR 还是 VBR。两个信号可以区分。

文件头。 LAME 编码器会在 VBR 文件的第一帧写入 Xing 四个字母,CBR 文件写入 Info。在文件前 64KB 里找这几个字母即可。

帧大小的波动。ffprobe -show_entries packet=size 取出帧大小,算变异系数(标准差 ÷ 平均值)。

对 96 集同时应用两种方法的结果:

创建日期集数文件头帧大小变异系数
09-067Info(CBR)70.002
09-0715Info(CBR)150.002
09-0867Xing(VBR)670.489
09-097Xing(VBR)70.467

CBR 是 0.002,VBR 是 0.47~0.49——两个信号在全部 96 集上一致,界限清楚。这张表引出了下一节的发现。

为什么同一个问题发生了两次

这个问题在最初发现时就修过。把编码对话集的函数改成了 -b:a 96k(CBR),并把已有的 524 集重新编码。还加了一个测试:

def test_the_encoder_is_not_asked_for_variable_quality(self):
    src = inspect.getsource(podcast.assemble)
    self.assertNotIn("-q:a", src)

测试通过了,于是放心了。可上表里 09-08、09-09 的 74 集歌曲是 VBR。歌曲集由另一个函数assemble_song)编码,那里的 -q:a 4 原封不动。测试只看了 assemble 一个函数,对其余的什么也没说。

只守一个函数的测试,会对其余函数给出 虚假的安心。测试改成了这样:

def test_every_factory_encode_is_constant_bitrate(self):
    for fn in (podcast.assemble, podcast.assemble_song, blog_podcast.assemble):
        src = inspect.getsource(fn)
        self.assertIn("libmp3lame", src)     # 必须是编码 mp3 的函数
        self.assertNotIn("-q:a", src)
        self.assertIn("MP3_BITRATE", src)

def test_variable_quality_survives_only_where_nothing_is_seeked(self):
    # 即使漏了函数名也能抓到:在整个模块里找 "-q:a"。
    # 只有把单个词整段播放的 tts_pronounce 例外——它没有跳转。
    ...

改后的测试在修复前的文件上准确指向了 assemble_song。应该先问的是测试 没有覆盖什么

重新编码

把 74 集重新编码为 CBR。成品不覆盖,而是以新的哈希键上传后只改数据库里的 audio_key——这样正在播放旧文件的人不会和新文件混在一起。

ffmpeg -i in.mp3 -ar 44100 -c:a libmp3lame -b:a 96k out.mp3
重新编码的集数74(失败 0)
文件大小平均 1.47MB → 1.50MB(+2%)
保存的区间时间与实际声音与重新编码前相同(大多 0.06 秒)

96k CBR 比原来 VBR 的平均 84kbps 略高。音质无损失,文件大 2%。用来换取精确的跳转,代价很低。

总结

  • MP3 没有时间到字节的对照表。CBR 用比例式精确定位,VBR 用 100 格的 TOC 估算。
  • 实测:CBR 最大 0.04 秒,VBR 最大 0.92 秒,且 VBR 的误差越靠后越大。
  • 只看文件区分格式,看 Xing/Info 头和帧大小变异系数(0.002 对 0.47)。
  • 需要跳转的文件用 -b:a 编码。-q:a 只留给整段播放的短片段。
  • 只看一个函数的测试对其余函数给出虚假的安心。把做同一件事的函数 全部 数出来放进测试。