技術約5分で読めます

M5Stack CoreS3でフィラー音声再生・マイク録音・口パク表示ができるか実機の前に仕様を確認した

いけさん目次

前回RTX3050Ti 4GBで組んだ音声チャットサーバーを用意したが、今回はM5Stack CoreS3からAPIを呼び出して、機器を喋らせるのを実験する。
その前に準備段階として動作させる為の仕様の確認と実装手順を確認しておくことにする。

作りたいもの

デバイス側の動きは次の流れを想定している。

flowchart TD
    A[タッチで録音開始] --> B[マイクで録音]
    B --> C[WAVをサーバーへPOST]
    C --> D[ジョブID受領<br/>フィラー音声を再生]
    D --> E[0.5〜1秒間隔でポーリング]
    E --> F[完成したWAVチャンクを<br/>順にダウンロード]
    F --> G[チャンクを再生<br/>再生中は口パク]
    G --> H[完了フラグで終了]

確認する項目はこれ。

  • SDカードに置いたフィラーWAVを内蔵スピーカーで再生できるか
  • マイクで録音した音声をWAV化してサーバーへ送れるか
  • 口パク用の画像を切り替えながら表示できるか

実装予定の環境

項目内容
本体M5Stack CoreS3 Development Kit (K128)
開発環境Windows 11 + arduino-cli
ライブラリM5Unified 0.2.18 / M5GFX 0.2.25
サーバーSTT+LLM+TTSの音声チャットサーバー(Tailscale経由)
フィラー音声Irodori-TTSで作り置きしたWAV
口パク画像128x128のPNG 3枚(口閉じ・半開き・口開き)

TTSサーバーが返すWAVのヘッダを確認したところ、48kHz・モノラル・16bit PCMだった。
フィラー音声も同じ形式になるので、まずは再生形式の確認から。

SDのフィラーWAVを内蔵スピーカーで鳴らせるか

M5Unifiedのリポジトリに examples/Advanced/Speaker_SD_wav_file というサンプルがあり、SDからWAVを読んで内蔵スピーカーで再生する処理がそのまま実装されている。

対応フォーマットはSpeaker_Class.cppの検証コード内にあるので、抜粋するとこんな感じ。

// Speaker_Class.cpp playWav()の検証部(抜粋)
if ( memcmp(wav->RIFF,    "RIFF",     4)
  || memcmp(wav->WAVEfmt, "WAVEfmt ", 8)
  || wav->audiofmt != 1          // リニアPCMのみ
  || wav->bit_per_sample < 8
  || wav->bit_per_sample > 16    // 8〜16bit
  || wav->channel == 0
  || wav->channel > 2 )          // モノラル / ステレオ
  { return false; }

リニアPCM・8〜16bit・2chまでという条件で、サンプルレートは任意。
ヘッダから読んだレートを出力側(CoreS3は既定48kHz)へバックグラウンドで線形補間リサンプリングするため、事前変換はいらない。
フィラーの形式は48kHz・モノラル・16bitのWavなので再生可能。

再生はFreeRTOSタスクによるバックグラウンド処理で、playWav()は呼ぶと即座に戻る。
M5.Speaker.isPlaying()で完了を判定でき、再生中もloop()で画面描画やHTTP処理を回せる。
注意点として、再生が終わるまで渡したバッファを解放しないこと。解放すると当然再生できない。

フィラーは1本数百KBなので、SDから毎回読むより起動時にPSRAM(8MB)へ読み込んでおく。
48kHz・モノラル・16bitは1秒あたり約94KBで、8MBなら約85秒分入る。

struct WavClip { uint8_t* buf; size_t len; };
static WavClip filler[3];

bool loadWavToPsram(const char* path, WavClip& clip) {
  File f = SD.open(path);
  if (!f) return false;
  clip.len = f.size();
  clip.buf = (uint8_t*)heap_caps_malloc(clip.len, MALLOC_CAP_SPIRAM);
  size_t rd = f.read(clip.buf, clip.len);
  f.close();
  return clip.buf && rd == clip.len;
}

// 再生は非同期。呼んだら即戻る
M5.Speaker.playWav(filler[0].buf, filler[0].len);

SDカードは以前の切り分けで確認したとおり、手持ちのカードだと書き込みが10MHzでないと通らず、ソフトリセット後は電源を切るまで再マウントできない。
フィラー用途では起動時に読み出すだけなので、そこで全部の読み込みが完了すれば、以後のSDへのアクセスは不要になる。

マイク録音をサーバーへ送れるか

CoreS3はデュアルマイクを内蔵していて(ADCはES7210)、M5UnifiedのMicクラスで録音できる。
record()にバッファ・要素数・サンプルレートを渡す作りで、既定は16kHz。
裏のタスクがバッファを埋めていき、録音が終わるとisRecording()がfalseを返す。

マイクとスピーカーは同じI2Sバスを共有していて、同時には使えない。
M5Unified.cppのCoreS3向け設定では、どちらもI2S_NUM_1・BCK=GPIO34・WS=GPIO33に割り当てられている。
公式サンプルのMicrophone.inoも、この切り替えを明示的にやっている。

while (M5.Mic.isRecording()) { M5.delay(1); }
M5.Mic.end();
M5.Speaker.begin();   // ここからスピーカーが使える

// 再生が終わったら戻す
M5.Speaker.end();
M5.Mic.begin();

