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

テスト用コード
以下のコードは、Wi-Fi接続を行わず、電源を入れた瞬間を0秒として、SDカード内の led15_log.csv を再生し続けます。
コード(m5_sd_player_standalone.ino)
/**
* M5StickC Plus2 SD CSV Player (Standalone) - Fixed SPI
* * ■概要
* - SDカード内の "led15_log.csv" を読み込み、LEDを再生します。
* - Wi-Fi不要。電源を入れるだけで、内部時計(millis)に合わせて自動再生します。
* * ■重要な修正点 (Fixed SPI)
* - M5StickC Plus2は、画面描画に標準のSPIバスを使っています。
* - SDカードも同じバスを使おうとすると競合して「Init Fail」になりがちです。
* - そこで「HSPI」という別の通信レーンをSDカード専用に割り当てることで解決しています。
*/
#include <M5Unified.h> // 本体制御用ライブラリ
#include <Adafruit_NeoPixel.h> // LEDテープ制御用ライブラリ
#include <SD.h> // SDカード操作用ライブラリ
#include <SPI.h> // 通信プロトコル用ライブラリ
// ==========================================
// LED設定
// ==========================================
#define LED_PIN 32 // LEDテープの信号線 (G32)
#define LED_COUNT 15 // LEDの個数 (CSVデータの列数と一致させる)
// LED制御オブジェクトの作成
// NEO_GRB + NEO_KHZ800 は一般的なWS2812Bの設定です
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
// ==========================================
// SDカード / SPI通信設定 (ここが重要!)
// ==========================================
// M5StickC用 SD Hatのピン定義 (Hatの仕様に合わせて変更可)
const int SD_SCK = 0;
const int SD_MISO = 36;
const int SD_MOSI = 26;
const int SD_CS = 33;
// ★重要:SDカード専用のSPIクラスを作成
// 標準の SPI ではなく、独立した HSPI バスを使用します。
// これにより、画面描画との干渉を防ぎ、安定してSDを読み込めます。
SPIClass SPI_SD(HSPI);
File csvFile; // 開いたファイルを操作するための変数
// ==========================================
// データ読み込み設定
// ==========================================
// ★1行のバイト数(固定長読み込み用)
// データ文字数(189) + 改行コード(\n=1) = 190バイト
// ※Windowsで作成したCSVで色がズレる場合は、ここを 191 (\r\n) に変更してください
const int LINE_LEN = 190;
// フレームレート設定 (30fps想定)
// 1000ms ÷ 30fps = 33.333... ms
const float FRAME_DURATION = 33.333;
// ==========================================
// 再生制御用変数
// ==========================================
uint32_t startTime = 0; // 再生開始時刻 (リセットの基準点)
uint32_t lastUiMs = 0; // 画面表示を更新した最後の時刻
// --------------------------------------------------
// setup: 電源ON時に一度だけ実行される初期化処理
// --------------------------------------------------
void setup() {
// M5本体の初期化
auto cfg = M5.config();
M5.begin(cfg);
// 画面設定(横向き、文字サイズ)
M5.Display.setRotation(1);
M5.Display.setTextSize(1);
// LED初期化
strip.begin();
strip.setBrightness(10); // 眩しすぎないように明るさを制限 (0-255)
strip.show(); // 一旦消灯
// ★重要:SD専用のSPIバスを開始
// ここでピン割り当てを設定します
SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
// ★重要:SDカードのマウント
// 第2引数に、さきほど作った SPI_SD を渡します。これが成功の鍵です。
// 安定性を高めるため、通信速度を 15MHz に抑えています。
if (!SD.begin(SD_CS, SPI_SD, 15000000)) {
M5.Display.setTextColor(TFT_RED);
M5.Display.println("SD Init Fail!"); // 初期化失敗
M5.Display.println("Check connections");
while(1); // 停止
}
// CSVファイルのオープン確認
if (SD.exists("/led15_log.csv")) {
csvFile = SD.open("/led15_log.csv", FILE_READ); // 読み取り専用で開く
M5.Display.println("CSV Loaded!");
M5.Display.println("Playing...");
} else {
M5.Display.setTextColor(TFT_RED);
M5.Display.println("No /led15_log.csv"); // ファイルが見つからない
while(1); // 停止
}
// 現在時刻をスタート時間として記録
startTime = millis();
}
// --------------------------------------------------
// loop: ずっと繰り返されるメイン処理
// --------------------------------------------------
void loop() {
M5.update(); // ボタン状態の更新
// === リセット機能 ===
// 画面下の大きなボタン(BtnA)が押されたら、頭出しする
if (M5.BtnA.wasPressed()) {
startTime = millis(); // スタート時間を「今」に更新
M5.Display.setCursor(0, 60);
M5.Display.println("Replay! ");
}
// === 再生処理 ===
if (csvFile) {
// 1. 動画の「今あるべき時間」を計算
// (現在のマイコン時刻 - スタート時刻)
int32_t currentVideoTime = millis() - startTime;
// マイナスにならないようガード
if (currentVideoTime < 0) currentVideoTime = 0;
// 2. その時間は「何行目」のデータか計算
// 時間 ÷ 1フレームあたりの時間
long targetLine = currentVideoTime / FRAME_DURATION;
// 3. その行は「ファイルの先頭から何バイト目」か計算
// 行番号 × 1行のバイト数
long seekPos = targetLine * LINE_LEN;
// ファイルの範囲内なら読み込み実行
if (seekPos < csvFile.size()) {
// 計算した位置へ一瞬でワープ (seek)
csvFile.seek(seekPos);
// そこから1行まるごと読み込む
String line = csvFile.readStringUntil('\n');
// データが正しく読めていれば解析へ
// (ゴミデータを避けるため長さチェック)
if (line.length() > 180) {
parseAndShow(line);
}
} else {
// ファイルの最後まで来たら、最初からループ再生
startTime = millis();
}
}
// === 画面情報の更新 ===
// 毎回描画すると処理が重くなりLEDが遅れるので、1.0秒に1回だけ更新
if (millis() - lastUiMs >= 1000) {
lastUiMs = millis();
int32_t t = millis() - startTime;
M5.Display.setCursor(0, 40);
// 現在の再生時間を表示 (例: Time: 12345 ms)
M5.Display.printf("Time: %d ms ", (t > 0 ? t : 0));
}
}
// --------------------------------------------------
// parseAndShow: CSV解析&LED点灯関数
// --------------------------------------------------
// 引数 line の中身例: "000001234,255,000,000,000,255,..."
void parseAndShow(String line) {
// CSVは固定長(文字数が決まっている)なので、
// 検索処理をせず、文字数カウントだけで高速にデータを抜き出します。
// 時刻(9文字) + カンマ(1文字) = 最初の10文字はLEDデータではないので飛ばす
int charIndex = 10;
for (int i = 0; i < LED_COUNT; i++) {
// --- 赤(R) ---
// substring(開始位置, 終了位置) で "255" などの数字を切り出す
String sR = line.substring(charIndex, charIndex + 3);
charIndex += 4; // 3文字(数字) + 1文字(カンマ) 進める
// --- 緑(G) ---
String sG = line.substring(charIndex, charIndex + 3);
charIndex += 4;
// --- 青(B) ---
String sB = line.substring(charIndex, charIndex + 3);
charIndex += 4;
// 切り出した文字列を整数(int)に変換
int r = sR.toInt();
int g = sG.toInt();
int b = sB.toInt();
// LEDに色をセット
// gamma32() を通すと、人間の目に自然な明るさに補正されます
strip.setPixelColor(i, strip.gamma32(strip.Color(r, g, b)));
}
// 最後に show() を呼ぶことで、セットした色が実際に光ります
strip.show();
}
コード解説
このプログラムには、処理落ちを防ぎ、正確な同期を実現するための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); // その場所へワープ!
これにより、もし何らかの理由で処理が一瞬止まっても、復帰した瞬間に「遅れた分を取り戻して、今の時間に正しいフレーム」を即座に読み出すことができます。これが「音ズレ」を防ぐポイントです。

動作確認
- コードを書き込むと、自動的にLEDが光り始めます。
- 本体正面のボタンA(大きなボタン) を押してみてください。「Replay!」と表示され、アニメーションが最初からリスタートすれば成功です。
- LEDの光り方が、元の動画の動きと合っているか確認しましょう(Wi-Fi同期していないので、厳密なタイミングはズレていますが、動きのスピード感が合っていればOKです)。

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