Step4: UDPによる時刻同期

前回のStep3ではM5StickC単体での再生を行いましたが、Step4ではいよいよPC(p5.js)と連携させます。 PC側で再生している動画の「現在時刻」を、Wi-Fi(UDP通信)を経由してM5StickCに通知し、ズレを自動補正するシステムを構築します。

システムの仕組み

このシステムは「PCが指揮者、M5StickCが演奏者」の関係になります。

  • PC (p5.js):
    • 自動送信:
      • 動画再生中は 1秒おき に「現在の再生時間(ミリ秒)」を送信します。
    • 手動操作:
      • シークバーを操作した瞬間や、デバッグ用コマンド(’c’キー)なども送信します。

  • M5StickC Plus2 (受信機):
    • 基本動作:
      • 普段は自分の時計(millis())を使って、自律的にSDカードのデータを読み進めます(Step3と同じ)。
    • 同期補正:
      • PCから「今は1000msだよ!」という時刻データが届くと、自分の持っている時刻とのズレ(オフセット)を修正し、ピタッと合わせます。

1. PC側:p5.js (sketch.js)

PC側のプログラムです。 sketch.js を以下のコードに書き換えてください。
bridge.jsindex.html は前回の「step05_video_color_udp_sync」のままでOKです。

bridge.js
/**
 * UDP Bridge Server (Node.js)
 * 役割: p5.js (WebSocket) と M5StickC (UDP) の間の通訳
 * 理由: ブラウザは直接UDP通信ができないため、このNode.jsアプリが仲介します。
 */

// --- 1. 必要なライブラリの読み込み ---
const WebSocket = require('ws');   // WebSocket通信用
const dgram = require('dgram');    // UDP通信用

// --- 2. 通信設定(定数) ---

const WS_PORT = 8080;       // p5.js (ブラウザ) からの接続を受け付けるポート
const UDP_PORT = 8000;      // M5StickC (マイコン) へデータを送信する先のポート

// ========== 送信先の設定(どちらか片方を選んでください) ==========

// 【パターンA】 全員に送る(ブロードキャスト) ※現在はこっちが有効
// '255.255.255.255' を指定すると、同じWi-Fi内の全デバイスに届きます
// const UDP_HOST = '255.255.255.255'; //ブロックしがち
const UDP_HOST = '192.168.1.255'; //ルーター繋がっている人のみ
// 192.168.1.255 (ディレクテッド・ブロードキャスト): 
// 「192.168.1.xxx のグループの皆さん!」と宛先を絞った投げ方です。

// 【パターンB】 特定の1台だけに送る(ユニキャスト)
// ※ 特定の相手に送る場合は、上のパターンAをコメントアウト(//)し、
//    下のコメント(//)を外して、M5の画面に表示されたIPアドレスを書いてください。
// const UDP_HOST = '192.168.1.104';  // 例: M5の画面に出ているIP

// =============================================================


// --- 3. UDPクライアント(送信機)の準備 ---

// IPv4形式のUDPソケット(通信の出入り口)を作成します
const udpClient = dgram.createSocket('udp4');

// ソケットをシステムにバインド(紐付け)して準備完了状態にします
udpClient.bind(() => {
    // ブロードキャスト(一斉送信)機能を有効にします
    // ※パターンB(特定送信)の場合でも、この行はそのままで問題ありません
    udpClient.setBroadcast(true);
});


// --- 4. WebSocketサーバー(受信機)の準備 ---

// 指定したポートでWebSocketサーバーを立ち上げます
const wss = new WebSocket.Server({ port: WS_PORT });

// サーバーが起動したことをターミナルに表示します
console.log(`Bridge running... WS:${WS_PORT} -> UDP:${UDP_HOST}:${UDP_PORT}`);


// --- 5. 通信のメイン処理 ---

// ブラウザ(p5.js)から接続があったときに実行されるイベント
wss.on('connection', ws => {
    console.log('p5.js connected!');

    // ブラウザからメッセージ(色データなど)が届いたときに実行されるイベント
    ws.on('message', message => {
        // ★デバッグ用:届いたデータをターミナルに表示!
        // これが表示されなければ、p5.js側がおかしいです
        console.log(`RX: ${message}`);

        // 受け取ったメッセージをUDPで送れる「Buffer(バイト列)」に変換します
        const msgBuffer = Buffer.from(message.toString());

        // 変換したデータをUDPを使って送信します
        udpClient.send(msgBuffer, 0, msgBuffer.length, UDP_PORT, UDP_HOST, (err) => {
            if (err) console.error(err);
        });
    });
});

