diff --git a/test/package.json b/test/package.json index db350ab..cdec9a1 100644 --- a/test/package.json +++ b/test/package.json @@ -6,6 +6,9 @@ "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: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" } } diff --git a/test/pixelstream/lava-lamp.js b/test/pixelstream/lava-lamp.js index 57b5fac..e80aa20 100644 --- a/test/pixelstream/lava-lamp.js +++ b/test/pixelstream/lava-lamp.js @@ -1,11 +1,17 @@ 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 SERPENTINE_WIRING = true; const BASE_BLOB_SPEED = 0.18; const PHASE_SPEED_MIN = 0.6; const PHASE_SPEED_MAX = 1.2; @@ -123,8 +129,8 @@ function generateFrame() { for (let row = 0; row < height; ++row) { for (let col = 0; col < width; ++col) { const energy = calculateEnergyAt(col, row); - const color = samplePalette(energy); - frame[toIndex(col, row)] = color; + const color = samplePaletteFromStops(paletteStops, energy); + frame[toIndex(col, row, width)] = color; } } @@ -151,62 +157,6 @@ function getBlobRadius(blob) { return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation; } -function toIndex(col, row) { - if (!SERPENTINE_WIRING || row % 2 === 0) { - return row * width + col; - } - return row * width + (width - 1 - col); -} - -function samplePalette(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 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 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 clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - function sendFrame() { const payload = generateFrame(); const message = Buffer.from(payload, 'utf8'); diff --git a/test/pixelstream/meteor-rain.js b/test/pixelstream/meteor-rain.js new file mode 100644 index 0000000..0ac8bbb --- /dev/null +++ b/test/pixelstream/meteor-rain.js @@ -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 [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})`, +); + diff --git a/test/pixelstream/ocean-glimmer.js b/test/pixelstream/ocean-glimmer.js new file mode 100644 index 0000000..b129c23 --- /dev/null +++ b/test/pixelstream/ocean-glimmer.js @@ -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 [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)`, +); + diff --git a/test/pixelstream/shared-frame-utils.js b/test/pixelstream/shared-frame-utils.js new file mode 100644 index 0000000..d9d7a2c --- /dev/null +++ b/test/pixelstream/shared-frame-utils.js @@ -0,0 +1,89 @@ +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); + } +} + +module.exports = { + SERPENTINE_WIRING, + clamp, + hexToRgb, + rgbToHex, + lerpRgb, + samplePalette, + toIndex, + createFrame, + frameToPayload, + fadeFrame, +}; + diff --git a/test/pixelstream/spiral-bloom.js b/test/pixelstream/spiral-bloom.js new file mode 100644 index 0000000..2b6da80 --- /dev/null +++ b/test/pixelstream/spiral-bloom.js @@ -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 [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)`, +); +