Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

自前サーバー(XiaoZhi プロトコル)

スタックちゃん(M5Stack CoreS3、公式ファーム 1.4.4)の接続先を、標準の XiaoZhi サーバーから 手元の Raspberry Pi 5 に切り替えるためのサーバーです。ファームウェアは公式バイナリのままで、 NVS の wifi/ota_url を書き換えるだけで接続先が変わります(アバターやアプリ連携は維持されます)。

構成

ファイル 役割
app.py OTA エンドポイント + WebSocket 本体(音声の往復、ツールの振り分け、会話履歴)
opus_codec.py libopus の ctypes 束縛(上り 16kHz / 下り 24kHz / 60ms)
local_stt.py ローカル音声認識(sherpa-onnx / Vosk / faster-whisper)
mcp_client.py 本体の MCP サーバーに対するクライアント(initialize / tools/list / tools/call)
server_tools.py サーバー側のツール(天気・ドル円・株価指数・プロ野球ほか)。本体のツールと 1 つにまとめて LLM へ渡す
places.py 地名 → 座標の表(gen_places.py が Open-Meteo geocoding から生成)
gen_places.py places.py の生成器(座標を手書きしないための道具)
test_client.py 本体を模した試験クライアント(MCP デバイス役も兼ねる)
test_tools.py サーバー側ツールの単体試験(本体も LLM も不要)
test_gateway.py 既定経路の読み取りの単体試験(経路表を差し替えるので回線に依存しない)
test_hello_grace.py 本体の hello が届かない時にサーバーから先に hello を出すかの確認
test_firmware_handshake.py ファームの WebSocket 握手を byte 単位で写して通ることを確認する
test_broken_mcp.py 壊れた mcp メッセージ 1 通で対話ごと落ちないことの確認
test_auto_mode.py 本体が listen stop を送らない mode:auto でも応答できるかの確認
mock_llm.py さくらの AI Engine の代役(OpenAI 互換 + VOICEVOX 形式 TTS の中継)
bench_stt.py / bench_stt_opus.py / bench_stt_cer.py 音声認識の速度・精度の測定
bench_tts.py VOICEVOX の合成速度の測定
server.conf.example 設定の雛形(実ファイルは server.conf、秘密はそこだけに置く)
stackchan-server.service systemd ユニット

ツール

LLM には本体のツールとサーバー側のツールを 1 つの配列にまとめて渡し、 呼ばれた名前で振り分けます(app.py の call_tool)。

  • 本体側(MCP 経由): 機体の操作。ファーム 1.4.4 では self.audio_speaker.set_volume
  • サーバー側: get_weather(場所・今 / 今日 / 明日 / 明後日 / これから1週間)/ get_usdjpy(ドル円レート)/ get_stock_index(日経平均・ダウ・S&P500)/ get_llm_quota(さくら無料枠の使用回数と残り)/ get_crypto(ビットコイン・イーサリアムの円建て価格)/ get_news(NHK RSS の主要見出し)/ get_quake(気象庁の地震情報)/ get_warning(気象庁の警報・注意報、既定は神奈川県)/ get_typhoon(気象庁の台風情報)/ get_heat(環境省の暑さ指数と熱中症警戒アラート、既定は横浜)/ get_train(ODPT の運行情報。ODPT_TOKEN があれば京急・JR東・横浜市営地下鉄・東急・相鉄・東京メトロ、無ければ都営地下鉄のみ)/ get_onthisday(Wikipedia の今日は何の日)/ get_sky(月齢と日の出・日の入り、計算のみ)/ get_fuel_surcharge(国際線の燃油サーチャージ。行き先を言わなければ中東) / get_travel_advisory(外務省の海外安全情報。国ならその国、「中東」「ヨーロッパ」等ならその地域ぜんぶ、省略すれば世界ぜんぶを全 207 か国の集計で答える) / get_baseball(NPB の今日の試合速報と勝敗表の順位。球団を言わなければ全試合と両リーグの順位)/ get_roster_move(NPB の公示=出場選手の登録・登録抹消)/ get_cheer_song(横浜DeNAベイスターズ公式の選手応援歌の歌詞。ふりがなの方を読む。歌詞はこのリポジトリに置かず実行時に取りに行く)

