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);                               // その場所へワープ!

これにより、もし何らかの理由で処理が一瞬止まっても、復帰した瞬間に「遅れた分を取り戻して、今の時間に正しいフレーム」を即座に読み出すことができます。これが「音ズレ」を防ぐポイントです。

動作確認

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

ここまでのテストで、「M5StickC Plus2はSDカードのデータを正しい速度で再生できる」ことが証明されました。

次はいよいよ、Wi-Fiを使ってPCと接続し、完全同期システムを構築します。