Skip to content

Split View: MP3 를 VBR 로 구우면 대본 클릭이 어긋난다 — CBR 과 VBR 의 차이를 실측으로

|

MP3 를 VBR 로 구우면 대본 클릭이 어긋난다 — CBR 과 VBR 의 차이를 실측으로

증상

언어 학습 팟캐스트에는 대본이 있고, 대본의 줄을 누르면 그 말이 나오는 자리로 재생 위치가 옮겨 갑니다. 그런데 이런 보고를 받았습니다.

대본 중간을 누르면 그 말이 나오는 자리로 가야 하는데 가지 않는다. 한두 번은 괜찮은데 여러 번 옮기면 싱크가 어긋난다.

처음 의심한 곳은 화면과 데이터였습니다. 저장된 구간 시각(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바이트입니다 — 파일을 재생 시간 기준 1% 씩 100칸으로 나누고, 각 칸이 시작하는 바이트 위치를 0~255 로 어림해 적은 표입니다.

즉 VBR 파일에서 플레이어가 아는 것은 「이 시각은 대략 이 칸 언저리」 뿐입니다. 4분짜리 회차면 한 칸이 2.4초를 덮습니다. 대사 하나가 2~3초인 회차에서 그만큼 어긋나면 통째로 다른 줄로 갑니다.

실측 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 로 다시 구웠습니다. 완성본은 덮어쓰지 않고 새 해시 키로 올린 뒤 DB 의 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 는 통째로 재생하는 짧은 클립에만.
  • 한 함수만 보는 시험은 나머지에 대해 거짓 안심을 준다. 같은 일을 하는 함수 전부를 세어 시험에 넣는다.

VBR MP3 breaks transcript clicks — CBR vs VBR, measured

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.