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() を使って、定期的に動画の現在時刻(ミリ秒)を送信する処理を追加しました。
  • シークバー(画面下部の赤いバー)をクリックした際、即座にその時間を送信してM5側の反応を良くしています。
コード(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カード再生コード」に「UDP受信処理」を合体させました。

baseVideoTime = pcTime;baseMillis = millis(); この2行が同期の核心です。

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

※注意: コード内の 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>
#include <Adafruit_NeoPixel.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>

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

// ---------- Wi-Fi / UDP ----------
const char* WIFI_SSID  = "TP-Link_A438";
const char* WIFI_PASS  = "38283789";
const int   LOCAL_PORT = 8000;

WiFiUDP udp;
char packetBuffer[64];

// ---------- LED ----------
#define LED_PIN   32
#define LED_COUNT 15
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

// ---------- SD / SPI (HSPIをSD専用) ----------
const int SD_SCK  = 0;
const int SD_MISO = 36;
const int SD_MOSI = 26;
const int SD_CS   = 33;

SPIClass SPI_SD(HSPI);
File csvFile;

// ---------- CSVリスト ----------
const char* CSV_LIST[] = {
  "/clip0.csv",
  "/clip.csv",
  "/clip1.csv",
  "/clip2.csv",
  "/clip3.csv",
  "/clip4.csv"
};
const int CSV_COUNT = sizeof(CSV_LIST) / sizeof(CSV_LIST[0]);
int currentCsvIndex = 0;

// ---------- CSV固定長 / 再生設定 ----------
// ★重要:配列サイズに使うので define 推奨
// Windows改行(\r\n)などでズレるなら 191 に変更
#define LINE_LEN 190

// 30fps想定:1フレームのms
const float FRAME_DURATION = 33.333f;

// 固定長読み取り用バッファ(終端\0込み)
char lineBuf[LINE_LEN + 1];

// ==================================================
// 同期用(UDP基準点)
// ==================================================
uint32_t baseVideoTime = 0;  // 受信した基準動画時刻(ms)
uint32_t baseMillis    = 0;  // その瞬間のmillis()

// ==================================================
// 状態管理
// ==================================================
bool isDebugRed = false;
uint32_t lastUiMs = 0;

// ==================================================
// UI描画領域
// ==================================================
const int UI_X = 0;
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
// ==================================================
static inline int clamp255(int v) {
  if (v < 0) return 0;
  if (v > 255) return 255;
  return v;
}

// "000"〜"255" の3桁ASCIIを int に(ゼロ埋め前提)
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;
  return (p[0]-'0')*100 + (p[1]-'0')*10 + (p[2]-'0');
}

// 現在の動画時刻(ms)
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");
}

void drawBody() {
  M5.Display.fillRect(UI_X, BODY_Y, UI_W, BODY_H, TFT_BLACK);

  uint32_t t = getCurrentVideoTime();
  uint32_t elapsed = millis() - baseMillis;

  M5.Display.setCursor(0, BODY_Y + 0);
  M5.Display.setTextColor(TFT_CYAN);
  M5.Display.printf("Time : %lu ms", t);

  M5.Display.setCursor(0, BODY_Y + 12);
  M5.Display.setTextColor(TFT_YELLOW);
  M5.Display.printf("BaseV: %lu  elap:%lu", baseVideoTime, elapsed);

  M5.Display.setCursor(0, BODY_Y + 24);
  M5.Display.setTextColor(TFT_YELLOW);
  M5.Display.printf("BaseM: %lu  ms:%lu", baseMillis, (uint32_t)millis());

  M5.Display.setCursor(0, BODY_Y + 36);
  M5.Display.setTextColor(TFT_GREEN);
  M5.Display.printf("IP   : %s", WiFi.localIP().toString().c_str());

  M5.Display.setCursor(0, BODY_Y + 48);
  M5.Display.setTextColor(TFT_WHITE);
  M5.Display.printf("RSSI : %d dBm", WiFi.RSSI());

  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
// ==================================================
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;
  }

  // ファイル切替時は0ms基準へ
  baseVideoTime = 0;
  baseMillis    = millis();
  isDebugRed    = false;

  drawHeader();
  drawBody();
  return true;
}

