@m4l-jweb/surface 1.0.0 → 1.2.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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +119 -10
  3. package/src/react.tsx +140 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "peerDependencies": {
26
26
  "react": ">=18",
27
- "@m4l-jweb/bridge": "1.0.0"
27
+ "@m4l-jweb/bridge": "1.2.0"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "react": {
package/src/index.ts CHANGED
@@ -23,11 +23,11 @@
23
23
  * @m4l-jweb/build). Declaring a parameter here makes a dial appear in Live, and
24
24
  * `patcher/devices.mjs` has no `parameters` field any more.
25
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.
26
+ * The APP side is generated too now: `useParam()` / `useSurface()` read a declared
27
+ * parameter in React (@m4l-jweb/surface/react) and the selectors come from the
28
+ * declaration, so a device does not retype `set_<id>` in its own `protocol.ts`. Push
29
+ * banks ship as well - a surface that declares none falls back to declaration order,
30
+ * and Push shows every parameter.
31
31
  */
32
32
 
33
33
  /* ------------------------------------------------------------------ *
@@ -141,7 +141,62 @@ export interface WindowSpec {
141
141
  title: string;
142
142
  width: number;
143
143
  height: number;
144
- entry: string;
144
+ /**
145
+ * The component to bundle, from the device's own folder. Mutually exclusive
146
+ * with `site`: a window's content is either one of our components or a
147
+ * prebuilt directory, never both.
148
+ */
149
+ entry?: string;
150
+ /**
151
+ * The window's page is a SOUND SOURCE.
152
+ *
153
+ * It compiles to `[jweb~]` instead of `[jweb]`, and the page's L/R signal
154
+ * outlets leave the subpatcher on a pair of `[outlet]`s and are summed into the
155
+ * device's audio path at the same `[+~]` stage shape the `webaudio` chain uses.
156
+ * The device must therefore be an `audio` or `instrument` device; a MIDI device
157
+ * has no signal path and the build says so.
158
+ *
159
+ * A window that makes sound cannot wait to be opened before it loads: the page
160
+ * is pulsed open-then-closed once at device load (`loadbang`), so its
161
+ * AudioContext exists whether or not anyone ever looks at the window.
162
+ *
163
+ * The plain `[jweb]` window is untouched by this - `audio` ADDS a second
164
+ * primitive rather than changing the first.
165
+ */
166
+ audio?: boolean;
167
+ /**
168
+ * Output latency of the sounding window's `[jweb~]`, in milliseconds - the ring
169
+ * buffer between Chromium's audio thread and MSP. Max 9 documents 0 as the
170
+ * minimum (~23 ms at 44.1kHz, ~21 ms at 48kHz), warns that the minimum "may
171
+ * result in occasional drop-outs or distortion", and caps the maximum at three
172
+ * times the minimum. Unset leaves the object's own default.
173
+ *
174
+ * Only meaningful with `audio: true`; ignored otherwise.
175
+ */
176
+ latency?: number;
177
+ /**
178
+ * The `rendermode` attribute stamped on the window's jweb object:
179
+ * 0 = onscreen, 1 = offscreen. The Max reference notes offscreen "is slower".
180
+ * Default is 1, which is what every generated window has shipped with so far.
181
+ */
182
+ rendermode?: 0 | 1;
183
+ /**
184
+ * Report interval of the window's level tap (`[peakamp~ N]`), in milliseconds.
185
+ * Default 10, which is 100 float messages per second per channel into the
186
+ * wrapper's [js]. Raise it to trade meter smoothness for message traffic.
187
+ *
188
+ * Only meaningful with `audio: true`; ignored otherwise.
189
+ */
190
+ levelInterval?: number;
191
+ /**
192
+ * Window content from a PREBUILT static directory rather than a component of
193
+ * ours - a whole site, built by something else (its own Astro/vite build), and
194
+ * delivered as a folder next to the `.amxd` instead of base64 inside it.
195
+ *
196
+ * The path is relative to the device repo root and must contain `index.html`.
197
+ * Mutually exclusive with `entry`.
198
+ */
199
+ site?: string;
145
200
  /**
146
201
  * Keep the window in FRONT of Live, instead of behind it the moment Live is clicked.
147
202
  *
@@ -192,6 +247,32 @@ export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
192
247
  kind: "dial",
193
248
  ...spec,
194
249
  });
250
+
251
+ /**
252
+ * A POOL of interchangeable native dials, for a device whose real controls are not
253
+ * known until it runs.
254
+ *
255
+ * A `live.dial` is stamped into the frozen `.amxd` at build time, so a device cannot
256
+ * grow one when the user's code asks for a control. What it can do is reserve a
257
+ * fixed number of them and lend them out - `slider()` calls in a pattern, the
258
+ * parameters of whatever effect was just loaded - and that borrowing is the same
259
+ * problem in every device that has it.
260
+ *
261
+ * params: { ...knobPool(8) } // s1..s8, all 0..1
262
+ *
263
+ * The dials are declared 0..1 because a borrower's real range is not known here.
264
+ * `useControls()` in ./react then hands them out in order, tells Live what each one
265
+ * currently IS (name, unit, range - see describeParam in @m4l-jweb/bridge), and
266
+ * keeps the scaling straight.
267
+ */
268
+ export const knobPool = <N extends number>(count: N, prefix = "s"): Record<string, DialSpec> => {
269
+ const out: Record<string, DialSpec> = {};
270
+ for (let i = 1; i <= count; i++) {
271
+ // The short name is what Push prints when nothing has borrowed the slot yet.
272
+ out[`${prefix}${i}`] = dial({ range: [0, 1], default: 0, short: `${prefix.toUpperCase()}${i}` });
273
+ }
274
+ return out;
275
+ };
195
276
  export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
196
277
  kind: "toggle",
197
278
  ...spec,
@@ -243,10 +324,20 @@ export interface NativeLayout<K extends string = string> {
243
324
  */
244
325
  params: readonly K[];
245
326
  /**
246
- * Max rows per column. Default 3 - the device view is a fixed ~169 px tall and a
247
- * `live.dial` needs a 56 px pitch, so only 3 fit vertically.
327
+ * How the controls are arranged. Default 3.
328
+ *
329
+ * A NUMBER is rows per column, filled column-major: three down, then the next
330
+ * column. Adding a parameter never reshuffles the ones before it.
331
+ *
332
+ * AN ARRAY is the size of each row, filled left to right - `[1, 4, 4]` puts one
333
+ * control on the first row and four on each of the next two. Column-major cannot
334
+ * express that, and it is what a panel usually wants to say: a transport button
335
+ * ABOVE two banks of dials, rather than interleaved with them.
336
+ *
337
+ * Either way the device view is a fixed ~169 px tall and a `live.dial` needs a
338
+ * 56 px pitch, so three rows is the ceiling.
248
339
  */
249
- rows?: number;
340
+ rows?: number | number[];
250
341
  /**
251
342
  * LAYERED "two screens" instead of side-by-side. When true, `[jweb]` is built
252
343
  * full-width and the dials OVERLAP its left, and the app flips between them with
@@ -349,6 +440,19 @@ export function defineSurface<
349
440
  }
350
441
  }
351
442
 
443
+ // A window holds EITHER a component of ours or a prebuilt site. Both is
444
+ // ambiguous (which one loads?) and neither is an empty window - and both would
445
+ // otherwise fail deep in the build, as a vite entry that does not resolve or a
446
+ // page that never gets a url.
447
+ for (const [id, w] of Object.entries(def.windows ?? {})) {
448
+ if (w.entry && w.site) {
449
+ throw new Error(`surface: window "${id}" declares both entry "${w.entry}" and site "${w.site}" - a window holds one or the other`);
450
+ }
451
+ if (!w.entry && !w.site) {
452
+ throw new Error(`surface: window "${id}" declares neither entry nor site - it would open empty`);
453
+ }
454
+ }
455
+
352
456
  // A native layout may only name parameters that exist, and may not ask for more
353
457
  // rows than the device view holds. Both throw here, at build time, for the same
354
458
  // reason the bank checks do: a typo would otherwise generate a cord from a box
@@ -359,7 +463,12 @@ export function defineSurface<
359
463
  if (!def.params[id]) throw new Error(`surface: layout.native names "${id}", which is not a declared parameter`);
360
464
  }
361
465
  const rows = native.rows ?? 3;
362
- if (rows < 1 || rows > 3) throw new Error(`surface: layout.native.rows must be 1..3 - the device view is 169 px tall`);
466
+ if (Array.isArray(rows)) {
467
+ if (rows.length > 3) throw new Error(`surface: layout.native.rows has ${rows.length} rows - the device view is 169 px tall and holds 3`);
468
+ if (rows.some((n) => !(n > 0))) throw new Error(`surface: layout.native.rows must be positive counts, e.g. [1, 4, 4]`);
469
+ } else if (rows < 1 || rows > 3) {
470
+ throw new Error(`surface: layout.native.rows must be 1..3 - the device view is 169 px tall`);
471
+ }
363
472
  if (native.switch !== undefined && !def.params[native.switch]) {
364
473
  throw new Error(`surface: layout.native.switch names "${native.switch}", which is not a declared parameter`);
365
474
  }
package/src/react.tsx CHANGED
@@ -16,8 +16,8 @@
16
16
  * The state itself, the wire encoding and the echo guard live in `store.ts`, which
17
17
  * has no React in it and is tested without a DOM. This file is the hook.
18
18
  */
19
- import { useCallback, useMemo, useSyncExternalStore } from "react";
20
- import { outlet } from "@m4l-jweb/bridge";
19
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
20
+ import { describeParam, onParamRange, outlet } from "@m4l-jweb/bridge";
21
21
  import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
22
22
  import { JWEB_VARNAME } from "./index";
23
23
  import { paramStore, stateStore, watchStore } from "./store";
@@ -158,3 +158,141 @@ export function useStateSync<
158
158
  const set = useCallback((value: StateValue<S[K]>) => store.write(id, value), [store, id]);
159
159
  return [values[id] as StateValue<S[K]>, set];
160
160
  }
161
+
162
+ /* ------------------------------------------------------------------ *
163
+ * Borrowing from a native knob pool
164
+ * ------------------------------------------------------------------ */
165
+
166
+ /** A control a device wants on a native dial, described as well as it can. */
167
+ export interface BorrowedControl {
168
+ /** What to call it - the user's own term where the device can infer one. */
169
+ name: string;
170
+ /** Its real travel. The dial is declared 0..1; this is what the value means. */
171
+ min: number;
172
+ max: number;
173
+ /** How Live should print it ("Hz", "dB", "%"...). Optional; see describeParam. */
174
+ unit?: string;
175
+ /** Where it starts, in real units, the first time it takes a slot. */
176
+ value?: number;
177
+ }
178
+
179
+ /** A borrowed control, wired to the dial that is carrying it. */
180
+ export interface PooledControl extends BorrowedControl {
181
+ /** Position 0..1, for drawing a fader. */
182
+ norm: number;
183
+ /** The value the device should use, in the control's own units. */
184
+ raw: number;
185
+ /** Move it, from a 0..1 position. */
186
+ set: (norm: number) => void;
187
+ /** Which pool slot it landed on (1-based), for a tooltip. */
188
+ slot: number;
189
+ /** The parameter id behind it, for anything that needs to name it. */
190
+ param: string;
191
+ }
192
+
193
+ /**
194
+ * Hand a device's dynamic controls to a fixed pool of native dials.
195
+ *
196
+ * A device whose controls come from the user's code - a pattern's `slider()`s, the
197
+ * parameters of an effect just loaded - cannot declare them: a `live.dial` is
198
+ * stamped into the frozen `.amxd` at build time. So it declares a POOL
199
+ * (`knobPool(8)`) and borrows: control 1 takes the first slot, control 2 the
200
+ * second, and a control that goes away returns its slot. Order is the mapping,
201
+ * which means re-editing a line keeps the same knob on the same control.
202
+ *
203
+ * WHAT THIS OWNS, so no device has to:
204
+ *
205
+ * 1. The by-order borrowing, and seeding a freshly borrowed dial with the
206
+ * control's own starting value rather than leaving it at 0.
207
+ * 2. Telling Live what each dial currently IS - name, unit, range - so the panel
208
+ * stops reading `S1` and the readout stops reading `0.44` for 600 Hz.
209
+ * 3. THE SCALING, which is the part that bites. Live accepts a runtime range, and
210
+ * the parameter then reports IN THAT RANGE. A device that goes on normalizing
211
+ * 0..1 would scale an already-scaled value and the knob would stick at its
212
+ * minimum - the bug that got an earlier attempt at this reverted. The wrapper
213
+ * answers whether each range took, and this hook scales exactly once either way.
214
+ *
215
+ * KNOWN LIMIT, measured in Live: the name reaches the DEVICE PANEL, not Live's
216
+ * parameter registry or a Rack macro picker, which keep the pool's own `S1..S8`.
217
+ * A frozen device cannot rename a parameter there. So render the name in your own
218
+ * UI as well - never rely on the dial to carry it.
219
+ */
220
+ export function useControls<P extends Record<string, ParamSpec>>(
221
+ surface: Surface<P>,
222
+ controls: readonly BorrowedControl[],
223
+ poolIds: readonly Extract<keyof P, string>[],
224
+ ): PooledControl[] {
225
+ /* eslint-disable react-hooks/rules-of-hooks */
226
+ // A pool is dials, so its values are numbers - but `P` is the whole surface and
227
+ // the compiler cannot know the caller passed dial ids. Narrowed once, here,
228
+ // rather than at every write below.
229
+ const params = poolIds.map((id) => useParam(surface, id)) as unknown as [number, (v: number) => void][];
230
+ /* eslint-enable react-hooks/rules-of-hooks */
231
+
232
+ /** Which dials Live actually widened. Until it says so, the dial is 0..1. */
233
+ const [real, setReal] = useState<boolean[]>([]);
234
+ useEffect(() => {
235
+ onParamRange((id, took) => {
236
+ const i = poolIds.indexOf(id as Extract<keyof P, string>);
237
+ if (i < 0) return;
238
+ setReal((prev) => {
239
+ if (prev[i] === took) return prev;
240
+ const next = prev.slice();
241
+ next[i] = took;
242
+ return next;
243
+ });
244
+ });
245
+ // The pool is a declaration; it does not change at runtime.
246
+ // eslint-disable-next-line react-hooks/exhaustive-deps
247
+ }, []);
248
+
249
+ // Say what each slot is now carrying, on change only: describeParam writes Live
250
+ // parameter attributes, and a device re-rendering is not news.
251
+ const described = useRef<string[]>([]);
252
+ useEffect(() => {
253
+ for (let i = 0; i < poolIds.length; i++) {
254
+ const c = controls[i];
255
+ const key = c ? `${c.name} ${c.min} ${c.max} ${c.unit ?? ""}` : "";
256
+ if (described.current[i] === key) continue;
257
+ described.current[i] = key;
258
+ // A slot nobody is borrowing goes back to its declared identity, or it would
259
+ // keep the name of a control that is no longer there.
260
+ if (c) describeParam(poolIds[i], { name: c.name, unit: c.unit, range: [c.min, c.max] });
261
+ else describeParam(poolIds[i], { name: String(poolIds[i]).toUpperCase(), range: [0, 1] });
262
+ }
263
+ }, [controls, poolIds]);
264
+
265
+ // Seed a slot the first time a given control takes it, so an untouched dial reads
266
+ // what the control says rather than 0.
267
+ const seeded = useRef<string[]>([]);
268
+ useEffect(() => {
269
+ controls.forEach((c, i) => {
270
+ if (i >= poolIds.length || c.value === undefined) return;
271
+ const key = `${c.name} ${c.min} ${c.max}`;
272
+ if (seeded.current[i] === key) return;
273
+ seeded.current[i] = key;
274
+ const span = c.max - c.min || 1;
275
+ params[i][1](real[i] ? c.value : (c.value - c.min) / span);
276
+ });
277
+ seeded.current.length = controls.length;
278
+ // `params` is rebuilt every render by design; the seed is guarded by `seeded`.
279
+ // eslint-disable-next-line react-hooks/exhaustive-deps
280
+ }, [controls, real]);
281
+
282
+ return controls.slice(0, poolIds.length).map((c, i) => {
283
+ const span = c.max - c.min || 1;
284
+ const held = Number(params[i][0] ?? 0);
285
+ const norm = real[i] ? (held - c.min) / span : held;
286
+ return {
287
+ ...c,
288
+ norm: Math.min(1, Math.max(0, norm)),
289
+ raw: real[i] ? held : c.min + held * span,
290
+ set: (n: number) => {
291
+ const clamped = Math.min(1, Math.max(0, n));
292
+ params[i][1](real[i] ? c.min + clamped * span : clamped);
293
+ },
294
+ slot: i + 1,
295
+ param: String(poolIds[i]),
296
+ };
297
+ });
298
+ }