技術約9分で読めます

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キャッシュ起動が引き続き動くこと