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 +7 -0
- package/README.md +55 -0
- package/adapters.d.ts +140 -0
- package/adapters.js +216 -0
- package/esm/adapters.js +208 -0
- package/esm/fallback.js +55 -0
- package/esm/filter.js +62 -0
- package/esm/index.js +4 -0
- package/esm/main.js +51 -0
- package/esm/receiver.js +110 -0
- package/fallback.d.ts +39 -0
- package/fallback.js +59 -0
- package/filter.d.ts +35 -0
- package/filter.js +69 -0
- package/index.d.ts +8 -0
- package/index.js +20 -0
- package/main.d.ts +7 -0
- package/main.js +53 -0
- package/package.json +47 -0
- package/receiver.d.ts +59 -0
- package/receiver.js +114 -0
package/LICENSE
ADDED
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;
|
package/esm/adapters.js
ADDED
|
@@ -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
|
+
}
|
package/esm/fallback.js
ADDED
|
@@ -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
|
+
}
|