XIAO ESP32-C6とHermes Agentで作る音声対話IoTデバイス

XIAO ESP32-C6、SparkFun WM8960 Audio Codec Breakout、ESP-SRのWakeNet9sとAFE、FastAPI、faster-whisper、Hermes Agent、Edge TTSで構成した小型スマートスピーカーです。
最終的には16 kHz、16 bit、モノラルPCMを録音しながらHTTPのチャンク転送で送り、録音終了後にサーバーがWAVヘッダーを確定し、生成したWAVを端末へ返す構成になりました。今回の製作は、アナログ入力からエージェントの回答までにある境界を、一つずつ観測可能にする作業でした。
完成したデバイスの動作は次の動画で確認できます。
1. 完成した構成
端末の状態機械はWAIT_WAKE、WAIT_SPEECH、RECORDING、POSTING、PLAYBACKの5状態に分けました。録音が長い問題と、サーバー応答後に再生されない問題を、同じ「音声が返らない」という現象のまま調べずに済みます。
エッジ端末はマイク入力、ウェイクワード、終話判定、音声転送、応答再生を担当します。FastAPI製の音声ゲートウェイは端末認証、WAV受信、STT、Hermes Agent呼び出し、TTS、応答形式の統一を受け持ちます。Hermes Agent側にはモデル選択、会話、メモリ、スキル、ツール実行を残しました。
ESP32-C6にWhisperやエージェント本体を載せずサーバーへ分離したのは、計算量だけが理由ではありません。モデルやTTSを交換しても端末の通信契約を変えずに済み、外部サービスの認証情報もデバイスへ詰め込まずに済みます。
2. PSRAMなしのXIAO ESP32-C6で音声を扱う
制御側にはSeeed Studio XIAO ESP32-C6を使用しました。公式仕様では21 × 17.8 mmの基板に、最大160 MHzの高性能RISC-Vコア、低消費電力RISC-Vコア、512 KB SRAM、4 MBフラッシュ、2.4 GHz Wi-Fi 6、Bluetooth LE、IEEE 802.15.4無線を収めています。
今回使ったボードには外付けPSRAMがありません。音声バッファ、ESP-SR、TLSが内部SRAMを共有するため、録音全体をメモリへ持つ方式は早い段階で限界に達しました。
ファームウェアでは4 MBフラッシュを次のように分割しました。
| パーティション | オフセット | サイズ | 用途 |
|---|---|---|---|
| NVS | 0x9000 | 0x6000 | ESP-IDFの不揮発設定 |
| PHY init | 0xf000 | 0x1000 | 無線PHY初期化データ |
| factory | 0x10000 | 0x200000 | アプリケーション |
| model | 0x210000 | 0x1f0000 | ESP-SRのWakeNetモデル |
wn9s_hijasonをアプリへリンクするだけではなく、モデルパーティションも実機へ書き込みます。アプリだけを書き換えてモデル領域が古いままだと、選択したウェイクワードと実際のモデルが一致しません。
3. WM8960の配線とクロック
音声入出力にはSparkFun WM8960 Audio Codec Breakoutを使いました。WM8960はマイク入力用ADC、再生用DAC、入力PGA、ミキサー、Class-Dスピーカードライバーを備え、レジスタ設定にはI2C、PCM転送にはI2Sを使います。SparkFunの資料では、スピーカー出力は8 Ω負荷へ最大1 Wで、デジタル側の3V3とアナログ・スピーカー系のVINは別の電源ネットとして説明されています。
| XIAO端子 | GPIO | WM8960 | 役割 |
|---|---|---|---|
| D4 | GPIO22 | SDA | I2Cデータ |
| D5 | GPIO23 | SCL | I2Cクロック |
| D6 | GPIO16 | BCLK | I2Sビットクロック |
| D7 | GPIO17 | LRCLK / WS | I2S左右チャネルクロック |
| D8 | GPIO19 | DACDAT / DIN | ESP32からWM8960への再生データ |
| D9 | GPIO20 | ADCDAT / DOUT | WM8960からESP32への録音データ |
| 3V3 | 3V3 | デジタル電源 | |
| GND | GND | 共通基準電位 |
電源は使用するボードの版とジャンパー構成に合わせてVINも配線します。GPIO番号とXIAO基板上のD番号は一致しないため、シルクだけでなくGPIO番号で照合しました。
使用したWM8960ボードには24 MHz発振器があり、その信号をMCLKとして使いました。ESP32側はMCLKをI2S_GPIO_UNUSEDにし、別のMCLKを重ねません。WM8960内部では24 MHzを2分周してPLLへ入れ、N.K=8.192で98.304 MHzを作り、固定8分周後のSYSCLKを12.288 MHzにします。
WM8960はI2Sスレーブ、ESP32-C6はI2Sマスターとし、Philips I2S、16 bit、物理2スロットで動かしました。論理音声はモノラルなので、受信時は左スロットだけを取り出し、送信時は同じサンプルを左右へ複製します。
samples[done + i] = stereo[2u * i];
stereo[2u * i] = samples[done + i];
stereo[2u * i + 1u] = samples[done + i];
4. ESP-SRが動いているように見えて動かなかった
ファームウェアはESP-IDF 5.4.2で構築し、ESP-SR 2.4.0を固定導入しました。WakeNet9sはESP32-C6をサポートしていますが、「モデルをロードできた」と「モデルへ音声が流れた」は別の成功条件でした。
最初の実機ではマイクの自己テストが通り、wn9s_hijasonもロードされているのに、「Hi Jason」を一度も検出しませんでした。約35秒のログにはAFE feed failedが1,120回並び、fetch()、VAD、WakeNetのイベントはゼロでした。
4.1. feed()の戻り値はエラーコードではない
一つ目の原因は、ESP-SRのfeed()戻り値をesp_err_tだと思い、ESP_OKの0と比較したことでした。実際のAPIは入力サイズを返します。512サンプルなら、16 bit PCMなので正常値は1,024バイトになります。
const int expected_feed_bytes = samples * sizeof(*mono);
const int feed_bytes = afe->feed(data, mono);
if (feed_bytes != expected_feed_bytes) {
return ESP_FAIL;
}
4.2. AFEの割り当て先を内部RAMへ固定する
戻り値の扱いを直してもfeed()は-1を返し続けました。次の原因は、AFEがPSRAMを使うメモリ割り当てを選び、PSRAM非搭載のESP32-C6で内部リングバッファを作れなかったことでした。
cfg->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_INTERNAL;
修正後はfeed failedとfetch failedが0件になり、毎秒約31〜32フレームを処理しました。WakeNet検出とWAKE_ACCEPTEDも各2回確認できました。
[:alert type=“warning” title=“ESP-SRのfeed()戻り値に注意”]
feed()はesp_err_tではなく、入力PCMのバイト数を返します。0を成功と判定せず、今回の512サンプルなら1,024バイトという期待値と比較します。
5. クリッピングはVADより前に直す
録音にはWM8960の左入力経路を使い、LINPUT1からPGAとブーストミキサーを通して左ADCへ入れました。
初期値のPGAは約+30 dBで、実機ログでは起動時に3.5%、ウェイク後には最大18.6%のサンプルがクリップしていました。PGAを+6 dBへ下げ、入力ブーストを+0 dB、ADCデジタル音量を0 dBにすると、起動時のクリッピングは0まで下がりました。
VADやWhisperの調整を始める前に、ADCへ入る波形を健全にしておく必要があります。再生側のDAC音量と録音側のPGAは別レジスタですが、筐体内ではスピーカー音がマイクへ回り込みます。レジスタ上の独立と音響上の独立は同じではありません。
6. 録音時間を延ばすにはストリーミング化が必要でした
16 kHz、16 bit、モノラルPCMは1秒あたり32,000バイトになります。4秒なら128,000バイトですが、30秒では960,000バイトに達します。録音全体、TLS、HTTP、AFE、再生用バッファを同時に持つ設計では、単なる定数変更では収まりません。
そこで録音完了後にPOSTする方式をやめ、録音フレームをFreeRTOSのStreamBufferへ積み、低優先度のアップロードタスクが並行して送る構成へ変えました。
#define RATE 16000U
#define MAX_MS 10000U
#define MAX_SAMPLES (RATE * MAX_MS / 1000U)
#define UPLOAD_QUEUE_BYTES 65536U
最終仕様では録音上限を10秒へ戻しましたが、それでもPCMは320,000バイトになります。64 KiBのキューは、TLS書き込みの短い停滞を吸収しながら、後段の再生用連続メモリも残す妥協点でした。
6.1. 長さ未確定のWAVを送る
通常のRIFF/WAVヘッダーにはファイルサイズとdataチャンクサイズが必要ですが、録音中は最終長が分かりません。端末は両サイズを0にした仮ヘッダーを先頭へ送り、その後へPCMを続けます。HTTPリクエストはContent-Lengthを持たないTransfer-Encoding: chunkedで送信しました。
FastAPIのrequest.stream()から得るのは、ASGIがHTTPチャンクの枠を除去した後のWAVバイト列です。分割位置はRIFFチャンク境界と一致しないため、受信断片ごとにWAVを解析してはいけません。全バイトを一時ファイルへ書き、受信完了後に実サイズからRIFFサイズとPCMサイズを確定します。
7. ウェイクワードを認識対象から外す
WakeNetは検出時点より前の音声もAFE内部に保持しています。初期実装では約500 msのプリロールを録音先頭へ加えましたが、「Hi Jason」の後半が認識対象へ入り、文字起こしの先頭に「ジェイソン」が現れました。
ログ上のwake_word_lengthは0で、ウェイクワード長に合わせた動的な切り落としも使えませんでした。そこでアップロード用プリロールを0 msにしました。
#define UPLOAD_PRE_ROLL_MS 0U
これはすべての製品でプリロールを捨てるべきという意味ではありません。ウェイク検出用の履歴と、音声認識へ送る区間を別々に設計した結果です。
8. VADだけでは録音が終わらない
WebRTC VADをモード3、最小発話時間192 ms、最小無音時間300 ms、アプリ側の終話待ち800 msに設定しました。それでもファン音、机から伝わる振動、マイクの自己雑音が連続音声に近く見えると、VADがSPEECHから戻りません。
VADを厳しくするだけでは小声や語尾まで切り捨てやすくなります。そこでPCMフレームの平均絶対値を計算する音量ゲートを併用しました。
mean_abs = (|x[0]| + |x[1]| + ... + |x[N-1]|) / N
既定の閾値は512で、mean_abs < 512ならゲート上は無音とします。VADまたは音量ゲートのどちらかが無音を800 ms連続して示した時点で録音を終えます。
const bool gate_silent = pcm_level < CONFIG_SMARTSPEAKER_PCM_GATE_THRESHOLD;
const bool end_silence = !result.speech || gate_silent;
実機ログでは次の結果を得ました。
state=RECORDING pre-roll=0 ms pcm_gate_threshold=512
WAKENET_EVENT VAD SPEECH->SILENCE
state=POSTING samples=146432 vad_silent=1 pcm_mean_abs=245 gate_silent=1
chunked stream upload complete pcm=292864 bytes duration_ms=9152
146,432サンプルは16 kHzで9.152秒に相当し、10秒の強制上限より前にPOSTへ移っています。ただし同じ行でvad_silent=1でもあります。このログだけでは「PCMゲートだけが終了を決めた」とは証明できません。ゲート単独の効果を確認するには、VADがSPEECHのまま、gate_silent=1が800 ms続いて終了した試行が必要になります。
9. HTTP受信とI2S再生を別タスクにする
HTTP 200と応答WAVを受け取った時点でも、音声は滑らかに再生できませんでした。HTTP受信、WAV解析、I2S書き込みを一つのタスクで直列実行すると、ネットワークから次の断片が来ない間にDMAが空になります。
I2S DMAは6ディスクリプタ、各240フレームで、16 kHz換算では合計約90 msしか吸収できません。ログには再生開始時13,234バイト、約0.41秒分を蓄積していたにもかかわらず、アンダーランが308回記録されました。
HTTP側は受信PCMをStreamBufferへ投入し、再生専用タスクが一定速度でI2Sへ供給する構成へ変更しました。再生開始前に一定量をプリバッファすれば、短いネットワーク停滞はバッファ内のPCMで吸収できます。
最初の再生タスクはスタックを2,048バイトしか持たず、bytes[1024]とmono[513]などのローカル配列でStack protection faultを起こしました。4,096バイトへ増やした後はクラッシュが止まりました。
10. ESP_ERR_NO_MEMは最大連続領域で判断する
再生開始時にはrequest/playback failed: ESP_ERR_NO_MEMも発生しました。録音をストリーミング化しても、アップロード用キュー、TLS内部領域、タスクスタック、再生StreamBufferが一時的に重なるためです。
空きヒープ合計が要求サイズを超えていても、連続ブロックが足りなければ確保は失敗します。アップロードタスクと録音領域を解放してから再生バッファを確保し、98,304、65,536、32,768バイトの順で縮退させました。
playback buffer allocation failed bytes=98304 free_heap=170084 largest_block=73728
playback buffer ready bytes=65536 prebuffer=32768 free_heap=101720
空きヒープは170,084バイトありましたが、最大連続ブロックは73,728バイトでした。98,304バイトの要求は失敗し、65,536バイトなら収まりました。
playback consumer started buffered=35762 bytes
playback output enabled after first PCM block
playback complete samples=182400 underruns=0
最終的には11.4秒分の応答音声をアンダーランなしで再生できました。最初のPCMブロックをI2Sへ書いた後でDACをアンミュートし、先頭欠けやクリック音も避けています。
11. Hermes Agentの前に音声ゲートウェイを置く
ESP32が送るのはWAVですが、Hermes AgentのOpenAI互換APIへ送るのはテキストです。Linuxサーバー上にFastAPI製の音声ゲートウェイを置き、faster-whisper、Hermes Agent、Edge TTS、ffmpegをつなぎました。
外部へ公開するのは音声ゲートウェイだけで、Hermes Agent APIは127.0.0.1:8642へ束縛します。
11.1. Hermes Agentを単体で確認する
2026年9月1日時点の公式クイックスタートでは、LinuxのCLI版を次のインストーラーで導入できます。
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
source ~/.bashrc
hermes setupまたはhermes modelでプロバイダーとモデルを設定し、まず通常のチャットが一往復できることを確認します。OpenAI互換APIサーバーは~/.hermes/.envで有効化します。
API_SERVER_ENABLED=true
API_SERVER_KEY=十分に長いランダム値
hermes gateway
curl -sS http://127.0.0.1:8642/health
curl -sS http://127.0.0.1:8642/v1/models \
-H "Authorization: Bearer $API_SERVER_KEY"
既定の待受先は127.0.0.1:8642です。同一ホストの音声ゲートウェイからだけ使うなら、API_SERVER_HOSTを0.0.0.0へ変える必要はありません。
会話の識別にはX-Hermes-Session-Keyを使います。同じ音声チャネルを長期メモリ上の安定した範囲へ結び付けるキーであり、複数端末で一つの値を共有せず、端末の実IDを直接露出しない別名に分けます。
受信WAVはfaster-whisperで文字起こしし、認識文をHermes Agentの/v1/chat/completionsへ送ります。返答をEdge TTSでMP3へ変換し、ffmpegで16 kHz、16 bit、モノラルPCM WAVへそろえてESP32へ返します。
Whisperとffmpegは同期処理なのでasyncio.to_thread()へ移しました。Hermes AgentへのHTTP呼び出しはhttpx.AsyncClientを使い、今回の構成では180秒のタイムアウトを設定しています。端末側だけ300秒へ伸ばしても、途中のプロキシが60秒で閉じれば応答は届きません。
12. HTTPステータスとプロキシエラーを同じ障害として追わない
端末からは504に見える場面がありましたが、FastAPIのローカルポートへ同じWAVを送ると、93,604バイトを受信した直後に400を返していました。レスポンス本文は次のとおりでした。
{"detail":"Invalid WAV: canonical data chunk not found"}
旧実装はheader[36:40] == b"data"を要求し、44バイト固定のWAVだけを受け付けていました。しかしRIFF/WAVにはLIST、JUNK、factなどの追加チャンクが入り、dataが必ずオフセット36にあるとは限りません。
修正版では12バイトのRIFF/WAVEヘッダーを検証した後、各チャンクのIDとサイズを読み、偶数境界のパディングを考慮してfmt とdataを探索します。非圧縮PCM、16 kHz、16 bit、モノラル、block_align=2、byte_rate=32000も個別に検証しました。
調査時は同じ既知WAVを、Uvicornのローカルポートと公開HTTPSへ順に送ります。
time curl -v \
http://127.0.0.1:8080/api/voice-stream \
-H "X-Device-Key: $VOICE_DEVICE_KEY" \
-H "Content-Type: audio/wav" \
--data-binary @test.wav \
--output /tmp/response-local.wav
time curl -v \
https://voice.example.com/api/voice-stream \
-H "X-Device-Key: $VOICE_DEVICE_KEY" \
-H "Content-Type: audio/wav" \
--data-binary @test.wav \
--output /tmp/response-public.wav
| ローカル | 公開URL | 次に調べる層 |
|---|---|---|
| 200 | 200 | 端末側のHTTP応答処理と再生 |
| 200 | 504 | リバースプロキシと上流タイムアウト |
| 遅い、または失敗 | 504 | FastAPI、Whisper、Hermes Agent、TTS |
| 4xx | 4xx | 認証、Content-Type、サイズ制限、WAV検証 |
ローカルでも400だった今回の事例では、プロキシを調べ続けても原因には届きませんでした。
13. ログを読む順序
障害時は次の境界を順番に確認します。
- マイクから非ゼロのPCMが届いているか。
- WakeNetが検出し、状態が
RECORDINGへ移ったか。 - VADとPCMゲートの値が発話中と無音時で分離しているか。
- 録音サンプル数と送信PCMバイト数が
bytes = samples × 2で一致するか。 - サーバー受信長がWAVヘッダー44バイトとPCMバイト数の合計に一致するか。
- WAV確定、STT、エージェント、TTS、変換のどこまで到達したか。
- HTTPステータス、Content-Type、Content-Length、応答バイト数が端末の契約を満たすか。
- 再生バッファ、プリバッファ、アンミュート、アンダーランを確認する。
サーバーログにはrequest_id、受信バイト数、チャンク数、音声時間、WAV確定時間、STT時間、エージェント時間、TTS時間、総処理時間、HTTPステータス、応答バイト数を残します。音声全体、認証キー、Bearerトークン、完全な文字起こしは通常ログへ残しません。
14. 最後に効いたのは境界ごとの数でした
録音時間、転送方式、WAV形式、メモリ、終話判定は独立していません。録音上限を延ばせばPCMが増え、ストリーミング化すればWAV長が未確定になり、キューを増やせば再生用の連続メモリが減ります。VADを厳しくすれば雑音には強くなりますが、小声を切りやすくなります。
端末ではサンプル数、平均絶対値、最大連続ヒープ、再生アンダーランを数え、サーバーでは受信バイト数、WAVチャンク、各処理時間、HTTP境界を数えました。数がそろうと、504に見えた問題がWAVの固定オフセット判定だったことも、170 KiB空いているのに98 KiBを確保できない理由も説明できます。
残っている課題は、PCMゲート単独で終話したログの採取と、同じ発話セットを使った文字誤り率・終話欠け率の比較です。16 kHz、16 bit、モノラルから先に解像度を上げるより、入力ゲイン、マイク位置、電源ノイズ、筐体共振、無音区間の品質を測る方が、今回の構成では改善点を切り分けやすくなります。
15. 参考資料
- Getting Started with Seeed Studio XIAO ESP32-C6
- SparkFun Audio Codec Breakout WM8960
- ESP-IDF v5.4.2 Programming Guide
- ESP-SR v2.4.0 Component Registry
- Hermes Agent Quickstart
- Hermes Agent API Server
実測値とログは、今回使用したESP32-C6ファームウェア、monitor.log、FastAPIサーバーソース、および添付の開発記録に基づいています。
16. FAQ
- どのハードウェアとソフトウェアを組み合わせていますか?
- XIAO ESP32-C6、SparkFun WM8960 Audio Codec Breakout、ESP-SRのWakeNet9sとAFE、FastAPI、faster-whisper、Hermes Agent、Edge TTS、ffmpegを組み合わせています。
- XIAO ESP32-C6に外付けPSRAMはありますか?
- 今回使用したボードには外付けPSRAMがありません。音声バッファ、ESP-SR、TLSが内部SRAMを共有するため、録音全体をメモリへ保持せずストリーミング化しました。
- ESP-SRの
feed()は何を返しますか? esp_err_tではなく、入力PCMのバイト数を返します。512サンプルの16 bit PCMなら、正常値は1,024バイトです。- WakeNetがウェイクワードを検出しなかった原因は何ですか?
feed()の戻り値をESP_OKの0と比較していたことと、AFEがPSRAMを使う割り当てを選んでいたことが原因でした。戻り値を入力サイズとして扱い、AFEの割り当て先を内部RAMへ固定すると検出できました。- 録音時のクリッピングはどのように抑えましたか?
- WM8960のPGAを約+30 dBから+6 dBへ下げ、入力ブーストを+0 dB、ADCデジタル音量を0 dBにしました。起動時のクリッピングは実機ログで0まで下がりました。
- 録音をストリーミング化した理由は何ですか?
- 16 kHz、16 bit、モノラルPCMは1秒あたり32,000バイトになり、30秒では960,000バイトに達するためです。録音全体とTLS、AFE、再生バッファを同時に保持する方法では内部RAMに収まりませんでした。
- 録音データを送るキューのサイズはいくつですか?
- 最終構成では64 KiBのStreamBufferを使いました。TLS書き込みの短い停滞を吸収しながら、再生用の連続メモリを残す妥協点です。
- 文字起こしにウェイクワードが混ざったのはなぜですか?
- WakeNetが検出前の音声をAFE内部に保持するため、約500 msのプリロールを付けると「Hi Jason」の後半まで認識対象に入りました。アップロード用プリロールを0 msにし、検出用履歴と認識用区間を分けました。
- VADだけで録音を終えられなかったのはなぜですか?
- ファン音、机の振動、マイクの自己雑音が連続音声に近く見え、VADが
SPEECHから戻らない場合があったためです。WebRTC VADに加えて、平均絶対値512未満を無音とするPCMゲートを併用しました。 - PCMゲートだけで終話したことは証明できていますか?
- まだ証明できていません。
vad_silent=1とgate_silent=1が同じログ行に出ただけなので、VADがSPEECHのままゲート無音が800 ms続いて終了する試行が必要です。 - HTTP 504の原因はプロキシでしたか?
- 端末からは504に見えましたが、同じWAVをFastAPIのローカルポートへ送ると、93,604バイト受信後に400と
Invalid WAV: canonical data chunk not foundを返しました。まずローカルと公開URLを分けて確認する必要があります。 - WAVを44バイト固定で解析してはいけないのはなぜですか?
- RIFF/WAVには
LIST、JUNK、factなどの追加チャンクが入るため、dataがオフセット36にあるとは限りません。RIFF/WAVEヘッダーの後に各チャンクを走査し、fmtとdataを見つけます。 - 空きヒープがあるのに
ESP_ERR_NO_MEMになったのはなぜですか? - 確保には合計空き容量だけでなく、要求サイズ以上の連続ブロックが必要です。ログでは空きヒープ170,084バイトに対して最大連続ブロックが73,728バイトしかなく、98,304バイトの要求に失敗しました。
- 再生のアンダーランをどのように解消しましたか?
- HTTP受信とI2S書き込みを別タスクに分け、65,536バイトの再生バッファへ32,768バイトをプリバッファしてから出力を開始しました。11.4秒の応答音声をアンダーラン0回で再生できました。
- Hermes Agent APIを安全に公開するにはどうしますか?
- Hermes Agent APIは
127.0.0.1:8642へ束縛し、loopback運用でもAPI_SERVER_KEYを必須にします。外部公開が必要な場合は、音声ゲートウェイやリバースプロキシ側で認証、許可範囲、TLSを分離します。 - 障害調査で最低限残すログは何ですか?
request_id、受信バイト数、チャンク数、音声時間、WAV確定時間、STT・エージェント・TTSの処理時間、HTTPステータス、応答バイト数を残します。音声全体、認証キー、Bearerトークン、完全な文字起こしは通常ログへ残しません。