// --- 6. 終了処理(お行儀よく終わる設定) ---

// ターミナルで「Ctrl + C」が押されたときを検知します
process.on('SIGINT', () => {
    console.log('\n[SYS] SIGINT: closing...');
    udpClient.close();                 // UDPソケットを閉じる
    wss.close(() => process.exit(0));  // WebSocketサーバーを閉じて終了
});
index.html
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>UDP</title>
    <script src="https://cdn.jsdelivr.net/npm/p5@1.10.0/lib/p5.min.js"></script>
    <style>
        body {
            margin: 0;
            font: 14px/1.4 system-ui;
            overflow: hidden;
        }

        #ui {
            position: fixed;
            left: 8px;
            top: 8px;
            background: #0008;
            color: #fff;
            padding: 8px;
            border-radius: 8px;
        }
    </style>
</head>

<body>
    <div id="ui">
        status: <span id="st">connecting...</span><br>
        RGB: <span id="rgb">-</span>
    </div>
    <script src="./sketch.js"></script>
</body>

</html>

主な変更点:

  • socket.send() を使い、
    • 動画再生中は 定期的に現在時刻(ms)を送信
  • シークバー操作時に、
    • 即座にその時刻を送信し、M5 側の反応を高速化
  • c キーを押すと、
    • デバッグ用の UDP コマンドを送信(LED 赤点灯確認用)
コード(sketch.js

// === 設定 ===
const LED_NUM = 15;
const CANVAS_W = 320;
const CANVAS_H = 240;
const SYNC_INTERVAL = 500; // 0.5秒ごとに時刻同期

// === グローバル変数 ===
let vid;
let socket;
let rgbText;
let lastSendTime = 0;

const wsUrl = 'ws://localhost:8080';

function preload() {
  vid = createVideo("clip0.mp4");
}

function setup() {
  createCanvas(CANVAS_W, CANVAS_H);
  vid.size(CANVAS_W, 180);
  vid.hide();
  vid.loop(); // 自動再生開始

  rgbText = document.getElementById("rgb");

  // WebSocket接続
  socket = new WebSocket(wsUrl);
  socket.onopen = () => console.log("WS Connected");
}

function draw() {
  background(20);

  // === 動画描画 (Coverモード) ===
  let vidRatio = vid.width / vid.height;
  let canvasRatio = width / height;
  let w, h;
  if (canvasRatio > vidRatio) {
    w = width; h = w / vidRatio;
  } else {
    h = height; w = h * vidRatio;
  }
  let x = (width - w) / 2;
  let y = (height - h) / 2;
  image(vid, x, y, w, h);

  // === 現在時刻の取得 ===
  let currentSeconds = vid.time();        // 現在の秒数 (小数)
  let totalSeconds = vid.duration();      // 動画の長さ (秒)
  let currentMs = floor(currentSeconds * 1000);

  // === シークバー描画 ===
  // 画面の一番下に進行状況バーを表示     
  let progress = currentSeconds / totalSeconds;
  noStroke();
  fill(100);
  rect(0, height - 10, width, 10); // 背景バー
  fill(255, 0, 0);
  rect(0, height - 10, width * progress, 10); // 進捗バー

  // === UIテキスト ===
  fill(255); textSize(16);
  text(`Time: ${currentMs} ms`, 20, 30);
  text("[Space]:Play/Pause  [c]:Debug  [Click Bar]:Seek", 20, 50);

  // === プレビュー ===
  let c = get(width / 2, height / 2);
  rgbText.innerHTML = `${c[0]}, ${c[1]}, ${c[2]}`;
  fill(c); rect(width - 60, height - 60, 50, 50);

  // === 時刻送信 (Sync) ===
  if (socket.readyState === WebSocket.OPEN) {
    // 再生中、かつ一定時間経ったら送信
    // ※シーク直後などに即座に送るためのフラグ管理もできますが、
    //   今回はシンプルに定期送信のみにしています
    if (millis() - lastSendTime > SYNC_INTERVAL) {
      lastSendTime = millis();
      socket.send(currentMs.toString());
    }
  }
}

// === マウス操作 (シーク機能) ===
function mousePressed() {
  // 画面下部(シークバーエリア)をクリックしたら
  if (mouseY > height - 20) {
    let totalSeconds = vid.duration();
    // クリックした横位置(mouseX)から新しい時間を計算
    let newTime = map(mouseX, 0, width, 0, totalSeconds);
    
    // 動画をジャンプさせる!
    vid.time(newTime);
    
    // ジャンプした瞬間の時間をすぐにM5へ送る(反応を良くするため)
    let newMs = floor(newTime * 1000);
    if (socket.readyState === WebSocket.OPEN) {
       socket.send(newMs.toString());
    }
  }
}

// === キー操作 ===
function keyPressed() {
  if (key === 'c' || key === 'C') {
    if (socket.readyState === WebSocket.OPEN) {
      console.log("Sent: DEBUG_RED");
      socket.send("DEBUG_RED");
    }
  }

  if (key === ' ') {
    // 再生・一時停止トグル
    // (vid.elt.paused はHTML Video要素のプロパティ)
    if (vid.elt.paused) {
      vid.loop(); // 再開
    } else {
      vid.pause(); // 停止
    }
  }
}

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
}