// ==================================================
// UDP
// ==================================================
void handleUdp() {
  int packetSize = udp.parsePacket();
  if (!packetSize) return;

  int len = udp.read(packetBuffer, 63);
  if (len <= 0) return;

  packetBuffer[len] = 0;

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

  if (msg.indexOf("DEBUG_RED") >= 0) {
    isDebugRed = !isDebugRed;
    if (isDebugRed) {
      for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, strip.Color(255, 0, 0));
      strip.show();
    }
    return;
  }

  // 数値時刻として解釈
  // msgが "0" or 数値>0 を有効
  uint32_t pcTime = (uint32_t) strtoul(msg.c_str(), NULL, 10);
  if (pcTime > 0 || msg.equals("0")) {
    baseVideoTime = pcTime;
    baseMillis    = millis();
  }
}

// ==================================================
// CSV parse & LED show (fixed length)
// ==================================================
void parseAndShowFixed(const char* line) {
  // 形式:先頭 9桁時刻 + ',' を想定
  // 例: "000012345,255,000,..." ではなく
  //     "000012345,255,000,..." でも可だが、ここは「3桁固定」前提
  // 先頭チェック(壊れた行で暴走しない)
  if (line[9] != ',') return;

  int idx = 10; // 時刻9桁 + ',' の次

  for (int i = 0; i < LED_COUNT; i++) {
    int r = parse3(&line[idx]); idx += 4; // "RRR,"
    int g = parse3(&line[idx]); idx += 4; // "GGG,"
    int b = parse3(&line[idx]); idx += 4; // "BBB,"

    r = clamp255(r);
    g = clamp255(g);
    b = clamp255(b);

    strip.setPixelColor(i, strip.gamma32(strip.Color(r, g, b)));
  }
  strip.show();
}

// ==================================================
// play
// ==================================================
void playFromCsv() {
  if (isDebugRed) return;
  if (!csvFile) return;

  uint32_t currentVideoTime = getCurrentVideoTime();
  long targetLine = (long)(currentVideoTime / FRAME_DURATION);
  long seekPos = targetLine * (long)LINE_LEN;

  long sz = (long)csvFile.size();

  // 行全部読める位置か
  if (seekPos + (long)LINE_LEN <= sz) {
    csvFile.seek(seekPos);

    int n = csvFile.readBytes(lineBuf, LINE_LEN);
    if (n != LINE_LEN) {
      // SD読み取りが不安定なときの保険:頭出しして再試行
      baseVideoTime = 0;
      baseMillis = millis();
      return;
    }

    lineBuf[LINE_LEN] = '\0';

    // 末尾が '\r' なら落とす(Windows改行対策)
    if (LINE_LEN >= 1 && lineBuf[LINE_LEN - 1] == '\r') {
      lineBuf[LINE_LEN - 1] = '\0';
    }

    parseAndShowFixed(lineBuf);

  } else {
    // 末尾到達 → ループ
    baseVideoTime = 0;
    baseMillis = millis();
  }
}

// ==================================================
// setup
// ==================================================
void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

  M5.Display.setRotation(1);
  M5.Display.setTextSize(1);
  M5.Display.fillScreen(TFT_BLACK);

  strip.begin();
  strip.setBrightness(10);
  strip.show();

  // SD用SPI
  SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);

  // 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!");
    while (1) { delay(100); }
  }

  // 初期CSV
  if (!openCsvByIndex(currentCsvIndex)) {
    while (1) { delay(100); }
  }

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

  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.update();

  // 1) UDP
  handleUdp();

  // 2) Buttons
  if (M5.BtnA.wasPressed()) {
    baseVideoTime = 0;
    baseMillis = millis();
    isDebugRed = false;
    drawBody();
  }

  if (M5.BtnB.wasPressed()) {
    int nextIdx = (currentCsvIndex + 1) % 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;
    }

    if (!opened) {
      isDebugRed = true;
      for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, strip.Color(255, 0, 0));
      strip.show();
    }
  }

  // 3) Play
  playFromCsv();

  // 4) UI update
  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も一瞬でそのシーンの光り方に切り替われば、完全同期成功です!