天気の取得先は Open-Meteo です(API キー不要)。地名は同梱の表で引きます。 ドル円は Yahoo Finance → Coinbase → open.er-api.com の順で引きます(すべてキー不要・無料)。 株価指数は同じ Yahoo Finance の chart API で引きます(query1 → query2 のホスト冗長 + 6 時間の stale cache)。取引時間外は「◯月◯日の終値」と断って読み上げます。 無料枠の残りは、利用量 API が無い(/v1/usage 等は 404・応答ヘッダにも情報なし)ため、このサーバーから送った成功リクエストを月次で自前カウントして答えます(サーバー外の消費は数えられない旨も読み上げます)。 暗号資産は CoinGecko(1 回で両方 + 24時間変動率)→ Yahoo Finance の順で引きます。Coinbase の spot は BTC-JPY が実勢の 3.6 倍の異常値を返した実測があるため使いません。 Open-Meteo の geocoding は日本語名を引けない(「札幌」は 0 件、"Sapporo" なら当たる)ため、 ローマ字で引いて返ってきた日本語名と座標を gen_places.py で表に落としてあります。

現在時刻は毎回システムプロンプトに差し込みます。LLM は今が何時か知らないので、 ツールにするより確実で、往復も増えません。

会話の続き

本体は一区切りごとに WebSocket を切ります。接続の中だけで履歴を持つと毎回はじめましての 会話になるので、Device-Id ごとにサーバー側で保持します(既定 30 分・直近 10 往復、 HISTORY_TTL / HISTORY_TURNS)。

動かし方

python3 -m venv .venv
./.venv/bin/pip install aiohttp sherpa-onnx
cp server.conf.example server.conf && chmod 600 server.conf
./.venv/bin/python app.py

読み上げの既定は Open JTalk です(Raspberry Pi 5 の CPU でも 1 文 0.3 秒で合成できるため)。

sudo apt install open-jtalk open-jtalk-mecab-naist-jdic hts-voice-nitech-jp-atr503-m001

声質を優先するなら VOICEVOX も選べます(TTS_BACKEND=voicevox)。 合成は 1 文 2〜5 秒かかるので、初音までの待ちが数秒延びます。

docker run -d --name voicevox --restart unless-stopped -p 127.0.0.1:50021:50021 \
  voicevox/voicevox_engine:cpu-arm64-0.25.2

音声認識のモデル(sherpa-onnx / ReazonSpeech k2 v2 の int8)は csukuangfj/reazonspeech-k2-v2 から encoder / decoder / joiner の int8.onnx 3 つと tokens.txt を ~/models/reazonspeech-k2-v2 に置きます(合計 160MB 程度)。

歌う

旋律つきで歌う機能は 2 度外して、2026-08-06 に採譜を入れ替えて戻した。 経緯を残しておく(同じ道を二度歩かないため)。

1 度目(Sinsy 版): 実機で聴くと歌になっていなかった。全 25 曲を測ると 24 曲は起こした音符の 30〜57% が休み。基準を通った 1 曲も、聴けば駄目だった。こちらの測り方が耳と合っていなかった。 2 度目(VOICEVOX 版・2026-08-05): リズムが元音源と合っていなかった。原因は音符の切れ目を「音の高さが変わった所」で決めていたこと=歌のリズムは音節の始まりで決まるので、同じ高さで続く歌詞が 1 音に潰れて以降がずれる。

