M5Stack CoreS3にフィラー音声を貯めて鳴らす。VPS中継への経路変更と音量UIまで
目次
2026-07-21 追記: 工程2で顔のスプライト口パクとタップ・長押し・フリックのジェスチャー操作まで動かした → M5Stack CoreS3に顔を出して口パクさせる。画面下の仮想ボタンが無い実機でジェスチャー操作にするまで
前回の仕様確認で、フィラー再生・録音・口パクの3要素は仕様上成立すると判断した。
今回から実装に入る。一度に全部作らず工程を区切り、初回はフィラー音声の取得→SD保存→再生と、音量調整UIまで。
顔(口パク)の表示は次回、マイクで話しかける本体部分はさらにその後に回す。
経路はVPS中継にした
ESP32にはTailscaleクライアントを建てられないので、Tailscaleのネットワークに繋がらない。tailnetのアドレス(100.x.x.x)宛てにパケットを投げても行き先がない。
これは最初から分かっていて、当初はラズパイを間に挟んで中継させるつもりだった。ただあまり予算がないので、公開VPSにPHPの中継スクリプトを1枚置き、VPSがTailscale経由で自宅サーバーへ転送する構成にした。
flowchart LR
A[CoreS3] -->|HTTP + Bearer認証| B[VPS<br/>voice-relay.php]
B -->|Tailscale| C[自宅サーバー<br/>STT+LLM+TTS]
デバイスから見えるのはVPSのURLだけになる。
レスポンスJSONに入っている音声のURLも中継側でVPS宛てに書き換えてあるので、デバイス側の実装は「返ってきたURLをそのままGETする」で変わらない。
公開インターネットからアクセスできる場所に置く以上、全リクエストにBearerトークンを必須にした。付いていなければ401を返すだけの単純な仕組みだが、URLを知った第三者に叩かれて自宅サーバーのGPUが回されるのは避けられる。
フィラーはSDにキャッシュする
APIを建てたときの想定は「起動時にサーバーから取得してPSRAMに保持」だった。
ただ自宅サーバーはコールドスタートだとSTTモデルの読み込みに45秒かかり、TTSのウォームアップが終わるまでフィラー一覧が空で返ってくる。デバイスの電源を入れるたびに、この待ちがそのまま挟まる。
なので初回取得時にSDへ保存し、2回目以降の起動はSDから読むことにした。ネットにつながらなくてもフィラーだけは喋れる。
SDは以前切り分けたとおり、手持ちのカードだと書き込みが10MHzでないと通らず、ソフトリセット後は電源を切るまで再マウントできない。
なので取得→保存→読み出しは1回の起動内で完結させ、バスは最初から10MHzで開く。
static constexpr uint32_t SD_FREQ_HZ = 10000000; // 手持ちSDは書き込み10MHz縛り
g_sdOk = SD.begin(4, SPI, SD_FREQ_HZ);
フィラーは1本350〜440KBが3本。ダウンロードは4KBずつストリーミングでSDに書き、そのままPSRAMへ読み戻して常駐させる。
playWavは非同期再生で、再生が終わるまでバッファを解放してはいけないので、常駐にした。
SD書き込みに失敗した場合はPSRAM直行に切り替えて続行する。キャッシュが効かなくなるだけで、フィラー再生自体はそのまま動く。
SD書き込みのテスト用に、シリアルから refetch と送るとSDのフィラーを消してサーバーから取り直す処理も入れた。
電源ボタンを殺さない
電源ボタンの仕組みはCO2モニターとして復元したときに一度調べた。
電源ボタンはAXP2101につながっていて、ハードウェアだけでは電源が切れない。プログラムが M5.BtnPWR を読んで M5.Power.powerOff() を呼んで、初めてOFFになる。
つまり待ちループのどれか1つでも M5.update() を回し忘れると、その間だけ電源ボタンが効かなくなる。
今回のコードはWiFi接続待ち、フィラー一覧のリトライ、ダウンロードのストリーミングと待ちだらけなので、電源ボタンと電源状態表示をまとめた関数を作り、全部のループでこれを回すルールにした。
// 待ちループ用: これを回し続ければ電源ボタンと電源表示が維持される
static void power_tick() {
M5.update();
handle_power_button(); // BtnPWRで powerOff()
static uint32_t last = 0;
if (millis() - last >= 1000) {
last = millis();
update_power_indicator(); // USB給電/バッテリー駆動の表示
}
}
唯一カバーできないのはHTTPClientのブロッキング呼び出し中で、ここはタイムアウトを5〜20秒に抑えて、電源ボタンが効かない時間が最長でもそこまでになるようにした。
ただ、待ちループごとにこれを回す方式は、ループ処理が今後増えるとデカくなりすぎて仕様として不適だと思う。機能の盛り込みによってはステート管理にしようかなとちょっと思っている。
今回は音声処理をメインにしているので、CO2モニター処理は別途盛り込む予定で、本プログラムからは除外している。工程ごとに別プログラムとして書き、実機テストが済んだら最後にまとめて統合する。センサーもPort Aにつないだまま、このプログラムからは触らない。
音量UIを最初に作る理由
CoreS3には物理の音量キーがない。
音を出す機能を先に作って調整手段が後回しになると、いきなりでかい音量で喋り出されて非常に困る。なので再生と同じ工程で音量UIを入れた。
画面下にタッチの [-] [+] ボタンを置き、0〜10の11段階。M5.Speaker.setVolume() は0〜255なので、段階×25.5で丸めてマップする。
値はNVS(Preferences)に保存して再起動後も維持し、初期値は3/10の控えめにした。
static void apply_volume(int level, bool save) {
if (level < 0) level = 0;
if (level > VOL_STEPS) level = VOL_STEPS;
g_volLevel = level;
M5.Speaker.setVolume((uint8_t)(level * 255 / VOL_STEPS));
if (save) g_prefs.putUChar("vol", (uint8_t)level);
}
操作はこれだけ。上半分タッチでフィラーを順繰りに再生(再生中なら停止)、下の [-] [+] で音量、電源ボタンで電源OFF。
実機テスト
Content-Lengthが返ってこない
書き込んで最初の起動で、WiFi接続とフィラー一覧の取得までは通ったのに、WAVのダウンロードが3本とも失敗した。
VPSのレスポンスヘッダを見ると原因が分かった。
HTTP/1.1 200 OK
Content-Type: audio/wav
Transfer-Encoding: chunked
PHPの中継はストリーミングで返すのでContent-Lengthが無い。
最初の実装はgetSize()でサイズを見てからPSRAMを確保する作りだったため、-1が返ってきて何も始まらなかった。
さらに生のgetStreamPtr()で読むとチャンクの枠情報(サイズ行)が音声データに混ざるので、そのままSDに書くとWAVが壊れる。
HTTPClientにはチャンクデコード込みでStreamへ書き出すwriteToStream()があるので、これに切り替えた。
SDへはFileを渡すだけ。PSRAM側は書き込み先が必要になるため、PSRAM上の可変長バッファをStreamとして被せた。
// 可変長のPSRAMバッファに書き込むStream(チャンク転送でサイズ不明でも受けられる)
class PsramBufferStream : public Stream {
public:
uint8_t* buf = nullptr;
size_t len = 0, cap = 0;
size_t write(const uint8_t* data, size_t size) override {
if (len + size > cap) { /* heap_caps_reallocで倍々に拡張 */ }
memcpy(buf + len, data, size);
len += size;
return size;
}
// read系は空実装
};
int written = http.writeToStream(&ps); // チャンクデコード込み
修正後はフィラー3本(353〜403KB)が全部PSRAMに載った。
取れたデータは先頭の”RIFF”を見てWAVかどうか検証してから使う。中継がエラーページを返してきた場合、音声ではないデータをそのまま再生すると、失敗するかめちゃくちゃな音が出る。
再生と音量
タッチでフィラーを順繰りに再生しているところ。前半の3回は音量3、最後の1回だけ [-] で音量1に落として再生した。
48kHz/16bit/モノラルのWAVはplayWavにそのまま渡して正しく喋った。
動画の音声トラックを波形で見ると、音量3の再生はRMSが無音時の5〜12倍まで振れるのに対し、音量1はほとんど暗騒音に埋もれる程度まで落ちる。
テスト中の電源状態表示は「USB給電 100%」のまま安定していた。
SDが全周波数で認識しない
一番苦戦したのがSDだった。最初の起動で SD.begin() が失敗し、そこから泥沼にハマった。
まず自分の過去記事に書いてあったことを自分でやらかした。CoreS3のSDは SPI.begin(36, 35, 37, 4) でピンを明示しないとマウントできないのに、これを忘れていた。
しかし直してみたが全く動かず。クロックを25MHz→10MHz→4MHz→400kHzと落としても、完全に電源を切ってもマウントしない。
前回の切り分けで使った最小プログラム(2秒おきにリトライし続けるだけ)を書き込んで回したところ、カードを挿し直した瞬間にMOUNT OKに変わった。接触っぽかった。
マウント前に画面を触るとSDが認識しなくなる
これで解決かと思ったら、同じカード・同じ電源の切り方でも、最小プログラムは1発でマウントするのに本命のコードは5連敗する、という状態になった。
コードを比較して差分を消していくと、原因は、SDをマウントする前の画面描画だった。
CoreS3のLCDとmicroSDは同じSPIバス(SCK=36/MISO=35/MOSI=37)を共有していて、CSだけ別に持っている。
画面はM5GFXのドライバ、SDはArduinoのSPIクラスと、別々のドライバが同じバスを触る。カードがSPIモードに入る前のデリケートな初期化シーケンスに描画のクロックが混ざると、初期化が二度と通らなくなるようだった。
マウント後も同じで、描画やWiFi処理を挟んだ後の書き込みは失敗した。
対策として、SDに触る仕事を起動直後に全部まとめ、その間は画面に一切描画しないことにした。
// 起動フロー: 描画禁止フェーズにSD作業を集約する
delay(2000);
SPI.begin(36, 35, 37, 4); // ピン明示は必須
// SD.begin(25MHz)を2秒間隔でリトライ(この間、画面は真っ黒のまま)
// → キャッシュがあれば読み込み / なければ取得してSDへ保存
SD.end(); // 以後この起動ではSDに触らない
// ここから初めて画面描画・UIを解禁する
この構成にしたら、マウントも書き込みも25MHzのまま、あっさり通った。
前回の切り分けで出た「書き込みは10MHzでないと通らない」も、今回の全滅も、根っこはカードの接触と初期化中のバス干渉だった可能性が高い。
電源の切り方でも1つ分かったことがある。一度引っ掛かったカードは、素早いOFF→ONでは復活しなかった。
スイッチOFFのあと10秒待ってから入れ直す。これで次のコールドブートでは問題なくマウントした。DIN BASE側の残留電荷が抜けきっていなかったと推測している。
最終的な起動ログがこれ。2回目以降の起動はWiFiにもサーバーにも行かず、SDキャッシュだけで立ち上がる。
SD.begin OK @25MHz (attempt 2): size=30000 MB
SDから読み込み: /fillers/filler_0.wav (433964 bytes)
SDから読み込み: /fillers/filler_1.wav (353324 bytes)
SDから読み込み: /fillers/filler_2.wav (403244 bytes)
volume: 1/10 (NVS)
SDキャッシュから起動
今回の作業進捗
- WiFi接続→VPS中継からフィラー一覧が取れるか(Bearer認証込み)
- 48kHz/16bit/モノラルのWAVがplayWavでそのまま鳴るか
- 音量が [-] [+] で変わるか
- 電源状態表示(USB給電/バッテリー駆動)が正しく出るか
- フィラーWAV(各350〜440KB)をSDへ書き込めるか(25MHzで成功)
- 2回目以降の起動でSDキャッシュから読めるか(WiFi・サーバーなしで起動)
- 音量設定が再起動後も維持されるか(NVS。電源を切っても音量1が復元)
- 電源ボタンでの電源OFFが効き続けるか
次回の作業予定
- フラッシュ埋め込みPNG 3枚のスプライト化
- RGBA透過の見え方
- pushSprite切り替えのチラつき・速度
- 固定間隔の口パクの見え方
- フィラー再生と口パク描画の同時動作
- 音量オーバーレイと口パクの共存
- SDキャッシュ起動が引き続き動くこと