2. M5側:M5StickC Plus2 (受信&再生)

これまでの SD カード CSV 再生コード(Step3) に、
UDP 受信処理と同期ロジックを統合しています。

UDP で受け取る内容

  • "DEBUG_RED"
    • デバッグ用
    • LED を全赤にして再生を一時停止 / 再開
  • "12345" のような数値文字列
    • PC 側の 動画時刻(ms)

同期処理の流れ

  1. UDP を受信
  2. メッセージを String に変換し trim()
  3. 数値なら時刻として解釈
  4. 以下を更新
baseVideoTime = pcTime;
baseMillis    = millis();

「PCから送られてきた動画の時刻」と、「それを受け取った瞬間のM5の起動時間」をセットで記録します。 これにより、「現在時刻 = 基準動画時刻 + (今のM5時間 – 受け取った時のM5時間)」 という計算で、通信がない間も正確な動画時間を推測し続けることができます。

CSV 再生の仕組み(重要)

固定長 CSV を使う理由

本コードでは 高速・安定再生のために「固定長 CSV」 を採用しています。

  • 1 行 = 1 フレーム(30fps)
  • 行番号 = 動画フレーム番号
  • seek() で直接ジャンプ可能
targetLine = currentVideoTime / FRAME_DURATION;
seekPos    = targetLine * LINE_LEN;

注意点

  • LINE_LENCSV の 1 行のバイト数と完全一致させる必要があります
  • Windows 改行(\r\n)の場合は 191 に調整することがあります
  • RGB 値は必ず 3 桁ゼロ埋め(000〜255)

コード

※注意: コード内の WIFI_SSIDWIFI_PASS はご自身の環境に合わせて書き換えてください。

コード(step4_m5_sd_player.ino)
/**
 * M5StickC Plus2 SD CSV Player + UDP Sync (Multi-CSV, Clean UI)  [Robust Fixed-Length Reader]
 *
 * ■ 機能
 * - SDカード上のCSV(固定長)を30fps相当でseek再生し、WS2812B(15LED)を点灯
 * - UDPで受け取った「PC側の動画時刻(ms)」を基準に同期再生
 * - BtnA: 現在のCSVを 0ms に頭出し (= 同期基準をリセット)
 * - BtnB: 次のCSVへ切替 → 0msへ頭出しして再生
 *
 * ■ UDP受信内容
 * - "DEBUG_RED" を受け取ると、デバッグでLED全赤のON/OFF切替(再生停止)
 * - それ以外は "12345" のような数値文字列を「基準動画時刻(ms)」として解釈
 *
 * ■ 重要
 * - CSVは「行の長さが固定」である必要があります(LINE_LENと一致)
 * - RGB値は 000〜255 の3桁ゼロ埋め前提(例: 005, 120, 255)
 */

