Skip to content
Published on

VBR MP3 breaks transcript clicks — CBR vs VBR, measured

Share
Authors

The symptom

The language-learning podcast has a transcript, and clicking a line moves playback to where that line is spoken. Then this report came in:

Clicking the middle of the transcript should jump to where that line is spoken, but it does not. Once or twice is fine; after jumping around several times it drifts out of sync.

The first suspects were the UI and the data. I measured the stored segment times (start/end) against the actual audio, episode by episode — they matched. No episode had broken click handling. The problem was the audio file itself.

ffmpeg -i in.wav -c:a libmp3lame -q:a 4 out.mp3    # ← this is how it was encoded

-q:a 4 means VBR (variable bitrate). How that one line turns into "it drifts after several jumps" is what this post is about.

How a time becomes a byte offset inside an MP3

audio.currentTime = 83.2 in a browser has to become "start reading at byte N". MP3 has no time-to-byte table; it is just frames laid end to end. So a player does one of two things.

With CBR (constant bitrate) it is arithmetic. Every frame is the same size, so byte position is proportional to time.

byte offset = time ÷ total duration × total bytes

With VBR the frames vary in size and that proportion breaks. So the encoder writes a Xing header into the first frame with a TOC (table of contents) — 100 bytes long. The file is split into 100 slices of 1% of playback time, and each slice's starting byte is stored as a 0–255 approximation.

So all a VBR player knows is "this time is roughly in this slice". On a four-minute episode one slice covers 2.4 seconds. When a line is two or three seconds long, that error lands you on a different line.

Measurement 1 — how far a jump lands from its target

Reasoning is not evidence, so I measured real files by imitating what a player does.

  1. Pick a target time T (10%, 20%, … 90% of the duration).
  2. For VBR, read the Xing TOC and estimate the byte for T's slice. For CBR, use the proportion.
  3. Use ffprobe -show_entries packet=pos,pts_time to find the time of the frame that actually sits at that byte.
  4. The difference is the error you get when you click.
EpisodeFormatLengthError at 10%…90% (s)Max
#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

Three things stand out.

  • CBR stays within 0.04 s anywhere in the file — below what a person notices.
  • VBR is off by up to 0.9 s. At a line boundary you hear the end of the previous line.
  • VBR error grows toward the end of the file: −0.8 to −0.9 s clusters at 70–90%. That matches the report exactly — early clicks land near the start, later clicks land near the end.

Measurement 2 — telling the formats apart from the file alone

You cannot tell CBR from VBR by the file name. Two signals do it.

The header. LAME writes the four letters Xing into VBR files and Info into CBR files, in the first frame. Look for them in the first 64 KB.

Frame size variation. Pull frame sizes with ffprobe -show_entries packet=size and compute the coefficient of variation (standard deviation ÷ mean).

Applied to 96 episodes:

CreatedEpisodesHeaderFrame-size CV
09-067Info (CBR) 70.002
09-0715Info (CBR) 150.002
09-0867Xing (VBR) 670.489
09-097Xing (VBR) 70.467

CBR at 0.002, VBR at 0.47–0.49 — both signals agreed on all 96 files and the boundary is unmistakable. That table leads to the next section.

Why the same bug happened twice

This bug had been fixed once already. The function that encodes dialogue episodes was switched to -b:a 96k (CBR) and 524 existing episodes were re-encoded. A test was added:

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

The test passed, so it felt done. Yet the table shows 74 song episodes from 09-08 and 09-09 are VBR. Song episodes are encoded by a different function (assemble_song), and it still had -q:a 4. The test only looked at assemble, so it said nothing about anything else.

A test that guards one function gives false comfort about the rest. The test now reads:

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)     # must be an mp3-encoding function
        self.assertNotIn("-q:a", src)
        self.assertIn("MP3_BITRATE", src)

def test_variable_quality_survives_only_where_nothing_is_seeked(self):
    # Scan the whole module for "-q:a" so a missing name still gets caught.
    # tts_pronounce is the one allowed exception — a single word played whole, never seeked.
    ...

Run against the pre-fix file, the new test pointed straight at assemble_song. The question to ask first was what the test did not cover.

Re-encoding

The 74 episodes were re-encoded to CBR. The finished file is never overwritten: the new file goes up under a new content-hash key and only the database's audio_key changes, so a listener mid-playback never gets a mix of old and new.

ffmpeg -i in.mp3 -ar 44100 -c:a libmp3lame -b:a 96k out.mp3
Value
Episodes re-encoded74 (0 failures)
File sizeavg 1.47 MB → 1.50 MB (+2%)
Stored segment times vs actual audiounchanged (mostly 0.06 s)

96k CBR is slightly above the 84 kbps the old VBR averaged. No audible loss, 2% larger files — a cheap trade for exact seeking.

Takeaways

  • MP3 has no time-to-byte table. CBR finds the byte by proportion; VBR guesses through a 100-slot TOC.
  • Measured: CBR max 0.04 s, VBR max 0.92 s, and VBR error grows toward the end.
  • To classify a file, check the Xing/Info header and the frame-size CV (0.002 vs 0.47).
  • Anything that is seeked gets -b:a. Keep -q:a for short clips that are played whole.
  • A test that covers one function says nothing about the others. Count every function that does the same job and put them all in the test.