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です。

主な変更点:

  • 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 はご自身の環境に合わせて書き換えてください。

コード(m5_sd_player.ino)
/**
 * M5StickC Plus2 UDP Sync Player (Detail Monitor)
 * * ■ 表示項目
 * - Time: 現在の動画再生時刻 (計算結果)
 * - BaseV: 最後にUDPで受け取った基準動画時刻
 * - BaseM: その時のマイコン起動時間(millis)
 * - IP: 本体のIPアドレス
 */

#include <M5Unified.h>
#include <Adafruit_NeoPixel.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>

// ==========================================
// 設定エリア
// ==========================================
const char* WIFI_SSID = "YOURSSID";
const char* WIFI_PASS = "YOURPASS";
const int   LOCAL_PORT = 8000;

#define LED_PIN 32
#define LED_COUNT 15

const int SD_SCK = 0, SD_MISO = 36, SD_MOSI = 26, SD_CS = 33;
const int LINE_LEN = 190;
const float FRAME_DURATION = 33.333;

// ==========================================
// グローバル変数
// ==========================================
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
SPIClass SPI_SD(HSPI);
File csvFile;
WiFiUDP udp;
char packetBuffer[64];

// 同期制御用
uint32_t baseVideoTime = 0; // 基準となる動画時刻
uint32_t baseMillis = 0;    // 基準となるマイコン時刻

bool     isDebugRed = false;
uint32_t lastUiMs = 0;
int32_t  lastReceivedPcTime = -1; // 表示用

// --------------------------------------------------
// setup
// --------------------------------------------------
void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  M5.Display.setRotation(1);
  
  // 情報をたくさん出すので文字を少し小さく調整
  M5.Display.setTextSize(1.8); 

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

  SPI_SD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
  if (!SD.begin(SD_CS, SPI_SD, 15000000)) {
    M5.Display.setTextColor(TFT_RED);
    M5.Display.println("SD Init Fail!");
    while(1);
  }
  
  if (SD.exists("/led15_log.csv")) {
    csvFile = SD.open("/led15_log.csv", FILE_READ);
    M5.Display.println("CSV Loaded!");
  } else {
    M5.Display.setTextColor(TFT_RED);
    M5.Display.println("No csv file");
    while(1);
  }

  M5.Display.print("WiFi...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500); M5.Display.print(".");
  }
  
  udp.begin(LOCAL_PORT);
  M5.Display.fillScreen(TFT_BLACK);
  
  baseVideoTime = 0;
  baseMillis = millis();
}

// --------------------------------------------------
// loop
// --------------------------------------------------
void loop() {
  M5.update();

  // === 1. UDP受信 ===
  int packetSize = udp.parsePacket();
  if (packetSize) {
    int len = udp.read(packetBuffer, 63);
    if (len > 0) {
      packetBuffer[len] = 0;
      String msg = String(packetBuffer);

      if (msg.indexOf("DEBUG_RED") >= 0) {
        isDebugRed = !isDebugRed;
        M5.Display.fillRect(0, 100, M5.Display.width(), 30, TFT_BLACK);
        M5.Display.setCursor(0, 100);
        if (isDebugRed) {
           M5.Display.setTextColor(TFT_RED);
           M5.Display.println("DEBUG: ON");
           for(int i=0; i<LED_COUNT; i++) strip.setPixelColor(i, strip.Color(255, 0, 0));
           strip.show();
        } else {
           M5.Display.setTextColor(TFT_WHITE);
           M5.Display.println("DEBUG: OFF");
        }
      } else {
        // 時刻同期パケット受信
        uint32_t pcTime = strtoul(packetBuffer, NULL, 10);
        if (pcTime > 0 || msg.equals("0")) {
           // ★基準点を更新
           baseVideoTime = pcTime;
           baseMillis = millis();
           lastReceivedPcTime = pcTime;
        }
      }
    }
  }

  // === 2. LED制御 ===
  if (!isDebugRed && csvFile) {
    // 経過時間を足して現在時刻を算出
    uint32_t elapsed = millis() - baseMillis;
    uint32_t currentVideoTime = baseVideoTime + elapsed;

    long targetLine = currentVideoTime / FRAME_DURATION;
    long seekPos = targetLine * LINE_LEN;

    if (seekPos < csvFile.size()) {
      csvFile.seek(seekPos);
      String line = csvFile.readStringUntil('\n');
      if (line.length() > 180) {
        parseAndShow(line);
      }
    } else {
      // ループ再生
      baseVideoTime = 0;
      baseMillis = millis();
    }
  }

  // === 3. 画面更新 (詳細表示) ===
  if (millis() - lastUiMs >= 1000) {
    lastUiMs = millis();
    
    uint32_t elapsed = millis() - baseMillis;
    uint32_t t = baseVideoTime + elapsed;

    // 上半分をクリアして書き直し
    M5.Display.fillRect(0, 0, M5.Display.width(), 100, TFT_BLACK);
    M5.Display.setTextColor(TFT_CYAN);
    M5.Display.setCursor(0, 5);
    
    // 現在の計算上の動画時刻
    M5.Display.printf("Time : %lu ms\n", t);
    
    M5.Display.setTextColor(TFT_YELLOW);
    // 最後に受信した基準データ
    M5.Display.printf("BaseV: %lu\n", baseVideoTime);
    M5.Display.printf("elapsed: %lu\n", elapsed);
    M5.Display.printf("millis: %lu\n", millis());
    M5.Display.printf("BaseM: %lu\n", baseMillis);
    
    
    M5.Display.setTextColor(TFT_GREEN);
    // IPアドレス
    M5.Display.printf("IP   : %s\n", WiFi.localIP().toString().c_str());
    
    M5.Display.setTextColor(TFT_WHITE);
    // 電波強度
    M5.Display.printf("RSSI : %d dBm", WiFi.RSSI());
  }
}

// CSV解析
void parseAndShow(String line) {
  int charIndex = 10; 
  for (int i = 0; i < LED_COUNT; i++) {
    String sR = line.substring(charIndex, charIndex + 3); charIndex += 4; 
    String sG = line.substring(charIndex, charIndex + 3); charIndex += 4; 
    String sB = line.substring(charIndex, charIndex + 3); charIndex += 4; 
    strip.setPixelColor(i, strip.gamma32(strip.Color(sR.toInt(), sG.toInt(), sB.toInt())));
  }
  strip.show();
}

使い方・動作確認

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

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