概要
歌詞テキストをカナ読みに変換して歌唱音源に forced alignment するパイプラインで、読み推定の誤り(例:「二人」→ニニン)によりアライメントが行ごと崩壊する問題に当たりました。対策として、読みエンジン間で読みが割れた行だけ、CTCの対数尤度(forced alignmentのスコア)を候補読みごとに比べて音源に合う読みを選ぶ方法を実装したところ、「眩しい明日を」の「明日」が実際の歌唱どおりアスと判定されるなど、テキストだけでは原理的に決められない読みを音源から正しく回収できました。その実装と実測結果を書きます。
背景
歌唱音源から替え歌歌唱動画を自動生成するパイプラインでは、元歌詞をカナ化し、wav2vec2のCTC出力に torchaudio.functional.forced_align を掛けてモーラごとの歌唱タイミングを取っています。
元歌詞のカナ化に間違いがあると、forced alignmentの結果に悪影響を与えることがあります。今回サンプルとして用いたYOASOBI「夜に駆ける」では、「二人だけの空が広がる夜に」が「ニニンダケノ…」になり、直前の行末尾のロングトーン「(ように)に〜」に吸着してしまい、行全体の対応が1つずつずれてしまうことがありました。
そこで、なるべく読み推定の精度を上げて、forced alignmentの精度を高めるために、読み候補が複数ある場合に、歌唱音源に基づいて、適切な読みを選択する処理を検討しました。
方針
2段構えにします。
- 読みが曖昧な行の推定: pyopenjtalk-plusとMeCab + unidic-liteそれぞれで読みを取得し、長音正規化(ヨウ≒ヨー等を吸収)しても読みが異なる行を「曖昧な行」としました。
- 音響による判定: 曖昧な行について、候補読みごとに音源区間とのforced alignmentスコアを比べ、尤度の高い読みを採用する
2では、全歌詞のアライメントにはwav2vec2のCTC対数確率(log_probs、フレーム×語彙の行列)が必要ですが、これは再利用可能なので、読み候補の数だけアライメントしても、計算量はほぼ増えません。実装としては第一候補に基づいて各行に対する音源区間を推定し、曖昧な行に対応する区間だけ(前後0.5秒のマージン付きで切り出して)、候補の数だけアライメントします。
なお、行区間を第一候補の読みから推定している点には循環があります。背景で見たとおり誤読はアライメントを行ごと崩壊させうるので、区間が大きくずれていれば区間内の候補比較も成立しません。それでも実用上機能するのは、候補間の違いが行内の1〜2モーラ程度で、区間境界のズレの多くは前後0.5秒のマージンで吸収できるためです。また読みの確定後は、選ばれた読みで全体をアライメントし直すため、最終的なタイミングに誤読側のアライメントは残りません。マージンを超えるほど区間が崩壊するケースではこの1パスでは足りず、再アライメント後に区間を取り直して判定を繰り返す反復が必要になりますが、今回の曲では1パスで足りました。
def _variant_score(log_probs_slice, targets: list[int]) -> float:
"""行の区間log_probsに対する読み候補の合計対数尤度。"""
if not targets or log_probs_slice.shape[0] < len(targets):
return float("-inf")
_, scores = taf.forced_align(
log_probs_slice.unsqueeze(0), torch.tensor([targets]), blank=0,
)
return float(scores[0].sum())
同じ音源区間・同じフレーム数の上で比べるので、候補間のスコア差はそのまま対数ベイズ因子(どちらの読みが音響を良く説明するかの証拠の強さ)になります。
試行錯誤1: 平均尤度だと行の長さで差が薄まる
最初はスコアを「平均フレーム対数尤度」にしていました。すると、正しい修正(後述のヒガ)が差0.016、誤った修正(同ドッテ)が差0.008と、閾値で分離しにくい状態になりました。
原因は単純で、候補間で違うのは1〜2モーラなのに、行全体(数百フレーム)で平均しているため、行が長いほど差が希釈されるからです。合計(=対数ベイズ因子)に変えると、正しい修正は4〜13、誤った修正は1.2と、3倍以上の分離になりました。
試行錯誤2: 僅差で既定候補を覆さない
CTCスコアは万能ではなく、僅差のときに間違った候補を選ぶことがあります。実測では「その度怒って泣いていくの」に対し、unidic側の誤読「ドッテ」の方がわずかに(対数尤度差1.2)高スコアになりました。歌唱の「お」の立ち上がりが弱く、破裂音の「ド」でも音響的に説明できてしまうようです。
pyopenjtalk-plusは辞書やライブラリのアップデートが続いていて、どちらかというとunidicより信頼性が高い印象がありました。そこで、pyopenjtalkを既定候補として、「既定候補を覆すには対数ベイズ因子2.0以上(尤度比約7.4倍)の証拠を要求する」というマージンを入れました。ベイズ的に言えば、既定候補に約7.4:1の事前オッズを置き、それを覆すだけの証拠を要求していることになります。確信のある修正だけが通り、僅差の誤修正は既定の読みに留まります。なお、この閾値2.0は今回の1曲・実質数例で較正した値なので、曲や読みエンジンの組み合わせが変われば再調整が前提です。
実測(夜に駆ける、歌詞51行)
- 読み候補が割れた行: 14行(長音正規化のおかげで、残り37行は候補1つ)
- 音響判定が既定読みを覆した行: 2行(いずれも正解と一致)
| 行 | pyopenjtalk-plus(既定) | unidic候補 | 音響判定 | 実際の歌唱 | 対数ベイズ因子 |
|---|---|---|---|---|---|
| 思いつく限り眩しい明日を | アシタ | アス | アス ✓ | アス | 11.8 |
| いつか日が昇るまで | ビガ | ヒガ | ヒガ ✓ | ヒガ | 5.3 |
| その度怒って泣いていくの | オコッテ | ドッテ | 棄却(オコッテ維持) ✓ | オコッテ | 1.2 < 2.0 |
「明日」は歌詞カードを見ても分からない(アシタともアスとも読める)情報が音源から回収できており、狙いどおりの動きです。「日が」は既定側の連濁誤り(ビガ)の修正で、これも音響が正しく判定しました。
パイプライン全体の評価(XF MIDI由来の正解データとの突き合わせ)では、モーラ被覆率98%・ピッチ一致98%・タイミングMAE 4msとなり、読み起因の行崩壊は解消しています。非常に単純な方法で、改良の余地はいろいろありますが、forced alignment時に行レベルのズレを防ぐという目的では、ある程度機能すると考えました。
関連手法
「音声を使って漢字の読みを絞る」というアイデア自体は既にいくつかあり、本手法もその系譜に位置づけられます。
- ふりがなWhisper(記事 / モデル): 漢字かな混じりテキストをプロンプトに与え、音源から整合するカナ読みをend-to-endで直接出力するWhisperです(TTS学習データの読み精度向上が目的)。候補を用意しなくても読みが得られるのが特徴で、話し声の読み検証にはこちらが手軽だと思います。Whisperは歌唱音源でも結構強いので、これを試してもうまく行ったかもしれません。
- 音声認識結果からの読み仮名推定(AI Shift, 記事): 音声認識のN-best結果それぞれをMeCabで読み下し、編集距離と多数決で同形異音語(例:「河野」→コーノ/カワノ)の読みを推定する手法です。今回は元歌詞は別途入手できている前提のため、音声認識結果と元歌詞をアライメントする手間が増えそうで見送りました。ただこれも機能するように思います。
今回は行レベルのズレが起こらなければ(補正できれば)いいくらいの目標だったので、関連研究そのままでもおそらく機能したように思うのですが、forced alignmentのパイプラインがすでにあったので、log_probを再利用できる今回の方法を選びました。
なお候補読みの生成は、本記事では「読みエンジン間で割れた行」から作りましたが、MeCabのN-best解で1エンジンの上位候補を並べる方法もあります。どの作り方でも、最終的に音源で選ぶ部分(2の実装)はそのまま使えます。
おわりに
「読みが複数考えられるとき、音声認識(CTC)の尤度で正しい読みを推定する」というアイデアはシンプルですが、実装上は以下の3点が効きました。
- 候補生成を「エンジン間で割れた行だけ」に絞る(長音正規化で表記ゆれを吸収)
- log_probsを再利用し、行の区間を切り出してスコアリングする(追加コストほぼゼロ)
- スコアは平均でなく合計(対数ベイズ因子)で比べ、既定候補を覆すのに閾値を要求する
読み推定の誤りに悩んでいて、かつ対象の音声・音源が手元にあるケース(歌詞アライメント、字幕生成、朗読の読み検証など)なら、同じ構成がそのまま使えると思います。