症状
語学ポッドキャストには台本があり、行を押すとその発話の位置へ再生が移ります。そこへこんな報告が来ました。
台本の途中を押すと、その言葉が出る場所へ行くはずなのに行かない。一、二回は大丈夫だが、何度も移動するとずれる。
最初に疑ったのは画面とデータでした。保存された区間時刻(start/end)が実際の音と合っているか、話ごとに測ってみましたが合っていました。クリックが効かない話もありませんでした。問題は 音声ファイルそのもの にありました。
ffmpeg -i in.wav -c:a libmp3lame -q:a 4 out.mp3 # ← こう書き出していた
-q:a 4 は VBR(可変ビットレート)です。この一行がどうして「何度も移動するとずれる」という症状になるのか、が本記事の内容です。
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 — 同じ話で飛んだ先の誤差
言葉だけでは推測なので、実際のファイルで測りました。方法はプレイヤーの計算をそのまま真似ることです。
- 目標時刻 T を決める(全体の 10%, 20%, … 90%)。
- VBR なら Xing TOC を読んで T が属する区画のバイト位置を概算する。CBR なら比例式で計算する。
ffprobe -show_entries packet=pos,pts_timeで そのバイト位置に実際にあるフレームの時刻 を探す。- その差が「押したときに実際に行く場所の誤差」である。
| 話 | 形式 | 長さ | 10%〜90% の九地点での誤差(秒) | 最大 |
|---|---|---|---|---|
| #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 |
三つのことが見えます。
- 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-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 は 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はまるごと再生する短いクリップだけに。 - 一つの関数だけを見る試験は残りに対して偽りの安心を与える。同じ仕事をする関数を すべて 数えて試験に入れる。