録音してWAV化してPOSTする流れは、AIスタックチャン系で確立されたパターンがある。
AI_StackChan2は16kHz・16bit・モノラルで3.75秒分(約120KB)をPSRAMに確保し、先頭に44バイトのRIFFヘッダを直書きしてからPCMを録り、multipart/form-dataでWhisper APIへPOSTしている。
ファイルシステムを経由しないので、録音のためにSDへ書く必要はない。

// 16kHzで3.75秒分をPSRAMに録る(AI_StackChan2の構成)
constexpr size_t record_size = 60000;   // 16000Hz x 3.75秒
int16_t* buf = (int16_t*)heap_caps_malloc(record_size * 2, MALLOC_CAP_SPIRAM);
for (int i = 0; i < 400; i++) {
  M5.Mic.record(&buf[i * 150], 150, 16000);
}

今回のサーバーもマルチパート形式でWAVを受けるので、宛先を差し替えれば同じ形のまま送れるはず。
STT側のQwen3-ASRも16kHzのWAV入力で問題なし。

録音開始のトリガーは、CoreS3に物理ボタンがない(画面下のボタンもタッチ式)ため、タッチで開始して固定秒数録る方式が実装例でも主流。
無音検出で発話終了を判定する実例もあるが、まずは固定長で実装する。

他にやるとしたらマイクとしては起動しっぱなしで、トリガーワード、ここだと「かなちゃん」という話しかけを認識したら送る、という方法もあるが、M5Stackの性能だとちょっと厳しいかも。
後音声で言うと、再生時の音量調整の物理キーなどがM5Stackにはデフォルトではない、でかい音声で鳴らされると困るのでこれもどこで調整するかは検討する余地がある。

128x128のPNGで口パクできるか

どうせ喋るなら顔はつけたい。そこでかなちゃんのドット絵を用意して表示することにした。
用意した画像は128x128のPNGが3枚(口閉じ・半開き・口開き)で、各18KB程度。

M5GFXはPNGデコーダを内蔵していて、drawPngFile()でSDのPNGを直接描画できる。
ただしESP32系のPNGデコードは320x240で数百msかかる実測報告があり、表示のたびにデコードする方式では口パクの切り替え(毎秒2〜4回)に間に合わない可能性がある。

そこで、起動時に一度だけデコードしてスプライト(M5Canvas)に保持し、pushSprite()で切り替える方式にする。
128x128・16bppのスプライトは1枚32KBで、3枚でも96KB。
PSRAMどころか内部RAMに置けるサイズなので、DMA転送も使える。
LovyanGFXの描画速度計測では全画面320x240のpushSpriteで30〜50ms程度なので、128x128の切り替えはこれより短いと見込んでいる。

M5Canvas mouth[3] = { M5Canvas(&M5.Display), M5Canvas(&M5.Display), M5Canvas(&M5.Display) };
const char* pngs[3] = { "/close.png", "/half.png", "/open.png" };

void setup() {
  // SD.begin()の後で
  for (int i = 0; i < 3; i++) {
    mouth[i].setColorDepth(16);
    mouth[i].createSprite(128, 128);   // 32KB/枚
    mouth[i].drawPngFile(SD, pngs[i]); // デコードは起動時の1回だけ
  }
}

void loop() {
  M5.update();
  if (M5.Speaker.isPlaying()) {
    static bool open_mouth = false;
    mouth[open_mouth ? 2 : 0].pushSprite(96, 56);
    open_mouth = !open_mouth;
    M5.delay(150);
  } else {
    mouth[0].pushSprite(96, 56);
  }
}

スピーカーはI2S、画面はSPIでバスが別なので、再生と描画は同時に動かせる。

発展形として、スタックチャンのm5stack-avatarは再生中の音声バッファの振幅から口の開き具合を計算してリップシンクしている。
3枚の画像は振幅を2つのしきい値で3段階に割り当てる方式にも使えるので、固定間隔の切り替えで違和感があれば変更してみて、それでもダメなら中割りを増やしてみることにする。

バスの整理と実機で確認すること

3要素を1台で動かすときのバスの使い分けを表にまとめた。

バスつながっているもの制約
I2S (I2S_NUM_1)スピーカー(AW88298) / マイク(ES7210)同時使用不可。end()とbegin()で切り替える
SPILCD(ILI9342C) / microSDバス共有。起動時にSDの読み込みを完了させれば競合しない
I2Cマイク・アンプ制御 / 電源(AXP2101) / タッチM5Unifiedが管理

状態遷移で見ると、マイクとスピーカーの切り替え位置は2箇所に固定される。

flowchart TD
    A[待機<br/>Mic on] -->|タッチ| B[録音]
    B -->|固定秒数で終了<br/>Mic off / Speaker on| C[サーバーへ送信<br/>ジョブID受領]
    C --> D[フィラー再生<br/>口パク]
    D -->|チャンク到着| E[返答再生<br/>口パク]
    E -->|完了<br/>Speaker off / Mic on| A

ここまでの確認で、3要素とも仕様上は成立すると判断した。
実機で確認するのは次の点。

  • 手持ちのLAZOS 32GBから、起動時にフィラーWAVと画像の読み込みが完了できるか
  • マイクのゲイン(magnification、既定16)とノイズフィルタの調整がどの程度必要か
  • Mic/Speaker切り替え時のノイズや音の尻切れ(実装例では切り替え前後に200〜500msの待機を挟んでいる)
  • 固定間隔の口パクで違和感がないか
  • 音量調整を実機のどこにつけるか
  • CO2モニターとしてつないでいるCO2センサーを外すのか、つけっぱなしなら機能としてどうするのか