← ブログに戻る

Whisper文字起こしを使わない歌詞同期: 実測誤差0.12秒

カラオケ画面で歌詞の行が光る、あれを自分の練習ツールに付けようとしました。歌詞はあります。決めたいのは、その各行が何秒に来るかという一点だけです。

最初に思いついたのは音声認識でした。Whisper で歌を文字起こしして、出てきた文字列を手元の歌詞と突き合わせて、時刻を移す。そういう構成を紹介する記事もライブラリもあって、実際に動くものが公開されています。

この構成は採りませんでした。 遠回りなうえ、外れ方が悪いからです。代わりに使ったのが強制アライメント (CTC forced alignment) で、歌詞を正解として渡し、時刻だけを貼らせます。

解くべきなのは「いつ」だけです

音声認識は「何を歌っているか」を解く道具です。ところが今回は、その答えが歌詞という形ではっきり手元にあります。持っているものをもう一度解かせて、答え合わせをして、時刻だけ抜き出す。手数が増えるだけなら、まだいいのですが。

音声認識と強制アライメントの入出力の違い。音声認識は音声だけを入力にして聞き取った文字列と時刻を返すため、歌詞そのものが書き換わりうる。強制アライメントは音声と歌詞の両方を入力にして、渡した文字に時刻だけを貼る

音声認識強制アライメント
入力音声音声 + 歌詞
出力聞き取った文字列と時刻渡した文字に貼った時刻
外れ方歌詞が書き換わる時刻がずれる

外れ方が違います。 差が出るのは表の最後の行で、選ぶ理由になったのもここでした。

日本語の歌唱は音声認識の苦手な相手です。母音が伸び、子音が落ち、伴奏が被ります。Whisper の文字起こしが会話音声で実用になるからといって、歌唱でそのまま通るわけではありません。そこで出た聞き取り違いが歌詞として保存されると、後から直すには原曲を聴き直すことになります。一方で時刻がずれた場合は、歌詞そのものは無傷です。ずれた行を見つけて押し直せば済みます。

同じ Whisper でも、API と手元の GPU で精度が違う

ここは私の実感です。日本語の歌を OpenAI の API (whisper-1) に通すと、聞き取りはかなり崩れます。ところが同じ Whisper をローカルの GPU で回すと、精度は体感でずいぶん上がります。

API 側はモデルもデコードの設定も選べません。手元で回すなら large-v3 や turbo を選べますし、VAD の掛け方、ビーム幅、initial_prompt まで触れます。どれがどれだけ効いているかは切り分けていないので、原因までは断定しません。

つまり「音声認識は歌に弱い」は、正確には「API 越しの Whisper は歌に弱い」です。歌詞が手元に無いなら、ローカルの Whisper が現実的な出発点になります。それでもこの記事の選択は変わりません。 歌詞が既にあるなら、聞き取りの精度がどれだけ上がっても、書き換わる余地を残す理由がないためです。

どちらも間違えます。違うのは、間違えた後に何が残るかです。

今回使ったモデルは MMS_FA です。Meta の多言語音声モデル MMS (Massively Multilingual Speech) から、アライメント用に配られている版で、日本語もこれ 1 つで扱えます。

中身は CTC (Connectionist Temporal Classification) という仕組みです。音声を 20 ミリ秒ほどの区間に刻み、区間ごとに「どの文字らしいか」と「どの文字でもない」の確率を出します。文字がいつ鳴ったかを直接は教わっていないのに、この確率の並びから、どの文字がどこにあるかを引き出せます。時刻が欲しい今回に向いているのはここです。

配られている形式は ONNX (Open Neural Network Exchange) で、学習に使ったフレームワークに縛られずに読める入れ物です。容量は 1.2GB。実行は onnxruntime に任せますが、これはブラウザ側の音程検出で既に積んでいたので、アライメントのために重い依存が増えることはありませんでした。

漢字をそのまま渡すと、中国語として整列されます

ここでいう CTC アライナは、さきほどのモデルを呼んで整列を実行する側、つまりライブラリのことです (ctc-forced-aligner)。渡した文字を、先にローマ字へ直してから音と照合します。多言語を 1 つのモデルで扱うために、文字の種類をラテン文字へ寄せる作りだからです。変換には uroman を使います。実体は unidecode です。

