@kernel.chat/kbot 3.52.0 → 3.54.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/dist/agents/replit.js +1 -1
- package/dist/behaviour.d.ts +30 -0
- package/dist/behaviour.d.ts.map +1 -0
- package/dist/behaviour.js +191 -0
- package/dist/behaviour.js.map +1 -0
- package/dist/bootstrap.js +1 -1
- package/dist/bootstrap.js.map +1 -1
- package/dist/integrations/ableton-m4l.d.ts +124 -0
- package/dist/integrations/ableton-m4l.d.ts.map +1 -0
- package/dist/integrations/ableton-m4l.js +338 -0
- package/dist/integrations/ableton-m4l.js.map +1 -0
- package/dist/integrations/ableton-osc.d.ts.map +1 -1
- package/dist/integrations/ableton-osc.js +6 -2
- package/dist/integrations/ableton-osc.js.map +1 -1
- package/dist/music-learning.d.ts +181 -0
- package/dist/music-learning.d.ts.map +1 -0
- package/dist/music-learning.js +340 -0
- package/dist/music-learning.js.map +1 -0
- package/dist/skill-system.d.ts +68 -0
- package/dist/skill-system.d.ts.map +1 -0
- package/dist/skill-system.js +386 -0
- package/dist/skill-system.js.map +1 -0
- package/dist/tools/ableton.d.ts.map +1 -1
- package/dist/tools/ableton.js +24 -8
- package/dist/tools/ableton.js.map +1 -1
- package/dist/tools/arrangement-engine.d.ts +2 -0
- package/dist/tools/arrangement-engine.d.ts.map +1 -0
- package/dist/tools/arrangement-engine.js +644 -0
- package/dist/tools/arrangement-engine.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +5 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/producer-engine.d.ts +71 -0
- package/dist/tools/producer-engine.d.ts.map +1 -0
- package/dist/tools/producer-engine.js +1859 -0
- package/dist/tools/producer-engine.js.map +1 -0
- package/dist/tools/sound-designer.d.ts +2 -0
- package/dist/tools/sound-designer.d.ts.map +1 -0
- package/dist/tools/sound-designer.js +896 -0
- package/dist/tools/sound-designer.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// kbot Arrangement Engine — Turn any loop into a full song structure
|
|
2
|
+
//
|
|
3
|
+
// Takes whatever clips exist in slot 0 (scene 1) and builds a complete
|
|
4
|
+
// arrangement by duplicating, muting/unmuting, and automating across scenes.
|
|
5
|
+
//
|
|
6
|
+
// Tool: arrange_song
|
|
7
|
+
//
|
|
8
|
+
// Supported styles: trap, pop, edm, ambient
|
|
9
|
+
// Energy curves: build_drop, steady, wave, climax_end
|
|
10
|
+
//
|
|
11
|
+
// Requires: AbletonOSC loaded in Ableton Live
|
|
12
|
+
import { registerTool } from './index.js';
|
|
13
|
+
import { ensureAbleton, formatAbletonError } from '../integrations/ableton-osc.js';
|
|
14
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
15
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
16
|
+
function extractArgs(args) {
|
|
17
|
+
return args.map(a => {
|
|
18
|
+
if (a.type === 'b')
|
|
19
|
+
return '[blob]';
|
|
20
|
+
return a.value;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const ROLE_KEYWORDS = {
|
|
24
|
+
drums: ['drum', 'kick', 'snare', 'hat', 'hihat', 'hi-hat', 'clap', 'kit', '808', 'perc', 'break'],
|
|
25
|
+
bass: ['bass', 'sub', '808', 'low', 'reese'],
|
|
26
|
+
melody: ['melody', 'lead', 'synth', 'keys', 'piano', 'pluck', 'bell', 'arp'],
|
|
27
|
+
chords: ['chord', 'harmony', 'stab', 'organ'],
|
|
28
|
+
pad: ['pad', 'atmosphere', 'atmo', 'string', 'texture', 'ambient', 'wash'],
|
|
29
|
+
vocal: ['vocal', 'vox', 'voice', 'acapella', 'chant', 'choir'],
|
|
30
|
+
perc: ['perc', 'shaker', 'tamb', 'conga', 'bongo', 'rim', 'wood', 'snap', 'click'],
|
|
31
|
+
fx: ['fx', 'riser', 'sweep', 'impact', 'noise', 'transition', 'reverse', 'whoosh'],
|
|
32
|
+
unknown: [],
|
|
33
|
+
};
|
|
34
|
+
function classifyTrack(name) {
|
|
35
|
+
const lower = name.toLowerCase();
|
|
36
|
+
// Specific check: "808" alone is bass, not drums
|
|
37
|
+
if (lower === '808' || lower === 'sub 808' || lower === '808 bass')
|
|
38
|
+
return 'bass';
|
|
39
|
+
for (const [role, keywords] of Object.entries(ROLE_KEYWORDS)) {
|
|
40
|
+
if (role === 'unknown')
|
|
41
|
+
continue;
|
|
42
|
+
for (const kw of keywords) {
|
|
43
|
+
if (lower.includes(kw))
|
|
44
|
+
return role;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return 'unknown';
|
|
48
|
+
}
|
|
49
|
+
// ── Song Structures ─────────────────────────────────────────────────────────
|
|
50
|
+
function getTrapStructure(totalBars) {
|
|
51
|
+
// Scale sections proportionally if not 64 bars
|
|
52
|
+
const scale = totalBars / 64;
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
name: 'Intro',
|
|
56
|
+
bars: Math.round(4 * scale),
|
|
57
|
+
active: ['melody', 'pad', 'chords', 'fx'],
|
|
58
|
+
volumeOverrides: { melody: 0.6, pad: 0.5 },
|
|
59
|
+
filterSweep: 'up',
|
|
60
|
+
fade: 'in',
|
|
61
|
+
energy: 0.2,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'Verse 1',
|
|
65
|
+
bars: Math.round(8 * scale),
|
|
66
|
+
active: ['drums', 'bass', 'melody', 'chords'],
|
|
67
|
+
energy: 0.5,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'Hook',
|
|
71
|
+
bars: Math.round(8 * scale),
|
|
72
|
+
active: 'all',
|
|
73
|
+
energy: 0.85,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'Verse 2',
|
|
77
|
+
bars: Math.round(8 * scale),
|
|
78
|
+
active: ['drums', 'bass', 'melody', 'chords', 'perc', 'vocal'],
|
|
79
|
+
energy: 0.6,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Hook 2',
|
|
83
|
+
bars: Math.round(8 * scale),
|
|
84
|
+
active: 'all',
|
|
85
|
+
volumeOverrides: { pad: 0.9, fx: 0.8 },
|
|
86
|
+
energy: 0.95,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'Bridge',
|
|
90
|
+
bars: Math.round(4 * scale),
|
|
91
|
+
active: ['melody', 'pad', 'chords', 'vocal', 'fx'],
|
|
92
|
+
filterSweep: 'down',
|
|
93
|
+
energy: 0.3,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'Final Hook',
|
|
97
|
+
bars: Math.round(8 * scale),
|
|
98
|
+
active: 'all',
|
|
99
|
+
energy: 1.0,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'Outro',
|
|
103
|
+
bars: Math.max(Math.round(4 * scale), 2),
|
|
104
|
+
active: ['melody', 'pad'],
|
|
105
|
+
volumeOverrides: { melody: 0.5, pad: 0.4 },
|
|
106
|
+
fade: 'out',
|
|
107
|
+
energy: 0.15,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
function getPopStructure(totalBars) {
|
|
112
|
+
const scale = totalBars / 64;
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
name: 'Intro',
|
|
116
|
+
bars: Math.round(4 * scale),
|
|
117
|
+
active: ['chords', 'pad', 'melody'],
|
|
118
|
+
volumeOverrides: { chords: 0.7, melody: 0.5 },
|
|
119
|
+
fade: 'in',
|
|
120
|
+
energy: 0.2,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'Verse 1',
|
|
124
|
+
bars: Math.round(8 * scale),
|
|
125
|
+
active: ['drums', 'bass', 'chords', 'vocal'],
|
|
126
|
+
volumeOverrides: { drums: 0.8 },
|
|
127
|
+
energy: 0.45,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'Pre-Chorus',
|
|
131
|
+
bars: Math.round(4 * scale),
|
|
132
|
+
active: ['drums', 'bass', 'chords', 'melody', 'vocal', 'pad'],
|
|
133
|
+
filterSweep: 'up',
|
|
134
|
+
energy: 0.65,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'Chorus',
|
|
138
|
+
bars: Math.round(8 * scale),
|
|
139
|
+
active: 'all',
|
|
140
|
+
energy: 0.9,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'Verse 2',
|
|
144
|
+
bars: Math.round(8 * scale),
|
|
145
|
+
active: ['drums', 'bass', 'chords', 'vocal', 'perc'],
|
|
146
|
+
energy: 0.5,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'Pre-Chorus 2',
|
|
150
|
+
bars: Math.round(4 * scale),
|
|
151
|
+
active: ['drums', 'bass', 'chords', 'melody', 'vocal', 'pad', 'perc'],
|
|
152
|
+
filterSweep: 'up',
|
|
153
|
+
energy: 0.7,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'Chorus 2',
|
|
157
|
+
bars: Math.round(8 * scale),
|
|
158
|
+
active: 'all',
|
|
159
|
+
energy: 1.0,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'Bridge',
|
|
163
|
+
bars: Math.round(4 * scale),
|
|
164
|
+
active: ['pad', 'vocal', 'melody'],
|
|
165
|
+
energy: 0.3,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'Final Chorus',
|
|
169
|
+
bars: Math.round(8 * scale),
|
|
170
|
+
active: 'all',
|
|
171
|
+
energy: 1.0,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'Outro',
|
|
175
|
+
bars: Math.max(Math.round(4 * scale), 2),
|
|
176
|
+
active: ['chords', 'pad', 'melody'],
|
|
177
|
+
fade: 'out',
|
|
178
|
+
energy: 0.15,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
function getEdmStructure(totalBars) {
|
|
183
|
+
const scale = totalBars / 64;
|
|
184
|
+
return [
|
|
185
|
+
{
|
|
186
|
+
name: 'Intro',
|
|
187
|
+
bars: Math.round(8 * scale),
|
|
188
|
+
active: ['drums', 'perc'],
|
|
189
|
+
volumeOverrides: { drums: 0.6 },
|
|
190
|
+
fade: 'in',
|
|
191
|
+
energy: 0.3,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'Build 1',
|
|
195
|
+
bars: Math.round(4 * scale),
|
|
196
|
+
active: ['drums', 'bass', 'melody', 'fx', 'perc'],
|
|
197
|
+
filterSweep: 'up',
|
|
198
|
+
energy: 0.65,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'Drop 1',
|
|
202
|
+
bars: Math.round(8 * scale),
|
|
203
|
+
active: 'all',
|
|
204
|
+
energy: 1.0,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'Breakdown',
|
|
208
|
+
bars: Math.round(8 * scale),
|
|
209
|
+
active: ['melody', 'pad', 'chords'],
|
|
210
|
+
volumeOverrides: { melody: 0.7, pad: 0.6 },
|
|
211
|
+
energy: 0.25,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'Build 2',
|
|
215
|
+
bars: Math.round(4 * scale),
|
|
216
|
+
active: ['drums', 'bass', 'melody', 'pad', 'fx', 'perc'],
|
|
217
|
+
filterSweep: 'up',
|
|
218
|
+
energy: 0.7,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'Drop 2',
|
|
222
|
+
bars: Math.round(8 * scale),
|
|
223
|
+
active: 'all',
|
|
224
|
+
energy: 1.0,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'Outro',
|
|
228
|
+
bars: Math.round(8 * scale),
|
|
229
|
+
active: ['drums', 'pad', 'melody'],
|
|
230
|
+
volumeOverrides: { drums: 0.7, pad: 0.5, melody: 0.4 },
|
|
231
|
+
fade: 'out',
|
|
232
|
+
energy: 0.2,
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
function getAmbientStructure(totalBars) {
|
|
237
|
+
const scale = totalBars / 64;
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
name: 'Emergence',
|
|
241
|
+
bars: Math.round(8 * scale),
|
|
242
|
+
active: ['pad', 'fx'],
|
|
243
|
+
volumeOverrides: { pad: 0.3, fx: 0.2 },
|
|
244
|
+
fade: 'in',
|
|
245
|
+
energy: 0.1,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'Texture A',
|
|
249
|
+
bars: Math.round(12 * scale),
|
|
250
|
+
active: ['pad', 'melody', 'fx'],
|
|
251
|
+
volumeOverrides: { melody: 0.5 },
|
|
252
|
+
energy: 0.3,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'Bloom',
|
|
256
|
+
bars: Math.round(8 * scale),
|
|
257
|
+
active: ['pad', 'melody', 'chords', 'fx', 'vocal'],
|
|
258
|
+
energy: 0.5,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'Peak',
|
|
262
|
+
bars: Math.round(8 * scale),
|
|
263
|
+
active: 'all',
|
|
264
|
+
volumeOverrides: { drums: 0.4, bass: 0.4 },
|
|
265
|
+
energy: 0.7,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'Texture B',
|
|
269
|
+
bars: Math.round(12 * scale),
|
|
270
|
+
active: ['pad', 'melody', 'chords', 'fx'],
|
|
271
|
+
volumeOverrides: { melody: 0.6 },
|
|
272
|
+
energy: 0.4,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'Dissolve',
|
|
276
|
+
bars: Math.round(8 * scale),
|
|
277
|
+
active: ['pad', 'fx'],
|
|
278
|
+
volumeOverrides: { pad: 0.4, fx: 0.3 },
|
|
279
|
+
fade: 'out',
|
|
280
|
+
energy: 0.1,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
const STRUCTURES = {
|
|
285
|
+
trap: getTrapStructure,
|
|
286
|
+
pop: getPopStructure,
|
|
287
|
+
edm: getEdmStructure,
|
|
288
|
+
ambient: getAmbientStructure,
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Adjust section energies based on a global energy curve.
|
|
292
|
+
* This modulates the volume/intensity of each section relative to its position.
|
|
293
|
+
*/
|
|
294
|
+
function applyEnergyCurve(sections, curve) {
|
|
295
|
+
const total = sections.length;
|
|
296
|
+
return sections.map((section, i) => {
|
|
297
|
+
const position = i / (total - 1 || 1); // 0 to 1
|
|
298
|
+
let multiplier = 1.0;
|
|
299
|
+
switch (curve) {
|
|
300
|
+
case 'build_drop':
|
|
301
|
+
// Linear build to 75%, then full energy, then sharp drop at end
|
|
302
|
+
if (position < 0.75)
|
|
303
|
+
multiplier = 0.5 + position * 0.67;
|
|
304
|
+
else if (position < 0.9)
|
|
305
|
+
multiplier = 1.0;
|
|
306
|
+
else
|
|
307
|
+
multiplier = 0.4;
|
|
308
|
+
break;
|
|
309
|
+
case 'steady':
|
|
310
|
+
// Minimal variation — keep everything close to the section's natural energy
|
|
311
|
+
multiplier = 0.85 + position * 0.15;
|
|
312
|
+
break;
|
|
313
|
+
case 'wave':
|
|
314
|
+
// Sinusoidal — two energy peaks
|
|
315
|
+
multiplier = 0.6 + 0.4 * Math.sin(position * Math.PI * 2);
|
|
316
|
+
break;
|
|
317
|
+
case 'climax_end':
|
|
318
|
+
// Steady build toward maximum energy at the end
|
|
319
|
+
multiplier = 0.4 + position * 0.6;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
...section,
|
|
324
|
+
energy: Math.max(0.05, Math.min(1.0, section.energy * multiplier)),
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Scan the current Ableton session to discover tracks and their contents.
|
|
330
|
+
*/
|
|
331
|
+
async function scanSession(osc) {
|
|
332
|
+
const countResult = await osc.query('/live/song/get/num_tracks');
|
|
333
|
+
const trackCount = Number(extractArgs(countResult)[0]) || 0;
|
|
334
|
+
const tracks = [];
|
|
335
|
+
for (let i = 0; i < Math.min(trackCount, 32); i++) {
|
|
336
|
+
try {
|
|
337
|
+
const nameResult = await osc.query('/live/track/get/name', i);
|
|
338
|
+
const name = String(extractArgs(nameResult)[1] || `Track ${i + 1}`);
|
|
339
|
+
const volResult = await osc.query('/live/track/get/volume', i);
|
|
340
|
+
const baseVolume = Number(extractArgs(volResult)[1]) || 0.85;
|
|
341
|
+
let hasClip = false;
|
|
342
|
+
try {
|
|
343
|
+
const clipResult = await osc.query('/live/clip_slot/get/has_clip', i, 0);
|
|
344
|
+
hasClip = Boolean(extractArgs(clipResult)[2]);
|
|
345
|
+
}
|
|
346
|
+
catch { /* no clip */ }
|
|
347
|
+
tracks.push({
|
|
348
|
+
index: i,
|
|
349
|
+
name,
|
|
350
|
+
role: classifyTrack(name),
|
|
351
|
+
hasClipInSlot0: hasClip,
|
|
352
|
+
baseVolume,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return tracks;
|
|
360
|
+
}
|
|
361
|
+
// ── Scene Builder ───────────────────────────────────────────────────────────
|
|
362
|
+
/**
|
|
363
|
+
* Ensure we have enough scenes. AbletonOSC creates scenes at the end.
|
|
364
|
+
*/
|
|
365
|
+
async function ensureScenes(osc, needed) {
|
|
366
|
+
const countResult = await osc.query('/live/song/get/num_scenes');
|
|
367
|
+
const current = Number(extractArgs(countResult)[0]) || 1;
|
|
368
|
+
const toCreate = needed - current;
|
|
369
|
+
for (let i = 0; i < toCreate; i++) {
|
|
370
|
+
osc.send('/live/song/create_scene', -1);
|
|
371
|
+
await sleep(100);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Duplicate a clip from slot 0 to a target slot on a given track.
|
|
376
|
+
* Uses the AbletonOSC duplicate_clip_to endpoint.
|
|
377
|
+
*/
|
|
378
|
+
async function duplicateClipToSlot(osc, trackIndex, targetSlot) {
|
|
379
|
+
osc.send('/live/clip_slot/duplicate_clip_to', trackIndex, 0, trackIndex, targetSlot);
|
|
380
|
+
await sleep(50);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Build the full arrangement: duplicate clips, set up mutes, name scenes.
|
|
384
|
+
*/
|
|
385
|
+
async function buildArrangement(osc, tracks, sections) {
|
|
386
|
+
const tracksWithClips = tracks.filter(t => t.hasClipInSlot0);
|
|
387
|
+
if (tracksWithClips.length === 0) {
|
|
388
|
+
throw new Error('No clips found in scene 1 (slot 0). Create your loop elements in the first scene row, then run arrange_song.');
|
|
389
|
+
}
|
|
390
|
+
const log = [];
|
|
391
|
+
// Calculate total scenes needed (1 scene per section)
|
|
392
|
+
const totalScenes = sections.length;
|
|
393
|
+
log.push(`Creating ${totalScenes} scenes for ${sections.length} sections...`);
|
|
394
|
+
// Ensure we have enough scenes
|
|
395
|
+
await ensureScenes(osc, totalScenes);
|
|
396
|
+
await sleep(300);
|
|
397
|
+
// Name each scene and populate clips
|
|
398
|
+
let currentSceneIndex = 0;
|
|
399
|
+
for (const section of sections) {
|
|
400
|
+
const sceneIdx = currentSceneIndex;
|
|
401
|
+
// Name the scene
|
|
402
|
+
osc.send('/live/scene/set/name', sceneIdx, `${section.name} [${section.bars}b]`);
|
|
403
|
+
await sleep(50);
|
|
404
|
+
// For scene 0 (the source), we don't duplicate — just adjust mutes/volumes.
|
|
405
|
+
// For subsequent scenes, duplicate clips from slot 0.
|
|
406
|
+
if (sceneIdx > 0) {
|
|
407
|
+
for (const track of tracksWithClips) {
|
|
408
|
+
await duplicateClipToSlot(osc, track.index, sceneIdx);
|
|
409
|
+
}
|
|
410
|
+
await sleep(150);
|
|
411
|
+
}
|
|
412
|
+
// Now set clip lengths for this section (loop length = section bars * 4 beats)
|
|
413
|
+
const sectionBeats = section.bars * 4;
|
|
414
|
+
for (const track of tracksWithClips) {
|
|
415
|
+
try {
|
|
416
|
+
// Check the clip exists in this slot before modifying
|
|
417
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
418
|
+
if (extractArgs(hasClip)[2]) {
|
|
419
|
+
// Set loop end to match section length (keep loop start at 0)
|
|
420
|
+
osc.send('/live/clip/set/loop_end', track.index, sceneIdx, sectionBeats);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch { /* skip if clip missing */ }
|
|
424
|
+
}
|
|
425
|
+
// Determine which tracks should be active vs muted in this section
|
|
426
|
+
const activeRoles = section.active;
|
|
427
|
+
for (const track of tracksWithClips) {
|
|
428
|
+
const isActive = activeRoles === 'all' || activeRoles.includes(track.role);
|
|
429
|
+
// We use clip deactivation rather than track muting so we don't
|
|
430
|
+
// affect the source clips. Set the clip's mute state.
|
|
431
|
+
try {
|
|
432
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
433
|
+
if (extractArgs(hasClip)[2]) {
|
|
434
|
+
if (!isActive) {
|
|
435
|
+
// Mute (deactivate) the clip — sets it to not play when scene launches
|
|
436
|
+
osc.send('/live/clip/set/muted', track.index, sceneIdx, 1);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
// Unmute (activate) the clip
|
|
440
|
+
osc.send('/live/clip/set/muted', track.index, sceneIdx, 0);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch { /* skip */ }
|
|
445
|
+
}
|
|
446
|
+
// Apply per-track volume overrides via clip gain
|
|
447
|
+
// This lets us shape dynamics without destructively changing track volumes
|
|
448
|
+
if (section.volumeOverrides) {
|
|
449
|
+
for (const track of tracksWithClips) {
|
|
450
|
+
const override = section.volumeOverrides[track.role];
|
|
451
|
+
if (override !== undefined) {
|
|
452
|
+
try {
|
|
453
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
454
|
+
if (extractArgs(hasClip)[2]) {
|
|
455
|
+
// Clip gain is in dB: 0 dB = no change. Convert volume 0-1 to dB range.
|
|
456
|
+
// Simple linear mapping: 1.0 → 0 dB, 0.5 → -6 dB, 0.25 → -12 dB
|
|
457
|
+
const gainDb = override > 0 ? 20 * Math.log10(override) : -60;
|
|
458
|
+
osc.send('/live/clip/set/gain', track.index, sceneIdx, Math.max(-40, gainDb));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch { /* skip */ }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Apply energy-based master volume hint (set all active clips' gain)
|
|
466
|
+
// Energy shapes the overall intensity of each section
|
|
467
|
+
const energyGainDb = section.energy > 0 ? 20 * Math.log10(Math.max(0.1, section.energy)) : -40;
|
|
468
|
+
// Only apply energy gain to tracks without explicit overrides
|
|
469
|
+
for (const track of tracksWithClips) {
|
|
470
|
+
const hasExplicitOverride = section.volumeOverrides?.[track.role] !== undefined;
|
|
471
|
+
const isActive = activeRoles === 'all' || activeRoles.includes(track.role);
|
|
472
|
+
if (isActive && !hasExplicitOverride) {
|
|
473
|
+
try {
|
|
474
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
475
|
+
if (extractArgs(hasClip)[2]) {
|
|
476
|
+
// Blend base energy: full energy = 0 dB, low energy = attenuated
|
|
477
|
+
// Don't attenuate too aggressively — keep it musical
|
|
478
|
+
const blendedGain = Math.max(-12, energyGainDb * 0.5);
|
|
479
|
+
if (blendedGain < -1) {
|
|
480
|
+
osc.send('/live/clip/set/gain', track.index, sceneIdx, blendedGain);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch { /* skip */ }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Apply fade in/out by adjusting first/last clips' gain
|
|
488
|
+
if (section.fade === 'in') {
|
|
489
|
+
// Reduce gain on this section's clips — the "fade in" is a quieter start
|
|
490
|
+
for (const track of tracksWithClips) {
|
|
491
|
+
const isActive = activeRoles === 'all' || activeRoles.includes(track.role);
|
|
492
|
+
if (isActive) {
|
|
493
|
+
try {
|
|
494
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
495
|
+
if (extractArgs(hasClip)[2]) {
|
|
496
|
+
osc.send('/live/clip/set/gain', track.index, sceneIdx, -8); // -8 dB fade-in start
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch { /* skip */ }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else if (section.fade === 'out') {
|
|
504
|
+
for (const track of tracksWithClips) {
|
|
505
|
+
const isActive = activeRoles === 'all' || activeRoles.includes(track.role);
|
|
506
|
+
if (isActive) {
|
|
507
|
+
try {
|
|
508
|
+
const hasClip = await osc.query('/live/clip_slot/get/has_clip', track.index, sceneIdx);
|
|
509
|
+
if (extractArgs(hasClip)[2]) {
|
|
510
|
+
osc.send('/live/clip/set/gain', track.index, sceneIdx, -10); // -10 dB fade-out
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch { /* skip */ }
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const activeCount = tracksWithClips.filter(t => activeRoles === 'all' || activeRoles.includes(t.role)).length;
|
|
518
|
+
const mutedCount = tracksWithClips.length - activeCount;
|
|
519
|
+
log.push(` Scene ${sceneIdx + 1}: **${section.name}** — ${section.bars} bars, ${activeCount} active, ${mutedCount} muted (energy: ${(section.energy * 100).toFixed(0)}%)`);
|
|
520
|
+
currentSceneIndex++;
|
|
521
|
+
}
|
|
522
|
+
return log;
|
|
523
|
+
}
|
|
524
|
+
// ── Tool Registration ───────────────────────────────────────────────────────
|
|
525
|
+
export function registerArrangementEngine() {
|
|
526
|
+
registerTool({
|
|
527
|
+
name: 'arrange_song',
|
|
528
|
+
description: [
|
|
529
|
+
'Turn a loop into a full song arrangement in Ableton Live.',
|
|
530
|
+
'Reads all tracks and clips in scene 1 (your loop), then builds a complete',
|
|
531
|
+
'song structure by duplicating clips across scenes, muting/unmuting elements',
|
|
532
|
+
'per section, and applying energy shaping.',
|
|
533
|
+
'',
|
|
534
|
+
'Workflow:',
|
|
535
|
+
' 1. Create your loop elements in scene 1 (row 1) — drums, bass, melody, etc.',
|
|
536
|
+
' 2. Name your tracks descriptively (e.g. "Drums", "808 Bass", "Melody", "Pad")',
|
|
537
|
+
' 3. Run arrange_song to build the full arrangement',
|
|
538
|
+
' 4. Launch scene 1 and let Follow Actions advance through the song',
|
|
539
|
+
'',
|
|
540
|
+
'Track names are classified by role (drums, bass, melody, chords, pad, vocal, perc, fx)',
|
|
541
|
+
'and each section activates/deactivates roles according to the chosen style.',
|
|
542
|
+
].join('\n'),
|
|
543
|
+
parameters: {
|
|
544
|
+
style: {
|
|
545
|
+
type: 'string',
|
|
546
|
+
description: 'Song structure style: "trap" (verse/hook), "pop" (verse/chorus/bridge), "edm" (build/drop), "ambient" (textural evolution). Default: trap',
|
|
547
|
+
},
|
|
548
|
+
bars: {
|
|
549
|
+
type: 'number',
|
|
550
|
+
description: 'Total song length in bars. Default: 64 (~2 min at 140 BPM). Sections scale proportionally.',
|
|
551
|
+
},
|
|
552
|
+
energy_curve: {
|
|
553
|
+
type: 'string',
|
|
554
|
+
description: 'Global energy envelope: "build_drop" (rises then falls), "steady" (minimal variation), "wave" (two peaks), "climax_end" (builds to finale). Default: build_drop',
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
tier: 'free',
|
|
558
|
+
timeout: 60_000,
|
|
559
|
+
async execute(args) {
|
|
560
|
+
const style = String(args.style || 'trap').toLowerCase();
|
|
561
|
+
const totalBars = Number(args.bars) || 64;
|
|
562
|
+
const energyCurve = String(args.energy_curve || 'build_drop').toLowerCase();
|
|
563
|
+
// Validate inputs
|
|
564
|
+
if (!STRUCTURES[style]) {
|
|
565
|
+
return `Unknown style "${style}". Options: ${Object.keys(STRUCTURES).join(', ')}`;
|
|
566
|
+
}
|
|
567
|
+
if (!['build_drop', 'steady', 'wave', 'climax_end'].includes(energyCurve)) {
|
|
568
|
+
return `Unknown energy curve "${energyCurve}". Options: build_drop, steady, wave, climax_end`;
|
|
569
|
+
}
|
|
570
|
+
if (totalBars < 8 || totalBars > 512) {
|
|
571
|
+
return `Bar count must be between 8 and 512. Got: ${totalBars}`;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const osc = await ensureAbleton();
|
|
575
|
+
const lines = [];
|
|
576
|
+
lines.push(`## Arrangement Engine`, '');
|
|
577
|
+
lines.push(`- **Style**: ${style}`);
|
|
578
|
+
lines.push(`- **Length**: ${totalBars} bars`);
|
|
579
|
+
lines.push(`- **Energy curve**: ${energyCurve}`);
|
|
580
|
+
lines.push('');
|
|
581
|
+
// Step 1: Scan session
|
|
582
|
+
lines.push('### Scanning session...');
|
|
583
|
+
const tracks = await scanSession(osc);
|
|
584
|
+
const tracksWithClips = tracks.filter(t => t.hasClipInSlot0);
|
|
585
|
+
if (tracksWithClips.length === 0) {
|
|
586
|
+
return [
|
|
587
|
+
'## No clips found in scene 1',
|
|
588
|
+
'',
|
|
589
|
+
'The arrangement engine needs your loop elements in **scene 1** (the first row of clip slots).',
|
|
590
|
+
'',
|
|
591
|
+
'Setup:',
|
|
592
|
+
' 1. Create clips in scene 1 for each element (drums, bass, melody, etc.)',
|
|
593
|
+
' 2. Name tracks descriptively — the engine classifies by name',
|
|
594
|
+
' 3. Run `arrange_song` again',
|
|
595
|
+
'',
|
|
596
|
+
`Detected ${tracks.length} tracks: ${tracks.map(t => `${t.name} (${t.role})`).join(', ') || 'none'}`,
|
|
597
|
+
].join('\n');
|
|
598
|
+
}
|
|
599
|
+
// Show discovered tracks
|
|
600
|
+
lines.push('');
|
|
601
|
+
lines.push('| Track | Role | Clip in Scene 1 |');
|
|
602
|
+
lines.push('|-------|------|-----------------|');
|
|
603
|
+
for (const track of tracks) {
|
|
604
|
+
const clipStatus = track.hasClipInSlot0 ? 'Yes' : '-';
|
|
605
|
+
lines.push(`| ${track.name} | ${track.role} | ${clipStatus} |`);
|
|
606
|
+
}
|
|
607
|
+
lines.push('');
|
|
608
|
+
// Step 2: Generate structure
|
|
609
|
+
let sections = STRUCTURES[style](totalBars);
|
|
610
|
+
sections = applyEnergyCurve(sections, energyCurve);
|
|
611
|
+
// Step 3: Build arrangement
|
|
612
|
+
lines.push('### Building arrangement...');
|
|
613
|
+
lines.push('');
|
|
614
|
+
const buildLog = await buildArrangement(osc, tracks, sections);
|
|
615
|
+
lines.push(...buildLog);
|
|
616
|
+
lines.push('');
|
|
617
|
+
// Summary
|
|
618
|
+
const totalSectionBars = sections.reduce((sum, s) => sum + s.bars, 0);
|
|
619
|
+
const tempoResult = await osc.query('/live/song/get/tempo');
|
|
620
|
+
const bpm = Number(extractArgs(tempoResult)[0]) || 120;
|
|
621
|
+
const durationSec = (totalSectionBars * 4 * 60) / bpm;
|
|
622
|
+
const minutes = Math.floor(durationSec / 60);
|
|
623
|
+
const seconds = Math.round(durationSec % 60);
|
|
624
|
+
lines.push('### Summary');
|
|
625
|
+
lines.push('');
|
|
626
|
+
lines.push(`- **Sections**: ${sections.length}`);
|
|
627
|
+
lines.push(`- **Total bars**: ${totalSectionBars}`);
|
|
628
|
+
lines.push(`- **Duration**: ~${minutes}:${seconds.toString().padStart(2, '0')} at ${bpm} BPM`);
|
|
629
|
+
lines.push(`- **Tracks used**: ${tracksWithClips.length} (with clips)`);
|
|
630
|
+
lines.push('');
|
|
631
|
+
lines.push('**Next steps:**');
|
|
632
|
+
lines.push('- Launch **Scene 1** to start playback');
|
|
633
|
+
lines.push('- Set up **Follow Actions** on each scene to auto-advance');
|
|
634
|
+
lines.push('- Tweak individual clip gains or mutes to taste');
|
|
635
|
+
lines.push('- Add FX automation (filter sweeps, reverb sends) for transitions');
|
|
636
|
+
return lines.join('\n');
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
return `Arrangement failed: ${err.message}\n\n${formatAbletonError()}`;
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
//# sourceMappingURL=arrangement-engine.js.map
|