ナレーションの話速をBPMで設計する。実測BPM98と、間をビートに乗せる試み

開発eurekapu-nuxt4

自分のサイトに置いてあるナレーション音声が、いま何拍で喋っているのかを知らなかった。 頭にあるのはBPM120という数字だけで、手元の音声がそこに近いのか遠いのかも分からない。 サンプルは何でもいいから1本測ってほしい、というところから今日が始まった。

手元の音声はいま何拍で喋っているのか

ffprobeで尺を取り、テキストと突き合わせて実測させた。 chapter01の122件はそのまま測れた。 chapter04だけ落ちたのは、対応表の末尾にカンマが無くてパーサが読み損ねていたからで、そこを直させた。 発話の前後に入っている無音も測って、尺から差し引かせている。

出てきた答えは、全体でBPM98相当。 BPM120に持っていくには1.22倍速が要る。 やっぱり遅い、という感覚は当たっていた。 ただし話者別の内訳を出させると、遅いのはnarratorだけだった。 narratorがBPM74で、yuiとkaiは標準の範囲に収まっている。 「全部が遅い」ではなく「ナレーターだけが遅い」である。

耳で「なんか遅い」と言っていたものが、「1.22倍足りない」という一つの数字になった。

BPM98とBPM120の差は耳でどう違うか

98と120の差が耳にどう届くかは、換算表を眺めても分からない。 HTMLを1枚だけ作らせて、現状とBPM120ベースを並べて聴けるようにした。 ページを持ち回れるように1ファイル完結にして、音声はdata URIで本文に埋め込ませている。 再生はピッチを保ったまま鳴った。

描画を確認させたところ、コンソールにエラーが1件出ていた。 原因は音声要素の preload='none' で、外したら消えた。

聴いてみると、120のほうが自分には聞きやすい。 1.2倍でこれか、というのが正直な感想だった。 そして聞きやすさより先に気になったのは、この120が音楽で言うBPM120と同じ値だということだった。

BPM120の4つ打ちをナレーションに重ねる

音楽のBPM120が心地よいという話がある。 だとすれば、ナレーションの裏で120の4つ打ちを鳴らしたときに、両者が一つのリズムとして噛み合うのかを確かめたくなった。 Web Audio APIでトラックを合成させて、ナレーションと同時に鳴らす構成にしてもらった。 トラックのテンポを固定したままナレーションだけ現状の速度に落とせば、ズレていく感じもそのまま体験できる。

最初の版では、キックが2発しか出ていなかった。 4つ打ちを名乗っていながら、4拍のうち2拍が空いている。 パターンを直させたら、4拍すべてにエネルギーが乗った。

鳴らしてみると、ドゥンドゥドゥドゥン、と聞こえる。 この揺れを作っているのはキックではなくベースだった。 低音だけを取り出させると、その形で鳴っている。

ここで手が止まった。 自分の声で録るより、こちらのほうがテンポをコントロールできる。 話す速さを気合いで揃えるのではなく、数値で決められる。 そこにこのとき初めて実感が伴った。

文と文のあいだをどこに置くか

テンポが数値で決まるなら、文と文のあいだに置く間も同じグリッドに乗るはずだった。 既存の音源を加工するのではなく、VOICEVOXを立ち上げて対象の一節を再合成させた(v0.25.1、narratorはspeaker 31)。

合成の作り方は一度検討させている。 選んだのは、文全体を自然な抑揚のまま合成しておいて、音素長から分かるポーズ位置で切り出す方式だった。 狙った位置と実際の切断点のずれは最大42msで、切断点はすべて無音の中に落ちた。

切り出したフレーズをビートのグリッドに置き直し、タイムラインを見られるページにさせた。 ここまでは狙いどおりに見えた。

「4分の3の先頭から」と言いたくなるズレ

タイムラインを見ると、「そして会計期間」の手前に1.10秒の空きがあった。 拍の途中から始まっていて、直前のフレーズは読点で終わっている。 句点で終わっているならまだ間を長く取る理由があるが、読点でこの空きは長い。 区間の途中で終わったなら、次の区間の頭から始めてほしい。

原因はグリッドの刻みだった。 タイムラインの数字は小節番号で、間隔は2秒。 細い線が1拍で0.5秒にあたる。 配置の刻みが「2拍=1.0秒」になっていたので、5.0秒という位置がそもそも選択肢に無く、6.0秒まで飛んでいた。 刻みを1拍(0.5秒)に変えたら、5.0秒に置けるようになった。

前のバージョンを残させてから差し替えた。 手前の空きは1.10秒から0.10秒になった。 「そして会計期間」は5.00秒、3小節目の3拍目の頭から始まる。 このときのspeedScaleは1.38である。

そのあと、拍ではなく小節を単位にして置く設定も試した。 こちらでは「そして会計期間」が6.00秒、4小節目の1拍目から始まる。 小節の頭ちょうどから文が立ち上がる配置になった。

停止ボタンを押してもビートが鳴り続けた

配置を詰めている最中に、明らかなバグを踏んだ。 停止ボタンを押すと、ナレーションは止まるのにバックグラウンドの4つ打ちだけが鳴り続ける。

原因は音のスケジュール方法にあった。 ビートを鳴らす処理は先の時刻まで音を予約しておく作りで、停止処理はナレーション側のノードしか止めていない。 予約済みの音は誰にも止められないまま順番に鳴る。

ビート専用のバスを1本作らせて、停止時にそこを切断する形に直させた。 同じ問題が前のバージョン2つにも残っていたので、まとめて直させている。 検証は耳ではなく出力レベルの実測でやらせた。 止めたあとにレベルが落ちていることを数値で確認して、ようやく直ったと言えた。

手順をスキルに落とす

ここまでの手順は、合成、ポーズ位置での切り出し、BPMグリッドへの配置、プレビューHTMLの生成、という並びで固まっていた。 手元にTTS系のスキルは無かったので、新しく作らせた。 スクリプトの出力がHTMLと完全に一致することも確認させている(音声は12.66秒で、配置12.16秒に末尾の0.5秒を足した長さ)。

この過程でもう1件、原因の分かりにくいバグが出た。 HTML側に data-gridComma という属性を書いていた。 ところがHTMLのパーサは属性名を小文字にするので、JavaScript側の dataset.gridComma では読めず、値がNaNになる。 data-mode のような1語の属性は無事だったので、区切りの単位だけは動いていて、壊れていることに気づきにくかった。 プリセットの選択表示が崩れていたのも同じ理由だった。

最後は、スキルのディレクトリにできた __pycache__ を消そうとして、~/.claude 配下の rm -rf を止めるガードに引っかかった。 削除は実行されていない。 キャッシュは自分で消してから、生成そのものが起きないように import の直前へ sys.dont_write_bytecode = True を入れさせた。

今日の残り

朝の質問は「いまどれくらいの速さか」だけだった。 終わってみると、速さと間の両方を数値で決められる道具が手元にできている。

まだ手を付けていないのは、BPM74で喋っているnarratorの音源そのものである。 全体を1.22倍にするのか、話者ごとに別の目標値を置くのかは、今日は決めていない。