// ==================================================
// include(必要なライブラリ)
// ==================================================
#include <M5Unified.h>          // M5StickC Plus2 本体(画面/ボタン/電源/基礎)
#include <Adafruit_NeoPixel.h>  // WS2812B(NeoPixel)制御
#include <SD.h>                 // SDカード読み書き
#include <SPI.h>                // SPIバス(SDはSPI接続)
#include <WiFi.h>               // Wi-Fi接続
#include <WiFiUdp.h>            // UDP通信(時刻同期の受信)

// ==================================================
// 設定(ここだけ触ればOK)
// ==================================================

// ---------- Wi-Fi / UDP ----------
// Wi-FiのSSID(アクセスポイント名)
const char* WIFI_SSID  = "TP-Link_A438";
// Wi-Fiのパスワード
const char* WIFI_PASS  = "38283789";
// M5側が待ち受けるUDPポート番号(PC側送信先もこれ)
const int   LOCAL_PORT = 8000;

// UDP受信用のオブジェクト
WiFiUDP udp;

// UDPで受け取った生データの一時格納バッファ
// ※大きすぎる必要はない("DEBUG_RED" や "1234567890" 程度を想定)
char packetBuffer[64];

// ---------- LED ----------
// WS2812Bのデータ入力ピン(M5StickC Plus2のGPIO)
#define LED_PIN   32
// LEDの個数
#define LED_COUNT 15

// NeoPixelストリップのオブジェクト
// NEO_GRB: WS2812Bの色順(多くはGRB)
// NEO_KHZ800: 800kHz方式(WS2812B標準)
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

// ---------- SD / SPI (HSPIをSD専用) ----------
// SDをVSPIではなくHSPIで使うためのピン定義
// (M5StickC Plus2環境での配線前提。授業配布のSDモジュールに合わせている想定)
const int SD_SCK  = 0;   // SCK(クロック)
const int SD_MISO = 36;  // MISO(SD→M5)
const int SD_MOSI = 26;  // MOSI(M5→SD)
const int SD_CS   = 33;  // CS(チップセレクト)

// HSPIを使うためのSPIClassインスタンス
SPIClass SPI_SD(HSPI);

// 現在開いているCSVファイル(再生対象)
File csvFile;

// ---------- CSVリスト ----------
// 再生候補CSVのパス一覧(SDカード直下を想定)
// ※存在しないファイルがあると openCsvByIndex() でエラー表示
const char* CSV_LIST[] = {
  "/clip0.csv",
  "/clip.csv",
  "/clip1.csv",
  "/clip2.csv",
  "/clip3.csv",
  "/clip4.csv",
  "/clip5.csv",
  "/clip6.csv",
  "/clip7.csv",
  "/clip8.csv",
  "/clip9.csv",
  "/clip10.csv",
  "/clip11.csv",
  "/clip12.csv",
  "/clip13.csv",
};

// CSVの数(配列長から自動計算)
const int CSV_COUNT = sizeof(CSV_LIST) / sizeof(CSV_LIST[0]);

// 現在再生しているCSVのインデックス(0〜CSV_COUNT-1)
int currentCsvIndex = 0;

// ---------- CSV固定長 / 再生設定 ----------
// ★重要:固定長読み取りの「1行あたりのバイト数」
// - これとCSV実ファイルの「1行のバイト数」が一致しないとseekがズレて破綻する
// - Windows改行(\r\n)でズレる場合は 191 に変更することがある(末尾\r分)
#define LINE_LEN 190

// 30fps想定:1フレーム時間(ms)
// 1000/30 = 33.333...
const float FRAME_DURATION = 33.333f;

// 固定長読み取り用バッファ(終端\0込みで+1)
// ※readBytesでLINE_LEN読み、最後に'\0'でC文字列化してparseしやすくする
char lineBuf[LINE_LEN + 1];

// ==================================================
// 同期用(UDP基準点)
// ==================================================

// UDPで受信した「PC側の動画時刻(ms)」
// これが「基準となる動画時刻」
uint32_t baseVideoTime = 0;

// その基準動画時刻を受け取った瞬間のマイコンmillis()
// baseVideoTime + (millis()-baseMillis) で「現在の動画時刻」を推定する
uint32_t baseMillis    = 0;

// ==================================================
// 状態管理
// ==================================================

