Merge pull request 'feat: udp stream' (#12) from feature/udp-raw into main
Reviewed-on: #12
This commit is contained in:
127
examples/pixelstream/PixelStreamController.cpp
Normal file
127
examples/pixelstream/PixelStreamController.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
namespace {
|
||||
constexpr int COMPONENTS_PER_PIXEL = 3;
|
||||
}
|
||||
|
||||
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
|
||||
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
|
||||
}
|
||||
|
||||
void PixelStreamController::begin() {
|
||||
pixels.begin();
|
||||
pixels.setBrightness(config.brightness);
|
||||
// Default all pixels to green so we can verify hardware before streaming frames
|
||||
for (uint16_t i = 0; i < config.pixelCount; ++i) {
|
||||
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
|
||||
}
|
||||
pixels.show();
|
||||
|
||||
ctx.on("udp/raw", [this](void* data) {
|
||||
this->handleEvent(data);
|
||||
});
|
||||
|
||||
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
|
||||
}
|
||||
|
||||
void PixelStreamController::handleEvent(void* data) {
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payload = static_cast<String*>(data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applyFrame(*payload)) {
|
||||
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
bool PixelStreamController::applyFrame(const String& payload) {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
|
||||
|
||||
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
|
||||
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
|
||||
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
|
||||
|
||||
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
|
||||
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
|
||||
FrameComponents components{};
|
||||
if (!tryParsePixel(payload, base, components)) {
|
||||
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
|
||||
return false;
|
||||
}
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
|
||||
}
|
||||
|
||||
// Clear any remaining pixels so stale data is removed when fewer frames are provided
|
||||
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, 0);
|
||||
}
|
||||
|
||||
pixels.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
|
||||
if (config.matrixWidth == 0) {
|
||||
return logicalIndex;
|
||||
}
|
||||
|
||||
const uint16_t row = logicalIndex / config.matrixWidth;
|
||||
const uint16_t col = logicalIndex % config.matrixWidth;
|
||||
|
||||
if (!config.matrixSerpentine || (row % 2 == 0)) {
|
||||
return row * config.matrixWidth + col;
|
||||
}
|
||||
|
||||
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
|
||||
return row * config.matrixWidth + reversedCol;
|
||||
}
|
||||
|
||||
int PixelStreamController::hexToNibble(char c) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return 10 + c - 'a';
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return 10 + c - 'A';
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int rHi = hexToNibble(payload[startIndex]);
|
||||
const int rLo = hexToNibble(payload[startIndex + 1]);
|
||||
const int gHi = hexToNibble(payload[startIndex + 2]);
|
||||
const int gLo = hexToNibble(payload[startIndex + 3]);
|
||||
const int bHi = hexToNibble(payload[startIndex + 4]);
|
||||
const int bLo = hexToNibble(payload[startIndex + 5]);
|
||||
|
||||
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
|
||||
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
|
||||
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
42
examples/pixelstream/PixelStreamController.h
Normal file
42
examples/pixelstream/PixelStreamController.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
struct PixelStreamConfig {
|
||||
uint8_t pin;
|
||||
uint16_t pixelCount;
|
||||
uint8_t brightness;
|
||||
uint16_t matrixWidth;
|
||||
bool matrixSerpentine;
|
||||
neoPixelType pixelType;
|
||||
};
|
||||
|
||||
class PixelStreamController {
|
||||
public:
|
||||
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
|
||||
void begin();
|
||||
|
||||
private:
|
||||
struct FrameComponents {
|
||||
uint8_t red;
|
||||
uint8_t green;
|
||||
uint8_t blue;
|
||||
};
|
||||
|
||||
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
|
||||
void handleEvent(void* data);
|
||||
bool applyFrame(const String& payload);
|
||||
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
|
||||
static int hexToNibble(char c);
|
||||
|
||||
NodeContext& ctx;
|
||||
PixelStreamConfig config;
|
||||
Adafruit_NeoPixel pixels;
|
||||
};
|
||||
|
||||
|
||||
33
examples/pixelstream/README.md
Normal file
33
examples/pixelstream/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# PixelStream Example
|
||||
|
||||
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `udp/raw` via `NodeContext::on`.
|
||||
- Converts incoming frames into pixel colors for strips or matrices.
|
||||
- Supports serpentine (zig-zag) matrix wiring.
|
||||
|
||||
## Payload Format
|
||||
|
||||
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
|
||||
|
||||
```
|
||||
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Strip Mode
|
||||
|
||||
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
|
||||
|
||||
### Matrix Mode
|
||||
|
||||
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
|
||||
|
||||
## Configuration
|
||||
|
||||
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.
|
||||
|
||||
|
||||
60
examples/pixelstream/main.cpp
Normal file
60
examples/pixelstream/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include <Arduino.h>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
#ifndef PIXEL_PIN
|
||||
#define PIXEL_PIN 2
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_COUNT
|
||||
#define PIXEL_COUNT 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_BRIGHTNESS
|
||||
#define PIXEL_BRIGHTNESS 80
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_WIDTH
|
||||
#define PIXEL_MATRIX_WIDTH 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_SERPENTINE
|
||||
#define PIXEL_MATRIX_SERPENTINE 0
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_TYPE
|
||||
#define PIXEL_TYPE NEO_GRB + NEO_KHZ800
|
||||
#endif
|
||||
|
||||
Spore spore({
|
||||
{"app", "pixelstream"},
|
||||
{"role", "led"},
|
||||
{"pixels", String(PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
PixelStreamController* controller = nullptr;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
static_cast<uint8_t>(PIXEL_PIN),
|
||||
static_cast<uint16_t>(PIXEL_COUNT),
|
||||
static_cast<uint8_t>(PIXEL_BRIGHTNESS),
|
||||
static_cast<uint16_t>(PIXEL_MATRIX_WIDTH),
|
||||
static_cast<bool>(PIXEL_MATRIX_SERPENTINE),
|
||||
static_cast<neoPixelType>(PIXEL_TYPE)
|
||||
};
|
||||
|
||||
controller = new PixelStreamController(spore.getContext(), config);
|
||||
controller->begin();
|
||||
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,13 @@ private:
|
||||
static bool isResponseMsg(const char* msg);
|
||||
static bool isNodeInfoMsg(const char* msg);
|
||||
static bool isClusterEventMsg(const char* msg);
|
||||
static bool isRawMsg(const char* msg);
|
||||
void onDiscovery(const char* msg);
|
||||
void onHeartbeat(const char* msg);
|
||||
void onResponse(const char* msg);
|
||||
void onNodeInfo(const char* msg);
|
||||
void onClusterEvent(const char* msg);
|
||||
void onRawMessage(const char* msg);
|
||||
unsigned long lastHeartbeatSentAt = 0;
|
||||
std::vector<MessageHandler> messageHandlers;
|
||||
};
|
||||
|
||||
@@ -10,9 +10,10 @@ namespace ClusterProtocol {
|
||||
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
|
||||
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
|
||||
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
|
||||
constexpr const char* RAW_MSG = "RAW";
|
||||
constexpr uint16_t UDP_PORT = 4210;
|
||||
// Increased buffer to accommodate node info JSON over UDP
|
||||
constexpr size_t UDP_BUF_SIZE = 512;
|
||||
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
|
||||
constexpr size_t UDP_BUF_SIZE = 2048;
|
||||
constexpr const char* API_NODE_STATUS = "/api/node/status";
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +100,45 @@ build_src_filter =
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags =
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream_d1]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.1m256.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
@@ -152,6 +152,11 @@ void ApiServer::setupWebSocket() {
|
||||
|
||||
// Subscribe to all local events and forward to websocket clients
|
||||
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
||||
// Ignore raw UDP frames
|
||||
if (event == "udp/raw") {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
||||
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ void ClusterManager::listen() {
|
||||
|
||||
void ClusterManager::initMessageHandlers() {
|
||||
messageHandlers.clear();
|
||||
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
|
||||
messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
|
||||
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
||||
messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" });
|
||||
@@ -113,6 +114,15 @@ bool ClusterManager::isClusterEventMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isRawMsg(const char* msg) {
|
||||
// RAW frames must be "RAW:<payload>"; enforce the delimiter so we skip things like "RAW_HEARTBEAT".
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (strncmp(msg, ClusterProtocol::RAW_MSG, prefixLen) != 0) {
|
||||
return false;
|
||||
}
|
||||
return msg[prefixLen] == ':';
|
||||
}
|
||||
|
||||
void ClusterManager::onDiscovery(const char* /*msg*/) {
|
||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||
@@ -240,6 +250,23 @@ void ClusterManager::onClusterEvent(const char* msg) {
|
||||
ctx.fire(eventKey, &data);
|
||||
}
|
||||
|
||||
void ClusterManager::onRawMessage(const char* msg) {
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (msg[prefixLen] != ':') {
|
||||
LOG_WARN("Cluster", "RAW message received without payload delimiter");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* payloadStart = msg + prefixLen + 1;
|
||||
if (*payloadStart == '\0') {
|
||||
LOG_WARN("Cluster", "RAW message received with empty payload");
|
||||
return;
|
||||
}
|
||||
|
||||
String payload(payloadStart);
|
||||
ctx.fire("udp/raw", &payload);
|
||||
}
|
||||
|
||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||
auto& memberList = *ctx.memberList;
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"scripts": {
|
||||
"pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js",
|
||||
"pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js",
|
||||
"pixelstream:rainbow": "node pixelstream/rainbow.js",
|
||||
"pixelstream:lava-lamp": "node pixelstream/lava-lamp.js",
|
||||
"pixelstream:meteor-rain": "node pixelstream/meteor-rain.js",
|
||||
"pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js",
|
||||
"pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js",
|
||||
"pixelstream:nebula-drift": "node pixelstream/nebula-drift.js",
|
||||
"pixelstream:voxel-fireflies": "node pixelstream/voxel-fireflies.js",
|
||||
"pixelstream:wormhole-tunnel": "node pixelstream/wormhole-tunnel.js",
|
||||
"pixelstream:circuit-pulse": "node pixelstream/circuit-pulse.js",
|
||||
"pixelstream:aurora-curtains": "node pixelstream/aurora-curtains.js"
|
||||
}
|
||||
}
|
||||
|
||||
115
test/pixelstream/aurora-curtains.js
Normal file
115
test/pixelstream/aurora-curtains.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 65;
|
||||
const BAND_COUNT = 5;
|
||||
const WAVE_SPEED = 0.35;
|
||||
const HORIZONTAL_SWAY = 0.45;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('01010a') },
|
||||
{ stop: 0.2, color: hexToRgb('041332') },
|
||||
{ stop: 0.4, color: hexToRgb('0c3857') },
|
||||
{ stop: 0.65, color: hexToRgb('1aa07a') },
|
||||
{ stop: 0.85, color: hexToRgb('68d284') },
|
||||
{ stop: 1.0, color: hexToRgb('f4f5c6') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node aurora-curtains.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const bands = createBands(BAND_COUNT, width);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBands(count, matrixWidth) {
|
||||
const generatedBands = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
generatedBands.push({
|
||||
center: Math.random() * (matrixWidth - 1),
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
width: 1.2 + Math.random() * 1.8,
|
||||
});
|
||||
}
|
||||
return generatedBands;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const verticalRatio = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
let intensity = 0;
|
||||
|
||||
bands.forEach((band, index) => {
|
||||
const sway = Math.sin(timeSeconds * WAVE_SPEED + band.phase + verticalRatio * Math.PI * 2) * HORIZONTAL_SWAY;
|
||||
const center = band.center + sway * (index % 2 === 0 ? 1 : -1);
|
||||
const distance = Math.abs(col - center);
|
||||
const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width));
|
||||
intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2);
|
||||
});
|
||||
|
||||
const normalized = clamp(intensity / bands.length, 0, 1);
|
||||
const gradientBlend = clamp((normalized * 0.7 + verticalRatio * 0.3), 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, gradientBlend);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming aurora curtains to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
80
test/pixelstream/bouncing-ball.js
Normal file
80
test/pixelstream/bouncing-ball.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node bouncing-ball.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
let position = Math.random() * (pixels - 1);
|
||||
let velocity = randomVelocity();
|
||||
|
||||
function randomVelocity() {
|
||||
const min = 0.15;
|
||||
const max = 0.4;
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
return (min + Math.random() * (max - min)) * sign;
|
||||
}
|
||||
|
||||
function rebound(sign) {
|
||||
velocity = randomVelocity() * sign;
|
||||
}
|
||||
|
||||
function mix(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
const dt = intervalMs / 1000;
|
||||
position += velocity * dt * 60; // scale velocity to 60 FPS reference
|
||||
|
||||
if (position < 0) {
|
||||
position = -position;
|
||||
rebound(1);
|
||||
} else if (position > pixels - 1) {
|
||||
position = (pixels - 1) - (position - (pixels - 1));
|
||||
rebound(-1);
|
||||
}
|
||||
|
||||
const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position)));
|
||||
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
if (i === activeIndex) {
|
||||
payload += 'ff8000';
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(i - position);
|
||||
const intensity = Math.max(0, 1 - distance);
|
||||
const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0');
|
||||
const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0');
|
||||
payload += '00' + green + blue;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
166
test/pixelstream/circuit-pulse.js
Normal file
166
test/pixelstream/circuit-pulse.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const PATH_FADE = 0.85;
|
||||
const PULSE_LENGTH = 6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('020209') },
|
||||
{ stop: 0.3, color: hexToRgb('023047') },
|
||||
{ stop: 0.6, color: hexToRgb('115173') },
|
||||
{ stop: 0.8, color: hexToRgb('1ca78f') },
|
||||
{ stop: 1.0, color: hexToRgb('94fdf3') },
|
||||
];
|
||||
|
||||
const accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node circuit-pulse.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const paths = createPaths(width, height);
|
||||
const pulses = createPulses(paths.length);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createPaths(matrixWidth, matrixHeight) {
|
||||
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
|
||||
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
|
||||
const generatedPaths = [];
|
||||
|
||||
for (let y = 1; y < matrixHeight; y += horizontalStep) {
|
||||
const path = [];
|
||||
for (let x = 0; x < matrixWidth; ++x) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
for (let x = 2; x < matrixWidth; x += verticalStep) {
|
||||
const path = [];
|
||||
for (let y = 0; y < matrixHeight; ++y) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
return generatedPaths;
|
||||
}
|
||||
|
||||
function createPulses(count) {
|
||||
const pulseList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
pulseList.push(spawnPulse(index));
|
||||
}
|
||||
return pulseList;
|
||||
}
|
||||
|
||||
function spawnPulse(pathIndex) {
|
||||
const color = accentColors[pathIndex % accentColors.length];
|
||||
return {
|
||||
pathIndex,
|
||||
position: 0,
|
||||
speed: 3 + Math.random() * 2,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePulse(pulse, deltaSeconds) {
|
||||
pulse.position += pulse.speed * deltaSeconds;
|
||||
const path = paths[pulse.pathIndex];
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pulse.position >= path.length + PULSE_LENGTH) {
|
||||
Object.assign(pulse, spawnPulse(pulse.pathIndex));
|
||||
pulse.position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPulse(pulse) {
|
||||
const path = paths[pulse.pathIndex];
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < PULSE_LENGTH; ++offset) {
|
||||
const index = Math.floor(pulse.position) - offset;
|
||||
if (index < 0 || index >= path.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = path[index];
|
||||
const intensity = Math.max(0, 1 - offset / PULSE_LENGTH);
|
||||
const baseColor = samplePalette(paletteStops, intensity);
|
||||
frame[toIndex(x, y, width)] = baseColor;
|
||||
addHexColor(frame, toIndex(x, y, width), pulse.color, intensity * 1.4);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, PATH_FADE);
|
||||
|
||||
pulses.forEach((pulse) => {
|
||||
updatePulse(pulse, frameTimeSeconds);
|
||||
renderPulse(pulse);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming circuit pulse to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, paths=${paths.length})`
|
||||
);
|
||||
|
||||
55
test/pixelstream/fade-green-blue.js
Normal file
55
test/pixelstream/fade-green-blue.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node fade-green-blue.js <device-ip> [port] [pixels] [speed-hz]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const intervalMs = 50;
|
||||
let tick = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function generateFrame() {
|
||||
const timeSeconds = (tick * intervalMs) / 1000;
|
||||
const phase = timeSeconds * speed * Math.PI * 2;
|
||||
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
|
||||
|
||||
const green = Math.round(255 * (1 - blend));
|
||||
const blue = Math.round(255 * blend);
|
||||
|
||||
let payload = 'RAW:';
|
||||
const gHex = green.toString(16).padStart(2, '0');
|
||||
const bHex = blue.toString(16).padStart(2, '0');
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
payload += '00';
|
||||
payload += gHex;
|
||||
payload += bHex;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
tick += 1;
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`);
|
||||
|
||||
171
test/pixelstream/lava-lamp.js
Normal file
171
test/pixelstream/lava-lamp.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
hexToRgb,
|
||||
samplePalette: samplePaletteFromStops,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const DEFAULT_BLOB_COUNT = 6;
|
||||
const BASE_BLOB_SPEED = 0.18;
|
||||
const PHASE_SPEED_MIN = 0.6;
|
||||
const PHASE_SPEED_MAX = 1.2;
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node lava-lamp.js <device-ip> [port] [width] [height] [interval-ms] [blob-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (blobCount <= 0) {
|
||||
console.error('Blob count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const totalPixels = width * height;
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
const maxAxis = Math.max(width, height);
|
||||
const minBlobRadius = Math.max(3, maxAxis * 0.18);
|
||||
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('050319') },
|
||||
{ stop: 0.28, color: hexToRgb('2a0c4f') },
|
||||
{ stop: 0.55, color: hexToRgb('8f1f73') },
|
||||
{ stop: 0.75, color: hexToRgb('ff4a22') },
|
||||
{ stop: 0.9, color: hexToRgb('ff9333') },
|
||||
{ stop: 1.0, color: hexToRgb('fff7b0') },
|
||||
];
|
||||
|
||||
const blobs = createBlobs(blobCount);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBlobs(count) {
|
||||
const blobList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8);
|
||||
blobList.push({
|
||||
x: Math.random() * Math.max(1, width - 1),
|
||||
y: Math.random() * Math.max(1, height - 1),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
minRadius: minBlobRadius * (0.6 + Math.random() * 0.3),
|
||||
maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4),
|
||||
intensity: 0.8 + Math.random() * 0.7,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN),
|
||||
});
|
||||
}
|
||||
return blobList;
|
||||
}
|
||||
|
||||
function updateBlobs(deltaSeconds) {
|
||||
const maxX = Math.max(0, width - 1);
|
||||
const maxY = Math.max(0, height - 1);
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
blob.x += blob.vx * deltaSeconds;
|
||||
blob.y += blob.vy * deltaSeconds;
|
||||
|
||||
if (blob.x < 0) {
|
||||
blob.x = -blob.x;
|
||||
blob.vx = Math.abs(blob.vx);
|
||||
} else if (blob.x > maxX) {
|
||||
blob.x = 2 * maxX - blob.x;
|
||||
blob.vx = -Math.abs(blob.vx);
|
||||
}
|
||||
|
||||
if (blob.y < 0) {
|
||||
blob.y = -blob.y;
|
||||
blob.vy = Math.abs(blob.vy);
|
||||
} else if (blob.y > maxY) {
|
||||
blob.y = 2 * maxY - blob.y;
|
||||
blob.vy = -Math.abs(blob.vy);
|
||||
}
|
||||
|
||||
blob.phase += blob.phaseVelocity * deltaSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
updateBlobs(frameTimeSeconds);
|
||||
|
||||
const frame = new Array(totalPixels);
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const energy = calculateEnergyAt(col, row);
|
||||
const color = samplePaletteFromStops(paletteStops, energy);
|
||||
frame[toIndex(col, row, width)] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function calculateEnergyAt(col, row) {
|
||||
let energy = 0;
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
const radius = getBlobRadius(blob);
|
||||
const dx = col - blob.x;
|
||||
const dy = row - blob.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = Math.max(0, 1 - distance / radius);
|
||||
energy += blob.intensity * falloff * falloff;
|
||||
});
|
||||
|
||||
return clamp(energy / blobs.length, 0, 1);
|
||||
}
|
||||
|
||||
function getBlobRadius(blob) {
|
||||
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
|
||||
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`,
|
||||
);
|
||||
|
||||
132
test/pixelstream/meteor-rain.js
Normal file
132
test/pixelstream/meteor-rain.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 45;
|
||||
const DEFAULT_METEOR_COUNT = 12;
|
||||
const BASE_SPEED_MIN = 4;
|
||||
const BASE_SPEED_MAX = 10;
|
||||
const TRAIL_DECAY = 0.76;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('0a0126') },
|
||||
{ stop: 0.3, color: hexToRgb('123d8b') },
|
||||
{ stop: 0.7, color: hexToRgb('21c7d9') },
|
||||
{ stop: 1.0, color: hexToRgb('f7ffff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const meteorCount = parseInt(process.argv[7] || String(DEFAULT_METEOR_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node meteor-rain.js <device-ip> [port] [width] [height] [interval-ms] [meteor-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(meteorCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and meteor-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (meteorCount <= 0) {
|
||||
console.error('Meteor count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const meteors = createMeteors(meteorCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createMeteors(count, matrixWidth, matrixHeight) {
|
||||
const meteorList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
meteorList.push(spawnMeteor(matrixWidth, matrixHeight));
|
||||
}
|
||||
return meteorList;
|
||||
}
|
||||
|
||||
function spawnMeteor(matrixWidth, matrixHeight) {
|
||||
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
|
||||
const speed = BASE_SPEED_MIN + Math.random() * (BASE_SPEED_MAX - BASE_SPEED_MIN);
|
||||
return {
|
||||
x: Math.random() * matrixWidth,
|
||||
y: -Math.random() * matrixHeight,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
};
|
||||
}
|
||||
|
||||
function drawMeteor(meteor) {
|
||||
const col = Math.round(meteor.x);
|
||||
const row = Math.round(meteor.y);
|
||||
if (col < 0 || col >= width || row < 0 || row >= height) {
|
||||
return;
|
||||
}
|
||||
|
||||
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, energy);
|
||||
}
|
||||
|
||||
function updateMeteors(deltaSeconds) {
|
||||
meteors.forEach((meteor, index) => {
|
||||
meteor.x += meteor.vx * deltaSeconds;
|
||||
meteor.y += meteor.vy * deltaSeconds;
|
||||
|
||||
drawMeteor(meteor);
|
||||
|
||||
if (meteor.x > width + 1 || meteor.y > height + 1) {
|
||||
meteors[index] = spawnMeteor(width, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
updateMeteors(frameTimeSeconds);
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming meteor rain to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, meteors=${meteorCount})`,
|
||||
);
|
||||
|
||||
104
test/pixelstream/nebula-drift.js
Normal file
104
test/pixelstream/nebula-drift.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 70;
|
||||
const PRIMARY_SPEED = 0.15;
|
||||
const SECONDARY_SPEED = 0.32;
|
||||
const WAVE_SCALE = 0.75;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('100406') },
|
||||
{ stop: 0.25, color: hexToRgb('2e0f1f') },
|
||||
{ stop: 0.5, color: hexToRgb('6a1731') },
|
||||
{ stop: 0.7, color: hexToRgb('b63b32') },
|
||||
{ stop: 0.85, color: hexToRgb('f48b2a') },
|
||||
{ stop: 1.0, color: hexToRgb('ffe9b0') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node nebula-drift.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function layeredWave(u, v, speed, offset) {
|
||||
return Math.sin((u * 3 + v * 2) * Math.PI * WAVE_SCALE + timeSeconds * speed + offset);
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const primary = layeredWave(u, v, PRIMARY_SPEED, 0);
|
||||
const secondary = layeredWave(v, u, SECONDARY_SPEED, Math.PI / 4);
|
||||
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + timeSeconds * 0.18);
|
||||
|
||||
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
|
||||
const envelope = Math.sin((u * v) * Math.PI * 2 + timeSeconds * 0.1) * 0.25 + 0.75;
|
||||
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
|
||||
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming nebula drift to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
95
test/pixelstream/ocean-glimmer.js
Normal file
95
test/pixelstream/ocean-glimmer.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const SHIMMER = 0.08;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('031521') },
|
||||
{ stop: 0.35, color: hexToRgb('024f6d') },
|
||||
{ stop: 0.65, color: hexToRgb('13a4a1') },
|
||||
{ stop: 0.85, color: hexToRgb('67dcd0') },
|
||||
{ stop: 1.0, color: hexToRgb('fcdba4') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node ocean-glimmer.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const base =
|
||||
0.33 +
|
||||
0.26 * Math.sin(u * Math.PI * 2 + timeSeconds * 1.2) +
|
||||
0.26 * Math.sin(v * Math.PI * 2 - timeSeconds * 0.9) +
|
||||
0.26 * Math.sin((u + v) * Math.PI * 2 + timeSeconds * 0.5);
|
||||
const noise = (Math.random() - 0.5) * SHIMMER;
|
||||
const value = clamp(base + noise, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming ocean glimmer to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
59
test/pixelstream/rainbow.js
Normal file
59
test/pixelstream/rainbow.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node rainbow.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let offset = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function wheel(pos) {
|
||||
pos = 255 - pos;
|
||||
if (pos < 85) {
|
||||
return [255 - pos * 3, 0, pos * 3];
|
||||
}
|
||||
if (pos < 170) {
|
||||
pos -= 85;
|
||||
return [0, pos * 3, 255 - pos * 3];
|
||||
}
|
||||
pos -= 170;
|
||||
return [pos * 3, 255 - pos * 3, 0];
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const colorIndex = (i * 256 / pixels + offset) & 255;
|
||||
const [r, g, b] = wheel(colorIndex);
|
||||
payload += r.toString(16).padStart(2, '0');
|
||||
payload += g.toString(16).padStart(2, '0');
|
||||
payload += b.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
offset = (offset + 1) & 255;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
118
test/pixelstream/shared-frame-utils.js
Normal file
118
test/pixelstream/shared-frame-utils.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const SERPENTINE_WIRING = true;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const normalizedHex = hex.trim().toLowerCase();
|
||||
const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex;
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
const boundedValue = clamp(Math.round(value), 0, 255);
|
||||
const hex = boundedValue.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}
|
||||
|
||||
function lerpRgb(lhs, rhs, t) {
|
||||
return {
|
||||
r: Math.round(lhs.r + (rhs.r - lhs.r) * t),
|
||||
g: Math.round(lhs.g + (rhs.g - lhs.g) * t),
|
||||
b: Math.round(lhs.b + (rhs.b - lhs.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
function samplePalette(paletteStops, value) {
|
||||
const clampedValue = clamp(value, 0, 1);
|
||||
|
||||
for (let index = 0; index < paletteStops.length - 1; ++index) {
|
||||
const left = paletteStops[index];
|
||||
const right = paletteStops[index + 1];
|
||||
if (clampedValue <= right.stop) {
|
||||
const span = right.stop - left.stop || 1;
|
||||
const t = clamp((clampedValue - left.stop) / span, 0, 1);
|
||||
const interpolatedColor = lerpRgb(left.color, right.color, t);
|
||||
return rgbToHex(interpolatedColor);
|
||||
}
|
||||
}
|
||||
|
||||
return rgbToHex(paletteStops[paletteStops.length - 1].color);
|
||||
}
|
||||
|
||||
function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) {
|
||||
if (!serpentine || row % 2 === 0) {
|
||||
return row * width + col;
|
||||
}
|
||||
return row * width + (width - 1 - col);
|
||||
}
|
||||
|
||||
function createFrame(width, height, fill = '000000') {
|
||||
return new Array(width * height).fill(fill);
|
||||
}
|
||||
|
||||
function frameToPayload(frame) {
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function fadeFrame(frame, factor) {
|
||||
for (let index = 0; index < frame.length; ++index) {
|
||||
const hex = frame[index];
|
||||
const r = parseInt(hex.slice(0, 2), 16) * factor;
|
||||
const g = parseInt(hex.slice(2, 4), 16) * factor;
|
||||
const b = parseInt(hex.slice(4, 6), 16) * factor;
|
||||
frame[index] = toHex(r) + toHex(g) + toHex(b);
|
||||
}
|
||||
}
|
||||
|
||||
function addRgbToFrame(frame, index, rgb) {
|
||||
if (index < 0 || index >= frame.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = hexToRgb(frame[index]);
|
||||
const updated = {
|
||||
r: clamp(current.r + rgb.r, 0, 255),
|
||||
g: clamp(current.g + rgb.g, 0, 255),
|
||||
b: clamp(current.b + rgb.b, 0, 255),
|
||||
};
|
||||
frame[index] = rgbToHex(updated);
|
||||
}
|
||||
|
||||
function addHexColor(frame, index, hexColor, intensity = 1) {
|
||||
if (intensity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = hexToRgb(hexColor);
|
||||
addRgbToFrame(frame, index, {
|
||||
r: base.r * intensity,
|
||||
g: base.g * intensity,
|
||||
b: base.b * intensity,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERPENTINE_WIRING,
|
||||
clamp,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
lerpRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
fadeFrame,
|
||||
addRgbToFrame,
|
||||
addHexColor,
|
||||
};
|
||||
|
||||
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
test/pixelstream/snek/package.json
Normal file
17
test/pixelstream/snek/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "snek",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
274
test/pixelstream/snek/public/app.js
Normal file
274
test/pixelstream/snek/public/app.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(() => {
|
||||
const GRID_SIZE = 16;
|
||||
const TICK_MS = 120; // game speed
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
// NeoPixel matrix transmission config
|
||||
// Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine.
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR
|
||||
const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar
|
||||
const SERPENTINE = true; // true if every other row is reversed
|
||||
const FLIP_X = true; // set true if physical matrix is mirrored horizontally
|
||||
const FLIP_Y = false; // set true if physical matrix is flipped vertically
|
||||
|
||||
/** Game state */
|
||||
let snakeSegments = [];
|
||||
let currentDirection = { x: 1, y: 0 };
|
||||
let pendingDirection = { x: 1, y: 0 };
|
||||
let appleCell = null;
|
||||
let score = 0;
|
||||
let isGameOver = false;
|
||||
let sendingFrame = false;
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => setWsStatus('open'));
|
||||
ws.addEventListener('close', () => setWsStatus('closed'));
|
||||
ws.addEventListener('error', () => setWsStatus('error'));
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
// value is 0..255, BRIGHTNESS is 0..1
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
// Apply optional flips to align with physical orientation
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
|
||||
// Serpentine wiring reverses every other row (commonly odd rows)
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
// target is a Uint8Array, baseIndex is pixelIndex * 3
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
// Fallback to GRB if misconfigured
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
// Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data; // RGBA
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
// try to reconnect after a delay
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ];
|
||||
currentDirection = { x: 1, y: 0 };
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
score = 0;
|
||||
scoreEl.textContent = String(score);
|
||||
isGameOver = false;
|
||||
placeApple();
|
||||
}
|
||||
|
||||
function placeApple() {
|
||||
const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`));
|
||||
let x, y;
|
||||
do {
|
||||
x = Math.floor(Math.random() * GRID_SIZE);
|
||||
y = Math.floor(Math.random() * GRID_SIZE);
|
||||
} while (occupied.has(`${x},${y}`));
|
||||
appleCell = { x, y };
|
||||
}
|
||||
|
||||
function stepGame() {
|
||||
if (isGameOver) return;
|
||||
|
||||
// Commit pending direction (prevents double-turning in one tick)
|
||||
if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) {
|
||||
currentDirection = pendingDirection;
|
||||
}
|
||||
|
||||
const head = snakeSegments[0];
|
||||
const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y };
|
||||
|
||||
// Wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Self collision
|
||||
for (let i = 0; i < snakeSegments.length; i++) {
|
||||
const seg = snakeSegments[i];
|
||||
if (seg.x === newHead.x && seg.y === newHead.y) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move snake
|
||||
snakeSegments.unshift(newHead);
|
||||
const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y);
|
||||
if (ateApple) {
|
||||
score += 1;
|
||||
scoreEl.textContent = String(score);
|
||||
placeApple();
|
||||
} else {
|
||||
snakeSegments.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function renderGame() {
|
||||
// Background
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apple
|
||||
ctx.fillStyle = '#d23';
|
||||
ctx.fillRect(appleCell.x, appleCell.y, 1, 1);
|
||||
|
||||
// Snake
|
||||
ctx.fillStyle = '#3bd16f';
|
||||
for (const cell of snakeSegments) {
|
||||
ctx.fillRect(cell.x, cell.y, 1, 1);
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
// Simple overlay pixel art for game over (draw a cross)
|
||||
ctx.fillStyle = '#f33';
|
||||
for (let i = 0; i < GRID_SIZE; i++) {
|
||||
ctx.fillRect(i, i, 1, 1);
|
||||
ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (sendingFrame) return;
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
// Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
stepGame();
|
||||
renderGame();
|
||||
sendFrame();
|
||||
}
|
||||
|
||||
// Controls
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' && currentDirection.y !== 1) {
|
||||
pendingDirection = { x: 0, y: -1 };
|
||||
} else if (e.key === 'ArrowDown' && currentDirection.y !== -1) {
|
||||
pendingDirection = { x: 0, y: 1 };
|
||||
} else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) {
|
||||
pendingDirection = { x: -1, y: 0 };
|
||||
} else if (e.key === 'ArrowRight' && currentDirection.x !== -1) {
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
setInterval(gameLoop, TICK_MS);
|
||||
})();
|
||||
59
test/pixelstream/snek/public/index.html
Normal file
59
test/pixelstream/snek/public/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Snek 16x16</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px; /* make it big */
|
||||
height: 640px; /* make it big */
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Snek</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move. Space to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
test/pixelstream/snek/server.js
Normal file
83
test/pixelstream/snek/server.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
// UDP settings
|
||||
// Default broadcast; override with unicast address via UDP_ADDR if you have a single receiver
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '10.0.1.144';
|
||||
|
||||
// NeoPixel frame properties for basic validation/logging (no transformation here)
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3; // GRB without alpha
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
97
test/pixelstream/spiral-bloom.js
Normal file
97
test/pixelstream/spiral-bloom.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('051923') },
|
||||
{ stop: 0.2, color: hexToRgb('0c4057') },
|
||||
{ stop: 0.45, color: hexToRgb('1d7a70') },
|
||||
{ stop: 0.7, color: hexToRgb('39b15f') },
|
||||
{ stop: 0.88, color: hexToRgb('9dd54c') },
|
||||
{ stop: 1.0, color: hexToRgb('f7f5bc') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node spiral-bloom.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let rotation = 0;
|
||||
let hueShift = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
rotation += frameTimeSeconds * 0.7;
|
||||
hueShift += frameTimeSeconds * 0.2;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const arm = 0.5 + 0.5 * Math.sin(5 * (angle + rotation) + hueShift * Math.PI * 2);
|
||||
const value = Math.min(1, radius * 0.8 + arm * 0.4);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming spiral bloom to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
165
test/pixelstream/voxel-fireflies.js
Normal file
165
test/pixelstream/voxel-fireflies.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
const DEFAULT_FIREFLY_COUNT = 18;
|
||||
const HOVER_SPEED = 0.6;
|
||||
const GLOW_SPEED = 1.8;
|
||||
const TRAIL_DECAY = 0.8;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('02030a') },
|
||||
{ stop: 0.2, color: hexToRgb('031c2d') },
|
||||
{ stop: 0.4, color: hexToRgb('053d4a') },
|
||||
{ stop: 0.6, color: hexToRgb('107b68') },
|
||||
{ stop: 0.8, color: hexToRgb('14c491') },
|
||||
{ stop: 1.0, color: hexToRgb('f2ffd2') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const fireflyCount = parseInt(process.argv[7] || String(DEFAULT_FIREFLY_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node voxel-fireflies.js <device-ip> [port] [width] [height] [interval-ms] [firefly-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(fireflyCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and firefly-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fireflyCount <= 0) {
|
||||
console.error('Firefly count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const fireflies = createFireflies(fireflyCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createFireflies(count, matrixWidth, matrixHeight) {
|
||||
const list = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
list.push(spawnFirefly(matrixWidth, matrixHeight));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function spawnFirefly(matrixWidth, matrixHeight) {
|
||||
return {
|
||||
x: Math.random() * (matrixWidth - 1),
|
||||
y: Math.random() * (matrixHeight - 1),
|
||||
targetX: Math.random() * (matrixWidth - 1),
|
||||
targetY: Math.random() * (matrixHeight - 1),
|
||||
glowPhase: Math.random() * Math.PI * 2,
|
||||
dwell: 1 + Math.random() * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function updateFirefly(firefly, deltaSeconds) {
|
||||
const dx = firefly.targetX - firefly.x;
|
||||
const dy = firefly.targetY - firefly.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance < 0.2) {
|
||||
firefly.dwell -= deltaSeconds;
|
||||
if (firefly.dwell <= 0) {
|
||||
firefly.targetX = Math.random() * (width - 1);
|
||||
firefly.targetY = Math.random() * (height - 1);
|
||||
firefly.dwell = 1 + Math.random() * 2;
|
||||
}
|
||||
} else {
|
||||
const speed = HOVER_SPEED * (0.8 + Math.random() * 0.4);
|
||||
firefly.x += (dx / distance) * speed * deltaSeconds;
|
||||
firefly.y += (dy / distance) * speed * deltaSeconds;
|
||||
}
|
||||
|
||||
firefly.glowPhase += deltaSeconds * GLOW_SPEED * (0.7 + Math.random() * 0.6);
|
||||
}
|
||||
|
||||
function drawFirefly(firefly) {
|
||||
const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5;
|
||||
const col = Math.round(firefly.x);
|
||||
const row = Math.round(firefly.y);
|
||||
|
||||
for (let dy = -1; dy <= 1; ++dy) {
|
||||
for (let dx = -1; dx <= 1; ++dx) {
|
||||
const sampleX = col + dx;
|
||||
const sampleY = row + dy;
|
||||
if (sampleX < 0 || sampleX >= width || sampleY < 0 || sampleY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = clamp(1 - distance * 0.7, 0, 1);
|
||||
const intensity = baseGlow * falloff;
|
||||
if (intensity <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frame[toIndex(sampleX, sampleY, width)] = samplePalette(paletteStops, intensity);
|
||||
if (distance === 0) {
|
||||
addHexColor(frame, toIndex(sampleX, sampleY, width), 'ffd966', intensity * 1.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
|
||||
fireflies.forEach((firefly) => {
|
||||
updateFirefly(firefly, frameTimeSeconds);
|
||||
drawFirefly(firefly);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming voxel fireflies to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, fireflies=${fireflyCount})`
|
||||
);
|
||||
|
||||
108
test/pixelstream/wormhole-tunnel.js
Normal file
108
test/pixelstream/wormhole-tunnel.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const RING_DENSITY = 8;
|
||||
const RING_SPEED = 1.4;
|
||||
const RING_SHARPNESS = 7.5;
|
||||
const TWIST_INTENSITY = 2.2;
|
||||
const TWIST_SPEED = 0.9;
|
||||
const CORE_EXPONENT = 1.6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('010005') },
|
||||
{ stop: 0.2, color: hexToRgb('07204f') },
|
||||
{ stop: 0.45, color: hexToRgb('124aa0') },
|
||||
{ stop: 0.7, color: hexToRgb('36a5ff') },
|
||||
{ stop: 0.87, color: hexToRgb('99e6ff') },
|
||||
{ stop: 1.0, color: hexToRgb('f1fbff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node wormhole-tunnel.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const radialPhase = radius * RING_DENSITY - timeSeconds * RING_SPEED;
|
||||
const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * RING_SHARPNESS);
|
||||
|
||||
const twist = Math.sin(angle * TWIST_INTENSITY + timeSeconds * TWIST_SPEED) * 0.35 + 0.65;
|
||||
const depth = Math.pow(clamp(1 - radius, 0, 1), CORE_EXPONENT);
|
||||
|
||||
const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming wormhole tunnel to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user