StackChanの音声チャットをESP-IDFでビルドし直してTailscaleで直結した
目次
前回は、CoreS3単体の確認用プログラムでTailscaleの直接UDPを通し、音声サーバーから同じWAVを平均約2.4秒で取れるようになった。
中継サーバー(DERP)経由だけの約9秒から、だいぶ短くなった。
ただ、StackChan Bodyで動かしている音声チャットの本体は、まだVPSのPHP中継を通している。
確認用プログラムはESP-IDF、音声チャットはArduinoの書き方で、ビルドの仕組みが別になっているため、音声チャットのほうにTailscaleを組み込んで、中継なしで話せるのか試してみた。
検証環境
| 項目 | 使用した環境 |
|---|---|
| 実機 | M5Stack CoreS3(ESP32-S3、16MiBフラッシュ、8MiB PSRAM)、StackChan Body |
| 作業用PC | Windows、Tailscale 1.102.4 |
| ビルド | ESP-IDF 5.5.4、Dockerイメージ espressif/idf:v5.5.4 |
| Arduino部分 | arduino-esp32 3.3.10(ESP-IDFのコンポーネントとして使用) |
| ライブラリ | M5Unified 0.2.17、M5GFX 0.2.24、StackChan-BSP 1.1.0 |
| 接続先 | 自宅の音声サーバー。前回と同じ |
Arduino環境でTailscaleを動かせない原因
音声チャットは、arduino-cliでesp32コア3.3.10を使ってビルドしている。
Arduinoのビルドは、ESP-IDFをあらかじめビルドした固定の設定(sdkconfig)に基づいて動いている。
前回直したTailscaleの部品は、lwIPやTLSの内部設定に依存している。
lwIPのコアロック(排他制御)やTLSのバッファの扱いがそうで、固定のsdkconfigでは前回の設定を再現できなかった。
そこで構成を逆にした。
音声チャット全体をESP-IDFでビルドし、ArduinoはESP-IDFの部品(arduino-esp32コンポーネント)として載せる構成にした。
Espressifが用意しているやり方で、音声チャットのコードはほとんどそのまま使えた。
音声チャットをESP-IDFのプロジェクトにする
音声チャットの .ino を main.cpp にして、先頭に #include <Arduino.h> を足しただけで持ってきた。
顔の画像や待ち時間用の音声を埋め込んだヘッダー、アプリ領域を6MBにしたパーティション表もそのまま使った。
ライブラリはプロジェクトの components/ に置いた。
| ライブラリ | 取り込み方 |
|---|---|
| M5Unified 0.2.17、M5GFX 0.2.24 | ローカルのArduinoライブラリをコピー。どちらもCMakeLists.txtに「arduino-esp32を使うなら有効に」という行があり、有効にした |
| StackChan-BSP 1.1.0 | ESP-IDF用のファイルがないのでCMakeLists.txtを書いた。赤外線やNFCのライブラリへの依存が書かれているが、ソースは読み込んでいない |
| Tailscale、WireGuard | 前回の確認用プログラムからコピー |
ボードの設定は、arduino-cliでCoreS3を選んだときのコンパイルオプションに合わせた。
USBシリアルの割り当て(ARDUINO_USB_MODE=1、ARDUINO_USB_CDC_ON_BOOT=1)とPSRAMを有効化し、接続はQSPIにした。
ESP-IDF 6.0でのビルドエラー
前回の確認用プログラムはESP-IDF 6.0だったので、まずは6.0で組んだ。
arduino-esp32 3.3.10の定義上は、6.0も対応範囲(5.3以上6.1未満)に入っている。
| 止まった場所 | 内容 | 対処 |
|---|---|---|
| CMake | コンポーネント版のarduino-esp32にCoreS3用のピン定義フォルダーがない | ピン定義は音声チャットもライブラリも使っていないので、汎用のESP32-S3の定義にした |
| コンパイル | 6.0のヘッダーがC++で警告を出し、ESP-IDFの既定では警告がエラーになる | 警告をエラーにしない設定にした |
| arduino-esp32のSDライブラリ | 6.0で変わったFATのAPIと型が合わない | 音声チャットはSDを使わないので、使うライブラリだけビルドする設定にした |
| M5Unified | 6.0で音声入出力(I2S)の定義が変わり、ポート番号の書き方が通らない | 直すと次々に別のエラーが出た |
使うライブラリだけビルドする設定は、全部のライブラリが既定で有効になっていて、要らないものを1つずつ無効にする作りだった。
SDなど26個を無効にして、arduino-esp32自体は通った。
M5Unifiedを0.2.23へ上げるとマイクとスピーカーの挙動も変わるため、音声チャット側で作った録音の調整が崩れるのを避けた。
ローカルのarduino-cliにあるesp32コア3.3.10のビルド済みライブラリを確認すると、ESP-IDF 5.5.4で作られていた。
Arduino・M5側は5.5.4で動いている実績があるので、全体を5.5.4に下げて、Tailscaleの部品のほうを合わせることにした。
ESP-IDF 5.5.4で通すまで
Tailscaleの部品が使っているのは、ESP-IDFのTLS、証明書バンドル、lwIP、HTTPクライアントくらいで、暗号ライブラリを直接は呼んでいない。
6.0にしかない部品の指定を1つ外すと、Arduino・M5・StackChan-BSPはそのまま通った。
| 止まった場所 | 原因 | 対処 |
|---|---|---|
| Tailscale | 6.0はCをC23でビルドし、bool が組み込みの型。5.5はC17 | bool を使うヘッダー3つに <stdbool.h> を追加 |
| 音声チャット本体 | arduino-esp32の WiFi のヘッダーが見つからない | 本体の依存にWi-Fiとネットワークの部品を追加 |
| 音声チャット本体 | WiFi のヘッダーが、無効にしたWi-Fi設定用ライブラリの部品を参照している | そのライブラリは有効に戻した |
| リンク | HTTPS用クライアントの関数が未定義 | 暗号ライブラリの事前共有鍵(PSK)の設定が無効だと中身が空になる。Arduinoの設定と同じく有効にした |
Arduinoの esp32s3 向け設定を確認すると、lwIPのコアロックも有効になっていた。
前回、直接UDPでデータが届いた瞬間にlwIPの処理が固まった原因がこのロックで、Arduinoと組み合わせる場合にも前回の修正を入れた。
アプリは4,113,680バイトで、arduino-cliでのビルド(4,183,715バイト)とほぼ同じ大きさになった。
ビルドを変えても元と同じに動くか
先に、Tailscaleを入れていない状態で書き込んだ。
顔の5表情の展開、保存済みの音量、Wi-Fi、頭上タッチの基準取りまで、元のArduino版と同じ流れで起動した。
会話はシリアルから確かめた。
recnow 3 は3秒録音してそのまま会話を1往復するテスト用のコマンドで、誰も話していないので「話していない」という返事(no_speech)が返ってくる想定だ。
VPS中継へ96,187バイトを送って782msでHTTP 200が返り、ポーリングで no_speech が届いた。
ビルドを切り替えても、中継経由の会話はそのまま動いた。
起動シーケンスの追加と直結
起動直後は、Wi-Fiが切れてつなぎ直したり、音声サーバーとの最初のハンドシェイクに数秒かかったりする。
その間に話しかけると遅いか失敗するので、準備が終わるまでは顔を出さないで、受け付けないようにした。
| 段階 | 待つ条件 | 上限 |
|---|---|---|
| Wi-Fi | 3秒続けてつながっている | 30秒 |
| 時刻合わせ | NTPで時刻が取れる | 30秒 |
| Tailscale | 承認が済み、Tailscale上のIPアドレスが割り当てられる | 120秒 |
| 音声サーバー | 状態確認用のURLが200を返す。最初の要求でWireGuardのハンドシェイクも完了する | 60秒 |
画面には「起動中」と今の段階を出すようにした。
どこかで失敗したら、今までのVPS中継に切り替えて起動する。
Tailscaleの端末名は前回と同じにしたので、フラッシュに残った鍵でブラウザの承認なしに参加できた。
最初の起動では、音声サーバーの段階でシリアルの出力が止まった。
PCから tailscale ping するとCoreS3は直接UDPで応答していて、本体は動いていた。
ESP-IDFのログを、Arduinoの Serial と同じUSBシリアルへ出す設定にしていたので、取り合っていたのだと思う。
Arduinoの esp32s3 向け設定と同じく、ログの出力先をUART0にしてUSBシリアルをサブの出力先にしたら、止まらなくなった。
このあとの起動は、Wi-Fiから音声サーバーまでの4つの段階を経て26.8秒で直結になった。
同じく recnow 3 を送ると、直結で1,580ms、次の起動では474msでHTTP 200が返った。
どちらも1回ずつのため、中継との差があるかはまだ分からない。
Wi-Fiタイムアウト時の再試行
ある起動では、Wi-Fiが30秒でつながらずにVPS中継へ切り替わり、その30秒後にやっとつながった。
Arduino版を作っていたころにも、起動直後のWi-Fi接続が1回だけ30秒でタイムアウトしたことがある。
中継もWi-Fiがないと使えないため、中継へ落としても解決しなかった。
起動時のWi-Fiは、切ってつなぎ直すのを3回まで試すようにした。
触っていない状態での誤判定
直結で動かしていると、頭上タッチが「叩いた」と判定されて録音が始まり、「聞き取れなかった」と出るのを繰り返し始めた。
テスト中は、頭には一度も触れていない。
ログに残った叩き判定は、すべて誤判定だった。
| 起動 | つなぎ方 | ログの長さ | 叩き判定 |
|---|---|---|---|
| ビルド確認 | VPS中継 | 約45秒 | 0回 |
| 直結の1回目 | 直結 | 約2分半 | 1回 |
| Wi-Fi失敗で切り替え | VPS中継 | 約2分半 | 0回 |
| 直結の2回目 | 直結 | 約4分 | 18回 |
| Arduino版に書き戻し | VPS中継 | 約35秒 | 2回 |
最初直結のときだけ出ているように見えたが、Arduino版に書き戻しても起動から35秒で2回出た。
中継のログがたまたま短かっただけで、Arduino版でも再現したため、ESP-IDFへの移行やTailscaleで新しく出たものではなかった。
何もない場所に置いてあって、近くに触れるものもない。
それでもタッチの読み取り値は1〜2が続き、ときどき3まで上がっていた。
叩きの判定は強さ3以上なので、上がったときに録音が始まってしまう。
直結の2回で出た計19回とも、声が出ないまま5秒待ってから送らずに捨てていて、サーバーへは1回も送っていない。
画面には毎回、6秒ほどの「録音中」と「聞き取れなかった」が出ていた。
誤判定の直前10回の読み取りのうち、触れた判定になっていたのは6〜8回。
以前、実際に叩いたときのログも7〜8回で、直前の値からは見分けられなかった。
判定の強さを上げると、今度は本物の叩きが検知されなくなる。
判定はそのままにして、声を待つ時間を5秒から2.5秒に短くし、声がなくて捨てたときは表示を出さないようにした。
起動シーケンスにも、タッチの読み取りが3秒続けて0になるまで待つ段階を足した。
それでも、顔を出した6秒後から約3分で26回出た。
ただ、その誤判定を最後に、その後15分以上は1回も出なかった。
ひとつ前の起動でも、誤判定は顔を出してから3分半以上続いていた。
書き込みのたびに再起動していたため、ずっと起動直後の数分を見ていたことになる。
誤判定は起動直後の数分間に集中して出て、時間がたつと止まるようだった。
なぜ止まるのかまでは突き止めていない。
起動直後のタッチ受付抑制
顔を出したあと、タッチの読み取りが30秒続けて0になるまで、叩きを一切受け付けないようにした。
触れた判定が出たら30秒を数え直し、5分たっても誤反応が止まらなければ受け付けを始めるようにした。
受け付けるまでは、画面の下に「準備中」と出すようにした。
最初の版では、「準備中」が消えたあとに叩いても反応しなかった。
ログには強さ2止まりの反応が並んでいた。
準備中の間、反応が出ていても15秒ごとにタッチの基準を取り直していたのが原因だった。
触れている最中に取り直すと、その状態が基準になり、本物の叩きが弱く判定されてしまう。
Arduino版を調整したときに分かっていたことで、取り直しは反応が途切れたときだけにした。
起動時のタッチ待ちも同じに直した。
| 段階 | かかった時間 |
|---|---|
| 起動から音声サーバーへの直結まで | 28.5秒 |
| タッチの誤反応が止まるまで | 23.4秒 |
| 準備中(その間の反応48回は無視) | 91.3秒 |
起動から叩きを受け付けるまで、約2分半かかった。
直結での会話テスト
準備中が消えてから叩いてみると、強さ3で叩きとして検知され、録音が始まった。
話しかけた声は直結で送られ、返事が再生された。
| 区間 | 実測 |
|---|---|
| 録音 | 3,803ms(話し始めまで1,100ms) |
| 送信(57,787バイト) | 331ms |
| 返事の感情が届くまで | 10,088ms |
| 1文目の音声が届くまで | 14,068ms |
| 1文目の音声のダウンロード(170,284バイト) | 771ms |
| 1文目の再生開始 | 14,897ms |
| 2文目の音声のダウンロード(101,164バイト) | 587ms |
| 会話の終わりまで | 23,782ms |
約170KB(170,284バイト)の音声を771ms、約216 KiB/sで受け取れていて、前回の確認用プログラムと同じくらいの速さが出ている。
1文目の再生までの約15秒は、ほとんどがサーバー側で返事を作っている時間だった。
なお、この1往復では音が出ていなかった。
誤判定が続いていたときに音を止めようと送った音量0の指定が、本体に保存されたままになっていたためだ。