技術約7分で読めます

M5Stack CoreS3に顔を出して口パクさせる。画面下の仮想ボタンが無い実機でジェスチャー操作にするまで

いけさん目次

前回でフィラー音声のSDキャッシュ再生と音量UIまで動いた。
今回は顔をつける。フィラーを喋っている間、かなちゃんのドット絵が口パクする状態までが今回の工程で、マイクで話しかける本体部分は次に回す。

素材

用意したのは128x128のPNGが3枚。口閉じ(ニュートラル)・口半開き・口開きで、各18KB程度のRGBAドット絵。

仕様確認のときに机上で確定させた方式をそのまま使う。
ESP32系のPNGデコードは遅い(320x240で数百msの実測報告がある)ので、表示のたびにデコードするのではなく、起動時に一度だけデコードしてスプライト(M5Canvas)に焼き、以後はpushSprite()で切り替える。

128x128・16bppのスプライトは1枚32KB、3枚で96KB。PSRAMどころか内部RAMに置けるサイズで、DMA転送も使える。
スピーカーはI2S、画面はSPIでバスが別なので、再生と描画は同時に動かせる。

PNGはSDに置かずフラッシュに埋め込む

当初の計画ではPNGもSDカードに置く予定だったが、ここを変えた。

前回の実機テストで分かったとおり、CoreS3のLCDとSDは同じSPIバスを別ドライバで共有していて、扱いを間違えるとマウント不能・書き込み失敗が起きる。SD作業は起動直後の「描画禁止フェーズ」に集約する、という運用でフィラーは解決した。

顔のPNGも同じフェーズで読めば動くはずだが、そもそも顔は固定素材で、サーバー由来で内容が変わるフィラーとは性格が違う。
3枚で56KBしかないので、PNGのバイナリをそのままCの配列にしてコードに埋め込んだ。

# PNG → Cヘッダ化(xxd相当)
python -c "
data = open('kana_close.png','rb').read()
print('const unsigned char kana_close_png[] PROGMEM = {')
print(','.join(str(b) for b in data))
print('};')
" > kana_close.h

M5GFX(LovyanGFX)はメモリ上のPNGを直接デコードできるので、SDを経由しなくて良くなった。

mouth[i].setColorDepth(16);
mouth[i].createSprite(128, 128);
mouth[i].drawPng(kana_pngs[i], kana_png_lens[i], 0, 0);  // メモリから直接デコード

これでSDの役割は「フィラー音声のキャッシュ」だけになり、顔はSDカードを抜いても表示できる。
SDからの読み込み処理は増えていない。

口パクの動かし方

再生中かどうかはM5.Speaker.isPlaying()で分かるので、まずは固定間隔の切り替えで作ってみる。

// 再生中: 閉じ→半開き→開き→半開き→... を150ms間隔で回す
static const uint8_t seq[] = {0, 1, 2, 1};
if (M5.Speaker.isPlaying()) {
  mouth[seq[(millis() / 150) % 4]].pushSprite(FACE_X, FACE_Y);
} else {
  mouth[0].pushSprite(FACE_X, FACE_Y);  // 待機中は口閉じ
}

3枚あるので閉↔開の2値ではなく、半開きを遷移に挟んだ4フレームのループにする。
これで違和感があれば、スタックチャンのm5stack-avatarがやっているように、再生中の音声バッファの振幅から口の開き具合を決める方式に切り替える。

顔タップとUI操作をどう分けるか

デフォルトは顔だけの画面にしたい。しかし「タップしたらUIを出す」にすると、最終形の「タップして話しかける」と操作が衝突する。タッチしたらUIが切り替わってしまうとなると、話しかけるタイミングがわからなくなる。

CoreS3の物理キーは電源ボタンとリセットしかなく、電源ボタンは電源OFF役としてすでに使っている。
そこで、画面下の表示に使わないエリアを使うことにした。CoreS3のタッチパネルはLCD(240px)より下まで反応していて、M5UnifiedではBtnA/B/Cの仮想ボタンとして使えると思う。LCDの外なので、顔のタップとは物理的に競合しない。

操作はこう割り付けた。

入力動作
顔(画面内)タップ話しかけ(今回は再生/停止のプレースホルダ)
画面下エリア BtnA(左)音量[-]。1秒だけ音量オーバーレイを表示して消える
画面下エリア BtnC(右)音量[+]。同上
画面下エリア BtnB(中央)顔画面⇔UI画面の切り替え
電源ボタン電源OFF(従来どおり)

顔画面は顔128x128を中央に大きく置き、電源状態(バッテリー/USB給電)だけ上に小さく表示しておく。
前回作った音量ボタンやステータス行はUI画面側へ移し、普段は顔だけが見えている状態にする。音量は画面下エリアのタッチでUI画面を開かずに調整でき、変更した瞬間だけ数字がオーバーレイで出る。

電源ボタンの短押しをUI切り替えに転用する案も考えたが、せっかく直した「押せば切れる」の信頼性を損ないたくないのでやめた。
……と、ここまで設計して実機に進んだ。

実機テスト

