M5Stack Stopwatchで、オリジナル LT(Lightning Talks)タイマーを作成した。
2026.09.21

どもです。
今回はマイコン工作の話でもと。
社内やコミュニティで LT(Lightning Talks)をやるとき、「残り時間どれくらい?」が毎回ふわっとしがちでして。スマホのタイマーを置いておくのも味気ないので、M5StopWatch でキャラクターがパラパラ漫画のように動く LT タイマーを作ってみましたぁ!
タイマーが進むと、待機中 → 計測中 → 残りわずか → 終了、とキャラクターのアニメーションが切り替わって、合わせて通知音も鳴る、というものです。キャラクターはスプラトゥーンのヒメ(推し)とフウカにご登場いただきました。

M5StopWatch とは
今回使ったのは M5Stack の M5StopWatch。1.75 インチの円形 AMOLED タッチディスプレイを積んだ、手のひらサイズの開発ボードです。名前の通りストップウォッチっぽい見た目で、ボタンが 2 つとスピーカー、バッテリーまで入っているので、タイマー用途にはこれ以上ないくらいピッタリでした。

公式ドキュメントから、今回の工作に関係するところだけ抜粋しておきます。
| 項目 | 仕様 |
|---|---|
| SoC | ESP32-S3R8(Xtensa 32bit LX7 デュアルコア、240MHz) |
| ディスプレイ | 1.75 インチ 円形 AMOLED、466 × 466、タッチ対応 |
| Flash / PSRAM | 16MB / 8MB |
| ボタン | プログラマブルボタン × 2 + 電源ボタン |
| オーディオ | ES8311 コーデック、1W スピーカー、MEMS マイク |
| バッテリー | 450mAh、USB Type-C 給電 |
| その他 | 振動モーター、六軸 IMU(BMI270)、RTC(RX8130CE)、Wi-Fi 2.4GHz、HY2.0-4P 拡張ポート |
| サイズ / 重量 | 52.0 × 52.0 × 15.5mm / 39.0g |
詳細は公式ドキュメントをどうぞ。
Flash が 16MB あるので、後述する FATFS 領域にアニメーションの画像を丸ごと置いても余裕があります。画面が 466 × 466 の円形なので、レイアウトは中央寄せを基本にして、四隅には何も置かないようにしています。
完成したもの
まずは動いているところを。


画面構成はこんな感じです。
- 上段:残り時間(カウントダウン)または経過時間(カウントアップ)を
MM:SSで表示 - その下:
Lightning Talks Timerのタイトル - 中央:キャラクターのアニメーション
- 下段:
COUNT DOWN/COUNT UPのモード表示と、3 MINなどのプリセット表示
できること
| 項目 | 仕様 |
|---|---|
| プリセット | 3 分 / 5 分 / 7 分(ボタンで循環切替) |
| カウント方式 | カウントダウン / カウントアップ(画面タップで切替) |
| 分解能 | 1 秒(millis() ベース) |
| アニメーション | 待機 / 計測中 / 第 1 通知 / 第 2 通知 / 終了 の 5 状態、各 19 フレーム、約 8fps |
| 通知 | 残り時間に応じて 2 段階の通知音 + アニメ切替、終了時にファンファーレ ×2 |
操作
| 操作 | 動作 |
|---|---|
| ボタン A(短押し) | スタート(ピロリロリロ♪)/ 一時停止(ピッ) |
| ボタン A(長押し 1 秒) | リセット(ピッ) |
| ボタン B | プリセット切替(3 分 → 5 分 → 7 分 → 3 分) |
| 画面タップ | カウントダウン / カウントアップ切替(計測中は無効) |
構成
ハードは M5StopWatch 1 台だけ。ソフトは Arduino IDE で書いた .ino が 1 ファイルと、アニメーション用の連番画像です。
M5StopWatchTimer/
├── M5StopWatchTimer.ino メインスケッチ
└── data/ FATFS にアップロードするファイル
└── anim/
├── idle/ 待機中 1.jpg 〜 19.jpg
├── running/ 計測中 1.jpg 〜 19.jpg
├── warning1/ 第1通知 1.jpg 〜 19.jpg
├── warning2/ 第2通知 1.jpg 〜 19.jpg
└── finish/ 終了 1.jpg 〜 19.jpg
依存ライブラリ
| ライブラリ | 用途 |
|---|---|
M5Unified |
本体初期化・ボタン・タッチ・スピーカー |
M5GFX |
描画(M5Canvas によるスプライト描画) |
FFat(ESP32 標準) |
内蔵フラッシュの FATFS 領域から画像を読み出す |
M5Unified を使っているので、基本的には M5Unified 対応のデバイスならボタンや画面サイズの違いを吸収して動くはずです。ただしキャラクターの描画位置とサイズは固定値で決め打ちしているので、画面の小さい機種では下が切れます。この辺りは後述します。
アニメーションの仕組み
今回の肝はここです。
やっていることは非常に原始的で、連番の JPEG を内蔵フラッシュに置いておいて、120ms ごとに次の番号のファイルを読んで描く。それだけです。パラパラ漫画ですね。

