@m4l-jweb/surface 0.6.5 → 0.9.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 +2 -2
- package/src/index.ts +186 -3
- package/src/react.tsx +75 -2
- package/src/store.ts +133 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": "0.
|
|
27
|
+
"@m4l-jweb/bridge": "0.9.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"react": {
|
package/src/index.ts
CHANGED
|
@@ -92,10 +92,32 @@ export interface MenuSpec<O extends string = string> {
|
|
|
92
92
|
short: string;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A LABELLED toggle button - a `live.text` in toggle mode, which a bare
|
|
97
|
+
* `live.toggle` cannot be: it carries visible text. The on/off value is the same as
|
|
98
|
+
* a toggle; `label` is what the button reads. Handy as a native view switch (a
|
|
99
|
+
* "Back" button) where the plain orange square of a toggle says nothing.
|
|
100
|
+
*/
|
|
101
|
+
export interface ButtonSpec {
|
|
102
|
+
kind: "button";
|
|
103
|
+
default: boolean;
|
|
104
|
+
/** The text on the button. */
|
|
105
|
+
label: string;
|
|
106
|
+
short: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ParamSpec = DialSpec | ToggleSpec | MenuSpec | ButtonSpec;
|
|
96
110
|
|
|
97
111
|
/** The value type a given parameter carries. `useParam` will be typed by this. */
|
|
98
|
-
export type ParamValue<P extends ParamSpec> = P extends DialSpec
|
|
112
|
+
export type ParamValue<P extends ParamSpec> = P extends DialSpec
|
|
113
|
+
? number
|
|
114
|
+
: P extends ToggleSpec
|
|
115
|
+
? boolean
|
|
116
|
+
: P extends ButtonSpec
|
|
117
|
+
? boolean
|
|
118
|
+
: P extends MenuSpec<infer O>
|
|
119
|
+
? O
|
|
120
|
+
: never;
|
|
99
121
|
|
|
100
122
|
/* ------------------------------------------------------------------ *
|
|
101
123
|
* Windows and State
|
|
@@ -120,6 +142,20 @@ export interface WindowSpec {
|
|
|
120
142
|
width: number;
|
|
121
143
|
height: number;
|
|
122
144
|
entry: string;
|
|
145
|
+
/**
|
|
146
|
+
* Keep the window in FRONT of Live, instead of behind it the moment Live is clicked.
|
|
147
|
+
*
|
|
148
|
+
* For a window you read WHILE working in Live - a reference, a cheatsheet - the
|
|
149
|
+
* default behaviour makes it useless: clicking back into the device to type is
|
|
150
|
+
* exactly what sends the window behind the main window. For a window you work IN (an
|
|
151
|
+
* editor), the default is right and this should stay off.
|
|
152
|
+
*
|
|
153
|
+
* It compiles to `[thispatcher]` <- `window flags ... float, window exec`, which is
|
|
154
|
+
* Max's documented route. **The flag list is a REPLACEMENT, not an addition** - that
|
|
155
|
+
* is why the generated message names `grow`, `close` and `title` alongside `float`.
|
|
156
|
+
* Send `float` alone and the window comes up with no close box.
|
|
157
|
+
*/
|
|
158
|
+
alwaysOnTop?: boolean;
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
/**
|
|
@@ -160,6 +196,11 @@ export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
|
|
|
160
196
|
kind: "toggle",
|
|
161
197
|
...spec,
|
|
162
198
|
});
|
|
199
|
+
/** A labelled toggle button (`live.text`). Same on/off value as a toggle, with visible text. */
|
|
200
|
+
export const button = (spec: Omit<ButtonSpec, "kind">): ButtonSpec => ({
|
|
201
|
+
kind: "button",
|
|
202
|
+
...spec,
|
|
203
|
+
});
|
|
163
204
|
/**
|
|
164
205
|
* The options are spelled out rather than written as `Omit<MenuSpec<O>, "kind">`,
|
|
165
206
|
* and that is not a style choice: TypeScript cannot infer `O` THROUGH an `Omit`,
|
|
@@ -184,6 +225,47 @@ export interface Bank<K extends string> {
|
|
|
184
225
|
params: readonly K[];
|
|
185
226
|
}
|
|
186
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Which parameters render as NATIVE `live.*` objects in the device view, and how
|
|
230
|
+
* they are laid out.
|
|
231
|
+
*
|
|
232
|
+
* The compiler ALREADY generates a `live.dial` / `live.toggle` / `live.menu` for
|
|
233
|
+
* every declared parameter; they are invisible today only because they carry no
|
|
234
|
+
* `presentation` attribute, and Live shows the presentation view. Naming a
|
|
235
|
+
* parameter here makes the SAME object visible - a presentation overlay on codegen
|
|
236
|
+
* that already exists, with no wiring change: it is the same parameter, the same
|
|
237
|
+
* fan-out graph, `useParam()` still reads it, now drawn by Max instead of React.
|
|
238
|
+
*/
|
|
239
|
+
export interface NativeLayout<K extends string = string> {
|
|
240
|
+
/**
|
|
241
|
+
* In display order: fills rows top-to-bottom, then overflows into the next
|
|
242
|
+
* column (column-major, so adding a parameter does not reshuffle the rest).
|
|
243
|
+
*/
|
|
244
|
+
params: readonly K[];
|
|
245
|
+
/**
|
|
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.
|
|
248
|
+
*/
|
|
249
|
+
rows?: number;
|
|
250
|
+
/**
|
|
251
|
+
* LAYERED "two screens" instead of side-by-side. When true, `[jweb]` is built
|
|
252
|
+
* full-width and the dials OVERLAP its left, and the app flips between them with
|
|
253
|
+
* `useNativePanel` (hide one layer, show the other) - because runtime reposition
|
|
254
|
+
* of native objects does not work in a frozen M4L device, only hide/show does.
|
|
255
|
+
* When false (the default), the dials sit BESIDE a right-shifted `[jweb]`, both
|
|
256
|
+
* visible at once.
|
|
257
|
+
*/
|
|
258
|
+
panel?: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* A parameter that is the VIEW SWITCH, not a grid dial: pinned to the top-right
|
|
261
|
+
* (over the web UI's own switch button, so the control stays in one place across
|
|
262
|
+
* both views), kept out of the `params` grid, and shown in both modes. Meant for a
|
|
263
|
+
* toggle in a `panel` layout - the way back from the native panel, since the web
|
|
264
|
+
* UI is hidden there.
|
|
265
|
+
*/
|
|
266
|
+
switch?: K;
|
|
267
|
+
}
|
|
268
|
+
|
|
187
269
|
/**
|
|
188
270
|
* The declaration. `P`, `S` and `W` are inferred from what you write - they exist so
|
|
189
271
|
* that `useParam`, `useStateSync` and `useWindow` are typed against THIS surface: a
|
|
@@ -199,6 +281,8 @@ export interface SurfaceDef<
|
|
|
199
281
|
banks?: readonly Bank<Extract<keyof P, string>>[];
|
|
200
282
|
windows?: W;
|
|
201
283
|
state?: S;
|
|
284
|
+
/** Which parameters render as native Max objects in the device view. */
|
|
285
|
+
layout?: { native?: NativeLayout<Extract<keyof P, string>> };
|
|
202
286
|
}
|
|
203
287
|
|
|
204
288
|
export interface Surface<
|
|
@@ -265,9 +349,39 @@ export function defineSurface<
|
|
|
265
349
|
}
|
|
266
350
|
}
|
|
267
351
|
|
|
352
|
+
// A native layout may only name parameters that exist, and may not ask for more
|
|
353
|
+
// rows than the device view holds. Both throw here, at build time, for the same
|
|
354
|
+
// reason the bank checks do: a typo would otherwise generate a cord from a box
|
|
355
|
+
// that never gets a presentation rect, or overflow a 169 px view silently.
|
|
356
|
+
const native = def.layout?.native;
|
|
357
|
+
if (native) {
|
|
358
|
+
for (const id of native.params) {
|
|
359
|
+
if (!def.params[id]) throw new Error(`surface: layout.native names "${id}", which is not a declared parameter`);
|
|
360
|
+
}
|
|
361
|
+
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`);
|
|
363
|
+
if (native.switch !== undefined && !def.params[native.switch]) {
|
|
364
|
+
throw new Error(`surface: layout.native.switch names "${native.switch}", which is not a declared parameter`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
268
368
|
return { ...def, ids };
|
|
269
369
|
}
|
|
270
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Does this parameter render as a native Max object? App code uses it to stop
|
|
373
|
+
* drawing an HTML control the device view now owns. Cheap and honest: a parameter
|
|
374
|
+
* that is not in `layout.native` is still an HTML control, so `useParam()` stays
|
|
375
|
+
* the source of truth either way.
|
|
376
|
+
*/
|
|
377
|
+
export const isNative = (surface: Surface, id: string): boolean => !!surface.layout?.native?.params.includes(id as never);
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The scripting name the build gives `[jweb]` when a surface declares native
|
|
381
|
+
* layout, so the app can hide/show it at runtime (see `useNativePanel`).
|
|
382
|
+
*/
|
|
383
|
+
export const JWEB_VARNAME = "obj-jweb";
|
|
384
|
+
|
|
271
385
|
/** The default value of every parameter. The app's initial state, before Live replies. */
|
|
272
386
|
export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P>): { [K in keyof P]: ParamValue<P[K]> } {
|
|
273
387
|
const out = {} as { [K in keyof P]: ParamValue<P[K]> };
|
|
@@ -275,9 +389,78 @@ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P
|
|
|
275
389
|
return out;
|
|
276
390
|
}
|
|
277
391
|
|
|
392
|
+
/* ------------------------------------------------------------------ *
|
|
393
|
+
* defineWatch - declare what to OBSERVE in Live, once, as code.
|
|
394
|
+
*
|
|
395
|
+
* The twin of defineSurface, and it exists to kill hard rule 4 BY CONSTRUCTION.
|
|
396
|
+
* A LiveAPI observer built during `loadbang` is dead forever; the only safe place
|
|
397
|
+
* to create one is `live.thisdevice`'s bang. That is a trap a hand-written
|
|
398
|
+
* observer falls into silently - the object constructs without error and then
|
|
399
|
+
* notifies nothing. So no device writes the observer at all: it DECLARES the LOM
|
|
400
|
+
* path and property, the build injects the list as data, and the packaged wrapper
|
|
401
|
+
* creates every observer from bang() (see setupWatches in @m4l-jweb/wrapper). The
|
|
402
|
+
* one place LiveAPI is safe is the one place the observers are made.
|
|
403
|
+
*
|
|
404
|
+
* Each change reaches the UI as `watch_<key> <value...>`, exactly as a parameter
|
|
405
|
+
* reaches it as `<id> <value>` - one declaration, one name, both sides. `useWatch`
|
|
406
|
+
* binds it; the app never types the selector.
|
|
407
|
+
*
|
|
408
|
+
* This is READ-ONLY: an observed property flows Live -> UI and nowhere back. A
|
|
409
|
+
* value the app can also WRITE is a parameter (`defineSurface`), not a watch.
|
|
410
|
+
* ------------------------------------------------------------------ */
|
|
411
|
+
|
|
412
|
+
/** One observed Live property. `T` is the value the app sees, so `useWatch` is typed from it. */
|
|
413
|
+
export interface WatchSpec<T = unknown> {
|
|
414
|
+
/** The LOM object path, e.g. `"live_set"` or `"live_set view selected_track"`. */
|
|
415
|
+
path: string;
|
|
416
|
+
/** An OBSERVABLE property on that object, e.g. `"tempo"`, `"scale_name"`, `"is_playing"`. */
|
|
417
|
+
property: string;
|
|
418
|
+
/** What the app shows before Live has replied - the same role a parameter's `default` plays. */
|
|
419
|
+
default: T;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The value type a watch carries. `useWatch` is typed by this. */
|
|
423
|
+
export type WatchValue<S extends WatchSpec> = S extends WatchSpec<infer T> ? T : never;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Declare one watch. `watch<string>({ path: "live_set", property: "scale_name", default: "C" })`
|
|
427
|
+
* carries the value type through, so `useWatch(w, "scale")` is `string` with nothing to cast.
|
|
428
|
+
*/
|
|
429
|
+
export const watch = <T>(spec: WatchSpec<T>): WatchSpec<T> => ({ ...spec });
|
|
430
|
+
|
|
431
|
+
export interface WatchDef<W extends Record<string, WatchSpec>> {
|
|
432
|
+
watches: W;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface Watch<W extends Record<string, WatchSpec> = Record<string, WatchSpec>> extends WatchDef<W> {
|
|
436
|
+
/** Declaration order - the order the build emits WATCH_SPECS and the wrapper attaches observers. */
|
|
437
|
+
readonly keys: readonly Extract<keyof W, string>[];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Declare the watch surface.
|
|
442
|
+
*
|
|
443
|
+
* Like defineSurface, the checks run HERE, at call time, and throw - the build
|
|
444
|
+
* imports this module to emit the observer list, so a bad declaration fails
|
|
445
|
+
* `pnpm build` and CI. A key becomes the selector suffix `watch_<key>`, so it may
|
|
446
|
+
* not carry whitespace (Max would split the message on it); a path or property
|
|
447
|
+
* left blank would attach an observer to nothing, silently, which is the exact
|
|
448
|
+
* failure this API exists to prevent.
|
|
449
|
+
*/
|
|
450
|
+
export function defineWatch<const W extends Record<string, WatchSpec>>(def: WatchDef<W>): Watch<W> {
|
|
451
|
+
const keys = Object.keys(def.watches) as Extract<keyof W, string>[];
|
|
452
|
+
for (const key of keys) {
|
|
453
|
+
if (/\s/.test(key)) throw new Error(`watch: key "${key}" has whitespace - it becomes the selector watch_${key}, which Max would split`);
|
|
454
|
+
const w = def.watches[key];
|
|
455
|
+
if (!w.path) throw new Error(`watch: "${key}" has no path - an observer with no LOM object attaches to nothing`);
|
|
456
|
+
if (!w.property) throw new Error(`watch: "${key}" has no property - an observer with no property notifies nothing`);
|
|
457
|
+
}
|
|
458
|
+
return { ...def, keys };
|
|
459
|
+
}
|
|
460
|
+
|
|
278
461
|
/** How a value is displayed - the parameter's own `format`, or a sane default. */
|
|
279
462
|
export function formatValue(spec: ParamSpec, value: unknown): string {
|
|
280
|
-
if (spec.kind === "toggle") return value ? "on" : "off";
|
|
463
|
+
if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
|
|
281
464
|
if (spec.kind === "menu") return String(value);
|
|
282
465
|
if (spec.format) return spec.format(Number(value));
|
|
283
466
|
const n = Number(value);
|
package/src/react.tsx
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
20
20
|
import { outlet } from "@m4l-jweb/bridge";
|
|
21
|
-
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
|
|
22
|
-
import {
|
|
21
|
+
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
|
|
22
|
+
import { JWEB_VARNAME } from "./index";
|
|
23
|
+
import { paramStore, stateStore, watchStore } from "./store";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* A two-way binding to one Live parameter. `[value, setValue]`, like useState -
|
|
@@ -67,6 +68,78 @@ export function useWindow<
|
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Show or hide a NATIVE dial in the device view at runtime.
|
|
73
|
+
*
|
|
74
|
+
* `layout.native` makes a parameter a native `live.*` object, but its presentation
|
|
75
|
+
* is STATIC - the dial is always visible. This is the runtime override: the app says
|
|
76
|
+
* which native params should be shown, and a `[thispatcher]` runs `script show`/
|
|
77
|
+
* `script hide` on the object by its scripting name (`param-<id>`, see
|
|
78
|
+
* `applyNativeControl` in @m4l-jweb/build). The parameter itself is untouched - a
|
|
79
|
+
* hidden dial still automates, MIDI-maps and reaches Push; only visibility changes.
|
|
80
|
+
*
|
|
81
|
+
* Returns a stable `(id, visible) => void`. Drive it from an effect that mirrors the
|
|
82
|
+
* app's own "which stages are active" state, e.g. `useEffect` over the shown set.
|
|
83
|
+
*/
|
|
84
|
+
export function useNativeVisibility<P extends Record<string, ParamSpec>>(
|
|
85
|
+
_surface: Surface<P>,
|
|
86
|
+
): (id: Extract<keyof P, string>, visible: boolean) => void {
|
|
87
|
+
return useCallback((id: Extract<keyof P, string>, visible: boolean) => {
|
|
88
|
+
// The varname applySurface() gave the object is `param-<id>`. Keep this prefix
|
|
89
|
+
// in step with that codegen - it is the one string both sides must agree on.
|
|
90
|
+
outlet(visible ? "native_show" : "native_hide", `param-${id}`);
|
|
91
|
+
}, []);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Flip the device view between the WEB UI and a NATIVE control panel - the "two
|
|
96
|
+
* screens" model. Runtime reposition/resize of presentation objects does NOT work in
|
|
97
|
+
* a frozen M4L device (measured: `presentation_rect` writes are stored but never
|
|
98
|
+
* redrawn), but `hidden` DOES. So instead of reflowing dials, we layer them:
|
|
99
|
+
*
|
|
100
|
+
* "web" - show [jweb] (full width), hide every native dial and the switch. The
|
|
101
|
+
* web UI paints its own switch button; the native one would only fight
|
|
102
|
+
* it for the same top-right spot.
|
|
103
|
+
* "native" - hide [jweb], show every native dial AND the switch - which is the way
|
|
104
|
+
* back, since the web UI is hidden here.
|
|
105
|
+
*
|
|
106
|
+
* Only `hidden` is used, so this actually works where reflow could not, and no layer
|
|
107
|
+
* is ever visible at the same time as another - so z-order never matters.
|
|
108
|
+
*/
|
|
109
|
+
export function useNativePanel<P extends Record<string, ParamSpec>>(surface: Surface<P>): (mode: "web" | "native") => void {
|
|
110
|
+
return useCallback(
|
|
111
|
+
(mode: "web" | "native") => {
|
|
112
|
+
const native = surface.layout?.native;
|
|
113
|
+
if (!native) return;
|
|
114
|
+
const web = mode === "web";
|
|
115
|
+
const toggle = (varname: string) => outlet(web ? "native_hide" : "native_show", varname);
|
|
116
|
+
outlet(web ? "native_show" : "native_hide", JWEB_VARNAME);
|
|
117
|
+
for (const id of native.params as readonly string[]) toggle(`param-${id}`);
|
|
118
|
+
if (native.switch) toggle(`param-${native.switch}`);
|
|
119
|
+
},
|
|
120
|
+
[surface],
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A one-way binding to an observed Live property, declared with `defineWatch()`.
|
|
126
|
+
*
|
|
127
|
+
* Read-only, so it returns the value alone - no setter. The mirror of `useParam`:
|
|
128
|
+
* a parameter the app both reads and writes; a watch it only reads. Turning the
|
|
129
|
+
* tempo, changing the scale, selecting a track in Live moves this React state, and
|
|
130
|
+
* the wrapper attaches every observer from `bang()` - the one place LiveAPI is
|
|
131
|
+
* safe - so the trap of a dead loadbang observer is not one a device can fall into.
|
|
132
|
+
*
|
|
133
|
+
* The selector is DERIVED (`watch_<key>`), exactly as `useParam`'s is, so a key
|
|
134
|
+
* that is not declared is a build error at the call site, not a value that never
|
|
135
|
+
* arrives.
|
|
136
|
+
*/
|
|
137
|
+
export function useWatch<W extends Record<string, WatchSpec>, K extends Extract<keyof W, string>>(watch: Watch<W>, key: K): WatchValue<W[K]> {
|
|
138
|
+
const store = useMemo(() => watchStore(watch), [watch]);
|
|
139
|
+
const values = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
140
|
+
return values[key] as WatchValue<W[K]>;
|
|
141
|
+
}
|
|
142
|
+
|
|
70
143
|
/**
|
|
71
144
|
* A two-way binding to a JSON state slot, persisted in the Live SET.
|
|
72
145
|
*
|
package/src/store.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* the user's hand wins, and the next value after the window lands normally.
|
|
31
31
|
*/
|
|
32
32
|
import { bindInlet, outlet } from "@m4l-jweb/bridge";
|
|
33
|
-
import { defaults, type ParamSpec, type StateSpec, type Surface } from "./index";
|
|
33
|
+
import { defaults, type ParamSpec, type StateSpec, type Surface, type WatchSpec } from "./index";
|
|
34
34
|
|
|
35
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
36
|
export const GUARD_MS = 120;
|
|
@@ -40,7 +40,7 @@ const EPSILON = 1e-6;
|
|
|
40
40
|
|
|
41
41
|
/** Max stores every parameter as a NUMBER. A menu is an index into its options; a toggle is 0/1. */
|
|
42
42
|
export function toWire(spec: ParamSpec, value: unknown): number {
|
|
43
|
-
if (spec.kind === "toggle") return value ? 1 : 0;
|
|
43
|
+
if (spec.kind === "toggle" || spec.kind === "button") return value ? 1 : 0;
|
|
44
44
|
if (spec.kind === "menu") {
|
|
45
45
|
const i = spec.options.indexOf(String(value));
|
|
46
46
|
return i < 0 ? 0 : i;
|
|
@@ -50,7 +50,7 @@ export function toWire(spec: ParamSpec, value: unknown): number {
|
|
|
50
50
|
|
|
51
51
|
/** ...and back. An out-of-range menu index falls back to the default rather than `undefined`. */
|
|
52
52
|
export function fromWire(spec: ParamSpec, wire: number): unknown {
|
|
53
|
-
if (spec.kind === "toggle") return wire >= 0.5;
|
|
53
|
+
if (spec.kind === "toggle" || spec.kind === "button") return wire >= 0.5;
|
|
54
54
|
if (spec.kind === "menu") return spec.options[Math.round(wire)] ?? spec.default;
|
|
55
55
|
return wire;
|
|
56
56
|
}
|
|
@@ -155,8 +155,70 @@ export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface
|
|
|
155
155
|
* OUT: `get_state <id>`, `sync_state <id> <json>` - one handler each, in the wrapper.
|
|
156
156
|
* IN: `state_<id> <json>` - one binding per slot, so the
|
|
157
157
|
* bridge can dispatch it without the app unpacking an id.
|
|
158
|
+
*
|
|
159
|
+
* ------------------------------------------------------------------------------
|
|
160
|
+
* EVERY VALUE TRAVELS INSIDE AN ENVELOPE, because a Max [dict] IS a key/value map
|
|
161
|
+
* and cannot hold anything else.
|
|
162
|
+
*
|
|
163
|
+
* The wrapper stores a slot by handing the JSON to `Dict.parse()`. A dict has KEYS;
|
|
164
|
+
* that is what a dict is. So an OBJECT round-tripped fine and nothing else did:
|
|
165
|
+
* `state<string>` sent `"c1 e1"` and `state<FxParam[]>` sent `["cutoff"]`, and
|
|
166
|
+
* `parse()` had nowhere to put either. The dict stayed empty, `stringify()` gave back
|
|
167
|
+
* `{}`, and the app read its own default forever.
|
|
168
|
+
*
|
|
169
|
+
* That one bug wore two disguises, and cost real debugging as both: a drum map (an
|
|
170
|
+
* object) persisted while the pattern text (a string) silently did not - which looks
|
|
171
|
+
* exactly like Live losing your work; and the fx device's `named` slot (an array) came
|
|
172
|
+
* back `{}` on every load, which was written off as the state-DEFAULT seeding gap. That
|
|
173
|
+
* gap is real, but it is not this: `named` had never persisted at all.
|
|
174
|
+
*
|
|
175
|
+
* So the wire format is `{"__value": <whatever>}`, always. The dict gets its key, and
|
|
176
|
+
* `state<T>` means what it says for every T.
|
|
158
177
|
* ------------------------------------------------------------------ */
|
|
159
178
|
|
|
179
|
+
/** The one key a [dict] carries, so that a scalar has somewhere to live. */
|
|
180
|
+
const ENVELOPE = "__value";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The envelope, as JSON, with every literal space escaped - so it crosses as ONE atom.
|
|
184
|
+
*
|
|
185
|
+
* [jweb] hands each argument to Max, and Max SPLITS A SYMBOL ON WHITESPACE. The
|
|
186
|
+
* wrapper has always papered over this by rejoining the pieces with a single space,
|
|
187
|
+
* which works for exactly as long as the payload never contains meaningful whitespace.
|
|
188
|
+
* `JSON.stringify` of an object is compact, so a drum map arrived in one piece and the
|
|
189
|
+
* seam held.
|
|
190
|
+
*
|
|
191
|
+
* A PATTERN IS NOTHING BUT WHITESPACE. `"c1 e1"` would come back `"c1 e1"` - the run
|
|
192
|
+
* of spaces rejoined as one, the user's text quietly reformatted - and a multi-line
|
|
193
|
+
* pattern (which is the whole point of the Studio window) is worse.
|
|
194
|
+
*
|
|
195
|
+
* ` ` is JSON's own escape for a space, so the payload contains no literal spaces
|
|
196
|
+
* at all: Max cannot split what is not there, the rejoin becomes a no-op, and the dict's
|
|
197
|
+
* JSON parser turns the escapes back into the spaces the user typed. Newlines and tabs
|
|
198
|
+
* need no help - `JSON.stringify` already escapes those as `\n` and `\t`.
|
|
199
|
+
*/
|
|
200
|
+
function stateToWire(value: unknown): string {
|
|
201
|
+
return JSON.stringify({ [ENVELOPE]: value }).replace(/ /g, "\\u0020");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Unwrap what came out of the dict.
|
|
206
|
+
*
|
|
207
|
+
* A missing envelope is not an error, and there are two innocent ways to get one: a
|
|
208
|
+
* slot Live has never saved (a fresh, empty `{}`), or a value written by a build from
|
|
209
|
+
* before the envelope - which could only ever have been an object, since nothing else
|
|
210
|
+
* could be stored. Both are answered without blanking the app, so opening an old set
|
|
211
|
+
* keeps its drum map.
|
|
212
|
+
*/
|
|
213
|
+
function unwrap(parsed: unknown, fallback: unknown): unknown {
|
|
214
|
+
if (parsed && typeof parsed === "object" && ENVELOPE in (parsed as Record<string, unknown>)) {
|
|
215
|
+
return (parsed as Record<string, unknown>)[ENVELOPE];
|
|
216
|
+
}
|
|
217
|
+
// An empty dict means "nothing saved yet" - the declared default is the honest answer.
|
|
218
|
+
if (parsed && typeof parsed === "object" && Object.keys(parsed as object).length === 0) return fallback;
|
|
219
|
+
return parsed ?? fallback;
|
|
220
|
+
}
|
|
221
|
+
|
|
160
222
|
export interface StateStore {
|
|
161
223
|
get(): Values;
|
|
162
224
|
subscribe(fn: () => void): () => void;
|
|
@@ -190,7 +252,10 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
|
|
|
190
252
|
for (const id of ids) {
|
|
191
253
|
bindInlet(`state_${id}`, (raw) => {
|
|
192
254
|
try {
|
|
193
|
-
|
|
255
|
+
// The dict hands back `{"__value": ...}` - see the envelope note above. An
|
|
256
|
+
// empty dict and a pre-envelope value both resolve to something sane rather
|
|
257
|
+
// than to nothing.
|
|
258
|
+
values = { ...values, [id]: unwrap(JSON.parse(String(raw)), surface.state![id].default) };
|
|
194
259
|
notify();
|
|
195
260
|
} catch {
|
|
196
261
|
// An empty dict stringifies to "{}" and parses fine; anything that does
|
|
@@ -212,10 +277,73 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
|
|
|
212
277
|
write(id, value) {
|
|
213
278
|
values = { ...values, [id]: value };
|
|
214
279
|
notify();
|
|
215
|
-
|
|
280
|
+
// Enveloped, ALWAYS - a bare string or array has nowhere to live in a [dict] -
|
|
281
|
+
// and space-escaped, so Max cannot split the payload on the way.
|
|
282
|
+
outlet("sync_state", id, stateToWire(value));
|
|
216
283
|
},
|
|
217
284
|
};
|
|
218
285
|
|
|
219
286
|
stateStores.set(surface, store);
|
|
220
287
|
return store;
|
|
221
288
|
}
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------------------------ *
|
|
291
|
+
* The watch store - the read-only Live values behind useWatch().
|
|
292
|
+
*
|
|
293
|
+
* A watch is the mirror image of a parameter: it flows Live -> UI only, so this
|
|
294
|
+
* store has a `get`/`subscribe` and NO `write`. Each declared watch binds
|
|
295
|
+
* `watch_<key>` exactly once - same one-binding-per-selector reason paramStore
|
|
296
|
+
* exists - and fans out to subscribers. The wrapper attaches the observer (from
|
|
297
|
+
* bang(), the only safe place) and resends the current value on ui_ready, so a
|
|
298
|
+
* page that loaded after the last change still gets it; the app just calls
|
|
299
|
+
* uiReady() as it already does for tempo.
|
|
300
|
+
* ------------------------------------------------------------------ */
|
|
301
|
+
|
|
302
|
+
export interface WatchStore {
|
|
303
|
+
get(): Values;
|
|
304
|
+
subscribe(fn: () => void): () => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const watchStores = new WeakMap<object, WatchStore>();
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* One store per watch declaration, created once and never torn down - the bridge's
|
|
311
|
+
* bindings are process-wide, so tearing one down would unbind a watch another
|
|
312
|
+
* component still reads.
|
|
313
|
+
*/
|
|
314
|
+
export function watchStore(watch: { watches?: Record<string, WatchSpec>; keys?: readonly string[] }): WatchStore {
|
|
315
|
+
const existing = watchStores.get(watch);
|
|
316
|
+
if (existing) return existing;
|
|
317
|
+
|
|
318
|
+
const keys = watch.keys ?? (watch.watches ? Object.keys(watch.watches) : []);
|
|
319
|
+
|
|
320
|
+
// What the app shows before Live has replied: the declared defaults.
|
|
321
|
+
let values: Values = {};
|
|
322
|
+
for (const key of keys) values[key] = watch.watches?.[key].default;
|
|
323
|
+
|
|
324
|
+
const listeners = new Set<() => void>();
|
|
325
|
+
const notify = () => {
|
|
326
|
+
for (const fn of listeners) fn();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
for (const key of keys) {
|
|
330
|
+
// `watch_<key> <value...>` - out of the wrapper's observeProperty, forwarded on
|
|
331
|
+
// change and once on ui_ready. A scalar arrives as one atom; a property that
|
|
332
|
+
// yields several (a colour, a pair) arrives spread, so keep the list intact.
|
|
333
|
+
bindInlet(`watch_${key}`, (...args) => {
|
|
334
|
+
values = { ...values, [key]: args.length <= 1 ? args[0] : args };
|
|
335
|
+
notify();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const store: WatchStore = {
|
|
340
|
+
get: () => values,
|
|
341
|
+
subscribe(fn) {
|
|
342
|
+
listeners.add(fn);
|
|
343
|
+
return () => listeners.delete(fn);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
watchStores.set(watch, store);
|
|
348
|
+
return store;
|
|
349
|
+
}
|