@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/bin/m4l-jweb.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* m4l-jweb - the device build CLI.
|
|
4
|
+
*
|
|
5
|
+
* m4l-jweb init scaffold a new device repo from templates/starter/
|
|
6
|
+
* m4l-jweb build wrapper + patchers + package (the usual one)
|
|
7
|
+
* m4l-jweb wrapper compile wrapper/*.ts to one ES5 script, acorn-gated
|
|
8
|
+
* m4l-jweb patchers manifest -> one patcher JSON per device
|
|
9
|
+
* m4l-jweb package write each .amxd + the release zip
|
|
10
|
+
* m4l-jweb install copy the built devices into Ableton's User Library
|
|
11
|
+
*
|
|
12
|
+
* Run from a device repo. Bundling the UI (`vite build`) is the app's job and
|
|
13
|
+
* stays in the repo's own npm scripts; everything Max-shaped lives here.
|
|
14
|
+
*/
|
|
15
|
+
import { buildAll, buildWrapper, generatePatchers, installDevices, packageDevices } from "../src/index.mjs";
|
|
16
|
+
import { initProject } from "../src/init.mjs";
|
|
17
|
+
|
|
18
|
+
const commands = {
|
|
19
|
+
init: (root, args) => initProject(root, args),
|
|
20
|
+
build: buildAll,
|
|
21
|
+
wrapper: async (root) => void buildWrapper(root),
|
|
22
|
+
patchers: async (root) => void (await generatePatchers(root)),
|
|
23
|
+
package: packageDevices,
|
|
24
|
+
install: installDevices,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const cmd = process.argv[2] ?? "build";
|
|
28
|
+
const run = commands[cmd];
|
|
29
|
+
|
|
30
|
+
if (!run) {
|
|
31
|
+
console.error(`m4l-jweb: unknown command "${cmd}"\nusage: m4l-jweb [${Object.keys(commands).join(" | ")}]`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await run(process.cwd(), process.argv.slice(3));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(`m4l-jweb: ${e.message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m4l-jweb/build",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alienmind/m4l-jweb.git",
|
|
10
|
+
"directory": "packages/build"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"m4l-jweb": "./bin/m4l-jweb.mjs"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.mjs",
|
|
20
|
+
"./chains": "./src/chains.mjs",
|
|
21
|
+
"./amxd": "./src/amxd.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
"templates"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"acorn": "^8.17.0",
|
|
30
|
+
"archiver": "^7.0.1",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"@m4l-jweb/wrapper": "0.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/amxd.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* amxd.mjs - write a Max for Live .amxd container, headless. No Max in the loop.
|
|
3
|
+
*
|
|
4
|
+
* The format is undocumented but simple, reverse-engineered from devices saved
|
|
5
|
+
* by Max 8/9:
|
|
6
|
+
*
|
|
7
|
+
* 'ampf' <u32le 4> <'aaaa'|'mmmm'|'iiii'> audio / midi / instrument
|
|
8
|
+
* 'meta' <u32le 4> <u32le 7>
|
|
9
|
+
* 'ptch' <u32le size-to-EOF>
|
|
10
|
+
* 'mx@c' <u32be 16> <u32be 0> <u32be 16 + payload sizes (excl. dlst)>
|
|
11
|
+
* <patcher JSON> <dependency payloads...>
|
|
12
|
+
* 'dlst' <u32be chunk size incl. header> directory: one 'dire' per file
|
|
13
|
+
*
|
|
14
|
+
* The device is written "frozen", with the wrapper embedded (the same shape Max
|
|
15
|
+
* produces when you freeze), so Max never resolves it via a search path. The
|
|
16
|
+
* internal main-patcher name is set to the output file's basename.
|
|
17
|
+
*
|
|
18
|
+
* This is the piece that removes Max from the loop entirely.
|
|
19
|
+
*/
|
|
20
|
+
import { parse } from "acorn";
|
|
21
|
+
|
|
22
|
+
/** 'aaaa' audio effect / 'mmmm' MIDI effect / 'iiii' instrument. */
|
|
23
|
+
export const AMXD_TYPES = {
|
|
24
|
+
midi: 0x6d6d6d6d,
|
|
25
|
+
audio: 0x61616161,
|
|
26
|
+
instrument: 0x69696969,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TAG_BY_CODE = { 0x61616161: "aaaa", 0x6d6d6d6d: "mmmm", 0x69696969: "iiii" };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Max's [js] is an ES5-era interpreter: one modern token (an arrow function, a
|
|
33
|
+
* `const`, a trailing comma in a call) kills the whole script at load with a
|
|
34
|
+
* bare "syntax error" and no stack. So ES5 is a build GATE, not a style
|
|
35
|
+
* preference. Call this on the exact bytes that will ship.
|
|
36
|
+
*/
|
|
37
|
+
export function assertES5(js, label = "wrapper") {
|
|
38
|
+
try {
|
|
39
|
+
parse(js, { ecmaVersion: 5 });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
throw new Error(`${label} is not valid ES5: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Encode a Buffer as base64 chunks assigned to `<prefix>_B64`, plus its name and
|
|
47
|
+
* byte count, as ES5 source appended to the wrapper.
|
|
48
|
+
*
|
|
49
|
+
* Why: Chromium (jweb) cannot read Max's frozen virtual filesystem, so a UI
|
|
50
|
+
* frozen into the device is invisible to it. The wrapper, however, always runs -
|
|
51
|
+
* so it carries the payload and writes it to a real file on first load.
|
|
52
|
+
*/
|
|
53
|
+
export function payloadJs(prefix, name, buf) {
|
|
54
|
+
const CHUNK = 30000; // multiple of 3 -> each chunk is padding-free base64
|
|
55
|
+
const chunks = [];
|
|
56
|
+
for (let i = 0; i < buf.length; i += CHUNK) {
|
|
57
|
+
chunks.push(JSON.stringify(buf.subarray(i, i + CHUNK).toString("base64")));
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
`\n// ---- generated: embedded ${prefix} payload ----\n` +
|
|
61
|
+
`var ${prefix}_NAME = ${JSON.stringify(name)};\n` +
|
|
62
|
+
`var ${prefix}_BYTES = ${buf.length};\n` +
|
|
63
|
+
`var ${prefix}_B64 = [\n${chunks.join(",\n")}\n];\n`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Additional self-extracting payloads, beyond the UI.
|
|
69
|
+
*
|
|
70
|
+
* Same reasoning as the UI payload: anything that is NOT a Max-native object
|
|
71
|
+
* (an external process, a Node bundle, a data file read by the web app) cannot
|
|
72
|
+
* see a frozen dependency, so it has to become a real file on disk. The wrapper
|
|
73
|
+
* writes each of these next to the .amxd on load - see extractExtraPayloads() in
|
|
74
|
+
* the wrapper's core.ts.
|
|
75
|
+
*/
|
|
76
|
+
export function extraPayloadsJs(payloads) {
|
|
77
|
+
const names = payloads.map((p) => JSON.stringify(p.name));
|
|
78
|
+
const bytes = payloads.map((p) => p.data.length);
|
|
79
|
+
const blobs = payloads.map((p) => {
|
|
80
|
+
const CHUNK = 30000;
|
|
81
|
+
const chunks = [];
|
|
82
|
+
for (let i = 0; i < p.data.length; i += CHUNK) {
|
|
83
|
+
chunks.push(JSON.stringify(p.data.subarray(i, i + CHUNK).toString("base64")));
|
|
84
|
+
}
|
|
85
|
+
return `[\n${chunks.join(",\n")}\n]`;
|
|
86
|
+
});
|
|
87
|
+
return (
|
|
88
|
+
`\n// ---- generated: ${payloads.length} extra payload(s) ----\n` +
|
|
89
|
+
`var EXTRA_PAYLOAD_NAMES = [${names.join(", ")}];\n` +
|
|
90
|
+
`var EXTRA_PAYLOAD_BYTES = [${bytes.join(", ")}];\n` +
|
|
91
|
+
`var EXTRA_PAYLOAD_B64 = [\n${blobs.join(",\n")}\n];\n`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const u32be = (n) => {
|
|
96
|
+
const b = Buffer.alloc(4);
|
|
97
|
+
b.writeUInt32BE(n >>> 0);
|
|
98
|
+
return b;
|
|
99
|
+
};
|
|
100
|
+
const u32le = (n) => {
|
|
101
|
+
const b = Buffer.alloc(4);
|
|
102
|
+
b.writeUInt32LE(n >>> 0);
|
|
103
|
+
return b;
|
|
104
|
+
};
|
|
105
|
+
const field = (tag, payload) => Buffer.concat([Buffer.from(tag, "latin1"), u32be(8 + payload.length), payload]);
|
|
106
|
+
const paddedName = (name) => {
|
|
107
|
+
const raw = Buffer.from(name + "\0", "latin1");
|
|
108
|
+
return Buffer.concat([raw, Buffer.alloc((4 - (raw.length % 4)) % 4)]);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the .amxd bytes.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} o
|
|
115
|
+
* @param {string} o.patcherJson the patcher, as JSON text
|
|
116
|
+
* @param {string} o.wrapperJs the ES5 wrapper (payloads already appended)
|
|
117
|
+
* @param {string} o.deviceName basename of the output file, e.g. "my-device.amxd"
|
|
118
|
+
* @param {Array<{name,data,type?}>} [o.extras] extra frozen dependencies
|
|
119
|
+
* @returns {Buffer}
|
|
120
|
+
*/
|
|
121
|
+
export function buildAmxd({ patcherJson, wrapperJs, deviceName, extras = [] }) {
|
|
122
|
+
assertES5(wrapperJs, "wrapper.js");
|
|
123
|
+
|
|
124
|
+
const amxdtype = JSON.parse(patcherJson).patcher?.project?.amxdtype;
|
|
125
|
+
const deviceTag = TAG_BY_CODE[amxdtype];
|
|
126
|
+
if (!deviceTag) throw new Error(`unknown project.amxdtype ${amxdtype}`);
|
|
127
|
+
|
|
128
|
+
const macDate = Math.floor(Date.now() / 1000) + 2082844800; // secs since 1904-01-01
|
|
129
|
+
|
|
130
|
+
const files = [
|
|
131
|
+
{ type: "JSON", name: deviceName, data: Buffer.from(patcherJson, "utf8"), flag: 0x11 },
|
|
132
|
+
{ type: "TEXT", name: "wrapper.js", data: Buffer.from(wrapperJs, "utf8"), flag: 0 },
|
|
133
|
+
...extras.map((e) => ({
|
|
134
|
+
type: e.type ?? (/\.(maxpat|json)$/.test(e.name) ? "JSON" : "TEXT"),
|
|
135
|
+
name: e.name,
|
|
136
|
+
data: e.data,
|
|
137
|
+
flag: 0,
|
|
138
|
+
})),
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// Payload region: the patcher JSON first, at offset 16 (right after mx@c).
|
|
142
|
+
let offset = 16;
|
|
143
|
+
const entries = [];
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
const at = offset;
|
|
146
|
+
offset += f.data.length;
|
|
147
|
+
entries.push(
|
|
148
|
+
field(
|
|
149
|
+
"dire",
|
|
150
|
+
Buffer.concat([
|
|
151
|
+
field("type", Buffer.from(f.type, "latin1")),
|
|
152
|
+
field("fnam", paddedName(f.name)),
|
|
153
|
+
field("sz32", u32be(f.data.length)),
|
|
154
|
+
field("of32", u32be(at)),
|
|
155
|
+
field("vers", u32be(0)),
|
|
156
|
+
field("flag", u32be(f.flag)),
|
|
157
|
+
field("mdat", u32be(macDate)),
|
|
158
|
+
]),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const payload = Buffer.concat(files.map((f) => f.data));
|
|
164
|
+
const mxc = Buffer.concat([Buffer.from("mx@c", "latin1"), u32be(16), u32be(0), u32be(16 + payload.length)]);
|
|
165
|
+
const ptchBody = Buffer.concat([mxc, payload, field("dlst", Buffer.concat(entries))]);
|
|
166
|
+
|
|
167
|
+
return Buffer.concat([
|
|
168
|
+
Buffer.from("ampf", "latin1"),
|
|
169
|
+
u32le(4),
|
|
170
|
+
Buffer.from(deviceTag, "latin1"),
|
|
171
|
+
Buffer.from("meta", "latin1"),
|
|
172
|
+
u32le(4),
|
|
173
|
+
u32le(7),
|
|
174
|
+
Buffer.from("ptch", "latin1"),
|
|
175
|
+
u32le(ptchBody.length),
|
|
176
|
+
ptchBody,
|
|
177
|
+
]);
|
|
178
|
+
}
|
package/src/chains.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chains.mjs - the box()/line() DSL and the canned chain vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A patcher is just JSON: `boxes` (objects, e.g. "route midinote flush") and
|
|
5
|
+
* `lines` (cords: [sourceBox, outlet] -> [destBox, inlet]). So we never draw
|
|
6
|
+
* one - we generate it. Patch cords become code review.
|
|
7
|
+
*
|
|
8
|
+
* A chain is a small function that claims [jweb]'s outlet, routes the selectors
|
|
9
|
+
* it owns, and passes everything else on to `unmatchedTo`. Add your own with
|
|
10
|
+
* `registerChain()`; keep them small and named after what they do.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let y = 300; // stack generated objects below the hand-made ones
|
|
14
|
+
|
|
15
|
+
/** Reset the layout cursor. The build calls this once per device. */
|
|
16
|
+
export function resetLayout() {
|
|
17
|
+
y = 300;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const box = (id, text, extra = {}) => ({
|
|
21
|
+
box: {
|
|
22
|
+
id,
|
|
23
|
+
maxclass: "newobj",
|
|
24
|
+
text,
|
|
25
|
+
numinlets: 1,
|
|
26
|
+
numoutlets: 1,
|
|
27
|
+
outlettype: [""],
|
|
28
|
+
patching_rect: [16, (y += 32), 220, 20],
|
|
29
|
+
...extra,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const line = (srcId, srcOut, dstId, dstIn) => ({
|
|
34
|
+
patchline: { source: [srcId, srcOut], destination: [dstId, dstIn] },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Drop every cord touching a box, then the box. */
|
|
38
|
+
export function removeBox(boxes, lines, id) {
|
|
39
|
+
const i = boxes.findIndex((b) => b.box.id === id);
|
|
40
|
+
if (i >= 0) boxes.splice(i, 1);
|
|
41
|
+
for (let j = lines.length - 1; j >= 0; j--) {
|
|
42
|
+
const pl = lines[j].patchline;
|
|
43
|
+
if (pl.source[0] === id || pl.destination[0] === id) lines.splice(j, 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drop a specific cord (used when a chain takes over jweb's output). */
|
|
48
|
+
export function removeLine(lines, srcId, dstId) {
|
|
49
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
50
|
+
const pl = lines[i].patchline;
|
|
51
|
+
if (pl.source[0] === srcId && pl.destination[0] === dstId) lines.splice(i, 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
|
|
57
|
+
*
|
|
58
|
+
* Also CUTS the template's direct midiin -> midiout thru cord: a device that
|
|
59
|
+
* transforms notes must not also leak the untransformed ones.
|
|
60
|
+
*/
|
|
61
|
+
function midiInChain({ boxes, lines, jwebId }) {
|
|
62
|
+
removeLine(lines, "obj-midiin", "obj-midiout");
|
|
63
|
+
boxes.push(
|
|
64
|
+
box("obj-midiparse", "midiparse", {
|
|
65
|
+
numinlets: 1,
|
|
66
|
+
numoutlets: 8,
|
|
67
|
+
outlettype: ["list", "list", "int", "int", "int", "list", "int", ""],
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
boxes.push(box("obj-noteinmsg", "prepend notein"));
|
|
71
|
+
lines.push(line("obj-midiin", 0, "obj-midiparse", 0));
|
|
72
|
+
lines.push(line("obj-midiparse", 0, "obj-noteinmsg", 0)); // outlet 0 = note: pitch, velocity
|
|
73
|
+
lines.push(line("obj-noteinmsg", 0, jwebId, 0));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* "midiout" - the app emits `midinote <pitch> <vel> <durMs> <chan> <delayMs>`
|
|
78
|
+
* and `flush`. Compute WHEN in your app; let Max place the note precisely.
|
|
79
|
+
*/
|
|
80
|
+
function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
|
|
81
|
+
// This chain consumes jweb's output, so the template's direct jweb -> js cord
|
|
82
|
+
// is replaced by the route's unmatched outlet.
|
|
83
|
+
removeLine(lines, jwebId, unmatchedId);
|
|
84
|
+
|
|
85
|
+
boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
|
|
86
|
+
// Explicit unpack instead of letting pipe spread the list: unpack fires
|
|
87
|
+
// right-to-left, so the delay (outlet 4) lands in pipe's delay inlet BEFORE
|
|
88
|
+
// the pitch (outlet 0) hits the hot inlet.
|
|
89
|
+
boxes.push(
|
|
90
|
+
box("obj-unpack", "unpack 0 0 0 0 0", {
|
|
91
|
+
numinlets: 1,
|
|
92
|
+
numoutlets: 5,
|
|
93
|
+
outlettype: ["int", "int", "int", "int", "int"],
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
boxes.push(
|
|
97
|
+
box("obj-pipe", "pipe 0 0 0 0 0", {
|
|
98
|
+
numinlets: 5, // 4 data inlets + delay
|
|
99
|
+
numoutlets: 4,
|
|
100
|
+
outlettype: ["int", "int", "int", "int"],
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
boxes.push(box("obj-makenote", "makenote 100 250", { numinlets: 3, numoutlets: 2, outlettype: ["int", "int"] }));
|
|
104
|
+
boxes.push(box("obj-packnote", "pack 0 0", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
|
|
105
|
+
boxes.push(box("obj-fmt", "midiformat", { numinlets: 7, numoutlets: 1, outlettype: ["int"] }));
|
|
106
|
+
// `route` STRIPS the selector: a bare "flush" emerges from outlet 1 as a bang,
|
|
107
|
+
// which makenote ignores. Re-materialize the word with a message box so
|
|
108
|
+
// makenote actually releases hanging notes.
|
|
109
|
+
boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
110
|
+
|
|
111
|
+
lines.push(line(jwebId, 0, "obj-route", 0));
|
|
112
|
+
lines.push(line("obj-route", 0, "obj-unpack", 0));
|
|
113
|
+
lines.push(line("obj-route", 1, "obj-flushmsg", 0));
|
|
114
|
+
lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
|
|
115
|
+
for (let i = 0; i < 5; i++) lines.push(line("obj-unpack", i, "obj-pipe", i));
|
|
116
|
+
lines.push(line("obj-pipe", 0, "obj-makenote", 0)); // pitch
|
|
117
|
+
lines.push(line("obj-pipe", 1, "obj-makenote", 1)); // velocity
|
|
118
|
+
lines.push(line("obj-pipe", 2, "obj-makenote", 2)); // duration ms
|
|
119
|
+
lines.push(line("obj-pipe", 3, "obj-fmt", 6)); // channel
|
|
120
|
+
lines.push(line("obj-makenote", 0, "obj-packnote", 0));
|
|
121
|
+
lines.push(line("obj-makenote", 1, "obj-packnote", 1));
|
|
122
|
+
lines.push(line("obj-packnote", 0, "obj-fmt", 0));
|
|
123
|
+
lines.push(line("obj-fmt", 0, "obj-midiout", 0));
|
|
124
|
+
// Unmatched selectors (ui_ready, write_clip, read_notes...) carry on.
|
|
125
|
+
lines.push(line("obj-route", 2, unmatchedId, 0));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** "passthrough" - an audio effect that passes its input through untouched. */
|
|
129
|
+
function passthroughChain({ boxes, lines }) {
|
|
130
|
+
// An audio effect has no MIDI ports.
|
|
131
|
+
removeBox(boxes, lines, "obj-midiin");
|
|
132
|
+
removeBox(boxes, lines, "obj-midiout");
|
|
133
|
+
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
134
|
+
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
135
|
+
lines.push(line("obj-plugin", 0, "obj-plugout", 0));
|
|
136
|
+
lines.push(line("obj-plugin", 1, "obj-plugout", 1));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const CHAINS = {
|
|
140
|
+
midiin: midiInChain,
|
|
141
|
+
midiout: midiOutChain,
|
|
142
|
+
passthrough: passthroughChain,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
|
146
|
+
export function registerChain(name, fn) {
|
|
147
|
+
CHAINS[name] = fn;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Real Live parameters: automatable, MIDI-mappable, and the ONLY thing Push can
|
|
152
|
+
* display. Each becomes a live.* object wired into the UI as `<id> <value>`, so
|
|
153
|
+
* a parameter change is just another inlet message to your app.
|
|
154
|
+
*/
|
|
155
|
+
export function addParameters(boxes, lines, params, dstId) {
|
|
156
|
+
let x = 480;
|
|
157
|
+
for (const p of params) {
|
|
158
|
+
const objId = `obj-param-${p.id}`;
|
|
159
|
+
const prependId = `obj-prepend-${p.id}`;
|
|
160
|
+
boxes.push({
|
|
161
|
+
box: {
|
|
162
|
+
id: objId,
|
|
163
|
+
maxclass: p.object, // live.dial | live.toggle | live.menu
|
|
164
|
+
numinlets: 1,
|
|
165
|
+
numoutlets: 1,
|
|
166
|
+
outlettype: [""],
|
|
167
|
+
parameter_enable: 1,
|
|
168
|
+
patching_rect: [x, 300, 44, 48],
|
|
169
|
+
saved_attribute_attributes: {
|
|
170
|
+
valueof: {
|
|
171
|
+
parameter_longname: p.id,
|
|
172
|
+
parameter_shortname: p.id.slice(0, 8), // Push shows short names
|
|
173
|
+
parameter_type: p.object === "live.toggle" ? 2 : 0, // 2 = enum, 0 = float
|
|
174
|
+
...(p.range ? { parameter_range: p.range } : {}),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
boxes.push(box(prependId, `prepend ${p.id}`));
|
|
180
|
+
lines.push(line(objId, 0, prependId, 0));
|
|
181
|
+
lines.push(line(prependId, 0, dstId, 0));
|
|
182
|
+
x += 56;
|
|
183
|
+
}
|
|
184
|
+
}
|