技術約9分で読めます

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
作業用PCWindows、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.0ESP-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を使わないので、使うライブラリだけビルドする設定にした
M5Unified6.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はそのまま通った。

止まった場所原因対処
Tailscale6.0はCをC23でビルドし、bool が組み込みの型。5.5はC17bool を使うヘッダー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-Fi3秒続けてつながっている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の指定が、本体に保存されたままになっていたためだ。