// DEBUG_REDモードかどうか(ONなら再生停止&全LED赤)
bool isDebugRed = false;

// UI更新の周期制御用(最後にUIを描いた時刻)
uint32_t lastUiMs = 0;

// ==================================================
// UI描画領域(画面のどこに何を描くか)
// ==================================================

// 画面左上x座標(基本0)
const int UI_X = 0;
// 画面幅(M5StickC Plus2横向きで240)
const int UI_W = 240;

// ヘッダ領域(上の帯)
const int HEADER_Y = 0;
const int HEADER_H = 40;

// ボディ領域(情報表示エリア)
const int BODY_Y   = 40;
const int BODY_H   = 96;

// ==================================================
// utility(小物関数)
// ==================================================

// 0〜255の範囲に丸める(壊れたデータ対策)
static inline int clamp255(int v) {
  if (v < 0) return 0;
  if (v > 255) return 255;
  return v;
}

// "000"〜"255" の3桁ASCIIを int に(ゼロ埋め前提)
// 例: "005" -> 5, "120" -> 120
// p は 3文字以上ある前提で呼ばれる(固定長CSV設計)
static inline int parse3(const char* p) {
  // '0'〜'9' 以外が来たら壊れてるので0扱い(安全側)
  if (p[0] < '0' || p[0] > '9') return 0;
  if (p[1] < '0' || p[1] > '9') return 0;
  if (p[2] < '0' || p[2] > '9') return 0;

  // 3桁を数値化
  return (p[0]-'0')*100 + (p[1]-'0')*10 + (p[2]-'0');
}

// 現在の動画時刻(ms)を推定して返す
// 「最後に受け取ったPC時刻(baseVideoTime)」に
// 「それから経過したマイコン時間(millis()-baseMillis)」を足す
uint32_t getCurrentVideoTime() {
  uint32_t elapsed = millis() - baseMillis;
  return baseVideoTime + elapsed;
}

// ==================================================
// 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("UDP Sync SD Player");

  // 再生中ファイル名
  M5.Display.setCursor(0, 12);
  M5.Display.printf("File: %s", CSV_LIST[currentCsvIndex]);

  // 操作ガイド
  M5.Display.setCursor(0, 24);
  M5.Display.println("A:Reset  B:Next  UDP:Sync");
}

// ボディ(動的情報:時刻・基準・IP・RSSI・デバッグ状態)
void drawBody() {
  // ボディ領域を黒で塗りつぶし
  M5.Display.fillRect(UI_X, BODY_Y, UI_W, BODY_H, TFT_BLACK);

  // 現在推定している動画時刻
  uint32_t t = getCurrentVideoTime();

  // 基準受信からの経過ms
  uint32_t elapsed = millis() - baseMillis;

  // 表示:現在時刻
  M5.Display.setCursor(0, BODY_Y + 0);
  M5.Display.setTextColor(TFT_CYAN);
  M5.Display.printf("Time : %lu ms", t);

  // 表示:基準動画時刻(baseVideoTime)と経過時間(elapsed)
  M5.Display.setCursor(0, BODY_Y + 12);
  M5.Display.setTextColor(TFT_YELLOW);
  M5.Display.printf("BaseV: %lu  elap:%lu", baseVideoTime, elapsed);

  // 表示:基準時のmillis(baseMillis)と現在millis()
  M5.Display.setCursor(0, BODY_Y + 24);
  M5.Display.setTextColor(TFT_YELLOW);
  M5.Display.printf("BaseM: %lu  ms:%lu", baseMillis, (uint32_t)millis());

  // 表示:IPアドレス(Wi-Fi接続確認用)
  M5.Display.setCursor(0, BODY_Y + 36);
  M5.Display.setTextColor(TFT_GREEN);
  M5.Display.printf("IP   : %s", WiFi.localIP().toString().c_str());

  // 表示:電波強度RSSI(接続不安定の切り分け用)
  M5.Display.setCursor(0, BODY_Y + 48);
  M5.Display.setTextColor(TFT_WHITE);
  M5.Display.printf("RSSI : %d dBm", WiFi.RSSI());

  // 表示:DEBUG_RED状態
  M5.Display.setCursor(0, BODY_Y + 60);
  if (isDebugRed) {
    M5.Display.setTextColor(TFT_RED);
    M5.Display.print("DEBUG_RED: ON (play stop)");
  } else {
    M5.Display.setTextColor(TFT_WHITE);
    M5.Display.print("DEBUG_RED: OFF");
  }

  // デバッグ用:LINE_LEN表示(固定長ズレ原因の切り分けに便利)
  M5.Display.setCursor(0, BODY_Y + 72);
  M5.Display.setTextColor(TFT_DARKGREY);
  M5.Display.printf("LINE_LEN=%d", LINE_LEN);
}

