wavegrid 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ All Rights Reserved.
2
+
3
+ Copyright (c) 2024 Interweb, Inc.
4
+
5
+ This software and associated documentation files (the "Software") may not be
6
+ reproduced, distributed, or used without express written permission from
7
+ Interweb, Inc.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @wavegrid/receiver
2
+
3
+ <p align="center" width="100%">
4
+ <img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5
+ </p>
6
+
7
+ <p align="center" width="100%">
8
+ <a href="https://github.com/constructive-io/Illuminate/actions/workflows/ci.yml">
9
+ <img height="20" src="https://github.com/constructive-io/Illuminate/actions/workflows/ci.yml/badge.svg" />
10
+ </a>
11
+ </p>
12
+
13
+ The **brain** of the Illuminate installation. Sits between the control layer (Canvas/Simulator) and the physical hardware (BEYOND/OSC).
14
+
15
+ ## Design Principles
16
+
17
+ 1. **Never jolt** — runs its own independent low-pass filter on all incoming state, so even if a client disconnects mid-transition, the output always flows smoothly
18
+ 2. **Always alive** — on signal loss, gracefully falls back to ambient 3D sine wave animations instead of freezing or going dark
19
+ 3. **Hardware bridge** — translates HSB grid state into OSC messages for BEYOND (future phase)
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ Canvas ──ws──▶ Simulator ──ws──▶ Receiver ──osc──▶ BEYOND
25
+
26
+ own LP filter
27
+ sine fallback
28
+ health monitor
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```sh
34
+ pnpm dev:receiver
35
+ # Connects to simulator at ws://localhost:3000
36
+ # Outputs state to console (or OSC when configured)
37
+ ```
38
+
39
+ ## Fallback Behavior
40
+
41
+ When the upstream WebSocket connection drops:
42
+ - The receiver continues running its own tick loop at 60fps
43
+ - Current state smoothly transitions into a 3D sine wave pattern
44
+ - Sine waves sweep through hue/brightness across the 7×7 grid
45
+ - When connection restores, sine wave smoothly blends back to received state
46
+
47
+ ## Configuration
48
+
49
+ | Env | Default | Description |
50
+ |-----|---------|-------------|
51
+ | `SIMULATOR_URL` | `ws://localhost:3000` | Upstream WebSocket |
52
+ | `RECEIVER_ALPHA` | `0.06` | Low-pass filter smoothing (lower = smoother) |
53
+ | `FALLBACK_DELAY` | `3000` | ms before switching to sine fallback |
54
+ | `OSC_HOST` | — | BEYOND OSC target host (future) |
55
+ | `OSC_PORT` | — | BEYOND OSC target port (future) |
package/adapters.d.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Adapter interfaces for the Illuminate Receiver.
3
+ *
4
+ * The Receiver is a pure state engine — it doesn't know or care where
5
+ * input comes from or where output goes. These adapters make both sides
6
+ * pluggable so the module can be published today and connected to any
7
+ * hardware/protocol later by instantiating the right adapter class.
8
+ */
9
+ import { EventEmitter } from 'events';
10
+ import { CannonState } from './filter';
11
+ /**
12
+ * Translates a logical grid index (0–48) to a hardware address string.
13
+ * Examples:
14
+ * (index) => `/beyond/laser/${index + 1}`
15
+ * (index) => `dmx/fixture/${index * 3}`
16
+ */
17
+ export type AddressMapping = (index: number) => string;
18
+ /**
19
+ * Generic output adapter interface.
20
+ * Implement this to send filtered grid state to any target:
21
+ * OSC/BEYOND, DMX, Art-Net, MQTT, HTTP, file, etc.
22
+ */
23
+ export interface OutputAdapter {
24
+ /** Called every tick (~60fps) with the full filtered grid state. */
25
+ send(grid: CannonState[]): void;
26
+ /** Clean up resources (close sockets, etc). */
27
+ close(): void;
28
+ }
29
+ /**
30
+ * Configuration shared by all output adapters that use address mapping.
31
+ */
32
+ export interface MappedOutputConfig {
33
+ /** Translate grid index → hardware address. */
34
+ mapping?: AddressMapping;
35
+ }
36
+ /**
37
+ * Generic input adapter interface.
38
+ * Implement this to receive grid state from any source:
39
+ * WebSocket, MQTT, HTTP polling, serial, etc.
40
+ *
41
+ * Emits:
42
+ * 'state' (grid: CannonState[]) — new state snapshot received
43
+ * 'connected' — upstream connection established
44
+ * 'disconnected' — upstream connection lost
45
+ */
46
+ export interface InputAdapter {
47
+ /** Start receiving state. */
48
+ connect(): void;
49
+ /** Stop receiving and clean up. */
50
+ disconnect(): void;
51
+ /** Register event listener. */
52
+ on(event: 'state', listener: (grid: CannonState[]) => void): this;
53
+ on(event: 'connected', listener: () => void): this;
54
+ on(event: 'disconnected', listener: () => void): this;
55
+ /** Remove event listener. */
56
+ off(event: string, listener: (...args: unknown[]) => void): this;
57
+ }
58
+ /**
59
+ * Console output adapter — logs state periodically for dev/debug.
60
+ * Use this when no hardware is connected.
61
+ */
62
+ export declare class ConsoleOutput implements OutputAdapter {
63
+ private frameCount;
64
+ private logInterval;
65
+ constructor(opts?: {
66
+ logEveryNFrames?: number;
67
+ });
68
+ send(grid: CannonState[]): void;
69
+ close(): void;
70
+ }
71
+ /**
72
+ * Callback output adapter — calls a user-provided function each tick.
73
+ * Useful for custom integrations, testing, or piping to another system.
74
+ */
75
+ export declare class CallbackOutput implements OutputAdapter {
76
+ private fn;
77
+ constructor(fn: (grid: CannonState[]) => void);
78
+ send(grid: CannonState[]): void;
79
+ close(): void;
80
+ }
81
+ /**
82
+ * Multi-output adapter — fans out to multiple output adapters.
83
+ * Use this to send to both console and hardware simultaneously.
84
+ *
85
+ * Example:
86
+ * new MultiOutput([new ConsoleOutput(), new BeyondOscOutput(config)])
87
+ */
88
+ export declare class MultiOutput implements OutputAdapter {
89
+ private outputs;
90
+ constructor(outputs: OutputAdapter[]);
91
+ send(grid: CannonState[]): void;
92
+ close(): void;
93
+ }
94
+ /**
95
+ * WebSocket input adapter — connects to an upstream server and
96
+ * receives state snapshots as JSON messages.
97
+ *
98
+ * Expected message format: { type: "state", grid: CannonState[] }
99
+ * This is what the Illuminate simulator broadcasts.
100
+ */
101
+ export declare class WebSocketInput extends EventEmitter implements InputAdapter {
102
+ private url;
103
+ private ws;
104
+ private reconnectTimer;
105
+ private reconnectInterval;
106
+ private _connected;
107
+ private _running;
108
+ constructor(opts: {
109
+ url: string;
110
+ reconnectInterval?: number;
111
+ });
112
+ get connected(): boolean;
113
+ connect(): void;
114
+ disconnect(): void;
115
+ private doConnect;
116
+ private scheduleReconnect;
117
+ }
118
+ /**
119
+ * WebSocket server output adapter — broadcasts filtered state to
120
+ * all connected downstream clients.
121
+ *
122
+ * Use this to relay the receiver's output to other systems, UIs,
123
+ * or a chain of downstream receivers.
124
+ */
125
+ export declare class WebSocketOutput implements OutputAdapter {
126
+ private wss;
127
+ private port;
128
+ private mapping;
129
+ private broadcastInterval;
130
+ private frameCount;
131
+ constructor(opts: {
132
+ port: number;
133
+ mapping?: AddressMapping;
134
+ broadcastEveryNFrames?: number;
135
+ });
136
+ /** Start the WebSocket server. Call this before the receiver starts ticking. */
137
+ listen(): void;
138
+ send(grid: CannonState[]): void;
139
+ close(): void;
140
+ }
package/adapters.js ADDED
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ /**
3
+ * Adapter interfaces for the Illuminate Receiver.
4
+ *
5
+ * The Receiver is a pure state engine — it doesn't know or care where
6
+ * input comes from or where output goes. These adapters make both sides
7
+ * pluggable so the module can be published today and connected to any
8
+ * hardware/protocol later by instantiating the right adapter class.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.WebSocketOutput = exports.WebSocketInput = exports.MultiOutput = exports.CallbackOutput = exports.ConsoleOutput = void 0;
12
+ const events_1 = require("events");
13
+ // ═══════════════════════════════════════════════════
14
+ // Built-in Output Adapters
15
+ // ═══════════════════════════════════════════════════
16
+ /**
17
+ * Console output adapter — logs state periodically for dev/debug.
18
+ * Use this when no hardware is connected.
19
+ */
20
+ class ConsoleOutput {
21
+ frameCount = 0;
22
+ logInterval;
23
+ constructor(opts = {}) {
24
+ this.logInterval = opts.logEveryNFrames ?? 60;
25
+ }
26
+ send(grid) {
27
+ this.frameCount++;
28
+ if (this.frameCount % this.logInterval === 0) {
29
+ const sample = grid[0];
30
+ process.stdout.write(`\r ◈ frame ${this.frameCount} sample[0]: h=${sample.h.toFixed(1)} s=${sample.s.toFixed(1)} b=${sample.b.toFixed(1)} `);
31
+ }
32
+ }
33
+ close() {
34
+ console.log('\n ◈ Console output closed');
35
+ }
36
+ }
37
+ exports.ConsoleOutput = ConsoleOutput;
38
+ /**
39
+ * Callback output adapter — calls a user-provided function each tick.
40
+ * Useful for custom integrations, testing, or piping to another system.
41
+ */
42
+ class CallbackOutput {
43
+ fn;
44
+ constructor(fn) {
45
+ this.fn = fn;
46
+ }
47
+ send(grid) {
48
+ this.fn(grid);
49
+ }
50
+ close() {
51
+ // nothing to clean up
52
+ }
53
+ }
54
+ exports.CallbackOutput = CallbackOutput;
55
+ /**
56
+ * Multi-output adapter — fans out to multiple output adapters.
57
+ * Use this to send to both console and hardware simultaneously.
58
+ *
59
+ * Example:
60
+ * new MultiOutput([new ConsoleOutput(), new BeyondOscOutput(config)])
61
+ */
62
+ class MultiOutput {
63
+ outputs;
64
+ constructor(outputs) {
65
+ this.outputs = outputs;
66
+ }
67
+ send(grid) {
68
+ for (const out of this.outputs) {
69
+ out.send(grid);
70
+ }
71
+ }
72
+ close() {
73
+ for (const out of this.outputs) {
74
+ out.close();
75
+ }
76
+ }
77
+ }
78
+ exports.MultiOutput = MultiOutput;
79
+ // ═══════════════════════════════════════════════════
80
+ // Built-in Input Adapters
81
+ // ═══════════════════════════════════════════════════
82
+ /**
83
+ * WebSocket input adapter — connects to an upstream server and
84
+ * receives state snapshots as JSON messages.
85
+ *
86
+ * Expected message format: { type: "state", grid: CannonState[] }
87
+ * This is what the Illuminate simulator broadcasts.
88
+ */
89
+ class WebSocketInput extends events_1.EventEmitter {
90
+ url;
91
+ ws = null;
92
+ reconnectTimer = null;
93
+ reconnectInterval;
94
+ _connected = false;
95
+ _running = false;
96
+ constructor(opts) {
97
+ super();
98
+ this.url = opts.url;
99
+ this.reconnectInterval = opts.reconnectInterval ?? 2000;
100
+ }
101
+ get connected() { return this._connected; }
102
+ connect() {
103
+ if (this._running)
104
+ return;
105
+ this._running = true;
106
+ this.doConnect();
107
+ }
108
+ disconnect() {
109
+ this._running = false;
110
+ if (this.reconnectTimer) {
111
+ clearTimeout(this.reconnectTimer);
112
+ this.reconnectTimer = null;
113
+ }
114
+ if (this.ws) {
115
+ this.ws.close();
116
+ this.ws = null;
117
+ }
118
+ this._connected = false;
119
+ }
120
+ doConnect() {
121
+ // Dynamic import to avoid issues in environments without ws
122
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
123
+ const { WebSocket } = require('ws');
124
+ try {
125
+ this.ws = new WebSocket(this.url);
126
+ this.ws.on('open', () => {
127
+ this._connected = true;
128
+ this.emit('connected');
129
+ });
130
+ this.ws.on('message', (raw) => {
131
+ try {
132
+ const msg = JSON.parse(raw.toString());
133
+ if (msg.type === 'state' && Array.isArray(msg.grid)) {
134
+ this.emit('state', msg.grid);
135
+ }
136
+ }
137
+ catch (_e) {
138
+ // ignore malformed messages
139
+ }
140
+ });
141
+ this.ws.on('close', () => {
142
+ this._connected = false;
143
+ this.emit('disconnected');
144
+ this.scheduleReconnect();
145
+ });
146
+ this.ws.on('error', () => {
147
+ this.scheduleReconnect();
148
+ });
149
+ }
150
+ catch (_e) {
151
+ this.scheduleReconnect();
152
+ }
153
+ }
154
+ scheduleReconnect() {
155
+ if (this.reconnectTimer || !this._running)
156
+ return;
157
+ this.reconnectTimer = setTimeout(() => {
158
+ this.reconnectTimer = null;
159
+ if (this._running)
160
+ this.doConnect();
161
+ }, this.reconnectInterval);
162
+ }
163
+ }
164
+ exports.WebSocketInput = WebSocketInput;
165
+ // ═══════════════════════════════════════════════════
166
+ // WebSocket Output Adapter
167
+ // ═══════════════════════════════════════════════════
168
+ /**
169
+ * WebSocket server output adapter — broadcasts filtered state to
170
+ * all connected downstream clients.
171
+ *
172
+ * Use this to relay the receiver's output to other systems, UIs,
173
+ * or a chain of downstream receivers.
174
+ */
175
+ class WebSocketOutput {
176
+ wss = null;
177
+ port;
178
+ mapping;
179
+ broadcastInterval;
180
+ frameCount = 0;
181
+ constructor(opts) {
182
+ this.port = opts.port;
183
+ this.mapping = opts.mapping ?? null;
184
+ this.broadcastInterval = opts.broadcastEveryNFrames ?? 1;
185
+ }
186
+ /** Start the WebSocket server. Call this before the receiver starts ticking. */
187
+ listen() {
188
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
189
+ const { WebSocketServer } = require('ws');
190
+ this.wss = new WebSocketServer({ port: this.port });
191
+ console.log(` \u25C8 WebSocket output listening on :${this.port}`);
192
+ }
193
+ send(grid) {
194
+ if (!this.wss)
195
+ return;
196
+ this.frameCount++;
197
+ if (this.frameCount % this.broadcastInterval !== 0)
198
+ return;
199
+ const payload = this.mapping
200
+ ? JSON.stringify({ type: 'state', grid, mapping: grid.map((_, i) => this.mapping(i)) })
201
+ : JSON.stringify({ type: 'state', grid });
202
+ for (const client of this.wss.clients) {
203
+ if (client.readyState === 1) {
204
+ client.send(payload);
205
+ }
206
+ }
207
+ }
208
+ close() {
209
+ if (this.wss) {
210
+ this.wss.close();
211
+ this.wss = null;
212
+ console.log(` \u25C8 WebSocket output closed`);
213
+ }
214
+ }
215
+ }
216
+ exports.WebSocketOutput = WebSocketOutput;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Adapter interfaces for the Illuminate Receiver.
3
+ *
4
+ * The Receiver is a pure state engine — it doesn't know or care where
5
+ * input comes from or where output goes. These adapters make both sides
6
+ * pluggable so the module can be published today and connected to any
7
+ * hardware/protocol later by instantiating the right adapter class.
8
+ */
9
+ import { EventEmitter } from 'events';
10
+ // ═══════════════════════════════════════════════════
11
+ // Built-in Output Adapters
12
+ // ═══════════════════════════════════════════════════
13
+ /**
14
+ * Console output adapter — logs state periodically for dev/debug.
15
+ * Use this when no hardware is connected.
16
+ */
17
+ export class ConsoleOutput {
18
+ frameCount = 0;
19
+ logInterval;
20
+ constructor(opts = {}) {
21
+ this.logInterval = opts.logEveryNFrames ?? 60;
22
+ }
23
+ send(grid) {
24
+ this.frameCount++;
25
+ if (this.frameCount % this.logInterval === 0) {
26
+ const sample = grid[0];
27
+ process.stdout.write(`\r ◈ frame ${this.frameCount} sample[0]: h=${sample.h.toFixed(1)} s=${sample.s.toFixed(1)} b=${sample.b.toFixed(1)} `);
28
+ }
29
+ }
30
+ close() {
31
+ console.log('\n ◈ Console output closed');
32
+ }
33
+ }
34
+ /**
35
+ * Callback output adapter — calls a user-provided function each tick.
36
+ * Useful for custom integrations, testing, or piping to another system.
37
+ */
38
+ export class CallbackOutput {
39
+ fn;
40
+ constructor(fn) {
41
+ this.fn = fn;
42
+ }
43
+ send(grid) {
44
+ this.fn(grid);
45
+ }
46
+ close() {
47
+ // nothing to clean up
48
+ }
49
+ }
50
+ /**
51
+ * Multi-output adapter — fans out to multiple output adapters.
52
+ * Use this to send to both console and hardware simultaneously.
53
+ *
54
+ * Example:
55
+ * new MultiOutput([new ConsoleOutput(), new BeyondOscOutput(config)])
56
+ */
57
+ export class MultiOutput {
58
+ outputs;
59
+ constructor(outputs) {
60
+ this.outputs = outputs;
61
+ }
62
+ send(grid) {
63
+ for (const out of this.outputs) {
64
+ out.send(grid);
65
+ }
66
+ }
67
+ close() {
68
+ for (const out of this.outputs) {
69
+ out.close();
70
+ }
71
+ }
72
+ }
73
+ // ═══════════════════════════════════════════════════
74
+ // Built-in Input Adapters
75
+ // ═══════════════════════════════════════════════════
76
+ /**
77
+ * WebSocket input adapter — connects to an upstream server and
78
+ * receives state snapshots as JSON messages.
79
+ *
80
+ * Expected message format: { type: "state", grid: CannonState[] }
81
+ * This is what the Illuminate simulator broadcasts.
82
+ */
83
+ export class WebSocketInput extends EventEmitter {
84
+ url;
85
+ ws = null;
86
+ reconnectTimer = null;
87
+ reconnectInterval;
88
+ _connected = false;
89
+ _running = false;
90
+ constructor(opts) {
91
+ super();
92
+ this.url = opts.url;
93
+ this.reconnectInterval = opts.reconnectInterval ?? 2000;
94
+ }
95
+ get connected() { return this._connected; }
96
+ connect() {
97
+ if (this._running)
98
+ return;
99
+ this._running = true;
100
+ this.doConnect();
101
+ }
102
+ disconnect() {
103
+ this._running = false;
104
+ if (this.reconnectTimer) {
105
+ clearTimeout(this.reconnectTimer);
106
+ this.reconnectTimer = null;
107
+ }
108
+ if (this.ws) {
109
+ this.ws.close();
110
+ this.ws = null;
111
+ }
112
+ this._connected = false;
113
+ }
114
+ doConnect() {
115
+ // Dynamic import to avoid issues in environments without ws
116
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
117
+ const { WebSocket } = require('ws');
118
+ try {
119
+ this.ws = new WebSocket(this.url);
120
+ this.ws.on('open', () => {
121
+ this._connected = true;
122
+ this.emit('connected');
123
+ });
124
+ this.ws.on('message', (raw) => {
125
+ try {
126
+ const msg = JSON.parse(raw.toString());
127
+ if (msg.type === 'state' && Array.isArray(msg.grid)) {
128
+ this.emit('state', msg.grid);
129
+ }
130
+ }
131
+ catch (_e) {
132
+ // ignore malformed messages
133
+ }
134
+ });
135
+ this.ws.on('close', () => {
136
+ this._connected = false;
137
+ this.emit('disconnected');
138
+ this.scheduleReconnect();
139
+ });
140
+ this.ws.on('error', () => {
141
+ this.scheduleReconnect();
142
+ });
143
+ }
144
+ catch (_e) {
145
+ this.scheduleReconnect();
146
+ }
147
+ }
148
+ scheduleReconnect() {
149
+ if (this.reconnectTimer || !this._running)
150
+ return;
151
+ this.reconnectTimer = setTimeout(() => {
152
+ this.reconnectTimer = null;
153
+ if (this._running)
154
+ this.doConnect();
155
+ }, this.reconnectInterval);
156
+ }
157
+ }
158
+ // ═══════════════════════════════════════════════════
159
+ // WebSocket Output Adapter
160
+ // ═══════════════════════════════════════════════════
161
+ /**
162
+ * WebSocket server output adapter — broadcasts filtered state to
163
+ * all connected downstream clients.
164
+ *
165
+ * Use this to relay the receiver's output to other systems, UIs,
166
+ * or a chain of downstream receivers.
167
+ */
168
+ export class WebSocketOutput {
169
+ wss = null;
170
+ port;
171
+ mapping;
172
+ broadcastInterval;
173
+ frameCount = 0;
174
+ constructor(opts) {
175
+ this.port = opts.port;
176
+ this.mapping = opts.mapping ?? null;
177
+ this.broadcastInterval = opts.broadcastEveryNFrames ?? 1;
178
+ }
179
+ /** Start the WebSocket server. Call this before the receiver starts ticking. */
180
+ listen() {
181
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
182
+ const { WebSocketServer } = require('ws');
183
+ this.wss = new WebSocketServer({ port: this.port });
184
+ console.log(` \u25C8 WebSocket output listening on :${this.port}`);
185
+ }
186
+ send(grid) {
187
+ if (!this.wss)
188
+ return;
189
+ this.frameCount++;
190
+ if (this.frameCount % this.broadcastInterval !== 0)
191
+ return;
192
+ const payload = this.mapping
193
+ ? JSON.stringify({ type: 'state', grid, mapping: grid.map((_, i) => this.mapping(i)) })
194
+ : JSON.stringify({ type: 'state', grid });
195
+ for (const client of this.wss.clients) {
196
+ if (client.readyState === 1) {
197
+ client.send(payload);
198
+ }
199
+ }
200
+ }
201
+ close() {
202
+ if (this.wss) {
203
+ this.wss.close();
204
+ this.wss = null;
205
+ console.log(` \u25C8 WebSocket output closed`);
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 3D sine wave fallback animations.
3
+ *
4
+ * When the upstream signal is lost, the receiver doesn't freeze or go dark.
5
+ * Instead it smoothly transitions into ambient sine wave patterns that sweep
6
+ * across the 7×7 grid in three dimensions (hue, brightness, and time).
7
+ *
8
+ * The waves create organic, slowly evolving color movement — like the
9
+ * installation is breathing on its own.
10
+ */
11
+ import { GRID_SIZE, NUM_CANNONS } from './filter';
12
+ export const DEFAULT_FALLBACK_CONFIG = {
13
+ baseHue: 220,
14
+ hueSpread: 60,
15
+ brightnessMin: 30,
16
+ brightnessMax: 85,
17
+ spatialFreq: 0.8,
18
+ timeFreq: 0.015,
19
+ timeFreq2: 0.009
20
+ };
21
+ /**
22
+ * Compute one frame of the 3D sine wave fallback and write targets
23
+ * into the filtered grid. The receiver's low-pass filter then smoothly
24
+ * converges the output to these targets.
25
+ *
26
+ * Three overlapping sine waves create organic movement:
27
+ * 1. Primary wave sweeps diagonally across the grid (hue)
28
+ * 2. Secondary wave moves perpendicular (brightness)
29
+ * 3. Tertiary slow wave modulates saturation for depth
30
+ */
31
+ export function computeFallbackFrame(grid, tick, config = DEFAULT_FALLBACK_CONFIG) {
32
+ const { baseHue, hueSpread, brightnessMin, brightnessMax, spatialFreq, timeFreq, timeFreq2 } = config;
33
+ const brightRange = brightnessMax - brightnessMin;
34
+ for (let i = 0; i < NUM_CANNONS; i++) {
35
+ const row = Math.floor(i / GRID_SIZE);
36
+ const col = i % GRID_SIZE;
37
+ // Normalize to -1..1
38
+ const nx = (col / (GRID_SIZE - 1)) * 2 - 1;
39
+ const ny = (row / (GRID_SIZE - 1)) * 2 - 1;
40
+ // Primary diagonal wave → hue
41
+ const wave1 = Math.sin((nx + ny) * spatialFreq * Math.PI + tick * timeFreq);
42
+ // Secondary perpendicular wave → brightness
43
+ const wave2 = Math.sin((nx - ny) * spatialFreq * Math.PI * 0.7 + tick * timeFreq2);
44
+ // Tertiary slow radial wave → saturation depth
45
+ const dist = Math.sqrt(nx * nx + ny * ny);
46
+ const wave3 = Math.sin(dist * Math.PI + tick * timeFreq * 0.4);
47
+ // Map to HSB
48
+ const h = (baseHue + wave1 * hueSpread + 360) % 360;
49
+ const s = 70 + wave3 * 20; // 50–90 range
50
+ const b = brightnessMin + ((wave2 + 1) / 2) * brightRange;
51
+ grid[i].targetH = h;
52
+ grid[i].targetS = Math.max(0, Math.min(100, s));
53
+ grid[i].targetB = Math.max(0, Math.min(100, b));
54
+ }
55
+ }