0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RoboMaster S1に耳と口を授ける:MCPとローカルLLMで「会話から動く相棒」を作る

0
Posted at

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 0xb3sound_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_STATUS0x3f/0x1e)を receiver = 0x01(音声モジュール) へ送る
  • データRM_AUDIO_TO_APP0x3f/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_float16small は精度不足で「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キーもクラウドも不要です。

ループはこう動きます。

  1. 聞く(LED水色で「聞いてます」を表示 → 録音 → whisper)
  2. 「前」「止まって」などの明示コマンドはルールで即実行(LLMを通さない=最速)
  3. それ以外は「ディレクターLLM」が {say, emotion, gesture, look} のJSONを1発で返す
  4. 感情に応じてLEDを光らせ、ジンバルでしぐさをして、喋る

身体表現

LEDとジンバルは、それぞれ専用のコマンドがありました。

  • LED0x3f/0x33receiver = 0x09ctrl_mode = 7(SDK制御)が必須で、effect が 0=消灯 / 1=常灯 / 2=呼吸 / 3=点滅
  • ジンバル0x3f/0xb0receiver = 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に looksearch のフィールドを判断させると、挨拶しただけで視覚モード(+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編]

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?