画面下エリアはタッチセンサーの範囲外だった

書き込んで最初に確かめたのが画面下エリアのタッチで、これがどこを触っても反応しない。
タッチの生座標をシリアルに出して画面下エリアを触ってみると、原因がはっきり出た。

touch press: x=297 y=239   ← 画面下エリアを触ってもyは239止まり
touch press: x=319 y=239
touch press: x=181 y=238

yが239より先が返ってこない。 LCDの下を触っても「画面の一番下を触った」ことになり、LCD外のタッチはそもそも取れない。ハズレ個体を引いたのかと思ったが、調べるとこれはCoreS3の仕様だった。
M5Unifiedのソースでは、CoreS3もCore2と同じ「y>=240のタッチをBtnA/B/C判定にする」扱いになっている。

case board_t::board_M5StackCore2:
case board_t::board_M5StackCoreS3SE:
case board_t::board_M5StackCoreS3:
  tb_y = 240;   // y>=240のタッチをBtnA/B/C判定にする

Core2はタッチフィルムが表示の下まで伸びていて、y=240〜279が実際に返るからこれで動く。CoreS3のタッチはy=239までしか返らず、カメラやマイクのある帯はセンサーの範囲外なので、y>=240のタッチは発生しようがない。公式docsには下部のマイク・カメラの位置がBtnA/B/Cのタッチゾーンだと書いてあるが、コミュニティの検証でも「画面の下縁を触るとBtnAがたまに効く程度」と報告されている。
Core2の感覚で画面の下にBtnA/B/Cがあるつもりでいたが、CoreS3にその領域は最初から無かった。画面下エリアを狙ったタップは全部「画面内タップ=再生/停止」に取られていた、というオチ。

M5Unifiedにはボタン判定を画面内まで広げるM5.setTouchButtonHeight()もあるので、CoreS3で仮想ボタンを使うなら画面の下端を割り当てるしかない。そこで画面最下段の24pxを下端バー(左=音量-、中央=切替、右=+)にしてみたが、これも失敗。
24pxは実寸で2.4mmほどしかなく、誤タップの山になった。ボタンを押してるつもりが顔を触っててかなちゃんがずっと喋ってた。

タップ・長押し・フリックに分けた

ボタンの面積に頼るのをやめて、操作の種類で分けることにした。

操作動作
タップ(どこでも)話しかけ(今回は疑似応答=フィラー再生/停止)
長押し(0.5秒)顔画面⇔UI画面の切り替え
上下フリック音量±(1秒だけオーバーレイ表示)

M5Unifiedのタッチはクリック・長押し・フリックを区別して返してくれるので、分岐を書くだけで済む。

auto t = M5.Touch.getDetail();
if (t.wasFlicked()) {
  if (abs(t.distanceY()) > abs(t.distanceX()) && abs(t.distanceY()) >= 30) {
    apply_volume(g_volLevel + (t.distanceY() < 0 ? 1 : -1), true);  // 上フリックで+
  }
} else if (t.wasHold()) {
  toggle_screen();
} else if (t.wasClicked()) {
  toggle_play();
}

タップと長押しとフリックは場所で分けていないので、下端バーのような誤タップは起きなかった。
「顔に触ると喋る」も当初の予定通り使える。

口パクの動作

タップでフィラーを再生し、喋っている間だけ口が動く。長押しでUI画面、フリックで音量も動かしているところ。

数字も取れた。

  • フラッシュ埋め込みPNG 3枚のデコードは87ms(起動時に1回だけ)
  • RGBA透過は黒背景への焼き込みで問題なし。pushSpriteの切り替えにチラつきは出ていない
  • 150ms・4フレーム(閉→半→開→半)の口パクは、フィラーの短い相槌に対してはそれらしく見えた
  • フィラー再生(I2S)と口パク描画(SPI)の同時動作も問題なし
  • 前回作ったSDキャッシュ起動もそのまま動いている(SDから読み込み ×3 → 顔表示まで数秒、WiFi不要)

今回の作業進捗

  • フラッシュ埋め込みPNG 3枚のスプライト化(デコード87ms)
  • RGBA透過の見え方(黒背景焼き込みで問題なし)
  • pushSprite切り替えのチラつき・速度
  • 固定間隔(150ms・4フレーム)の口パクの見え方
  • フィラー再生と口パク描画の同時動作
  • 操作の誤反応 → 画面下エリアのタッチ不在・下端バー誤タップを経てジェスチャーで解決
  • 音量オーバーレイと口パクの共存
  • SDキャッシュ起動が引き続き動くこと

次回の作業予定

この先は「録音」「通信」「非同期の組み立て」で山場が3つあるので、1山場=1工程に分割した。次は録音の単体確認から。

  • タッチで固定秒数録音→その場で自分の声を再生する「ボイスレコーダー」(サーバー不要)
  • Mic/SpeakerのI2S排他の切り替え(end()/begin()、切り替え前後のノイズ・尻切れ)
  • マイクゲイン(magnification)の調整
  • 録音フォーマット48kHz/16bit/モノラルをPSRAMに確保