セットアップと実装で実際に踏んだ問題、その原因の突き止め方、測って決めたことの記録です。新しいものが上にあります。
索引は ../README.md にあります。
歌えるようになった・壁は物差しの方でした(2026-08-06): 2 度外した「旋律つきで歌う」を戻しました。決め手は採譜の工夫ではなく、自分の物差しが実音源では成立していなかったと分かったことです。
- 合成器は最初から楽譜どおりに鳴らしていました。楽譜が言う音の始まりの時刻と、出来た音の立ち上がりの時刻を突き合わせると、ずれは中央 9〜13ms・96〜98% が 60ms 以内でした(合成した曲・長短の差が大きい曲・実曲の 44 音の 3 通り)。
- 実音源に対して「音どうし」を比べる物差しには、消せない床がありました。音源自身の立ち上がりで区切って歌わせれば、リズムとしてはそれ以上正しくなりようがありません。それでも読みは 142〜680ms でした。合唱+ブラスの音源と、ひとりの合成音では、音の勢いの形がそもそも違うためです。前日まで「方式の差」だと思って比べていた数字は、全部この床の中でした(実際、どの方式もこの床より良い値を出していました)。
- そこで実曲用は時刻どうしを比べる物差しに替えました。音符の始まりが音源の立ち上がりにどれだけ乗っているかを見て、でたらめに置いた対照と等間隔の対照を必ず並べます。合成した曲での較正は 正解の切れ目 10.0ms / でたらめ 34.0ms / 等間隔 38.7ms です。
- 立ち上がりの拾い方でも 2 回まちがえました。勢いの差分の山では正解とでたらめが 1.45 倍しか離れず、山を全部拾うと実音源で 25 秒に 742 本(34ms ごと)取れて飽和し、でたらめでも 8.9ms になります。まわりの平均の 1.6 倍以上で、最短 0.12 秒あけると 259〜411ms ごとに落ち着きました。
- 採譜は歌詞のモーラ数に音符の数を固定して、動的計画法で区切る方式にしました。実曲 5 曲で 20〜60ms(でたらめ 54.8〜105.1ms)です。歌詞を同じ声で歌わせた参照に合わせる方式(DTW)は、実音源では でたらめ と同等以下でした。参照を男声に替えても変わりません。一方、実曲のリズムを同じ声で歌い直したものを起こし直させると、どの方式も切れ目を 27〜38ms で当てます。方式の力の差ではなく、音源の性質の差でした。
- 音源が歌詞を 1 対 1 で覆っていない曲は歌いません。13 曲のうち 12 曲は 0.22〜0.58 秒/モーラに固まり、1 曲だけ 1.59 秒(歌詞 14 モーラに対して 22.2 秒鳴っている)でした。この曲は楽譜との差 3.17 半音・最長無音 1.46 秒で、聴ける歌になりません。
試験も測る対象を直しました。採譜の試験はすでに使っていない合成器を呼んでいて(しかも import せずに呼ぶ行があり落ちていました)本番の経路を測っていませんでした。いまはリズム(でたらめ対照の 70% 以下)と高さ(楽譜との差 1.5 半音以内)を別々に測ります。割り込みの試験は返事が 6 秒未満だと窓が開かないのに、その前提を確かめずに FAIL と言っていたので、長さの決まる問いに替えたうえで前提を明示するようにしました。
歌う機能を外した(2026-08-05): 実機で聴いてもらったところ「ひどい」「歌えてない」でした。全 25 曲を測ると、24 曲は起こした音符の 30〜57% が休みで、歌ではなく短い音の連打です。唯一その基準を通った曲も、聴けば駄目でした。測り方(休みの割合・楽譜との平均ずれ)が人の耳と合っていなかったということなので、数値が通ったことを根拠に出したのが間違いです。道具ごと外しました(歌詞の読み上げは残っています)。
同じときに、声の経路で確かめて 2 つ直しました。「宮崎」と普通の崎で言っても「宮﨑敏郎」に当たるようにし(旧字・異体字を同じ字として畳む)、見つからない選手名で 25 件を読み上げるのをやめました。
次は VOICEVOX 自身の歌唱合成で作り直します。Pi のエンジン(0.25.2)は歌唱を持っていて、ずんだもんの歌声も出せます。今の詰まりは、楽譜を解釈する役と声を出す役でスタイルが分かれている点です。
応援歌を歌えるようにした(2026-08-04): 「歌わせる」。前に「メロディの数値データが要る」で止めていた所です。
歌声の合成は Sinsy(名古屋工業大学の HMM 歌声合成・Modified BSD)を使います。声は同じ所の nitech_jp_song070_f001(Creative Commons Attribution 3.0)。Raspberry Pi では C++ を組まないので、GitHub Actions で aarch64 向けにクロスビルドし、出来た実行ファイルと辞書だけを Pi へ置きました(.github/workflows/build-sinsy.yml。2026-08-06 に Sinsy の合成器ごと削除しました=歌は VOICEVOX に替わり、このビルドを使う所が無くなったためです)。Pi は Debian bookworm なので、走らせる側の Ubuntu を古い方に留めています(新しい glibc を要求する実行ファイルは Pi で動かない)。Codespace を使わなかったのは、起動に事前の承認が要る取り決めだからです。 public リポジトリの Actions なら無料で無制限です。
メロディをどこから取るかが本題でした。 公式にあるのは歌詞とふりがなだけです。ドレミ表記を載せている個人サイトは横浜の分を持っておらず(sitemap を全部見て 0 件)、載っている球団の分も画像でした。ゲームの応援歌パスワードはひらがなで符号化されたメロディそのものですが、復号の仕様は公開されておらず、変換器も見つかりません。
そこで単旋律の歌唱音源から自分で採譜することにしました(transcribe.py)。自己相関で基本周波数を追い、同じ高さが続く所を 1 音にまとめ、音の長さが割り切れる格子を探して 16分音符いくつ分かに直します。格子は「ずれがいちばん小さい」で選んではいけません。細かい格子ほど必ず割り切れるので、実測でテンポ 242、範囲を絞っても端の 188 に張り付きました。十分に割り切れる中でいちばん粗い格子を採ると 106 に落ち着きます。自己相関が 1 オクターブ間違える所(周期の 2 倍にも山が立つ)は、まわりの中央値から 12 半音ちょうど離れている値を寄せて直します。
耳で確かめられないので、閉ループで測りました。 採譜 → その音符で歌わせる → もう一度その音の高さを追う、と回して元の音源と突き合わせます。宮﨑敏郎の応援歌で平均のずれ 0.50 半音、長さは 13.2 秒に対して 13.6 秒でした。合成の側も、著作権の切れた唱歌「ふるさと」で楽譜との平均 0.85 半音を確かめてあります(Sinsy は統計モデルなので、ぴったりには合いません)。
歌詞は公式のふりがなを 1 音ずつ音符に乗せます(「きょ」は 1 つ、伸ばす音は前に付ける)。宮﨑敏郎は音符 33 に対して歌詞もぴったり乗りました。音源も起こした音符もリポジトリには置かず、Pi の中に取っておくだけにしてあります(歌詞と同じ扱い)。
道具は歌詞を読む get_cheer_song と別に sing_cheer_song を足しました。音は文字で返せないので、道具は音符を ctx に載せ、返事を言い終わってからサーバーが歌います。読み上げの経路(Opus で送る・途中で割り込みを聞く)はそのままで、合成の代わりに出来た歌を差し込むだけです。実際に「宮﨑の応援歌を歌って」「桑原の応援歌うたってよ」の両方で歌う方の道具が選ばれました。
最初に使った音源の一覧は 2017 年の顔ぶれで、いまの歌詞 25 件のうち 3 件しか重なりませんでした(「いま 2026 年だよ」と指摘を受けました)。同じサイトの別のページ——ゲームのパスワードを配っている方——に選手ごとの音源が 69 曲あり、こちらには牧秀悟・佐野恵太・神里和毅・柴田竜拓・関根大気・森敬斗も載っています。取得元をそちらへ替えて、歌えるのは 12 件になりました(その他の右打者・その他の左打者・捕手のテーマ を含む)。代打のテーマは音源が無く歌えません(2017 年のメドレー側には「投手・汎用・チャンステーマは収録してません」と書かれています)。歌詞しか無い選手は歌わずに断ります——音だけ鳴らしても意味がないためです。
新しい音源は録音の質が違い、直すことが 3 つ出ました。低い方へ 1 オクターブ外れる(牧の音源は中心が A4 なのに下位 5% が A1 でした)ので、まわりの中央値ではなく曲全体の中心から 9 半音より離れた値を寄せます。歌っている最中も声が途切れて音が細切れになるので、0.12 秒までの切れ目は前後が同じ高さなら埋めます。そして Sinsy は小節をまたぐ音のつなぎに continue を受け付けません(start と stop だけ)。3 つ以上に割れた音の真ん中に continue と書いていて、読み込みごと落ちていました。牧の応援歌は 25 秒あって初めてここに当たります。
牧秀悟の応援歌で、元の音源との平均のずれは 1.03 半音(26.0 秒に対して原曲 25.2 秒)でした。
残りの 13 件も追いました。 応援歌は前の選手の曲を歌詞替えで受け継ぐことが多く、音源側の表も「牧秀悟(村田修一流用)」と書いています。流用の記載は応援歌まとめサイトの各選手ページにあるので、13 件ぶん機械的に引くと、梶原昂希が下園辰哉の曲で、その音源は手元にありました。これで 13 件が歌えます。
残る 12 件は埋まりませんでした。音源の取得元は 2022 年 5 月で更新が止まっており(sitemap の最終更新で確認)、2023 年以降の選手は構造的に載りません。現行の応援歌まとめ 3 サイトはいずれも YouTube 埋め込みだけで、落とせる音源ファイルを持ちません。流用元(度会←横山道哉、三森←仁志敏久、石上←1995 年のシュワーズ)にも音源が無く、林・松尾・京田・蝦名は流用の記載が無い=独自曲です。
歌える曲を増やす道は開けてあります。cache/songs/ に「公式の見出しと同じ名前」で音を置けば、その曲は歌えます(配布ページより優先します)。取得元は問いません——動画共有サイトから落とすのはそこの規約が禁じているので、こちらからは行いません。私自身は音を聴けず、採譜しているのは自己相関の処理なので、ファイルとして手元にあることだけが要件です。
ゲームのパスワードは旋律をひらがなで符号化したものなので、手元にある「パスワードと自分で起こした音符」の対から規則を割れないか測りました。1 音あたり 2.80 / 3.23 / 3.35 文字とばらつき、固定長の符号ではないと分かった所で打ち切っています。対を増やせば筋はあります(パスワードは 71 曲ぶん機械的に取れます)。
聞き取りの下ごしらえを測って直した(2026-08-04): 実機のログを読み返すと、話しかけの書き起こしが「初めての振り替えもネット条件は発見」「あれば家に連れてもらえない」のように崩れていて、返事はその崩れた文に真面目に答えていました。認識器そのものではなく、渡す前の下ごしらえに手を入れます。
いちばん効いたのは黙っている時間を落とすことでした。本体は一語の返事にも十数秒のバッファを送ってきます(実測で「よいしょ」の一語に 18.36 秒)。前後に 6 秒の黙りが付く条件で CER は 44.4% → 23.9%(SNR10)、23.2% → 12.1%(SNR15)になり、認識にかかる時間も 22.3 秒 → 6.1 秒(25 文の合計)に縮みました。RPi5 は他の作業と共有している機械なので、速くなった分も同じくらい大事です。
査読で「声とみなす下限の決め方」の穴が出て、直しました。「いちばん大きいフレームの 2 割以上を声とみなす」という決め方だと、咳やドアの音が 1 つ入るだけで本当の声がその下に沈み、物音の周りだけを残して声を全部捨てます。試験で再現でき(声 rms 702 に対して測った音量が 29491)、いまは上位 5% の位置を基準にしています。ところがそれだけにすると、今度は雑音のあるバッファで下限が雑音の高さを下回り、切り出しがまったく効かなくなりました(CER 23.9% → 40.4%、認識時間も 6.8 秒 → 24.2 秒)。上位 5% の 2 割と黙っている所の 2 倍の高い方、が両方を満たします。音量そのものも平均ではなく中央値で測ります(平均だと轟音 1 つで 40 倍ずれます)。
同じ査読で出た「倍率に下限が無い」「小さい声で振り切れる」は、良し悪しが測られていなかったので摘みにして A/B しました。倍率の下限は差が出ず(採らない)、振り切れ防止は切り出しが効いていれば差が出ませんが、保険として既定にしています。
次が声の大きさをそろえることと、認識器の前後に足す無音を 0.3 秒から 0.8 秒に伸ばすことです。無音は伸ばすほど雑音に強くなり、0.3 / 0.5 / 0.8 / 1.2 秒の順に SNR10 で 32.3% → 25.3% → 19.9% → 16.2% でした。ただし雑音の乱数を 3 通り振ると 0.8 秒と 1.2 秒の差は種の違いに埋もれるので、実機の返事が遅くならない 0.8 秒を採っています。割る音量は「声が出ている所」だけで測ります。全体の rms で割ると、同じ声でも黙っていた長さで倍率が変わってしまうためです。
語彙のえこひいき(hotwords)とビーム探索は効きませんでした。道具の名前 20 語を入れて modified_beam_search で回すと、きれいな音声では道具を含む 15 文の CER が 1.7% から 2.8〜7.9% に悪化し、雑音下では道具の文が 43.3% から 36.5% に良くなる代わりに一般文が 11.8% から 15.1% に悪くなります。合成音声での測定なので実機の声で見直す余地は残りますが、既定にはしません。
まとめると、きれいな音声で 5.7% → 3.0%、実機に近い条件(小さい声・雑音・前後に 6 秒の黙り)で 44.4% → 23.9% です。合成音声で測っているので絶対値は楽観側で、設定どうしの比較として読みます。
野球のことを答えられるようにした(2026-08-03): 「横浜ベイスターズの速報とか、選手入れ替え情報とか、各選手の応援歌歌えるとか、できないの?」。3 つとも、鍵の要らない公開ページから取れます。
速報は npb.jp の「試合速報」(1 分ごとに更新)と、同じページの勝敗表です。勝敗表には順位の数字が書かれていないので、行の並び順を順位として読みます。球団を言われればその球団の試合と順位、言われなければ今日の全試合と両リーグの順位を答えます。「今何位?」は球団の引数が空で届くので、そこで順位が落ちないようにしています。
選手の入れ替えは公示(出場選手登録・登録抹消)です。同じページの後半に全支配下選手の一覧があるので、そこで切らないと 60 人読み上げることになります。
応援歌は球団の公式ページに歌詞とふりがながあり、音源はありません。読み上げにはふりがなの方を使います。歌詞はこのリポジトリに置かず、聞かれた時に取りに行きます。
査読で 2 件見つかり、どちらも自分で再現してから直しました。最後の見出しの歌詞がページの終わりまで伸びて、script の中の丸括弧("body"、広告の URL)を歌詞として拾っていたこと。自分のふりがなを持たない見出しが、次の見出しの文言を歌詞にしていたこと。見出しでも script でも切り、英字の混じる行は読みとみなさないようにしました。
そのまま読ませたい答えは、読み上げ用の整形が敵になります。言い直しを畳む処理が「オオオオー」と「オオオオオ」、「かっとばせ」と「かっとばせー!」を同じものと見なして消していました(似ている度合いで判定しているため)。この道具の返事だけ畳まないようにしています。文の数の上限も締めの一行を落としていたので、長さを決めている字数の方に譲らせました。道具の答えに「そのまま読み上げてください」と書いたら、それ自体が「そのまま読んでみてね」と発話されました。指示は道具の説明の側に置くのが正しい場所です。
読み上げの途中で割り込めない理由が分かった(2026-08-03): 前日に入れた割り込みの窓が、実際のログではほとんど空振りしていました。返事の 1 つ目の窓は、閉じた 0.5〜1.1 秒あとに本体の listen start が届いています。
本体に貯まっている音が鳴り終わるのを、こちらは 送ったフレーム数 × 60ms − 経過 の見積りで待っていました。ところがこの基準の時刻を合成の前に取っていたので、本体が鳴らし始めるまでの時間(実測 1.2〜3.3 秒)ぶん見積りが短くなります。2 つ目以降の窓は基準を取り直すので合っていました。加えて本体側にも自前の貯まりがあります。
見積りで待つのをやめ、本体が「聞き始めた」と言ってくる合図そのものを待ってから窓を開けるようにしました。窓の中は 20ms 刻みで見て、声が立った時点で切り上げます。合図が来なかった窓は割り込みとして扱いません。遅れて届いた合図で本体が聞いたままになっていると、窓ではなく待ち時間ぜんぶの音で誤判定するためです。
実機なしで確かめられるよう test_barge.py を足しました。本体のふるまいのうち成否を決める 2 点だけ真似ます。読み上げを受けている間はマイクを送らないことと、tts stop から遅れて聞き始めることです。実測の最悪値 1.1 秒で通り、上限を超える 3.0 秒では落ちます。
答えを 1 件に絞るのをやめた(2026-08-03): 「中身が限定的はやめて。渡航情報が 1 つの国とか」「ちゃんと網羅して全部」という指摘を受けて、渡航情報と燃油サーチャージの既定を部分から全体に変えました。
渡航情報は、国を言われればその国、「中東」「ヨーロッパ」などの地域ならその地域ぜんぶ、何も言われなければ世界ぜんぶを答えます。外務省の open data に一覧は無く、案内ページ側(anzen.mofa.go.jp の危険情報一覧)は今もメンテナンス中の代替ページしか返しません。地域単位の XML は 7〜20MB の領事メール集で、危険レベルの一覧ではありませんでした。そこで全 207 か国の XML を直接集計しています。危険レベルは XML の先頭にあるので広域情報の手前で打ち切ればよく、実測 5.2 秒・0.85MB・207 件成功。12 時間キャッシュし、期限が切れたら古い値で答えながら裏で取り直します。
「全土がレベル 4」と「一部の地域だけレベル 4」を分けているのは、国ごとのフラグが地域別に立つためです(インドやパキスタンは国境地帯だけがレベル 4)。実データでは、危険情報が出ているのは 207 か国のうち 125 か国、全土がレベル 4 なのは 14 か国でした。
燃油サーチャージは行き先を言われなければ表の全方面を読みます(6 区分・177 字)。読み上げの字数上限を 160 から 240 に上げたのは、網羅した答えが途中で切れるためです。文の数は 2 のままです。
読み上げの途中で話しかけられるようにした(2026-08-03): 「話してるとき、こちらから話しかけても把握してほしい。長いと待てない」という要望に対して、まず本体が読み上げ中にマイクを送っているのかを局面ごとに数えました。10 サンプルの実測で、読み上げ中に届いたのは 0〜2 フレーム、考え中は 3〜64 フレームです。本体は鳴らしている間ほぼマイクを送りません。
送出を止めるだけでは足りません。サーバーは実時間の 0.85 倍の速さで送るので、本体には先行ぶんが貯まっていて、こちらが黙っても鳴り続けます。そこで文の切れ目で tts stop を送り、貯まりが鳴り終わるまで待ってから 0.4 秒だけ聞く窓を開けます。本体は tts stop を受け取ると自分から listen start を送ってくるので、既存の VAD と「前の発話を処理中なので中断する」経路がそのまま割り込みになり、新しい状態機械は要りませんでした。
窓を文の切れ目にだけ置くと、実際にはほとんど開きません。長い返事ほど 1 文(実測 24.8 秒で 1 sentences)で、複数文の返事は 3.5〜9.5 秒しかないからです。そこで音そのものを見て切るようにしました。6 秒あたりの ±2 秒を探していちばん静かな 60ms(rms 300 以下)を継ぎ目にし、どこも鳴っていれば切りません。実測では 24.02 秒の応答が 5 片に分かれ、継ぎ目の rms は 13〜28(読点の無音そのもの)でした。3.51 秒の短い返事は分割されません。
語の途中で切らないようにした+週間天気(2026-08-03): 実機で「途切れ途切れの変な話し方」と言われ、ログを見ると天気の応答が 最高気温:26.2 度 最低気温:22.9 度 … という読点が 1 つも無い箇条書きでした。読点が無いと分割の切り所も無く、字数で切るしかありません。そこが最後の砦の _avoid_mid_token に守られておらず(数字と英字しか見ていなかった)、「現|在」「55|%」で切れていました。漢字熟語・カタカナ語・数字+単位・範囲(26.2〜32.1)も 1 語として扱い、あわせて生成側のコロンを読点に直します(最高気温:26.2度 → 最高気温は、26.2度)。同じ応答が途中切れゼロ・0.128 秒/モーラで読めるようになりました。
週間天気(「1週間天気とか分からないの困るね」)も足しました。Open-Meteo は同じ 1 回の取得で日別を伸ばせるので forecast_days を 7 にし、when=week で「気温の幅」と「雨が降りそうな日」だけにまとめます(7 日を 1 日ずつ読むと 1 分を超えます)。「雨の日 5 日以上」を最初は「ほか(5 日)」と書いていましたが、声だと日付の「5 日」と区別できないので「ほとんどの日が雨模様」に変えました。
渡航情報を答えられるようにした(2026-08-03): 「ドバイって今安全?」に答える get_travel_advisory を追加しました。外務省の海外安全情報オープンデータ(e-Gov データカタログ登録・キー不要・無料)から、その国の危険情報レベルと発表日を読みます。案内ページ anzen.mofa.go.jp/opendata/opendata.html はメンテナンス中の代替ページしか返しませんでしたが、e-Gov のカタログを引くと配布本体は ezairyu.mofa.go.jp 側にあり、そちらは生きていました。国コードは公式の country.xlsx(207 か国)から機械生成して埋め込み、都市名(ドバイ・ホノルル・パリなど)でも引けます。
作りで気をつけたのは 3 点です。1 国分の XML は Light 版でも 1.5MB あり、後半は領事メールが延々と続くので、<mail が現れた時点で読むのをやめます(実測 64〜128KB で足り、全文を読んだ場合と読み取り結果は一致します)。国名は最長一致にしないと「インド」が「インドネシア」を、「ドミニカ国」が「ドミニカ共和国」を食います(実際に一度取り違えました)。そして広域情報は複数の国に同じものが出ます——ハワイやフランスの回答にも「中東情勢を受けた注意喚起」が付いてしまうので、その国だけの話に聞こえないよう「広い地域向けの注意喚起として」と断り、7 日以内に出たものだけ読みます(毎回付けると回答が 21 秒になりました)。配布側のファイルは数分おきに作り直されている(同じ日の 05:45〜05:49 に 3 か国とも更新)ので、こちらは 30 分ごとに取り直します。
査読で実バグが 3 件見つかりました。いちばん重いのは、取得先が 200 でメンテ用の HTML を返すと「危険情報は出ていません」と断言してしまうことです(危険レベル 4 のアフガニスタンで再現しました)。危険情報の形をしていない応答は読まずに投げ、古い値かエラー文言に落とすよう直しています。ほかに「アメリカ合衆国」が北マリアナ諸島になる(表で 4 件が同じ長さに並び、先頭が先勝ちしていた)、広域情報の題名が読点なしで 37 字あり読み上げが崩れる、の 2 件。あわせて「南アフリカ」「マケドニア」のように言われた方が正式名称より短いと引き当てられないことも分かったので、その場合は部分一致で拾い、「コンゴ」のように複数の国に当たる言い方だけは選ばずに聞き返します。
読み上げの遅さを「測って直す」ようにした(2026-08-02 夕): 実機で「『くーださーい』がスローモーションになる」という指摘を受け、合成器の音素トレース(open_jtalk -ot)で追いました。同じ「ください」が、ふだん 0.52 秒のところ 3.655 秒(音素ひとつずつが 0.53 秒に張り付く)になっていました。
原因を探る統制実験で、この日の朝までここに書いていた「膝は 34 モーラ付近」が否定されました。無意味語「あさひ」を 10 回並べた読点なし 30 モーラは 0.122 秒/モーラで健全なのに、実文「何か聞きたいことややってほしいことがあれば教えてください」は 29 モーラで 0.235、「春のことでも他に気になることがあれば教えてくださいね。」は 0.358 まで落ちます。そして同じ文に読点を 1 つ入れるだけで 0.127 に戻ります。壊れるかどうかは語の並びしだいで、長さからは予測できないということです。
閾値を探すのをやめました。いまは _synth_checked が合成したあとに実測の 秒/モーラ を読み、OJT_BAD_PACE(既定 0.17)を超えていたらその片を 0.6 倍の長さに分け直して合成し直します(合成は実時間の 6% ほどで終わるので、測ってから直す方が安上がりです)。実測で 8.77 → 5.92 秒、10.60 → 4.46 秒、7.05 → 4.31 秒に縮み、もともと健全だった 8 文は 1 ミリ秒も変わりません。閾値を下げる方式と違って、余計な分割が増えないのが利点です。実文 19 片の速さは 0.114〜0.155 で、誤って分け直された片はありませんでした。
査読では、返り値を (音声, 速さ) の組に変えたことに診断スクリプト 2 本が追従しておらず、片方は bytes + tuple で落ち、もう片方は len(tuple) が 2 になって秒数が黙って誤る、という実バグが見つかりました。どちらも直し、返り値の型が環境変数によって変わらないよう常に組を返す形に統一しています。
燃油サーチャージを答えられるようにした(2026-08-02): 「ドバイまでのサーチャージいくら?」に答える get_fuel_surcharge を追加しました。公式の API は無いので、ANA の燃油特別付加運賃ページ(静的 HTML)から、その日の発券日に効く期間の日本発の表を読みます。行き先を言われなければ中東(ドバイ方面)です。JAL の公式ページは自動取得が遮断されている(403)ため、ANA 基準の目安として読み上げます。改定は 2 か月ごとで、期間の切り替わりには自動で追従します。
査読で実バグが 2 件見つかりました。表の 1 行目が「日本-欧州・北米(ハワイ除く)・中東・オセアニア」という形なので、行き先の語をそのまま部分一致させると「ハワイ」も「韓国」も除外の注記の方に先に当たって別の額を返します。括弧内の「〜除く」を落としてから照合するよう直し、実ページと同じ形の試験を足しました。
電車を東京・神奈川の路線に広げた(2026-08-02): 運行情報が都営地下鉄しか答えられないのは、ODPT のキー不要の公開分がそこまでだからです。無料の開発者登録で得られるキーを入れれば、京急・JR東日本・横浜市営地下鉄・東急・相鉄・東京メトロまで読めます。キー到着後すぐ効くよう、既定の対象事業者を広げ、乱れている路線は京急を先頭にした身近な順で読み上げるようにしました。横浜シーサイドラインは ODPT にデータが無いことをカタログで確認しています。
壊れた生成を読み上げないようにした(2026-08-02): 実機の会話で、生成が崩れて「緑が \ ………」と省略記号ばかり 90 字を超える応答が返り、そのまま合成されました(195 応答中 1 回)。省略記号・バックスラッシュ・不可視文字が空白を挟んで 5 個以上続いたら応答ごと捨て、既存の「うまく答えられませんでした」に合流させます。演出としての「……」は 1 個に畳むだけで残します。
分割の物差しを字数からモーラに替えた(2026-08-02): 13 個のツールの応答を実際の読み上げ経路に通して測ったところ、無料枠の残りを答える文が0.185 秒/モーラでまだ伸びていました。しきい値が字数(24 文字)だったため、数字混じりの長さを測れていなかったのが原因です(「2996」は「にせんきゅうひゃくきゅうじゅうろく」で 13 モーラあります)。
何が伸びを決めるのかを 13 例で測り直しました。句読点で切れた 1 かたまりのモーラ数が 33 までなら 0.12〜0.15 秒/モーラで健全、35 以上になると0.24〜0.48 に一斉に破綻します(膝は 34 付近)。(この「膝は 34 付近」も同日夕の統制実験で否定しました。読点 1 つで結果が変わるため、長さの閾値では決まりません。冒頭の 2026-08-02 夕の項を参照してください)字数では説明できず(27 字で 0.351 の一方、26 字で 0.122)、以前ここに書いた「1 アクセント句の長さで決まる」も誤りでした(全文脈ラベルの F: 欄で測ると、最大 7 モーラの句しか無い文が 0.251 で、最大 10 モーラの句を含む文が 0.124 です)。モーラ数が効かないように見えていたのは、句読点をまたいで数えていたからです(句読点を含む 35〜36 モーラの文は健全なままでした)。
そこで、合成せずに読みのモーラ数を見積る _mora_est を入れ、しきい値を OJT_MAX_MORA(既定 30)に替えました。13 ツールの応答すべてで最悪値が 0.185 から 0.144 に下がっています。
査読で「見積りが実際より小さくなる入力があるのでは」と指摘を受け、合成器の音素列と突き合わせて確かめたところ、3 件が実際に過小でした(全角の % が 0 モーラ、全角の英字が 0 モーラ、007 の先頭ゼロを捨てていた)。いずれも修正し、再測定で 5 例とも実際以上になっています。数字だけの長大な連続が分割できない点は、読みを壊さない方を選ぶ意図した動作としてそのままにしました。
末尾の間延びを直した(2026-08-01 夜): 「たまに末尾がスローモーションになる」という指摘の原因は、以前直した呼気段落の伸びとは別物でした。Open JTalk は合成のたびに前 0.41 秒・後 0.58 秒ほどの無音を必ず付けます。応答は文ごとに合成して送るので、短い返事ほど無音の比率が上がり、実測で全体の 27〜56% が無音でした(「よかった!また何かあったら呼んでね。」は 3.94 秒のうち 1.99 秒が無音)。最後の文は必ず 0.58 秒の無音で終わるため、そこが間延びして聞こえます。合成音の前後の無音を落として、文の区切りに 0.12 秒の間だけ足し直すようにしました。実文 6 本で全体の長さが 11.6〜38.0% 短くなり、振幅 100 以上の音は 1 ミリ秒も失われないことを確認しています。
しきい値の決め方は実測に依りました。パディングは真の無音ではなく振幅 20〜50 程度の微小ノイズを含むため、しきい値 45 では無音を音と誤判定し(全語で頭の検出が 0.31 秒早まる)、一方で日本語の弱い子音(「ふ」の摩擦、語尾の無声化母音)は振幅 50〜100 に出ます。その隙間の 60 を採り、頭 0.02 秒・尻 0.08 秒の余白を残しています。
調べる過程で分かったことを 2 つ記録しておきます。ひとつは、読み上げの破綻は文字数では予測できないこと。44 モーラでも 0.127 秒/モーラで健全な例がある一方、語境界が立たない長い漢字連結(24 文字・47 モーラ)は 0.191 秒/モーラまで落ちます。(この時点では「1 アクセント句の長さで決まる」と書きましたが、後日の測り直しで否定しました。次の項を参照してください)もうひとつは、モーラ数で区切ろうとすると「29」を「2」と「9」に割ってしまうこと(実際に踏みました)。読みが壊れるくらいなら間延びしたまま出す方がよいので、語の途中でしか切れない場合は分割しない作りにしています。
あわせて、単体試験 6 本が sys.path に稼働中のコピーを決め打ちしていたのを、スクリプト自身の場所を見るよう直しました。作業中の変更が検証されないまま「通った」ことになる作りでした。
暮らし系ツール 5 つの追加(2026-08-01): get_typhoon(気象庁の台風 JSON。発生中の台風の番号・名前・現在位置・勢力・進路・中心気圧を読み上げ、熱帯低気圧は台風として数えない)、get_heat(環境省の暑さ指数 WBGT 予測 CSV と熱中症警戒アラート CSV。いまの指数・きょうの最高・警戒レベル・行動の目安まで。既定は横浜/神奈川県)、get_train(公共交通オープンデータセンター ODPT の列車運行情報)、get_onthisday(Wikipedia の「今日は何の日」。リンク記法を読み上げ用の素の文に直して 3 件)、get_sky(月齢と日の出・日の入り。外部通信なしの計算のみ)を追加しました。
月齢は平均朔望月だけで出すと最大 0.5 日ずれるため、太陽との離角を Meeus の短縮級数で計算しています。国立天文台の暦要項が公表する朔弦望 4 点(2026-07-14 18:44 の朔、07-29 23:36 の望、08-13 02:37 の朔、08-28 13:19 の望)との差は 0.08〜0.21 日でした。日の出・日の入りも夏至・冬至の公表値と 1 分以内で一致します。
電車の運行情報だけは事情が違います。以前この用途で広く使われていたtetsudo.rti-giken.jp の無料 JSON は接続できず、無料公開は 2022 年に終了していました。ODPT はキー不要の公開エンドポイントもありますが、そちらに載っているのは都営地下鉄だけで、JR東日本・京急・東急・相鉄・東京メトロは無料の登録キーが要ります。そのためODPT_TOKEN があれば横浜まわりの各社を、無ければ都営地下鉄だけを見て「いまは都営地下鉄しか調べられません」と断る作りにしました。
5 つとも無料・追加課金なしです。実 LLM のツール選択は 27/27 でした。
追加後の査読で 6 点直しています。ODPT のトークンは URL クエリに載るため、aiohttp の例外文字列(403, message=..., url='...consumerKey=...')がそのままログに落ちる経路がありました。実際に混入することを確かめた上で伏せ字にしています。登録キーでの取得が失敗したときにキー不要の公開分へ退避せずツールごと沈黙する穴も塞ぎました。暑さ指数は環境省の提供が夏期だけ(令和8年度は 4月22日〜10月21日)なので、期間外に「取得先が応答しません」と言わないよう分けています。熱中症警戒アラートは 5 時版と 17 時版があり、夕方は 17 時版、未明は前日 17 時版を見るようにしました。ほかに「今日は何の日」の読み上げが長い日に途中で切れないよう件数を詰める処理と、入れ子テンプレート・<ref> の残骸を落とす処理を足しています。
ニュース・地震・警報ツールの追加(2026-08-01): get_news(NHK の公開 RSS から主要ニュースの見出し 3 件。応答整形が 2 文で切るため読点で繋いだ一文で返す)、get_quake(気象庁の公開 JSON から最新の震度つき地震。震度 5 弱などの表記変換と「この24時間で◯回」の集計つき)、get_warning(気象庁の警報・注意報 JSON。status が発表・継続のものだけを有効とみなし、コードは気象庁 XML の標準表で名前に引く。既定エリアは神奈川県)を追加しました。すべてキー不要・無料の公開データで、キャッシュと取得先全滅時の退避は他のツールと同じ作りです。実 LLM のツール選択は 18/18 でした。
暗号資産ツールの追加(2026-08-01): ビットコイン・イーサリアムの円建て価格を答える get_crypto を追加しました。主は CoinGecko(キー不要・1 リクエストで両方 + 24時間変動率)、予備は Yahoo Finance の BTC-JPY / ETH-JPY です。導入前の三点照合で Coinbase spot の BTC-JPY が実勢の 3.6 倍の異常値を返すことを確認したため Coinbase は使いません。読み上げは「およそ991万円、前日から4%安い」のような丸めた形で、桁違いの値を弾く門番と取得先全滅時の退避(6 時間まで)も他のツールと同じ作りです。
無料枠残量ツールの追加(2026-08-01): 「無料枠あとどれくらい?」に答える get_llm_quota を追加しました。さくらのAI Engine には利用量を返す API が無い(/v1/usage 等は全部 404、応答ヘッダにもレート制限情報なし、コンパネの利用量はセッション認証)ことを実測で確認した上で、このサーバーから送った成功リクエストを月次 JSON で自前カウントする方式にしています。サーバーの外で使った分は数えられないため、その旨も一緒に読み上げます。実 LLM のツール選択は 13/13 でした。
スローモーション読み上げの修正(2026-08-01): 実機で「たまに読み上げがスローモーションになる」問題の真因を特定して直しました。サーバーの送出遅延ではなく Open JTalk 自体の現象で、読点・句点の無い長い連続(呼気段落)が約 30 字を超えると時間長モデルが壊れ、音素が引き伸ばされます(実測: 28 字 0.19 秒/字 → 36 字 0.52 秒/字。無音が挟まるのではなく有音そのものが伸びる)。機械的に読点を足しても位置が語境界でないと直らないため(「教えても、らえると」では遅いまま)、長い連続は語境界らしい所(助詞の後ろ・直後が平仮名でない所)でテキストごと分け、別々に合成して短い無音で繋ぐようにしました(split_long_runs)。実機で 23.6 秒かかっていた応答は 11.4 秒(自然な速さ)になります。
株価指数ツールの追加(2026-08-01): サーバー側ツールに get_stock_index(日経平均・ダウ平均・S&P500)を追加しました。ドル円と同じ Yahoo Finance の chart API(キー不要・無料)で、指定が無ければ 3 指数まとめて答えます。取引時間外は「◯月◯日の終値」と断り、前日比も読み上げます。S&P500 の読み上げ名は Open JTalk が記号を読めないため「エスアンドピー500」にしてあります。単体試験(実取得・取得先全滅時の退避・書式)と実 LLM のツール選択 12/12 を確認済みです。
会話ツールの追加と OOM 修正(2026-08-01): サーバー側ツールに get_usdjpy(ドル円レート)を追加しました。取得元は Yahoo Finance → Coinbase → open.er-api.com の 3 段(すべて無料・キー不要)で、天気と同じくキャッシュと古い値の上限を持ちます。実 LLM のツール選択は 9/9 でした。あわせて、mode:auto の実機が環境音を流し続けると受信バッファが際限なく育つ VAD の盲点(無音だけではどのカウンタも進まない)を修正しました。この盲点は実際に RAM 7GB 超の OOM kill を 2 回起こしていたものです。無音だけでも 15 秒で必ず掃き出し、声の無い掃き出しは STT に回さず捨てます(test_vad.py / probe_vad_silence.py で検証)。さらに、環境音が「うん」「あっ」のような相槌として書き起こされるたびに勝手に返事をしてうるさい問題も止めました。唐突な相槌・一文字・空認識には返答せず、こちらが話した直後の相槌だけ会話の返事として通します(test_filler.py / probe_filler.py)。
音声バックエンドの差し替え(2026-07-26): プロトコルの読み取りと、自前サーバー(OTA エンドポイント + WebSocket)の実装・検証まで終わりました。実機を使わず、本体を模した試験クライアントで次が通ることを確認しています。
- OTA が現在版と同じ
versionを返し、更新を走らせないこと - サーバーの
hello(transportはwebsocket厳密一致)を返せること - 上り Opus(16000Hz mono / 60ms)を復号できること
stt/llm/ttsを送り、下り Opus(24000Hz)を生成できること
本体の接続先を切り替えるための NVS イメージ(wifi 名前空間に ota_url、16KB)も生成しました。ただし 16KB を丸ごと差し替えると既存の Wi-Fi 設定とアプリの紐付けが消えて再ペアリングが必要になるため、この案は採らず、既存の NVS に 1 エントリだけ追記する方式に変えて 2026-07-30 に実施しました(検討メモに手順と実測)。再ペアリングは不要でした。
ローカル音声スタック(2026-07-27): 認識と合成を Raspberry Pi 5 上のローカル処理に寄せ、常駐まで通しました。外部 API を使わずに、発話 → 認識 → 読み上げの往復が動きます(応答の生成だけはまだドライランです)。
- 認識 = sherpa-onnx + ReazonSpeech k2 v2(zipformer transducer、int8)
- 合成 = ローカルの VOICEVOX エンジン(Docker、ずんだもん)
- 応答は文ごとに合成して送るので、最初の音が出るまで 2.2〜2.5 秒
- systemd で常駐(設定は EnvironmentFile 経由。秘密はリポジトリに入れません)
認識バックエンドは実測で選びました。VOICEVOX で作った 12 文を、本体と同じ Opus(16000Hz / 60ms)に通してから認識させた結果です。
| バックエンド | CER | RTF | 備考 |
|---|---|---|---|
| sherpa-onnx + ReazonSpeech k2 v2 (int8) | 4.3% | 0.16 | 誤りは「明日 → あした」のような表記差だけ |
| Vosk small-ja 0.22 | 11.3% | 1.05 | 「温度 → 腕」のように意味が壊れる |
| faster-whisper small (int8) | 1.4% | 2.48 | 精度は最良だが 2.5 秒の発話に 6 秒かかる |
RTF は音声の長さに対する処理時間で、1.0 を超えると認識が発話に追いつきません。会話で使えるのは速さと意味の正しさが両立した sherpa-onnx だけだったので、これを既定にしました。精度優先のバッチ処理向けに faster-whisper も選べるようにしてあります。
この測定は合成音声に対するものなので、実際のマイク入力より楽観的な値です。実機のマイクで取り直すまでは目安として扱っています。
サーバーの実装を公開・道具の呼び出しに対応(2026-07-27): サーバー一式を server/ に置きました。あわせて、本体が持っている道具(MCP のツール)を使えるようにしています。
- 本体との接続直後に MCP の
initializeとtools/listを送り、本体が持つツールの一覧を取る - 応答生成が関数呼び出しを返したら
tools/callで本体に実行させ、その結果を踏まえて返事を作る - 応答トークンが無くても検証できるよう、OpenAI 互換のモック(
server/mock_llm.py)を同梱
発話の処理は WebSocket の受信ループとは別のタスクで動かしています。受信ループの中で待つと本体からのツール応答を読めず、自分の待ちでタイムアウトします(実測で 10 秒の待ちが発生しました)。
サーバー側の道具と会話の続き(2026-07-28): 本体が持つ道具(機体の操作)だけでは外の情報を答えられないので、サーバー側にも道具を置き、両方を 1 つの配列にまとめて応答生成へ渡し、名前で振り分けるようにしました。第一号は天気(Open-Meteo、鍵不要)です。地名の表は geocoding の実応答から生成しています(座標を手書きしないため)。
会話の続きは Device-Id ごとにサーバーが保持します。本体は一区切りごとに WebSocket を切るので、接続の中だけで持つと毎回はじめましての会話になります。
地名の解決には落とし穴がありました。geocoding の 1 位は目的地とは限らず、"Tottori" は 1 位が釧路市の一地区(人口が空)で、鳥取市は 4 位でした。先頭を採ると「鳥取の天気」に北海道の天気を答えます。地物の種別(県庁所在地かどうか)と人口で並べ替えてから採るようにしています。
実物の応答生成で通した(2026-07-29): それまでの検証はこちらが仕込んだ関数呼び出しを返すモックだったので、本物のモデルで道具の選択が成り立つかは分かっていませんでした。手元の Raspberry Pi 5 に小さなモデル(3B)を置いて OpenAI 互換の経路をそのまま駆動し、3 つの不具合を見つけて直しました。
- システム文に現在時刻を入れると、プロンプトキャッシュが毎回捨てられる。道具の定義はチャットテンプレート上システム文の後ろに描かれるので、時刻が分単位で変わると道具ごと作り直しになります。同じ発話列で 1 往復 35.1 秒 対 7.8 秒でした
- 道具の往復を会話の続きに残さないと、省略形の追い質問で道具を呼び直さない。「あしたの大阪の天気」の直後の「じゃあ鳥取はどう?」で、実際とは違う数値を作って読み上げました
- 生成が上限で切れると道具の呼び出しが壊れ、閉じタグが本文として読み上げられる
小さいモデルでも破綻させない・ネットが切れても話せる(2026-07-30): 前回「残りはモデル側の粗さ」と書いた 2 点は、サーバー側で直せるものでした。詳細は server/README.md にあります。
-
催促よりサーバー側で決める。日付の指定が落ちる問題を、定義で必須にしたり文で促したりすると、モデルは道具そのものを呼ぶのをやめて数値を作り話しました(呼び出し 1/9)。促すのはやめ、落ちた時にサーバーが決めるようにしました(発話の言葉 → 少し前に調べた日を引き継ぐ → 今日)。引き継ぎは 180 秒以内だけで、何十分も前の「明日」を新しい質問に継ぎません
-
守らせたい形は文で頼まず処理で切る。システム文を足すほど道具の選択が落ちます
システム文 道具の呼び出し 2 文以内 指示なし 7/9 3/9 07-29 までの文 4/9 6/9 指示を足した長い文 1/9 8/9 短縮 + 処理で切る 7/9 読み上げ 9/9 システム文はモデルにしかできないこと(道具を使う・作り話をしない)だけに絞り、文の数と長さは処理側で切ります
-
ネットが切れても会話が続く。認識と合成はもともとローカルなので、応答生成だけ手元の小さいモデルへ落ちるようにしました。落ちるのは向こう側の都合(401/403/429/5xx・接続不能・待ち時間切れ)だけで、こちらの組み立てが悪い 400/404/422 では落ちません。落ちている間は本番を叩き直しません(1 発話で 2 回叩くので、毎回待つと本体が数十秒黙ります)
-
モデルを大きくしても解決しません。同じ測定を 7B で流したら 3B より悪く、8GB の Raspberry Pi 5 では swap が満杯になって SSH が応答を返せなくなりました。この箱に置くのは 3B までです
本体を自前サーバーへ向けたあと(2026-07-30): 接続先の切り替えは済みましたが、本体が繋いだあと hello が来ないまま 15 秒で切れる状態が出ています。原因はまだ確定していません。分かったことと打った手を書いておきます。
- 最初の詰まりはファイアウォールでした。サーバーは
0.0.0.0:8000で待っていたのに、Raspberry Pi 5 の ufw が既定拒否で 22 と 3389 しか通しておらず、本体からの接続が落とされていました。LAN からの 8000 番だけを開けて解決しています(どこからでも、にはしません) - ファームの実装を読むと待ち時間は 2 段です。M5Stack のファームは上流の xiaozhi-esp32 v2.2.4 に patch を当てたもので、その patch は WebSocket 周りを触っていません。順序は「握手要求を送って完了を 10 秒待つ → 成功して初めて
helloを送る → サーバーのhelloをさらに 10 秒待つ」。つまり握手待ちで切れた場合、helloは 1 バイトも出ません - サーバー側は容疑から外れました。ファームの WebSocket クライアントの握手要求を byte 単位で写した試験(
server/test_firmware_handshake.py)を作って当てたところ、握手もhelloの往復も通ります。ヘッダの並び(実装がstd::mapなので ASCII 昇順)、Hostにポートを付けない、拡張もUser-Agentも送らない、マスク付きフレーム、どれを真似ても問題ありません helloが届かない場合の保険を入れました。HELLO_GRACE(既定 3 秒)待って来なければ、サーバーから先にhelloを送ります。ファーム側はhelloの順序を問わないので安全です。往路のhelloが落ちているのが原因なら、これだけで本体は開きます
実機との往復が成立・真因は自分の NVS 追記ミスでした(2026-07-31): 前日の「hello が来ない」の真因は、NVS へ追記した ota_url エントリの鍵フィールドの余白を消去状態(0xFF)のまま残していたことでした。ESP-IDF は鍵を 0x00 で埋め、その保存バイト列からハッシュ索引を引くため、0xFF 埋めのエントリは CRC も長さも正しいのに永久に見つかりません。自作の検証は全部すり抜けました。教訓は「自作パーサを信じず Espressif 公式の nvs_partition_tool で検証する」です(詳細は検討メモ)。修正後、声 → 認識 → 応答 → 読み上げの全往復が自前サーバー経由で動いています。接続先の往復切り替え(自前⇔出荷時)は tools/switch-backend.ps1 で 1 コマンド化し、実機で検証済みです。
応答生成を本物にした・話し終わりから約 2 秒で返るようにした(2026-07-31): 応答生成をさくらのAI Engine(gpt-oss-120b、無償枠 月 3,000 リクエスト)に切り替えました。ツール選択の探針は 7/7、応答 0.4〜1.4 秒です。実機での会話で見つけて直したのは次の 3 点です。
max_tokens200 では本文が空のまま切れることがある。gpt-oss 系はツールを渡すと思考(analysis チャネル)にもトークンを使うため、少ない上限だと本文に届く前にfinish_reason: lengthになります。さくらはリクエスト数課金なので上限を上げてもコストは変わりません- 発話に添えた時刻を応答に書き写してくる。しかも「(20:23)」のコロン形式と「(2026-07-31 21:27)」の日付形式の 2 通りで、日付の数字列が音声合成に渡ると合成時間が延びて初音が 12 秒まで膨らみました(「遅い」の実体はこれ)。除去パターンの拡張とシステムプロンプトでの禁止の二重で対処しています
- 読み上げの最初のかたまりを短くする。合成時間は文の長さにほぼ比例するので、第一文を読点で割って先に鳴らすようにしました
仕上げとして、音声合成を VOICEVOX から Open JTalk に替えました(利用者の選択。声質より速さを優先)。1 文の合成が実測 0.27 秒(約 15 倍速)になり、話し終わりから初音まで約 2 秒です。絵文字だけの断片を渡すと No phoneme で合成ごと失敗するので、音にならない断片は事前に除外しています。
音源が無い選手を、ゲームの譜面から歌えるようにする(2026-08-06〜07・進行中): 歌えなかった 12 曲の穴を埋める道が見つかりました。応援歌エディタの譜面動画(ピアノロール画面を 1 小節ずつ映すもの)が YouTube に大量にあり、公式歌詞 25 曲のうち 15 曲ぶんが揃います。譜面には音の高さと長さがそのまま描かれているので、歌唱音源からの採譜と違って耳の誤差が入りません。読み取りは、再生カーソルが左へ戻る所でページを切り、フレームの中央値で合成してカーソルを消し、行ごとの地と山から敷居を引いて音符の帯を拾います。同じ曲を 2 回演奏する動画なので、絵の差で小節を対応付けて 2 回ぶんを合流させると読みが安定します。
- 休みを詰める細工は禁止。声の出ている割合という数字を上げたくて曲中の休みを縮めたら、リズムが譜面と違うものになり「音符まであるのに、なんでリズム同じのできないの?」と指摘されました。譜面の長さはそのまま鳴らします
- 最後の「かっとばせー!○○!」は歌ではなくコール。譜面にも音符がありません。旋律に乗せると余った音符に詰め込まれて歌が壊れます。いまは音として付けず、歌い終わりで終わります
- 渡す前に測る。声の出ている割合・楽譜との高さのずれ・最長の無音を、既存の曲の実測の帯(声 81〜90%・ずれ 0.46〜2.05 半音・無音 0.41〜1.11 秒)と比べてから渡します。度会隆輝と石上泰輝がこの帯に入りました(どちらも音源が無く歌えなかった選手です)。読み方の版の優劣も、耳ではなく「音符の数が歌詞のモーラ数に合うか・埋まらないマスの割合・2 回の演奏の読みが一致するか」で決めます
譜面の 4 曲を実機に組み込み(2026-08-08): 譜面から起こした旋律を実機の応援歌ツールに繋ぎ、歌える曲が 12 → 16 になりました(度会隆輝・石上泰輝・林琢真・京田陽太=いずれも音源が配布されていない選手)。組み立ての本体は server/sheet_song.py(歌詞は 1 音 1 モーラ・足りないぶんは長い音符から 2 モーラ・コールは付けない)で、cheer_song.prepare が「音源が無い」の代わりに cache/sheets/<選手名>.json の譜面を探します。譜面 JSON は歌詞・音源と同じ扱いでリポジトリには入れません。
読み取りの側は 2 つの誤りを直しました。①半音の格子の傾きがわずかに過大で、基準の行から離れた行ほど格子が流れ、特定の高さの行を丸ごと読み落としていました(林・京田がこれで不合格帯だった)。音符の帯を行ごとにまとめ、行の中央値に本数の重みを付けて傾きを測り直します。②ピアノロール左上の小節番号(中抜きの数字)が、音の無いページで 1 マスの偽音符になり、曲の始まりの判定と小節の対応付けを壊していました。「過半のページで光り続ける列」を数字の居場所として覚え、それを呑み込む狭い読みを、実音符の無い行に限って捨てます。列の明るさの頻度だけで消そうとすると、同じ行の別の音符が列を共有して本物まで消えるので、この 2 段構えが要点です。
実測(既存 12 曲の帯: 声 84.8〜90.6%・ずれ 0.46〜1.97 半音・最長無音 0.40〜0.58 秒): 度会 声 86.0%・ずれ 0.53 / 石上 85.6%・0.64 / 林 84.8%・0.39 / 京田 80.8%・0.43(京田の声だけ帯を下回りますが、譜面の休符が多い曲のためで、マス占有率で割った合成効率は他と同一です)。VOICEVOX を止めたまま作り置きから 4 曲とも歌えることを確認し、通し試験(歌 7/7・ツール 5/5・会話 3/3・割り込み PASS・整形 91/91)も全部通っています。
実機で聞いてもらって直した 7 件(2026-08-08): 譜面から起こした 4 曲を実機に組み込み、実際に話しかけてもらったところ、聴いて初めて分かる問題が並んだ。数字が揃っていても耳では別物、という例が多い。
- 選手名の「名」を軒並み誤読していた(隆輝→タカテル、泰輝→ヤスシテル、琢真→ミガクシン、敬斗→タカシト、竜拓→リュウタク、敏郎→トシオ、昂希→ノボルノゾミ、神里→カミサト)。読みは日本野球機構の選手ページの公式ふりがなに合わせた(石上は「いしかみ」が公式)。歌う前の一言だけ別経路を通っていて置換をすり抜けていたのも直した
- 話しかけていないのに喋り続けた。相槌に返事してよい猶予(発話後 15 秒)が、返事のたびに開き直る作りだったので、テレビの音が「うん」「よし」と認識されるたびに自分の返事で窓を開け続けていた。相槌に返事できるのは直後の 1 回だけにした
- 「度会の歌歌って」で道具を呼ばず「歌えない」と即答した。新加入の選手を知らないモデルが自分の知識で答えていた。道具の説明に「人名らしき言葉+歌ってなら、心当たりが無くても必ず呼ぶ(歌えるかは道具が判定する)」と書いた
- 「京田の応援歌」と言うと歌詞を読み上げるだけだった。歌詞を読む道具の説明文自身に「◯◯の応援歌うたって」に使うと書いてあり、歌う道具と矛盾していた。線引きを両側に明記した
- 歌の後半のリズムが違った(「かがやけーきょうだー」が別のリズムになる)。原因は 3 つ重なっていた。①譜面の帯を横切る線のかすれで 4 マスの音が 2+2 に割れていた(本物の連打の切れ目は敷居から 107〜168 潜るのに対し、かすれは 0.5〜5 しか潜らない=深さで見分ける。真偽は譜面自身が音符ごとに描いている音名ラベルの数で全数検証した)②小節線を越えた音を二重に数えていた ③歌詞の配りが曲全体の後詰めだったので、離れた場所の 1 音のずれが伸ばす音節を狂わせていた(→ フレーズ単位の対応付けに変更)
- 会話の声が歌より小さい。実効値をそろえても直らず、周波数の中身を測ってようやく分かった。歌(女声)は 500Hz 以上に 90% 以上、会話(男声の合成)は 500Hz 未満に 73〜84% あり、本体の小さいスピーカーが鳴らせない帯に集中していた。鳴らない低音を落としてから、実際に鳴る帯の実効値を歌に合わせる
- 反応が遅い。聞き取りが生のバッファ十数秒をまるごと処理していたので、最後の発話 1 回ぶんだけ渡すようにした(10.76→2.00 秒・認識 0.78→0.22 秒)。さらに返答を書き終わるのを待たず、1 文できた時点で読み上げ始めるようにした(0.46 秒ぶん早く鳴り出す)
測って直す道具も足した。譜面の読みは音名ラベルの数と読んだ音符の数を小節ごとに突き合わせるのがいちばん確実で、深さ・幅・位置の 1 次元の物差しでは原理的に分けられない箇所がある。
実機で聞いてもらって直した 8 件(2026-08-23): 「話しかけても反応しない」「反応がにぶい、まれに反応したりで、とても使いづらい」から始まって、歌の指摘が続いた回。症状の名前から原因を推測せず、まず実ログの数字を見るのが今回もいちばん効いた。
- 反応がにぶい。原因は「声とみなす敷居が 1 本しかなかったこと」だった。声の大きさは 1 つの語の中で 3 倍以上ゆれるので、山だけが敷居を越えて谷が「無音」と数えられ、
VAD_MIN_SPEECH(0.3 秒) +VAD_SILENCE(0.6 秒) の条件が語の途中で成立して頭だけ掃き出す。実ログ 12:00〜12:24 の 24 分で、声の最大 rms が 200〜540 だった発話 30 件はすべて「あ」「あれ」「あっ」という断片になり、相槌の門番で無応答になっていた。同じ人が rms 980 以上で話した 8 件は全文が通っている。直し方はヒステリシスで、話し始めたあとは敷居を 0.6 倍に下げる。ただし下げっぱなしにはしない(物音で話し始めたことになると、その後は環境音でも「まだ喋っている」ことになり上限 15 秒まで抱え込む)ので、大きい声から 1.2 秒だけに限った。合わせて、ログが実効値ではなく固定値VAD_RMSを「閾値」として出していた観測側の嘘も直した。試験は実測帯(山 450 / 谷 300 / 環境音 200)で 4 件足し、ヒステリシスを無効にすると落ちることまで確かめた - 「神里」と言っても「上里」になる。「筒香」が「不都合」になる。聞き取りは音は合っていて字だけ違う(どちらも読みは正しい別表記)。照合は字と読みの前方一致だけだったので該当なしになっていた。読みで救済するとき、選手名を丸ごと比べると名のぶんで薄まり(ウエサト × カミサトカズキ = 0.36 で、無関係な スズキ = 0.40 に負ける)、選手の読みの頭とだけ比べる必要がある。さらに読みの近さだけでは「雲」「物」まで選手になるので、漢字を 1 つでも共有するかを条件に足した(上里/神里は「里」、度洗/度会は「度」を共有する。岡本・村上・中村は 1 つも共有しない)。実ログから拾った誤認識 22 件と選手でない語 18 件で測って 30/40 → 36/40
- 「しょうりへ」の「へ」が「え」の発音になっていない(林・松尾・戸柱・石上・梶原の 5 曲)。助詞の読み替えが「語末・行末の は/へ だけ」という規則だったので、空白の無い「しょうりへみちびけ」を取りこぼしていた。どこが助詞かは規則で当てず辞書に訊くことにした(読み上げに使っているのと同じ Open JTalk の形態素解析。読みの列と発音の列は別物で、助詞の「へ」は 読み ヘ / 発音 エ と分かれている)。全部かな書きの行はそのまま解析しても助詞にならないので、「へ」以降の部分文字列を渡して先頭の形態素を見るのが要点。「みせつけろへらる」の「へ」は ヘラルド の一部なので据え置かれる。公式歌詞 25 曲に出てくる「は」「へ」を全部書き出して 1 件ずつ人手で正解を決め、24 箇所すべて一致した(
server/test_particle.py) - 梶原の応援歌が古い歌になっていた。譜面の出所の動画(2024-04)が1 作目=別の選手からの流用で、現在の歌詞は 2 作目のものだった。新旧の判断は動画のタイトルと公開日で付く。2025 年作の譜面と、別チャンネルの 2024-2025 版の譜面をそれぞれ独立に読み取って一致したので差し替えた
- 譜面が正しいかどうかは「別の動画でもう一度読み取って一致するか」で決める。旋律そのものを突き合わせる方法は、同じ曲どうし 2.3〜3.0 半音・別人どうし 2.1〜3.0 半音で対照と重なって判別力が無い(過去に 2 通り試して両方だめだったのを、今回もう一度確かめた)。有効なのは採譜どうしの突き合わせで、位置と長さが一致する音だけを使って「新しい採譜の半音 → 今の採譜の半音」の直線をあてはめ、残差を見る。度会・佐野・林は残差 0.00 半音で完全一致(佐野は 3 本が一致し、音高は調が違うぶんの定数差 −2 と +3 だけ)。半音の物差しがずれた採譜も捨てずに較正して使える(柴田は約 2 倍ずれていたが、位置と長さが一致する 34 音で直線をあてはめると残差 最大 0.37 / 平均 0.13 半音で乗った)
- 柴田の「ミートと」が途中で割れて聞こえた。第 2 小節だけ他と違う形(他の小節は 2,4,2,… なのにそこだけ長い音 1 つ+1 マスの音 2 つ)で読まれていて、語の中に休符が入っていた。2022 年の動画のリズムに今の音高目盛りを移して作り直し、声 82.5 → 84.6%・ずれ 0.68 → 0.34 半音・最長無音 0.42 → 0.41 秒。末尾の迷い音を落とすのも要る(範囲外の 1 マスの音が 1 つ残っていたときは無音 1.27 秒・声 78.1% になった)
- 度会の「勝利」が「しょおおり」に聞こえた。譜面は別動画の採譜と完全一致していて正しく、原因は歌詞の当て方だった。1 音 1 モーラで配ると しょ(4) う(3) り(1) になり、「う」に長い音符が行って「り」が潰れる。「う」を長音として前にくっつけると しょー(4) り(3) … に(12) になる
- 佐野の「ロード」が「ロドー」に聞こえた。1 つの音符に 2 モーラを詰めるとき、いつも後ろを伸ばしていたのが原因。この規則は「ためにー」が「ためー・にー」に化けるのを防ぐために入れたものだが、長音が前にある「ロード」では裏目に出る。伸ばす側を「長音を持つモーラ」に決めるようにして ロー(12) ド(2) になった
直さなかったものもある。林の「スムーズでない」は据え置いた。別動画の採譜と食い違うのは第 5 小節だけで、そちらに替えると声が 85.0 → 83.8% に下がり末尾に伸ばしが 2 つ増える。どちらの読みも音楽的に有り得る形で決め手が無いので、決め手が無いまま数字を悪化させる変更はしないことにした(報告された「へ」の件は上の 3 番目で解決している)。牧は譜面を外して音源に戻した(譜面の重ね位置が 2 モーラぶんずれていて、教わった「きたえーた」の伸ばしが「き」の位置に来ていた。13 曲中いちばん悪い数字だった)。
版違いを 2 つ並べて「どちらが良いですか」と渡さない、という手順上の失敗もした。優劣は自分で物差しを決めて測って決め、仕上がった 1 つだけを渡す。耳でしか決まらないことだけを尋ねる。
残っている宿題が 1 つある。筒香だけ 20.0 秒(他は 9〜12 秒、森 15.1 秒、その他の左打者 19.1 秒)で、1 モーラあたり 0.51 秒と他の 2 倍近く間延びしている。譜面が無く音源から旋律を採る曲なので、原因は上の 8 件とは別系統(音源の切り出し側)にある。