@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
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>
|