@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
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/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { MusicTheory } from './MusicTheory.js';
|
|
2
|
+
import { STYLE_ALIASES, resolveDrumStyle, defaultTempoFor } from './StyleRegistry.js';
|
|
3
|
+
import { lookup } from '../utils/TableLookup.js';
|
|
4
|
+
import { ValidationError } from '../utils/CategorisedError.js';
|
|
5
|
+
import { BEATS_PER_CYCLE } from '../utils/Tempo.js';
|
|
6
|
+
/** Longest fill this will assemble, so `bars` cannot build a huge string. */
|
|
7
|
+
const MAX_FILL_BARS = 64;
|
|
2
8
|
export class PatternGenerator {
|
|
3
9
|
theory = new MusicTheory();
|
|
4
10
|
// Note interval lookup for chord calculations
|
|
@@ -7,15 +13,26 @@ export class PatternGenerator {
|
|
|
7
13
|
* Get a note at a specific interval from the root
|
|
8
14
|
*/
|
|
9
15
|
getInterval(root, semitones) {
|
|
10
|
-
|
|
16
|
+
// The `.replace('#','b')` used to run BEFORE this lookup, so its
|
|
17
|
+
// keys could never match — dead code. 'c#' became 'cb', which is not
|
|
18
|
+
// in `notes`, so the root came back unchanged; 'd#' became 'db',
|
|
19
|
+
// which IS in `notes` but is the wrong note, a semitone flat. So
|
|
20
|
+
// getFifth('D#') returned 'ab' and getFifth('F#') returned 'F#',
|
|
21
|
+
// while MusicTheory.getNote got the same intervals right (#318).
|
|
22
|
+
const lower = root.toLowerCase();
|
|
11
23
|
const sharpToFlat = {
|
|
12
|
-
'c#': 'db', 'd#': 'eb', 'f#': 'gb', 'g#': 'ab', 'a#': 'bb'
|
|
24
|
+
'c#': 'db', 'd#': 'eb', 'f#': 'gb', 'g#': 'ab', 'a#': 'bb',
|
|
25
|
+
// Enharmonics that are not in `notes` but are legal input.
|
|
26
|
+
'e#': 'f', 'b#': 'c', 'cb': 'b', 'fb': 'e',
|
|
13
27
|
};
|
|
14
|
-
const searchRoot = sharpToFlat[
|
|
28
|
+
const searchRoot = Object.hasOwn(sharpToFlat, lower) ? sharpToFlat[lower] : lower;
|
|
15
29
|
const idx = this.notes.indexOf(searchRoot);
|
|
16
30
|
if (idx === -1)
|
|
17
31
|
return root;
|
|
18
|
-
|
|
32
|
+
// Modulo alone goes negative for a negative interval; none are used
|
|
33
|
+
// today, but the guard costs nothing and the bug it prevents is the
|
|
34
|
+
// same shape as the one above.
|
|
35
|
+
return this.notes[(((idx + semitones) % 12) + 12) % 12];
|
|
19
36
|
}
|
|
20
37
|
getFourth(root) { return this.getInterval(root, 5); }
|
|
21
38
|
getFifth(root) { return this.getInterval(root, 7); }
|
|
@@ -119,26 +136,15 @@ export class PatternGenerator {
|
|
|
119
136
|
]
|
|
120
137
|
};
|
|
121
138
|
// Handle aliases
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
'massive_attack': 'trip_hop',
|
|
132
|
-
'flying_lotus': 'trip_hop',
|
|
133
|
-
'boombap': 'boom_bap',
|
|
134
|
-
'golden_era': 'boom_bap',
|
|
135
|
-
'premier': 'boom_bap',
|
|
136
|
-
'alchemist': 'boom_bap',
|
|
137
|
-
'daringer': 'boom_bap',
|
|
138
|
-
'hitboy': 'boom_bap'
|
|
139
|
-
};
|
|
140
|
-
const resolvedStyle = styleMap[style.toLowerCase()] || style.toLowerCase();
|
|
141
|
-
const stylePatterns = patterns[resolvedStyle] || patterns.techno;
|
|
139
|
+
// hasOwn, not `??`: STYLE_ALIASES is a plain object literal, so
|
|
140
|
+
// STYLE_ALIASES['constructor'] returns Object and ['__proto__'] the
|
|
141
|
+
// prototype. Neither is nullish, so `??` never fired and the result
|
|
142
|
+
// was used as a string — TypeError, categorised `internal` (#295).
|
|
143
|
+
const lowerStyle = String(style ?? '').toLowerCase();
|
|
144
|
+
const resolvedStyle = Object.hasOwn(STYLE_ALIASES, lowerStyle)
|
|
145
|
+
? STYLE_ALIASES[lowerStyle]
|
|
146
|
+
: lowerStyle;
|
|
147
|
+
const stylePatterns = lookup(patterns, resolvedStyle, patterns.techno);
|
|
142
148
|
const index = Math.min(Math.floor(complexity * stylePatterns.length), stylePatterns.length - 1);
|
|
143
149
|
return stylePatterns[index];
|
|
144
150
|
}
|
|
@@ -190,21 +196,15 @@ export class PatternGenerator {
|
|
|
190
196
|
.release(0.3)`
|
|
191
197
|
};
|
|
192
198
|
// Handle aliases
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
'boombap': 'boom_bap',
|
|
203
|
-
'golden_era': 'boom_bap',
|
|
204
|
-
'premier': 'boom_bap'
|
|
205
|
-
};
|
|
206
|
-
const resolvedStyle = styleMap[style.toLowerCase()] || style.toLowerCase();
|
|
207
|
-
return patterns[resolvedStyle] || patterns.techno;
|
|
199
|
+
// hasOwn, not `??`: STYLE_ALIASES is a plain object literal, so
|
|
200
|
+
// STYLE_ALIASES['constructor'] returns Object and ['__proto__'] the
|
|
201
|
+
// prototype. Neither is nullish, so `??` never fired and the result
|
|
202
|
+
// was used as a string — TypeError, categorised `internal` (#295).
|
|
203
|
+
const lowerStyle = String(style ?? '').toLowerCase();
|
|
204
|
+
const resolvedStyle = Object.hasOwn(STYLE_ALIASES, lowerStyle)
|
|
205
|
+
? STYLE_ALIASES[lowerStyle]
|
|
206
|
+
: lowerStyle;
|
|
207
|
+
return lookup(patterns, resolvedStyle, patterns.techno);
|
|
208
208
|
}
|
|
209
209
|
/**
|
|
210
210
|
* Generates a melodic pattern from a scale
|
|
@@ -222,7 +222,8 @@ export class PatternGenerator {
|
|
|
222
222
|
const useStep = Math.random() < stepProbability;
|
|
223
223
|
let noteIndex;
|
|
224
224
|
if (useStep) {
|
|
225
|
-
//
|
|
225
|
+
// One scale degree, up or down. The comment used to claim "1 or
|
|
226
|
+
// 2" while the code only ever moved by 1 (#324).
|
|
226
227
|
const step = Math.random() < 0.5 ? 1 : -1;
|
|
227
228
|
noteIndex = (lastNoteIndex + step + scale.length) % scale.length;
|
|
228
229
|
}
|
|
@@ -246,12 +247,20 @@ export class PatternGenerator {
|
|
|
246
247
|
generateChords(progression, voicing = 'triad') {
|
|
247
248
|
const voicings = {
|
|
248
249
|
triad: '.struct("1 ~ ~ ~")',
|
|
250
|
+
// `.add(note("7"))` transposes every note of the chord up 7
|
|
251
|
+
// semitones — a perfect FIFTH, not a seventh. Strudel spells a
|
|
252
|
+
// seventh chord in the chord symbol itself, so the honest fix is
|
|
253
|
+
// to stop pretending this voicing adds one: it is a fifth-doubled
|
|
254
|
+
// stab, and is now named for what it does. `seventh` is kept as
|
|
255
|
+
// an alias so existing callers do not break, but it maps to the
|
|
256
|
+
// same thing rather than to a lie (#324).
|
|
257
|
+
fifths: '.struct("1 ~ ~ ~").add(note("7"))',
|
|
249
258
|
seventh: '.struct("1 ~ ~ ~").add(note("7"))',
|
|
250
259
|
sustained: '.attack(0.5).release(2)',
|
|
251
260
|
stab: '.struct("1 ~ 1 ~").release(0.1)',
|
|
252
261
|
pad: '.attack(2).release(4).room(0.8)'
|
|
253
262
|
};
|
|
254
|
-
return `note("<${progression}>").s("sawtooth")${voicings
|
|
263
|
+
return `note("<${progression}>").s("sawtooth")${lookup(voicings, voicing, voicings.triad)}`;
|
|
255
264
|
}
|
|
256
265
|
/**
|
|
257
266
|
* Generates a complete multi-layer musical pattern
|
|
@@ -260,48 +269,73 @@ export class PatternGenerator {
|
|
|
260
269
|
* @param bpm - Tempo in beats per minute (default: 120)
|
|
261
270
|
* @returns Complete Strudel pattern with drums, bass, chords, and melody
|
|
262
271
|
*/
|
|
263
|
-
generateCompletePattern(style, key = 'C', bpm
|
|
272
|
+
generateCompletePattern(style, key = 'C', bpm) {
|
|
264
273
|
// Handle aliases and special genres
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
274
|
+
// hasOwn, not `??`: STYLE_ALIASES is a plain object literal, so
|
|
275
|
+
// STYLE_ALIASES['constructor'] returns Object and ['__proto__'] the
|
|
276
|
+
// prototype. Neither is nullish, so `??` never fired and the result
|
|
277
|
+
// was used as a string — TypeError, categorised `internal` (#295).
|
|
278
|
+
const lowerStyle = String(style ?? '').toLowerCase();
|
|
279
|
+
const resolvedStyle = Object.hasOwn(STYLE_ALIASES, lowerStyle)
|
|
280
|
+
? STYLE_ALIASES[lowerStyle]
|
|
281
|
+
: lowerStyle;
|
|
282
|
+
// The style's own tempo when the caller did not name one.
|
|
283
|
+
//
|
|
284
|
+
// The parameter defaulted to 120, so `bpm || 170` below could never
|
|
285
|
+
// reach 170 — `bpm` was never falsy. Every per-style fallback here
|
|
286
|
+
// was dead code and every specialized genre generated at 120:
|
|
287
|
+
// intelligent_dnb at 120 rather than 170, trip_hop at 120 rather
|
|
288
|
+
// than 90, boom_bap at 120 rather than 92, and plain techno at 120
|
|
289
|
+
// rather than 130.
|
|
290
|
+
//
|
|
291
|
+
// `compose` passes `defaultTempoFor(style)` explicitly, so the
|
|
292
|
+
// shipped tool path was already right and only direct callers saw
|
|
293
|
+
// this. Reading the same registry here makes the two agree instead
|
|
294
|
+
// of relying on every caller to remember (#482).
|
|
295
|
+
const tempo = bpm ?? defaultTempoFor(resolvedStyle);
|
|
284
296
|
// Use specialized generators for new genres
|
|
285
297
|
switch (resolvedStyle) {
|
|
286
298
|
case 'intelligent_dnb':
|
|
287
|
-
return this.generateIntelligentDnB(key,
|
|
299
|
+
return this.generateIntelligentDnB(key, tempo);
|
|
288
300
|
case 'trip_hop':
|
|
289
|
-
return this.generateTripHop(key,
|
|
301
|
+
return this.generateTripHop(key, tempo);
|
|
290
302
|
case 'boom_bap':
|
|
291
|
-
return this.generateBoomBap(key,
|
|
303
|
+
return this.generateBoomBap(key, tempo);
|
|
292
304
|
}
|
|
293
305
|
// Default generation for other styles
|
|
294
306
|
const drums = this.generateDrumPattern(resolvedStyle, 0.7);
|
|
295
307
|
const bass = this.generateBassline(key, resolvedStyle);
|
|
296
|
-
|
|
297
|
-
|
|
308
|
+
// Melody scale and chord progression must agree on mode.
|
|
309
|
+
//
|
|
310
|
+
// The scale was hardcoded to `minor` for everything except jazz,
|
|
311
|
+
// while house / ambient / default got the `pop` progression — a
|
|
312
|
+
// major I-V-vi-IV. So house in C produced <C G Am F> under a melody
|
|
313
|
+
// of g#3 d#4 f4 c5 …, sounding Ab and Eb over C major and F major
|
|
314
|
+
// triads (#324). Pick the progression first, then take the scale
|
|
315
|
+
// that belongs to it.
|
|
298
316
|
const chordStyle = resolvedStyle === 'jazz' ? 'jazz' :
|
|
299
317
|
resolvedStyle === 'house' ? 'pop' :
|
|
300
318
|
resolvedStyle === 'techno' ? 'edm' : 'pop';
|
|
319
|
+
const scaleForChords = {
|
|
320
|
+
jazz: 'major',
|
|
321
|
+
pop: 'major',
|
|
322
|
+
edm: 'minor',
|
|
323
|
+
modal: 'minor',
|
|
324
|
+
};
|
|
325
|
+
const scale = this.theory.generateScale(key, lookup(scaleForChords, chordStyle, 'minor'));
|
|
326
|
+
const melody = this.generateMelody(scale);
|
|
301
327
|
const progression = this.theory.generateChordProgression(key, chordStyle);
|
|
302
328
|
const chords = this.generateChords(progression, resolvedStyle === 'ambient' ? 'pad' : 'stab');
|
|
303
|
-
|
|
304
|
-
|
|
329
|
+
// The header names what the drums actually are. `resolvedStyle` is
|
|
330
|
+
// only alias-resolved, so an unknown genre stamped its own name over
|
|
331
|
+
// a techno pattern (#279).
|
|
332
|
+
const drumStyle = resolveDrumStyle(resolvedStyle);
|
|
333
|
+
const header = drumStyle.supported
|
|
334
|
+
? `// ${drumStyle.resolved} pattern in ${key} at ${tempo} BPM`
|
|
335
|
+
: `// ${drumStyle.resolved} pattern in ${key} at ${tempo} BPM` +
|
|
336
|
+
` (no drums defined for "${resolvedStyle}")`;
|
|
337
|
+
return `${header}
|
|
338
|
+
setcpm(${tempo}/${String(BEATS_PER_CYCLE)})
|
|
305
339
|
|
|
306
340
|
stack(
|
|
307
341
|
// Drums
|
|
@@ -328,7 +362,7 @@ stack(
|
|
|
328
362
|
const third = this.getMinorThird(safeKey);
|
|
329
363
|
return `// Intelligent DnB in ${key} at ${tempo} BPM
|
|
330
364
|
// Style: LTJ Bukem / Good Looking Records
|
|
331
|
-
|
|
365
|
+
setcpm(${tempo}/${String(BEATS_PER_CYCLE)})
|
|
332
366
|
samples('github:tidalcycles/dirt-samples')
|
|
333
367
|
|
|
334
368
|
let chords = chord("<${safeKey}m9 ${fourth}m9 ${seventh}m9 ${third}maj7>/4").dict('ireal')
|
|
@@ -407,7 +441,7 @@ stack(
|
|
|
407
441
|
const seventh = this.getMinorSeventh(safeKey);
|
|
408
442
|
return `// Trip Hop in ${key} at ${tempo} BPM
|
|
409
443
|
// Style: Portishead / Massive Attack
|
|
410
|
-
|
|
444
|
+
setcpm(${tempo}/${String(BEATS_PER_CYCLE)})
|
|
411
445
|
|
|
412
446
|
stack(
|
|
413
447
|
// === DRUMS - Slow, heavy, dusty ===
|
|
@@ -494,7 +528,7 @@ stack(
|
|
|
494
528
|
const minorThird = this.getMinorThird(safeKey);
|
|
495
529
|
return `// Boom Bap in ${key} at ${tempo} BPM
|
|
496
530
|
// Style: DJ Premier / Alchemist / Daringer
|
|
497
|
-
|
|
531
|
+
setcpm(${tempo}/${String(BEATS_PER_CYCLE)})
|
|
498
532
|
|
|
499
533
|
stack(
|
|
500
534
|
// === DRUMS - Hard hitting, swing ===
|
|
@@ -573,13 +607,27 @@ stack(
|
|
|
573
607
|
*/
|
|
574
608
|
generateVariation(pattern, variationType = 'subtle') {
|
|
575
609
|
const variations = {
|
|
610
|
+
// `.rev` and `.palindrome` need their CALL PARENTHESES.
|
|
611
|
+
//
|
|
612
|
+
// `pattern.rev` is a function, not a Pattern, so `every(4, x =>
|
|
613
|
+
// x.rev)` hands Strudel a function where it wants a pattern.
|
|
614
|
+
// Measured against @strudel/core, on the cycles where the
|
|
615
|
+
// transform applies:
|
|
616
|
+
//
|
|
617
|
+
// sequence(pure('bd'), pure('sd')) -> "bd"@0 "sd"@0.5
|
|
618
|
+
// .every(4, x => x.rev) -> undefined@0
|
|
619
|
+
// .every(4, x => x.rev()) -> "bd"@0.5 "sd"@0
|
|
620
|
+
//
|
|
621
|
+
// One valueless event in place of the music, every fourth cycle,
|
|
622
|
+
// and it throws nothing — so a pattern that looks right goes
|
|
623
|
+
// partly silent (#482).
|
|
576
624
|
subtle: '.sometimes(x => x.fast(2))',
|
|
577
|
-
moderate: '.every(4, x => x.rev).sometimes(x => x.fast(2))',
|
|
625
|
+
moderate: '.every(4, x => x.rev()).sometimes(x => x.fast(2))',
|
|
578
626
|
extreme: '.every(2, x => x.jux(rev)).sometimes(x => x.iter(4))',
|
|
579
|
-
glitch: '.sometimes(x => x.chop(8).rev).rarely(x => x.speed(-1))',
|
|
580
|
-
evolving: '.slow(4).every(8, x => x.fast(2)).every(16, x => x.palindrome)'
|
|
627
|
+
glitch: '.sometimes(x => x.chop(8).rev()).rarely(x => x.speed(-1))',
|
|
628
|
+
evolving: '.slow(4).every(8, x => x.fast(2)).every(16, x => x.palindrome())'
|
|
581
629
|
};
|
|
582
|
-
return pattern + (variations
|
|
630
|
+
return pattern + lookup(variations, variationType, variations.subtle);
|
|
583
631
|
}
|
|
584
632
|
/**
|
|
585
633
|
* Generates a drum fill for transitions
|
|
@@ -588,17 +636,52 @@ stack(
|
|
|
588
636
|
* @returns Strudel fill pattern code
|
|
589
637
|
*/
|
|
590
638
|
generateFill(style, bars = 1) {
|
|
639
|
+
// One BAR of material per style. `bars` assembles them below.
|
|
591
640
|
const fills = {
|
|
592
|
-
techno:
|
|
593
|
-
house:
|
|
594
|
-
dnb
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
641
|
+
techno: 's("bd*8, cp*4")',
|
|
642
|
+
house: 's("bd*4, cp*2, hh*16")',
|
|
643
|
+
// dnb's fill is double-time by character, not by bar count.
|
|
644
|
+
dnb: 's("bd*8, sn*8").fast(2)',
|
|
645
|
+
trap: 's("bd*4, hh*32")',
|
|
646
|
+
breakbeat: 's("bd cp bd cp, hh*8").iter(4)',
|
|
647
|
+
intelligent_dnb: 's("breaks165").fit().slice(16, "0 2 4 6 8 10 12 14 1 3 5 7 9 11 13 15").gain(0.7)',
|
|
648
|
+
trip_hop: 's("bd ~ sd ~, hh*4").room(0.5)',
|
|
649
|
+
boom_bap: 's("bd sd bd sd, hh*8").swing(0.1)',
|
|
600
650
|
};
|
|
601
|
-
|
|
651
|
+
// Resolve the style the same way every other generator does.
|
|
652
|
+
//
|
|
653
|
+
// `lookup` is an exact-key match, so this alone saw neither the
|
|
654
|
+
// alias table nor a capital letter: `generateFill('triphop')` and
|
|
655
|
+
// `generateFill('House')` both fell through to the techno fill,
|
|
656
|
+
// while `generateDrumPattern` resolved both correctly (#482).
|
|
657
|
+
const lowerStyle = String(style ?? '').toLowerCase();
|
|
658
|
+
const resolvedStyle = Object.hasOwn(STYLE_ALIASES, lowerStyle)
|
|
659
|
+
? STYLE_ALIASES[lowerStyle]
|
|
660
|
+
: lowerStyle;
|
|
661
|
+
const base = lookup(fills, resolvedStyle, fills.techno);
|
|
662
|
+
// `bars` is a LENGTH, which is what the tool schema promises and
|
|
663
|
+
// what the handler's "Generated N bar fill" message claims.
|
|
664
|
+
//
|
|
665
|
+
// It used to append `.fast(bars)`. In Strudel that compresses the
|
|
666
|
+
// pattern into 1/N of ONE cycle and repeats it N times inside that
|
|
667
|
+
// cycle, so asking for more bars made the fill shorter and denser
|
|
668
|
+
// and the output occupied one bar however large `bars` got. The
|
|
669
|
+
// schema and the message were both false for any bars != 1, and
|
|
670
|
+
// the unit test asserted the literal text `.fast(2)` — locking in
|
|
671
|
+
// the implementation rather than any intended behaviour, the exact
|
|
672
|
+
// anti-pattern the tempo postmortem warns about (#482).
|
|
673
|
+
//
|
|
674
|
+
// `cat` takes one cycle per argument, measured:
|
|
675
|
+
//
|
|
676
|
+
// cat(a, a, a, b) -> cycle 0: a 1: a 2: a 3: b 4: a
|
|
677
|
+
//
|
|
678
|
+
// so N bars is N arguments, and the last one is the base at double
|
|
679
|
+
// time — the ordinary way a fill builds into the next phrase.
|
|
680
|
+
const count = Number.isInteger(bars) && bars > 1 ? Math.min(bars, MAX_FILL_BARS) : 1;
|
|
681
|
+
if (count === 1)
|
|
682
|
+
return base;
|
|
683
|
+
const earlier = new Array(count - 1).fill(base);
|
|
684
|
+
return `cat(${[...earlier, `${base}.fast(2)`].join(', ')})`;
|
|
602
685
|
}
|
|
603
686
|
/**
|
|
604
687
|
* Generates a transition between two musical styles
|
|
@@ -637,7 +720,7 @@ stack(
|
|
|
637
720
|
*/
|
|
638
721
|
generatePolyrhythm(sounds, patterns) {
|
|
639
722
|
if (sounds.length !== patterns.length) {
|
|
640
|
-
throw new
|
|
723
|
+
throw new ValidationError('Number of sounds must match number of patterns');
|
|
641
724
|
}
|
|
642
725
|
const rhythms = sounds.map((sound, i) => {
|
|
643
726
|
return `s("${sound}").euclid(${patterns[i]}, 16)`;
|