// ==================================================
// CSV open(CSVファイルの切替・オープン)
// ==================================================

// 指定インデックスのCSVを開く
// 成功: true / 失敗: false(画面にエラー表示)
bool openCsvByIndex(int idx) {
  // すでにファイルを開いていたら閉じる(ファイルハンドル枯渇防止)
  if (csvFile) csvFile.close();

  // 開きたいパス
  const char* path = CSV_LIST[idx];

  // まず存在確認(SDにないなら即エラー表示)
  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);

  // open失敗(存在はするが開けない/SD不調など)
  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;
  }

  // ファイル切替時は「動画時刻0msから再生」扱いにする
  baseVideoTime = 0;       // 動画時刻を0に
  baseMillis    = millis(); // その瞬間を基準に
  isDebugRed    = false;    // デバッグ停止を解除

  // UI更新
  drawHeader();
  drawBody();

  return true;
}

// ==================================================
// UDP(受信処理:時刻同期/デバッグコマンド)
// ==================================================

// UDPパケットが来ていたら読む
void handleUdp() {
  // 受信パケットがあるか(なければ0)
  int packetSize = udp.parsePacket();
  if (!packetSize) return;

  // 最大63文字だけ読む(末尾に'\0'を置くため)
  int len = udp.read(packetBuffer, 63);
  if (len <= 0) return;

  // C文字列として扱えるよう終端を入れる
  packetBuffer[len] = 0;

  // 前後の空白/改行を落とす("123\n" や " 123 " でも死なないように)
  String msg = String(packetBuffer);
  msg.trim();

  // デバッグコマンド:DEBUG_REDが含まれるならトグル
  if (msg.indexOf("DEBUG_RED") >= 0) {
    // ON/OFF切り替え
    isDebugRed = !isDebugRed;

    // ONになった瞬間はLEDを全赤に固定して即表示
    // ※OFFになった場合は次のplayFromCsv()で通常表示に戻る
    if (isDebugRed) {
      for (int i = 0; i < LED_COUNT; i++) {
        strip.setPixelColor(i, strip.Color(255, 0, 0));
      }
      strip.show();
    }
    return;
  }

  // それ以外は「PC側動画時刻(ms)」の数値として解釈する
  // strtoulで文字列→数値化(不正なら0になりやすい)
  uint32_t pcTime = (uint32_t) strtoul(msg.c_str(), NULL, 10);

  // pcTime>0 または msg=="0" を有効として採用
  // (0ms同期もできるよう "0" は特別扱い)
  if (pcTime > 0 || msg.equals("0")) {
    baseVideoTime = pcTime;   // 新しい基準動画時刻
    baseMillis    = millis(); // 受信した瞬間のマイコン時刻(ここが同期の基準点)
  }
}

// ==================================================
// CSV parse & LED show(固定長行の解釈→LED反映)
// ==================================================