直したときに分かったこと(2026-08-06・全部実測)

  • 合成器は楽譜どおりに鳴らしている。楽譜が言う時刻と出来た音の立ち上がりを突き合わせると、ずれは中央 9〜13ms・96〜98% が 60ms 以内(eval_render.py)。壁は採譜でも合成器でもなかった
  • 🔴 実音源では「音どうしの比較」を物差しに使えない。音源自身の立ち上がりで区切って歌わせても(=リズムとしては正解)読みは 142〜680ms(eval_oracle.py)。合唱+ブラスの音源と独唱では音の勢いの形がそもそも違う。この床を測るまで、方式の差だと思っていた数字は全部この雑音だった
  • 実曲用の物差しは時刻どうしにする(eval_onset.py)。音符の始まりが音源の立ち上がりに乗る度合いを見て、でたらめに置いた対照を必ず並べる。較正: 正解の切れ目 10.0ms / でたらめ 34.0ms / 等間隔 38.7ms
  • 立ち上がりの拾い方を 2 回まちがえた。①勢いの差分の山では正解 55.0ms・でたらめ 79.5ms で 1.45 倍しか離れない ②山を全部拾うと実音源で 25 秒に 742 本=飽和して でたらめ も 8.9ms。まわりの平均の 1.6 倍以上+最短 0.12 秒で 259〜411ms ごとに落ち着く
  • 4 つの方式を測った結果、歌詞のモーラ数に固定して DP で区切る(segment_dp.py)を採った。実曲 5 曲で 20〜60ms(でたらめ 54.8〜105.1ms・等間隔 65.3〜133.1ms)。歌詞合わせの DTW は実音源では でたらめ と同等以下で、参照を男声に替えても変わらない(eval_ref.py)=合唱+ブラスに独唱の参照は当たらない
  • 公平な条件(実曲のリズムを同じ声で歌い直したもの・eval_semi.py)では 4 方式とも切れ目を 27〜38ms で復元する=方式の力の差ではなく音源の性質の差
  • 音源が歌詞を 1 対 1 で覆っていない曲は歌わない。13 曲のうち 12 曲は 0.22〜0.58 秒/モーラに固まり、「その他の右打者」だけ 1.59 秒(歌詞 14 モーラに対し 22.2 秒鳴っている)。1 音が 0.8 秒を超えたら外す

音源が無い選手は譜面から歌う(2026-08-08)

配布ページに音源が無い選手(度会隆輝・石上泰輝・林琢真・京田陽太)は、 ゲームの応援歌エディタの譜面(ピアノロールを 1 小節ずつ映した動画)から 起こした旋律で歌う。歌える曲は 12 → 16 になった。

  • 譜面には音の高さと長さがそのまま描かれているので、歌唱音源からの採譜と 違って耳の誤差が入らない。読み取りは別リポジトリ、出来た旋律を cache/sheets/<選手名>.json に置くと cheer_song.prepare が拾う (譜面はリポジトリに入れない。歌詞・音源と同じ扱い)
  • 歌詞の配りは sheet_song.py。旋律を休符でフレーズに区切り、歌詞を モーラ単位で対応付ける(全体を一括で後詰めすると、離れた場所の 1 音の ずれが伸ばす音節を狂わせる=「ためにー」が「ためーに」になった)。 1 音に 2 モーラ詰めるときは先を短く・後を伸ばす(均等に割ると 「ためー・にー」に化ける)
  • 休みを詰める細工はしない。譜面の長さをそのまま鳴らす
  • 最後の「かっとばせー!○○!」は歌ではなくコールなので音として付けない
  • 実測(既存 12 曲の帯は 声 84.8〜90.6%・楽譜とのずれ 0.46〜1.97 半音): 度会 86.0%/0.53・石上 87.3%/0.45・林 84.8%/0.39・京田 83.9%/0.40

以下は部品の記録。Sinsy 版の記録は docs/progress.md。

