@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "1.2.0",
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.2.0"
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
- export const knobPool = <N extends number>(count: N, prefix = "s"): Record<string, DialSpec> => {
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.