はじめに
2026年8月25日から学校のカリキュラムの一環として、iPresence株式会社にてインターン生として業務に関わらせていただいている、大阪ハイテクノロジー専門学校 人工知能学科の2年生です。
本記事では、インターンの中で取り組んだ内容をまとめています。
運転席の近くに置いたロボットが、ドライバーの話し相手になる──そんな使い方を考えて、スタックチャンを会話ロボットにしました。ふだんは AI が会話し、会話に合わせて表情を変え、顔を追って首を向けます。在宅の友人が加わりたいときは、ロボットのカメラ映像を見ながら、ロボットを通して話しかけたり首を動かしたりできるようにします。
想定しているユースケース
| 登場人物 | 役割 |
|---|---|
| ドライバー | 運転しながらロボットと会話する |
| ロボット(M5StackChan) | 運転席の近くに置く。会話・表情・首の動きで応じる |
| AI | ロボットが話す内容と表情をコントロールする |
| 在宅の友人 | 家からカメラ映像を見て、ロボットを通して話し、首(パン・チルト)を操作する |
| # | やりたいこと | 実現方法 | 現状 |
|---|---|---|---|
| 1 | AI と会話できる | Gemini Live API(LiveKit Agents 経由) | 実装済み |
| 2 | 友人が在宅から、話と映像を見ながらパン・チルト操作できる | Web コンソール(映像・双方向通話・パン / チルト) | 一部(Web コンソールはローカル実行のみ。外部公開は今後) |
| 3 | AI が会話に応じて表情を変える | エージェントの状態を RPC setFaceState で顔に反映 |
実装済み(会話の状態に連動) |
| 4 | Face Tracking ができる |
face_track.py(mediapipe + PD 制御) |
実装済み(首のサーボが止まる件は「課題と今後」) |
このユースケースに向けて、M5StackChan AI Desktop Robot(Switch Science SKU 11131、M5Stack CoreS3 + 2 軸サーボの卓上ロボット)を、LiveKit(WebRTC) と Google Gemini Live API で会話できる AI ロボットにしました。
- 電源を入れるだけで会話できる(ウェイクワードもタップ操作もなし)
- ESP32-S3 から マイク音声とカメラ映像を同時に LiveKit へ Publish
- 首(2 軸サーボ)と顔(LCD)を、AI エージェントからも Web コンソールからも RPC で操作
- mediapipe による 顔追従
この記事で分かること
- ESP32-S3 を LiveKit のルームに参加させ、音声・映像・RPC を同時に扱う構成
- Gemini Live API(音声⇔音声)を LiveKit Agents から使う構成
- 実機で測って分かった制約(エコー/映像解像度)と、その判断の根拠
- 落ちる場所が毎回違うクラッシュを、カメラドライバのキャッシュ操作まで追い詰めた経緯
デモ
(TODO: デモ動画)
作ったもの
システム構成
すべての参加者は 1 つの LiveKit ルーム stackchan で出会い、参加者どうしを直接つなぐ経路は持ちません。
| 参加者 | identity | 実体 | 役割 |
|---|---|---|---|
| デバイス | stackchan |
CoreS3 ファームウェア(ESP-IDF、C) | マイク音声とカメラ映像を Publish。音声を再生。RPC を受ける |
| 会話エージェント | agent-* |
agent/agent.py(LiveKit Agents + Gemini Live) |
音声対話。表情・首を RPC で操作 |
| 顔追従 | face-tracker |
agent/face_track.py(mediapipe) |
映像から顔を検出し、首を追従させる |
| Web コンソール | web-viewer-* |
web-app/(Next.js) |
映像の閲覧、首・マイク・スピーカーの操作、双方向通話 |
環境
ハードウェア
| 部品 | 型番 | 接続 | 用途 |
|---|---|---|---|
| 本体 | M5Stack CoreS3(ESP32-S3、PSRAM QUAD) | — | 全体制御 |
| マイク ADC | ES7210 | I2C / I2S | 音声入力(ゲイン 38 dB) |
| スピーカーアンプ | AW88298 | I2C / I2S | 音声出力(既定音量 35) |
| カメラ | GC0308 | DVP / SCCB | 映像 |
| LCD | ILI9342C 2.0 インチ 320×240 | SPI | 顔 UI |
| サーボ | FEETECH SCS0009 ×2 | UART(1 Mbaud) | 首(pan / tilt) |
| 胴体エキスパンダ | PY32 | I2C | サーボ電源、RGB LED 12 個 |
| 頭部タッチ | Si12T | I2C | 撫でられた検知 |
| 加速度センサ | BMI270 | I2C | 揺れ検知 |
ソフトウェア
| 区分 | 使用技術 | バージョン |
|---|---|---|
| ファームウェア | ESP-IDF | v5.5.3 で動作確認(v5.4 以降) |
| LiveKit ESP32 SDK(client-sdk-esp32) | v0.3.10(パッチ適用) | |
| esp_peer | v1.5.5(v1.4.2 から更新) | |
| esp32-camera | 2.1.7 | |
| エージェント | Python | 3.13 |
| livekit-agents / livekit-plugins-google | 1.7.1 | |
| google-genai | 2.20.0 | |
| mediapipe | 1.0.1 | |
| Web | Next.js / React | 16.3.4 / 19.2.8 |
| livekit-client / livekit-server-sdk | ^2.22.3 / ^2.19.0 | |
| SFU | LiveKit(セルフホスト。当初は LiveKit Cloud) | TODO(サーバーのバージョン。手元のログからは取れなかった) |
| 開発 PC | Windows 11 Pro(PowerShell 7) | — |
フォルダ構成(抜粋)
M5_STACKCHAN_VoiceAgent/
├─ main/ … ファームウェア本体(ESP32-S3 / ESP-IDF、C)
├─ components/ … 上流コンポーネントのローカル複製(パッチ済み)
├─ agent/ … AI エージェントと運用スクリプト(Python)
├─ web-app/ … 操作用 Web コンソール(Next.js)
└─ docs/ … ハードウェア資料・日次の作業記録
セットアップ(概要)
前提:
- ESP-IDF v5.4 以降
- LiveKit サーバー(セルフホスト or LiveKit Cloud)
- 2.4 GHz 帯の Wi-Fi
idf.py menuconfig の Stackchan Voice Agent メニューで Wi-Fi・LiveKit 接続情報・サーボピン等を設定します。
idf.py set-target esp32s3
idf.py build
idf.py -p <シリアルポート> flash monitor # Windows なら COM3 など
エージェント側は agent/.env.example を参考に agent/.env を作成します。
.\start_demo.ps1 # エージェント・顔追従・Web コンソール
.\start_demo.ps1 -NoAgent # 音声なし(ロボットは喋らない)
.\stop_demo.ps1 # 停止
実装のポイント
1. デバイス側(ESP-IDF / C)
起動シーケンス:Wi-Fi を LCD より先に
順序は Wi-Fi → LCD → ルーム参加 で固定しています。
- LCD(SPI ドライバ + フレームバッファ)を先に立ち上げると、
esp_wifi_init()が静的な rx/tx バッファ用の 内部 DRAM を確保できず、ESP_ERR_NO_MEMで中断した - ネットワーク接続に失敗しても画面は出す。顔が緑のまま なら「起動したが繋がっていない」と分かる
- LiveKit との TLS ハンドシェイクの前に、SNTP で時刻を合わせておく
void app_main(void)
{
esp_log_level_set("*", ESP_LOG_INFO);
livekit_system_init();
board_init();
stackchan_init();
if (media_init() != 0) {
ESP_LOGE(TAG, "Media pipeline failed to start; aborting");
return;
}
// TLS ハンドシェイクの前に正しい時刻が必要
esp_sntp_config_t sntp_config = ESP_NETIF_SNTP_DEFAULT_CONFIG_MULTIPLE(
2, ESP_SNTP_SERVER_LIST("time.google.com", "pool.ntp.org"));
esp_netif_sntp_init(&sntp_config);
// LCD を先に上げると esp_wifi_init() が内部 DRAM を確保できない
bool online = lk_example_network_connect();
// ネットワークが失敗しても画面は出す(顔が緑のまま = 未接続)
display_init();
// ...(セルフテスト、胴体 LED、頭部タッチ・加速度・照度センサの初期化)
if (online) {
join_room();
} else {
ESP_LOGE(TAG, "Network connection failed");
}
}
音声・映像を同時に Publish
ルーム参加時のオプションで、音声(Opus 16 kHz モノラル)と映像(H.264 160×120・10 fps)を両方 Publish し、音声だけを Subscribe します。
livekit_room_options_t room_options = {
.publish = {
.kind = LIVEKIT_MEDIA_TYPE_BOTH,
.audio_encode = {
.codec = LIVEKIT_AUDIO_CODEC_OPUS,
.sample_rate = 16000,
.channel_count = 1,
},
.video_encode = {
.codec = LIVEKIT_VIDEO_CODEC_H264,
.width = 160,
.height = 120,
.fps = 10,
},
.capturer = media_get_capturer(),
},
.subscribe = {
.kind = LIVEKIT_MEDIA_TYPE_AUDIO,
.renderer = media_get_renderer(),
},
.on_state_changed = on_state_changed,
.on_room_info = on_room_info,
.on_participant_info = on_participant_info,
};
- 映像は 固定 QP 20、GOP 1 秒(実測 9.7 fps)
- fps を 5 ではなく 10 にした理由:1 フレームの画質は変わらないが、動きが判読できるようになる。Espressif による S3 の SW エンコーダの計測値は 320×240 / 11 fps で、160×120 はその 4 分の 1 の面積なので余裕がある
- 以前はカメラと音声が内部 DRAM を奪い合って同時に使えなかったが、
CONFIG_CAMERA_PSRAM_DMAでカメラの DMA ディスクリプタを PSRAM に移して解消(2026-09-09)。このモードには落とし穴があり、後述の「落ちる場所が毎回違うクラッシュ」の原因になった
2. AI エージェント(LiveKit Agents + Gemini Live API)
-
google.beta.realtime.RealtimeModelによる音声⇔音声の会話。STT / LLM / TTS が一体型のため、個別のパイプライン配線は不要 - モデル名はコードでは指定しておらず、プラグインの既定値を使用。指定しているのは voice(
Puck)とターン検出の感度 - マイクにエコーの参照信号がない分、ターン検出は発話の始まり・終わりとも HIGH(素早く判定する側)に寄せている
session = AgentSession(
llm=google.beta.realtime.RealtimeModel(
voice="Puck",
# 参照信号のないマイクのノイズフロアは、連続発話に見え得る。
# ターン検出の両端を素早く判定する方向に寄せる
realtime_input_config=types.RealtimeInputConfig(
automatic_activity_detection=types.AutomaticActivityDetection(
start_of_speech_sensitivity=types.StartSensitivity.START_SENSITIVITY_HIGH,
end_of_speech_sensitivity=types.EndSensitivity.END_SENSITIVITY_HIGH,
),
),
),
)
ツールは首を動かす move_randomly の 1 つです。中身はデバイスへの RPC 呼び出しです。
@function_tool()
async def move_randomly(self, context: RunContext) -> str:
"""Physically moves Stackchan's head. ..."""
try:
await self._ctx.room.local_participant.perform_rpc(
destination_identity=DEVICE_IDENTITY,
method="randomMove",
payload="{}",
response_timeout=2.0,
)
return "Moving now."
except Exception:
logger.debug("randomMove RPC failed", exc_info=True)
return "I tried to move, but couldn't reach my motors."
- エージェントの状態(idle / listening / thinking / speaking)が変わるたびに、RPC
setFaceStateで顔の表情を切り替える - 冒頭の挨拶は
generate_reply()を 1 回呼び、内容は指示文(日本語で短く挨拶して待つ)に任せる
監視役(supervise.py)
- 子プロセスが死んだら新しいプロセスで起動し直す(バックオフ 2〜60 秒)
- 8 秒ごとにルームを確認し、エージェントがいなければ明示的に dispatch する(ワーカーが LiveKit に登録されるのを待ってから)
- 起動前に古い dispatch を消す(同じルームに 2 つ目のジョブが入ると Rust FFI がパニックし、ワーカーごと落ちるため)
3. 首サーボ(FEETECH SCS0009)
- PWM ではなく UART シリアルバス(1 Mbaud)。プロトコルは独自実装(リトライ・バス排他制御つき、
main/scservo.c) - サーボ電源は胴体側の I/O エキスパンダ(PY32)で ON/OFF
- 可動域:pan 90±128 度(-38〜218)、tilt 43〜135
- 中心点は NVS に保存して校正(
startCalibration→ 手で位置決め →saveCalibration)
可動域の自動学習(ストール検知)
可動域の限界を定数で手調整するのをやめ、M5Stack 公式ファームウェア(hal_servo.cpp の handle_stall)と同じ考え方で 負荷と位置からストッパーを検知 しています。閾値も公式のものです。
- 移動指令のたびに、その軸を 100 ms ごとに見張る(最長約 3 秒)
- 位置が動かない かつ 負荷が跳ね上がった(絶対値 650 以上、または前回から 150 以上の上昇)状態が 2 回続いたら、そこを進行方向の限界として記録し、押すのをやめてその場で保持
- 負荷だけで判定できないときは、電流(350 以上)を補助に使う
- 公式との違い:公式はバスを独占して 50 ms ごとにポーリングするが、こちらは RPC や顔追従とバスを共有しているため、行き先がある間だけ・1 回試行の読み取りで見張る
#define STALL_TICK_MS 100
#define STALL_MIN_TARGET_DELTA_RAW 8 // これ未満なら到達済み
#define STALL_MAX_POSITION_DELTA_RAW 1 // 「動いていない」
#define STALL_LOAD_ABS_THRESHOLD 650
#define STALL_LOAD_RISE_THRESHOLD 150
#define STALL_CURRENT_ABS_THRESHOLD 350
#define STALL_CONFIRM_SAMPLES 2
#define STALL_MAX_WATCH_TICKS 30 // ~3s で見張りをやめる
// ...(stall_tick() 内)
int position_delta = abs_int(raw - axis->last_raw);
bool position_stuck = position_delta <= STALL_MAX_POSITION_DELTA_RAW;
bool load_spike = load_abs >= STALL_LOAD_ABS_THRESHOLD ||
load_abs - axis->last_load_abs >= STALL_LOAD_RISE_THRESHOLD;
// 電流はセカンドオピニオン。安価な信号がストールを指すときだけ読む
if (position_stuck && !load_spike) {
int current = 0;
if (scservo_read_current(axis->id, ¤t) &&
abs_int(current) >= STALL_CURRENT_ABS_THRESHOLD) {
load_spike = true;
}
}
- 限界は狭まる方向にしか変わらず、再起動で初期値に戻る
4. 顔追従(face_track.py)
- mediapipe の Face Detector(
blaze_face_short_range)で最大の顔を検出し、PD 制御 で首を向ける - 指令間隔 0.12 秒、サーボの移動時間 180 ms(間隔より長くして、首が指令の合間に止まらず滑り続けるようにする)
- 見失ったら停止し、12 秒見えなければ正面(pan 90 / tilt 50)に戻る
- 顔の枠をデータトピック
face-boxで配信し、Web コンソールが映像に重ねて描画 - エージェントとは独立したプロセス。ロボットを喋らせずに開発・実行できる
def _step(
error: float,
derivative: float,
kp: float,
kd: float,
min_step: float,
deadband: float = DEADBAND,
) -> float:
"""1 軸の PD 補正。誤差が小さければ 0。"""
if abs(error) < deadband:
return 0.0
step = kp * error + kd * derivative
# 減衰項が補正を打ち消すなら、それが狙い(オーバーシュートより惰性で進む)
if abs(step) < min_step:
if abs(step) < min_step / 2.0:
return 0.0
step = min_step if step > 0 else -min_step
return max(-MAX_STEP, min(MAX_STEP, step))
| パラメータ | pan | tilt | 補足 |
|---|---|---|---|
| kP | 12.0 | 5.0 | tilt は 22 で ±50 度、9 でも約 20 度のリミットサイクルが出たため 5 |
| kD | 2.5 | 1.5 | 10 fps 信号の微分はノイジーで、これ以上はノイズをサーボへ送るだけ |
| デッドバンド(半フレーム比) | 0.06 | 0.18 | pan の 0.06 は 160×120 で約 5 px = 検出器のジッタ |
- 実効のループゲインは kP ではなく「kP × 指令レート」。指令を毎秒 2.5 回から 8 回に上げた時点で、旧ゲインは実質 3 倍になっていた。毎秒 8 回で kP 24 を試すと誤差の符号が毎回反転してハンチングし、12 で整定した
- I 項は意図的に置いていない。対象は動き続ける人物なので、積分した誤差は効く頃には古くなっている
- tilt の感度(px/度)は、機械的ストッパー付近で測った値が小さすぎた。実際に追従で使う範囲では tilt は敏感な軸だった
5. 顔 UI(LCD)とセンサ反応
- M5Stack 公式アバターを移植した目・口の顔。まばたき、表情、アニメーション
-
顔(目と口)の色で接続状態を表示:画面表示時点で緑、ルーム参加で白
- 緑のまま → 起動したが繋がっていない/画面が暗い → そもそも起動していない
- エージェントが会話可能になると、うなずいて挨拶する
- 頭部タッチ(撫でる)で嬉しい表情、揺れで驚く反応
6. RPC 設計
エージェント・Web コンソール・agent/rpc_call.py から呼べるデバイス側 RPC(主なもの):
| メソッド | Payload | 返り値 |
|---|---|---|
setHeadPose |
{"pan":n,"tilt":n,"ms":50-2000} |
{"pan","tilt","held","panOk","tiltOk"} |
getHeadPose |
なし | {"pan","tilt","panOk","tiltOk"} |
nod / randomMove
|
なし | {"ok":true} |
setFaceState |
{"state":"idle"|"listening"|"thinking"|"speaking"} |
{"ok":true} |
setMicMuted |
{"muted":true|false} |
{"muted":bool} |
setSpeakerVolume |
{"volume":0-100} |
{"volume":n} |
startCalibration / saveCalibration
|
なし | {"ok":true} |
setHeadPose の返り値は 指令値ではなく実際の姿勢 です。ジェスチャー中の指令は破棄され(held: true)、可動域外の値は丸められます。
この形になった経緯:以前は サーボが 1 度も応答しなくても成功を返していた。返していたのが到達値ではなく指令値だったため、首が死んでいても UI は正常に見えていた。現在は次のように返し分けます。
| 状況 | 結果 |
|---|---|
| 未初期化(無効ビルド、起動時の失敗) | エラー(「無応答」と区別し、配線や電源を疑わせない) |
| 両軸とも無応答 | エラー |
| 片軸だけ無応答 | 成功。軸ごとの結果(panOk / tiltOk)と実際の姿勢を返す |
| ジェスチャー中で保持 | 成功。held: true
|
片軸の無応答を全体の失敗にしないのは、動いた方の軸の姿勢まで呼び出し側に届かなくなり、顔追従が古い基準姿勢から補正し続けてしまうためです。
7. Web コンソール(Next.js)
- 映像(顔の枠の重ね描き)、pan / tilt 操作、マイクミュート、マイクレベル計、スピーカー音量、双方向通話
- 首・マイク・スピーカーの操作は エージェントを経由せず直接 RPC で届く
- 現状はローカル実行のみ(
npm run dev、localhost:3000)
ハマりどころ・計測で分かったこと
| 分野 | 症状 | 原因(ひとこと) |
|---|---|---|
| 音声 | ロボットが自分の声に返事をし続ける | エコーの参照信号がなく、自分の声が人の声と同じ大きさで届く |
| 音声 | エージェントがロボットの声を聞いていない | RoomIO が別の参加者にリンクしていた |
| 映像 | 160×120 より上げられない | openh264 の解像度上限と、1 フレーム=1 NAL |
| 映像 | 横縞が出る | 解像度テーブルの欠落と、バッファの取り違え(2 つの別原因) |
| 安定性 | 落ちる場所が毎回違うクラッシュ | カメラドライバのキャッシュ無効化が、ヒープの管理情報を消していた |
| 運用 | ロボットが起きたまま無言で固まる | libsoxr のアサーションが、見えないダイアログでスレッドを止めていた |
| 運用 | 会話が死んでもエージェントが居座る | セッションが閉じてもジョブは部屋に残る |
音声・エコー
割り込み(barge-in)を諦めた理由
症状:ロボットが自分の声を拾って応答し、無限に自問自答する
assistant: お話しましょう!
user: お 話し ましょう 。
原因:CoreS3 のスピーカーはマイクから数センチの位置にあり、音響エコーの参照信号を取る配線がない(ES7210 の 3・4ch が未接続)
| 音源 | マイクでの実測(音量 35・ゲイン 38 dB) |
|---|---|
| ロボット自身の声 | -21 〜 -36 dBFS |
| 人の声 | -26 〜 -28 dBFS |
| 暗騒音 | -47 〜 -56 dBFS |
→ ロボット自身の声が人の声と同じ大きさで届くため、VAD の感度調整でも音量調整でも分離できない
対処:エージェントが話している間、エージェント自身の音声入力を閉じる
input_muted = False
def _set_input_muted(muted: bool) -> None:
nonlocal input_muted
if muted == input_muted:
return # 冪等にする: 高速な切り替えで以前レースが起きた
input_muted = muted
session.input.set_audio_enabled(not muted)
async def on_agent_state_changed(ev: AgentStateChangedEvent):
_set_input_muted(ev.new_state == "speaking")
# ...(続けて setFaceState RPC で表情を切り替える)
- 発話中のまま固まった場合に備え、2 秒ごとに「発話がないのに入力が閉じたまま」を検知して開け直す見張り(
_unmute_watchdog)を併設 - その代償として割り込みは不可
最初に試して差し戻した方法:デバイス側の RPC setMicMuted でマイクを閉じる方式。次の 2 つで失敗した。
- RPC の往復遅延で、ユーザー発話の冒頭が毎回切り落とされた(Gemini はその断片を無意味な文として文字起こしした)
- unmute が 1 回失われただけで、ロボットが永久に聞こえなくなった
set_audio_enabled() はセッション自身の入力に対するローカル呼び出しなので、ネットワークを挟まず、失われることもありません。
スピーカー音量の既定値 35 を上げないこと。80 にすると自分の声を拾って無限に自問自答します。
allow_interruptions=False は効きません。RealtimeModel はサーバー側でターン検出を行うため、SDK はこの値を警告付きで無視します。挨拶の自己割り込みを防いでいるのも、上の入力ゲートです。
エージェントが別の参加者の音声を聞いていた
症状:接続・挨拶・RPC はすべて正常に見えるのに、エージェントがロボットのマイク音声を受け取っていない
原因:participant_identity を指定しないと、RoomIO はたまたまルームの先頭にいる参加者にリンクする。それが前回セッションの残骸で何も Publish しない古い web-viewer のことがあった
対処:リンク先をデバイスに固定する
await session.start(
agent=Stackchan(ctx),
room=ctx.room,
room_options=RoomOptions(participant_identity=DEVICE_IDENTITY),
)
映像
160×120 が上限
壁 1:openh264 が約 1,000 マクロブロックを超える解像度を拒否
| 解像度 | マクロブロック数 | 結果 |
|---|---|---|
| 576×432 | 972 | 通る |
| 640×360 | 920 | 通る |
| 640×480 | 1,200 |
cmInitParaError で拒否 |
H.264 標準のレベル制限とは一致せず、Espressif 独自のビルド設定と推定(ライブラリのログ文字列が削除されており、理由は確認できない)
壁 2:1 フレーム = 1 NAL 固定(SM_SINGLE_SLICE)のため、大きなフレームは多数の RTP パケットに FU-A で分割され、1 つ失うとフレーム全体が壊れる。スライス分割(SM_SIZELIMITED_SLICE)は、160×120 で試すと 4.7 fps → 0.5 fps に落ち、上部の帯以外が一面の緑になった(先頭スライスしか届かない)
枠内のつまみも使い切った
- QP・GOP・fps は実測で調整済み
- ROI:SW エンコーダに存在しない(ESP32-S3 には H.264 のハードウェアエンコーダがない)
- QVGA で取り込んで縮小:スケーラがパック 4:2:2 に非対応
GOP を 1 秒にした理由:esp_peer は RTCP PLI を受け取ってもログに出すだけで何もせず、SW エンコーダは Force IDR にも非対応。フレームが 1 枚壊れると次の鍵フレームまで直らないため
映像に横縞が残る(2 つの別原因)
| 原因 | 対処 |
|---|---|
esp_capture の get_video_quality() に 160×120 のケースがなく、QVGA にフォールバックしていた |
ケースを追加(components/ のパッチ) |
DVP ソースの buf_count=2 では、共有インデックス cur_pic が上書きされ、誤ったバッファがカメラドライバへ返る |
buf_count = 1 に固定(ダブルバッファリングは失う) |
esp_capture_video_dvp_src_cfg_t video_cfg = {
// buf_count=2 だと共有インデックス cur_pic が上書きされ、
// release_frame() が誤ったバッファを返す(横縞の原因)
.buf_count = 1,
// ...(ピン設定)
.xclk_freq = 20000000,
.i2c_port = BOARD_I2C_PORT,
};
切り分けの決め手:同じセンサの JPEG スナップショット(YUV420 変換を通らない経路)は綺麗だったため、センサや DMA キャプチャ自体は正常と判断できた
安定性
落ちる場所が毎回違うクラッシュ
症状:起動やルーム入室の数秒〜数十秒後に、ときどき再起動する(10 回に 1 回前後)。勝手に復帰して元どおり動くので、見逃しやすい。しかも落ちる場所が毎回違った。
- lwIP の TCP 処理(
tcp_write、tcp_free_acked_segmentsでLoadProhibited) - ヒープの解放(
tlsf_freeのblock already marked as freeアサーション) - DTLS ハンドシェイク中の mbedTLS
- カメラの後始末(
cam_deinitから呼んだtlsf_freeのアサーション)
手がかり:どれも カメラ/H.264 の立ち上げ直後 に集中していた。落ちる場所がばらばらなのは、落ちた場所が被害者で、壊した犯人が別にいる(メモリ破壊)ことを示している。ELF を毎回保存し、バックトレースを突き合わせて絞り込んだ。
原因:esp32-camera 2.1.7 の PSRAM DMA モード(CONFIG_CAMERA_PSRAM_DMA)は、フレームを受け取るたびにフレームバッファのデータキャッシュを無効化する。そのとき、開始アドレスを キャッシュ行(64 バイト)の境界まで切り下げて 無効化する。
static inline void cam_drop_psram_cache(void *addr, size_t len)
{
size_t line = dcache_line_size();
if (line == 0) {
line = 32; /* sane fallback */
}
uintptr_t start = (uintptr_t)addr & ~(line - 1);
size_t sync_len = (len + ((uintptr_t)addr - start) + line - 1) & ~(line - 1);
esp_cache_msync((void *)start, sync_len,
ESP_CACHE_MSYNC_FLAG_DIR_M2C | ESP_CACHE_MSYNC_FLAG_INVALIDATE);
}
ところがフレームバッファは 16 バイト境界にしか揃っておらず(揃え幅を決める ll_cam_get_dma_align が、この構成では 16 を返す)、常にキャッシュ行の途中(行頭から 48 バイト)に置かれていた。そのため、バッファ直前のヒープの管理情報(ブロックの大きさ、空きリストのつなぎなど)が載った行まで毎フレーム無効化 され、CPU がまだ PSRAM へ書き戻していなかった更新が捨てられていた。
決め手:ヒープの毒入れ(CONFIG_HEAP_POISONING_COMPREHENSIVE)で壊れた番地を記録すると、無効化したキャッシュ行の先頭とぴったり一致 した(フレームバッファ 0x3C414970 → 行頭 0x3C414940、壊れた番地 0x3C414940 / 44 / 48)。
対処:フレームバッファの先頭をキャッシュ行に揃える。
if (cam_obj->psram_mode) {
dma_align = ll_cam_get_dma_align(cam_obj);
+ // 先頭をデータキャッシュの 1 行に揃え、直前のヒープの管理情報を巻き込まない
+ size_t line = dcache_line_size();
+ if (line > dma_align) {
+ dma_align = (uint8_t)line;
+ }
結果:ルームへの入退室を繰り返す自動試験で、修正前は 39 回中 5 回クラッシュ、修正後は 25 回中 0 回。
教訓
- 落ちる場所がばらばらなら、落ちた場所ではなく「その前に何が動いたか」を見る
- ヒープの整合性チェックを頻繁に走らせると、かえって再現しにくくなった。キャッシュの書き戻しのタイミングが変わるためと考えられる
- 内部 DRAM 不足を解消するために有効にした PSRAM DMA モード(2026-09-09)が、この不具合の入口だった
ESP-IDF・コンポーネント
managed_components/ のパッチが消える
原因:managed_components/ は idf.py reconfigure のたびに再取得される
対処:上流 5 つを components/ へ複製してパッチを当てた
| コンポーネント | パッチ内容 |
|---|---|
esp_capture |
get_video_quality() に 160×120 を追加(無いと QVGA にフォールバックし横縞が出る) |
esp_video_codec |
入力フォーマットを YUV422 優先に(色崩れ対策)/エラーコードの誤ラベル修正/ビットレート下限 |
esp_peer |
v1.4.2 → v1.5.5。SCTP の過剰再送で RPC が全滅する不具合の修正 |
example_utils |
WPA3-Personal(SAE)対応。PMF を有効にしないと接続できない |
livekit |
RPC の ACK と応答に宛先を設定(無いと全参加者へブロードキャストされる)/再生する音声の持ち主が退出したら、次の 1 本へ付け替える(上流は最初の 1 本を放さない) |
Wi-Fi:5 GHz を指定すると認証エラーに見える
- ESP32-S3 は 5 GHz 非対応。5 GHz の SSID を指定すると
reason=201 NO_AP_FOUNDになり、認証エラーと紛らわしい
エージェントの運用
ロボットは起きているのに無言でハングする
症状:イベントループがフレームの途中で止まり、ログは空のまま(一度は 3 分、テストでは 19 分気づかれなかった)
原因:livekit_ffi.dll 同梱の libsoxr は、FFT テーブルを同期されていない遅延初期化で作る。2 つのスレッドが同時にリサンプラを作るとアサーションが発火し、Windows の CRT アサーションはモーダルダイアログ なので、誰にも見えないボタンが押されるまでスレッドが止まる(_set_error_mode(_OUT_TO_STDERR) でもダイアログは出た)
対処:音声が始まる前にリサンプラを 1 つ作り、プロセスの生存期間中ずっと保持する。キャッシュが初期化済みのまま残るので、レースする経路を通らない
def pin_soxr_fft_cache() -> None:
"""libsoxr のグローバル FFT キャッシュをプロセスの生存期間中開いたまま保持する。"""
global _fft_cache_pin
if _fft_cache_pin is not None:
return
try:
from livekit import rtc
_fft_cache_pin = rtc.AudioResampler(
input_rate=48000, output_rate=16000, num_channels=1
)
except Exception:
logger.exception("could not pin the soxr FFT cache")
- 計測(2 スレッド × 各 20,000 回作成):保持あり 8 回中 8 回クリーン/保持なし 8 回中 4 回でストールまたはアクセス違反
- 加えて、起動の見張り(60 秒で終了コード 17)とイベントループの停止検知(15 秒で終了コード 18)で自己終了し、
supervise.pyが再起動する
会話が死んでもエージェントが部屋に居座る
症状:Gemini の再接続が回復不能なエラー(1007 / 1008)で終わると、エージェントは部屋にいるのに誰も応答しない。監視役は「エージェントがいる」と見て再 dispatch しない
対処:セッションが閉じたら、ジョブごと部屋から退出する。監視役が不在を検知して新しいジョブを送り込む
dispatch の猶予判定が一度も通らなかった
原因:AgentDispatchState.created_at は秒ではなく ナノ秒 単位の Unix 時刻。time.time() と直接比較していたため常に「作成直後」に見えた
対処:time.time_ns() と比較する
now_ns = time.time_ns()
grace_ns = int(DISPATCH_GRACE_S * 1e9)
if any(now_ns - d.state.created_at < grace_ns for d in dispatches):
return False # dispatch 直後なので、ジョブが参加するまで待つ
LiveKit Cloud の接続分数上限
-
当初は LiveKit Cloud を使用していたが、接続分数の上限に達したためセルフホストの SFU に移行
-
8/27 に初めて接続してから、9/15 に上限に達するまで約 3 週間。開発中はデバイスとエージェント(のちに顔追従と Web コンソールも)を、ルームにつなぎっぱなしにしていた
-
上限に達すると、デバイスも PC も一切入室できなくなった(
429 Too Many Requests)。REST API は動き続けたので、最初はデバイスの故障に見えた
振り返り:苦労した点
1. エラーが出ないまま、動いていなかった
いちばん時間を取られたのは「壊れている」ではなく「正常に見えて、実は動いていない」状態だった。
| 何が | 見え方 | 実際 | 分かったきっかけ |
|---|---|---|---|
| 首のサーボ(08-28) | PWM で駆動。コンパイルも実行もエラーなし | UART のシリアルサーボ(FEETECH SCS0009)で、プロトコルが別物。さらに電源は本体側の別 IC(PY32)が握っていて、入れないと全コマンドを無視 | 受信した生バイトがゼロ → 公式の StackChan-BSP を読んだ |
| エージェントの耳(09-10) | 接続・挨拶・RPC はすべて正常 | 前回の残骸の、無音の Web 閲覧者の音声を聞いていた | ログを捨てて、ルーム側で音量(dBFS)を実測 |
| RPC(〜09-29) | 首の操作が常に「成功」 | サーボが 1 度も応答しなくても、指令値を返していた | 首が動かないのに UI が正常だった |
| データチャネル(09-01〜10) | 音声と映像は正常 | RPC だけ全滅(SCTP の INIT を 165 ms ごとに永遠に再送) | esp_peer の変更履歴に一致する修正を見つけた |
| 胴体の LED(09-24) | レジスタを 3 回直しても点かない | そもそも 5V が来ていなかった。電源を切っていたコードのコメント自体が誤り | M5Unified の原典を clone して読んだ |
| エージェントの無言ハング(09-14) | ロボットは起きている。ログは空 | 誰にも見えない Windows のダイアログでスレッドが止まっていた | [記事に記載済み] |
- なぜ大変だったか:どれもエラーが出ず、ログも正常に見えたので、「どこかが壊れている」という手がかり自体がなかった。疑う場所を決められないまま、正常に見える部分から一つずつ確かめていくしかなかった
- 学び:エラーが出ないことは、正常に動いている証拠にならない。ログを信じる前に、実際に起きていることを測る(受信したバイト、音量の dBFS、首の位置)。ハードの仕様は、要約ではなく公式の原典(StackChan-BSP、M5Unified)を読んで確かめる
2. ESP32-S3 の内部 DRAM の取り合い
- 08-27:PSRAM を Octal と思い込んで起動できず、実は Quad だった(モジュール名の「R8」から推測していた)
- 08-31:カメラと音声を同時に動かすと内部 DRAM が約 1.9 KB まで減り、LiveKit の TLS 用バッファが確保できずにクラッシュ。いったんは「音声とカメラを切り替えて使う」方式にした
- 起動順:LCD を Wi-Fi より先に上げると
esp_wifi_init()がESP_ERR_NO_MEMで中断 → Wi-Fi → LCD の順に固定[記事に記載済み] - 09-09:
CONFIG_CAMERA_PSRAM_DMAでカメラの DMA ディスクリプタを PSRAM へ移し、同時使用が可能に - 10-01:その PSRAM DMA モードが、ヒープを壊すクラッシュの入口だった(3. と 8. を参照)
- なぜ大変だったか:内部 DRAM の不足は、足りなくした機能ではなく、その後に確保しようとした別の機能(Wi-Fi の初期化、TLS のバッファ)のエラーとして現れる。原因と症状が離れているうえ、起動の順番や同時に動かす機能の組み合わせで結果が変わった
- 学び:「PSRAM が 8 MB ある」ことと「内部 DRAM に余裕がある」ことは別の話。何がどちらのメモリを使うかを意識し、起動の順番を固定する。そして、メモリの問題を解決するために変えた設定が、別の不具合を連れてくることがある(8. のクラッシュ)
3. 映像の画質の壁(160×120 に落ち着くまで)
- openh264 が約 1,000 マクロブロック超を拒否(640×480 は不可)。パラメータを 1 つずつ変えて二分探索し、解像度だけが原因と特定(09-15)
- スライス分割を試したら、先頭スライスしか届かず失敗
- ROI は SW エンコーダに無く、QVGA で取り込んで縮小する案はスケーラが非対応
- 「VGA が通らない理由」の仮説が 2 度覆った(CPU が張り付く説 → メモリ不足説 → どちらも誤り)
- 残ったつまみで調整:QP 25 → 20(鮮鋭度 +2.7%、1 フレームのバイト数 +6.1%)、GOP 1 秒、10 fps(実測 9.7)
- なぜ大変だったか:上限を決めていたのが、ソースの無いコンパイル済みライブラリの中だった。ログの文字列も削られていて、拒否の理由を知る手段がなく、パラメータを 1 つずつ変えて外側から挟み込むしかなかった
- 学び:もっともらしい仮説ほど、否定する測定を先に考える(VGA が通らない理由は、2 度とも測定で覆った)。限界が動かせないと分かったら、残ったつまみ(QP・GOP・fps)を実測で詰める
4. エコー:割り込みを諦めるまで[記事に記載済み]
- ロボットの声(-21〜-36 dBFS)と人の声(-26〜-28 dBFS)がマイクで同じ大きさ。VAD でも音量でも分離できない
- 失敗 1:スピーカー音量 80 で自問自答が 2 回起きた
- 失敗 2:デバイス側でマイクを閉じる RPC → 発話の頭が切れ、unmute を 1 回落とすと永久に聞こえなくなった
- 3 回目:エージェントのプロセス内で入力を閉じる方式で解決。代償は割り込み不可
- 振り返りの視点:実測した数値(自分の声と人の声が同じ大きさ)が、VAD や音量の調整といった最初に思いつく対策を、試す前に退けてくれた。デバイス側でマイクを閉じる案は、「unmute を 1 回取りこぼすと、二度と聞こえなくなる」という壊れ方で失敗した。復旧の経路が、会話が続くことに依存していてはいけない
5. マイクのゲインが行ったり来たり(30 → 12 → 38 dB)
- 08-28:30 dB では 1 ターンで会話が止まり、12 dB で解決(VAD の感度調整だけでは直らなかった)
- 09-10:今度は 12 dB で応答しない。測ると 5 cm の拍手でもピーク -46 dBFS(ノイズフロア -73 dBFS)しかなく、38 dB(チップの上限 37.5 dB)に上げて解決
- 12 dB で一度うまくいった理由は、今も分かっていない
- なぜ大変だったか:同じ「応答しない」という症状に対して、正解が「下げる」ときと「上げる」ときがあった。しかもエージェント側のログは毎回正常で、手がかりにならなかった
- 学び:音声の問題は、ログではなく音量(dBFS)を実測して判断する。過去にうまくいった設定値も、前提が変われば当てにならない
6. 首(サーボ)まわり
- プロトコルと電源(1. を参照)
- tilt の可動域:ソフトは上下対称(0〜180)を想定していたが、実際の機械的な可動域は 38〜140。ストッパーに押し付けたまま「ブーン」と鳴っていた → 公式ファームのストール検知を移植し、可動域を自分で学習させた[記事に記載済み]
- バスの沈黙(未解明)
- 起動後しばらくで、両軸が同時に応答しなくなる
- 原因ではないと分かったもの:書き込み経路、プロトコル、サーボのモード、指令値のクランプ、ジェスチャ経路
- 測れたこと:胴体 LED(12 個で約 240 mA、同じ 5V バス)を消すと、沈黙までの時間が 13〜22 秒から 26 秒以上に延びる(ただし消しても直らない)
- 対策(別ブランチ):沈黙を検知してサーボ電源を入れ直す/同じ姿勢を書き込まない/大きな指令の速さを抑える → 数分の操縦では止まらなくなった
- 測り方の罠:カメラ映像を目で見て「首が動いた」と判断すると、被写体が動いたのを逆向きに取り違える。位相相関で判定するようにした
- 首の取り合い:顔追従と Web 操作がどちらも首に指令を送る → 動作モード(AI / RemoteControl)を分けた
- なぜ大変だったか:「首が動かない」の原因候補が、プロトコル・電源・可動域・モード・制御と多く、1 つ直しても別の理由で動かないことが続いた。バスの沈黙は今も原因が分かっていない。そのうえ、動いたかどうかの判定(カメラ映像を目で見る)自体が当てにならなかった
- 学び:まず判定の方法を疑う(目視ではなく、位相相関で測る)。原因ではないと分かったものを記録して、同じ確認を繰り返さない。原因が分からなくても、異常を検知して電源を入れ直すような復旧の経路は作れる(数分の操縦では止まらなくなった。それより長い連続運転は未確認)
7. 顔追従の制御
- 検出自体は 160×120 で十分(148 / 148 フレーム、信頼度の中央値 0.62、1 フレーム 4.8 ms)。最初の「79% しか検出できない」は、被写体がフレームから半分はみ出していただけ
- 自信満々に間違えた罠が 3 つ
- 首を動かさない試運転(dry-run)では、閉ループを検証できない
-
cv2.phaseCorrelateの符号を逆に解釈し、パンの制御が発散した - 機械的ストッパーの近くで測った tilt の感度(0.81 px/度)は、実際に使う範囲(約 10 px/度)と全く違った
- 実効ゲインは kP ではなく「kP × 指令レート」。指令を毎秒 2.5 回から 8 回に上げた時点で、それまでのゲインは実質 3 倍になっていた。毎秒 8 回では kP 24 がハンチングし、12 で整定した[記事に記載済み]
- なぜ大変だったか:どの罠も、間違った結論を自信を持って出してきた。試運転は「収束しない」と言い、校正は符号を逆に教え、ストッパー近くの測定は感度を小さく見せた
- 学び:閉ループの制御は、閉ループのまま実機で確かめる。測定値は、どの条件で測ったか(ストッパーの近くか、実際に使う範囲か)と一緒に残す。指令のレートを変えたら、ゲインも見直す
8. 落ちる場所が毎回違うクラッシュ[記事に記載済み]
- 09-17〜09-30 の間に 6 回目撃。落ちる場所は lwIP の TCP 処理、ヒープの解放、DTLS、カメラの後始末とばらばら
- 毎回 ELF を保存してバックトレースを読み、「カメラの起動直後に集中」という共通点から絞り込んだ
- 10-01:カメラドライバのキャッシュ無効化がヒープの管理情報を消していたと特定(入退室の試験で 39 回中 5 回 → 25 回中 0 回)
- ヒープの整合性チェックを強めると、かえって再現しにくくなった
- 振り返りの視点:2. で内部 DRAM の不足を解決するために有効にした PSRAM DMA モードが、この不具合の入口だった。落ちるたびに場所が違うので、1 回ずつ見ると毎回「別の不具合」に見える。ELF を毎回保存して並べたことで、「カメラの起動直後」という共通点が見えた
9. PC 側エージェントの運用(Windows ならではの罠が多い)
- 見えない CRT ダイアログで無言ハング(libsoxr)[記事に記載済み]
- cp932 のコンソールで、日本語や「—」をログに出すと例外になる。それも Rust が待っているスレッドの上で → 子プロセスは UTF-8 を強制
- コンソールの選択操作(QuickEdit)で子プロセスが丸ごと固まる → 子の出力はファイルへ
- クラッシュの原因が監視役自身だった:古い dispatch が残り、次のワーカーに 2 つ同時に渡されて FFI がパニック → 起動のたびに掃除する
- 冷えたワーカーは dispatch からジョブ到着まで 70 秒かかり、猶予 30 秒では二重 dispatch を自分で作っていた → 90 秒に
- dispatch の作成時刻が秒ではなくナノ秒だった[記事に記載済み]
- 長時間動かすと壊れる、という見立ては誤りだった:3 時間 43 分の連続稼働では 0 回、部屋の作り直しを繰り返した 12 分間では 3 回クラッシュ
- LiveKit Cloud の接続分数の上限(429)で、デバイスも PC も一切入室できなくなった。REST API は動くので、デバイスの故障に見えた → セルフホストの SFU へ(共有のため、他チームの部屋を巻き込まない配慮が要る)
- スクリプトで喋らせる発話(
generate_reply)は、プロセスの生存中に 1 回目しか音が出ない(テキストは毎回生成される) - なぜ大変だったか:落ち方が「静かに止まる」「Rust がパニックする」「無言で異常終了する」と何通りもあり、しかも原因が、監視役やログ出力といった本来は安全網のはずの部分に潜んでいた。「長時間動かすと壊れる」という最初の見立ても誤りだった
- 学び:再現の条件を測ってから仮説を立てる(引き金は稼働時間ではなく、部屋の作り直しの繰り返しだった)。自動復旧の仕組みも、それ自体が不具合の原因になりうる(古い dispatch の山積み)。Windows では、ログの文字コードとコンソールの操作だけでプロセスが止まりうる
10. Wi-Fi
- ステルス SSID はスキャンに出ないが、接続はできる(「無い」と判断しない)
- ESP32-S3 は 2.4 GHz 専用。5 GHz の SSID を指定すると
reason=201(NO_AP_FOUND)で、パスワード間違いに見える - WPA3 は PMF を有効にしないと繋がらない(上流の例はゼロのままだった)
- 部品をローカルに複製した拍子に、設定から SSID が消えて空になった
- 学び:繋がらないときは、帯域(チャンネル番号)→ ビルドされた設定に SSID が入っているか → 切断理由のコード、の順に確かめる。パスワードを疑うのは最後でよい
11. 起動方式の変遷
- 音声のウェイクワード:聞き間違いが正規表現の工夫では直らなかった
- → 画面のタップ起動(09-01)
- → 電源を入れたら挨拶して即会話(09-08)。同時に、落ちても自分で立ち直る監視役を導入
- なぜ変えたか:音声のウェイクワードは、ほぼ毎回違う文字列に聞き取られ、正規表現を広げても直らなかった。タップ起動は、起動・停止の状態管理がかえって不具合の元になった(タップの連打で起動処理どうしが競合し、そのあと音声の出力が壊れたままになった。起動のたびの挨拶も、プロセスにつき 1 回しか音が出なかった)。最後は「電源が入って部屋に入ったら起きている」と割り切り、挨拶はエージェントの指示文に任せた
課題と今後
課題
| 課題 | 現状(2026-10-05) | 次の一手 |
|---|---|---|
| 首のサーボバスの沈黙 | 原因は未解明。別ブランチの対策で数分は止まらない | main へ取り込む。10 分以上の連続試験 |
| クラッシュ修正の取り込み | 修正は試験で確認済み。main のリポジトリには未反映 |
components/ に取り込む。esp32-camera の上流へ報告するか決める |
| 動作モード(AI / RemoteControl) | 実装済み・実機未確認。Web 画面の変更は未コミット | 実機に書き込んで確かめる。遠隔操作中と分かる顔の表示を決める |
| 再生する音声の付け替え(SDK 改修) | 実装済み・実機未確認 | エージェントが入れ替わっても声が戻るかを確かめる |
| 会話が死んだときの自動退出 | 実装済み・実機未確認 | 上と合わせて確かめる |
| スクリプト発話で音が出ない | 保留(プロセスごとに 1 回目だけ鳴る) | 専用の TTS を足す、または RoomIO の内部を読む |
| 割り込み(barge-in) | 不可(エコーの参照信号が取れない) | 参照信号を取れるハードが要る |
| 映像の解像度 | 160×120 が上限 | 上流(openh264 のビルド設定)次第 |
| 同時に再生できる音声は 1 本だけ | 仕様として受け入れ(動作モードで、送り手を常に 1 人にする) | — |
| エージェントのプロセスが落ちることがある | 監視役(supervise.py)の自動再起動でカバー | 無言の異常終了(終了コード 4294967295)の原因は未捕捉 |
| 顔追従がハングしても気づかない | 未着手 | 制御ループの中で RPC を待たない。監視を付ける |
| AI の返答の吹き出し | 単体では表示できたが、本番の会話では未確認 | 本番の会話で確かめる |
| Web コンソールの外部公開 | ローカル実行のみ。認証なし | 配置と認証の設計 |
| 車内での運用 | 机上の構成(Wi-Fi と PC が前提) | PC なしの構成、モバイル回線、電源 |
| AI の指示文と実態の食い違い | 「カメラは見えている」と答えさせるが、映像を入力していない | 映像を入力するか、指示文を直す |
| 遠隔操作中の音声 | 操作者の声がデバイスのマイクに回り込み、本人に返る。Web 画面が複数あると、話す権利が決まっていない | 押している間だけ話す方式など |
| パンの可動域 ±300 度 | 保留 | 1 回転を超える位置の追跡が要る |
| センサと LED の活用 | 照度・近接は値を読むだけ。LED は青の常時点灯だけ | 状態表示などに使うか決める |
今後
-
動作モード(AI / RemoteControl)の切り替え:実装済み・実機未確認
- AI モード:エージェントが会話し、顔追従が首を動かす
- RemoteControl モード:エージェントは退出し、操作者が Web からロボットを通して話し、首を動かす
- 分ける理由:首(顔追従と Web 操作がどちらも
setHeadPoseを送る)と、再生する音声(1 本だけ)の取り合いをなくすため
- 再生する音声トラックの付け替え:持ち主が退出したら次の 1 本へ付け替える SDK 改修(実機未確認)
- デバイスでの映像受信
- Web コンソールの外部公開と認証
動作モードの仕組み(実機未確認)
- モードはデバイスではなく ルームのメタデータ
{"mode":"ai"|"remote"}に持たせる。空・壊れた JSON・知らない値はai - 書くのは Web のサーバー側(
/api/mode)だけ。RoomService.UpdateRoomMetadataには部屋の管理権限が要り、API キーはサーバーにしかないため - デバイス・エージェント・顔追従・Web はメタデータ変更のイベントで、監視役は
list_roomsで同じ値を読む
static void on_room_info(const livekit_room_info_t *info, void *ctx)
{
(void)ctx;
bool remote = metadata_says_remote(info->metadata);
if (room_mode_known && remote == room_mode_remote) {
return;
}
room_mode_known = true;
room_mode_remote = remote;
ESP_LOGI(TAG, "Room mode: %s", remote ? "remote" : "ai");
stackchan_set_remote_mode(remote);
// 鳴らす声の持ち主を 1 人にする(AI ではエージェント、RemoteControl では操作者)
livekit_room_set_audio_source(room_handle, remote ? LIVEKIT_AUDIO_SOURCE_STANDARD
: LIVEKIT_AUDIO_SOURCE_AGENT);
// ...(RemoteControl に入ったら表情を待機に戻し、吹き出しを消す)
}
おわりに
ESP32-S3 の小さな基板で音声・映像・RPC を同時に扱い、PC の AI エージェントと Web コンソールを、1 つの LiveKit のルームでつなぐ構成を作りました。電源を入れるだけで会話でき、会話に合わせて表情を変えるところまでは動いています。首も動きますが、サーボのバスが沈黙する問題がまだ残っています。
振り返ると、いちばん効いたのは「測ってから判断する」ことでした。エコーは音量の実測で対策の方向が決まり、エージェントが黙る不具合はログではなく音量で見つかり、落ちる場所が毎回違うクラッシュは、毎回の ELF を並べて原因にたどり着きました。逆に、目で見た印象やもっともらしい仮説は、何度も外れました。
次は、実装した動作モード(AI / RemoteControl)を実機で確かめること、首のサーボバスの沈黙対策とカメラドライバの修正を取り込むこと、そして Web コンソールを外部に公開して、在宅の友人が本当に参加できるようにすることです。
参考
- Switch Science SKU 11131(M5StackChan AI Desktop Robot)
- livekit/client-sdk-esp32
- m5stack/StackChan ― M5Stack 公式ファームウェア。サーボ制御(可動域の自動学習、ストール検知)を参考にした
- espressif/esp32-camera ― カメラドライバ(「安定性」の項)
