@m4l-jweb/surface 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "0.2.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.2.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
- export function DevHarness() {
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 - read this before you reach for it. Shipped today (Stage 0.3 of
22
- * doc/TODO.md): the declaration, its types, and its validation. NOT shipped:
23
- * the codegen that turns it into Max objects and wiring (Stage 2). So a Surface
24
- * currently type-checks, validates, and drives the dev harness - but the
25
- * parameters Live actually sees still come from `parameters` in
26
- * `patcher/devices.mjs`. Declaring one does not yet make a dial appear in Live.
27
- * Keep the two in step by hand until Stage 2 lands and deletes the manifest field.
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
- /** [min, max]. Live needs a bounded range; there is no unbounded parameter. */
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
- unit?: string;
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
- export const menu = <O extends string>(spec: Omit<MenuSpec<O>, "kind">): MenuSpec<O> => ({ kind: "menu", ...spec });
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
+ }