@m4l-jweb/build 0.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/bin/m4l-jweb.mjs +40 -0
- package/package.json +34 -0
- package/src/amxd.mjs +178 -0
- package/src/chains.mjs +184 -0
- package/src/index.mjs +325 -0
- package/src/init.mjs +52 -0
- package/templates/base.json +120 -0
- package/templates/install-mac.sh +59 -0
- package/templates/install-windows.ps1 +64 -0
- package/templates/starter/README.md +21 -0
- package/templates/starter/index.html +12 -0
- package/templates/starter/package.json +36 -0
- package/templates/starter/patcher/devices.mjs +32 -0
- package/templates/starter/src/app/App.tsx +87 -0
- package/templates/starter/src/app/protocol.ts +32 -0
- package/templates/starter/src/app/worker.ts +26 -0
- package/templates/starter/src/index.css +104 -0
- package/templates/starter/src/main.tsx +10 -0
- package/templates/starter/src/vite-env.d.ts +9 -0
- package/templates/starter/tsconfig.app.json +24 -0
- package/templates/starter/tsconfig.json +4 -0
- package/templates/starter/tsconfig.node.json +15 -0
- package/templates/starter/vite.config.ts +34 -0
- package/templates/starter/vitest.config.ts +12 -0
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* Fields
|
|
7
|
+
* name output basename -> dist/<pkg>/<name>.amxd
|
|
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, ...).
|
|
23
|
+
*/
|
|
24
|
+
export default [
|
|
25
|
+
{
|
|
26
|
+
name: "{{name}}",
|
|
27
|
+
type: "midi",
|
|
28
|
+
chains: ["midiout"],
|
|
29
|
+
parameters: [{ id: "running", object: "live.toggle" }],
|
|
30
|
+
unmatchedTo: "js",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App.tsx - the jweb UI. A hello-world React page wired to the Max bridge.
|
|
3
|
+
*
|
|
4
|
+
* It demonstrates the whole device loop end to end:
|
|
5
|
+
* - announce `ui_ready` on mount and show the state the wrapper replies with
|
|
6
|
+
* (mode, build stamp, tempo, transport ticks);
|
|
7
|
+
* - forward each transport tick into an optional Web Worker and render the
|
|
8
|
+
* count the worker sends back;
|
|
9
|
+
* - flag a stale install when the wrapper's build stamp does not match the
|
|
10
|
+
* one baked into this page.
|
|
11
|
+
*
|
|
12
|
+
* Replace the body with your device UI. The bridge surface never changes.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState } from "react";
|
|
15
|
+
import { bindInlet, inJweb, uiReady } from "@m4l-jweb/bridge";
|
|
16
|
+
import { IN } from "./protocol";
|
|
17
|
+
import DemoWorker from "./worker.ts?worker&inline";
|
|
18
|
+
|
|
19
|
+
declare const __APP_VERSION__: string;
|
|
20
|
+
|
|
21
|
+
export default function App() {
|
|
22
|
+
const [mode, setMode] = useState("dev");
|
|
23
|
+
const [wrapperBuild, setWrapperBuild] = useState<string | null>(null);
|
|
24
|
+
const [tempo, setTempo] = useState<number | null>(null);
|
|
25
|
+
const [playing, setPlaying] = useState(false);
|
|
26
|
+
const [beats, setBeats] = useState(0);
|
|
27
|
+
const [workerTicks, setWorkerTicks] = useState(0);
|
|
28
|
+
const workerRef = useRef<Worker | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const worker = new DemoWorker();
|
|
32
|
+
worker.onmessage = (e: MessageEvent) => {
|
|
33
|
+
const [type, value] = e.data as [string, number];
|
|
34
|
+
if (type === "ticks") setWorkerTicks(value);
|
|
35
|
+
};
|
|
36
|
+
workerRef.current = worker;
|
|
37
|
+
|
|
38
|
+
bindInlet(IN.mode, (m) => setMode(String(m)));
|
|
39
|
+
bindInlet(IN.build, (b) => setWrapperBuild(String(b)));
|
|
40
|
+
bindInlet(IN.tempo, (bpm) => setTempo(Number(bpm)));
|
|
41
|
+
bindInlet(IN.tick, (isPlaying, position) => {
|
|
42
|
+
setPlaying(Number(isPlaying) === 1);
|
|
43
|
+
setBeats(Number(position));
|
|
44
|
+
worker.postMessage(["tick"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Handshake: the page loads asynchronously, so never assume the wrapper
|
|
48
|
+
// already sent state - announce readiness and let it reply.
|
|
49
|
+
uiReady();
|
|
50
|
+
|
|
51
|
+
return () => worker.terminate();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// The wrapper stamp is "<version> <iso date>"; the UI only bakes in the
|
|
55
|
+
// version. A mismatch means a mixed install: Live embeds a copy of the
|
|
56
|
+
// device in the set, so a reinstalled .amxd does NOT update instances
|
|
57
|
+
// already on tracks.
|
|
58
|
+
const stale = wrapperBuild !== null && wrapperBuild.split(" ")[0] !== __APP_VERSION__;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<main className="device">
|
|
62
|
+
<header>
|
|
63
|
+
<h1>{{name}}</h1>
|
|
64
|
+
<span className={`badge ${inJweb ? "live" : "dev"}`}>{inJweb ? "in Max" : "browser dev"}</span>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
<dl>
|
|
68
|
+
<dt>mode</dt>
|
|
69
|
+
<dd>{mode}</dd>
|
|
70
|
+
<dt>tempo</dt>
|
|
71
|
+
<dd>{tempo === null ? "-" : `${tempo.toFixed(1)} BPM`}</dd>
|
|
72
|
+
<dt>transport</dt>
|
|
73
|
+
<dd>
|
|
74
|
+
<span className={playing ? "dot on" : "dot"} /> {playing ? "playing" : "stopped"} @ {beats.toFixed(2)} beats
|
|
75
|
+
</dd>
|
|
76
|
+
<dt>worker ticks</dt>
|
|
77
|
+
<dd>{workerTicks}</dd>
|
|
78
|
+
</dl>
|
|
79
|
+
|
|
80
|
+
<footer>
|
|
81
|
+
<span>ui {__APP_VERSION__}</span>
|
|
82
|
+
<span>wrapper {wrapperBuild ?? "-"}</span>
|
|
83
|
+
{stale && <span className="warn">stale install - delete and re-drag the device</span>}
|
|
84
|
+
</footer>
|
|
85
|
+
</main>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.ts - the typed list of selectors crossing the UI <-> device bridge.
|
|
3
|
+
*
|
|
4
|
+
* This is the single source of truth for BOTH sides of the bridge:
|
|
5
|
+
* - the web app binds/emits these selectors (via @m4l-jweb/bridge)
|
|
6
|
+
* - the [js] wrapper and the generated patcher route these selectors
|
|
7
|
+
*
|
|
8
|
+
* A Max message is a selector word followed by arguments
|
|
9
|
+
* (e.g. `tick 1 12.5`). Keep selectors here so a CI lint can assert every one
|
|
10
|
+
* appears in a route or handler on the patcher/wrapper side.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Selectors the DEVICE sends INTO the UI (bindInlet these). */
|
|
14
|
+
export const IN = {
|
|
15
|
+
/** wrapper -> UI: current run mode (midi | audio | instrument). */
|
|
16
|
+
mode: "mode",
|
|
17
|
+
/** wrapper -> UI: build stamp, for the stale-install check. */
|
|
18
|
+
build: "build",
|
|
19
|
+
/** wrapper -> UI: transport state. args: `<playing 0|1> <beats>`. */
|
|
20
|
+
tick: "tick",
|
|
21
|
+
/** wrapper -> UI: Live tempo in BPM. args: `<bpm>`. */
|
|
22
|
+
tempo: "tempo",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** Selectors the UI sends OUT to the device (outlet these). */
|
|
26
|
+
export const OUT = {
|
|
27
|
+
/** UI -> wrapper: page finished loading; reply with current state. */
|
|
28
|
+
ui_ready: "ui_ready",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export type InSelector = (typeof IN)[keyof typeof IN];
|
|
32
|
+
export type OutSelector = (typeof OUT)[keyof typeof OUT];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker.ts - optional compute worker (inlined into the single-file build).
|
|
3
|
+
*
|
|
4
|
+
* The place for anything that must not fight the UI thread: sequencers,
|
|
5
|
+
* analyzers, DSP-adjacent math. Dedicated workers are also exempt from the
|
|
6
|
+
* timer throttling Chromium applies to hidden pages - and a device's view is
|
|
7
|
+
* often not visible.
|
|
8
|
+
*
|
|
9
|
+
* This hello-world worker just counts the transport ticks it is fed and posts
|
|
10
|
+
* the running total back. Replace it with your own message-driven engine
|
|
11
|
+
* (Live pushes time in, you push events out) and delete it if you do not need
|
|
12
|
+
* a worker at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let ticks = 0;
|
|
16
|
+
|
|
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
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The Live device view is a FIXED height (about 169 px) - budget every row.
|
|
3
|
+
* An overgrown layout does not scroll, it silently clips at the bottom.
|
|
4
|
+
*/
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #2f2f2f;
|
|
7
|
+
--fg: #d6d6d6;
|
|
8
|
+
--muted: #8a8a8a;
|
|
9
|
+
--accent: #6fd6a8;
|
|
10
|
+
--warn: #e8a33d;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
* {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
color: var(--fg);
|
|
22
|
+
font: 11px/1.4 "Segoe UI", system-ui, sans-serif;
|
|
23
|
+
height: 169px;
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.device {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
gap: 6px;
|
|
31
|
+
height: 100%;
|
|
32
|
+
padding: 8px 10px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
header {
|
|
36
|
+
align-items: center;
|
|
37
|
+
border-bottom: 1px solid #444;
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 8px;
|
|
40
|
+
padding-bottom: 5px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
h1 {
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
letter-spacing: 0.08em;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.badge {
|
|
49
|
+
border-radius: 3px;
|
|
50
|
+
font-size: 9px;
|
|
51
|
+
padding: 1px 5px;
|
|
52
|
+
text-transform: uppercase;
|
|
53
|
+
}
|
|
54
|
+
.badge.live {
|
|
55
|
+
background: var(--accent);
|
|
56
|
+
color: #1a1a1a;
|
|
57
|
+
}
|
|
58
|
+
.badge.dev {
|
|
59
|
+
background: #555;
|
|
60
|
+
color: var(--fg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dl {
|
|
64
|
+
column-gap: 10px;
|
|
65
|
+
display: grid;
|
|
66
|
+
grid-template-columns: auto 1fr;
|
|
67
|
+
row-gap: 3px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dt {
|
|
71
|
+
color: var(--muted);
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
font-size: 9px;
|
|
74
|
+
align-self: center;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dd {
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.dot {
|
|
82
|
+
background: #666;
|
|
83
|
+
border-radius: 50%;
|
|
84
|
+
display: inline-block;
|
|
85
|
+
height: 6px;
|
|
86
|
+
width: 6px;
|
|
87
|
+
}
|
|
88
|
+
.dot.on {
|
|
89
|
+
background: var(--accent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
footer {
|
|
93
|
+
border-top: 1px solid #444;
|
|
94
|
+
color: var(--muted);
|
|
95
|
+
display: flex;
|
|
96
|
+
font-size: 9px;
|
|
97
|
+
gap: 10px;
|
|
98
|
+
margin-top: auto;
|
|
99
|
+
padding-top: 5px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.warn {
|
|
103
|
+
color: var(--warn);
|
|
104
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// Injected by vite's `define` (see vite.config.ts) - the UI's own build stamp.
|
|
4
|
+
declare const __APP_VERSION__: string;
|
|
5
|
+
|
|
6
|
+
declare module "*?worker&inline" {
|
|
7
|
+
const workerConstructor: new () => Worker;
|
|
8
|
+
export default workerConstructor;
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"paths": {
|
|
12
|
+
"@/*": ["./src/*"]
|
|
13
|
+
},
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"moduleDetection": "force",
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"moduleDetection": "force",
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["vite.config.ts", "patcher/**/*.mjs"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import { fileURLToPath, URL } from "node:url";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { viteSingleFile } from "vite-plugin-singlefile";
|
|
5
|
+
import pkg from "./package.json";
|
|
6
|
+
|
|
7
|
+
// The device UI is bundled into ONE self-contained index.html (every script,
|
|
8
|
+
// style and asset inlined) so it can be embedded in the .amxd as a base64
|
|
9
|
+
// payload and extracted to a real file:// path that jweb (Chromium) reads.
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
base: "./",
|
|
12
|
+
plugins: [react(), viteSingleFile()],
|
|
13
|
+
resolve: {
|
|
14
|
+
alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
|
|
15
|
+
},
|
|
16
|
+
define: {
|
|
17
|
+
__APP_VERSION__: JSON.stringify(pkg.version),
|
|
18
|
+
},
|
|
19
|
+
// A compute worker (src/app/worker.ts) is inlined into the single chunk:
|
|
20
|
+
// a ?worker&inline blob URL cannot resolve relative chunk imports at
|
|
21
|
+
// runtime, so bundle dynamic imports in too.
|
|
22
|
+
worker: {
|
|
23
|
+
format: "es",
|
|
24
|
+
rollupOptions: {
|
|
25
|
+
output: {
|
|
26
|
+
inlineDynamicImports: true,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
build: {
|
|
31
|
+
outDir: "dist",
|
|
32
|
+
emptyOutDir: true,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import { fileURLToPath, URL } from "node:url";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
|
|
7
|
+
},
|
|
8
|
+
test: {
|
|
9
|
+
include: ["src/**/*.test.{ts,tsx}"],
|
|
10
|
+
environment: "node",
|
|
11
|
+
},
|
|
12
|
+
});
|