Step3: millis()に合わせてSDカード読み込みテスト

Step2でSDカードの読み書きができることは確認できましたが、動画と同期させるためには「1秒間に30回、正確なタイミングでデータを読み出す」というスピードが求められます。

ここではWi-FiやPCとの通信は一旦忘れ、M5StickC Plus2の内部時計(millis())だけを使って、「PCがなくても、電源を入れるだけで正しいリズムで光る」 自律再生プレイヤー を作成します。これができれば、あとは「基準となる時刻」をPCから受け取るだけで同期が完成します。

ここでの確認事項

  1. PCなし(Wi-Fiなし)での再生: M5StickC Plus2単体でLEDがアニメーションすることを確認する。
  2. 固定長シーク(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);                               // その場所へワープ!

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

動作確認

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

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

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