unidecode は漢字を中国語読みにします。

「泣」 → Qi4

日本語の歌詞をそのまま渡すと、日本語の音声に対して中国語読みの列を当てにいくことになります。エラーにはなりません。それらしい時刻が返ってきます。 これが一番たちが悪い型です。

先に pykakasi で仮名にしてから渡します。変換はこれで 4 回。 どれが抜けても処理は止まらず、それらしい結果だけが返ってきます。

歌詞の1行がCTCに届くまでの変換の連なり。通す順番は、行のテキスト「泣かした事もある」をpykakasiで仮名にし、音にならない記号を落とし、unidecodeで1文字ずつローマ字にし、normalize_uromanで1文字1トークンに整えてからCTCに渡す。pykakasiを抜くと漢字がそのままunidecodeに入り「Qi4 ka shi ta」という中国語読みの列になるが、エラーは出ず、それらしい時刻が返る

英語のフレーズが混じる行は、ラテン文字がそのまま残るので変換を通しても壊れません。

読点や記号は落とします。音として現れないものに時刻を貼っても、行の頭と終わりを決める役には立ちません。

伴奏が入っていると、ギターを声だと誤認識します

音源をそのまま渡すと、CTC がギターやシンセを声と取り違えて時刻がずれます。これは音程検出のときと同じ話なので、対処も同じで、Demucs でボーカルを分離してから渡すことになります。CPU で 4 分の曲に約 2 分。

分離した音声は残しません。音源を手元に持たない方針は、歌詞を扱うときも変えていません。

行の終わりは、間奏を飲み込みます

素直に組むと、こういう行ができます。

(サビ終わりの 1 行)   106.26 - 141.02

35 秒です。実際には 112 秒あたりで歌い終わっていて、残りは間奏です。CTC は行の最後の文字を次に歌い出すまで引き延ばすので、間奏がまるごとその行のものになります。

対処は 2 つ。

1 つは、文字ごとの <star> トークンです。各文字の前に「何でもない音」を挟むと、歌の無い区間がそこに逃げます。挟まないと、間奏のぶんが前後の文字に配られて、時刻が後ろへずれていきます。

もう 1 つは、行の中で音が途切れた場所を終わりにすることです。文字と文字の間が空いたら、そこで歌い終わったものとして切ります。しきい値は 4 秒にしています。

最初は 2 秒でした。サビ終わりにアドリブを挟む行が、途中で切れました。間奏はもっと長いので、4 秒まで上げても間奏は飲み込みません。

どこまで当たるか

人が付けた時刻を正解にして測りました。 手動字幕のある曲を選び、字幕から歌詞のテキストだけを取り出してアライナに渡し、返ってきた時刻を元の字幕と比べます。55 行の曲での結果です。

行の頭のずれ
中央値0.12 秒
平均0.68 秒
0.3 秒以内45 / 55 行
1 秒以内50 / 55 行
最大12.1 秒

中央値の 0.12 秒は、歌っていて気付かない範囲です。平均が中央値の 5 倍以上あるところに、この手法の性格が出ています。

ずれ方に癖があります。 数行だけが数秒飛び、残りはほぼ合っています。外れたのは曲の終盤 3 行と、サビ頭の 2 行でした。

使う側から見ると、これは悪い性質ではありません。全体が 0.7 秒ずれる方式だと、全行が少しずつ気持ち悪くなります。数行だけ飛ぶなら、その行だけ直せば残りは使えます。

なお、渡す行は正解と同じ区切りにしないと測れません。字幕の 1 件に改行が入っていると、全文からは 2 行に見えます。区切りが変わると pykakasi の読みまで変わるので、行数を合わせるだけでは同じものを測ったことになりません。

出来上がりを疑う手がかりを別に持つ

数行だけ飛ぶのなら、どの行が飛んだかを人に見せる必要があります。ところが、CTC のスコアは当てになりませんでした。外れた行のスコアが高いことがあります。

