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

XIAO ESP32-C6とWM8960を使った音声対話デバイスのイラスト

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を端末へ返す構成になりました。今回の製作は、アナログ入力からエージェントの回答までにある境界を、一つずつ観測可能にする作業でした。

完成したデバイスの動作は次の動画で確認できます。

この記事の要点

今回の解決の軸は、AFEへ渡した入力サイズの確認、PSRAMなしを前提にしたストリーミング化、RIFFチャンクの正規解析、ローカルと公開URLを分けた計測です。

1. 完成した構成

端末の状態機械はWAIT_WAKEWAIT_SPEECHRECORDINGPOSTINGPLAYBACKの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フラッシュを次のように分割しました。

パーティションオフセットサイズ用途
NVS0x90000x6000ESP-IDFの不揮発設定
PHY init0xf0000x1000無線PHY初期化データ
factory0x100000x200000アプリケーション
model0x2100000x1f0000ESP-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端子GPIOWM8960役割
D4GPIO22SDAI2Cデータ
D5GPIO23SCLI2Cクロック
D6GPIO16BCLKI2Sビットクロック
D7GPIO17LRCLK / WSI2S左右チャネルクロック
D8GPIO19DACDAT / DINESP32からWM8960への再生データ
D9GPIO20ADCDAT / DOUTWM8960からESP32への録音データ
3V33V3デジタル電源
GNDGND共通基準電位

電源は使用するボードの版とジャンパー構成に合わせて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 failedfetch 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サイズを確定します。

WAVを44バイト固定で判定しない
RIFF/WAVのdataチャンクは、LISTJUNKなどの追加チャンクによって位置がずれます。12バイトのRIFF/WAVEヘッダーから各チャンクを走査し、fmt dataを見つけてください。今回の400は、この固定オフセット判定が原因でした。

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をアンミュートし、先頭欠けやクリック音も避けています。

再生側の到達点

再生タスクを分離し、65,536バイトのバッファと32,768バイトのプリバッファを使うことで、11.4秒の応答音声をアンダーラン0回で再生できました。DACは最初のPCMブロックを書き込んだ後にアンミュートします。

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へ束縛します。

Hermes Agent APIを直接公開しない
Hermes AgentのAPIサーバーは、設定によっては端末操作やファイルアクセスを含むツールへ到達できます。API_SERVER_KEYはloopback運用でも必須にし、外部公開が必要な場合も音声ゲートウェイやリバースプロキシ側で認証・許可範囲・TLSを分離します。

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_HOST0.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にはLISTJUNKfactなどの追加チャンクが入り、dataが必ずオフセット36にあるとは限りません。

修正版では12バイトのRIFF/WAVEヘッダーを検証した後、各チャンクのIDとサイズを読み、偶数境界のパディングを考慮してfmt dataを探索します。非圧縮PCM、16 kHz、16 bit、モノラル、block_align=2byte_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次に調べる層
200200端末側のHTTP応答処理と再生
200504リバースプロキシと上流タイムアウト
遅い、または失敗504FastAPI、Whisper、Hermes Agent、TTS
4xx4xx認証、Content-Type、サイズ制限、WAV検証

ローカルでも400だった今回の事例では、プロキシを調べ続けても原因には届きませんでした。

13. ログを読む順序

障害時は次の境界を順番に確認します。

  1. マイクから非ゼロのPCMが届いているか。
  2. WakeNetが検出し、状態がRECORDINGへ移ったか。
  3. VADとPCMゲートの値が発話中と無音時で分離しているか。
  4. 録音サンプル数と送信PCMバイト数がbytes = samples × 2で一致するか。
  5. サーバー受信長がWAVヘッダー44バイトとPCMバイト数の合計に一致するか。
  6. WAV確定、STT、エージェント、TTS、変換のどこまで到達したか。
  7. HTTPステータス、Content-Type、Content-Length、応答バイト数が端末の契約を満たすか。
  8. 再生バッファ、プリバッファ、アンミュート、アンダーランを確認する。

サーバーログには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. 参考資料

実測値とログは、今回使用した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=1gate_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にはLISTJUNKfactなどの追加チャンクが入るため、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トークン、完全な文字起こしは通常ログへ残しません。