応援歌は sing_cheer_song で旋律つきで歌う(歌詞を読むだけなら get_cheer_song)。音は文字で返せないので、道具は音符を ctx["song"] に載せ、返事を言い終わってから sing_song が歌う。送り方は読み上げと同じ経路(Opus・割り込みの窓つき)。

  • 合成は VOICEVOX 0.25 の歌唱合成(sing_vv.py)。声はずんだもん。役が 2 つに分かれている=楽譜を読む /sing_frame_audio_query は type=sing のスタイルしか受けず、engine に入っているのは波音リツ(6000)の 1 つだけ。声を出す /frame_synthesis は type=frame_decode=ずんだもん(3003)。照会は 6000、合成は 3003 と分けて呼ぶ
  • 歌は先に作って cache/songs/<名前>.sung.tNsM.wav に置いておく(prerender.py)。VOICEVOX は 2.68GB 常駐し合成に 9〜30 秒かかるので、頼まれてから作ると共有機の RPi5 を占有して返事も待たせる。普段 VOICEVOX は止めておける
  • 歌詞は 1 音 1 モーラしか受けない。「ワー」「パン」「ー」は 400。小書き仮名は付けられる字が決まっていて、きゃ・ふぁ は通るが さぁ は通らない(560 通り投げて通った 100 組を _COMBO に持つ)。音符の中の長音は音の長さで表すので落とし、音符をまたぐ長音は前の母音に開く
  • 声の出る帯へオクターブ単位で寄せてから歌わせる(octave_shift)。1 音ずつ測ったところ E3 より下は書いた高さで鳴らない(40〜49 を渡すと 65〜73 が鳴る)。応援歌の音源は男声なのでそのままでは帯の下に落ちる(牧秀悟は +2 オクターブ)
  • 楽譜は [高さ, 16分音符いくつ分, 歌詞]=["A#4", 4, "か"]
  • メロディは公式に無いので、単旋律の歌唱音源から transcribe.py が起こす。格子(テンポ)は「ずれが最小」で選ぶと必ずいちばん細かい所に張り付く(実測 242→188)。十分に割り切れる中でいちばん粗い格子を採る
  • 音源も起こした音符も cache/songs/ に置くだけでリポジトリには入れない(歌詞と同じ)
  • cache/songs/<公式の見出し>.<mp3|m4a|wav|ogg|flac> に音を置けば、その曲は歌えるようになる(配布ページより優先。音源が公開されていない選手はこれしか手が無い)。置き換えたら採譜し直す(音源より古い採譜結果は捨てる)
  • 音源はプロスピA のパスワードを配っているページの選手別音源(69 曲)。最初に使った 2017 年の一覧は今の歌詞と 3 件しか重ならなかった。歌えるのは 13 件(牧秀悟・佐野恵太・宮﨑敏郎・戸柱恭孝・筒香嘉智・神里和毅・柴田竜拓・関根大気・森敬斗・梶原昂希・その他の右打者・その他の左打者・捕手のテーマ)。代打のテーマは音源が無い
  • 応援歌は前の選手の曲を歌詞替えで受け継ぐ(音源側の表も「牧秀悟(村田修一流用)」と書く)。音源が無くても流用元の旋律を借りれば歌える(_REUSE。梶原昂希は下園辰哉の曲)。流用の記載は www.yakyu-ouen.net の各選手ページにある
  • 残りが歌えない理由は調べ切ってある: 取得元は2022 年 5 月で更新が止まっており、現行の応援歌まとめ 3 サイトはいずれも YouTube 埋め込みだけで音源ファイルを持たない。流用元(度会←横山道哉、三森←仁志敏久、石上←シュワーズ)にも音源が無い
  • 歌詞が公式に無い選手は歌わない(音だけ鳴らしても意味がない)
  • 録音の質が違うので前処理が要る: 曲全体の中心から 9 半音より離れた値は 1 オクターブ間違いとして寄せる/0.12 秒までの声の切れ目は埋める(歌の途中で声が拾えず音が細切れになる)
  • 相関は重なった分だけで正規化する(NCCF)。ac/ac[0] のままだと低い音ほど山が低く出て声と認められない。実測(牧秀悟): 45.4% が「声でない」とされ、その 90% は音が鳴っていた
  • 短すぎる区切りは捨てずに隣へ吸わせる(absorb_short)。捨てるとその時間がまるごと休みになり歌に穴が開く(牧秀悟は休み 33.3%→1.5%)
  • 歌の頭と終わりの休みは落とし、曲中の休みは 0.8 秒で頭打ち(音源の前置きで歌い出しが 1 秒以上遅れ、曲中の 1 秒級の切れ目はひとりで歌うと止まって聞こえる)
  • 起こし方を変えたら取ってある採譜結果は捨てる(transcribe.VERSION)。音源の更新時刻だけでは気付けない
  • 選手名は読みでも引く。聞き取りは同じ読みの別の字を返す(「牧」→「真木」)。読みは Open JTalk の解析結果から取る。姓は名前の先頭にあるので前方一致を途中一致より先に見る(「森」が 森敬斗 と 三森大貴 に当たって絞れないため)

