技術約10分で読めます

Claude CodeとCodexとAntigravityの作業完了をStackChanに読み上げさせた

いけさん目次

前回は、StackChanにCO2センサーをつないで、部屋の空気の値を音声チャットの返答に使えるようにした。
Claude CodeやCodex、AntigravityなどのAIエージェントに作業を任せて別の作業をしていると、処理が終わったのか、コマンド実行の許可を待って止まっているのかが画面を見ないと分からない。

そこで、AIエージェントが作業終了時などに外部コマンドを呼ぶフック(Hook)の仕組みを使い、完了通知をStackChanにしゃべらせる構成を作った。
音声サーバー側でフックからの通知を受け取り、LLMのQwenで1〜2文に要約したうえで音声合成(TTS)して保持するエンドポイントを用意した。
StackChanが待ち受け中にその通知を取得し、かなの声で読み上げる。

検証環境

項目使用した環境
実機M5Stack CoreS3(ESP32-S3)、StackChan Body
ファームESP-IDF 5.5.4、arduino-esp32 3.3.10、M5Unified 0.2.17、StackChan-BSP 1.1.0
音声サーバーへの経路Tailscaleで直結(中継なし)
音声サーバー別PC。LLM Qwen3.8-Max(ModelScope)、TTS音声合成
通知を送るAIClaude Code、Codex、Antigravity(どれもWindows PCで実行)

音声サーバーの改修は、サーバーのPCで動いている別のClaudeが担当した。
こちらで作ったのはStackChanのファームと、各AIのフック設定だ。

通知が届くまでの流れ

flowchart LR
  A["AIエージェントの<br/>Hook"] -->|POST /notify| B["音声サーバー<br/>Qwenで要約・TTS"]
  B -->|GET /notifications| C["StackChan<br/>待ち受け中に取得"]

サーバー側に追加したのは、フックの受け口と、端末が通知を取りに行くエンドポイントの2つ。

エンドポイント内容
POST /notify中身の形式は問わない。Claude CodeのHookが出力するJSONでも、ただのテキストでも受け付ける。受け取った内容をそのままQwen3.8-Maxに送り、「どのAIが・どのプロジェクトで・何を終えたか(何の確認を待っているか)」をかなの口調で1〜2文にまとめ、表情パラメータも付けてTTSにかける
GET /notifications?after=<id>音声まで完成した通知を古い順に返す。本文、表情、音声のURLが付く。返ってきた最大のIDを次の after に渡せば、順序どおり重複なく取得できる

受け付けは即座に返り、要約と音声化はそのあと7〜15秒かかる。
通知はサーバーのメモリ上に直近50件保持され、サーバーを再起動するとIDは1から振り直しになる。

StackChan側の実装

待ち受け中、つまり録音・会話・再生のどれもしていないときだけ、7秒おきに通知を取りに行く。
新しい通知があれば、16kHzの音声をダウンロードし、表情と首の動きを通知の表情に合わせてから再生する。
首の動きは、前回作った返答用の動きをそのまま使った。

場面動き
起動直後最新のIDを覚えるだけで、電源を切っていた間にたまった通知は読み上げない
複数たまっている1件ずつ読み上げる。1件目の再生が終わってから次を取りに行く
サーバーの再起動最新のIDが端末側の値より小さくなったら、0から再取得する
読み上げ中にKeyユニットを押す読み上げを止めて、話しかけの録音に入る
サーバーが止まっている接続待ちを1.5秒で打ち切る

最後の接続待ちは、実機テストのあとに追加した。
サーバーの再起動中に1回の取得で5秒止まり、その間は顔の目ぱちまで止まる現象が出たためだ。
HTTP応答待ちの時間は3秒に絞っていたが、ソケット接続自体のタイムアウトが既定の5秒のまま残っていた。

各AIのHook

どのAIも作業が終わったときにコマンドを実行できるフック(Hook)の仕組みがあるので、それを音声サーバーへの送信に使う。
送られるのは、各AIの直近の返答テキストや作業フォルダーの情報で、音声サーバー経由でQwenのAPIに送られる。

AI送るタイミング送り方
Claude Code返答が終わったとき、コマンドの許可を待っているときHookからcurlで、受け取ったJSONをそのまま送信
Codexゴール(Goal)が完了したときだけHookから小さなPythonスクリプトを呼び出し
Antigravity作業が終わったとき(サブエージェント等は除く)Hookから小さなPythonスクリプトを呼び出し

Claude Code

ユーザー設定の settings.json に、Stop(返答が終わった)と Notification(通知を出した)の2つのイベントフックを入れた。

