Step4: UDPによる時刻同期
前回のStep3ではM5StickC単体での再生を行いましたが、Step4ではいよいよPC(p5.js)と連携させます。 PC側で再生している動画の「現在時刻」を、Wi-Fi(UDP通信)を経由してM5StickCに通知し、ズレを自動補正するシステムを構築します。
システムの仕組み
このシステムは「PCが指揮者、M5StickCが演奏者」の関係になります。
- PC (p5.js):
- 自動送信:
- 動画再生中は 1秒おき に「現在の再生時間(ミリ秒)」を送信します。
- 手動操作:
- シークバーを操作した瞬間や、デバッグ用コマンド(’c’キー)なども送信します。
- シークバーを操作した瞬間や、デバッグ用コマンド(’c’キー)なども送信します。
- 自動送信:
- M5StickC Plus2 (受信機):
- 基本動作:
- 普段は自分の時計(
millis())を使って、自律的にSDカードのデータを読み進めます(Step3と同じ)。
- 普段は自分の時計(
- 同期補正:
- PCから「今は1000msだよ!」という時刻データが届くと、自分の持っている時刻とのズレ(オフセット)を修正し、ピタッと合わせます。
- PCから「今は1000msだよ!」という時刻データが届くと、自分の持っている時刻とのズレ(オフセット)を修正し、ピタッと合わせます。
- 基本動作:

1. PC側:p5.js (sketch.js)
PC側のプログラムです。 sketch.js を以下のコードに書き換えてください。
※ bridge.js と index.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_SSID と WIFI_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();
}
使い方・動作確認
すべての準備が整ったら、以下の手順で動作を確認しましょう。
- M5StickCの起動:
- Arduinoにコードを書き込みます。
- Wi-Fiに接続されると、画面にIPアドレスが表示され、LEDが光り始めます(この時点ではまだPCと同期しておらず、自律再生しています)。
- Bridgeの起動:
- VSCodeのターミナルで
node bridge.jsを実行します(前回の講義参照)。← 忘れないように- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- VSCodeのターミナルで
- p5.jsの起動:
- ブラウザで
index.htmlを開きます。動画が自動再生されます。- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ただ授業中は全員が実施するとバグるので土田のみ起動します(教室にいる人に限り)
- ブラウザで
- 同期チェック:
- 接続確認: p5.jsの画面上でキーボードの 「c」キー を押してください。UDP通信が成功していれば、M5StickCのLEDが一瞬「赤色」に点灯します。
- シーク確認: p5.js画面下部の赤いシークバーをクリックして、動画時間を飛ばしてください。M5StickCのLEDも一瞬でそのシーンの光り方に切り替われば、完全同期成功です!
- 接続確認: p5.jsの画面上でキーボードの 「c」キー を押してください。UDP通信が成功していれば、M5StickCのLEDが一瞬「赤色」に点灯します。