確かめ方は test_transcribe.py(リズムが音源に乗っているか=でたらめ対照の 70% 以下と、楽譜どおりの高さで歌えているか=1.5 半音以内を別々に測る)、test_sing_tool.py(言い方 2 通りで道具が選ばれるか・歌詞が音符に乗るか)、test_sing_e2e.py(声で頼んでから鳴る音までを通しで)。物差しそのものは rhythm_eval.py(音どうし・正解つきの曲用)と eval_onset.py(時刻どうし・実曲用)で、どちらも対照つきで較正してから読む。

聞き取りの下ごしらえ

認識器に渡す前に 3 つのことをする(local_stt.py)。実機の話しかけは最大 rms が 500〜1200と小さく、本体は一語の返事にも十数秒のバッファを送ってくる(黙っていた分がそのまま前に付く。実測で「よいしょ」に 18.36 秒)ので、そのまま渡すと大きく崩れる。

  1. 黙っている所を落とす(SHERPA_TRIM、既定 0.4 秒)。60ms ごとの音量を見て、声とみなす範囲の前後に 0.4 秒だけ残す。声が見つからなければ何もしない。
  2. 声の大きさをそろえる(SHERPA_NORM_RMS、既定 3000)。倍率は SHERPA_NORM_MAX_GAIN(既定 12)で頭打ちにし、SHERPA_NORM_CLIP(既定 0.95)で振り切れないようにする。SHERPA_NORM_MIN_LEVEL(既定 200)より小さい音は持ち上げない(雑音だけのバッファを増幅しないため)。割る音量は「声が出ている所」だけで測る(全体の rms で割ると、同じ声でも黙っていた長さで倍率が変わる)。
  3. 前後に無音を足す(SHERPA_PAD、0.3 → 0.8 秒)。

声とみなす下限は「上位 5% の音量の 2 割」と「黙っている所(下から 2 割の位置)の 2 倍」の高い方。いちばん大きいフレームを基準にしてはいけない(咳やドアの音が 1 つ入ると本当の声がその下に沈み、物音の周りだけが残る=査読の指摘。test_stt_front.py で再現してある)。かといって上位 5% だけでは雑音の高さを跨げず、切り出しが効かなくなる(実測 CER 23.9% → 40.4%)。音量は選んだフレームの中央値で測る(平均だと轟音 1 つで 40 倍ずれる)。

Open JTalk で作った 25 文を Opus に通し、声の大きさを変えて白色雑音を足した音で測った(bench_stt_prod.py)。

条件 いままで 直した後
きれい 5.7% 3.0%
雑音 SNR15・前後に 6 秒 23.2% 12.1%
雑音 SNR10・前後に 6 秒 44.4% 23.9%
大きい声 rms6000・SNR15・前後に 6 秒 22.9% 11.8%
小さい声 rms300・SNR15・前後に 6 秒 34.7% 13.1%

前後に黙りが付く条件では、認識にかかる時間も 22.3 秒 → 6.1 秒(25 文の合計)に縮む。

語彙のえこひいき(hotwords)とビーム探索は効かない。道具の名前 20 語を入れて modified_beam_search で回すと、きれいな音声では道具を含む 15 文の CER が 1.7% から 2.8〜7.9% に悪化し、雑音下でも一般文が 11.8% から 15.1% に悪くなる(bench_stt_pick.py と同じ作りの使い捨てで測った。既定にしないので残していない)。合成音声での測定なので実機の声で見直す余地はあるが、既定にはしない。

読み上げの速さ

Open JTalk は合成のたびに前後へ固定の無音(前 0.41 秒・後 0.58 秒ほど)を付ける。文ごとに合成するため短い返事ほど無音の比率が上がる(実測 27〜56%)ので、_trim_silence で前後を落とし、区切りに OJT_END_PAUSE(既定 0.12 秒)だけ足し直す。しきい値 OJT_TRIM_LEVEL(既定 60)はパディングの微小ノイズ(振幅 20〜50)より上、弱い子音(振幅 50〜100)より下に置いてある。

