@m4l-jweb/surface 1.2.0 → 1.3.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 +103 -2
- package/src/react.tsx +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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.
|
|
27
|
+
"@m4l-jweb/bridge": "1.3.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"react": {
|
package/src/index.ts
CHANGED
|
@@ -265,13 +265,39 @@ export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
|
|
|
265
265
|
* currently IS (name, unit, range - see describeParam in @m4l-jweb/bridge), and
|
|
266
266
|
* keeps the scaling straight.
|
|
267
267
|
*/
|
|
268
|
-
|
|
268
|
+
/** 0 .. N-1 as a union. The recursion is bounded by the count the caller wrote. */
|
|
269
|
+
type UpTo<N extends number, Acc extends number[] = []> = Acc["length"] extends N ? Acc[number] : UpTo<N, [...Acc, Acc["length"]]>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The dials are numbered from 1, and TypeScript cannot add. A lookup tuple is the
|
|
273
|
+
* whole trick: index 0 holds 1, index 7 holds 8. It stops at 32, which is four Push
|
|
274
|
+
* banks - past that `knobPool` still WORKS, its keys just widen back to `string` and
|
|
275
|
+
* `useParam(surface, "s33")` stops being checked.
|
|
276
|
+
*/
|
|
277
|
+
type OneBased = [
|
|
278
|
+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
279
|
+
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32,
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
/** `PoolIds<8>` is `"s1" | ... | "s8"`. */
|
|
283
|
+
export type PoolIds<N extends number, P extends string = "s"> = `${P}${OneBased[UpTo<N>] & number}`;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The KEYS are the point, not just the values. `params: { ...knobPool(8) }` has to
|
|
287
|
+
* leave `keyof P` carrying `s1`..`s8`, or the spread widens the surface to an index
|
|
288
|
+
* signature and every downstream `useParam(surface, "s3")` and `layout.native.params`
|
|
289
|
+
* entry stops type-checking against the declaration it came from.
|
|
290
|
+
*/
|
|
291
|
+
export const knobPool = <N extends number, P extends string = "s">(
|
|
292
|
+
count: N,
|
|
293
|
+
prefix: P = "s" as P,
|
|
294
|
+
): Record<PoolIds<N, P>, DialSpec> => {
|
|
269
295
|
const out: Record<string, DialSpec> = {};
|
|
270
296
|
for (let i = 1; i <= count; i++) {
|
|
271
297
|
// The short name is what Push prints when nothing has borrowed the slot yet.
|
|
272
298
|
out[`${prefix}${i}`] = dial({ range: [0, 1], default: 0, short: `${prefix.toUpperCase()}${i}` });
|
|
273
299
|
}
|
|
274
|
-
return out
|
|
300
|
+
return out as Record<PoolIds<N, P>, DialSpec>;
|
|
275
301
|
};
|
|
276
302
|
export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
|
|
277
303
|
kind: "toggle",
|
|
@@ -567,6 +593,81 @@ export function defineWatch<const W extends Record<string, WatchSpec>>(def: Watc
|
|
|
567
593
|
return { ...def, keys };
|
|
568
594
|
}
|
|
569
595
|
|
|
596
|
+
/* ------------------------------------------------------------------ *
|
|
597
|
+
* defineFiles - declare that a device touches DISK, once, as code.
|
|
598
|
+
*
|
|
599
|
+
* The third sibling of defineSurface and defineWatch, and the one reality asked
|
|
600
|
+
* for. Writing a file is not one feature, it is three that must travel together:
|
|
601
|
+
*
|
|
602
|
+
* the `download` chain it owns [maxurl], and phase three of saveToFile is a
|
|
603
|
+
* `file://` place through it - even for a device that
|
|
604
|
+
* downloads nothing and only writes bytes it already has;
|
|
605
|
+
* the device folder where the files went, which the page cannot know and
|
|
606
|
+
* the wrapper has to tell it (`device_folder`);
|
|
607
|
+
* the selectors `fetch_to_file`, the `save_*` exchange.
|
|
608
|
+
*
|
|
609
|
+
* Get the first one wrong and the failure is SILENT: the bytes are written
|
|
610
|
+
* correctly, the place request leaves on an aux outlet with nothing on the other
|
|
611
|
+
* end, no reply ever comes, and the promise never settles. The UI sits on
|
|
612
|
+
* "Rendering..." next to a scratch file that looks almost right. Nothing in the
|
|
613
|
+
* build or the tests could see it, because nothing knew the device meant to write.
|
|
614
|
+
*
|
|
615
|
+
* Now it does. A device that declares files gets [maxurl] whether or not its
|
|
616
|
+
* manifest remembered to ask, and gets told its own folder; a device that declares
|
|
617
|
+
* none gets neither.
|
|
618
|
+
*
|
|
619
|
+
* NO SUBFOLDER OPTION, deliberately. A save's destination must be a FLAT name in
|
|
620
|
+
* the device folder: Max's [js] File and maxurl (libcurl) resolve a subdirectory
|
|
621
|
+
* differently, so `sub/x.wav` writes the .part where File agrees and the place
|
|
622
|
+
* cannot reach it (see saveToFile in @m4l-jweb/wrapper). An option for a path that
|
|
623
|
+
* does not work is worse than no option.
|
|
624
|
+
* ------------------------------------------------------------------ */
|
|
625
|
+
|
|
626
|
+
export interface FilesDef {
|
|
627
|
+
/**
|
|
628
|
+
* The page holds the bytes and writes them next to the .amxd - a rendered WAV,
|
|
629
|
+
* an export, a bounce. `saveToFile()` in @m4l-jweb/bridge.
|
|
630
|
+
*/
|
|
631
|
+
saves?: boolean;
|
|
632
|
+
/**
|
|
633
|
+
* The page hands Max a URL and Max streams it to disk on its own thread, with
|
|
634
|
+
* nothing crossing the message bridge. `fetchToFile()` in @m4l-jweb/bridge.
|
|
635
|
+
*/
|
|
636
|
+
fetches?: boolean;
|
|
637
|
+
/**
|
|
638
|
+
* Tell the page where its files land, at ui_ready (`device_folder <path>`).
|
|
639
|
+
* On by default: a file the user cannot find is a file that was not written, and
|
|
640
|
+
* the page has no other way to learn its own device's folder. Turn it off for a
|
|
641
|
+
* device whose files are its own business - a cache it fills and reads back.
|
|
642
|
+
*/
|
|
643
|
+
tellPage?: boolean;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export interface Files extends FilesDef {
|
|
647
|
+
readonly saves: boolean;
|
|
648
|
+
readonly fetches: boolean;
|
|
649
|
+
readonly tellPage: boolean;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Declare what this device does with disk.
|
|
654
|
+
*
|
|
655
|
+
* Like defineSurface and defineWatch, the checks run HERE, at call time, and
|
|
656
|
+
* throw - the build imports this module to derive the chain and the banner, so a
|
|
657
|
+
* bad declaration fails `pnpm build` and CI.
|
|
658
|
+
*/
|
|
659
|
+
export function defineFiles(def: FilesDef): Files {
|
|
660
|
+
const saves = def.saves ?? false;
|
|
661
|
+
const fetches = def.fetches ?? false;
|
|
662
|
+
// A declaration that claims neither would still pull in [maxurl] and still tell
|
|
663
|
+
// the page a folder, for a device that touches no disk at all. That is not a
|
|
664
|
+
// harmless default, it is a device carrying a chain nobody can explain.
|
|
665
|
+
if (!saves && !fetches) {
|
|
666
|
+
throw new Error(`files: declare at least one of saves / fetches - a device that does neither should not declare files at all`);
|
|
667
|
+
}
|
|
668
|
+
return { saves, fetches, tellPage: def.tellPage ?? true };
|
|
669
|
+
}
|
|
670
|
+
|
|
570
671
|
/** How a value is displayed - the parameter's own `format`, or a sane default. */
|
|
571
672
|
export function formatValue(spec: ParamSpec, value: unknown): string {
|
|
572
673
|
if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
|
package/src/react.tsx
CHANGED
|
@@ -212,6 +212,21 @@ export interface PooledControl extends BorrowedControl {
|
|
|
212
212
|
* minimum - the bug that got an earlier attempt at this reverted. The wrapper
|
|
213
213
|
* answers whether each range took, and this hook scales exactly once either way.
|
|
214
214
|
*
|
|
215
|
+
* `widenRange` IS OFF BY DEFAULT, and that is a measured decision rather than
|
|
216
|
+
* caution. A dial whose range was widened at runtime stops following its automation
|
|
217
|
+
* lane and any Rack macro mapped to it: both keep driving the parameter in the
|
|
218
|
+
* BUILD-TIME domain the frozen device gave them, so the value lands at the bottom of
|
|
219
|
+
* the new range and the dial never moves, silently. Left alone, the dials stay 0..1,
|
|
220
|
+
* this hook does the scaling, and automation, macros and Push all keep working - at
|
|
221
|
+
* the cost of the dial's own readout showing 0.44 rather than 600 Hz. Turn it on
|
|
222
|
+
* only where that readout is worth more than the automation.
|
|
223
|
+
*
|
|
224
|
+
* `describe: false` borrows and scales as usual but says NOTHING to Live. A device
|
|
225
|
+
* with two pages - a device view and a window - has two React trees against one
|
|
226
|
+
* pool, and both calling this means the last render wins: the dial ends up named
|
|
227
|
+
* after whichever page rendered most recently, flickering between them. Exactly one
|
|
228
|
+
* page should describe. The other still gets its `PooledControl[]` to draw.
|
|
229
|
+
*
|
|
215
230
|
* KNOWN LIMIT, measured in Live: the name reaches the DEVICE PANEL, not Live's
|
|
216
231
|
* parameter registry or a Rack macro picker, which keep the pool's own `S1..S8`.
|
|
217
232
|
* A frozen device cannot rename a parameter there. So render the name in your own
|
|
@@ -221,6 +236,7 @@ export function useControls<P extends Record<string, ParamSpec>>(
|
|
|
221
236
|
surface: Surface<P>,
|
|
222
237
|
controls: readonly BorrowedControl[],
|
|
223
238
|
poolIds: readonly Extract<keyof P, string>[],
|
|
239
|
+
options: { widenRange?: boolean; describe?: boolean } = {},
|
|
224
240
|
): PooledControl[] {
|
|
225
241
|
/* eslint-disable react-hooks/rules-of-hooks */
|
|
226
242
|
// A pool is dials, so its values are numbers - but `P` is the whole surface and
|
|
@@ -229,6 +245,8 @@ export function useControls<P extends Record<string, ParamSpec>>(
|
|
|
229
245
|
const params = poolIds.map((id) => useParam(surface, id)) as unknown as [number, (v: number) => void][];
|
|
230
246
|
/* eslint-enable react-hooks/rules-of-hooks */
|
|
231
247
|
|
|
248
|
+
const { widenRange = false, describe = true } = options;
|
|
249
|
+
|
|
232
250
|
/** Which dials Live actually widened. Until it says so, the dial is 0..1. */
|
|
233
251
|
const [real, setReal] = useState<boolean[]>([]);
|
|
234
252
|
useEffect(() => {
|
|
@@ -250,6 +268,7 @@ export function useControls<P extends Record<string, ParamSpec>>(
|
|
|
250
268
|
// parameter attributes, and a device re-rendering is not news.
|
|
251
269
|
const described = useRef<string[]>([]);
|
|
252
270
|
useEffect(() => {
|
|
271
|
+
if (!describe) return;
|
|
253
272
|
for (let i = 0; i < poolIds.length; i++) {
|
|
254
273
|
const c = controls[i];
|
|
255
274
|
const key = c ? `${c.name} ${c.min} ${c.max} ${c.unit ?? ""}` : "";
|
|
@@ -257,10 +276,10 @@ export function useControls<P extends Record<string, ParamSpec>>(
|
|
|
257
276
|
described.current[i] = key;
|
|
258
277
|
// A slot nobody is borrowing goes back to its declared identity, or it would
|
|
259
278
|
// 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] });
|
|
279
|
+
if (c) describeParam(poolIds[i], { name: c.name, unit: c.unit, range: [c.min, c.max], widenRange });
|
|
280
|
+
else describeParam(poolIds[i], { name: String(poolIds[i]).toUpperCase(), range: [0, 1], widenRange });
|
|
262
281
|
}
|
|
263
|
-
}, [controls, poolIds]);
|
|
282
|
+
}, [controls, poolIds, widenRange, describe]);
|
|
264
283
|
|
|
265
284
|
// Seed a slot the first time a given control takes it, so an untouched dial reads
|
|
266
285
|
// what the control says rather than 0.
|