画像は FATFS に置く
ESP32 の内蔵フラッシュには、スケッチ(プログラム)とは別にファイルシステム用の領域を確保できます。SPIFFS や LittleFS が有名ですが、今回は FATFS(FFat) を使いました。
#include
// setup() 内
if (!FFat.begin(true)) {
Serial.println("FATFS mount failed");
// 画面を赤くして FATFS ERROR を表示、停止
}
FFat.begin(true) の true は「未フォーマットなら自動でフォーマットする」というオプションです。マウントに失敗した場合は画面を赤で塗って FATFS ERROR と表示し、そのまま止まるようにしています。パーティションスキームを間違えて FATFS 領域が無い状態で書き込むと、ここで止まってくれるので原因がすぐ分かります。
同梱している画像は 5 状態 × 19 フレーム = 95 枚で、合計約 780KB。JPEG にしておけば FATFS の容量的にはまったく余裕があります。
連番ファイルを探す
フレームのパスは「ディレクトリ + 番号 + 拡張子」で組み立てます。PNG / JPG / JPEG の 3 つに対応していて、同じ番号のファイルがあれば png → jpg → jpeg の順で優先します。
String getFramePath(const String& dir, int frame)
{
char filename[64];
snprintf(filename, sizeof(filename), "%s/%d.png", dir.c_str(), frame);
if (FFat.exists(filename)) {
return String(filename);
}
snprintf(filename, sizeof(filename), "%s/%d.jpg", dir.c_str(), frame);
if (FFat.exists(filename)) {
return String(filename);
}
snprintf(filename, sizeof(filename), "%s/%d.jpeg", dir.c_str(), frame);
if (FFat.exists(filename)) {
return String(filename);
}
return "";
}
フレーム数は、1 から順番に存在確認して、無くなったところで打ち切るという方法で数えています。
int countAnimationFrames(const String& dir)
{
int frame = 1;
while (true) {
String path = getFramePath(dir, frame);
if (path.length() == 0) {
break;
}
frame++;
}
return frame - 1;
}
これのおかげで、画像を差し替えるときにフレーム数をコードに書く必要がありません。data/anim/idle/ に 1.jpg から 30.jpg まで置けば 30 フレームのアニメになります。状態ごとにフレーム数が違っていても構いません。
状態の切り替え
アニメーションの状態は enum で持ち、setAnimation() で切り替えます。ディレクトリを差し替えて、フレーム数を数え直して、1 フレーム目に戻す。
enum AnimState {
ANIM_IDLE,
ANIM_RUNNING,
ANIM_WARNING1,
ANIM_WARNING2,
ANIM_FINISH
};
void setAnimation(AnimState state)
{
currentAnim = state;
switch (state) {
case ANIM_IDLE: animDirectory = "/anim/idle"; break;
case ANIM_RUNNING: animDirectory = "/anim/running"; break;
case ANIM_WARNING1: animDirectory = "/anim/warning1"; break;
case ANIM_WARNING2: animDirectory = "/anim/warning2"; break;
case ANIM_FINISH: animDirectory = "/anim/finish"; break;
}
animFrameCount = countAnimationFrames(animDirectory);
animFrame = 1;
lastAnimUpdate = millis();
redrawRequested = true;
}
フレームを進める
loop() から毎回呼ばれる updateAnimation() で、前回から 120ms 経っていればフレームを 1 つ進めます。finish だけは最終フレームで止まり、それ以外はループします。
const unsigned long ANIM_INTERVAL = 120;
void updateAnimation()
{
if (animFrameCount <= 0) {
return;
}
unsigned long now = millis();
if (now - lastAnimUpdate < ANIM_INTERVAL) {
return;
}
lastAnimUpdate = now;
animFrame++;
if (currentAnim == ANIM_FINISH) {
// 終了アニメは最終フレームで停止
if (animFrame > animFrameCount) {
animFrame = animFrameCount;
}
}
else {
// それ以外はループ
if (animFrame > animFrameCount) {
animFrame = 1;
}
}
redrawRequested = true;
}
delay() で待つのではなく millis() の差分で判定しているので、アニメーションを回している間もボタンやタイマーの処理が止まりません。Arduino でアニメーションをやるときの基本ですね。
描画はキャンバス(スプライト)に
キャラクターの描画は M5GFX の drawJpgFile() / drawPngFile() を使います。ここで重要なのが、M5.Display に直接描くのではなく、M5Canvas に描くことです。
M5Canvas canvas(&M5.Display);
void drawCharacter()
{
// ...
const int characterWidth = 220;
const int characterHeight = 255;
int x = (screenWidth - characterWidth) / 2;
int y = 120;
if (lowerPath.endsWith(".png")) {
canvas.drawPngFile(FFat, path.c_str(), x, y, characterWidth, characterHeight);
}
else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
canvas.drawJpgFile(FFat, path.c_str(), x, y, characterWidth, characterHeight);
}
}
M5.Display に直接描くと、画面クリア → 文字描画 → 画像描画、という過程がそのまま画面に出てしまい、フレームが切り替わるたびにチラつきます。いかぁーん。
なので、画面と同じサイズのキャンバス(メモリ上のスプライト)を作っておいて、そこに全部描いてから pushSprite() で一括転送しています。
void drawScreen()
{
redrawRequested = false;
canvas.fillScreen(TFT_BLACK);
drawTimer(); // 時間・タイトル・モード・プリセット
drawCharacter(); // キャラクター
canvas.pushSprite(0, 0);
}
これでチラつきは完全に消えます。通常画面で M5.Display に直接描いている箇所は、起動時の FATFS ERROR 表示以外にはありません。
もう一つ、drawJpgFile() の幅・高さ引数は拡縮ではなくクリップ範囲です。指定した範囲より大きい画像は、はみ出した分が切り捨てられるだけで縮小はされません。なので素材側を 220 × 255px 以内に収めておく必要があります。且つ、M5StopWatchは円形なので端っこが切れるのを考えないといけません。
再描画は必要なときだけ
drawScreen() は毎ループ呼ぶのではなく、redrawRequested フラグが立っているときだけ呼びます。
void loop()
{
M5.update();
// ... ボタン・タッチ処理 ...
updateTimer();
updateAnimation();
if (redrawRequested) {
drawScreen();
}
delay(1);
}
フラグはタイマーが 1 秒進んだとき、アニメーションのフレームが進んだとき、ボタン操作があったときに立ちます。何も変わっていないフレームで JPEG をデコードし直すのは無駄なので。
タイマーの実装
1 秒の刻み方
タイマーは millis() ベースで、1000ms 経過するごとに elapsedSeconds を 1 増やします。
void updateTimer()
{
if (!running) {
return;
}
unsigned long now = millis();
if (now - lastTimerUpdate < 1000) {
return;
}
lastTimerUpdate += 1000;
elapsedSeconds++;
// ...
}
ここで lastTimerUpdate = now ではなく lastTimerUpdate += 1000 にしているのがポイントです。
loop() は正確に 1000ms ごとに回るわけではないので、判定が通るのは「1000ms 経過した後のどこか」になります。= now にしてしまうと、その遅れぶんが毎秒積み重なって、7 分のタイマーが 7 分数秒になります。+= 1000 なら、遅れは次の判定で吸収されて、誤差が持ち越されません。
地味ですが、LT タイマーで時間がずれるのは信用に関わるので、ここは真面目にやっています。
スタート時はファンファーレの後から数える
スタート時は、ファンファーレ(ピロリロリロ♪、約 0.6 秒)を鳴らし終えてから lastTimerUpdate = millis() をセットしています。音を鳴らしている間は delay() で止まっているので、先にセットすると鳴り終わった瞬間に 0.6 秒ぶん進んでしまうためです。
if (M5.BtnA.wasClicked()) {
running = !running;
if (running) {
playNotification(4); // ピロリロリロ♪
lastTimerUpdate = millis(); // 鳴り終わってから 0 秒スタート
if (currentAnim == ANIM_IDLE || currentAnim == ANIM_FINISH) {
setAnimation(ANIM_RUNNING);
}
}
else {
playNotification(1); // ピッ
setAnimation(ANIM_IDLE);
}
redrawRequested = true;
}
通知のタイミング
計測中は checkNotifications() で残り時間(または経過時間)を見て、通知点を通過したらアニメ切替 + 通知音を鳴らします。各通知は 1 回だけ鳴るように notified1 〜 notified3 のフラグで管理し、リセットでクリアします。
カウントダウンの通知点は、プリセットの長さで変えています。
int warning1Remaining;
int warning2Remaining;
if (total > 4 * 60) {
// 5 分 / 7 分: 残り 4 分と残り 2 分
warning1Remaining = 4 * 60;
warning2Remaining = 2 * 60;
}
else {
// 3 分: 残り 2/3 と残り 1/3(= 残り 2:00 と 1:00)
warning1Remaining = total * 2 / 3;
warning2Remaining = total / 3;
}
| プリセット | 第 1 通知(warning1) |
第 2 通知(warning2) |
終了(finish) |
|---|---|---|---|
| 3 分 | 残り 2:00 | 残り 1:00 | 残り 0:00 |
| 5 分 | 残り 4:00 | 残り 2:00 | 残り 0:00 |
| 7 分 | 残り 4:00 | 残り 2:00 | 残り 0:00 |
「残り 4 分と 2 分」で固定にすると 3 分プリセットのときに第 1 通知が鳴らないので、4 分以下のプリセットは残り時間の 2/3・1/3 に切り替える形にしました。
カウントアップのときは経過 3 分 / 5 分 / 7 分の絶対値で通知します。プリセットに到達した時点で止まるので、3 分プリセットなら finish だけ、5 分なら 3 分通知 + 終了、7 分なら全部、という動きです。
操作まわりの小ネタ
短押しと長押しを両立させる
ボタン A は短押しでスタート / 一時停止、1 秒長押しでリセットです。ここで wasPressed() を使うと、長押しのつもりでも押した瞬間にスタートが走ってしまいます。
なので短押しの判定は wasClicked()(離した瞬間に発火)、長押しは wasHold() を使い、ホールドのしきい値を setup() で 1 秒に設定しています。
// setup()
M5.BtnA.setHoldThresh(1000);
// loop()
if (M5.BtnA.wasClicked()) {
// スタート / 一時停止
}
if (M5.BtnA.wasHold()) {
resetTimer();
playNotification(1);
}
M5Unified はホールドとして扱った押下をクリックとしては発火させないので、長押しリセットのときにスタートが同時に走ることはありません。この辺りが M5Unified の便利なところです。
画面タップの下端は無視する
カウントダウン / カウントアップの切り替えは画面タップです。ただ、機種によっては画面の下端が仮想ボタン(BtnA / BtnB)に割り当てられているので、そこをタップすると「モード切替 + ボタン操作」が同時に走ってしまいます。
なので、下端 40px はタップ判定から除外しています。
if (M5.Touch.isEnabled() && !running) {
auto touch = M5.Touch.getDetail();
if (touch.wasClicked() && touch.y < screenHeight - 40) {
toggleCountMode();
playNotification(1);
}
}
計測中は切り替え不可にしているのは、モードを切り替えるとタイマーがリセットされるからです。LT の途中で誤タップして時間が消えたら大惨事なので。
操作音だけ小さくする
音は M5.Speaker.tone(周波数, 長さ, チャンネル) で鳴らしています。「ピッ」という操作音と、通知音・ファンファーレでスピーカーのチャンネルを分けて、操作音のチャンネルだけ音量を下げています。
const uint8_t CH_NOTIFY = 0; const uint8_t CH_CLICK = 1; const uint8_t CLICK_VOLUME = 90; // 0〜255、255 で他と同じ音量 // setup() M5.Speaker.setChannelVolume(CH_CLICK, CLICK_VOLUME); // 操作音 M5.Speaker.tone(1500, 120, CH_CLICK); // 通知音 M5.Speaker.tone(1500, 100, CH_NOTIFY);
ボタンを押すたびに通知音と同じ大きさで「ピッ」と鳴るとうるさいんですよね。CLICK_VOLUME を変えるだけで操作音の音量だけ調整できます。
通知音は 5 段階です。
| レベル | 音 | 用途 |
|---|---|---|
| 1 | ピッ | ボタン操作・タップ・一時停止・リセット |
| 2 | ピッ・ピッ | 第 1 通知 |
| 3 | ピッ・ピッ・ピッ | 第 2 通知 |
| 4 | ピロリロリロ♪ | スタート |
| 5 | ピロリロリロ♪ ×2 | 終了 |
ピロリロリロ♪ は 1200 → 1600 → 2000 → 2600Hz と上がっていく 4 音です。なんとなく「始まった感」が出るかなと。
セットアップ手順
- Arduino IDE に
M5UnifiedとM5GFXをインストール - ボードを対象の M5Stack デバイスに設定し、パーティションスキームを FATFS が使えるものに変更(
Default with FFATや16M Flash (2MB APP / 12.5MB FATFS)など) data/以下を FATFS に書き込む(Arduino IDE 用の ESP32 FatFS アップロードツールなどを使用)。書き込み後は/anim/idle/1.jpgのようなパスになりますM5StopWatchTimer.inoを書き込む
起動するとシリアル(115200bps)に FATFS の容量と、各状態のフレーム数が出ます。画像がちゃんと入っているかはここで確認できます(容量の数値はパーティション設定によって変わります)。
========================== M5StopWatch Timer ========================== Touch: enabled Mounting FATFS... FATFS mounted FATFS total: (総容量) bytes FATFS used : (使用量) bytes Animation files: idle: 19 running: 19 warning1: 19 warning2: 19 finish: 19 Animation: /anim/idle / frames: 19
アニメーションを差し替える
data/anim/<状態名>/ に 1 から始まる連番で画像を置くだけです。フレーム数は自動で数えるので、コードの変更は不要。
注意点は前述の通り、画像は拡縮されないこと。220 × 255px より大きいとはみ出した部分が切れます。素材はこのサイズ以内で作ってください。容量を考えると JPEG がおすすめです。
今後について
今回はとにかく「自分の LT で使える」を目標に作ったので、プリセットが 3 / 5 / 7 分の決め打ちだったり、通知点の判定がベタ書きだったり、キャラクターの描画位置が固定だったりと、汎用性はまだまだです。
なので今後は、
- プリセットや通知点を設定ファイルや画面から変えられるようにする
- キャラクターの描画位置とサイズを画面サイズに合わせて自動調整する
- アニメーションの状態を増やしたり、名前で自由に定義できるようにする
といった感じで、もっと汎用的に使える形に改良して、ソースを公開しようかなと考えています。合わせて、以前から手元で作っている M5 Bunnar のほうも一緒に公開できればと。
「連番画像を FATFS から読んで動かす」というのは、タイマーに限らず、卓上のちょっとしたマスコット的なものにも使える仕組みなので、その辺りを共通のライブラリっぽくまとめられたら良いなぁ、と妄想しております。
まとめ
- M5Unified + M5GFX + FFat で、キャラクターアニメ付きの LT タイマーを作った
- アニメは FATFS に置いた連番 JPEG を 120ms ごとに読み替えるだけのパラパラ漫画方式。フレーム数は存在確認で自動カウント
- チラつき防止は
M5Canvasに全部描いてからpushSprite()で一括転送 drawJpgFile()の幅・高さは拡縮ではなくクリップ範囲- タイマーは
lastTimerUpdate += 1000で誤差を持ち越さない - 短押しは
wasClicked()、長押しはwasHold()。画面下端 40px はタップ除外 - 操作音はスピーカーチャンネルを分けて音量だけ下げる
LT の残り時間をイカちゃんが教えてくれる、というだけでなんとなく場が和むので、作ってよかったなぁと思っています。まぁ、発表者本人はタイマーどころではないので、見ているのは主に聴衆なんですがw
ではではぁ。またまたぁ。