かたまりが長すぎる時の伸びは split_long_runs が別に見ている。一次の物差しは_mora_est(読みのモーラ数を合成せずに見積る)で、OJT_MAX_MORA(既定 30)で切る。

ただし、伸びるかどうかは長さの閾値では決まらない。2026-08-02 の統制実験で、無意味語「あさひ」を 10 回並べた読点なし 30 モーラは 0.122 秒/モーラで健全なのに、実文「何か聞きたいことややってほしいことがあれば教えてください」は 29 モーラで 0.235、「春のことでも他に気になることがあれば教えてくださいね。」は 0.358 まで落ちた。同じ文に読点を 1 つ入れるだけで 0.127 に戻る。壊れるかどうかは語の並びしだいで、モーラ数からも字数からも予測できない(以前ここに書いた「膝は 34 モーラ付近」は、この実験で否定された)。

そこで _synth_checked が合成してから実測の速さを見る。open_jtalk -ot の音素トレースから 秒/モーラ を出し、OJT_BAD_PACE(既定 0.17)を超えていたら、その片を 0.6 倍の長さに分け直して合成し直す(OJT_RESPLIT_ROUNDS 回まで、12 モーラ未満の片は対象外)。実測で 8.77→5.92 秒 / 10.60→4.46 秒 / 7.05→4.31 秒に縮み、もともと健全だった 8 文は完全に不変だった(閾値を下げる方式と違い、余計な分割が増えない)。実文 19 片の速さは 0.114〜0.155 で、誤って分け直された片は無い。

見積りは数字を読みの長さで数える(3000 は 4 モーラ「さんぜん」、2996 は 13、64362 は 17)。全角の数字・英字・% は半角に直してから数える(合成器は同じに読む)。007 のように 0 で始まる並びは1 桁ずつ読まれる。見積りは実測 20 例すべてで実際以上(比 1.00〜1.28)=早めに切る安全側に倒してある。過小になると分割し損ねるため、下振れする変更は入れない。

数字や英字の途中では切らない(「29」を「2」「9」に割ると読みが壊れる)。語の途中でしか切れない場合は分割しない(間延びの方がまし)。

読み上げの途中で話しかけられるよう、split_for_barge が長い音を「いちばん静かな所」(BARGE_PAUSE_RMS 以下の 60ms)で切り、listen_gap がその継ぎ目で tts stop → 鳴り終わり待ち → BARGE_WINDOW(既定 0.4 秒)の聞き取り → BARGE_SPEECH_MS(既定 240ms)以上の声があれば残りを中止、を行う。BARGE_MIN_SEC(既定 6 秒)に満たない返事には窓を挟まない。BARGE_IN=0 で無効。

OJT_LOG_PACE=1 を立てると、合成のたびに「秒/モーラ」をログに出す(診断用)。健全なら 0.12〜0.14 に収まる。

小さいスピーカーで聞こえる大きさにする(2026-08-08)

「歌は大きくて聞きやすいのに会話の声は小さい」と 2 度言われた。実効値を そろえても直らなかったので中身を測ったところ、鳴っている帯がまるで 違った。

500Hz 未満 500Hz 以上
歌(VOICEVOX 女声) 4〜10% 90% 以上
会話(Open JTalk 男声) 73〜84% 16〜27%(1.5kHz 以上はほぼ皆無)

本体のスピーカーは小さく低音を鳴らせないので、同じ実効値でも会話だけ 聞こえない。そこで _shape_for_speaker が ① 400Hz 以下を落とす(鳴らない音でピーク=上げ代を埋めない)→ ② ゆっくりした自動音量で小さい所を持ち上げる→ ③ 可聴帯(500〜4000Hz)の実効値を歌に合わせる(割れないようピークで 頭打ち)の順で整える。実測 可聴帯 964〜1467 → 2141〜3138(歌 2946〜3192)。 声そのものは替えていない(合成器の選択は使う人が決めること)。

反応を速くする(2026-08-08)

