--- title: "読み上げBotの「声が遅い・途中で止まる」を潰した話 — 派手さゼロの背骨の中身" description: "地震速報や人狼より、ずっと手間をかけているのは「声がすぐ出る・間延びしない・黙り込まない・混んでも詰まらない」の4つ。長文を小さく切って先に喋り始める仕組み、無音の正体が本文中のスペースだった話、静かに固まるのを見張る番犬、2台のBotで空いている方が出る仕組みまで。地味な話です。" url: https://ryuuneko.com/blog/zunda-yomiage-backbone published: 2026-09-11 20:27:51.386036 updated: 2026-09-11 20:27:51.386036 --- 前に「[で、これまだ読み上げBOTなの?](/blog/zunda-yomiage-bot-jimon)」という記事で、地震速報も人狼も生えたけれど、いちばん手間をかけているのは地味な「声を読む」部分だ、と書きました。今回はその中身です。 読み上げBotに求められることは、突き詰めると4つしかありません。 - **すぐ出る** — 発言してから声が出るまでが短い - **間延びしない** — 変なところで黙らない - **黙り込まない** — 失敗しても止まらない - **混んでも詰まらない** — 人が多くても順番待ちで固まらない どれも「できていて当たり前」で、使っている人は気づきません。気づくのは壊れたときだけです。この記事は、その4つを1つずつ潰していった記録で、専門知識なしで読めるように書きます。 --- ## その1: すぐ出る — 長文を「小さく切って、先に喋り始める」 最初に困ったのは長文でした。誰かが200文字くらいの文章を貼ると、声が出るまで数秒待たされる。理由は単純で、音声合成(文字を声に変える処理)は文字数に比例して時間がかかり、**全部できあがるまで再生を始めていなかった**からです。 手元の環境で測ると、6文字で0.3秒、11文字で0.5秒、30文字で1.5秒ほど。200文字なら10秒近く沈黙します。 なので、文章を切って、先頭だけ先に合成して、それを再生している間に続きを合成することにしました。ここで大事なのは切り方です。 - **先頭は小さく(30文字くらい)**。ここが読み始めまでの時間を決める - **以降は2倍ずつ大きくして、上限(200文字)まで**。合成は再生より3〜4倍速いので、「今のかたまりを再生している時間 ≥ 次のかたまりを合成する時間」が成り立てば、途中で途切れません - **切る位置は文の区切りを優先**。「。!?」や改行、閉じ括弧の後で切り、無ければ「、」、無ければ空白、それも無ければ強制的に切る 結果、200文字の文章でも最初の声は1秒前後で出て、そのまま最後まで途切れずに読みます。 ![長文を先頭30文字・以降2倍ずつのかたまりに切り、先頭を再生している間に次を合成する時間軸の図](/blog/uploads/zunda-tts-chunks.svg) *▲ 先頭のかたまりを小さくして読み始めを早くし、以降は再生時間の中で次の合成を終わらせる。* 合成側も並列にしました。音声合成エンジン(VOICEVOX)は2つ動いているので、2つのかたまりを同時に合成し、できあがりが前後しても再生は順番どおりに待つ。ただし先読みは8秒分まで。それ以上先まで合成しておいても無駄なので、そこで止めて、エンジンを他のサーバーの読み上げに譲ります。 ### 上限を下げたら、分割が死んでいた ここで1つ恥ずかしい話。文章の長さには安全弁として上限があり、それより長いと切り捨てます。この上限がある時期まで200文字だったせいで、**「200文字を超えたら切り捨て、それ以下なら1かたまり」になり、分割読み上げが事実上働いていませんでした**。上限を2000文字に上げて初めて、上の仕組みが意味を持った。コードは正しいのに、隣の定数のせいで動いていない類です。 --- ## その2: 間延びしない — 無音の正体は「スペース」だった 分割を入れたら、今度は「かたまりの継ぎ目で間が空く」「改行やスペースのところで妙に黙る」という感想が出ました。 継ぎ目の方は分かりやすい原因でした。合成エンジンは1回の合成の前後に0.1秒ずつ無音を付けます。分割すると、その無音がかたまりごとに乗る。なので継ぎ目では前後の無音を0にしました(句読点で作られる自然な間は別の仕組みなので残ります)。 問題は、それでも残った「妙に黙る」の方です。調べると、**合成エンジンは本文中の空白そのものを「間」として発音する**仕様でした。こちらの前処理は連続した空白を半角スペース1個に縮めて残していたので、そのスペース1個ぶんの沈黙が、文中のあちこちに入っていた。継ぎ目とは無関係の、別の原因です。 対策は、合成の直前に空白を取り除く。ただし「hello world」が「helloworld」にならないように、両隣が英数字のスペースだけは残す。前処理の出力自体は変えていません。前処理の結果は、編集されたメッセージの差分計算にも使っていて、そちらに波及させたくなかったからです。 「改行で黙る」の方は、実は改行が制御文字として前処理の段階で消えていたので、もともと起きていませんでした。感想の中に混ざっていた別の現象を、空白の件と一緒に見ていただけでした。原因を1つに決めつけず、要因ごとに切り分けるのが、こういう「間」の調整では効きます。 --- ## その3: 黙り込まない — 失敗を「無音」にしない ここが、いちばん反省の多い部分です。 ### 予備の合成器が、そもそも動いていなかった 合成エンジンが一時的に失敗したときのために、簡易の予備エンジン(espeak-ng)に切り替える仕組みがありました。ところが、Botが動くコンテナに**その予備エンジンのバイナリが入っていなかった**。切り替え先が無いので予備も失敗し、そのかたまりは無言で欠落する。しかもログには何も出ない。 コード上は「フォールバックあり」で、テストも通っていた。でも実物では一度も動いたことがない。コンテナに同梱して、実際に音声ファイルが出ることを確認して、ようやく「予備がある」と言える状態になりました。欠落したときはどのかたまりが落ちたかをログに出すようにもしました。 ### 静かに固まる もう1つ、質の悪い壊れ方があります。並列で合成していると、まれに合成タスクが途中で消えることがある(内部の異常終了など)。すると再生側は「次のかたまり」を永遠に待ち、**そのサーバーの読み上げだけが、エラーも出さずに止まる**。他のサーバーは平気なので、こちらは気づけません。 対策は番犬です。「再生中のものが無く、次に再生すべき番号があり、それが完成待ちでもなく、合成中のタスクも無い」という矛盾した状態を検知したら、その番号を諦めて次へ進む。正直に書くと、この番犬は本番で異常終了を意図的に起こせないため、実機での再現はできていません。ロジックの見直しだけで入れています。 ### 数えるのをやめて、毎回計算する 先読みが8秒分を超えたら止める、という仕組みは、最初「足したら加算、再生したら減算」で残量を管理していました。これは一度ズレると戻らない。ズレたまま「まだ8秒以上ある」と思い込むと、新しい合成を止め続けて、これも無音で固まります。なので残量は、いま手元にあるかたまりの推定再生時間を毎回足し直す形にしました。増減の簿記より、毎回数え直す方が、この規模なら安いし壊れません。 3つに共通しているのは、**「エラーが出て止まる」より「何も言わずに止まる」の方が怖い**ということです。前者は利用者が気づいて報告してくれる。後者は「今日ずんだー調子悪いね」で終わって、こちらには何も届きません。 --- ## その4: 混んでも詰まらない — 2台で「空いている方」が出る ずんだーには「あんこー」という姉妹Botがいます。もともとは別々に設定を持つ2台でしたが、いまは**1つのプール**として動きます。 - 自動参加の設定はサーバー共通で、複数のボイスチャンネルを登録できる - 誰かがボイスチャンネルに入ると、**空いている方のBot**が入る。1つのチャンネルには1台だけ - `/tts start` を、既に別のチャンネルで読み上げ中のBotに打つと、そのBotが空いている方に「代わりに行って」と頼み、行けたら「あんこーが参加しました」と返す 「1つのチャンネルに1台だけ」は、データベースの表で守っています。チャンネルごとに1行しか入らない表に「私が担当します」と書き込み、先に書けた方が勝ち。負けた方は入りません。30秒ごとに担当中のBotが「まだ生きています」と更新し、90秒更新が無ければ担当を外します。Botが落ちても、そのチャンネルが永久に「使用中」で塞がらないためです。 この「先に書けた方が勝ち」を入れたあと、レビューで穴が3つ見つかりました。 - **Botが再起動すると、自分の古い担当記録に自分が弾かれる**(最大90秒、自分のチャンネルに戻れない)。同じBotの古い記録なら奪い返してよい、に変更 - **管理者がBotを手でチャンネルから追い出すと、担当記録だけが残って永久に塞がる**。Bot自身の入退室も監視して、追い出されたら後始末をするように - **サーバーから追放されたときも同じ**。こちらは別の経路だったので、同じ後始末を足した どれも「正常な使い方」では起きず、人が手で操作したときにだけ出る類です。読み上げの中身より、こういう「人が横から触ったとき」の方が、壊れ方の種類が多い。 --- ## 数字で見ると | 項目 | 前 | 後 | |---|---|---| | 200文字の読み始め | 数秒〜10秒近く | 1秒前後 | | かたまりの継ぎ目の無音 | 0.2秒(0.1+0.1) | 0 | | 予備エンジン | 存在するが動かない | 動く。欠落はログに出る | | 静かに固まる | 手動再起動まで | 番犬が検知して次へ | | 同じサーバーで同時に読める場所 | 1つ | 2つ(Bot2台) | 「後」の列は、どれも使っている側からは見えません。声がすぐ出て、途切れず、止まらない。それだけです。 --- ## おわりに 前の記事で「十徳ナイフにいくら道具が生えても、ナイフの刃がいちばん研がれている」と書きました。この記事はその刃の話で、震度マップや天文計算に比べると、見た目の面白さはありません。 でも、読み上げBotが読み上げBotでいられるのは、この4つが黙って動いているからです。次に何が生えるにしても、最初に直すのは「声が遅い」「読めない」「途中で止まる」の方。ここだけは、後回しにしないでおこうと思います。 --- *※この記事は2026年6月〜9月時点の実装をもとにしています。数値は自宅サーバーでの実測で、環境により変わります。*