// 1行分(固定長)を解析してLEDに反映する
void parseAndShowFixed(const char* line) {
  // 想定フォーマット:
  // [0..8]  : 9桁の時刻(ダミーでもOK。ここでは参照しない)
  // [9]     : ',' であることを想定(最低限の整合性チェック)
  //
  // 以降は  LED_COUNT 個ぶん繰り返し:
  // "RRR,GGG,BBB," を固定で並べる(各3桁+カンマ)
  //
  // ※この関数は「固定位置読み」なので、CSV生成側と設計一致が絶対条件

  // 先頭9桁時刻の直後が','でなければフォーマット不正なので無視
  // (壊れた行で変なインデックス参照して暴走しないため)
  if (line[9] != ',') return;

  // idx: 色データ開始位置(時刻9桁 + ',' = 10文字を飛ばす)
  int idx = 10;

  // LED 0..LED_COUNT-1 まで色を読む
  for (int i = 0; i < LED_COUNT; i++) {
    // R: "RRR," の先頭3桁を読む → idxを4進める
    int r = parse3(&line[idx]); idx += 4;

    // G: "GGG," を読む
    int g = parse3(&line[idx]); idx += 4;

    // B: "BBB," を読む
    int b = parse3(&line[idx]); idx += 4;

    // 念のため0..255に丸め
    r = clamp255(r);
    g = clamp255(g);
    b = clamp255(b);

    // ガンマ補正してセット(見た目の明るさの線形性が良くなる)
    strip.setPixelColor(i, strip.gamma32(strip.Color(r, g, b)));
  }

  // 実際にLEDへ反映
  strip.show();
}

// ==================================================
// play(現在時刻→対象行seek→読み取り→表示)
// ==================================================

// 現在推定している動画時刻に対応する行をSDから読み、LEDに表示する
void playFromCsv() {
  // DEBUG_RED中は再生停止(LEDは固定赤のまま)
  if (isDebugRed) return;

  // ファイルが開いていなければ何もしない
  if (!csvFile) return;

  // 現在の動画時刻(ms)
  uint32_t currentVideoTime = getCurrentVideoTime();

  // 対象フレーム番号(30fps換算):時刻 / 33.333ms
  // 例:1000msなら約30フレーム
  long targetLine = (long)(currentVideoTime / FRAME_DURATION);

  // 固定長なので seek位置 = 行番号 * 1行バイト数
  long seekPos = targetLine * (long)LINE_LEN;

  // ファイルサイズ
  long sz = (long)csvFile.size();

  // seekPosからLINE_LENバイト読めるか確認(範囲外なら末尾)
  if (seekPos + (long)LINE_LEN <= sz) {
    // 指定位置に移動
    csvFile.seek(seekPos);

    // LINE_LENバイトを一気に読む(固定長前提)
    int n = csvFile.readBytes(lineBuf, LINE_LEN);

    // 期待長さと違う → SD読み取り不安定 or seekズレ or 接触不良など
    // ここでは「再同期(0msへ頭出し)」して安全側に倒す
    if (n != LINE_LEN) {
      baseVideoTime = 0;
      baseMillis = millis();
      return;
    }

    // C文字列化(parse関数が'\0'終端を期待するため)
    lineBuf[LINE_LEN] = '\0';

    // Windows改行(\r\n)対策:
    // 固定長の末尾に '\r' が混じるケースがあるので落とす
    // (ただし本質はCSV生成側でLINE_LENを一致させること)
    if (LINE_LEN >= 1 && lineBuf[LINE_LEN - 1] == '\r') {
      lineBuf[LINE_LEN - 1] = '\0';
    }

    // 解析してLED反映
    parseAndShowFixed(lineBuf);

  } else {
    // 末尾到達(または過ぎた)→ ループ再生として0msに戻す
    baseVideoTime = 0;
    baseMillis = millis();
  }
}