"hooks": {
  "Stop": [
    {"hooks": [{"type": "command",
      "command": "curl -s -m 5 -X POST \"http://<音声サーバー>/notify?agent=claude-code\" --data-binary @- >/dev/null 2>&1 || true"}]}
  ],
  "Notification": [
    {"matcher": "permission_prompt",
      "hooks": [{"type": "command",
      "command": "curl -s -m 5 -X POST \"http://<音声サーバー>/notify?agent=claude-code\" --data-binary @- >/dev/null 2>&1 || true"}]}
  ]
}

最初は Notification に matcher を付けていなかった。
すると、作業が終わって放置していると、60秒ほどして「確認を待ってるよ」ともう1回しゃべる挙動になった。
Claude Codeは入力待ちが続いたアイドル時にも通知を出すため、permission_prompt に絞って許可待ちのときだけ通知するようにした。

作業していたClaude Codeにこの設定を書かせようとしたところ、自動モードの安全判定で「会話内容を外部へ送る設定」としてブロックされた。
そのため、この設定ファイルの追記はCodexに実行してもらった。

Codex

最初はCodexの設定ファイルの notify を使い、ターンが終わるたびに通知を送っていた。
ただ、ブログの記事作業では出典の照合などのためにCodexを読み取り専用で何度も起動する。
そのたびにStackChanがしゃべると作業の邪魔になるため、Hookに切り替え、ゴール(Goal)を完了させたときだけ送る形にした。

Goalの完了を記録するツール呼び出しが成功したらそのターンに印を付け、最後の返答が終わった時点で印があれば1回だけ送信する。
通常のターンを回した際に、サーバーへ通知が飛ばない動作まで確認した。

Antigravity

Antigravityのフックには、Claude Codeのように標準入力から最後の返答JSONがそのまま渡ってくるわけではない。
フック引数として渡される会話記録(transcript.jsonl)のファイルパスから最後の返答を読み出して送るPythonスクリプトを用意した。

Antigravityは文体チェックなどの処理でサブエージェントを何体も並列起動する。
そのまま送ると1回のチェックでStackChanが何度も通知を読み上げてしまうため、スクリプト側で次の条件に該当する場合は送信を抑止している。

送らない場合判定方法
サブエージェント会話記録の最初の入力が、ユーザーの直接の指示かどうか
文体チェックのスキャン最初の指示にチェック用の定義ファイル名が含まれている、または返答がスキャンの出力フォーマットになっている
連続完了前回の送信から20秒以内
手動停止環境変数またはミュート用の一時ファイルが存在する

AI名の識別子を英字の「antigravity」のまま送ると、要約文にも英字が混ざってTTSが正しく発音できなかった。
カタカナの「アンチグラビティ」として送ることで、読み上げも安定した。

読み上げまでの時間

Hookを入れる前に、PCからClaude CodeのHookと同じ形のJSONを手で送って、StackChanの動きを確かめた。

送った内容送信から端末が拾うまでかなの通知表情音声の長さ
返答の終了(ファームの書き込みまで終わった)10.5秒クロードコードがCO2Monitorのプロジェクトで、スタックチャンに通知を読み上げる処理を追加して書き込みまで終わらせたよ。smile9.0秒
許可待ち9.1秒クロードコードがリルティングチャンネルウェブでコマンド実行の許可を待ってるよfun5.4秒
返答の終了(上と同時に送信)15.6秒クロードコードがLiltingChannelWebで、スタックチャンの通知記事に実機テスト結果を追記して一区切りついたよ。smile8.5秒

送信から拾うまでの時間は、サーバーの要約と音声化の待ちに、7秒おきの取得の待ちが足された時間だ。
2件を同時に送ると、1件目を読み終えた0.2秒後に2件目を取りに行き、順番に読み上げた。
1回の取得は68〜354ms、16kHzの音声(約280KB)のダウンロードは1.2秒前後だった。

声が割れる、途切れる

しゃべりはしたが、聞いていた側の感想は「なんかが終わったのはわかった、声が割れる」だった。
割れは前日から少しあったらしく、この日の1件目がいちばんひどかった。

音声のピーク

通知の音声をPCで取得して測定すると、平均音量を示すRMS(Root Mean Square)はフィラー(応答待ちのつなぎ言葉)とほぼ同じだった。
違ったのはピーク値で、通知の音声はどれもデジタル音声の最大振幅である0dBFS(0dBFSが上限で、マイナスになるほど小さい単位)まで振れていた。

音声ピーク平均(RMS)
1件目(割れて聞こえた)0.0dBFS-16.8dBFS
2件目(あまり割れていない)-0.6dBFS-16.5dBFS
3件目(あまり割れていない)0.0dBFS-16.6dBFS
フィラー15本-6.2〜0.0dBFS-15.2〜-16.5dBFS

