- 概要
- Step1 データ生成(PC/Python):p5.jsで動画からLEDを光らせるための時刻付きCSVデータを書き出す
- Step2: M5StickC Plus2 SDカード読み込みテスト
- Step3: millis()に合わせてSDカード読み込みテスト ← イマココ
- Step4: UDPによる時刻同期
- 課題
Step3: millis()に合わせてSDカード読み込みテスト
Step2でSDカードの読み書きができることは確認できましたが、動画と同期させるためには「1秒間に30回、正確なタイミングでデータを読み出す」というスピードが求められます。
ここではWi-FiやPCとの通信は一旦忘れ、M5StickC Plus2の内部時計(millis())だけを使って、「PCがなくても、電源を入れるだけで正しいリズムで光る」 自律再生プレイヤー を作成します。これができれば、あとは「基準となる時刻」をPCから受け取るだけで同期が完成します。
ここでの確認事項
- PCなし(Wi-Fiなし)での再生: M5StickC Plus2単体でLEDがアニメーションすることを確認する。
- 固定長シーク(Seek): ファイルの最初から順番に読むのではなく、「今の時間に必要な行」へ一瞬でワープする技術を学ぶ。

テスト用コード
以下のコードは、Wi-Fi接続を行わず、電源を入れた瞬間(またはボタンを押した瞬間)を0秒として、SDカード内のCSVファイルを再生し続けます。
コード(step3_m5_sd_player_standalone.ino)
/**
* M5StickC Plus2 SD CSV Player (Standalone)
* - BtnA: 現在選択中のCSVを頭出ししてリスタート
* - BtnB: 次のCSVへ切り替え → 自動で頭出しして再生
* - UIは「領域を消してから描く」方式で、文字が積み上がらないようにする
*/
#include <M5Unified.h> // M5本体制御
#include <Adafruit_NeoPixel.h> // WS2812B制御
#include <SD.h> // SDカード
#include <SPI.h> // SPI通信
// ==========================================
// LED設定
// ==========================================
#define LED_PIN 32 // LEDテープ信号線
#define LED_COUNT 15 // LED個数(CSV列数と一致)
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// ==========================================
// SDカード / SPI通信設定(HSPIで画面SPIと分離)
// ==========================================
const int SD_SCK = 0; // Hat配線に合わせる
const int SD_MISO = 36;
const int SD_MOSI = 26;
const int SD_CS = 33;
SPIClass SPI_SD(HSPI); // ★SD専用SPI(HSPI)
File csvFile; // 現在開いているCSV
// ==========================================
// CSV切替設定
// ==========================================
// ★SD直下に置く前提。必要ならパスを変更すること。
// 例)/data/clip0.csv に置くなら "/data/clip0.csv"
const char* CSV_LIST[] = {
"/clip.csv",
"/clip0.csv",
"/clip1.csv",
"/clip2.csv",
"/clip3.csv",
"/clip4.csv"
};
const int CSV_COUNT = sizeof(CSV_LIST) / sizeof(CSV_LIST[0]);
int currentCsvIndex = 0; // 現在のCSV番号
// ==========================================
// CSV固定長読み込み設定
// ==========================================
// 1行のバイト数(固定長seek用)
// ずれる場合は 190 ↔ 191 (\r\n) を疑う
const int LINE_LEN = 190;
// 30fps想定:1000 / 30 = 33.333...
const float FRAME_DURATION = 33.333;
// ==========================================
// 再生制御用変数
// ==========================================
uint32_t startTime = 0; // 再生開始基準(頭出しの基準)
uint32_t lastUiMs = 0; // UI更新を間引くためのタイムスタンプ
// ==========================================
// UI描画設定(領域を決めて消してから描く)
// ==========================================
// 画面を「ヘッダ」と「情報行」に分け、毎回そこを塗りつぶして描画する
// ※座標は setRotation(1) 前提
const int UI_X = 0;
const int UI_W = 240; // M5StickC Plus2の横幅(概ね240)
const int HEADER_Y = 0;
const int HEADER_H = 40; // 上部:ファイル名・操作説明
const int INFO_Y = 40;
const int INFO_H = 56; // 下部:時間など
// --------------------------------------------------
// UIヘッダを再描画する(ファイル切替時など)
// --------------------------------------------------
void drawHeader() {
// ヘッダ領域を黒で塗りつぶしてから描く(文字の積み上がり防止)
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
// カーソル位置を固定して“同じ場所に描く”
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_WHITE);
M5.Display.println("SD CSV Player");
M5.Display.setCursor(0, 12);
M5.Display.printf("File: %s", CSV_LIST[currentCsvIndex]);
M5.Display.setCursor(0, 24);
M5.Display.println("A:Replay B:Next");
}
// --------------------------------------------------
// UI情報部を再描画する(定期更新)
// --------------------------------------------------
void drawInfo(int32_t tMs) {
// 情報領域も同様に塗りつぶしてから描く
M5.Display.fillRect(UI_X, INFO_Y, UI_W, INFO_H, TFT_BLACK);
M5.Display.setCursor(0, INFO_Y);
M5.Display.setTextColor(TFT_WHITE);
M5.Display.printf("Time: %d ms", (tMs > 0 ? tMs : 0));
// 必要なら追加で情報表示(例:line番号など)
long targetLine = (tMs > 0 ? (long)(tMs / FRAME_DURATION) : 0);
M5.Display.setCursor(0, INFO_Y + 12);
M5.Display.printf("Line: %ld", targetLine);
if (csvFile) {
M5.Display.setCursor(0, INFO_Y + 24);
M5.Display.printf("Size: %ld bytes", (long)csvFile.size());
} else {
M5.Display.setCursor(0, INFO_Y + 24);
M5.Display.setTextColor(TFT_RED);
M5.Display.print("CSV not open");
M5.Display.setTextColor(TFT_WHITE);
}
}
// --------------------------------------------------
// 指定インデックスのCSVを開く(開けたら頭出し)
// --------------------------------------------------
bool openCsvByIndex(int idx) {
// 既に開いているファイルがあるなら閉じる
if (csvFile) {
csvFile.close();
}
const char* path = CSV_LIST[idx];
// ファイル存在チェック
if (!SD.exists(path)) {
// エラー表示(ヘッダ領域に出す)
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_RED);
M5.Display.println("Missing file:");
M5.Display.setCursor(0, 12);
M5.Display.println(path);
M5.Display.setTextColor(TFT_WHITE);
return false;
}
// 読み取りでオープン
csvFile = SD.open(path, FILE_READ);
if (!csvFile) {
M5.Display.fillRect(UI_X, HEADER_Y, UI_W, HEADER_H, TFT_BLACK);
M5.Display.setCursor(0, 0);
M5.Display.setTextColor(TFT_RED);
M5.Display.println("Open failed:");
M5.Display.setCursor(0, 12);
M5.Display.println(path);
M5.Display.setTextColor(TFT_WHITE);
return false;
}
// 切替(または起動)時は必ず頭出し
startTime = millis();
// ヘッダはここで描き直す(積み上がり防止)
drawHeader();
return true;
}
// --------------------------------------------------
// CSV解析&LED点灯(固定長CSV前提)
// --------------------------------------------------
void parseAndShow(const String& line) {
// 先頭:時刻(9文字) + カンマ(1) = 10文字を飛ばす
int charIndex = 10;
// LED_COUNT個のRGBを読む
for (int i = 0; i < LED_COUNT; i++) {
// R (3桁)
String sR = line.substring(charIndex, charIndex + 3);
charIndex += 4; // 3桁 + ','
// G (3桁)
String sG = line.substring(charIndex, charIndex + 3);
charIndex += 4;
// B (3桁)
String sB = line.substring(charIndex, charIndex + 3);
charIndex += 4;
// 数値化
int r = sR.toInt();
int g = sG.toInt();
int b = sB.toInt();
// LEDにセット(gamma補正あり)
strip.setPixelColor(i, strip.gamma32(strip.Color(r, g, b)));
}
// まとめて反映
strip.show();
}
// --------------------------------------------------
// 起動時の初期化
// --------------------------------------------------
void setup() {
// M5初期化
auto cfg = M5.config();
M5.begin(cfg);
// 画面初期化
M5.Display.setRotation(1);
M5.Display.setTextSize(1);
M5.Display.fillScreen(TFT_BLACK);
M5.Display.setTextColor(TFT_WHITE);
// LED初期化
strip.begin();
strip.setBrightness(10);
strip.show(); // 消灯
// SD専用SPI開始(HSPI)
SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
// SDマウント(第2引数にSPI_SDを渡すのが重要)
if (!SD.begin(SD_CS, SPI_SD, 15000000)) {
M5.Display.setTextColor(TFT_RED);
M5.Display.setCursor(0, 0);
M5.Display.println("SD Init Fail!");
M5.Display.println("Check wiring");
while (1);
}
// 最初のファイルを開く
if (!openCsvByIndex(currentCsvIndex)) {
while (1);
}
// UI初回描画
drawHeader();
drawInfo(0);
// 再生基準を設定
startTime = millis();
}
// --------------------------------------------------
// メインループ
// --------------------------------------------------
void loop() {
// ボタン状態更新
M5.update();
// --------------------------
// BtnA: 同じファイルを頭出し
// --------------------------
if (M5.BtnA.wasPressed()) {
startTime = millis(); // 頭出し(再生基準を今に)
// UI情報は次の定期更新でも良いが、即時反映したいならここで描く
drawInfo(0);
}
// --------------------------
// BtnB: 次ファイルへ切替
// --------------------------
if (M5.BtnB.wasPressed()) {
// 次へ
int nextIdx = (currentCsvIndex + 1) % CSV_COUNT;
// 次が開けない場合に備え、最大CSV_COUNT回まで探す
bool opened = false;
for (int tries = 0; tries < CSV_COUNT; tries++) {
currentCsvIndex = nextIdx;
if (openCsvByIndex(currentCsvIndex)) {
opened = true;
break;
}
// ダメならさらに次
nextIdx = (nextIdx + 1) % CSV_COUNT;
}
// もし全部ダメなら再生停止(csvFileは閉じた状態)
if (!opened) {
if (csvFile) csvFile.close();
}
// 切替直後は情報欄も即更新(見た目が分かりやすい)
drawInfo(0);
}
// --------------------------
// 再生処理(LED優先)
// --------------------------
if (csvFile) {
// 経過時間(再生基準から)
int32_t currentVideoTime = (int32_t)(millis() - startTime);
if (currentVideoTime < 0) currentVideoTime = 0;
// 何フレーム目か
long targetLine = (long)(currentVideoTime / FRAME_DURATION);
// その行の先頭バイト位置(固定長なので掛け算で一発)
long seekPos = targetLine * LINE_LEN;
// ファイル範囲内なら読む
if (seekPos < (long)csvFile.size()) {
csvFile.seek(seekPos);
// 1行読む(\nまで)
String line = csvFile.readStringUntil('\n');
// 変な行を弾く(最低限の長さチェック)
if (line.length() > 180) {
parseAndShow(line);
}
} else {
// 末尾まで行ったらループ再生
startTime = millis();
}
}
// --------------------------
// UI更新(重いので間引く)
// --------------------------
if (millis() - lastUiMs >= 1000) {
lastUiMs = millis();
int32_t t = (int32_t)(millis() - startTime);
drawInfo(t);
}
}
コード解説
このプログラムには、処理落ちを防ぎ、正確な同期を実現するための2つの工夫があります。
1. SPI通信の分離(HSPIの使用)
Step2でも触れましたが、M5StickC Plus2は画面表示にSPI通信を使っています。SDカードも同じSPIを使うと、画面を描画するたびにSDカードの読み込みが待たされ、LEDがカクつく原因になります。
SPIClass SPI_SD(HSPI); // 新しい通信レーンを作成
SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
このように HSPI という別の通信レーンをSDカード専用に割り当てることで、画面表示の影響を受けずに高速な読み込みを実現しています。
2. 「固定長」を活かしたシーク(Seek)処理
通常のテキスト読み込みは「1行目、2行目…」と順番に読んでいくため、途中から再生したり、処理が遅れたときにとばしたりすることが苦手です。
しかし、Step1でPythonを使って作ったCSVは「すべての行が同じ文字数(190バイト)」になるように設計しました。これがここで効いてきます。
プログラムは以下の計算式で、読みたい場所へ直接ジャンプ(Seek)しています。
$$\text{読み出し位置(byte)} = \left( \frac{\text{経過時間(ms)}}{\text{1フレームの時間(33.3ms)}} \right) \times \text{1行のバイト数(190)}$$
long targetLine = currentVideoTime / FRAME_DURATION; // 今は何フレーム目?
long seekPos = targetLine * LINE_LEN; // それは何バイト目?
csvFile.seek(seekPos); // その場所へワープ!
これにより、もし何らかの理由で処理が一瞬止まっても、復帰した瞬間に「遅れた分を取り戻して、今の時間に正しいフレーム」を即座に読み出すことができます。これが「音ズレ」を防ぐポイントです。

動作確認
- コードを書き込むと、自動的に
CSV_LISTの最初のファイル(clip.csvなど)が再生され、LEDが光り始めます。 - ボタンA(正面) を押すと、現在のファイルが最初からリスタートします(頭出し確認)。
- ボタンB(側面) を押すと、次のCSVファイルへ切り替わります。

ここまでのテストで、「M5StickC Plus2はSDカードのデータを正しい速度で再生できる」ことが証明されました。
次はいよいよ、Wi-Fiを使ってPCと接続し、完全同期システムを構築します。