@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.
Files changed (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. 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
- const normalizedRoot = root.toLowerCase().replace('#', 'b');
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[normalizedRoot] || normalizedRoot;
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
- return this.notes[(idx + semitones) % 12];
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
- const styleMap = {
123
- 'liquid_dnb': 'intelligent_dnb',
124
- 'atmospheric_dnb': 'intelligent_dnb',
125
- 'intelligent': 'intelligent_dnb',
126
- 'liquid': 'intelligent_dnb',
127
- 'atmospheric': 'intelligent_dnb',
128
- 'bukem': 'intelligent_dnb',
129
- 'triphop': 'trip_hop',
130
- 'portishead': 'trip_hop',
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
- const styleMap = {
194
- 'liquid_dnb': 'intelligent_dnb',
195
- 'atmospheric_dnb': 'intelligent_dnb',
196
- 'intelligent': 'intelligent_dnb',
197
- 'liquid': 'intelligent_dnb',
198
- 'bukem': 'intelligent_dnb',
199
- 'triphop': 'trip_hop',
200
- 'portishead': 'trip_hop',
201
- 'massive_attack': 'trip_hop',
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
- // Move by step (1 or 2 scale degrees)
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[voicing] || voicings.triad}`;
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 = 120) {
272
+ generateCompletePattern(style, key = 'C', bpm) {
264
273
  // Handle aliases and special genres
265
- const styleMap = {
266
- 'liquid_dnb': 'intelligent_dnb',
267
- 'atmospheric_dnb': 'intelligent_dnb',
268
- 'intelligent': 'intelligent_dnb',
269
- 'liquid': 'intelligent_dnb',
270
- 'atmospheric': 'intelligent_dnb',
271
- 'bukem': 'intelligent_dnb',
272
- 'triphop': 'trip_hop',
273
- 'portishead': 'trip_hop',
274
- 'massive_attack': 'trip_hop',
275
- 'flying_lotus': 'trip_hop',
276
- 'boombap': 'boom_bap',
277
- 'golden_era': 'boom_bap',
278
- 'premier': 'boom_bap',
279
- 'alchemist': 'boom_bap',
280
- 'daringer': 'boom_bap',
281
- 'hitboy': 'boom_bap'
282
- };
283
- const resolvedStyle = styleMap[style.toLowerCase()] || style.toLowerCase();
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, bpm || 170);
299
+ return this.generateIntelligentDnB(key, tempo);
288
300
  case 'trip_hop':
289
- return this.generateTripHop(key, bpm || 90);
301
+ return this.generateTripHop(key, tempo);
290
302
  case 'boom_bap':
291
- return this.generateBoomBap(key, bpm || 92);
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
- const scale = this.theory.generateScale(key, resolvedStyle === 'jazz' ? 'dorian' : 'minor');
297
- const melody = this.generateMelody(scale);
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
- return `// ${resolvedStyle} pattern in ${key} at ${bpm} BPM
304
- setcpm(${bpm})
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
- setcps(${tempo}/60)
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
- setcps(${tempo}/60/2)
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
- setcps(${tempo}/60/2)
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[variationType] || variations.subtle);
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: `s("bd*8, cp*4").fast(${bars})`,
593
- house: `s("bd*4, cp*2, hh*16").fast(${bars})`,
594
- dnb: `s("bd*8, sn*8").fast(${bars * 2})`,
595
- trap: `s("bd*4, hh*32").fast(${bars})`,
596
- breakbeat: `s("bd cp bd cp, hh*8").iter(4).fast(${bars})`,
597
- 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).fast(${bars})`,
598
- trip_hop: `s("bd ~ sd ~, hh*4").room(0.5).fast(${bars})`,
599
- boom_bap: `s("bd sd bd sd, hh*8").swing(0.1).fast(${bars})`
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
- return fills[style] || fills.techno;
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 Error('Number of sounds must match number of patterns');
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)`;