そこで別の経路で音を見ている数字を使いました。このツールは音程検出 (SwiftF0) で譜面を作っているので、歌い出しの時刻がそちらからも出ます。CTC と音程検出は互いを参照していないので、両方の歌い出しが 0.3 秒以内で揃っていれば、少なくとも片方の思い込みではありません。

揃わなかった行に印を付けて出します (歌詞そのものは伏せています)。

   8   79.26-  86.01  (8 行目の歌詞)
   9   86.06-  92.84 ? (9 行目の歌詞)

25/29 行に時刻が付きました。譜面の歌い出しと揃った行: 25/29

この ? は間違いの印ではありません。譜面と突き合わせて確かめられなかった、それだけを表します。揃わない行には、理由のはっきりしたものが混ざります。

25/29 行  いとしのエリー     揃わないのは繰り返しのサビ 4 行
38/52 行  からだ☆ダンダン   揃わないのは「GO!」など音程の無い掛け声

音程の無い叫びや台詞には音符が立たないので、譜面側に比べる相手がいません。原理的に揃わない行を「間違い」と呼ぶと、直しようのない警告が並ぶだけになります。

揃った行が 7 割を切るときは、別のことを疑います。音源と歌詞が別バージョンで、尺の違うリマスターに別バージョンの歌詞を当てている、というような場合です。

同じ数字が二度出ないことに気づく

ここが山でした。この作業で一番の収穫は、誤差そのものより、その誤差が再現するかどうかを確かめたことにあります。

上の誤差表を書いた後、念のため同じ音源・同じコードでもう一度測りました。 違う数字が出ました。

中央値平均最大0.3 秒以内
1 回目0.09 秒0.23 秒4.3 秒49 / 55
2 回目0.12 秒0.67 秒12.1 秒46 / 55

最大は 4.3 秒と 12.1 秒。同じものを測ったとは言えません。

同じ音源を2回通したときの誤差の違いと、その原因。CTCの整列は決定的だが、手前のDemucsがランダムな時間シフトを引くため、分離されたボーカルが毎回変わる

犯人は手前でした。CTC の整列そのものは決定的で、動いていたのは分離です。

Demucs の --shifts は既定が 1 です。推論の前にランダムな時間シフトを引きます。この機能は複数回の結果を平均してノイズを均すためのもので、平均を取る効果は 2 以上でないと出ません。 既定の 1 は、ばらつきだけを足していたことになります。

0 を渡しました。以降は同じ音源から同じ時刻が出ます。

まとめ

  • 歌詞が手元にあるなら強制アライメントを使う。音声認識は要らない。間違えた後に残るものが違う
  • 日本語は先に仮名にする。漢字のまま渡すと中国語読みで整列され、しかもエラーにならない
  • CTC は行末を次の歌い出しまで引き延ばす。<star> を挟み、途切れた場所で切る
  • ずれ方に癖がある。中央値 0.12 秒に対して最大 12.1 秒。特定の行だけが飛ぶ
  • 出来上がりの検算は、別経路の数字 (譜面の歌い出し) と突き合わせる
  • 「一度うまくいった」と「いつでも同じものが出る」は別のこと。 2 回測って初めて、ばらついていたのが分離だと分かった

効いたのは最後の項目です。1 回目の測定だけで記事にしていたら、再現しない数字を、実測と称してそのまま公開していたことになります。

感想

音声認識の記事は多いのに、アライメントの記事はあまり見かけません。だから書きました。

やってみると、歌詞を光らせたかっただけなのに、一番長く触っていたのは誤差の測り方でした。精度を上げる手より、測り直す手のほうが効いています。MV の字幕に並んでいるあの時刻が人の手で置かれたものだと気付いたのも、作ってからでした。自動で近づけることはできますが、近づけたものを信じてよいかは、そこから別に確かめることになります。

かなりニッチな部類だと思います。ただ、テキストが手元にあって時刻だけが欲しい場面は、歌詞に限りません。必要になった人には、そのまま効くはずです。

参考

8000行でわかる大規模言語モデル 関連書籍 8000行でわかる大規模言語モデル nanochat 解説 · LLM 作り方 · Karpathy · $48でGPT-2級を訓練する全工程を読む (Zennで全章無料) 書籍ページを見る →