status: draft
created: 2026-07-20
series: RoboMaster S1 アプリレス化(2/3)
RoboMaster S1に耳と口を授ける:MCPとローカルLLMで「会話から動く相棒」を作る
全3回の2本目です。
①[プロトコル解析編]/②MCPで会話から操縦し、喋らせる編(この記事)/③[ROS 2 VSLAM編]前回、DJI RoboMaster S1 の通信プロトコルを解析し、アプリなしで駆動・映像・テレメトリを取れるようにしました。今回はそこに耳と口をつけて、LLMと繋ぎます。
ゴール:チャットから呼べるロボット
作りたいのはフチコマ、つまり「勝手に動いて勝手に報告してくる相棒」です。そのためには、LLM側から機体を道具として呼べる必要があります。
そこで MCP(Model Context Protocol)サーバにしました。Claude Code に .mcp.json で登録すると、会話の中から直接ツールを呼べるようになります。
最終的に実装したツールはこれだけあります。
| ツール | 主な引数 | 内容 |
|---|---|---|
drive |
direction(forward/back/left/right), duration, speed
|
基本駆動 |
drive_seq |
steps[]=forward/strafe/turn/gimbal_yaw/gimbal_pitch
|
複合動作を段間で止めずに連結 |
turn |
degrees(±360), tolerance
|
yaw実測の閉ループ旋回 |
stop_chassis |
— | 即停止 |
get_status |
— | battery / attitude / position / gimbal / velocity / imu |
capture_image |
settle |
ライブ映像から最新フレーム取得 |
speak |
text |
機体スピーカーで日本語を喋る |
listen |
seconds |
機体マイクで聞き取って文字起こし |
converse |
wake_word, timeout
|
ウェイクワード待受 |
「右に90度回して」と書くと機体が回り、「何が見える?」で写真が返ってくる。この時点でもう楽しいのですが、本命は音声です。
口をつける:機体に日本語を喋らせる
S1にはスピーカーがあります。EPのSDKを読むと、音の再生は2系統ありました。
-
プリセット再生:
cmdset 0x3f / cmdid 0xb3、sound_idにスキャン音・射撃音などのIDを入れる -
カスタムwav:FTPで機体にwavを置き、
sound_id = 0xE0 + nで再生
やりたいのは当然カスタムのほうです。TTSで作った音声を鳴らしたい。
チェーンはこうなりました。
テキスト → Windows SAPI(Haruka, 日本語)で 48kHz/mono/16bit WAV
→ FTP(21番) で 機体の /python/sdk_audio_{id}.wav へ upload
→ PLAY_SOUND_TASK (sound_id = 0xE0 + id) 送信
→ S1 が喋る
機体のFTPはポート21が開いていて認証なし、/python ディレクトリが最初から存在します。EPのドキュメントにある変換コマンド(0xBC)は不要で、0xE0 系IDで直接再生できました。
ハマり① リプレイしたセッションはシーケンス番号がズレる
セッション確立をパケットの「リプレイ」で行っていたため、送信側のシーケンス番号(sseq / msgc)が更新されないままでした。
信頼チャネルは順序を見ているので、ズレたシーケンス番号で送ったパケットは全部破棄されます。音が鳴らない。エラーも返らない。ただの無反応。
リプレイ後の最終値に同期させて、ようやく鳴りました。
ハマり② 信頼chだけだと不安定
カスタム音声の再生は、信頼チャネルだけに送ると鳴ったり鳴らなかったりします。信頼chとリアルタイムchの両方に送ると確実に鳴る、というのが実機で得た結論でした。
ハマり③ 「返事が遅い」の正体はACK待ちだった
喋るまでが妙に長い(6秒)。TTSは0.35秒、FTPは0.1秒、再生自体は2秒。計算が合いません。
犯人は 来ないACKを待っていた1.5秒×2 でした。play_sound のACKを待つ実装にしていたのですが、この経路ではACKが返ってこないケースがある。待たないようにしたら 6秒→3.06秒。
耳をつける:機体のマイクで聞く
S1には動画の音声収録用のマイクが載っています。使えるはずです。
公式アプリの通信を実際にキャプチャして調べたところ、こうなっていました。
-
有効化:
RM_SET_AUDIO_STATUS(0x3f/0x1e)をreceiver = 0x01(音声モジュール) へ送る -
データ:
RM_AUDIO_TO_APP(0x3f/0x1d)が push で降ってくる。映像と同じく10607セッションにin-band
ここでも宛先で詰まりました。最初は receiver = 0x09(シャシ側)に送っていて完全に無反応。0x01 が正しい宛先でした。
コーデックはOpusで、素のまま流れてくる
0x1d の payload は先頭が必ず 0xf8。これは Opus の TOC バイト(config 31 = CELT fullband / mono / 20ms)でした。つまり 1 push = 1 Opusフレームが丸ごと入っていて、長さ接頭辞などの余計な枠はありません。
機体内蔵のデコーダ(libmedia_codec.pyd)はDLL依存で読み込めなかったので、payloadを自前でOgg Opusコンテナに包んでffmpegに渡す方式にしました。OpusHead を作って granule を刻んで Ogg CRC32 を計算するだけで、あとは ffmpeg が WAV にしてくれます。
文字起こしは faster-whisper(GPU)
-
large-v3/int8_float16。smallは精度不足で「90度→中央」「天気→点数」のような致命的な誤認識をしました - GPU(RTX 3070 Ti)で 文字起こし 0.2秒
-
vad_filter=True(Silero)とbeam_size=5が効きます。beam_size=1は精度が明確に落ちます
これで アプリレスのセッション → マイク有効化 → Opus受信 → Ogg化 → ffmpeg → whisper → 日本語テキスト が端から端まで通りました。
ハマり④ このマイクのOpusはCBRなのでサイズVADが効かない
「パケットサイズが大きい=喋っている」で発話区間を検出しようとしたのですが、S1のOpusは固定ビットレートでした。payloadは常に約241バイト。無音でも喋っていても同じサイズです。
仕方がないので、録音しながら直近0.9秒分だけを小さなOggにしてffmpegでPCM化し、RMSを計算する方式に変えました。発話後0.6秒の無音で早期終了。「こんにちは」だけの発話で 4〜8秒 → 3.0秒 に短縮され、体感がはっきり変わりました。
全部繋ぐ:ローカル完結の音声エージェント
ここまでのMCPツールを部品として、独立した音声ループを書きました。Claude Code の会話はコンテキストが肥大すると1ターンが重くなるので、常時対話は専用スクリプトに切り出した形です。
耳 faster-whisper(GPU)
頭脳 Ollama / Qwen(会話・行動判断)
目 Ollama / Qwen(画像・数フレームの動画)
口 Windows SAPI → FTP → 機体スピーカー
体 合成セッション(駆動 / ジンバル / LED)
全部ローカル。APIキーもクラウドも不要です。
ループはこう動きます。
- 聞く(LED水色で「聞いてます」を表示 → 録音 → whisper)
- 「前」「止まって」などの明示コマンドはルールで即実行(LLMを通さない=最速)
- それ以外は「ディレクターLLM」が
{say, emotion, gesture, look}のJSONを1発で返す - 感情に応じてLEDを光らせ、ジンバルでしぐさをして、喋る
身体表現
LEDとジンバルは、それぞれ専用のコマンドがありました。
-
LED:
0x3f/0x33、receiver = 0x09。ctrl_mode = 7(SDK制御)が必須で、effectが 0=消灯 / 1=常灯 / 2=呼吸 / 3=点滅 -
ジンバル:
0x3f/0xb0、receiver = 0x04、角度は0.1度単位。ジンバルだけが動いて車体は不動
うなずく・首を振る・かしげる・きょろきょろする、といったしぐさを実装しました。待機中は装甲LEDをKnight Rider風にスイープさせ、喋っている間はTTS音声の音量エンベロープにLEDの明るさを追従させています。
なお、LEDが最初どうしても光らなかった原因は ctrl_mode = 0 かつ effect = 0、つまり**「消灯しろ」と送り続けていた**からでした。宛先は最初から合っていました。
実測レイテンシと、モデル選びの結論
| 区間 | 実測 |
|---|---|
| コマンド(前/後/右/左/止) | 即 |
| 会話ターン | listen 約4s + ディレクター約3s + 発話 |
| 視覚(look) | +6〜9s |
面白かったのは Ollamaに固定オーバーヘッドが約2.5秒/リクエストあることです(生成そのものは45〜130 tok/s と速い)。どの小型モデルでもこの2.5秒は乗る。
つまり モデルは速度ではなくJSONの信頼性で選ぶべき、という結論になりました。実際、あるモデルは2.8秒と最速でしたが6回に1回JSONが壊れ、採用できませんでした。
教訓:小型LLMのフィールドは信用せずルールで発火させる
一番効いた設計判断がこれです。
ディレクターLLMに look や search のフィールドを判断させると、挨拶しただけで視覚モード(+10秒)に入る、気まぐれに「前進」を選んで勝手に走り出すといった事故が起きました。
対策として、視覚・検索・天気はすべて「LLMの出したフィールドではなく、こちら側の正規表現ルールが一致した時だけ発火」に統一しました。さらに自律移動そのものを廃止し、車体が動くのは明示コマンドの時だけに。しぐさはジンバルとLEDだけで表現します。
小型LLMは「何を言うか」は上手いが「何をするか」の判断は不安定、という切り分けです。
落とし穴メモ
- MCPサーバの多重起動:機体セッションは「最後のhello勝ち・1クライアントのみ」。テレメトリや映像が来なくなったら、まず残骸プロセスを疑う
- MCPは stdin/stdout を UTF-8 に reconfigure 必須。cp932のままだと日本語のツール引数が化けて、機体が化けた音声を喋る
- S1は放置すると自動で電源が落ちる。長時間の作業中に突然オフラインになる
-
終了時のVRAM解放:Ollamaに
keep_alive: 0を投げ、whisperの参照を切ってgc.collect()。実測で 4280MiB → 327MiB
AI任せでやってみて(第2回ぶん)
この回はAIとの分業が一番うまく回りました。理由がはっきりしています。AIが自分で実機を叩いて、結果を自分の目で見られるようにしたからです。
最初はMCPツールとしてだけ実装していました。これが地獄で、ツールの挙動を直すたびにMCPサーバの再読込が要る。しかも古いプロセスが残っていて変更が反映されないことがあり、「直したはずなのに直っていない」が頻発しました。
そこで、同じコードをCLIからも叩けるようにしました。
python s1_mcp_server.py --listen 6
python s1_mcp_server.py --speak "喋る内容"
さらに listen の戻り値に、認識テキストだけでなくフレーム数と実測RMSを含めました。これが効きました。
- Claudeが自分でCLIを実行する
- 「RMS 516で正しく認識」「RMS 90で無音判定」といった数字を自分で見る
- 閾値を調整して、また自分で実行する
再読込を挟まず反復できるようになり、VADの調整のような実測を見ながら詰める作業をAIがひとりで回せるようになりました。私は機体の前で喋る係です。
効いた設計:AIに「観測できる出口」を渡す。
AIにコードを書かせるだけだと、正しく書けたかを人間が確認する係になって詰まります。
自分で実行して、自分で数字を読めるようにした瞬間に、生産性が段違いになりました。
それでも踏んだ、AIらしい失敗
LEDが光らない問題が象徴的でした。宛先アドレスを疑い、パケット長を疑い、コマンドIDを疑って、かなりの時間を溶かしています。
真相は、ctrl_mode = 0 かつ effect = 0、つまり「消灯しろ」というコマンドを送り続けていたというものでした。宛先は最初から正しかった。「光らない」から「宛先が違う」に飛びついて、送っている中身そのものを疑うのが最後になった形です。
もうひとつ、喋るまで6秒かかる問題。TTS 0.35秒、FTP 0.1秒、再生2秒。足しても合わないのに、しばらく「TTSが遅いのでは」という方向を探っていました。犯人は返ってこないACKを待つ1.5秒×2、つまり自分で書いた待機処理でした。
どちらも共通しているのは、AIは自分が書いたコードを容疑者リストの最後に置くということです。外部要因(機体の仕様、ネットワーク、モデルの性能)から順に疑っていく。実際には自分の実装が原因だった、というケースがかなりありました。次回、これが最悪の形で出ます。
ここまでで
喋ると聞くは実機で達成できました。フチコマに必要な4能力のうち1つです。
残るは「自分で回る」と「変化に気づく」。次回はROS 2に載せて、地図を作る話をします。……そして単眼カメラの原理的な限界に正面からぶつかります。
→ ③[ROS 2 VSLAM編]