@m4l-jweb/surface 0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +33 -0
  3. package/src/dev.tsx +177 -0
  4. package/src/index.ts +166 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Lopez (alienmind)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@m4l-jweb/surface",
3
+ "version": "0.2.0",
4
+ "description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/alienmind/m4l-jweb.git",
10
+ "directory": "packages/surface"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./dev": "./src/dev.tsx"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "LICENSE"
22
+ ],
23
+ "peerDependencies": {
24
+ "react": ">=18",
25
+ "@m4l-jweb/bridge": "0.2.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "react": {
29
+ "optional": true
30
+ }
31
+ },
32
+ "sideEffects": false
33
+ }
package/src/dev.tsx ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @m4l-jweb/surface/dev - a mocked Live, next to your app, in a browser tab.
3
+ *
4
+ * `pnpm dev` used to hand you `window.maxSimulate()` on the console. That is a
5
+ * shim, not an environment: to see a sequencer run you had to type ticks in by
6
+ * hand, and to see what your device was actually saying you had nothing at all.
7
+ *
8
+ * This is the other half of the device, mocked:
9
+ *
10
+ * - A TRANSPORT. Play/stop and a BPM field driving a real clock that emits
11
+ * `tick <playing> <beats>` and `tempo <bpm>` at the same 50 ms cadence the
12
+ * wrapper polls Live at. A sequencer becomes developable without a DAW.
13
+ * - A MESSAGE LOG. Every selector crossing the bridge, both directions. The
14
+ * bridge is the only channel between the two halves of a device, so tapping
15
+ * it shows you the device's entire contract, live. This is the single best
16
+ * debugging tool the stack has and it costs almost nothing.
17
+ *
18
+ * THE HONEST LIMIT: a mock is a mock. It cannot tell you about MIDI timing
19
+ * jitter, real DSP, or LiveAPI's behaviour on a loaded set. What it gives you is
20
+ * the whole message-level contract, exercised without Live - which is the part
21
+ * that is tedious to test and easy to get wrong. Keep "load it in Live" for what
22
+ * genuinely needs Live.
23
+ *
24
+ * NEVER SHIPPED. This module must not appear in the bundle embedded in a .amxd.
25
+ * Import it only behind `import.meta.env.DEV` (see src/main.tsx), and note that
26
+ * tests/bundle.test.mjs asserts HARNESS_MARKER is absent from the built ui.html.
27
+ */
28
+ import { useEffect, useRef, useState } from "react";
29
+ import { simulate, tapMessages, type BridgeMessage } from "@m4l-jweb/bridge";
30
+
31
+ /**
32
+ * A string that must never reach a production bundle. The build test greps the
33
+ * emitted ui.html for it: if this module survives tree-shaking, the test fails
34
+ * rather than a dev panel shipping inside someone's device.
35
+ */
36
+ export const HARNESS_MARKER = "m4l-jweb:dev-harness:do-not-ship";
37
+
38
+ /** The wrapper polls Live's transport at 20 Hz. The mock lies at the same rate. */
39
+ const TICK_MS = 50;
40
+ const LOG_LIMIT = 200;
41
+
42
+ export function DevHarness() {
43
+ const [playing, setPlaying] = useState(false);
44
+ const [bpm, setBpm] = useState(120);
45
+ const [beats, setBeats] = useState(0);
46
+ const [log, setLog] = useState<BridgeMessage[]>([]);
47
+
48
+ // Log every message crossing the bridge, both directions.
49
+ useEffect(() => tapMessages((m) => setLog((prev) => [m, ...prev].slice(0, LOG_LIMIT))), []);
50
+
51
+ // Tempo is OBSERVED in the real device, not polled: it is sent once on attach
52
+ // and then only on change. Mirror that - send it when it changes, not on tick.
53
+ useEffect(() => simulate("tempo", bpm), [bpm]);
54
+
55
+ // The transport clock. `beats` advances in musical time, so changing the BPM
56
+ // mid-playback changes the rate, exactly as it does in Live.
57
+ const beatsRef = useRef(0);
58
+ const bpmRef = useRef(bpm);
59
+ bpmRef.current = bpm;
60
+
61
+ useEffect(() => {
62
+ if (!playing) {
63
+ // Live reports position 0 and is_playing 0 when stopped, and keeps
64
+ // reporting it - a device that only listens for changes must still see this.
65
+ simulate("tick", 0, beatsRef.current);
66
+ return;
67
+ }
68
+ const id = setInterval(() => {
69
+ beatsRef.current += (TICK_MS / 60000) * bpmRef.current;
70
+ setBeats(beatsRef.current);
71
+ simulate("tick", 1, beatsRef.current);
72
+ }, TICK_MS);
73
+ return () => clearInterval(id);
74
+ }, [playing]);
75
+
76
+ function stop() {
77
+ setPlaying(false);
78
+ beatsRef.current = 0;
79
+ setBeats(0);
80
+ simulate("tick", 0, 0);
81
+ }
82
+
83
+ return (
84
+ <aside data-harness={HARNESS_MARKER} style={S.panel}>
85
+ <h2 style={S.h2}>LIVE (mocked)</h2>
86
+
87
+ <section style={S.section}>
88
+ <div style={S.row}>
89
+ <button style={S.btn} onClick={() => setPlaying((p) => !p)}>
90
+ {playing ? "pause" : "play"}
91
+ </button>
92
+ <button style={S.btn} onClick={stop}>
93
+ stop
94
+ </button>
95
+ <label style={S.label}>
96
+ BPM
97
+ <input style={S.input} type="number" min={20} max={300} step={0.5} value={bpm} onChange={(e) => setBpm(Number(e.target.value))} />
98
+ </label>
99
+ </div>
100
+ <div style={S.readout}>
101
+ bar {Math.floor(beats / 4) + 1} | beat {(beats % 4).toFixed(2)}
102
+ </div>
103
+ </section>
104
+
105
+ <section style={S.section}>
106
+ <div style={S.row}>
107
+ <h2 style={S.h2}>messages</h2>
108
+ <button style={S.btn} onClick={() => setLog([])}>
109
+ clear
110
+ </button>
111
+ </div>
112
+ <ol style={S.log}>
113
+ {log.map((m, i) => (
114
+ <li key={`${m.at}-${i}`} style={S.line}>
115
+ <span style={m.direction === "in" ? S.arrowIn : S.arrowOut}>{m.direction === "in" ? "->" : "<-"}</span>
116
+ <span style={S.selector}>{m.selector}</span>
117
+ <span style={S.args}>{m.args.map(String).join(" ")}</span>
118
+ </li>
119
+ ))}
120
+ {!log.length && <li style={S.empty}>nothing yet - press play, or interact with the device</li>}
121
+ </ol>
122
+ </section>
123
+
124
+ <p style={S.note}>
125
+ Dev only. Not in the built device. A mock cannot tell you about MIDI jitter, DSP or a real Live set - load it in Live for those.
126
+ </p>
127
+ </aside>
128
+ );
129
+ }
130
+
131
+ const mono = "ui-monospace, SFMono-Regular, Menlo, monospace";
132
+
133
+ const S: Record<string, React.CSSProperties> = {
134
+ panel: {
135
+ display: "flex",
136
+ flexDirection: "column",
137
+ gap: 12,
138
+ padding: 12,
139
+ background: "#14161a",
140
+ color: "#c8ccd4",
141
+ font: `12px ${mono}`,
142
+ minWidth: 280,
143
+ maxWidth: 360,
144
+ borderRight: "1px solid #262a31",
145
+ },
146
+ h2: { margin: 0, font: `600 11px ${mono}`, letterSpacing: "0.08em", textTransform: "uppercase", color: "#7d8694" },
147
+ section: { display: "flex", flexDirection: "column", gap: 6 },
148
+ row: { display: "flex", gap: 6, alignItems: "center" },
149
+ btn: {
150
+ background: "#242932",
151
+ color: "#c8ccd4",
152
+ border: "1px solid #333a45",
153
+ borderRadius: 3,
154
+ padding: "3px 9px",
155
+ font: `11px ${mono}`,
156
+ cursor: "pointer",
157
+ },
158
+ label: { display: "flex", gap: 4, alignItems: "center", marginLeft: "auto", color: "#7d8694" },
159
+ input: {
160
+ width: 62,
161
+ background: "#0e1013",
162
+ color: "#c8ccd4",
163
+ border: "1px solid #333a45",
164
+ borderRadius: 3,
165
+ padding: "3px 5px",
166
+ font: `11px ${mono}`,
167
+ },
168
+ readout: { color: "#7d8694" },
169
+ log: { listStyle: "none", margin: 0, padding: 0, overflowY: "auto", maxHeight: 260, display: "flex", flexDirection: "column", gap: 1 },
170
+ line: { display: "flex", gap: 6, whiteSpace: "nowrap" },
171
+ arrowIn: { color: "#5aa9e6" },
172
+ arrowOut: { color: "#e6a15a" },
173
+ selector: { color: "#c8ccd4" },
174
+ args: { color: "#7d8694", overflow: "hidden", textOverflow: "ellipsis" },
175
+ empty: { color: "#4d5460" },
176
+ note: { margin: 0, color: "#4d5460", lineHeight: 1.4 },
177
+ };
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @m4l-jweb/surface - declare a device's Live parameters once, as code.
3
+ *
4
+ * A device has TWO surfaces, and they are not competing - they are different
5
+ * projections of the same state:
6
+ *
7
+ * the Surface (Max) the App (Chromium)
8
+ * real Live parameters your React UI
9
+ * automatable, MIDI-mappable canvas, WebGL, whatever
10
+ * THE ONLY THING PUSH SEES the deep editor on the laptop
11
+ *
12
+ * Push cannot see your React UI. Not yours, not anyone's - it reads Live
13
+ * parameters and nothing else. So every musically meaningful control has to
14
+ * exist as a `live.dial` / `live.toggle` / `live.menu` with `parameter_enable`
15
+ * on, which until now meant maintaining the same control in four places: the
16
+ * Max object, the patcher wiring, the app's protocol, and the app's state.
17
+ * Change a range and three of the four silently disagree.
18
+ *
19
+ * This file is the one declaration all four are derived from.
20
+ *
21
+ * STATUS - read this before you reach for it. Shipped today (Stage 0.3 of
22
+ * doc/TODO.md): the declaration, its types, and its validation. NOT shipped:
23
+ * the codegen that turns it into Max objects and wiring (Stage 2). So a Surface
24
+ * currently type-checks, validates, and drives the dev harness - but the
25
+ * parameters Live actually sees still come from `parameters` in
26
+ * `patcher/devices.mjs`. Declaring one does not yet make a dial appear in Live.
27
+ * Keep the two in step by hand until Stage 2 lands and deletes the manifest field.
28
+ */
29
+
30
+ /* ------------------------------------------------------------------ *
31
+ * Parameter kinds
32
+ * ------------------------------------------------------------------ */
33
+
34
+ export interface DialSpec {
35
+ kind: "dial";
36
+ /** [min, max]. Live needs a bounded range; there is no unbounded parameter. */
37
+ range: [number, number];
38
+ default: number;
39
+ /** `step: 1` makes it an integer parameter (Max parameter_type 1). */
40
+ step?: number;
41
+ unit?: string;
42
+ /** What the dev harness and the Push preview print under the encoder. */
43
+ format?: (v: number) => string;
44
+ /** Push has ~8 characters per encoder label. Longer names are truncated. */
45
+ short: string;
46
+ }
47
+
48
+ export interface ToggleSpec {
49
+ kind: "toggle";
50
+ default: boolean;
51
+ short: string;
52
+ }
53
+
54
+ export interface MenuSpec<O extends string = string> {
55
+ kind: "menu";
56
+ options: readonly O[];
57
+ default: O;
58
+ short: string;
59
+ }
60
+
61
+ export type ParamSpec = DialSpec | ToggleSpec | MenuSpec;
62
+
63
+ /** The value type a given parameter carries. `useParam` will be typed by this. */
64
+ export type ParamValue<P extends ParamSpec> = P extends DialSpec ? number : P extends ToggleSpec ? boolean : P extends MenuSpec<infer O> ? O : never;
65
+
66
+ export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
67
+ kind: "dial",
68
+ ...spec,
69
+ });
70
+ export const toggle = (spec: Omit<ToggleSpec, "kind">): ToggleSpec => ({
71
+ kind: "toggle",
72
+ ...spec,
73
+ });
74
+ export const menu = <O extends string>(spec: Omit<MenuSpec<O>, "kind">): MenuSpec<O> => ({ kind: "menu", ...spec });
75
+
76
+ /* ------------------------------------------------------------------ *
77
+ * The surface
78
+ * ------------------------------------------------------------------ */
79
+
80
+ /** Push renders parameters in banks of eight. A bank is a page. */
81
+ export interface Bank<K extends string> {
82
+ name: string;
83
+ /** At most 8 - Push has eight encoders, and a ninth is silently dropped. */
84
+ params: readonly K[];
85
+ }
86
+
87
+ export interface SurfaceDef<P extends Record<string, ParamSpec>> {
88
+ params: P;
89
+ banks?: readonly Bank<Extract<keyof P, string>>[];
90
+ }
91
+
92
+ export interface Surface<P extends Record<string, ParamSpec> = Record<string, ParamSpec>> extends SurfaceDef<P> {
93
+ /** Declaration order. This is also the order Push falls back to without banks. */
94
+ readonly ids: readonly Extract<keyof P, string>[];
95
+ }
96
+
97
+ /** Push has eight encoders per page. A ninth parameter in a bank is not an error in Max - it just never appears. */
98
+ export const BANK_SIZE = 8;
99
+
100
+ /**
101
+ * Declare the parameter surface.
102
+ *
103
+ * `banks` may only name parameters that exist - that one is enforced by the
104
+ * type system (`Extract<keyof P, string>`), so a renamed parameter breaks the
105
+ * build at the point of the typo.
106
+ *
107
+ * Bank size, duplicate membership and default-in-range are checked HERE, at
108
+ * call time, and throw. That is not a weaker guarantee than a type: the build
109
+ * imports this module to generate the patcher, so a violation fails `pnpm
110
+ * build` and fails CI. It is only a less pretty error message.
111
+ */
112
+ export function defineSurface<const P extends Record<string, ParamSpec>>(def: SurfaceDef<P>): Surface<P> {
113
+ const ids = Object.keys(def.params) as Extract<keyof P, string>[];
114
+
115
+ for (const id of ids) {
116
+ const p = def.params[id];
117
+ if (p.kind === "dial") {
118
+ const [min, max] = p.range;
119
+ if (!(min < max)) throw new Error(`surface: "${id}" has an empty range [${min}, ${max}]`);
120
+ if (p.default < min || p.default > max) {
121
+ throw new Error(`surface: "${id}" default ${p.default} is outside its range [${min}, ${max}]`);
122
+ }
123
+ }
124
+ if (p.kind === "menu") {
125
+ if (!p.options.length) throw new Error(`surface: menu "${id}" has no options`);
126
+ if (!p.options.includes(p.default)) {
127
+ throw new Error(`surface: menu "${id}" default "${p.default}" is not one of its options`);
128
+ }
129
+ }
130
+ // Push truncates rather than errors, so a too-long short name is a silent
131
+ // display bug. Catch it where it is cheap to fix.
132
+ if (p.short.length > BANK_SIZE) {
133
+ throw new Error(`surface: "${id}" short name "${p.short}" is longer than ${BANK_SIZE} chars - Push will truncate it`);
134
+ }
135
+ }
136
+
137
+ const seen = new Set<string>();
138
+ for (const bank of def.banks ?? []) {
139
+ if (bank.params.length > BANK_SIZE) {
140
+ throw new Error(`surface: bank "${bank.name}" holds ${bank.params.length} params - Push shows ${BANK_SIZE} per page, the rest never appear`);
141
+ }
142
+ for (const id of bank.params) {
143
+ if (seen.has(id)) throw new Error(`surface: "${id}" appears in more than one bank`);
144
+ seen.add(id);
145
+ }
146
+ }
147
+
148
+ return { ...def, ids };
149
+ }
150
+
151
+ /** The default value of every parameter. The app's initial state, before Live replies. */
152
+ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P>): { [K in keyof P]: ParamValue<P[K]> } {
153
+ const out = {} as { [K in keyof P]: ParamValue<P[K]> };
154
+ for (const id of surface.ids) out[id] = surface.params[id].default as ParamValue<P[typeof id]>;
155
+ return out;
156
+ }
157
+
158
+ /** How a value is displayed - the parameter's own `format`, or a sane default. */
159
+ export function formatValue(spec: ParamSpec, value: unknown): string {
160
+ if (spec.kind === "toggle") return value ? "on" : "off";
161
+ if (spec.kind === "menu") return String(value);
162
+ if (spec.format) return spec.format(Number(value));
163
+ const n = Number(value);
164
+ const text = spec.step === 1 ? String(Math.round(n)) : n.toFixed(2);
165
+ return spec.unit ? `${text}${spec.unit}` : text;
166
+ }