- 概要
- Step1 データ生成(PC/Python):p5.jsで動画からLEDを光らせるための時刻付きCSVデータを書き出す
- Step2: M5StickC Plus2 SDカード読み込みテスト
- Step3: millis()に合わせてSDカード読み込みテスト
- Step4: UDPによる時刻同期 ← イマココ
- 課題
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です。
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)
同期処理の流れ
- UDP を受信
- メッセージを
Stringに変換しtrim() - 数値なら時刻として解釈
- 以下を更新
baseVideoTime = pcTime;
baseMillis = millis();
「PCから送られてきた動画の時刻」と、「それを受け取った瞬間のM5の起動時間」をセットで記録します。 これにより、「現在時刻 = 基準動画時刻 + (今のM5時間 – 受け取った時のM5時間)」 という計算で、通信がない間も正確な動画時間を推測し続けることができます。

CSV 再生の仕組み(重要)
固定長 CSV を使う理由
本コードでは 高速・安定再生のために「固定長 CSV」 を採用しています。
- 1 行 = 1 フレーム(30fps)
- 行番号 = 動画フレーム番号
seek()で直接ジャンプ可能
targetLine = currentVideoTime / FRAME_DURATION;
seekPos = targetLine * LINE_LEN;
注意点
LINE_LENは CSV の 1 行のバイト数と完全一致させる必要があります- Windows 改行(
\r\n)の場合は191に調整することがあります - RGB 値は必ず 3 桁ゼロ埋め(000〜255)

コード
※注意: コード内の WIFI_SSID と WIFI_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();
}
}
使い方・動作確認
すべての準備が整ったら、以下の手順で動作を確認しましょう。
- 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が一瞬「赤色」に点灯します。
