@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.
Files changed (41) hide show
  1. package/dist/agents/replit.js +1 -1
  2. package/dist/behaviour.d.ts +30 -0
  3. package/dist/behaviour.d.ts.map +1 -0
  4. package/dist/behaviour.js +191 -0
  5. package/dist/behaviour.js.map +1 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/bootstrap.js.map +1 -1
  8. package/dist/integrations/ableton-m4l.d.ts +124 -0
  9. package/dist/integrations/ableton-m4l.d.ts.map +1 -0
  10. package/dist/integrations/ableton-m4l.js +338 -0
  11. package/dist/integrations/ableton-m4l.js.map +1 -0
  12. package/dist/integrations/ableton-osc.d.ts.map +1 -1
  13. package/dist/integrations/ableton-osc.js +6 -2
  14. package/dist/integrations/ableton-osc.js.map +1 -1
  15. package/dist/music-learning.d.ts +181 -0
  16. package/dist/music-learning.d.ts.map +1 -0
  17. package/dist/music-learning.js +340 -0
  18. package/dist/music-learning.js.map +1 -0
  19. package/dist/skill-system.d.ts +68 -0
  20. package/dist/skill-system.d.ts.map +1 -0
  21. package/dist/skill-system.js +386 -0
  22. package/dist/skill-system.js.map +1 -0
  23. package/dist/tools/ableton.d.ts.map +1 -1
  24. package/dist/tools/ableton.js +24 -8
  25. package/dist/tools/ableton.js.map +1 -1
  26. package/dist/tools/arrangement-engine.d.ts +2 -0
  27. package/dist/tools/arrangement-engine.d.ts.map +1 -0
  28. package/dist/tools/arrangement-engine.js +644 -0
  29. package/dist/tools/arrangement-engine.js.map +1 -0
  30. package/dist/tools/index.d.ts.map +1 -1
  31. package/dist/tools/index.js +5 -0
  32. package/dist/tools/index.js.map +1 -1
  33. package/dist/tools/producer-engine.d.ts +71 -0
  34. package/dist/tools/producer-engine.d.ts.map +1 -0
  35. package/dist/tools/producer-engine.js +1859 -0
  36. package/dist/tools/producer-engine.js.map +1 -0
  37. package/dist/tools/sound-designer.d.ts +2 -0
  38. package/dist/tools/sound-designer.d.ts.map +1 -0
  39. package/dist/tools/sound-designer.js +896 -0
  40. package/dist/tools/sound-designer.js.map +1 -0
  41. 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