@m4l-jweb/surface 0.9.9 → 1.1.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/README.md +51 -0
- package/package.json +2 -2
- package/src/index.ts +95 -10
- package/src/react.tsx +140 -2
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @m4l-jweb/surface
|
|
2
|
+
|
|
3
|
+
Declare a device's Live parameters once, as code, and get the real thing: native `live.dial` / `live.text` objects in the patcher, automatable lanes, MIDI mapping, and Push. Plus React hooks to read and write them, and a mocked-Live harness so the UI runs in an ordinary browser.
|
|
4
|
+
|
|
5
|
+
Part of **[m4l-jweb](https://github.com/alienmind/m4l-jweb)** - build Ableton Live devices (`.amxd`) from a TypeScript repo: React UI, LiveAPI glue, CI builds, no Max editor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @m4l-jweb/surface
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// surface.ts - the declaration
|
|
17
|
+
import { defineSurface } from "@m4l-jweb/surface";
|
|
18
|
+
|
|
19
|
+
export default defineSurface({
|
|
20
|
+
params: {
|
|
21
|
+
cutoff: { type: "float", min: 20, max: 18000, unit: "Hz", default: 18000, exponent: 3 },
|
|
22
|
+
play: { type: "bool", default: 0 },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// App.tsx - the same parameter, from React
|
|
27
|
+
import { useParam, useStateSync } from "@m4l-jweb/surface/react";
|
|
28
|
+
|
|
29
|
+
const [cutoff, setCutoff] = useParam(surface, "cutoff"); // a real Live parameter
|
|
30
|
+
const [notes, setNotes] = useStateSync(surface, "notes"); // arbitrary JSON, saved in the Set
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Notes
|
|
34
|
+
|
|
35
|
+
- A parameter declared here is generated into the patcher AND into the wrapper - one declaration, both sides, so they cannot disagree.
|
|
36
|
+
- `useStateSync()` persists arbitrary JSON into the Ableton Live Set itself, per device instance, restored on load.
|
|
37
|
+
- `@m4l-jweb/surface/dev` renders the device against a mocked Live, so the UI is developed with hot reload in a browser rather than by reopening Live.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
Ableton Live 12 with Max 9. Devices are built on `[jweb~]`, the browser view with signal outlets; older hosts are unverified.
|
|
42
|
+
|
|
43
|
+
## Links
|
|
44
|
+
|
|
45
|
+
- [Repository and full README](https://github.com/alienmind/m4l-jweb)
|
|
46
|
+
- [Architecture](https://github.com/alienmind/m4l-jweb/blob/main/doc/ARCHITECTURE.md)
|
|
47
|
+
- [What Max actually does: the measured facts](https://github.com/alienmind/m4l-jweb/blob/main/doc/MAX-FACTS.md)
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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": "
|
|
27
|
+
"@m4l-jweb/bridge": "1.1.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
|
|
27
|
-
*
|
|
28
|
-
* device
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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,38 @@ export interface WindowSpec {
|
|
|
141
141
|
title: string;
|
|
142
142
|
width: number;
|
|
143
143
|
height: number;
|
|
144
|
-
|
|
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
|
+
* Window content from a PREBUILT static directory rather than a component of
|
|
169
|
+
* ours - a whole site, built by something else (its own Astro/vite build), and
|
|
170
|
+
* delivered as a folder next to the `.amxd` instead of base64 inside it.
|
|
171
|
+
*
|
|
172
|
+
* The path is relative to the device repo root and must contain `index.html`.
|
|
173
|
+
* Mutually exclusive with `entry`.
|
|
174
|
+
*/
|
|
175
|
+
site?: string;
|
|
145
176
|
/**
|
|
146
177
|
* Keep the window in FRONT of Live, instead of behind it the moment Live is clicked.
|
|
147
178
|
*
|
|
@@ -192,6 +223,32 @@ export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
|
|
|
192
223
|
kind: "dial",
|
|
193
224
|
...spec,
|
|
194
225
|
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A POOL of interchangeable native dials, for a device whose real controls are not
|
|
229
|
+
* known until it runs.
|
|
230
|
+
*
|
|
231
|
+
* A `live.dial` is stamped into the frozen `.amxd` at build time, so a device cannot
|
|
232
|
+
* grow one when the user's code asks for a control. What it can do is reserve a
|
|
233
|
+
* fixed number of them and lend them out - `slider()` calls in a pattern, the
|
|
234
|
+
* parameters of whatever effect was just loaded - and that borrowing is the same
|
|
235
|
+
* problem in every device that has it.
|
|
236
|
+
*
|
|
237
|
+
* params: { ...knobPool(8) } // s1..s8, all 0..1
|
|
238
|
+
*
|
|
239
|
+
* The dials are declared 0..1 because a borrower's real range is not known here.
|
|
240
|
+
* `useControls()` in ./react then hands them out in order, tells Live what each one
|
|
241
|
+
* currently IS (name, unit, range - see describeParam in @m4l-jweb/bridge), and
|
|
242
|
+
* keeps the scaling straight.
|
|
243
|
+
*/
|
|
244
|
+
export const knobPool = <N extends number>(count: N, prefix = "s"): Record<string, DialSpec> => {
|
|
245
|
+
const out: Record<string, DialSpec> = {};
|
|
246
|
+
for (let i = 1; i <= count; i++) {
|
|
247
|
+
// The short name is what Push prints when nothing has borrowed the slot yet.
|
|
248
|
+
out[`${prefix}${i}`] = dial({ range: [0, 1], default: 0, short: `${prefix.toUpperCase()}${i}` });
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
};
|
|
195
252
|
export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
|
|
196
253
|
kind: "toggle",
|
|
197
254
|
...spec,
|
|
@@ -243,10 +300,20 @@ export interface NativeLayout<K extends string = string> {
|
|
|
243
300
|
*/
|
|
244
301
|
params: readonly K[];
|
|
245
302
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
303
|
+
* How the controls are arranged. Default 3.
|
|
304
|
+
*
|
|
305
|
+
* A NUMBER is rows per column, filled column-major: three down, then the next
|
|
306
|
+
* column. Adding a parameter never reshuffles the ones before it.
|
|
307
|
+
*
|
|
308
|
+
* AN ARRAY is the size of each row, filled left to right - `[1, 4, 4]` puts one
|
|
309
|
+
* control on the first row and four on each of the next two. Column-major cannot
|
|
310
|
+
* express that, and it is what a panel usually wants to say: a transport button
|
|
311
|
+
* ABOVE two banks of dials, rather than interleaved with them.
|
|
312
|
+
*
|
|
313
|
+
* Either way the device view is a fixed ~169 px tall and a `live.dial` needs a
|
|
314
|
+
* 56 px pitch, so three rows is the ceiling.
|
|
248
315
|
*/
|
|
249
|
-
rows?: number;
|
|
316
|
+
rows?: number | number[];
|
|
250
317
|
/**
|
|
251
318
|
* LAYERED "two screens" instead of side-by-side. When true, `[jweb]` is built
|
|
252
319
|
* full-width and the dials OVERLAP its left, and the app flips between them with
|
|
@@ -349,6 +416,19 @@ export function defineSurface<
|
|
|
349
416
|
}
|
|
350
417
|
}
|
|
351
418
|
|
|
419
|
+
// A window holds EITHER a component of ours or a prebuilt site. Both is
|
|
420
|
+
// ambiguous (which one loads?) and neither is an empty window - and both would
|
|
421
|
+
// otherwise fail deep in the build, as a vite entry that does not resolve or a
|
|
422
|
+
// page that never gets a url.
|
|
423
|
+
for (const [id, w] of Object.entries(def.windows ?? {})) {
|
|
424
|
+
if (w.entry && w.site) {
|
|
425
|
+
throw new Error(`surface: window "${id}" declares both entry "${w.entry}" and site "${w.site}" - a window holds one or the other`);
|
|
426
|
+
}
|
|
427
|
+
if (!w.entry && !w.site) {
|
|
428
|
+
throw new Error(`surface: window "${id}" declares neither entry nor site - it would open empty`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
352
432
|
// A native layout may only name parameters that exist, and may not ask for more
|
|
353
433
|
// rows than the device view holds. Both throw here, at build time, for the same
|
|
354
434
|
// reason the bank checks do: a typo would otherwise generate a cord from a box
|
|
@@ -359,7 +439,12 @@ export function defineSurface<
|
|
|
359
439
|
if (!def.params[id]) throw new Error(`surface: layout.native names "${id}", which is not a declared parameter`);
|
|
360
440
|
}
|
|
361
441
|
const rows = native.rows ?? 3;
|
|
362
|
-
if (rows
|
|
442
|
+
if (Array.isArray(rows)) {
|
|
443
|
+
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`);
|
|
444
|
+
if (rows.some((n) => !(n > 0))) throw new Error(`surface: layout.native.rows must be positive counts, e.g. [1, 4, 4]`);
|
|
445
|
+
} else if (rows < 1 || rows > 3) {
|
|
446
|
+
throw new Error(`surface: layout.native.rows must be 1..3 - the device view is 169 px tall`);
|
|
447
|
+
}
|
|
363
448
|
if (native.switch !== undefined && !def.params[native.switch]) {
|
|
364
449
|
throw new Error(`surface: layout.native.switch names "${native.switch}", which is not a declared parameter`);
|
|
365
450
|
}
|
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
|
+
}
|