@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/src/index.mjs ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * index.mjs - the build pipeline: wrapper -> patchers -> package.
3
+ *
4
+ * Everything here is conventional over configurable. A device repo owns exactly
5
+ * two things: `src/app/` (the web app) and `patcher/devices.mjs` (the manifest).
6
+ * Optional escape hatches:
7
+ * patcher/base.json - override the patcher template
8
+ * wrapper/device.ts - extra [js] message handlers, concatenated last
9
+ */
10
+ import archiver from "archiver";
11
+ import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
12
+ import { copyFile, rename, stat } from "node:fs/promises";
13
+ import { execFileSync } from "node:child_process";
14
+ import { createRequire } from "node:module";
15
+ import path from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
+ import { CHAINS, addParameters, resetLayout } from "./chains.mjs";
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ const templates = path.join(pkgDir, "templates");
24
+
25
+ const UI_NAME = "ui.html";
26
+
27
+ /* ------------------------------------------------------------------ *
28
+ * Step 1: the wrapper
29
+ * ------------------------------------------------------------------ */
30
+
31
+ /**
32
+ * Compile the wrapper to ONE ES5 script.
33
+ *
34
+ * Max's [js] has no module system, so @m4l-jweb/wrapper ships SOURCES, not a
35
+ * library: core.ts + liveapi.ts (+ the device's own wrapper/device.ts) are
36
+ * compiled together as one TypeScript program - so they typecheck across the
37
+ * seam and see each other's globals - and their outputs are concatenated in
38
+ * order.
39
+ */
40
+ export function buildWrapper(root) {
41
+ const { sources, types } = require("@m4l-jweb/wrapper/sources");
42
+ const deviceExt = path.join(root, "wrapper", "device.ts");
43
+ const files = [...sources, ...(existsSync(deviceExt) ? [deviceExt] : [])];
44
+
45
+ const outDir = path.join(root, "dist", "wrapper");
46
+ const tmp = path.join(root, "dist", ".wrapper-tsc");
47
+ rmSync(tmp, { recursive: true, force: true });
48
+ mkdirSync(tmp, { recursive: true });
49
+ mkdirSync(outDir, { recursive: true });
50
+
51
+ // Copy every source next to each other FIRST, then compile in place.
52
+ //
53
+ // tsc derives its output layout from the common root of its inputs. The
54
+ // packaged sources live in node_modules and the device's own device.ts lives
55
+ // in the repo, so that common root can be some ancestor of both - and the
56
+ // outputs land in a mirrored directory tree instead of flat. Staging them in
57
+ // one directory makes the output names predictable, which is what lets us
58
+ // concatenate them in order below.
59
+ const staged = files.map((f, i) => {
60
+ // Prefix with the index: order is the contract (core must precede the rest),
61
+ // and two sources could share a basename.
62
+ const dest = path.join(tmp, `${String(i).padStart(2, "0")}-${path.basename(f)}`);
63
+ writeFileSync(dest, readFileSync(f, "utf8"));
64
+ return dest;
65
+ });
66
+ const stagedTypes = path.join(tmp, path.basename(types));
67
+ writeFileSync(stagedTypes, readFileSync(types, "utf8"));
68
+
69
+ // The ES5 target is a build gate, not a style preference. `module: "none"`
70
+ // forbids imports, which is exactly the [js] constraint.
71
+ const tsconfig = path.join(tmp, "tsconfig.json");
72
+ writeFileSync(
73
+ tsconfig,
74
+ JSON.stringify({
75
+ compilerOptions: {
76
+ target: "ES5",
77
+ lib: ["ES5"],
78
+ module: "none",
79
+ outDir: tmp,
80
+ strict: true,
81
+ // At [js] global scope `this` IS the jsthis object - that is how
82
+ // `this.patcher.filepath` works.
83
+ noImplicitThis: false,
84
+ noImplicitAny: false,
85
+ skipLibCheck: true,
86
+ types: [],
87
+ },
88
+ files: [stagedTypes, ...staged],
89
+ }),
90
+ );
91
+
92
+ execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-p", tsconfig], { stdio: "inherit" });
93
+
94
+ // Concatenate the emitted scripts in source order: core's lifecycle first, the
95
+ // device's own handlers last.
96
+ const js = staged.map((f) => readFileSync(f.replace(/\.ts$/, ".js"), "utf8")).join("\n");
97
+ assertES5(js, "wrapper");
98
+
99
+ const out = path.join(outDir, "wrapper.js");
100
+ writeFileSync(out, js);
101
+ rmSync(tmp, { recursive: true, force: true });
102
+
103
+ console.log(`m4l-jweb: wrapper.js (${js.length} bytes, ES5 verified, from ${files.length} sources)`);
104
+ return out;
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ *
108
+ * Step 2: the patchers
109
+ * ------------------------------------------------------------------ */
110
+
111
+ async function readManifest(root) {
112
+ const p = path.join(root, "patcher", "devices.mjs");
113
+ if (!existsSync(p)) throw new Error("patcher/devices.mjs not found - a device repo needs a manifest");
114
+ return (await import(pathToFileURL(p).href)).default;
115
+ }
116
+
117
+ /** patcher/base.json in the device repo wins; otherwise the packaged template. */
118
+ function readBase(root) {
119
+ const local = path.join(root, "patcher", "base.json");
120
+ const src = existsSync(local) ? local : path.join(templates, "base.json");
121
+ return JSON.parse(readFileSync(src, "utf8"));
122
+ }
123
+
124
+ /**
125
+ * A device repo may add its own chains in patcher/chains.mjs - importing it is
126
+ * enough, since registerChain() mutates the shared vocabulary:
127
+ *
128
+ * import { registerChain, box, line } from "@m4l-jweb/build/chains";
129
+ * registerChain("poly", ({ boxes, lines, jwebId }) => { ... });
130
+ *
131
+ * The canned chains cover the common shapes; anything device-specific (a
132
+ * synth voice bank, a sample player, an external host) belongs here rather than
133
+ * in the library.
134
+ */
135
+ async function loadDeviceChains(root) {
136
+ const p = path.join(root, "patcher", "chains.mjs");
137
+ if (!existsSync(p)) return;
138
+ await import(pathToFileURL(p).href);
139
+ console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
140
+ }
141
+
142
+ export async function generatePatchers(root) {
143
+ const devices = await readManifest(root);
144
+ const base = readBase(root);
145
+ await loadDeviceChains(root);
146
+ const outDir = path.join(root, "dist", "patchers");
147
+ mkdirSync(outDir, { recursive: true });
148
+
149
+ for (const d of devices) {
150
+ const amxdtype = AMXD_TYPES[d.type];
151
+ if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
152
+
153
+ const p = structuredClone(base);
154
+ const { boxes, lines } = p.patcher;
155
+ p.patcher.project.amxdtype = amxdtype;
156
+ resetLayout();
157
+
158
+ // The wrapper is mode-switched by its object-box argument. `mode` defaults
159
+ // to the device type, but they are not always the same thing: a sample
160
+ // player can be an audio-effect device ("type") that the wrapper must treat
161
+ // as a sampler ("mode").
162
+ //
163
+ // jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
164
+ const mode = d.mode ?? d.type;
165
+ boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
166
+
167
+ const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
168
+
169
+ for (const name of d.chains ?? []) {
170
+ const chain = CHAINS[name];
171
+ if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
172
+ chain({ boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d });
173
+ }
174
+
175
+ // Parameters feed the UI: a knob move arrives as just another inlet message.
176
+ addParameters(boxes, lines, d.parameters ?? [], "obj-jweb");
177
+
178
+ writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
179
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"})`);
180
+ }
181
+ return devices;
182
+ }
183
+
184
+ /* ------------------------------------------------------------------ *
185
+ * Step 3: package
186
+ * ------------------------------------------------------------------ */
187
+
188
+ /**
189
+ * Assemble dist/: the single-file UI, one .amxd per manifest entry, the
190
+ * installers, and a release zip.
191
+ *
192
+ * Each .amxd is self-contained - the UI travels inside it as a base64 payload in
193
+ * wrapper.js. The loose ui.html/wrapper.js are for inspection, not a runtime
194
+ * requirement.
195
+ */
196
+ export async function packageDevices(root) {
197
+ const dist = path.join(root, "dist");
198
+ const { name, version } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
199
+ const devices = await readManifest(root);
200
+
201
+ const outDir = path.join(dist, name);
202
+ mkdirSync(outDir, { recursive: true });
203
+
204
+ // vite emits dist/index.html (everything inlined by vite-plugin-singlefile).
205
+ const uiPath = path.join(outDir, UI_NAME);
206
+ const viteOut = path.join(dist, "index.html");
207
+ if (existsSync(viteOut)) await rename(viteOut, uiPath);
208
+ if (!existsSync(uiPath)) throw new Error(`no UI at ${uiPath} - run \`vite build\` first`);
209
+
210
+ const uiHtml = readFileSync(uiPath);
211
+ const wrapperJs = readFileSync(path.join(dist, "wrapper", "wrapper.js"), "utf8");
212
+
213
+ // The build stamp is what makes a stale install visible: the wrapper posts it
214
+ // and the UI renders it. Live embeds a copy of the device in the set, so an
215
+ // instance already on a track does NOT update when you reinstall.
216
+ const stamp = `${version} ${new Date().toISOString()}`;
217
+ const banner = `var BUILD_STAMP = ${JSON.stringify(stamp)};\n`;
218
+
219
+ for (const d of devices) {
220
+ const deviceName = `${d.name}.amxd`;
221
+
222
+ /**
223
+ * Payloads ride inside wrapper.js as base64 and are written to real files
224
+ * next to the .amxd on first load, because Chromium and any external
225
+ * process are blind to Max's frozen virtual filesystem. The UI is always
226
+ * one; a device can declare more (`payloads: ["dist/foo.cjs"]`).
227
+ */
228
+ let wrapperData = banner + wrapperJs + payloadJs("UI_PAYLOAD", UI_NAME, uiHtml);
229
+ const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
230
+ if (payloads.length) wrapperData += extraPayloadsJs(payloads);
231
+
232
+ const amxd = buildAmxd({
233
+ patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
234
+ wrapperJs: wrapperData,
235
+ deviceName,
236
+ // Frozen dependencies: readable by Max-native objects only (a poly~
237
+ // voice patcher, say), which is exactly why they can stay frozen.
238
+ extras: (d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })),
239
+ });
240
+ writeFileSync(path.join(outDir, deviceName), amxd);
241
+ console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
242
+ }
243
+
244
+ await copyFile(path.join(dist, "wrapper", "wrapper.js"), path.join(outDir, "wrapper.js"));
245
+
246
+ /**
247
+ * Loose files sit NEXT TO the .amxd in the installed folder, as real files.
248
+ *
249
+ * Needed when a Max object resolves a filename when it INSTANTIATES - before
250
+ * the wrapper has run and before it could have extracted anything. Such an
251
+ * object cannot be repointed at runtime, so the file has to be on disk under
252
+ * exactly the name the object was created with. The embedded payload of the
253
+ * same file is then only a fallback for a bare .amxd copied on its own.
254
+ */
255
+ const loose = [...new Set(devices.flatMap((d) => d.looseFiles ?? []))];
256
+ for (const f of loose) {
257
+ await copyFile(path.join(root, f), path.join(outDir, path.basename(f)));
258
+ console.log(`m4l-jweb: ${path.basename(f)} -> dist/${name}/ (loose)`);
259
+ }
260
+
261
+ // Installers go next to the devices so `dist/install-*.ps1` just works.
262
+ const installers = ["install-windows.ps1", "install-mac.sh"];
263
+ for (const f of installers) await copyFile(path.join(templates, f), path.join(dist, f));
264
+
265
+ const zipPath = path.join(dist, `${name}.zip`);
266
+ await new Promise((resolve, reject) => {
267
+ const output = createWriteStream(zipPath);
268
+ const archive = archiver("zip", { zlib: { level: 9 } });
269
+ output.on("close", resolve);
270
+ archive.on("error", reject);
271
+ archive.pipe(output);
272
+ const files = [...devices.map((d) => `${d.name}.amxd`), ...loose.map((f) => path.basename(f)), "wrapper.js", UI_NAME];
273
+ for (const f of files) {
274
+ archive.append(createReadStream(path.join(outDir, f)), { name: `${name}/${f}` });
275
+ }
276
+ for (const f of installers) {
277
+ archive.file(path.join(templates, f), { name: f, mode: 0o755 });
278
+ }
279
+ archive.finalize();
280
+ });
281
+
282
+ const { size } = await stat(zipPath);
283
+ console.log(`m4l-jweb: dist/${name}.zip (${size} bytes)`);
284
+ }
285
+
286
+ export async function buildAll(root) {
287
+ buildWrapper(root);
288
+ await generatePatchers(root);
289
+ await packageDevices(root);
290
+ }
291
+
292
+ /* ------------------------------------------------------------------ *
293
+ * Install
294
+ *
295
+ * Copy the built devices into Ableton's User Library. The per-platform scripts
296
+ * are the real implementation (they have to read Live's own config files to find
297
+ * the library); this just picks the right one and passes the device name.
298
+ *
299
+ * Live has no Linux build, so there is nothing to install there.
300
+ * ------------------------------------------------------------------ */
301
+ export async function installDevices(root) {
302
+ const { name } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
303
+
304
+ if (!existsSync(path.join(root, "dist", name))) {
305
+ throw new Error(`nothing built at dist/${name} - run \`pnpm build\` first`);
306
+ }
307
+
308
+ // The packaged scripts are the real implementation - they have to read Live's
309
+ // own config files to locate the User Library. Pass the device name and the
310
+ // built folder explicitly, since the script does not live in the repo.
311
+ const src = path.join(root, "dist", name);
312
+ const runners = {
313
+ win32: [
314
+ "powershell",
315
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(templates, "install-windows.ps1"), "-DeviceName", name, "-Src", src],
316
+ ],
317
+ darwin: ["bash", [path.join(templates, "install-mac.sh"), name, src]],
318
+ };
319
+ const runner = runners[process.platform];
320
+ if (!runner) {
321
+ throw new Error(`no installer for ${process.platform} - Ableton Live runs on macOS and Windows only`);
322
+ }
323
+
324
+ execFileSync(runner[0], runner[1], { stdio: "inherit", cwd: root });
325
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * init.mjs - `m4l-jweb init [dir] [--name <name>]`: scaffold a new device repo.
3
+ *
4
+ * The template lives in templates/starter/ and is not a hand-maintained copy:
5
+ * it mirrors this repo's own root app (src/app/, patcher/devices.mjs, the
6
+ * config files), which is itself a working hello-world device built on
7
+ * @m4l-jweb/bridge and @m4l-jweb/build. Keeping the template that close to a
8
+ * real, CI-built app is what keeps it from drifting out of sync with the
9
+ * library - change the shape of a real device, then port the same change
10
+ * into templates/starter/ in the same commit.
11
+ */
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const starter = path.join(pkgDir, "templates", "starter");
18
+
19
+ function walk(dir) {
20
+ const out = [];
21
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
+ const p = path.join(dir, entry.name);
23
+ if (entry.isDirectory()) out.push(...walk(p));
24
+ else out.push(p);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ export async function initProject(cwd, args = []) {
30
+ const positional = args.filter((a) => !a.startsWith("--"));
31
+ const nameFlagIndex = args.indexOf("--name");
32
+ const target = positional[0] ? path.resolve(cwd, positional[0]) : cwd;
33
+ const name = nameFlagIndex >= 0 ? args[nameFlagIndex + 1] : path.basename(target);
34
+
35
+ if (existsSync(target) && readdirSync(target).length > 0) {
36
+ throw new Error(`${target} is not empty - init needs an empty (or new) directory`);
37
+ }
38
+ mkdirSync(target, { recursive: true });
39
+
40
+ const files = walk(starter);
41
+ for (const src of files) {
42
+ const rel = path.relative(starter, src);
43
+ const dest = path.join(target, rel);
44
+ mkdirSync(path.dirname(dest), { recursive: true });
45
+ const contents = readFileSync(src, "utf8").replaceAll("{{name}}", name);
46
+ writeFileSync(dest, contents);
47
+ }
48
+
49
+ console.log(`m4l-jweb: scaffolded "${name}" at ${target} (${files.length} files)`);
50
+ console.log(`m4l-jweb: next steps:\n cd ${path.relative(cwd, target) || "."}\n pnpm install\n pnpm dev`);
51
+ return target;
52
+ }
@@ -0,0 +1,120 @@
1
+ {
2
+ "patcher": {
3
+ "fileversion": 1,
4
+ "appversion": {
5
+ "major": 9,
6
+ "minor": 1,
7
+ "revision": 4,
8
+ "architecture": "x64",
9
+ "modernui": 1
10
+ },
11
+ "classnamespace": "box",
12
+ "rect": [100.0, 100.0, 700.0, 360.0],
13
+ "openinpresentation": 1,
14
+ "openrect": [0.0, 0.0, 0.0, 169.0],
15
+ "openrectmode": 0,
16
+ "default_fontsize": 10.0,
17
+ "default_fontname": "Arial Bold",
18
+ "gridsize": [8.0, 8.0],
19
+ "boxanimatetime": 500,
20
+ "boxes": [
21
+ {
22
+ "box": {
23
+ "id": "obj-midiin",
24
+ "maxclass": "newobj",
25
+ "numinlets": 1,
26
+ "numoutlets": 1,
27
+ "outlettype": ["int"],
28
+ "patching_rect": [480.0, 24.0, 40.0, 20.0],
29
+ "text": "midiin"
30
+ }
31
+ },
32
+ {
33
+ "box": {
34
+ "id": "obj-midiout",
35
+ "maxclass": "newobj",
36
+ "numinlets": 1,
37
+ "numoutlets": 0,
38
+ "patching_rect": [480.0, 64.0, 47.0, 20.0],
39
+ "text": "midiout"
40
+ }
41
+ },
42
+ {
43
+ "box": {
44
+ "id": "obj-thisdevice",
45
+ "maxclass": "newobj",
46
+ "numinlets": 1,
47
+ "numoutlets": 3,
48
+ "outlettype": ["bang", "int", "int"],
49
+ "patching_rect": [16.0, 24.0, 71.0, 20.0],
50
+ "text": "live.thisdevice"
51
+ }
52
+ },
53
+ {
54
+ "box": {
55
+ "id": "obj-js",
56
+ "maxclass": "newobj",
57
+ "numinlets": 1,
58
+ "numoutlets": 2,
59
+ "outlettype": ["", ""],
60
+ "patching_rect": [16.0, 64.0, 78.0, 20.0],
61
+ "saved_object_attributes": {
62
+ "filename": "wrapper.js",
63
+ "parameter_enable": 0
64
+ },
65
+ "text": "js wrapper.js"
66
+ }
67
+ },
68
+ {
69
+ "box": {
70
+ "disablefind": 0,
71
+ "id": "obj-jweb",
72
+ "maxclass": "jweb",
73
+ "numinlets": 1,
74
+ "numoutlets": 1,
75
+ "outlettype": [""],
76
+ "patching_rect": [16.0, 104.0, 400.0, 169.0],
77
+ "presentation": 1,
78
+ "presentation_rect": [0.0, 0.0, 420.0, 169.0],
79
+ "rendermode": 1
80
+ }
81
+ }
82
+ ],
83
+ "lines": [
84
+ { "patchline": { "destination": ["obj-midiout", 0], "source": ["obj-midiin", 0] } },
85
+ { "patchline": { "destination": ["obj-js", 0], "source": ["obj-thisdevice", 0] } },
86
+ { "patchline": { "destination": ["obj-jweb", 0], "source": ["obj-js", 0] } },
87
+ { "patchline": { "destination": ["obj-js", 0], "source": ["obj-jweb", 0] } }
88
+ ],
89
+ "latency": 0,
90
+ "is_mpe": 0,
91
+ "external_mpe_tuning_enabled": 0,
92
+ "minimum_live_version": "",
93
+ "minimum_max_version": "",
94
+ "platform_compatibility": 0,
95
+ "project": {
96
+ "version": 1,
97
+ "creationdate": 3864900000,
98
+ "modificationdate": 3864900000,
99
+ "viewrect": [0.0, 0.0, 300.0, 500.0],
100
+ "autoorganize": 1,
101
+ "hideprojectwindow": 1,
102
+ "showdependencies": 1,
103
+ "autolocalize": 0,
104
+ "contents": {
105
+ "patchers": {}
106
+ },
107
+ "layout": {},
108
+ "searchpath": {},
109
+ "detailsvisible": 0,
110
+ "amxdtype": 1835887981,
111
+ "readonly": 0,
112
+ "devpathtype": 0,
113
+ "devpath": ".",
114
+ "sortmode": 0,
115
+ "viewmode": 0,
116
+ "includepackages": 0
117
+ },
118
+ "autosave": 0
119
+ }
120
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # install-mac.sh - copy the built devices into the Ableton User Library
3
+ # (Max For Live/m4l-jweb), replacing any previous install.
4
+ #
5
+ # The User Library path is read from the newest Live preferences file
6
+ # (~/Library/Preferences/Ableton/Live */Library.cfg, <ProjectPath>); Live's
7
+ # default location is the fallback.
8
+ #
9
+ # The device-folder name defaults to this repo's, and `m4l-jweb install` passes
10
+ # the package name explicitly - so a repo scaffolded under another name works.
11
+ #
12
+ # usage: install-mac.sh [device-name] [src-dir]
13
+ # `m4l-jweb install` passes both; standalone (from the zip) both are inferred.
14
+ set -euo pipefail
15
+ device_name="${1:-m4l-jweb}"
16
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+
18
+ # Source: an explicit second argument, else ./<name> next to this script (zip and
19
+ # dist layouts), else ../dist/<name> (running it straight from a repo checkout).
20
+ src="${2:-}"
21
+ if [ -z "$src" ]; then
22
+ src="$here/$device_name"
23
+ [ -d "$src" ] || src="$(dirname "$here")/dist/$device_name"
24
+ fi
25
+ if ! compgen -G "$src/*.amxd" > /dev/null; then
26
+ echo "No .amxd found next to this script or in dist/. Run 'pnpm build' first." >&2
27
+ exit 1
28
+ fi
29
+
30
+ user_lib=""
31
+ cfg="$(ls -t ~/Library/Preferences/Ableton/Live\ */Library.cfg 2>/dev/null | head -1 || true)"
32
+ if [ -n "$cfg" ]; then
33
+ p="$(sed -n 's/.*<ProjectPath Value="\([^"]*\)".*/\1/p' "$cfg" | head -1)"
34
+ if [ -n "$p" ] && [ -d "$p/User Library" ]; then
35
+ user_lib="$p/User Library"
36
+ elif [ -n "$p" ] && [ -d "$p" ]; then
37
+ user_lib="$p"
38
+ fi
39
+ fi
40
+ [ -n "$user_lib" ] || user_lib="$HOME/Music/Ableton/User Library"
41
+ if [ ! -d "$user_lib" ]; then
42
+ echo "Ableton User Library not found ($user_lib). Is Live installed?" >&2
43
+ exit 1
44
+ fi
45
+
46
+ dest="$user_lib/Max For Live/$device_name"
47
+ rm -rf "$dest"
48
+ mkdir -p "$dest"
49
+
50
+ # Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
51
+ for f in "$src"/*.amxd; do
52
+ cp "$f" "$dest/"
53
+ echo " installed $(basename "$f")"
54
+ done
55
+
56
+ echo "Installed to $dest"
57
+ echo "In Live: User Library > Max For Live > $device_name"
58
+ echo "NOTE: Live embeds a copy of the device in the set. Instances already"
59
+ echo " on a track will NOT update - delete and re-drag them."
@@ -0,0 +1,64 @@
1
+ # install-windows.ps1 - copy the built devices into the Ableton User Library
2
+ # (Max For Live\m4l-jweb), replacing any previous install.
3
+ #
4
+ # The User Library path is read from the newest Live preferences file
5
+ # (%APPDATA%\Ableton\Live <version>\Preferences\Library.cfg, <ProjectPath>);
6
+ # Live's default location is the fallback. No registry or env vars are involved -
7
+ # Live keeps all of this in plain config files.
8
+ #
9
+ # The device-folder name defaults to this repo's, and `m4l-jweb install` passes
10
+ # the package name explicitly - so a repo scaffolded under another name works.
11
+ # -Src is passed by `m4l-jweb install`; standalone (from the zip) it is found
12
+ # next to this script.
13
+ param([string]$DeviceName = "m4l-jweb", [string]$Src = "")
14
+ $ErrorActionPreference = "Stop"
15
+ $deviceName = $DeviceName
16
+
17
+ # Source: an explicit -Src, else ./<name> next to this script (zip and dist
18
+ # layouts), else ../dist/<name> (running it straight from a repo checkout).
19
+ $src = $Src
20
+ if (-not $src) {
21
+ $src = Join-Path $PSScriptRoot $deviceName
22
+ if (-not (Test-Path $src)) {
23
+ $src = Join-Path (Split-Path $PSScriptRoot) "dist\$deviceName"
24
+ }
25
+ }
26
+ $devices = @(Get-ChildItem (Join-Path $src "*.amxd") -ErrorAction SilentlyContinue)
27
+ if ($devices.Count -eq 0) {
28
+ Write-Error "No .amxd found next to this script or in dist\. Run 'pnpm build' first."
29
+ }
30
+
31
+ # User Library: newest Library.cfg wins.
32
+ $userLib = $null
33
+ $cfg = Get-ChildItem "$env:APPDATA\Ableton\Live *\Preferences\Library.cfg" -ErrorAction SilentlyContinue |
34
+ Sort-Object LastWriteTime -Descending | Select-Object -First 1
35
+ if ($cfg) {
36
+ $m = [regex]::Match((Get-Content $cfg.FullName -Raw), '<ProjectPath Value="([^"]+)"')
37
+ if ($m.Success) {
38
+ $p = $m.Groups[1].Value -replace "/", "\"
39
+ # ProjectPath may point at the library root that contains "User Library".
40
+ if (Test-Path (Join-Path $p "User Library")) { $userLib = Join-Path $p "User Library" }
41
+ elseif (Test-Path $p) { $userLib = $p }
42
+ }
43
+ }
44
+ if (-not $userLib) {
45
+ $userLib = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "Ableton\User Library"
46
+ }
47
+ if (-not (Test-Path $userLib)) {
48
+ Write-Error "Ableton User Library not found ($userLib). Is Live installed?"
49
+ }
50
+
51
+ $dest = Join-Path $userLib "Max For Live\$deviceName"
52
+ if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
53
+ New-Item -ItemType Directory -Force $dest | Out-Null
54
+
55
+ # Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
56
+ foreach ($f in $devices) {
57
+ Copy-Item $f.FullName $dest -Force
58
+ Write-Host " installed $($f.Name)"
59
+ }
60
+
61
+ Write-Host "Installed to $dest"
62
+ Write-Host "In Live: User Library > Max For Live > $deviceName"
63
+ Write-Host "NOTE: Live embeds a copy of the device in the set. Instances already"
64
+ Write-Host " on a track will NOT update - delete and re-drag them."
@@ -0,0 +1,21 @@
1
+ # {{name}}
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.
6
+
7
+ ```bash
8
+ 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
13
+ ```
14
+
15
+ You edit two places:
16
+
17
+ - `src/app/` - the web app (UI, optional worker, `protocol.ts`).
18
+ - `patcher/devices.mjs` - the device manifest (name, type, chains, parameters).
19
+
20
+ Everything else (`@m4l-jweb/wrapper`, `@m4l-jweb/build`) is packaged
21
+ infrastructure you should rarely need to touch.
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{name}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>