@m4l-jweb/surface 0.3.0 → 0.4.0
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/package.json +4 -2
- package/src/dev.tsx +146 -1
- package/src/index.ts +55 -10
- package/src/react.tsx +45 -0
- package/src/store.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
},
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/index.ts",
|
|
17
|
+
"./store": "./src/store.ts",
|
|
18
|
+
"./react": "./src/react.tsx",
|
|
17
19
|
"./dev": "./src/dev.tsx"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
],
|
|
23
25
|
"peerDependencies": {
|
|
24
26
|
"react": ">=18",
|
|
25
|
-
"@m4l-jweb/bridge": "0.
|
|
27
|
+
"@m4l-jweb/bridge": "0.4.0"
|
|
26
28
|
},
|
|
27
29
|
"peerDependenciesMeta": {
|
|
28
30
|
"react": {
|
package/src/dev.tsx
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { useEffect, useRef, useState } from "react";
|
|
29
29
|
import { simulate, tapMessages, type BridgeMessage } from "@m4l-jweb/bridge";
|
|
30
|
+
import { BANK_SIZE, formatValue, type ParamSpec, type Surface } from "./index";
|
|
31
|
+
import { useSurface } from "./react";
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* A string that must never reach a production bundle. The build test greps the
|
|
@@ -39,7 +41,9 @@ export const HARNESS_MARKER = "m4l-jweb:dev-harness:do-not-ship";
|
|
|
39
41
|
const TICK_MS = 50;
|
|
40
42
|
const LOG_LIMIT = 200;
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
type AnySurface = Surface<Record<string, ParamSpec>>;
|
|
45
|
+
|
|
46
|
+
export function DevHarness({ surface }: { surface?: AnySurface | null }) {
|
|
43
47
|
const [playing, setPlaying] = useState(false);
|
|
44
48
|
const [bpm, setBpm] = useState(120);
|
|
45
49
|
const [beats, setBeats] = useState(0);
|
|
@@ -102,6 +106,13 @@ export function DevHarness() {
|
|
|
102
106
|
</div>
|
|
103
107
|
</section>
|
|
104
108
|
|
|
109
|
+
{surface && surface.ids.length > 0 && (
|
|
110
|
+
<>
|
|
111
|
+
<Params surface={surface} />
|
|
112
|
+
<PushPreview surface={surface} />
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
|
|
105
116
|
<section style={S.section}>
|
|
106
117
|
<div style={S.row}>
|
|
107
118
|
<h2 style={S.h2}>messages</h2>
|
|
@@ -128,6 +139,122 @@ export function DevHarness() {
|
|
|
128
139
|
);
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
/**
|
|
143
|
+
* THE DEVICE PARAMETERS - the half of the device that is real to Push.
|
|
144
|
+
*
|
|
145
|
+
* Rendered from the SAME declaration the Max objects are generated from, and
|
|
146
|
+
* driven through the SAME store the app's `useParam` uses. So moving a control
|
|
147
|
+
* here is not a simulation of a parameter change: it goes through the bridge as
|
|
148
|
+
* `set_<id>`, exactly as the app's own controls do, and the app sees it come back
|
|
149
|
+
* as a parameter change. What a `[live.dial]` would do, minus Live.
|
|
150
|
+
*/
|
|
151
|
+
function Params({ surface }: { surface: AnySurface }) {
|
|
152
|
+
const [values, set] = useSurface(surface);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<section style={S.section}>
|
|
156
|
+
<h2 style={S.h2}>device parameters</h2>
|
|
157
|
+
{surface.ids.map((id) => {
|
|
158
|
+
const spec = surface.params[id];
|
|
159
|
+
const value = values[id];
|
|
160
|
+
return (
|
|
161
|
+
<div key={id} style={S.paramRow}>
|
|
162
|
+
<span style={S.paramName} title={id}>
|
|
163
|
+
{spec.short}
|
|
164
|
+
</span>
|
|
165
|
+
|
|
166
|
+
{spec.kind === "dial" && (
|
|
167
|
+
<input
|
|
168
|
+
style={S.range}
|
|
169
|
+
type="range"
|
|
170
|
+
min={spec.range[0]}
|
|
171
|
+
max={spec.range[1]}
|
|
172
|
+
// A float parameter needs a fine grain, or the harness quantises
|
|
173
|
+
// what Live would not. `step` on the declaration means the
|
|
174
|
+
// parameter is genuinely stepped.
|
|
175
|
+
step={spec.step ?? (spec.range[1] - spec.range[0]) / 1000}
|
|
176
|
+
value={Number(value)}
|
|
177
|
+
onChange={(e) => set(id, Number(e.target.value))}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{spec.kind === "toggle" && <input style={S.check} type="checkbox" checked={Boolean(value)} onChange={(e) => set(id, e.target.checked)} />}
|
|
182
|
+
|
|
183
|
+
{spec.kind === "menu" && (
|
|
184
|
+
<select style={S.select} value={String(value)} onChange={(e) => set(id, e.target.value)}>
|
|
185
|
+
{spec.options.map((o) => (
|
|
186
|
+
<option key={o} value={o}>
|
|
187
|
+
{o}
|
|
188
|
+
</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<span style={S.paramValue}>{formatValue(spec, value)}</span>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</section>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* THE PUSH PREVIEW - what a performer will actually be looking at.
|
|
203
|
+
*
|
|
204
|
+
* Eight encoders to a page, `short` names above, `format`ted values below. Push
|
|
205
|
+
* shows Live parameters and nothing else, so this is the whole of your device as
|
|
206
|
+
* far as the hardware is concerned. Getting it wrong - a label truncated to
|
|
207
|
+
* gibberish, a value that reads "0" when it means 280 Hz - is normally a
|
|
208
|
+
* hardware-in-the-loop discovery. Here it is a browser tab.
|
|
209
|
+
*
|
|
210
|
+
* With no banks declared, Live falls back to declaration order, and so does this.
|
|
211
|
+
*/
|
|
212
|
+
function PushPreview({ surface }: { surface: AnySurface }) {
|
|
213
|
+
const [values] = useSurface(surface);
|
|
214
|
+
const [page, setPage] = useState(0);
|
|
215
|
+
|
|
216
|
+
const banks = surface.banks?.length
|
|
217
|
+
? surface.banks.map((b) => ({ name: b.name, ids: [...b.params] }))
|
|
218
|
+
: chunk(surface.ids, BANK_SIZE).map((ids, i) => ({ name: `Bank ${i + 1}`, ids }));
|
|
219
|
+
|
|
220
|
+
const bank = banks[Math.min(page, banks.length - 1)];
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<section style={S.section}>
|
|
224
|
+
<div style={S.row}>
|
|
225
|
+
<h2 style={S.h2}>push preview</h2>
|
|
226
|
+
<span style={S.bankName}>
|
|
227
|
+
{bank.name} ({Math.min(page, banks.length - 1) + 1}/{banks.length})
|
|
228
|
+
</span>
|
|
229
|
+
{banks.length > 1 && (
|
|
230
|
+
<button style={S.btn} onClick={() => setPage((p) => (p + 1) % banks.length)}>
|
|
231
|
+
next
|
|
232
|
+
</button>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
<div style={S.push}>
|
|
236
|
+
{bank.ids.map((id) => {
|
|
237
|
+
const spec = surface.params[id];
|
|
238
|
+
return (
|
|
239
|
+
<div key={id} style={S.cell}>
|
|
240
|
+
{/* Push gives an encoder ~8 characters. defineSurface() rejects a
|
|
241
|
+
longer `short` outright, so this cannot silently truncate. */}
|
|
242
|
+
<div style={S.cellName}>{spec.short}</div>
|
|
243
|
+
<div style={S.cellValue}>{formatValue(spec, values[id])}</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</div>
|
|
248
|
+
</section>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const chunk = <T,>(xs: readonly T[], n: number): T[][] => {
|
|
253
|
+
const out: T[][] = [];
|
|
254
|
+
for (let i = 0; i < xs.length; i += n) out.push(xs.slice(i, i + n) as T[]);
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
|
|
131
258
|
const mono = "ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
132
259
|
|
|
133
260
|
const S: Record<string, React.CSSProperties> = {
|
|
@@ -166,6 +293,24 @@ const S: Record<string, React.CSSProperties> = {
|
|
|
166
293
|
font: `11px ${mono}`,
|
|
167
294
|
},
|
|
168
295
|
readout: { color: "#7d8694" },
|
|
296
|
+
paramRow: { display: "grid", gridTemplateColumns: "52px 1fr 64px", gap: 6, alignItems: "center" },
|
|
297
|
+
paramName: { color: "#7d8694", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
298
|
+
paramValue: { color: "#8fd0a4", textAlign: "right", overflow: "hidden", whiteSpace: "nowrap" },
|
|
299
|
+
range: { width: "100%", accentColor: "#8fd0a4" },
|
|
300
|
+
check: { justifySelf: "start", accentColor: "#8fd0a4" },
|
|
301
|
+
select: {
|
|
302
|
+
background: "#0e1013",
|
|
303
|
+
color: "#c8ccd4",
|
|
304
|
+
border: "1px solid #333a45",
|
|
305
|
+
borderRadius: 3,
|
|
306
|
+
padding: "2px 4px",
|
|
307
|
+
font: `11px ${mono}`,
|
|
308
|
+
},
|
|
309
|
+
bankName: { color: "#7d8694", marginLeft: "auto" },
|
|
310
|
+
push: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 2 },
|
|
311
|
+
cell: { background: "#0e1013", border: "1px solid #262a31", borderRadius: 2, padding: "4px 5px", overflow: "hidden" },
|
|
312
|
+
cellName: { color: "#7d8694", fontSize: 10, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
313
|
+
cellValue: { color: "#8fd0a4", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
169
314
|
log: { listStyle: "none", margin: 0, padding: 0, overflowY: "auto", maxHeight: 260, display: "flex", flexDirection: "column", gap: 1 },
|
|
170
315
|
line: { display: "flex", gap: 6, whiteSpace: "nowrap" },
|
|
171
316
|
arrowIn: { color: "#5aa9e6" },
|
package/src/index.ts
CHANGED
|
@@ -18,27 +18,61 @@
|
|
|
18
18
|
*
|
|
19
19
|
* This file is the one declaration all four are derived from.
|
|
20
20
|
*
|
|
21
|
-
* STATUS
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* STATUS. The Max side is generated: the build imports this declaration and emits
|
|
22
|
+
* the `live.*` objects and their wiring in both directions (`applySurface()` in
|
|
23
|
+
* @m4l-jweb/build). Declaring a parameter here makes a dial appear in Live, and
|
|
24
|
+
* `patcher/devices.mjs` has no `parameters` field any more.
|
|
25
|
+
*
|
|
26
|
+
* The APP side is not generated yet - `useParam()` / `useSurface()` and the
|
|
27
|
+
* generated protocol selectors are Stages 2.2 and 2.3 of doc/TODO.md - so a
|
|
28
|
+
* device still names its parameters in its own `protocol.ts` and sends `set_<id>`
|
|
29
|
+
* through the bridge itself. Push banks are deferred (3.3); until then Live falls
|
|
30
|
+
* back to declaration order, and Push shows every parameter.
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
33
|
/* ------------------------------------------------------------------ *
|
|
31
34
|
* Parameter kinds
|
|
32
35
|
* ------------------------------------------------------------------ */
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The units Live knows how to print. Anything else is a CUSTOM unit: Live shows
|
|
39
|
+
* the number and appends your string, and a sprintf pattern works too
|
|
40
|
+
* (`"%0.2f Bogons"`).
|
|
41
|
+
*
|
|
42
|
+
* Declaring one is not decoration. With no unit, a float parameter is printed
|
|
43
|
+
* with Max's default unit style, which is INTEGER - so a 0-1 cutoff reads "0" or
|
|
44
|
+
* "1" on a Push while sweeping perfectly smoothly underneath. Say `unit: "Hz"`
|
|
45
|
+
* and the same parameter reads "7.3 kHz".
|
|
46
|
+
*/
|
|
47
|
+
export type Unit = "Hz" | "dB" | "ms" | "%" | "st" | "pan" | "midi" | (string & {});
|
|
48
|
+
|
|
34
49
|
export interface DialSpec {
|
|
35
50
|
kind: "dial";
|
|
36
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* [min, max], IN REAL UNITS. Live needs a bounded range; there is no unbounded
|
|
53
|
+
* parameter.
|
|
54
|
+
*
|
|
55
|
+
* Declare the range the parameter actually has - `[40, 18000]` for a cutoff,
|
|
56
|
+
* not `[0, 1]` with the mapping hidden in a chain. Live's automation lane, Push
|
|
57
|
+
* and your app then all read Hz, and the DSP takes the value directly.
|
|
58
|
+
*/
|
|
37
59
|
range: [number, number];
|
|
38
60
|
default: number;
|
|
39
61
|
/** `step: 1` makes it an integer parameter (Max parameter_type 1). */
|
|
40
62
|
step?: number;
|
|
41
|
-
|
|
63
|
+
/** What Live prints. Omit only for a bare number. See {@link Unit}. */
|
|
64
|
+
unit?: Unit;
|
|
65
|
+
/**
|
|
66
|
+
* Bend the knob's travel: > 1 gives the BOTTOM of the range more of the sweep.
|
|
67
|
+
*
|
|
68
|
+
* Frequency and time want this, because hearing is logarithmic - a linear sweep
|
|
69
|
+
* of 40 Hz to 18 kHz spends almost all its travel in the top octave, where you
|
|
70
|
+
* cannot hear anything happening, and races through the bottom, where everything
|
|
71
|
+
* does. It changes the mapping of rotation to value, never the value itself.
|
|
72
|
+
*/
|
|
73
|
+
exponent?: number;
|
|
74
|
+
/** Quantise the range into N settings. */
|
|
75
|
+
steps?: number;
|
|
42
76
|
/** What the dev harness and the Push preview print under the encoder. */
|
|
43
77
|
format?: (v: number) => string;
|
|
44
78
|
/** Push has ~8 characters per encoder label. Longer names are truncated. */
|
|
@@ -71,7 +105,18 @@ export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
|
|
|
71
105
|
kind: "toggle",
|
|
72
106
|
...spec,
|
|
73
107
|
});
|
|
74
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The options are spelled out rather than written as `Omit<MenuSpec<O>, "kind">`,
|
|
110
|
+
* and that is not a style choice: TypeScript cannot infer `O` THROUGH an `Omit`,
|
|
111
|
+
* so it falls back to the constraint and every menu's value type widens to
|
|
112
|
+
* `string`. The whole point of a menu is that `useParam(surface, "rate")` gives
|
|
113
|
+
* you `"off" | "1/4" | ...` and a typo fails the build, so the inference is the
|
|
114
|
+
* feature.
|
|
115
|
+
*/
|
|
116
|
+
export const menu = <const O extends string>(spec: { options: readonly O[]; default: O; short: string }): MenuSpec<O> => ({
|
|
117
|
+
kind: "menu",
|
|
118
|
+
...spec,
|
|
119
|
+
});
|
|
75
120
|
|
|
76
121
|
/* ------------------------------------------------------------------ *
|
|
77
122
|
* The surface
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @m4l-jweb/surface/react - bind a declared parameter to React, in both directions.
|
|
3
|
+
*
|
|
4
|
+
* const [cutoff, setCutoff] = useParam(surface, "cutoff");
|
|
5
|
+
*
|
|
6
|
+
* That is a two-way binding to a REAL Live parameter, typed from the declaration:
|
|
7
|
+
* `number` for a dial, `boolean` for a toggle, the union of the options for a
|
|
8
|
+
* menu. Turning a Push encoder moves the React state; moving the React control
|
|
9
|
+
* moves the Live parameter, so automation, MIDI mapping and Push all follow.
|
|
10
|
+
*
|
|
11
|
+
* The component names no selectors. `cutoff` and `set_cutoff` are derived from the
|
|
12
|
+
* declaration, exactly as the patcher's `[prepend cutoff]` and `[route set_cutoff]`
|
|
13
|
+
* are - one declaration, one name, both sides. `tests/protocol.test.mjs` fails if a
|
|
14
|
+
* device re-declares one by hand.
|
|
15
|
+
*
|
|
16
|
+
* The state itself, the wire encoding and the echo guard live in `store.ts`, which
|
|
17
|
+
* has no React in it and is tested without a DOM. This file is the hook.
|
|
18
|
+
*/
|
|
19
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
20
|
+
import type { ParamSpec, ParamValue, Surface } from "./index";
|
|
21
|
+
import { paramStore } from "./store";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A two-way binding to one Live parameter. `[value, setValue]`, like useState -
|
|
25
|
+
* except the state lives in Live, and so do automation, MIDI mapping and Push.
|
|
26
|
+
*/
|
|
27
|
+
export function useParam<P extends Record<string, ParamSpec>, K extends Extract<keyof P, string>>(
|
|
28
|
+
surface: Surface<P>,
|
|
29
|
+
id: K,
|
|
30
|
+
): [ParamValue<P[K]>, (value: ParamValue<P[K]>) => void] {
|
|
31
|
+
const store = useMemo(() => paramStore(surface), [surface]);
|
|
32
|
+
const values = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
33
|
+
const set = useCallback((value: ParamValue<P[K]>) => store.write(id, value), [store, id]);
|
|
34
|
+
return [values[id] as ParamValue<P[K]>, set];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every parameter at once, for the component that wants the whole bag - the dev harness's panel, say. */
|
|
38
|
+
export function useSurface<P extends Record<string, ParamSpec>>(
|
|
39
|
+
surface: Surface<P>,
|
|
40
|
+
): [{ [K in keyof P]: ParamValue<P[K]> }, <K extends Extract<keyof P, string>>(id: K, value: ParamValue<P[K]>) => void] {
|
|
41
|
+
const store = useMemo(() => paramStore(surface), [surface]);
|
|
42
|
+
const values = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
43
|
+
const set = useCallback(<K extends Extract<keyof P, string>>(id: K, value: ParamValue<P[K]>) => store.write(id, value), [store]);
|
|
44
|
+
return [values as { [K in keyof P]: ParamValue<P[K]> }, set];
|
|
45
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts - the parameter state behind useParam(), with no React in it.
|
|
3
|
+
*
|
|
4
|
+
* Separate from react.tsx on purpose. Everything subtle about a two-way parameter
|
|
5
|
+
* binding lives here - the wire encoding, the echo guard - and none of it needs a
|
|
6
|
+
* DOM, so all of it is testable (tests/surface-store.test.mjs). React gets a thin
|
|
7
|
+
* useSyncExternalStore wrapper over this.
|
|
8
|
+
*
|
|
9
|
+
* ------------------------------------------------------------------------------
|
|
10
|
+
* ONE STORE PER SURFACE, and it is not an optimisation.
|
|
11
|
+
*
|
|
12
|
+
* The bridge holds ONE handler per selector: a second `bindInlet("cutoff", ...)`
|
|
13
|
+
* silently REPLACES the first. Two components each binding the parameter they
|
|
14
|
+
* read would mean one of them never updates again, with no error anywhere. So a
|
|
15
|
+
* surface binds each parameter exactly once, here, and fans out to subscribers.
|
|
16
|
+
*
|
|
17
|
+
* ------------------------------------------------------------------------------
|
|
18
|
+
* THE ECHO GUARD, and what it is actually for.
|
|
19
|
+
*
|
|
20
|
+
* It is NOT for our own writes coming back. They do not: the patcher feeds the
|
|
21
|
+
* live.* object a `set <value>`, which updates it without producing outlet output
|
|
22
|
+
* (see surface.mjs). That is the defence at the source, and it is the load-bearing
|
|
23
|
+
* one.
|
|
24
|
+
*
|
|
25
|
+
* This is the defence at the DESTINATION, against a different failure: a value
|
|
26
|
+
* arriving *while the user is dragging*. Live sends one whenever it likes - an
|
|
27
|
+
* automation lane is playing, someone turned the dial in Live, a Push encoder
|
|
28
|
+
* moved - and applying it mid-drag makes the control jump backwards under the
|
|
29
|
+
* mouse. So for a short window after a local write, an inbound value is dropped:
|
|
30
|
+
* the user's hand wins, and the next value after the window lands normally.
|
|
31
|
+
*/
|
|
32
|
+
import { bindInlet, outlet } from "@m4l-jweb/bridge";
|
|
33
|
+
import { defaults, type ParamSpec, type Surface } from "./index";
|
|
34
|
+
|
|
35
|
+
/** How long the user's hand beats an inbound value: long enough to cover the gaps between drag events, short enough to be imperceptible. */
|
|
36
|
+
export const GUARD_MS = 120;
|
|
37
|
+
|
|
38
|
+
/** Floats do not survive a round trip exactly. Anything closer than this is the same value. */
|
|
39
|
+
const EPSILON = 1e-6;
|
|
40
|
+
|
|
41
|
+
/** Max stores every parameter as a NUMBER. A menu is an index into its options; a toggle is 0/1. */
|
|
42
|
+
export function toWire(spec: ParamSpec, value: unknown): number {
|
|
43
|
+
if (spec.kind === "toggle") return value ? 1 : 0;
|
|
44
|
+
if (spec.kind === "menu") {
|
|
45
|
+
const i = spec.options.indexOf(String(value));
|
|
46
|
+
return i < 0 ? 0 : i;
|
|
47
|
+
}
|
|
48
|
+
return Number(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ...and back. An out-of-range menu index falls back to the default rather than `undefined`. */
|
|
52
|
+
export function fromWire(spec: ParamSpec, wire: number): unknown {
|
|
53
|
+
if (spec.kind === "toggle") return wire >= 0.5;
|
|
54
|
+
if (spec.kind === "menu") return spec.options[Math.round(wire)] ?? spec.default;
|
|
55
|
+
return wire;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Values = Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
export interface ParamStore {
|
|
61
|
+
get(): Values;
|
|
62
|
+
subscribe(fn: () => void): () => void;
|
|
63
|
+
write(id: string, value: unknown): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stores = new WeakMap<object, ParamStore>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The store for a surface - created once, never torn down. The bridge's bindings
|
|
70
|
+
* are process-wide, so tearing one down would unbind a parameter another component
|
|
71
|
+
* still reads.
|
|
72
|
+
*/
|
|
73
|
+
export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface<P>): ParamStore {
|
|
74
|
+
const existing = stores.get(surface);
|
|
75
|
+
if (existing) return existing;
|
|
76
|
+
|
|
77
|
+
// The app's state before Live has replied: the declared defaults, which are also
|
|
78
|
+
// what the live.* objects load with.
|
|
79
|
+
let values: Values = { ...(defaults(surface) as Values) };
|
|
80
|
+
const listeners = new Set<() => void>();
|
|
81
|
+
const pending = new Map<string, { wire: number; at: number }>();
|
|
82
|
+
|
|
83
|
+
const notify = () => {
|
|
84
|
+
for (const fn of listeners) fn();
|
|
85
|
+
};
|
|
86
|
+
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
87
|
+
|
|
88
|
+
for (const id of surface.ids) {
|
|
89
|
+
const spec = surface.params[id];
|
|
90
|
+
// `<id> <value>` - out of the live.* object, via [prepend <id>].
|
|
91
|
+
bindInlet(id, (raw) => {
|
|
92
|
+
const wire = Number(raw);
|
|
93
|
+
const p = pending.get(id);
|
|
94
|
+
if (p) {
|
|
95
|
+
// Our own value, come back around (Live CAN echo one: a `set` write is
|
|
96
|
+
// silent, but a value we sent while automation was writing the same
|
|
97
|
+
// parameter may still return). Nothing to apply, and clearing the guard
|
|
98
|
+
// early lets the next genuine value through sooner.
|
|
99
|
+
if (Math.abs(wire - p.wire) <= EPSILON) {
|
|
100
|
+
pending.delete(id);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// A DIFFERENT value, arriving while the user is still moving the control.
|
|
104
|
+
// Dropping it is the point - see the note at the top of this file.
|
|
105
|
+
if (now() - p.at < GUARD_MS) return;
|
|
106
|
+
pending.delete(id);
|
|
107
|
+
}
|
|
108
|
+
values = { ...values, [id]: fromWire(spec, wire) };
|
|
109
|
+
notify();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const store: ParamStore = {
|
|
114
|
+
get: () => values,
|
|
115
|
+
subscribe(fn) {
|
|
116
|
+
listeners.add(fn);
|
|
117
|
+
return () => listeners.delete(fn);
|
|
118
|
+
},
|
|
119
|
+
write(id, value) {
|
|
120
|
+
const spec = surface.params[id];
|
|
121
|
+
if (!spec) return;
|
|
122
|
+
const wire = toWire(spec, value);
|
|
123
|
+
// Optimistic: the control follows the hand at once rather than waiting for
|
|
124
|
+
// Live - which, because the patcher writes with `set`, would never send this
|
|
125
|
+
// value back anyway.
|
|
126
|
+
values = { ...values, [id]: value };
|
|
127
|
+
pending.set(id, { wire, at: now() });
|
|
128
|
+
notify();
|
|
129
|
+
outlet(`set_${id}`, wire);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
stores.set(surface, store);
|
|
134
|
+
return store;
|
|
135
|
+
}
|