0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

M5Stack Cardputer Adv を Rust(no_std) で「音声AIアシスタント」にしてみた

0
Last updated at Posted at 2026-08-18

はじめに

手のひらサイズのキーボード付きマイコン 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 など)や、オンデバイス推論・ストリーミング再生の採用を検討するのが良さそう、というのが今回の結論です。とはいえ「手のひらのマイコンに話しかけたら答えが返ってくる」体験は、作っていて素直に楽しかったです。

最後まで読んでいただきありがとうございました。同じような組み込み×AIの工作をしている方の参考になれば嬉しいです。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?