feat: ledlab

This commit is contained in:
2025-10-11 17:46:32 +02:00
commit 30814807aa
30 changed files with 5690 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
// Aurora Curtains preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, toIndex, samplePalette, hexToRgb } = require('./frame-utils');
class AuroraCurtainsPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.bands = [];
this.defaultParameters = {
bandCount: 5,
waveSpeed: 0.35,
horizontalSway: 0.45,
brightness: 1.0,
};
}
init() {
super.init();
this.createBands();
}
createBands() {
const bandCount = this.getParameter('bandCount') || 5;
this.bands = [];
for (let index = 0; index < bandCount; ++index) {
this.bands.push({
center: Math.random() * (this.width - 1),
phase: Math.random() * Math.PI * 2,
width: 1.2 + Math.random() * 1.8,
});
}
}
renderFrame() {
const frame = createFrame(this.width, this.height);
const waveSpeed = this.getParameter('waveSpeed') || 0.35;
const horizontalSway = this.getParameter('horizontalSway') || 0.45;
const brightness = this.getParameter('brightness') || 1.0;
const timeSeconds = this.frameCount * 0.05; // Convert frame count to time
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') },
];
for (let row = 0; row < this.height; ++row) {
const verticalRatio = row / Math.max(1, this.height - 1);
for (let col = 0; col < this.width; ++col) {
let intensity = 0;
this.bands.forEach((band, index) => {
const sway = Math.sin(timeSeconds * waveSpeed + band.phase + verticalRatio * Math.PI * 2) * horizontalSway;
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 = Math.min(intensity / this.bands.length, 1);
const gradientBlend = Math.min((normalized * 0.7 + verticalRatio * 0.3), 1);
let colorHex = samplePalette(paletteStops, gradientBlend);
// Apply brightness
if (brightness < 1.0) {
const rgb = hexToRgb(colorHex);
colorHex = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
}
frame[toIndex(col, row, this.width)] = colorHex;
}
}
return frame;
}
setParameter(name, value) {
super.setParameter(name, value);
// Recreate bands if band-related parameters change
if (name === 'bandCount') {
this.createBands();
}
}
getMetadata() {
return {
name: 'Aurora Curtains',
description: 'Flowing aurora-like curtains with wave motion',
parameters: {
bandCount: { type: 'range', min: 3, max: 10, step: 1, default: 5 },
waveSpeed: { type: 'range', min: 0.1, max: 2.0, step: 0.05, default: 0.35 },
horizontalSway: { type: 'range', min: 0.1, max: 1.0, step: 0.05, default: 0.45 },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = AuroraCurtainsPreset;

86
presets/base-preset.js Normal file
View File

@@ -0,0 +1,86 @@
// Base preset class for LEDLab
const { createFrame, frameToPayload } = require('./frame-utils');
class BasePreset {
constructor(width = 16, height = 16) {
this.width = width;
this.height = height;
this.frameCount = 0;
this.isActive = false;
this.parameters = {};
this.defaultParameters = {};
}
// Initialize the preset with default parameters
init() {
this.frameCount = 0;
this.resetToDefaults();
}
// Reset parameters to their default values
resetToDefaults() {
this.parameters = { ...this.defaultParameters };
}
// Set a parameter value
setParameter(name, value) {
this.parameters[name] = value;
}
// Get a parameter value
getParameter(name) {
return this.parameters[name];
}
// Get all parameters
getParameters() {
return { ...this.parameters };
}
// Start the preset
start() {
this.isActive = true;
this.init();
}
// Stop the preset
stop() {
this.isActive = false;
}
// Generate the next frame
generateFrame() {
if (!this.isActive) {
return null;
}
const frame = this.renderFrame();
this.frameCount++;
if (frame) {
return frameToPayload(frame);
}
return null;
}
// Override in subclasses to render a frame
renderFrame() {
// Default implementation returns a blank frame
return createFrame(this.width, this.height, '000000');
}
// Get preset metadata
getMetadata() {
return {
name: this.constructor.name,
description: 'Base preset class',
parameters: this.defaultParameters,
width: this.width,
height: this.height,
};
}
}
module.exports = BasePreset;

View File

@@ -0,0 +1,113 @@
// Bouncing Ball preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, toIndex } = require('./frame-utils');
class BouncingBallPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.position = 0;
this.velocity = 0;
this.defaultParameters = {
speed: 1.0,
ballSize: 1,
trailLength: 5,
color: 'ff8000',
brightness: 1.0,
};
}
init() {
super.init();
this.position = Math.random() * (this.width * this.height - 1);
this.velocity = this.randomVelocity();
}
randomVelocity() {
const min = 0.15;
const max = 0.4;
const sign = Math.random() < 0.5 ? -1 : 1;
return (min + Math.random() * (max - min)) * sign;
}
rebound(sign) {
this.velocity = this.randomVelocity() * sign;
}
mix(a, b, t) {
return a + (b - a) * t;
}
renderFrame() {
const frame = createFrame(this.width, this.height);
const speed = this.getParameter('speed') || 1.0;
const ballSize = this.getParameter('ballSize') || 1;
const trailLength = this.getParameter('trailLength') || 5;
const color = this.getParameter('color') || 'ff8000';
const brightness = this.getParameter('brightness') || 1.0;
const dt = 0.016; // Assume 60 FPS
this.position += this.velocity * speed * dt * 60;
// Handle boundary collisions
if (this.position < 0) {
this.position = -this.position;
this.rebound(1);
} else if (this.position > this.width * this.height - 1) {
this.position = (this.width * this.height - 1) - (this.position - (this.width * this.height - 1));
this.rebound(-1);
}
const activeIndex = Math.max(0, Math.min(this.width * this.height - 1, Math.round(this.position)));
// Render ball and trail
for (let i = 0; i < frame.length; i++) {
if (i === activeIndex) {
// Main ball
frame[i] = color;
} else {
// Trail effect
const distance = Math.abs(i - this.position);
if (distance <= trailLength) {
const intensity = Math.max(0, 1 - distance / trailLength);
const trailColor = this.hexToRgb(color);
const r = Math.round(this.mix(0, trailColor.r, intensity * brightness));
const g = Math.round(this.mix(20, trailColor.g, intensity * brightness));
const b = Math.round(this.mix(40, trailColor.b, intensity * brightness));
frame[i] = r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0');
}
}
}
return frame;
}
hexToRgb(hex) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
getMetadata() {
return {
name: 'Bouncing Ball',
description: 'A ball that bounces around the matrix with a trailing effect',
parameters: {
speed: { type: 'range', min: 0.1, max: 3.0, step: 0.1, default: 1.0 },
ballSize: { type: 'range', min: 1, max: 5, step: 1, default: 1 },
trailLength: { type: 'range', min: 1, max: 10, step: 1, default: 5 },
color: { type: 'color', default: 'ff8000' },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = BouncingBallPreset;

View File

@@ -0,0 +1,152 @@
// Circuit Pulse preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, addHexColor } = require('./frame-utils');
class CircuitPulsePreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.paths = [];
this.pulses = [];
this.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') },
];
this.accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
this.defaultParameters = {
pathFade: 0.85,
pulseLength: 6,
pulseSpeed: 5.0,
pulseCount: 3,
};
}
init() {
super.init();
this.paths = this.createPaths(this.width, this.height);
this.pulses = this.createPulses(this.paths.length);
}
createPaths(matrixWidth, matrixHeight) {
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
const generatedPaths = [];
// Horizontal paths
for (let y = 1; y < matrixHeight; y += horizontalStep) {
const path = [];
for (let x = 0; x < matrixWidth; ++x) {
path.push({ x, y });
}
generatedPaths.push(path);
}
// Vertical paths
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;
}
createPulses(count) {
const pulseList = [];
for (let index = 0; index < count; ++index) {
pulseList.push(this.spawnPulse(index));
}
return pulseList;
}
spawnPulse(pathIndex) {
const color = this.accentColors[pathIndex % this.accentColors.length];
return {
pathIndex,
position: 0,
speed: 3 + Math.random() * 2,
color,
};
}
updatePulse(pulse, deltaSeconds) {
pulse.position += pulse.speed * deltaSeconds;
const path = this.paths[pulse.pathIndex];
if (!path || path.length === 0) {
return;
}
if (pulse.position >= path.length + this.getParameter('pulseLength')) {
Object.assign(pulse, this.spawnPulse(pulse.pathIndex));
pulse.position = 0;
}
}
renderPulse(pulse) {
const path = this.paths[pulse.pathIndex];
if (!path) {
return;
}
const pulseLength = this.getParameter('pulseLength');
for (let offset = 0; offset < pulseLength; ++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 / pulseLength);
const baseColor = samplePalette(this.paletteStops, intensity);
this.frame[toIndex(x, y, this.width)] = baseColor;
addHexColor(this.frame, toIndex(x, y, this.width), pulse.color, intensity * 1.4);
}
}
renderFrame() {
this.frame = createFrame(this.width, this.height);
const pathFade = this.getParameter('pathFade') || 0.85;
const pulseCount = this.getParameter('pulseCount') || 3;
fadeFrame(this.frame, pathFade);
// Update pulse count if it changed
while (this.pulses.length < pulseCount) {
this.pulses.push(this.spawnPulse(this.pulses.length));
}
while (this.pulses.length > pulseCount) {
this.pulses.pop();
}
this.pulses.forEach((pulse) => {
this.updatePulse(pulse, 0.016); // Assume 60 FPS
this.renderPulse(pulse);
});
return this.frame;
}
getMetadata() {
return {
name: 'Circuit Pulse',
description: 'Animated circuit board with pulsing paths',
parameters: {
pathFade: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.85 },
pulseLength: { type: 'range', min: 3, max: 12, step: 1, default: 6 },
pulseSpeed: { type: 'range', min: 2.0, max: 8.0, step: 0.5, default: 5.0 },
pulseCount: { type: 'range', min: 1, max: 6, step: 1, default: 3 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = CircuitPulsePreset;

View File

@@ -0,0 +1,51 @@
// Fade Green Blue preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload } = require('./frame-utils');
class FadeGreenBluePreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.tick = 0;
this.defaultParameters = {
speed: 0.5, // cycles per second
brightness: 1.0,
};
}
renderFrame() {
const frame = createFrame(this.width, this.height);
const timeSeconds = (this.tick * 0.016); // Assume 60 FPS
const phase = timeSeconds * this.getParameter('speed') * Math.PI * 2;
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
const brightness = this.getParameter('brightness') || 1.0;
const green = Math.round(255 * (1 - blend) * brightness);
const blue = Math.round(255 * blend * brightness);
const gHex = green.toString(16).padStart(2, '0');
const bHex = blue.toString(16).padStart(2, '0');
for (let i = 0; i < frame.length; i++) {
frame[i] = '00' + gHex + bHex;
}
this.tick++;
return frame;
}
getMetadata() {
return {
name: 'Fade Green Blue',
description: 'Smooth fade between green and blue colors',
parameters: {
speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 0.5 },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = FadeGreenBluePreset;

133
presets/frame-utils.js Normal file
View File

@@ -0,0 +1,133 @@
// Frame utilities for LEDLab presets
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,
});
}
// Color wheel function for rainbow effects
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];
}
module.exports = {
clamp,
hexToRgb,
rgbToHex,
lerpRgb,
samplePalette,
toIndex,
createFrame,
frameToPayload,
fadeFrame,
addRgbToFrame,
addHexColor,
wheel,
};

146
presets/lava-lamp-preset.js Normal file
View File

@@ -0,0 +1,146 @@
// Lava Lamp preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
class LavaLampPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.blobs = [];
this.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') },
];
this.defaultParameters = {
blobCount: 6,
blobSpeed: 0.18,
minBlobRadius: 0.18,
maxBlobRadius: 0.38,
intensity: 1.0,
};
}
init() {
super.init();
this.blobs = this.createBlobs(this.getParameter('blobCount') || 6);
}
createBlobs(count) {
const blobList = [];
const maxAxis = Math.max(this.width, this.height);
const minBlobRadius = Math.max(3, maxAxis * (this.getParameter('minBlobRadius') || 0.18));
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * (this.getParameter('maxBlobRadius') || 0.38));
for (let index = 0; index < count; ++index) {
const angle = Math.random() * Math.PI * 2;
const speed = (this.getParameter('blobSpeed') || 0.18) * (0.6 + Math.random() * 0.8);
blobList.push({
x: Math.random() * Math.max(1, this.width - 1),
y: Math.random() * Math.max(1, this.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: (this.getParameter('intensity') || 1.0) * (0.8 + Math.random() * 0.7),
phase: Math.random() * Math.PI * 2,
phaseVelocity: 0.6 + Math.random() * 0.6,
});
}
return blobList;
}
updateBlobs(deltaSeconds) {
const maxX = Math.max(0, this.width - 1);
const maxY = Math.max(0, this.height - 1);
this.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;
});
}
renderFrame() {
this.updateBlobs(0.016); // Assume 60 FPS
const frame = createFrame(this.width, this.height);
for (let row = 0; row < this.height; ++row) {
for (let col = 0; col < this.width; ++col) {
const energy = this.calculateEnergyAt(col, row);
const color = samplePalette(this.paletteStops, energy);
frame[toIndex(col, row, this.width)] = color;
}
}
return frame;
}
calculateEnergyAt(col, row) {
let energy = 0;
this.blobs.forEach((blob) => {
const radius = this.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 / this.blobs.length, 0, 1);
}
getBlobRadius(blob) {
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
}
setParameter(name, value) {
super.setParameter(name, value);
// Recreate blobs if blob-related parameters change
if (name === 'blobCount' || name === 'blobSpeed' || name === 'minBlobRadius' || name === 'maxBlobRadius' || name === 'intensity') {
this.blobs = this.createBlobs(this.getParameter('blobCount') || 6);
}
}
getMetadata() {
return {
name: 'Lava Lamp',
description: 'Floating blobs creating organic color gradients',
parameters: {
blobCount: { type: 'range', min: 3, max: 12, step: 1, default: 6 },
blobSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.18 },
minBlobRadius: { type: 'range', min: 0.1, max: 0.3, step: 0.02, default: 0.18 },
maxBlobRadius: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.38 },
intensity: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = LavaLampPreset;

View File

@@ -0,0 +1,108 @@
// Meteor Rain preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
class MeteorRainPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.meteors = [];
this.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') },
];
this.defaultParameters = {
meteorCount: 12,
minSpeed: 4,
maxSpeed: 10,
trailDecay: 0.76,
};
}
init() {
super.init();
this.meteors = this.createMeteors(this.getParameter('meteorCount') || 12, this.width, this.height);
}
createMeteors(count, matrixWidth, matrixHeight) {
const meteorList = [];
for (let index = 0; index < count; ++index) {
meteorList.push(this.spawnMeteor(matrixWidth, matrixHeight));
}
return meteorList;
}
spawnMeteor(matrixWidth, matrixHeight) {
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
const speed = (this.getParameter('minSpeed') || 4) + Math.random() * ((this.getParameter('maxSpeed') || 10) - (this.getParameter('minSpeed') || 4));
return {
x: Math.random() * matrixWidth,
y: -Math.random() * matrixHeight,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
};
}
drawMeteor(meteor) {
const col = Math.round(meteor.x);
const row = Math.round(meteor.y);
if (col < 0 || col >= this.width || row < 0 || row >= this.height) {
return;
}
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
this.frame[toIndex(col, row, this.width)] = samplePalette(this.paletteStops, energy);
}
updateMeteors(deltaSeconds) {
this.meteors.forEach((meteor, index) => {
meteor.x += meteor.vx * deltaSeconds;
meteor.y += meteor.vy * deltaSeconds;
this.drawMeteor(meteor);
if (meteor.x > this.width + 1 || meteor.y > this.height + 1) {
this.meteors[index] = this.spawnMeteor(this.width, this.height);
}
});
}
renderFrame() {
this.frame = createFrame(this.width, this.height);
const trailDecay = this.getParameter('trailDecay') || 0.76;
const meteorCount = this.getParameter('meteorCount') || 12;
fadeFrame(this.frame, trailDecay);
// Update meteor count if it changed
while (this.meteors.length < meteorCount) {
this.meteors.push(this.spawnMeteor(this.width, this.height));
}
while (this.meteors.length > meteorCount) {
this.meteors.pop();
}
this.updateMeteors(0.016); // Assume 60 FPS
return this.frame;
}
getMetadata() {
return {
name: 'Meteor Rain',
description: 'Falling meteors with trailing effects',
parameters: {
meteorCount: { type: 'range', min: 5, max: 20, step: 1, default: 12 },
minSpeed: { type: 'range', min: 2, max: 8, step: 1, default: 4 },
maxSpeed: { type: 'range', min: 6, max: 15, step: 1, default: 10 },
trailDecay: { type: 'range', min: 0.6, max: 0.9, step: 0.02, default: 0.76 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = MeteorRainPreset;

View File

@@ -0,0 +1,81 @@
// Nebula Drift preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
class NebulaDriftPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.timeSeconds = 0;
this.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') },
];
this.defaultParameters = {
primarySpeed: 0.15,
secondarySpeed: 0.32,
waveScale: 0.75,
brightness: 1.0,
};
}
layeredWave(u, v, speed, offset) {
return Math.sin((u * 3 + v * 2) * Math.PI * this.getParameter('waveScale') + this.timeSeconds * speed + offset);
}
renderFrame() {
this.timeSeconds += 0.016; // Assume 60 FPS
const frame = createFrame(this.width, this.height);
const brightness = this.getParameter('brightness') || 1.0;
for (let row = 0; row < this.height; ++row) {
const v = row / Math.max(1, this.height - 1);
for (let col = 0; col < this.width; ++col) {
const u = col / Math.max(1, this.width - 1);
const primary = this.layeredWave(u, v, this.getParameter('primarySpeed') || 0.15, 0);
const secondary = this.layeredWave(v, u, this.getParameter('secondarySpeed') || 0.32, Math.PI / 4);
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + this.timeSeconds * 0.18);
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
const envelope = Math.sin((u * v) * Math.PI * 2 + this.timeSeconds * 0.1) * 0.25 + 0.75;
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
let color = samplePalette(this.paletteStops, value);
// Apply brightness
if (brightness < 1.0) {
const rgb = hexToRgb(color);
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
}
frame[toIndex(col, row, this.width)] = color;
}
}
return frame;
}
getMetadata() {
return {
name: 'Nebula Drift',
description: 'Organic drifting nebula with layered wave patterns',
parameters: {
primarySpeed: { type: 'range', min: 0.05, max: 0.3, step: 0.05, default: 0.15 },
secondarySpeed: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.32 },
waveScale: { type: 'range', min: 0.5, max: 1.0, step: 0.05, default: 0.75 },
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = NebulaDriftPreset;

View File

@@ -0,0 +1,79 @@
// Ocean Glimmer preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
class OceanGlimmerPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.timeSeconds = 0;
this.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') },
];
this.defaultParameters = {
shimmer: 0.08,
waveSpeed1: 1.2,
waveSpeed2: 0.9,
waveSpeed3: 0.5,
brightness: 1.0,
};
}
renderFrame() {
this.timeSeconds += 0.016; // Assume 60 FPS
const frame = createFrame(this.width, this.height);
const shimmer = this.getParameter('shimmer') || 0.08;
const brightness = this.getParameter('brightness') || 1.0;
for (let row = 0; row < this.height; ++row) {
const v = row / Math.max(1, this.height - 1);
for (let col = 0; col < this.width; ++col) {
const u = col / Math.max(1, this.width - 1);
const base =
0.33 +
0.26 * Math.sin(u * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed1') || 1.2)) +
0.26 * Math.sin(v * Math.PI * 2 - this.timeSeconds * (this.getParameter('waveSpeed2') || 0.9)) +
0.26 * Math.sin((u + v) * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed3') || 0.5));
const noise = (Math.random() - 0.5) * shimmer;
const value = clamp(base + noise, 0, 1);
let color = samplePalette(this.paletteStops, value);
// Apply brightness
if (brightness < 1.0) {
const rgb = hexToRgb(color);
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
}
frame[toIndex(col, row, this.width)] = color;
}
}
return frame;
}
getMetadata() {
return {
name: 'Ocean Glimmer',
description: 'Ocean-like waves with shimmer effects',
parameters: {
shimmer: { type: 'range', min: 0.02, max: 0.15, step: 0.01, default: 0.08 },
waveSpeed1: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.2 },
waveSpeed2: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 0.9 },
waveSpeed3: { type: 'range', min: 0.2, max: 1.0, step: 0.1, default: 0.5 },
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = OceanGlimmerPreset;

View File

@@ -0,0 +1,74 @@
// Preset registry for LEDLab
const RainbowPreset = require('./rainbow-preset');
const AuroraCurtainsPreset = require('./aurora-curtains-preset');
const BouncingBallPreset = require('./bouncing-ball-preset');
const CircuitPulsePreset = require('./circuit-pulse-preset');
const FadeGreenBluePreset = require('./fade-green-blue-preset');
const LavaLampPreset = require('./lava-lamp-preset');
const MeteorRainPreset = require('./meteor-rain-preset');
const NebulaDriftPreset = require('./nebula-drift-preset');
const OceanGlimmerPreset = require('./ocean-glimmer-preset');
const SpiralBloomPreset = require('./spiral-bloom-preset');
const VoxelFirefliesPreset = require('./voxel-fireflies-preset');
const WormholeTunnelPreset = require('./wormhole-tunnel-preset');
class PresetRegistry {
constructor() {
this.presets = new Map();
this.registerDefaults();
}
registerDefaults() {
this.register('rainbow', RainbowPreset);
this.register('aurora-curtains', AuroraCurtainsPreset);
this.register('bouncing-ball', BouncingBallPreset);
this.register('circuit-pulse', CircuitPulsePreset);
this.register('fade-green-blue', FadeGreenBluePreset);
this.register('lava-lamp', LavaLampPreset);
this.register('meteor-rain', MeteorRainPreset);
this.register('nebula-drift', NebulaDriftPreset);
this.register('ocean-glimmer', OceanGlimmerPreset);
this.register('spiral-bloom', SpiralBloomPreset);
this.register('voxel-fireflies', VoxelFirefliesPreset);
this.register('wormhole-tunnel', WormholeTunnelPreset);
}
register(name, presetClass) {
this.presets.set(name, presetClass);
}
createPreset(name, width = 16, height = 16) {
const presetClass = this.presets.get(name);
if (!presetClass) {
throw new Error(`Preset '${name}' not found`);
}
return new presetClass(width, height);
}
getPresetNames() {
return Array.from(this.presets.keys());
}
getPresetMetadata(name) {
try {
const preset = this.createPreset(name);
return preset.getMetadata();
} catch (error) {
console.error(`Error getting metadata for preset '${name}':`, error);
return null;
}
}
getAllPresetMetadata() {
const metadata = {};
for (const name of this.getPresetNames()) {
metadata[name] = this.getPresetMetadata(name);
}
return metadata;
}
}
// Export the constructor class
module.exports = PresetRegistry;

53
presets/rainbow-preset.js Normal file
View File

@@ -0,0 +1,53 @@
// Rainbow preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, wheel } = require('./frame-utils');
class RainbowPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.defaultParameters = {
speed: 1,
brightness: 1.0,
offset: 0,
};
}
renderFrame() {
const frame = createFrame(this.width, this.height);
const speed = this.getParameter('speed') || 1;
const brightness = this.getParameter('brightness') || 1.0;
const offset = (this.getParameter('offset') || 0) + this.frameCount * speed;
for (let i = 0; i < frame.length; i++) {
const colorIndex = (i * 256 / frame.length + offset) & 255;
const [r, g, b] = wheel(colorIndex);
// Apply brightness
const adjustedR = Math.round(r * brightness);
const adjustedG = Math.round(g * brightness);
const adjustedB = Math.round(b * brightness);
frame[i] = adjustedR.toString(16).padStart(2, '0') +
adjustedG.toString(16).padStart(2, '0') +
adjustedB.toString(16).padStart(2, '0');
}
return frame;
}
getMetadata() {
return {
name: 'Rainbow',
description: 'Classic rainbow pattern that cycles through colors',
parameters: {
speed: { type: 'range', min: 0.1, max: 5.0, step: 0.1, default: 1.0 },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = RainbowPreset;

View File

@@ -0,0 +1,81 @@
// Spiral Bloom preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex } = require('./frame-utils');
class SpiralBloomPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.rotation = 0;
this.hueShift = 0;
this.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') },
];
this.defaultParameters = {
rotationSpeed: 0.7,
hueSpeed: 0.2,
spiralArms: 5,
brightness: 1.0,
};
}
renderFrame() {
this.rotation += 0.016 * (this.getParameter('rotationSpeed') || 0.7);
this.hueShift += 0.016 * (this.getParameter('hueSpeed') || 0.2);
const frame = createFrame(this.width, this.height);
const brightness = this.getParameter('brightness') || 1.0;
const spiralArms = this.getParameter('spiralArms') || 5;
const cx = (this.width - 1) / 2;
const cy = (this.height - 1) / 2;
const radiusNorm = Math.hypot(cx, cy) || 1;
for (let row = 0; row < this.height; ++row) {
for (let col = 0; col < this.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(spiralArms * (angle + this.rotation) + this.hueShift * Math.PI * 2);
const value = Math.min(1, radius * 0.8 + arm * 0.4);
let color = samplePalette(this.paletteStops, value);
// Apply brightness
if (brightness < 1.0) {
const rgb = hexToRgb(color);
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
}
frame[toIndex(col, row, this.width)] = color;
}
}
return frame;
}
getMetadata() {
return {
name: 'Spiral Bloom',
description: 'Rotating spiral patterns blooming outward',
parameters: {
rotationSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.7 },
hueSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.2 },
spiralArms: { type: 'range', min: 3, max: 8, step: 1, default: 5 },
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = SpiralBloomPreset;

View File

@@ -0,0 +1,141 @@
// Voxel Fireflies preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp, addHexColor } = require('./frame-utils');
class VoxelFirefliesPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.fireflies = [];
this.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') },
];
this.defaultParameters = {
fireflyCount: 18,
hoverSpeed: 0.6,
glowSpeed: 1.8,
trailDecay: 0.8,
brightness: 1.0,
};
}
init() {
super.init();
this.fireflies = this.createFireflies(this.getParameter('fireflyCount') || 18, this.width, this.height);
}
createFireflies(count, matrixWidth, matrixHeight) {
const list = [];
for (let index = 0; index < count; ++index) {
list.push(this.spawnFirefly(matrixWidth, matrixHeight));
}
return list;
}
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,
};
}
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() * (this.width - 1);
firefly.targetY = Math.random() * (this.height - 1);
firefly.dwell = 1 + Math.random() * 2;
}
} else {
const speed = (this.getParameter('hoverSpeed') || 0.6) * (0.8 + Math.random() * 0.4);
firefly.x += (dx / distance) * speed * deltaSeconds;
firefly.y += (dy / distance) * speed * deltaSeconds;
}
firefly.glowPhase += deltaSeconds * (this.getParameter('glowSpeed') || 1.8) * (0.7 + Math.random() * 0.6);
}
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 >= this.width || sampleY < 0 || sampleY >= this.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;
}
this.frame[toIndex(sampleX, sampleY, this.width)] = samplePalette(this.paletteStops, intensity);
if (distance === 0) {
addHexColor(this.frame, toIndex(sampleX, sampleY, this.width), 'ffd966', intensity * 1.6);
}
}
}
}
renderFrame() {
this.frame = createFrame(this.width, this.height);
const trailDecay = this.getParameter('trailDecay') || 0.8;
const fireflyCount = this.getParameter('fireflyCount') || 18;
const brightness = this.getParameter('brightness') || 1.0;
fadeFrame(this.frame, trailDecay);
// Update firefly count if it changed
while (this.fireflies.length < fireflyCount) {
this.fireflies.push(this.spawnFirefly(this.width, this.height));
}
while (this.fireflies.length > fireflyCount) {
this.fireflies.pop();
}
this.fireflies.forEach((firefly) => {
this.updateFirefly(firefly, 0.016); // Assume 60 FPS
this.drawFirefly(firefly);
});
return this.frame;
}
getMetadata() {
return {
name: 'Voxel Fireflies',
description: 'Glowing fireflies that hover and move around',
parameters: {
fireflyCount: { type: 'range', min: 8, max: 30, step: 2, default: 18 },
hoverSpeed: { type: 'range', min: 0.3, max: 1.2, step: 0.1, default: 0.6 },
glowSpeed: { type: 'range', min: 1.0, max: 3.0, step: 0.2, default: 1.8 },
trailDecay: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.8 },
brightness: { type: 'range', min: 0.5, max: 1.5, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = VoxelFirefliesPreset;

View File

@@ -0,0 +1,90 @@
// Wormhole Tunnel preset for LEDLab
const BasePreset = require('./base-preset');
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
class WormholeTunnelPreset extends BasePreset {
constructor(width = 16, height = 16) {
super(width, height);
this.timeSeconds = 0;
this.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') },
];
this.defaultParameters = {
ringDensity: 8,
ringSpeed: 1.4,
ringSharpness: 7.5,
twistIntensity: 2.2,
twistSpeed: 0.9,
coreExponent: 1.6,
brightness: 1.0,
};
}
renderFrame() {
this.timeSeconds += 0.016; // Assume 60 FPS
const frame = createFrame(this.width, this.height);
const brightness = this.getParameter('brightness') || 1.0;
const cx = (this.width - 1) / 2;
const cy = (this.height - 1) / 2;
const radiusNorm = Math.hypot(cx, cy) || 1;
for (let row = 0; row < this.height; ++row) {
for (let col = 0; col < this.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 * (this.getParameter('ringDensity') || 8) - this.timeSeconds * (this.getParameter('ringSpeed') || 1.4);
const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * (this.getParameter('ringSharpness') || 7.5));
const twist = Math.sin(angle * (this.getParameter('twistIntensity') || 2.2) + this.timeSeconds * (this.getParameter('twistSpeed') || 0.9)) * 0.35 + 0.65;
const depth = Math.pow(clamp(1 - radius, 0, 1), this.getParameter('coreExponent') || 1.6);
const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1);
let color = samplePalette(this.paletteStops, value);
// Apply brightness
if (brightness < 1.0) {
const rgb = hexToRgb(color);
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
}
frame[toIndex(col, row, this.width)] = color;
}
}
return frame;
}
getMetadata() {
return {
name: 'Wormhole Tunnel',
description: 'Hypnotic tunnel effect with rotating rings',
parameters: {
ringDensity: { type: 'range', min: 4, max: 12, step: 1, default: 8 },
ringSpeed: { type: 'range', min: 0.5, max: 2.5, step: 0.1, default: 1.4 },
ringSharpness: { type: 'range', min: 3, max: 12, step: 0.5, default: 7.5 },
twistIntensity: { type: 'range', min: 1.0, max: 4.0, step: 0.2, default: 2.2 },
twistSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.9 },
coreExponent: { type: 'range', min: 1.0, max: 2.5, step: 0.1, default: 1.6 },
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
},
width: this.width,
height: this.height,
};
}
}
module.exports = WormholeTunnelPreset;