// ==================================================
// setup(起動時1回だけ)
// ==================================================
void setup() {
  // M5の初期設定を取得(電源管理/周辺設定など)
  auto cfg = M5.config();

  // M5開始
  M5.begin(cfg);

  // 画面を横向き(1)にする
  M5.Display.setRotation(1);

  // 文字サイズ(小さめ)
  M5.Display.setTextSize(1);

  // 画面クリア
  M5.Display.fillScreen(TFT_BLACK);

  // NeoPixel初期化
  strip.begin();

  // 明るさ(0〜255)※眩しすぎ防止
  strip.setBrightness(10);

  // 一旦全消灯を反映
  strip.show();

  // ---- SD用SPI初期化(HSPI)----
  // begin(SCK, MISO, MOSI, SS)
  SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);

  // ---- SDマウント ----
  // SD.begin(CS, SPI, freq)
  // freqは速すぎると不安定になることがある(ここは15MHz)
  if (!SD.begin(SD_CS, SPI_SD, 15000000)) {
    // SD初期化失敗なら画面に出して停止(授業ではここで配線/差し込み確認)
    M5.Display.setTextColor(TFT_RED);
    M5.Display.setCursor(0, 0);
    M5.Display.println("SD Init Fail!");
    while (1) { delay(100); }
  }

  // ---- 初期CSVを開く ----
  if (!openCsvByIndex(currentCsvIndex)) {
    // 初期ファイルが開けないなら停止(ファイル名/配置/SD確認)
    while (1) { delay(100); }
  }

  // ---- Wi-Fi接続 ----
  // 接続中表示
  M5.Display.setCursor(0, 120);
  M5.Display.setTextColor(TFT_WHITE);
  M5.Display.print("WiFi connecting");

  // STAモード(アクセスポイントに接続する側)
  WiFi.mode(WIFI_STA);

  // 接続開始
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  // 接続完了まで待つ(ここはブロッキング)
  // ※授業で「確実に接続してから進める」ためにシンプルにしている
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
    M5.Display.print(".");
  }

  // ---- UDP開始(受信待ち受け)----
  udp.begin(LOCAL_PORT);

  // ---- UI初期描画 ----
  M5.Display.fillScreen(TFT_BLACK);
  drawHeader();
  drawBody();

  // ---- 同期基準初期化(0ms)----
  baseVideoTime = 0;
  baseMillis = millis();
}

// ==================================================
// loop(メインループ)
// ==================================================
void loop() {
  // M5のボタン状態更新などを内部で更新
  M5.update();

  // 1) UDP受信処理(来てたら基準時刻を更新)
  handleUdp();

  // 2) ボタン処理
  // BtnA: 0msに頭出し(同期基準をリセット)
  if (M5.BtnA.wasPressed()) {
    baseVideoTime = 0;      // 動画時刻を0に
    baseMillis = millis();  // その瞬間を基準に
    isDebugRed = false;     // デバッグ停止解除
    drawBody();             // 表示更新
  }

  // BtnB: 次のCSVに切り替え(存在するファイルまで探索)
  if (M5.BtnB.wasPressed()) {
    // 次候補のインデックス(循環)
    int nextIdx = (currentCsvIndex + 1) % CSV_COUNT;

    // 開けたかどうか
    bool opened = false;

    // 最大CSV_COUNT回試す(= 全部探して見つからなければ諦める)
    for (int tries = 0; tries < CSV_COUNT; tries++) {
      currentCsvIndex = nextIdx;

      // 開けたら終了
      if (openCsvByIndex(currentCsvIndex)) {
        opened = true;
        break;
      }

      // 次へ(循環)
      nextIdx = (nextIdx + 1) % CSV_COUNT;
    }

    // どれも開けなかった(SDに該当ファイルがない等)→赤点灯で異常通知
    if (!opened) {
      isDebugRed = true;
      for (int i = 0; i < LED_COUNT; i++) {
        strip.setPixelColor(i, strip.Color(255, 0, 0));
      }
      strip.show();
    }
  }

  // 3) 再生処理(現在時刻に対応する行を表示)
  playFromCsv();

  // 4) UI更新(1秒に1回)
  // ※頻繁に更新すると画面ちらつき/負荷増になるので間引く
  if (millis() - lastUiMs >= 1000) {
    lastUiMs = millis();
    drawHeader();
    drawBody();
  }
}

使い方・動作確認

すべての準備が整ったら、以下の手順で動作を確認しましょう。

  1. M5StickCの起動:
    • Arduinoにコードを書き込みます。
    • Wi-Fiに接続されると、画面にIPアドレスが表示され、LEDが光り始めます(この時点ではまだPCと同期しておらず、自律再生しています)。

  2. Bridgeの起動:
    • VSCodeのターミナルで node bridge.js を実行します(前回の講義参照)。← 忘れないように
      • ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)

  3. p5.jsの起動:
    • ブラウザで index.html を開きます。動画が自動再生されます。
      • ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)

  4. 同期チェック:
    • 接続確認: p5.jsの画面上でキーボードの 「c」キー を押してください。UDP通信が成功していれば、M5StickCのLEDが一瞬「赤色」に点灯します。

    • シーク確認: p5.js画面下部の赤いシークバーをクリックして、動画時間を飛ばしてください。M5StickCのLEDも一瞬でそのシーンの光り方に切り替われば、完全同期成功です!