LabHub
블로그

블로그

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

한국어English日本語中文

증상

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

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

처음 의심한 곳은 화면과 데이터였습니다. 저장된 구간 시각(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

세 가지가 보입니다.

실측 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% 커집니다. 탐색 정확도와 맞바꾸기에 싼 값입니다.

정리

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다