- Published on
VBR MP3 breaks transcript clicks — CBR vs VBR, measured
- Authors

- Name
- Youngju Kim
- @fjvbn20031
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.
- Pick a target time T (10%, 20%, … 90% of the duration).
- For VBR, read the Xing TOC and estimate the byte for T's slice. For CBR, use the proportion.
- Use
ffprobe -show_entries packet=pos,pts_timeto find the time of the frame that actually sits at that byte. - The difference is the error you get when you click.
| Episode | Format | Length | Error at 10%…90% (s) | Max |
|---|---|---|---|---|
| #13 | CBR | 118.3s | −0.04, −0.04, −0.03, −0.02, −0.01, −0.01, 0.00, 0.01, 0.02 | 0.04s |
| #869 | VBR | 113.6s | 0.22, 0.20, −0.08, −0.07, −0.35, −0.48, −0.81, −0.28, −0.82 | 0.82s |
| #980 | VBR | 144.8s | −0.06, 0.25, −0.18, −0.63, −0.07, −0.23, 0.13, −0.92, −0.91 | 0.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:
| Created | Episodes | Header | Frame-size CV |
|---|---|---|---|
| 09-06 | 7 | Info (CBR) 7 | 0.002 |
| 09-07 | 15 | Info (CBR) 15 | 0.002 |
| 09-08 | 67 | Xing (VBR) 67 | 0.489 |
| 09-09 | 7 | Xing (VBR) 7 | 0.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-encoded | 74 (0 failures) |
| File size | avg 1.47 MB → 1.50 MB (+2%) |
| Stored segment times vs actual audio | unchanged (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/Infoheader and the frame-size CV (0.002 vs 0.47). - Anything that is seeked gets
-b:a. Keep-q:afor 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.