ネットの話題を拾って短い解説動画を組み立てる自作ツールを動かしていた。

音声合成の検品処理を通したはずの動画から、妙に間延びした声が流れてきた。「みんなで大家さん」という単語が「みんなで/オーヤさん」と完全に2語へ割れている。

検品ログを見直すと、この音声は平然と合格していた。

あり得ない。

合格判定をすり抜けた2語割れ

4モーラ目と6モーラ目で頂点の位置が異なる周波数グラフを比較観察するyuki

*合格音は「デ」で山を作り、却下音は「オー」まで山がずれ込んでいた。*

自作ツールの検品工程では、音声合成が出力したピッチの起伏を拾い、指定したアクセント核の高さが保たれているかを比率で判定していた。耳で聞けば誰でも吹き出すレベルの崩れなのに、検査スクリプトは緑色のランプを灯している。合格したテイクと却下したテイクのピッチ数値を並べた。

| テイク | モーラごとのピッチ周波数 (Hz) | ピーク位置 | | :--- | :--- | :--- | | 合格音 | 245, 245, 332, 342, 298, 265, 340, 305, 262 | 4モーラ目(デ) | | 却下音 | 191, 270, 350, 352, 352, 373, 354, 314, 253 | 6モーラ目(オー) |

本来の型3であれば、4モーラ目の「デ」に山が来て直後にストンと落ちなければならない。

ところが却下音は山の頂点が6モーラ目の「オー」までずり落ちていた。

「大家さん」の頭で再びピッチが跳ね上がった結果、ひとまとまりの商品名ではなく、勝手に作られた2つの単語に聞こえていたわけだ。

画面の判定表示は緑色だった。

ピッチ実測と2.0半音の死角

2.0半音の判定ラインと1.10半音の測定差を定規で測るyuki

*高さの比は+1.10半音にとどまり、2.0半音の閾値判定を素通りした。*

差が出ない。

当時の検査ロジックは、本来のアクセント核(4モーラ目)と実際に山が立った区間(6モーラ目)の周波数比を半音単位で算出し、基準値から2.0半音以上落ち込んでいるかどうかだけを監視していた。半音差の算出式は 12 * log2(373 / 350) で、値はわずか+1.10半音にとどまる。

スピーカーから流れる奇妙な「オーヤさん」を止め、端末に周波数の差分を吐かせて目を疑った。却下音のピッチを計算し直すと、本来の核の高さとの差はわずか+1.10半音しかない。

2.0半音の閾値よりはるかに小さかった。

高さの比率しか見ていない監視カメラの前を、ピッチの山が2モーラも横へスライドしながら悠々と通過していたことになる。

OpenJTalkの解説pyopenjtalkの実装、各種の日本語音素制御ドキュメントを見ても、アクセントの推定と制御はモーラごとの高低差やラベル位置で厳密に扱われている。それなのに、出力された波形の検品側が高さの比率という大雑把な指標に甘えていた。

音響的な高さがどれだけ規格内に収まっていても、山が立つ場所が隣のモーラへ滑った時点で、日本語のアクセントとしては完全に別物になる。

ピーク偏移判定と消えたアクセント型

漢字の表記と読み仮名でアクセント型が型1から型5へ変わる構造図を見るyuki

*読み仮名を振った瞬間に尾高型へ変わり、検品対象の下がり目が消滅した。*

山の位置ずれを捕まえるため、検品ロジックにピーク位置のオフセット判定を追加した。下がり目が存在する宣言句について、観測されたピッチの頂点が本来の下がり目から1モーラを超えて離れたら即座に崩れと判定する。

離れたモーラ数はスコアの減点幅にそのまま乗せ、探索ループが山を正しい核へ引き戻せるようにした。

これで片付いたと思って別の原稿を流したところ、今度は別の箇所で違和感が残った。

音が平らだ。

「大阪地裁は3件の判決で。」という文の「3件」が、どう聞いても平板に流れている。ログを追うと、そもそもこの句の検品自体がスキップされていた。

数字の読み仮名を展開した処理が原因だった。

元の表記: 「三件の」   -> サンゲンノ (5モーラ 型1: 下がり目あり、検品対象)
仮名展開: 「さんけんの」 -> サンケンノ (5モーラ 型5: 尾高型、語内で下がらない)

音声合成エンジンに「サンゲン」と濁音で読ませるのを防ぐため、台本側で「さんけん」と読み仮名を明示的に振っていた。

仮名文字列をそのまま解析器へ渡したせいで、形態素解析は型5の尾高型だと判定してしまった。語の内部でピッチが下がらない型になった結果、下がり目を監視する検品の対象外へ弾かれていた。

表記からの型復元と残った耳

漢字表記から取り出した型情報と仮名発音を合成パイプラインに流し込むyuki

*音は仮名で維持したまま、アクセント型だけを元の表記から復元して検品を通した。*

エンジンに正しい発音を渡すには仮名が必要だが、アクセント型の判定まで仮名に引っ張られては検品の意味がない。音声の組み立て処理で、元の漢字表記と展開後の仮名をタプルで保持するように改めた。

音声を生成する発音文字列は仮名のまま維持し、アクセント型だけを元の漢字表記から取り直して上書きする。

def restore_accent(original_text: str, kana_text: str, default_type: int) -> int:
    # 元の表記と仮名で句の切れ方が変わらない場合のみ型を復元する
    orig_mora_count = len(extract_moras(original_text))
    kana_mora_count = len(extract_moras(kana_text))
    if orig_mora_count == kana_mora_count:
        return extract_accent_type(original_text)
    # 照合に失敗した場合は推測された元の型をそのまま維持する
    return default_type

# テストケース: モーラ数が一致する「三件の」と「さんけんの」は型1へ復元される
assert restore_accent("三件の", "さんけんの", default_type=5) == 1
# 句の切れ方が変わる不一致ケースでは無理に上書きせずフォールバックする
assert restore_accent("100円", "ひゃくえん", default_type=2) == 2

元の表記だと句の切れ方が変わる読みには触らない設計にした。

判定の導入条件も整理した。型1から型Nのように下がり目が語内に存在するアクセント型にはピーク位置のモーラ偏移判定を導入する。一方で、平板型や語内でピッチが落ちない尾高型にはこの指標を適用せず、従来の高さ比率による判定に留める。

修正後に問題となった対象2件を再検証した。2語に割れていた「みんなで大家さん」は山の位置が6モーラ目から4モーラ目へ引き戻され、偏移0モーラで合格となった。検品から丸ごと抜け落ちていた「3件」も型1として再認識され、下がり目判定を通過して2件とも正常判定へ揃った。

高さの比率で2.0半音の余裕を測って安心していた検品コードより、動画を一瞬再生しただけで顔をしかめた自分の耳のほうがよほど狭い許容値で動いていたらしいw