「ちょっと反応が遅い」と言われたので、実ログで内訳を出した(発話の終わり から最初の音まで): 聞き取り 0.33〜1.98 秒 / 応答生成 0.62〜2.11 秒 / 発声開始 0.10〜0.29 秒 + 無音待ち 0.6 秒。

  • 聞き取りに渡す音を「いま終わった発話 1 回ぶん」だけにした。実機の バッファは十数秒あり、前の方に生活音が入っていると、そこから末尾までが まるごと認識器に渡っていた。答えるべきなのは VAD が終わりを見つけた 最後のかたまりなので、VAD と同じ間(VAD_SILENCE_MS より必ず長く) で切って最後だけ渡す。実測 渡す音 10.76→2.00 秒・認識 0.78→0.22 秒で、 認識結果の文字列は同一
  • 返答は書き終わるのを待たず、1 文できた時点で読み上げ始める。 chat_once に on_delta を渡すと流しながら受け取り(返す形は同じ)、 SentenceFeed が文に切って読み上げへ渡す。一括で削る shorten_reply と 同じ形になるよう、言い直しの畳み・文数・字数・最初のかたまりの分割まで 同じ規則を通す。実測 1 文目 0.98 秒 / 全部 1.44 秒=0.46 秒早く鳴り出す
  • 残る待ちは最初の欠片が届くまでの 1.15 秒(モデルが考えている時間)

歌の範囲と歌い方は曲ごとの表に持つ(2026-08-08)

実機で聞いてもらうと、歌詞の文字からは分からないことが次々に出た。規則で 当てにいって user 様に何度も言わせたので、教わった事実をそのまま表に書く 方針に変えた。

表 何を持つか 例
LEAD_CHANT 歌が始まる行 牧=先頭 4 行が掛け声(「オオオオー」3 行 + 「とどけ われらのこえ」)/筒香=先頭 3 行
TAIL_CHANT 歌が終わる行 筒香=末尾「ゴー!ゴー!つつごう!」
SUNG_LINES 実際の歌い方 柴田=「ねらーいすましたミートと」(公式歌詞に長音が無い)

「とどけ われらのこえ」は普通の歌詞に見えるので、文字の見た目で外すことは できない。掛け声に旋律を割り当てると歌にならない(牧は 25.5 秒のうち大半が 掛け声だった → 10.6 秒・楽譜とのずれ 0.66→0.47)。

助詞は読み替える。公式歌詞は仮名なので「グラブさばきは」がそのまま 「ha」と歌われていた。語末・行末の「は」「へ」だけ「わ」「え」にする (該当 4 曲。語頭の「はしる」「はまかぜ」は触らない)。

音源から歌う曲は、全歌詞で対応を取ってから歌う範囲だけ切り出す。歌詞だけ 減らすと 1 音が伸びて「歌詞と音源が対応していない」で弾かれる(筒香が 39 モーラ 32 秒 = 0.83 秒で一度歌えなくなった)。

歌詞を語の途中で切らない(2026-08-08)

フレーズへの割り当てで「ミー / トと」「いまそ / のいち」と割れていた。原因は 2 つ。語境界を外す罰が 0.3 しかなくモーラ数のずれの二乗(1・4・9…)に 負けていたこと、音符 1〜2 個の短いフレーズができて歌詞 1 行が乗らないのに 配っていたこと。

  • まず語の切れ目でしか切らない条件で解き、解けない曲だけ罰 6.0 で落とす
  • 短いフレーズは隣へ併合(柴田 [9,1,1,29]→[11,29] / 石上 [2,2,9,10,6]→[4,9,10,6])

読み取りで割れた音は繋ぐが、1 マスだけの音が絡む時に限る。「同じ高さなら 繋ぐ」と広く書いたら宮﨑 41→25 音・牧 31→20 音と本来別々の連打まで潰した ので取り下げた(いまは柴田 40→39・他 12 曲は無変化)。