再生中は首も動くので、首とサーボを疑って聞き比べた。

条件聞こえ方
首の動きを止めるだいぶ割れてる
首を動かすちょっとましになったけどまだ割れてる
サーボのトルクを切る「サーボの電源を〜」より後ろは割れてない、それより前はちょっと割れた

首とサーボの有無では大きな差はなかった。
最後の音声を0.5秒ごとに区切ってピークを見ると、前半の約6秒は-0.1〜-1.5dBFS、後半は-4〜-9dBFSで、割れて聞こえた範囲と重なっていた。

そこで、ダウンロードした音声のピークが-6dBFSを超えていたら、音声データ全体を減衰させてから鳴らすようにした。
返答の音声も同じダウンロード処理を通るため、通常の会話返答の音割れも抑えられる。
同じ音声を下げずに1回、下げて1回鳴らすと、下げたほうは「今のはよかった」となった。
ただ音量が少し小さくなったため、CoreS3本体のスピーカー音量設定を3から5に上げた。

同じ音声でも割れ方が変わる

音量を上げてから、同じ音声を何回か鳴らした。

条件聞こえ方
音量4ぷつぷつしてる
音量5(その直後)ぷつぷつしない
音量5、新しく届いた通知すげえ割れてる
音量5、同じ通知をもう一度まあまあよかった
音量5、同じ通知をさらにもう一度まあまあよかった

同じ音声で、1回目はひどく割れて、あとの2回はまあまあだった。
音声データそのものではなく、再生時の本体の負荷や状態で割れ方が変わっていた。

ほかにも、低音を削る(250Hz以下をカットしてから-6dBFSにそろえる)処理と、再生中の顔の目ぱち描画を止める処理の2つを試したが、どちらも「あまり差がない」「かわらん」だった。
低音を削ってもピークは0dBFSのままで、声のピークは低音域由来ではなかった。

スピーカーの送り出しが途切れていた

「長くしゃべらせると、プチプチ切れたり言葉に詰まったりするので聞き取りづらい」という点から、スピーカーの振幅飽和(クリッピング)だけでなく、再生データの供給不足(アンダーラン)で途切れている可能性を疑った。

M5Unifiedのスピーカー機能は、専用のRTOSタスクが音声を小分けにしてI2S(マイコンとオーディオアンプ間で音声データを送る通信規格)へ送り出している。
既定の設定と変更後の設定を比較した。

項目既定変更後
送り出しのバッファー256サンプル×8(出力48kHzで約43ms分)1024サンプル×8(約170ms分)
タスクの優先度210
動かすコア指定なしコア1(Wi-Fiが動いていないほう)

StackChanのファームでは、Wi-Fi通信、Tailscale(WireGuardの暗号処理)、首のサーボ制御、頭上タッチ判定がバックグラウンドで常に動いている。
どれかの処理が約43ms以上CPUを占有すると、I2Sへの送り出しが間に合わずに音が欠けてプチプチ音になる。
起動時にスピーカーの設定を上の表の右列(バッファ拡大・優先度引き上げ・コア固定)に変えて鳴らしたところ、「わりとよくなった」という評価になった。

下の動画は、この設定にしたあとで作業完了の通知を読み上げたところ。

作業記録

一度鳴っただけでは、離席中に聞き逃すと内容が残らない。
そこで、音声サーバー側で届いた通知から作業記録を残すようにした。

仕組み内容
完了作業の抽出要約生成時にQwenが実際に作業が完了したかを判定し、質問への回答や許可待ちは記録から除外する
長期記憶への集約同一AI・同一プロジェクトの作業が30分途切れたら、1行にまとめて長期記憶(外部のベクトルDB)に1件登録する
直近7日分の常時注入最大15行を、会話のたびにLLMへのシステムプロンプトに含める

直近7日分を毎回含めるようにしたのは、「最近何してたっけ?」のような漠然とした聞き方だと、長期記憶から検索したときの類似度が0.40前後にとどまり、しきい値の0.45を下回って拾えなかったためだ。
サーバー側にテスト用の作業記録を登録し、音声チャットで質問した際の返答は次のようになった。

質問かなの返事
最近何の作業してたっけ?最近だとタッチ判定のデバウンス処理を追加したり、通知用エンドポイントの実装とかをしてたよ。
スタックチャンのほうって何直したんだっけ?タッチ判定にデバウンス処理を追加して、ビルドとテストがちゃんと通るようにしたんだよ。
今日のお昼ごはん何がいいかな?もう午後3時近いから、がっつりラーメンとかどうかな。

作業と関係ない質問では作業の話題は混入せず、作業完了の判定もサーバー側のテストケース6件すべてで正しく判定できた。

下の動画は、音声チャットで今日の作業を聞いたところ。