@m4l-jweb/build 0.1.0 → 0.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.
@@ -1,21 +1,47 @@
1
1
  # {{name}}
2
2
 
3
- A Max for Live device, scaffolded with `m4l-jweb init`. See the
4
- [M4L-JWEB docs](https://github.com/alienmind/m4l-jweb) for the architecture
5
- this repo builds on.
3
+ A Max for Live device, built with [M4L-JWEB](https://github.com/alienmind/m4l-jweb).
6
4
 
7
5
  ```bash
8
6
  pnpm install
9
- pnpm dev # browser dev with the Max bridge simulated
10
- pnpm build # emits dist/{{name}}/<device>.amxd + release zip
11
- pnpm test # ES5 gate + protocol lint
12
- pnpm install:device # copy the built device into Ableton's User Library
7
+ pnpm dev # the device in a browser, with a mocked Live beside it
8
+ pnpm build # {{name}}.amxd - no Max installed
9
+ pnpm install:device # into Ableton's User Library
13
10
  ```
14
11
 
15
- You edit two places:
12
+ Then in Live: **User Library > Max For Live > {{name}}**, and drop it on a MIDI
13
+ track.
16
14
 
17
- - `src/app/` - the web app (UI, optional worker, `protocol.ts`).
18
- - `patcher/devices.mjs` - the device manifest (name, type, chains, parameters).
15
+ ## What you edit
19
16
 
20
- Everything else (`@m4l-jweb/wrapper`, `@m4l-jweb/build`) is packaged
21
- infrastructure you should rarely need to touch.
17
+ | File | What it is |
18
+ |---|---|
19
+ | `src/app/{{name}}/App.tsx` | The UI, and the device's logic. A React app. |
20
+ | `src/app/{{name}}/protocol.ts` | Every selector crossing the bridge. Both sides read it. |
21
+ | `src/app/{{name}}/surface.ts` | The Live parameters (automatable, MIDI-mappable, visible to Push). |
22
+ | `patcher/devices.mjs` | The manifest: name, type, chains, parameters. The patcher is generated from it. |
23
+
24
+ `src/app/shared/` and `scripts/` are infrastructure. You should rarely need to
25
+ touch them.
26
+
27
+ ## Adding a second device
28
+
29
+ Add an entry to `patcher/devices.mjs` and a folder at `src/app/<name>/`. Each
30
+ device builds into its own `.amxd` carrying its own UI bundle, and
31
+ `pnpm dev:<name>` runs it on its own.
32
+
33
+ ## Developing without Live
34
+
35
+ `pnpm dev` renders a mocked Live next to your device: a transport (play/stop,
36
+ BPM) driving real `tick` and `tempo` messages, and a log of every message
37
+ crossing the bridge. A sequencer is developable, and debuggable, in a browser.
38
+
39
+ A mock is a mock. It gives you the message-level contract without a DAW, but it
40
+ cannot tell you about MIDI jitter, real DSP, or LiveAPI on a loaded set. Load it
41
+ in Live for those.
42
+
43
+ ## Notes
44
+
45
+ Live embeds a **copy** of a device into the set, so reinstalling does not update
46
+ instances already on a track - delete them and re-drag from the browser. The
47
+ device prints a build stamp in its footer, so a stale one is visible.
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>{{name}}</title>
6
+ <title>M4L-JWEB device</title>
7
7
  </head>
8
8
  <body>
9
9
  <div id="root"></div>
@@ -1,36 +1,42 @@
1
1
  {
2
- "name": "{{name}}",
3
- "private": true,
4
- "version": "0.1.0",
5
- "type": "module",
6
- "description": "A Max for Live device built with M4L-JWEB.",
7
- "scripts": {
8
- "dev": "vite --host 127.0.0.1 --port 5175",
9
- "build": "tsc -b && vite build && m4l-jweb build",
10
- "build:wrapper": "m4l-jweb wrapper",
11
- "build:patchers": "m4l-jweb patchers",
12
- "install:device": "m4l-jweb install",
13
- "preview": "vite preview --host 127.0.0.1 --port 4175",
14
- "test": "vitest run"
15
- },
16
- "dependencies": {
17
- "@m4l-jweb/bridge": "^0.1.0",
18
- "react": "^19.0.0",
19
- "react-dom": "^19.0.0"
20
- },
21
- "devDependencies": {
22
- "@m4l-jweb/build": "^0.1.0",
23
- "@types/react": "^19.0.0",
24
- "@types/react-dom": "^19.0.0",
25
- "@vitejs/plugin-react": "^4.3.4",
26
- "typescript": "^5.7.0",
27
- "vite": "^6.0.0",
28
- "vite-plugin-singlefile": "^2.1.0",
29
- "vitest": "^2.1.0"
30
- },
31
- "pnpm": {
32
- "onlyBuiltDependencies": [
33
- "esbuild"
34
- ]
35
- }
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "packageManager": "pnpm@10.0.0",
7
+ "description": "A Max for Live device built with M4L-JWEB.",
8
+ "scripts": {
9
+ "dev": "node scripts/dev.mjs {{name}}",
10
+ "dev:{{name}}": "node scripts/dev.mjs {{name}}",
11
+ "build": "tsc -b && node scripts/build-ui.mjs && m4l-jweb build",
12
+ "build:wrapper": "m4l-jweb wrapper",
13
+ "build:patchers": "m4l-jweb patchers",
14
+ "install:device": "m4l-jweb install",
15
+ "preview": "vite preview --host 127.0.0.1 --port 4175",
16
+ "test": "vitest run --passWithNoTests",
17
+ "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"scripts/*.mjs\" \"patcher/*.mjs\""
18
+ },
19
+ "dependencies": {
20
+ "@m4l-jweb/bridge": "^0.3.0",
21
+ "@m4l-jweb/surface": "^0.3.0",
22
+ "react": "^19.0.0",
23
+ "react-dom": "^19.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@m4l-jweb/build": "^0.3.0",
27
+ "@types/node": "^22.0.0",
28
+ "@types/react": "^19.0.0",
29
+ "@types/react-dom": "^19.0.0",
30
+ "@vitejs/plugin-react": "^4.3.4",
31
+ "prettier": "^3.4.0",
32
+ "typescript": "^5.7.0",
33
+ "vite": "^6.0.0",
34
+ "vite-plugin-singlefile": "^2.1.0",
35
+ "vitest": "^2.1.0"
36
+ },
37
+ "pnpm": {
38
+ "onlyBuiltDependencies": [
39
+ "esbuild"
40
+ ]
41
+ }
36
42
  }
@@ -1,32 +1,41 @@
1
1
  /**
2
- * devices.mjs - the device manifest. THIS is what you edit to change the shape
3
- * of a device; the patcher itself is generated from it (see
4
- * @m4l-jweb/build). Patch cords become code review.
2
+ * devices.mjs - the device manifest. This is what you edit to change the shape
3
+ * of a device; the patcher is generated from it, so patch cords become code
4
+ * review rather than pixels.
5
5
  *
6
6
  * Fields
7
7
  * name output basename -> dist/<pkg>/<name>.amxd
8
8
  * type "midi" (MIDI effect) | "instrument" | "audio" (audio effect)
9
- * chains canned wiring, applied in order. Vocabulary:
10
- * "midiout" jweb -> route midinote -> pipe -> makenote
11
- * -> midiformat -> midiout. The app emits
12
- * `midinote <pitch> <vel> <durMs> <chan> <delayMs>`;
13
- * it computes WHEN, Max places it precisely.
14
- * "passthrough" plugin~ -> plugout~ (an audio effect that
15
- * passes its input through untouched).
16
- * parameters real Live parameters: automatable, MIDI-mappable, and the ONLY
17
- * thing Push can show. Each becomes a live.* object wired into
18
- * the UI as `<id> <value>`. No custom UI reaches Push - not
19
- * yours, not anyone's - so put every musically meaningful control
20
- * here as well as in the web UI.
21
- * unmatchedTo where messages the chains did not consume go. "js" sends them
22
- * to the wrapper (ui_ready, write_clip, read_notes, ...).
9
+ * ui the folder under src/app/ holding this device's UI. Defaults to
10
+ * `name`. Each device gets its own UI bundle inside its own .amxd.
11
+ * chains canned wiring, applied in order:
12
+ * "midiin" notes played into the device reach the app as
13
+ * `notein <pitch> <velocity>` (see onNote()).
14
+ * "midiout" the app emits `midinote <pitch> <vel> <durMs>
15
+ * <chan> <delayMs>` (see sendNote()); pipe +
16
+ * makenote + midiformat place it on Max's
17
+ * scheduler. The app computes WHEN.
18
+ * "lowpass" plugin~ -> onepole~ -> plugout~, with a `cutoff`
19
+ * parameter. An audio effect you can hear.
20
+ * "gain" plugin~ -> *~ -> plugout~, with a `gain` parameter.
21
+ * "passthrough" plugin~ -> plugout~. Does nothing to the audio.
22
+ * parameters real Live parameters: automatable, MIDI-mappable, and what Push
23
+ * reads. Each becomes a live.* object, and reaches the app as
24
+ * `<id> <value>`.
25
+ *
26
+ * Set `default`. Without it the object loads at the BOTTOM of its
27
+ * range, which for many parameters is a broken device.
28
+ * unmatchedTo where messages the chains did not consume go. "js" sends them to
29
+ * the wrapper (ui_ready, ...).
30
+ *
31
+ * Add a second device by adding an entry here and a folder at src/app/<name>/.
23
32
  */
24
33
  export default [
25
- {
26
- name: "{{name}}",
27
- type: "midi",
28
- chains: ["midiout"],
29
- parameters: [{ id: "running", object: "live.toggle" }],
30
- unmatchedTo: "js",
31
- },
34
+ {
35
+ name: "{{name}}",
36
+ type: "midi",
37
+ chains: ["midiin", "midiout"],
38
+ parameters: [{ id: "density", object: "live.dial", range: [0, 1], default: 0.5 }],
39
+ unmatchedTo: "js",
40
+ },
32
41
  ];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * build-ui.mjs - bundle one self-contained UI per device.
3
+ *
4
+ * Emits dist/ui/<device>/index.html, which `m4l-jweb build` then embeds into the
5
+ * matching .amxd as a base64 payload.
6
+ *
7
+ * Sequential, not parallel: vite reads DEVICE from the environment, and two
8
+ * concurrent builds would race on it. The builds are ~500 ms each.
9
+ */
10
+ import { build } from "vite";
11
+ import { uiDirs } from "./devices.mjs";
12
+
13
+ for (const dir of uiDirs) {
14
+ process.env.DEVICE = dir;
15
+ console.log(`\nm4l-jweb: bundling UI for ${dir}`);
16
+ await build();
17
+ }
18
+
19
+ console.log(`\nm4l-jweb: ${uiDirs.length} UI bundle(s) -> dist/ui/`);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * dev.mjs - run ONE device's UI in a browser, with the mocked-Live harness.
3
+ *
4
+ * node scripts/dev.mjs <device> (or: pnpm dev:<device>)
5
+ *
6
+ * The device must be in patcher/devices.mjs. With no argument, the first one in
7
+ * the manifest is used.
8
+ *
9
+ * Sets DEVICE for vite.config.ts, which points the `@device` alias at
10
+ * src/app/<device>/. Node rather than an inline env var in the npm script because
11
+ * `DEVICE=x vite` is not portable to Windows shells, and cross-env is a
12
+ * dependency this does not need.
13
+ */
14
+ import { createServer } from "vite";
15
+ import { devices, resolveDevice } from "./devices.mjs";
16
+
17
+ const name = resolveDevice(process.argv[2]);
18
+ process.env.DEVICE = name;
19
+
20
+ const server = await createServer({ server: { host: "127.0.0.1", port: 5175 } });
21
+ await server.listen();
22
+
23
+ console.log(`\n device: ${name} (others: ${devices.map((d) => d.name).filter((n) => n !== name).join(", ") || "none"})`);
24
+ server.printUrls();
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Types for scripts/devices.mjs, so vite.config.ts can import the manifest and
3
+ * still typecheck. The manifest itself stays plain .mjs - it is read by the Node
4
+ * build, which has no TypeScript in the loop.
5
+ */
6
+
7
+ /** One entry in patcher/devices.mjs. */
8
+ export interface DeviceManifestEntry {
9
+ name: string;
10
+ type: "midi" | "audio" | "instrument";
11
+ /** The wrapper's object-box argument. Defaults to `type`. */
12
+ mode?: string;
13
+ /** The folder under src/app/ holding this device's UI. Defaults to `name`. */
14
+ ui?: string;
15
+ chains?: string[];
16
+ parameters?: { id: string; object: string; range?: [number, number]; default?: number }[];
17
+ unmatchedTo?: string;
18
+ }
19
+
20
+ export declare const root: string;
21
+ export declare const devices: DeviceManifestEntry[];
22
+ export declare const uiDirs: string[];
23
+ export declare function uiDir(d: DeviceManifestEntry): string;
24
+ export declare function resolveDevice(arg?: string): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * devices.mjs - read the manifest, and map each device to its UI folder.
3
+ *
4
+ * The manifest (patcher/devices.mjs) is the single list of what this repo builds.
5
+ * The UI folder is `src/app/<ui ?? name>/`, so two devices CAN share one app by
6
+ * both setting the same `ui` - but they do not share by default, which is the
7
+ * point of the split.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ export const devices = (await import(pathToFileURL(path.join(root, "patcher/devices.mjs")).href)).default;
16
+
17
+ /** The folder under src/app/ holding a device's UI. */
18
+ export const uiDir = (d) => d.ui ?? d.name;
19
+
20
+ /** Every distinct UI that has to be built (two devices may share one). */
21
+ export const uiDirs = [...new Set(devices.map(uiDir))];
22
+
23
+ /** Validate a device name from the command line, with a useful error. */
24
+ export function resolveDevice(arg) {
25
+ const name = arg ?? devices[0]?.name;
26
+ const known = devices.map((d) => d.name);
27
+ if (!known.includes(name)) {
28
+ throw new Error(`unknown device "${name}" - patcher/devices.mjs declares: ${known.join(", ")}`);
29
+ }
30
+ const dir = uiDir(devices.find((d) => d.name === name));
31
+ if (!existsSync(path.join(root, "src/app", dir))) {
32
+ throw new Error(`device "${name}" has no UI at src/app/${dir}/`);
33
+ }
34
+ return dir;
35
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * shared/Frame.tsx - the chrome every device wears: a title, a where-am-I badge,
3
+ * and the two build stamps.
4
+ *
5
+ * The stamps are not decoration. Live embeds a COPY of a device into the set, so
6
+ * reinstalling does not update instances already on tracks - and a stale device
7
+ * behaves like a bug in code you have already fixed. The stamps make that
8
+ * visible instead of mysterious.
9
+ *
10
+ * They live in the HEADER, top right, and that placement is load-bearing: the
11
+ * device view is a fixed ~169 px and overgrown UI clips silently at the BOTTOM.
12
+ * A stamp in a footer is a staleness check that disappears exactly when the
13
+ * device has grown enough to be worth checking. Anchored to the header, it
14
+ * survives whatever the device does below it.
15
+ */
16
+ import type { ReactNode } from "react";
17
+ import { inJweb } from "@m4l-jweb/bridge";
18
+ import type { DeviceState } from "./device";
19
+
20
+ declare const __APP_VERSION__: string;
21
+
22
+ export function Frame({ title, device, children }: { title: string; device: DeviceState; children: ReactNode }) {
23
+ return (
24
+ <main className="device">
25
+ <header>
26
+ <h1>{title}</h1>
27
+ <span className={`badge ${inJweb ? "live" : "dev"}`}>{inJweb ? "in Max" : "browser dev"}</span>
28
+ <span className="stamp" title={`ui ${__APP_VERSION__} / wrapper ${device.build ?? "-"}`}>
29
+ {device.stale ? (
30
+ <span className="warn">stale - delete and re-drag the device</span>
31
+ ) : (
32
+ <>
33
+ <span>ui {__APP_VERSION__}</span>
34
+ <span>wrapper {device.build ?? "-"}</span>
35
+ </>
36
+ )}
37
+ </span>
38
+ </header>
39
+
40
+ <dl>{children}</dl>
41
+ </main>
42
+ );
43
+ }
44
+
45
+ /** The transport line. Shared because every device wants it and it has a trap in it. */
46
+ export function Transport({ device }: { device: DeviceState }) {
47
+ return (
48
+ <>
49
+ <dt>transport</dt>
50
+ <dd className="row">
51
+ <span>
52
+ <span className={device.playing ? "dot on" : "dot"} /> {device.playing ? "playing" : "stopped"} @ {device.beats.toFixed(2)} beats
53
+ {device.tempo !== null && `, ${device.tempo.toFixed(1)} BPM`}
54
+ </span>
55
+ {/* The transport is LIVE's, and nothing inside the device starts it. Not
56
+ obvious from in here, and it looks like the device is broken. */}
57
+ {!device.playing && <em className="hint">press play in Live to start the transport</em>}
58
+ </dd>
59
+ </>
60
+ );
61
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * shared/device.ts - the part of a device that every device has.
3
+ *
4
+ * Whatever else it does, a device is told its run mode, its build stamp, Live's
5
+ * tempo, and the transport position. Binding those four by hand in every app was
6
+ * four chances to get the handshake wrong, so it lives here once.
7
+ *
8
+ * The selectors are NOT re-declared here: they come from @m4l-jweb/bridge's
9
+ * DEVICE_IN, which each device spreads into its own protocol.ts. Same rule as
10
+ * the chains - the name you bind and the name the wrapper sends come from one
11
+ * definition.
12
+ */
13
+ import { useEffect, useRef, useState } from "react";
14
+ import { DEVICE_IN, bindInlet, uiReady } from "@m4l-jweb/bridge";
15
+
16
+ declare const __APP_VERSION__: string;
17
+
18
+ export interface DeviceState {
19
+ /** midi | audio | instrument | ... - the wrapper's object-box argument. */
20
+ mode: string;
21
+ /** The wrapper's build stamp, or null before it has replied. */
22
+ build: string | null;
23
+ /** Live's tempo in BPM, or null before the observer has fired. */
24
+ tempo: number | null;
25
+ playing: boolean;
26
+ beats: number;
27
+ /**
28
+ * The wrapper and this page disagree about which build they are.
29
+ *
30
+ * Live embeds a COPY of a device in the set, so reinstalling does not update
31
+ * instances already on tracks. Without this, a stale device looks like a bug in
32
+ * your code rather than a stale device.
33
+ */
34
+ stale: boolean;
35
+ }
36
+
37
+ /** A transport poll. Return-value-free: send your notes from inside it. */
38
+ export type TickHandler = (playing: boolean, beats: number) => void;
39
+
40
+ /**
41
+ * Bind the common inlets, announce `ui_ready`, and hand back the device's state.
42
+ *
43
+ * `onTick` is called on every transport poll (20 Hz). It is held in a ref and
44
+ * called through it, so your handler always sees fresh props WITHOUT the inlet
45
+ * being rebound on every render - rebinding would drop messages in the gap.
46
+ */
47
+ export function useDevice(onTick?: TickHandler): DeviceState {
48
+ const [mode, setMode] = useState("dev");
49
+ const [build, setBuild] = useState<string | null>(null);
50
+ const [tempo, setTempo] = useState<number | null>(null);
51
+ const [playing, setPlaying] = useState(false);
52
+ const [beats, setBeats] = useState(0);
53
+
54
+ const tick = useRef<TickHandler | undefined>(onTick);
55
+ tick.current = onTick;
56
+
57
+ useEffect(() => {
58
+ bindInlet(DEVICE_IN.mode, (m) => setMode(String(m)));
59
+ bindInlet(DEVICE_IN.build, (b) => setBuild(String(b)));
60
+ bindInlet(DEVICE_IN.tempo, (bpm) => setTempo(Number(bpm)));
61
+ bindInlet(DEVICE_IN.tick, (isPlaying, position) => {
62
+ const on = Number(isPlaying) === 1;
63
+ const at = Number(position);
64
+ setPlaying(on);
65
+ setBeats(at);
66
+ tick.current?.(on, at);
67
+ });
68
+
69
+ // The handshake. The page loads asynchronously, so anything the wrapper sent
70
+ // before these handlers existed is simply gone: announce readiness and let it
71
+ // resend. This is not optional.
72
+ uiReady();
73
+ }, []);
74
+
75
+ // The stamp is "<version> <iso date>"; the page only bakes in the version.
76
+ const stale = build !== null && build.split(" ")[0] !== __APP_VERSION__;
77
+
78
+ return { mode, build, tempo, playing, beats, stale };
79
+ }
@@ -15,12 +15,12 @@
15
15
  let ticks = 0;
16
16
 
17
17
  self.onmessage = (e: MessageEvent) => {
18
- const [type] = e.data as [string, ...unknown[]];
19
- if (type === "tick") {
20
- ticks += 1;
21
- self.postMessage(["ticks", ticks]);
22
- } else if (type === "reset") {
23
- ticks = 0;
24
- self.postMessage(["ticks", ticks]);
25
- }
18
+ const [type] = e.data as [string, ...unknown[]];
19
+ if (type === "tick") {
20
+ ticks += 1;
21
+ self.postMessage(["ticks", ticks]);
22
+ } else if (type === "reset") {
23
+ ticks = 0;
24
+ self.postMessage(["ticks", ticks]);
25
+ }
26
26
  };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * {{name}} - a MIDI effect. It sits on a MIDI track before the instrument, and
3
+ * transposes what you play up by an octave.
4
+ *
5
+ * Replace the body with your device. The bridge surface stays the same.
6
+ *
7
+ * pnpm dev
8
+ *
9
+ * runs this in a browser with a mocked Live beside it: a transport, and a log of
10
+ * every message crossing the bridge. No Live, no Max needed.
11
+ */
12
+ import { useEffect, useRef, useState } from "react";
13
+ import { bindInlet, flushNotes, onNote, sendNote } from "@m4l-jweb/bridge";
14
+ import { useDevice } from "../shared/device";
15
+ import { Frame, Transport } from "../shared/Frame";
16
+ import { IN } from "./protocol";
17
+
18
+ export default function App() {
19
+ const [density, setDensity] = useState(0.5);
20
+ const [last, setLast] = useState<{ from: number; to: number } | null>(null);
21
+
22
+ // The inlet handler is bound once, but it has to read the CURRENT parameter
23
+ // value. A ref keeps it fresh without rebinding on every render - rebinding
24
+ // would drop messages in the gap.
25
+ const densityRef = useRef(0.5);
26
+ densityRef.current = density;
27
+
28
+ const device = useDevice((playing) => {
29
+ // Called on every transport poll (20 Hz). A sequencer computes which notes
30
+ // fall in this slice of musical time and sends each with a delayMs that
31
+ // carries it to its true position; Max places it on the scheduler.
32
+ if (!playing) flushNotes();
33
+ });
34
+
35
+ useEffect(() => {
36
+ bindInlet(IN.density, (d) => setDensity(Number(d)));
37
+
38
+ // Notes played into the device. onNote drops note-offs: makenote on the Max
39
+ // side already owns the release.
40
+ onNote((pitch, velocity) => {
41
+ const up = Math.min(127, pitch + 12);
42
+ sendNote({
43
+ pitch: up,
44
+ velocity: Math.round(velocity * (0.5 + densityRef.current / 2)),
45
+ durationMs: 250,
46
+ });
47
+ setLast({ from: pitch, to: up });
48
+ });
49
+ }, []);
50
+
51
+ return (
52
+ <Frame title="{{name}}" device={device}>
53
+ <dt>density</dt>
54
+ <dd>{density.toFixed(2)} - turn the dial in Live, or automate it</dd>
55
+
56
+ <dt>last note</dt>
57
+ <dd>{last ? `${last.from} -> ${last.to}` : "play something"}</dd>
58
+
59
+ <Transport device={device} />
60
+ </Frame>
61
+ );
62
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * protocol.ts - every selector crossing this device's bridge.
3
+ *
4
+ * The single source of truth for both sides: the app binds/emits these, and the
5
+ * generated patcher routes them. An unrouted selector produces no error at
6
+ * runtime - the message just falls on the floor - so keep them here.
7
+ *
8
+ * Most of this is spreads, not declarations. Those names belong to the library:
9
+ *
10
+ * DEVICE_IN what the wrapper sends every device (mode, build, tick, tempo)
11
+ * CHAIN_IN what the `midiin` chain sends (notein)
12
+ * CHAIN_OUT what the `midiout` chain takes (midinote, flush)
13
+ *
14
+ * Spread them rather than retyping them, so a rename in the library is a type
15
+ * error here instead of silence. What is left is what is genuinely yours.
16
+ */
17
+ import { CHAIN_IN, CHAIN_OUT, DEVICE_IN } from "@m4l-jweb/bridge";
18
+
19
+ /** Device -> UI. */
20
+ export const IN = {
21
+ ...DEVICE_IN,
22
+ ...CHAIN_IN,
23
+ /** live.dial -> UI: a parameter is just another message. */
24
+ density: "density",
25
+ } as const;
26
+
27
+ /** UI -> device. */
28
+ export const OUT = {
29
+ ...CHAIN_OUT,
30
+ /** UI -> wrapper: page ready; send me the current state. */
31
+ ui_ready: "ui_ready",
32
+ } as const;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * surface.ts - the device's Live parameters, declared as code.
3
+ *
4
+ * Push reads Live parameters, not your UI, so anything musically meaningful has
5
+ * to exist here as well as in the app.
6
+ *
7
+ * Not wired up yet: this declaration is typechecked and validated, but the
8
+ * codegen that turns it into live.* objects is not built. For now the parameters
9
+ * Live sees come from `parameters` in patcher/devices.mjs, and the two have to
10
+ * be kept in step by hand.
11
+ */
12
+ import { defineSurface, dial } from "@m4l-jweb/surface";
13
+
14
+ export default defineSurface({
15
+ params: {
16
+ density: dial({
17
+ range: [0, 1],
18
+ default: 0.5,
19
+ format: (v) => `${Math.round(v * 100)}%`,
20
+ short: "Dens", // Push has ~8 characters per encoder label
21
+ }),
22
+ },
23
+
24
+ // Push renders parameters in banks of eight. A bank is a page.
25
+ banks: [{ name: "Perform", params: ["density"] }],
26
+ });