はじめに
手のひらサイズのキーボード付きマイコン M5Stack Cardputer Adv(ESP32-S3)を、**Rust の no_std + embassy(async ランタイム)**で「話しかけると答えてくれる小さなAI端末」に仕立ててみました。
- Fn+A を押しながら話す → 文字起こし → AIが回答 → 画面表示+スピーカーで読み上げ
「Cardputer Adv でどこまでできるか」の検証(PoC)ですが、単一ファームウェアで音声↔AI対話が実機で成立しました。本記事では構成と、途中でハマった組み込みらしい落とし穴(I2S全二重、DMA、RAM制約…)を中心にまとめます。
ソースは公開しています(MIT):
https://github.com/iwan-inc/cardputer-rust
全体構成
音の入り口・出口はデバイス、重い処理(文字起こし・LLM・音声合成)は母艦Mac上の自作HTTPサーバ、という役割分担です。
- デバイス側(Rust / no_std / embassy): ST7789 LCD、TCA8418 キーボード、ES8311 オーディオコーデック(マイク+スピーカー)、Wi-Fi/HTTP。
-
サーバ側(Python):
faster-whisper(STT)、Anthropic SDK(Claude、公式)、macOS のsay(TTS)。
ハードのつなぎこみ:ES8311 と I2S 全二重
一番苦労したのがマイク録音です。Cardputer Adv のオーディオは ES8311 コーデックで、マイク(ADC)とスピーカー(DAC)が同じ I2S のクロック(BCLK/WS)を共有します。
罠1:MCLK が無い → 32bit フレームで帳尻を合わせる
ES8311 は通常 MCLK を要求しますが、この配線では専用 MCLK ピンが無く BCLK を MCLK 代わりに使います。最初、素直に 16bit フレームで鳴らしたらサンプルが8回ずつ繰り返される(サンプル&ホールド)状態になり、「こんにちは」が「ん」しか認識されませんでした。
解決策は I2S を 32bit スロット(Data32Channel32)にして BCLK=1.024MHz(=64×fs)を稼ぎ、ES8311 の係数を mclk=1024000 に合わせること。データは 32bit スロットの上位16bitに載るので、そこを取り出します。
// 32bit スロットの上位 16bit が有効サンプル
buf[i] = (word >> 16) as i16;
罠2:録音は「TXに無音を流しながらRXを読む」
signal_loopback を有効にすると RX(マイク)は TX(スピーカー)のクロックに従属します。つまり録音中もTXを動かしてクロックを供給し続けないと、RXが1サンプルも取れずハングします。そこで録音は「TXに無音を書きながらRXを読む」を同時実行します。
// TX(無音)でクロック供給しつつ RX(録音)を同時に回す
let joined = join(
i2s_rx.read_dma_async(rx_buf),
i2s_tx.write_dma_async(silence),
);
ハマった本命バグ:録音が「半分」になる
「音声が短い」「認識が変」と悩まされた原因がこれでした。当初、クロックを切らさないよう TX無音を RX の2倍の長さ流し、join で両方の完了を待っていました。すると——
- RX 32ms 読む間、TX は 64ms 流れる
-
joinは遅い方(TX 64ms)を待つ - 64ms かけて 32ms 分しか録れない=約半分を取りこぼし、しかも2倍に時間圧縮
音声が「早口&ブツ切り」になり、Whisper が誤認識していたわけです。
修正は単純で、TX無音を「RX+わずかな余白」に縮めるだけ。これで取りこぼしが激減し、STTの精度が一気に上がりました。
// TX を RX の2倍にしていたのをやめ、必要最小限の余白だけにする
let take = 512; // RX 512サンプル
let tx_len = take + 128; // TX はRX+余白のみ(以前は take*2)
余談:欲張って RX を 1024 サンプル(4096byte)にしたら、今度は RX の DMA ディスクリプタに収まらず read が即エラー→0サンプル。組み込みの DMA はバッファ/ディスクリプタの上限が効いてくるので、欲張りは禁物でした。
再生の罠:循環DMA は録音を殺す
再生を「途切れなく(ギャップレス)」やろうとして 循環DMA(write_dma_circular) を使ったところ、音は綺麗に出るのに——その後の録音が全滅しました。ログを仕込んで判明したのが、
- 循環DMAを停止しても I2S の TXマスタークロックが復帰しない
- 以降の録音は毎回
capture timeout→ 0 サンプル → 「2回目以降は無言」
という状態。結局、「回答音声を全部ダウンロードしてから、ワンショットのDMA書き込みをブロック単位で連続」というシンプル方式に落ち着きました。TXが通常モードに保たれるので録音が生き続けます。"かっこいい"より"壊れない" の典型でした。
RAM の壁:再生は6秒まで
回答音声はデバイス内RAMに丸ごと持ってから再生しますが、録音/再生共用の静的バッファは6秒(192KB)が内部RAMの上限付近。8秒(256KB)にするとリンク時に DRAM 超過でこけます。
error: ... cannot move location counter backwards ...
長い回答は末尾が切れてしまうため、サーバ側で「TTSが6秒を超えたら警告ログ」を出し、AIの回答も2〜3文に収めるプロンプトにしています。本格的に長文を扱うならストリーミング再生が必要です。
漢字の誤読を直す:Claude に「よみ」を出させる
macOS の say(Kyoko)は漢字や数字を誤読します(「3776m」など)。そこで Claudeに回答を2行で出させることにしました。
- 1行目:表示用(漢字かな交じり)
- 2行目:
よみ:に続く全文のひらがな読み
サーバは1行目を画面表示、2行目(かな)を音声合成します。これで「富士山」→「ふじさん」、「3776m」→「さんぜんななひゃくななじゅうろくめーとる」と正しく読み上げられます。LLMは読み仮名生成が得意なので、専用の形態素解析を入れるより手軽で確実でした。
天気に実データで答える:Claude の Tool Use
「大阪の天気は?」に答えるため、Claude にツール(get_weather) を持たせ、中身は Open-Meteo を叩く既存関数にしました。天気を聞かれるとClaudeが地名を渡してツールを呼び、実データで回答します。
tools = [{
"name": "get_weather",
"description": "指定した場所の現在の天気と今日の予報を取得する。",
"input_schema": {
"type": "object",
"properties": {"location": {"type": "string", "description": "地名(英語/ローマ字)"}},
"required": ["location"],
},
}]
ハマり:Open-Meteo のジオコーディングはローマ字しか一致しない(「大阪」✗ /「Osaka」✓)。ツールの説明で「地名は英語/ローマ字で」と指示し、Claudeにローマ字化させて解決しました。
で、どれくらい速い?レイテンシ実測
「話し終わり」から「音が鳴り始める」まで約5.7秒。各段にタイムスタンプを仕込んで内訳を出しました(Claudeはストリーミングで最初のトークンまでの時間も計測)。
| 段階 | 所要 | 備考 |
|---|---|---|
| STT(文字起こし) | 約1.3s | モデルはsmall。初回のモデルロード〜1.2sは起動時プリロードで除去 |
| Claude(最初のトークンまで) | 約1.6s | |
| Claude(生成完了) | 約1.0s | |
| TTS(音声合成) | 約0.8s | |
| 通信・画像化・音声DL | 約1.0s | |
| 合計(→再生開始) | 約5.7s | 再生自体は等速 |
- 最大要因はクラウド往復(Claude)。デバイス側の処理は軽いです。
- STTは日本語だと
smallが精度の下限で、tiny/baseは3〜5倍速いものの「日本一高い山は」→「日本に近くは邪魔は」と誤認識、実用外でした。 - 天気質問はツール往復でClaude呼び出しが2回になるぶん、さらに遅くなります。
まとめ
- Cardputer Adv(ESP32-S3)+ Rust no_std + embassy で、音声↔AI対話を単一ファームで実現できました。
- 一番の学びは組み込みらしい部分——I2S全二重のクロック供給、DMAの上限、RAM制約、"壊れない"再生方式。派手なところより地味なところでハマります。
- 課題は往復レイテンシ(数秒)。会話としてはもう一歩で、体感を詰めるには通信・LLM側の工夫が要ります。
本格的に進めるなら、より高性能な上位機(Cardputer Zero など)や、オンデバイス推論・ストリーミング再生の採用を検討するのが良さそう、というのが今回の結論です。とはいえ「手のひらのマイコンに話しかけたら答えが返ってくる」体験は、作っていて素直に楽しかったです。
- リポジトリ(MIT): https://github.com/iwan-inc/cardputer-rust
- 最初のとっかかり: https://note.com/mackey1966/n/nf3173ae7d683
最後まで読んでいただきありがとうございました。同じような組み込み×AIの工作をしている方の参考になれば嬉しいです。