本体が音を送ってこない・すぐ切れる(2026-08-08)

  • 切っていたのはサーバーだった。WebSocketResponse(heartbeat=30) で 15 秒 PONG が無いと切る設定だが、本体は WS を自前実装していて PING に応答しない。 実測 1 日で 13 回、うち 1 回は音声 4,807 フレームが届いていた最中の切断。 → heartbeat=None
  • 本体は起動語「Hi, StackChan」まで音声を送らない(CONFIG_SEND_WAKE_WORD_DATA=n なので起動語自体も届かない)。サーバー側の起動語一覧は効かない。画面の顔を タップでも会話が始まる(stackchan_display.cc の onClick)
  • 切り分け用に送信 JSON・接続ごとの音声フレーム数・本体の状態を記録する。 WS closed …(届いた音声 0 フレーム) なら本体が送っていない

検証

実機なしで確認できます。

# サーバー側ツールだけ(本体も LLM も要らない)
./.venv/bin/python test_tools.py

# 流しながら読み上げる仕組み(LLM も本体も要らない)
./.venv/bin/python test_stream.py

# 回線にも本体にも依存しないもの
./.venv/bin/python test_gateway.py            # 既定経路の読み取り
./.venv/bin/python test_firmware_handshake.py # ファームの握手を byte 単位で写して当てる
./.venv/bin/python test_hello_grace.py        # hello が来ない時にこちらから先に出すか
./.venv/bin/python test_broken_mcp.py         # 壊れた mcp 1 通で落ちないか
./.venv/bin/python test_stt_front.py          # 聞き取りの下ごしらえ(切り出し・音量そろえ)

# 素の往復(合成音声を Opus で送って、認識・応答・読み上げまで)
TEST_TEXT="今日の天気を教えて。" ./.venv/bin/python test_client.py

# 応答生成とツール呼び出しまで(モックを使うのでトークン不要)
./.venv/bin/python mock_llm.py &
SAKURA_BASE=http://127.0.0.1:8100 SAKURA_TOKEN=dummy LLM_BACKEND=sakura ./.venv/bin/python app.py

本体が繋がるのに黙って切れる時(2026-07-30)

本体は接続してくるのに、こちらの受信ログが 0 行のまま 15 秒ほどで切れることがあります。 ファーム(上流 xiaozhi-esp32 v2.2.4)の待ち時間は 2 段です。

  1. 握手要求を送り、完了を 10 秒待つ(判定は応答に HTTP/1.1 101 を含むかだけ)
  2. 成功して初めて hello を送り、サーバーの hello をさらに 10 秒待つ

1 で切れた場合、hello は 1 バイトも出ません。 受信 0 行は「本体が送っていない」 場合と「送ったが届いていない」場合の両方でそう見えます。切り分けの順に見てください。

  • まずファイアウォール。本体は同じ LAN から来ます。待ち受けだけ合っていても 経路で落ちていれば繋がりません(ufw が既定拒否なら 8000 番を LAN 限定で開ける)
  • 握手が本体の形で通るかは test_firmware_handshake.py で確認できます。 ファームの WebSocket クライアントの要求を byte 単位で写してあります
  • hello の往路が落ちている場合の保険として、HELLO_GRACE(既定 3 秒)待って 本体の hello が来なければ、サーバーから先に hello を送ります。ファーム側は hello の順序を問わないので先に送っても壊れません。発動するとログに 「hello が 3.0 秒来ないので server hello を先に送る」と残るので、 この行が出ていれば往路が落ちていた証拠になります

覚え書き

  • 発話の処理は受信ループとは別のタスクで走らせています。受信ループの中で待つと、 ツール呼び出しの応答を読めないまま自分の待ちでタイムアウトします。
  • Open-Meteo は無料の公開 API なので 503(過負荷)が普通に返ります。読み上げる相手に 生のエラーを聞かせないよう、3 回まで粘ってから最後に成功した値へ退避します (15 分以上古ければ「※N分前の情報」と添えます)。
  • 設定ファイルを .env という名前にしていないのは、手元の環境で 資格情報保護のフックが .env を含むコマンドを止めるためです。
  • server.conf は追跡しません。トークンはここだけに置きます。
  • 受信ループは try/finally で囲ってあります。後始末で libopus の decoder/encoder を 解放しており、ここを飛ばすとネイティブ側が接続ごとに残るためです。

作った当時の記録

実物の応答生成で通すまでの測定、小さいモデルで破綻させないために決めたこと、回線が切れた時の扱いは docs/server-history.md にあります。