@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
@@ -0,0 +1,474 @@
1
+ /**
2
+ * MIDI Import Service (Phase 1 — literal transcription).
3
+ *
4
+ * Mirror of MIDIExportService: parses a `.mid` byte buffer and emits a
5
+ * playable Strudel pattern. Phase 1 is deliberately literal — no motif
6
+ * detection, no bar deduplication, no LLM idiomatic post-pass. Output
7
+ * is verbose-but-correct. Tracked by #201.
8
+ *
9
+ * Drum tracks (MIDI channel index 9, i.e. GM ch10) emit **one s() lane
10
+ * per distinct sample name**, not one merged string. This avoids the
11
+ * collision drop the prototyping spike exposed: events sharing a grid
12
+ * step in a single string can only hold one token, so hat hits that
13
+ * land on the same step as a kick get silently overwritten.
14
+ *
15
+ * Note LENGTH is carried, as a mini-notation weight (#477). Strudel
16
+ * divides a bar by total weight, so `c4@8` plus eight rests is still
17
+ * sixteen units and holds c4 for half the bar without moving anything
18
+ * beside it. Before that every note occupied exactly one step, so a
19
+ * whole-bar note, a half note and a staccato stab all emitted the
20
+ * identical pattern and nothing said the length had gone.
21
+ *
22
+ * Round-trip is not lossless: timing is quantized, velocity is dropped,
23
+ * voice information for two-voice melodies on one channel collapses
24
+ * into chord tokens. Phase 2-4 issues track each of those.
25
+ *
26
+ * Three further losses, reported in `summary.discarded` rather than
27
+ * left silent (#336, #477): only the FIRST tempo is read, so a file
28
+ * with tempo changes imports at its opening tempo; the grid is fixed at
29
+ * 4/4, so any other meter is laid onto 4/4 bars that will not line up;
30
+ * and a note still sounding at a bar line is held to the end of its bar
31
+ * rather than across it, since a bar is one token list and cannot lend
32
+ * weight to the next.
33
+ *
34
+ * @example
35
+ * const svc = new MIDIImportService();
36
+ * const result = svc.convertBuffer(buffer);
37
+ * console.log(result.pattern);
38
+ * // setcpm(120/4)
39
+ * // stack(
40
+ * // s("bd ~ ~ ~ ~ ~ ~ ~ bd ~ ~ ~ ~ ~ ~ ~"),
41
+ * // ...
42
+ * // )
43
+ */
44
+ import * as midiModule from '@tonejs/midi';
45
+ import { BEATS_PER_BAR, BEATS_PER_CYCLE } from '../utils/Tempo.js';
46
+ import { ValidationError } from '../utils/CategorisedError.js';
47
+ const Midi = midiModule.Midi || midiModule.default?.Midi;
48
+ /**
49
+ * GM percussion map → Strudel sample names. Comprehensive enough for
50
+ * most real-world drum tracks; the user can override or extend via
51
+ * `drum_map`.
52
+ *
53
+ * Toms collapse onto {lt, mt, ht} since Strudel's default sample bank
54
+ * exposes those three rather than the GM five. Anything not listed
55
+ * falls back to `~` and is surfaced in `summary.unmapped_drums`.
56
+ */
57
+ export const GM_DRUM_MAP = {
58
+ 35: 'bd', 36: 'bd', // bass drum
59
+ 37: 'rim', // side stick
60
+ 38: 'sd', 40: 'sd', // snare
61
+ 39: 'cp', // hand clap
62
+ 41: 'lt', 43: 'lt', // low/floor toms
63
+ 42: 'hh', 44: 'hh', // closed / pedal hat
64
+ 45: 'mt', 47: 'mt', // mid toms
65
+ 46: 'oh', // open hat
66
+ 48: 'ht', 50: 'ht', // high toms
67
+ 49: 'cr', 52: 'cr', 55: 'cr', 57: 'cr', // crashes
68
+ 51: 'rd', 53: 'rd', 59: 'rd', // rides
69
+ 54: 'tb', // tambourine
70
+ 56: 'cb', // cowbell
71
+ };
72
+ /** Maximum tracks emitted before we bail out (protects against pathological input). */
73
+ export const MAX_TRACKS = 64;
74
+ /** Maximum total note count after parse — protects memory in the renderer. */
75
+ export const MAX_NOTES = 50_000;
76
+ /**
77
+ * Cap on bars rendered from a file (~8 minutes at 4/4, 120bpm).
78
+ *
79
+ * Without this, `bars` came straight from the file's own timing and fed
80
+ * `new Array(bars * stepsPerCycle)`. A single note at a huge tick offset
81
+ * with a tiny ppq made that astronomically large: a 36-byte file rendered
82
+ * a 17.8MB pattern in 174MB of heap, and a 37-byte one aborted V8
83
+ * outright with a FATAL ERROR the tool's try/catch cannot intercept —
84
+ * killing the server and every open browser session with it (#235).
85
+ * The 1MB input cap in storage.ts does not help: the amplification is
86
+ * time-based, not size-based.
87
+ */
88
+ export const MAX_BARS = 512;
89
+ /** Convert MIDI note number to Strudel pitch notation (e.g. 60 → "c4"). */
90
+ function midiToNoteName(midi) {
91
+ const names = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
92
+ const octave = Math.floor(midi / 12) - 1;
93
+ return `${names[midi % 12]}${octave}`;
94
+ }
95
+ /**
96
+ * Build the per-step token array for a single drum sample within a track.
97
+ * Returns a length-`totalSteps` string array, each slot either the sample
98
+ * name or `~` (rest). The caller renders one s() lane per result.
99
+ */
100
+ function buildDrumLane(notes, sample, stepsPerBeat, totalSteps) {
101
+ const slots = new Array(totalSteps).fill('~');
102
+ for (const n of notes) {
103
+ const step = Math.round(n.beats * stepsPerBeat);
104
+ if (step < 0 || step >= totalSteps)
105
+ continue;
106
+ slots[step] = sample;
107
+ }
108
+ return slots;
109
+ }
110
+ /**
111
+ * Build the per-step token array for a pitched track. Simultaneous notes
112
+ * at the same step merge into `[a,b,c]` chord tokens (Strudel's parallel
113
+ * notation within a single step).
114
+ */
115
+ function buildPitchLane(notes, stepsPerBeat, totalSteps) {
116
+ const slots = new Array(totalSteps).fill(null);
117
+ // How long each occupied step holds, in steps. A note's own length,
118
+ // clipped below so it never runs into the next onset on this lane.
119
+ const holds = new Array(totalSteps).fill(1);
120
+ for (const n of notes) {
121
+ const step = Math.round(n.beats * stepsPerBeat);
122
+ if (step < 0 || step >= totalSteps)
123
+ continue;
124
+ const name = midiToNoteName(n.midi);
125
+ if (slots[step] === null) {
126
+ slots[step] = [name];
127
+ }
128
+ else {
129
+ slots[step].push(name);
130
+ }
131
+ if (n.lengthBeats !== undefined) {
132
+ // A chord's members share one token, so the shortest of them sets
133
+ // the hold — sustaining the others would be inventing notes.
134
+ const inSteps = Math.max(1, Math.round(n.lengthBeats * stepsPerBeat));
135
+ holds[step] = holds[step] === 1 && slots[step].length === 1
136
+ ? inSteps
137
+ : Math.min(holds[step], inSteps);
138
+ }
139
+ }
140
+ // Clip each hold at the next occupied step: one lane is one voice.
141
+ for (let i = 0; i < totalSteps; i++) {
142
+ if (slots[i] === null)
143
+ continue;
144
+ let next = i + 1;
145
+ while (next < totalSteps && slots[next] === null)
146
+ next++;
147
+ holds[i] = Math.min(holds[i], next - i);
148
+ }
149
+ return {
150
+ slots: slots.map((s) => {
151
+ if (s === null)
152
+ return '~';
153
+ if (s.length === 1)
154
+ return s[0];
155
+ return `[${s.join(',')}]`;
156
+ }),
157
+ holds,
158
+ };
159
+ }
160
+ /** Render a per-step token array as a Strudel mini-notation string, split into bars. */
161
+ function renderBars(slots, holds, stepsPerCycle, bars, onClipped = () => undefined) {
162
+ const barStrings = [];
163
+ for (let b = 0; b < bars; b++) {
164
+ const tokens = [];
165
+ // Weights within a bar must sum to `stepsPerCycle`, or the bar's
166
+ // own division changes. `c4@4` followed by twelve rests is 4+12=16
167
+ // units, so c4 occupies 4/16 of the cycle — measured:
168
+ //
169
+ // mini('c4@4 ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~') -> "c4"[0.000-0.250]
170
+ //
171
+ // A hold is clipped at the bar boundary, since a bar is one token
172
+ // list and cannot lend weight to the next (#477).
173
+ for (let i = 0; i < stepsPerCycle;) {
174
+ const at = b * stepsPerCycle + i;
175
+ // No `??` fallback: `at` is a number in [0, totalSteps) — the
176
+ // loop bounds are `bars * stepsPerCycle`, which is exactly the
177
+ // length of both arrays — so neither read can miss. The #308
178
+ // guard flags `table[key] ?? fallback` on sight, and it is right
179
+ // to: the fallback would have been dead code hiding a bug.
180
+ const token = slots[at];
181
+ const wanted = Math.max(holds[at], 1);
182
+ const held = Math.min(wanted, stepsPerCycle - i);
183
+ if (held < wanted)
184
+ onClipped();
185
+ tokens.push(held > 1 ? `${token}@${String(held)}` : token);
186
+ i += held;
187
+ }
188
+ barStrings.push(tokens.join(' '));
189
+ }
190
+ if (barStrings.length === 1) {
191
+ return barStrings[0];
192
+ }
193
+ // Wrap each bar in [...] so each cycle plays the whole bar; alternate per outer cycle.
194
+ return `<${barStrings.map((s) => `[${s}]`).join(' ')}>`;
195
+ }
196
+ export class MIDIImportService {
197
+ /**
198
+ * Convert a `.mid` byte buffer to a Strudel pattern string.
199
+ *
200
+ * @throws Error on parse failure or input that exceeds the configured caps.
201
+ */
202
+ convertBuffer(buffer, options = {}) {
203
+ const stepsPerCycle = options.steps_per_cycle ?? 16;
204
+ if (!Number.isInteger(stepsPerCycle) || stepsPerCycle < 1 || stepsPerCycle > 64) {
205
+ throw new Error(`Invalid steps_per_cycle: ${stepsPerCycle}. Must be an integer in [1, 64].`);
206
+ }
207
+ let midi;
208
+ try {
209
+ midi = new Midi(buffer);
210
+ }
211
+ catch (e) {
212
+ // Describe the file, not the exception. Interpolating the library's
213
+ // error produced "Cannot read properties of undefined (reading
214
+ // 'forEach')" for a truncated header — which reads as a server
215
+ // crash, and a caller cannot act on a TypeError from a parser's
216
+ // internals (#280). The underlying text is kept at the end for
217
+ // anyone debugging, after the part that says what to do.
218
+ const detail = String(e?.message ?? e);
219
+ // Don't assert the chunk name. @tonejs/midi reports the header as
220
+ // 'MHdr' while the actual bytes are 'MThd', so naming one while
221
+ // quoting the other read as a contradiction to whoever was
222
+ // debugging (#297). Say what to check instead.
223
+ throw new Error('Invalid MIDI file: could not be parsed. It may be truncated, or not a ' +
224
+ `MIDI file at all. Check the file opens in another MIDI tool. (${detail})`);
225
+ }
226
+ if (midi.tracks.length > MAX_TRACKS) {
227
+ throw new Error(`MIDI file has too many tracks (${midi.tracks.length} > ${MAX_TRACKS}).`);
228
+ }
229
+ // Ticks per quarter note. Everything positional below is derived
230
+ // from ticks so it stays independent of the file's tempo map (#478).
231
+ const ppq = midi.header.ppq;
232
+ if (!Number.isFinite(ppq) || ppq <= 0) {
233
+ throw new ValidationError(`MIDI file declares an unusable PPQ: ${String(ppq)}.`);
234
+ }
235
+ // Count total notes pre-render so we can fail fast on pathological inputs.
236
+ let totalNotes = 0;
237
+ let lastEndBeats = 0;
238
+ for (const t of midi.tracks) {
239
+ for (const n of t.notes) {
240
+ totalNotes++;
241
+ if (totalNotes > MAX_NOTES) {
242
+ throw new Error(`MIDI file has too many notes (>${MAX_NOTES}).`);
243
+ }
244
+ const endBeats = (n.ticks + n.durationTicks) / ppq;
245
+ if (endBeats > lastEndBeats)
246
+ lastEndBeats = endBeats;
247
+ }
248
+ }
249
+ const discarded = [];
250
+ // The file's tempo, not a rounded one.
251
+ //
252
+ // The grid below is derived from this while note times come from
253
+ // @tonejs/midi at the file's TRUE tempo, so rounding here made the
254
+ // two disagree and the error accumulated. Measured on a 100.4 BPM
255
+ // file with a note on beat 1 of each of 16 bars:
256
+ //
257
+ // bar 0 first onset at step 0
258
+ // bar 13 first onset at step 15 <- a full step early
259
+ //
260
+ // The downbeat had walked backwards past the bar line (#433).
261
+ //
262
+ // `exactBpm` drives the grid and the emitted `setcpm`, so the
263
+ // pattern plays at the tempo the file states; `bpm` stays rounded
264
+ // for the summary a human reads.
265
+ // Rounded to three decimals, once, and used for BOTH the grid and
266
+ // the emitted `setcpm` so the two cannot disagree.
267
+ //
268
+ // A MIDI tempo is stored as microseconds per quarter note, so
269
+ // @tonejs/midi hands back 100.40009437608872 for a 100.4 BPM file —
270
+ // 17 digits of float noise to write into a pattern. Three decimals
271
+ // is a relative error near 1e-6: about 40 microseconds of drift
272
+ // across sixteen bars, against the 0.15 SECONDS that rounding to an
273
+ // integer produced.
274
+ const exactBpm = Math.round((midi.header.tempos[0]?.bpm ?? 120) * 1000) / 1000;
275
+ const bpm = Math.round(exactBpm);
276
+ if (midi.header.tempos.length > 1) {
277
+ const others = midi.header.tempos
278
+ .slice(1, 4)
279
+ .map((t) => Math.round(t.bpm))
280
+ .join(', ');
281
+ discarded.push(`${String(midi.header.tempos.length - 1)} tempo change(s) — imported at the ` +
282
+ `first tempo (${String(bpm)} BPM); later tempos (${others}) were dropped`);
283
+ }
284
+ // The grid below is BEATS_PER_BAR wide, so any other meter is laid
285
+ // onto a 4/4 grid.
286
+ const signature = midi.header.timeSignatures?.[0]?.timeSignature;
287
+ if (Array.isArray(signature) && (signature[0] !== 4 || signature[1] !== 4)) {
288
+ discarded.push(`time signature ${String(signature[0])}/${String(signature[1])} — ` +
289
+ 'the grid is 4/4, so bar lines will not line up');
290
+ }
291
+ // Positions come from TICKS, not from seconds.
292
+ //
293
+ // The seconds chain that used to live here — secondsPerBeat,
294
+ // secondsPerBar, stepSeconds — is gone with the last reader of it;
295
+ // `eslint` is what noticed, since `tsc` does not flag an unused
296
+ // local. Export still reads BEATS_PER_BAR, so a round trip cannot
297
+ // rescale time by disagreeing about the bar (#336, #397).
298
+ //
299
+ // `@tonejs/midi` resolves `note.time` against the file's whole tempo
300
+ // MAP, while the pattern emits ONE `setcpm`. So a tempo change was
301
+ // baked into the spacing of a pattern that declares a constant
302
+ // tempo. Measured on eight even quarter notes over two bars with
303
+ // 120 BPM at tick 0 and 60 BPM at tick 1920:
304
+ //
305
+ // <[c4 ~ ~ ~ c#4 ~ ~ ~ d4 ~ ~ ~ d#4 ~ ~ ~]
306
+ // [e4 ~ ~ ~ ~ ~ ~ ~ f4 ~ ~ ~ ~ ~ ~ ~]
307
+ // [f#4 ~ ~ ~ ~ ~ ~ ~ g4 ~ ~ ~ ~ ~ ~ ~]>
308
+ //
309
+ // Three bars from a two-bar file, the last four notes at half
310
+ // speed — while `discarded` said the later tempo "was dropped".
311
+ // It was not dropped; it was applied to the positions and denied in
312
+ // the summary, which is worse than dropping it (#478).
313
+ //
314
+ // Ticks are tempo-independent by definition, so this is now true.
315
+ const stepsPerBeat = stepsPerCycle / BEATS_PER_BAR;
316
+ if (options.bars !== undefined) {
317
+ if (!Number.isInteger(options.bars) || options.bars < 1 || options.bars > MAX_BARS) {
318
+ throw new Error(`Invalid bars: ${options.bars}. Must be an integer in [1, ${MAX_BARS}].`);
319
+ }
320
+ }
321
+ const fileBars = Math.max(1, Math.ceil(lastEndBeats / BEATS_PER_BAR) || 1);
322
+ if (fileBars > MAX_BARS && options.bars === undefined) {
323
+ throw new Error(`MIDI file spans too many bars (${fileBars} > ${MAX_BARS}). ` +
324
+ `Pass bars=<n> to import a prefix.`);
325
+ }
326
+ // A CAP, which is what the option is documented as. It used to be
327
+ // taken literally in both directions, so a one-bar file imported
328
+ // with `bars: 2` emitted two bars — the second one entirely rests —
329
+ // and reported `bars: 2`. A cap that lengthens its input is not a
330
+ // cap, and the extra bar is silence the file does not contain
331
+ // (#475).
332
+ const bars = Math.min(options.bars ?? fileBars, fileBars, MAX_BARS);
333
+ const totalSteps = bars * stepsPerCycle;
334
+ // Belt and braces before the per-step allocations below: stepsPerCycle
335
+ // and bars are both bounded above, so this should be unreachable.
336
+ if (!Number.isSafeInteger(totalSteps) || totalSteps < 1 || totalSteps > MAX_BARS * 64) {
337
+ throw new Error(`Refusing to render ${totalSteps} steps.`);
338
+ }
339
+ // Caller-supplied sample names are INTERPOLATED into the pattern
340
+ // string, so they have to be names.
341
+ //
342
+ // `drum_map: { 36: 'x"y' }` produced
343
+ //
344
+ // s("x"y ~ ~ ~ ...")
345
+ //
346
+ // which is not valid Strudel and not valid anything — the quote
347
+ // closes the string and the rest of the bar becomes syntax. The
348
+ // same class as the scale-name injection: a value that reaches
349
+ // generated code without ever being checked as an identifier
350
+ // (#475).
351
+ //
352
+ // Strudel sample names are word characters, with `:n` selecting a
353
+ // variant within a bank. Nothing else can appear here.
354
+ const SAMPLE_NAME = /^[a-z0-9_]+(?::\d+)?$/i;
355
+ for (const [note, sample] of Object.entries(options.drum_map ?? {})) {
356
+ if (typeof sample !== 'string' || !SAMPLE_NAME.test(sample)) {
357
+ throw new ValidationError(`Invalid drum_map sample name for note ${note}: ${JSON.stringify(sample)}. `
358
+ + 'Sample names are letters, digits and underscores, optionally followed '
359
+ + 'by ":" and a variant number.');
360
+ }
361
+ }
362
+ const drumMap = { ...GM_DRUM_MAP, ...(options.drum_map ?? {}) };
363
+ const unmappedSet = new Set();
364
+ const lanes = [];
365
+ let renderedNotes = 0;
366
+ /** Notes whose length ran past the end of their bar and was cut there. */
367
+ let clippedHolds = 0;
368
+ for (const track of midi.tracks) {
369
+ if (track.notes.length === 0)
370
+ continue;
371
+ const isDrum = track.channel === 9;
372
+ if (isDrum) {
373
+ // Group by mapped sample; emit one s() lane per sample.
374
+ const bySample = new Map();
375
+ for (const n of track.notes) {
376
+ const sample = drumMap[n.midi];
377
+ if (!sample) {
378
+ unmappedSet.add(n.midi);
379
+ continue;
380
+ }
381
+ if (!bySample.has(sample))
382
+ bySample.set(sample, []);
383
+ bySample.get(sample).push({ midi: n.midi, beats: n.ticks / ppq });
384
+ }
385
+ // Sort sample names deterministically so output is stable.
386
+ const samples = Array.from(bySample.keys()).sort();
387
+ for (const sample of samples) {
388
+ const slots = buildDrumLane(bySample.get(sample), sample, stepsPerBeat, totalSteps);
389
+ // Counted from the SLOTS, before rendering (#478).
390
+ renderedNotes += slots.filter(slot => slot !== '~').length;
391
+ // Percussion is one-shot: a sample's length is its own, not
392
+ // the note's, so drum lanes hold for exactly one step.
393
+ const body = renderBars(slots, slots.map(() => 1), stepsPerCycle, bars);
394
+ lanes.push(` s("${body}")`);
395
+ }
396
+ }
397
+ else {
398
+ const { slots, holds } = buildPitchLane(track.notes.map((n) => ({
399
+ beats: n.ticks / ppq,
400
+ midi: n.midi,
401
+ lengthBeats: n.durationTicks / ppq,
402
+ })), stepsPerBeat, totalSteps);
403
+ // A chord slot holds several notes and the pattern really does
404
+ // contain all of them, so `[c4,e4]` counts as two. `slots` here
405
+ // holds RENDERED tokens, so count the names inside the token —
406
+ // reading `.length` off it counts characters.
407
+ renderedNotes += slots.reduce((n, slot) => n + (slot === '~' ? 0 : slot.replace(/[[\]]/g, '').split(',').length), 0);
408
+ const body = renderBars(slots, holds, stepsPerCycle, bars, () => { clippedHolds++; });
409
+ lanes.push(` note("${body}").s("piano")`);
410
+ }
411
+ }
412
+ // One cycle plays one bar (see `renderBars`), and every meter is laid
413
+ // onto 4/4 — so the bar is four beats and `setcpm(bpm)` would run the
414
+ // file four times too fast. Importing a 120 BPM file gave 480 (#397).
415
+ // The exact tempo, matching the grid the notes were placed on.
416
+ //
417
+ // Emitting the rounded one would put the drift back in the other
418
+ // direction: a grid built for 100.4 played at 100 walks apart just
419
+ // the same (#433). A fractional cpm is valid and the divisor is the
420
+ // beats-per-cycle conversion (#395).
421
+ // Counted from the rendered lanes, so the summary describes the
422
+ // pattern rather than the file it came from (#433).
423
+ // `renderedNotes` is accumulated as each lane is built, from the
424
+ // slot arrays themselves.
425
+ //
426
+ // It used to be recovered by re-parsing the RENDERED string:
427
+ // split the body on whitespace and count tokens that are not `~`.
428
+ // Multi-bar bodies attach punctuation to their edge tokens, so
429
+ //
430
+ // <[c4 ~ ~ ~ ... ~] [e4 ~ ~ ~ ... ~]>
431
+ //
432
+ // yields `<[c4`, `~]`, `[e4` and `~]>` among its tokens — and
433
+ // `~]` is not equal to `~`, so two rests counted as notes. Eight
434
+ // notes over two bars reported ten. Single-bar patterns have no
435
+ // such punctuation, which is why every existing test agreed with
436
+ // it (#478).
437
+ // A bar is one token list and cannot lend weight to the next, so a
438
+ // note still sounding at the bar line is cut there. Reported rather
439
+ // than done quietly — a loss nobody is told about is
440
+ // indistinguishable from correct output (#336, #477).
441
+ if (clippedHolds > 0) {
442
+ discarded.push(`${String(clippedHolds)} note(s) still sounding at a bar line — ` +
443
+ 'held to the end of their bar, not across it');
444
+ }
445
+ const tempoCall = `setcpm(${String(exactBpm)}/${String(BEATS_PER_CYCLE)})`;
446
+ const out = lanes.length === 0
447
+ ? `${tempoCall}\n\n// (no playable notes after parse)\nsilence`
448
+ : `${tempoCall}\n\nstack(\n${lanes.join(',\n')}\n)`;
449
+ return {
450
+ pattern: out,
451
+ summary: {
452
+ bpm,
453
+ bars,
454
+ lanes: lanes.length,
455
+ // Notes the pattern actually contains.
456
+ //
457
+ // This reported every note PARSED, including those it then
458
+ // dropped — the ones past the `bars` cap and those on unmapped
459
+ // drum pitches. Measured, a file of ten notes on an unmapped
460
+ // percussion pitch reported `{lanes: 0, notes: 10}` beside the
461
+ // pattern `silence`: ten notes claimed for a pattern that
462
+ // contains none (#433).
463
+ notes: renderedNotes,
464
+ // The parsed count is still worth having — it is the difference
465
+ // between "the file was empty" and "the file was dropped" — so
466
+ // it is reported alongside rather than instead.
467
+ ...(totalNotes !== renderedNotes ? { notesParsed: totalNotes } : {}),
468
+ unmapped_drums: Array.from(unmappedSet).sort((a, b) => a - b),
469
+ steps_per_cycle: stepsPerCycle,
470
+ ...(discarded.length > 0 ? { discarded } : {}),
471
+ },
472
+ };
473
+ }
474
+ }
@@ -1,3 +1,5 @@
1
+ import { lookup } from '../utils/TableLookup.js';
2
+ import { ValidationError } from '../utils/CategorisedError.js';
1
3
  export class MusicTheory {
2
4
  scales = {
3
5
  major: [0, 2, 4, 5, 7, 9, 11],
@@ -23,7 +25,13 @@ export class MusicTheory {
23
25
  rock: ['I', 'bVII', 'IV', 'I'],
24
26
  classical: ['I', 'IV', 'V', 'I'],
25
27
  modal: ['i', 'bVII', 'IV', 'i'],
26
- edm: ['i', 'VI', 'III', 'VII']
28
+ // Explicitly flattened. Uppercase VI/III/VII mean the major-scale
29
+ // degrees in chordMap, which under a minor tonic put every non-tonic
30
+ // chord a semitone sharp: C gave `cm A E B` where the progression
31
+ // wants `Cm Ab Eb Bb` (#318). The same symbol means different
32
+ // things in major and minor contexts, and chordMap cannot tell
33
+ // which, so the progression says what it means.
34
+ edm: ['i', 'bVI', 'bIII', 'bVII']
27
35
  };
28
36
  /**
29
37
  * Generates a musical scale from a root note
@@ -38,7 +46,11 @@ export class MusicTheory {
38
46
  if (rootIndex === -1) {
39
47
  throw new Error(`Invalid root note: ${root}`);
40
48
  }
41
- const scale = this.scales[scaleName];
49
+ // hasOwn for the same reason as chordMap below: a plain object
50
+ // literal returns an inherited value for 'constructor'/'__proto__',
51
+ // and `scale.map` then throws a raw TypeError. The tool layer's
52
+ // VALID_SCALES allowlist blocks it today; this is the second line.
53
+ const scale = Object.hasOwn(this.scales, scaleName) ? this.scales[scaleName] : undefined;
42
54
  if (!scale) {
43
55
  throw new Error(`Invalid scale: ${scaleName}`);
44
56
  }
@@ -55,14 +67,17 @@ export class MusicTheory {
55
67
  * @throws {Error} When style is invalid
56
68
  */
57
69
  generateChordProgression(key, style) {
58
- const progression = this.chordProgressions[style];
70
+ // hasOwn: same plain-object-literal hazard as scales above (#318).
71
+ const progression = Object.hasOwn(this.chordProgressions, style)
72
+ ? this.chordProgressions[style]
73
+ : undefined;
59
74
  if (!progression) {
60
75
  throw new Error(`Invalid progression style: ${style}`);
61
76
  }
62
77
  const chordMap = {
63
78
  'I': key,
64
79
  'I7': `${key}7`,
65
- 'i': `${key.toLowerCase()}m`,
80
+ 'i': `${key}m`,
66
81
  'ii': `${this.getNote(key, 2)}m`,
67
82
  'IIM7': `${this.getNote(key, 2)}m7`,
68
83
  'iii': `${this.getNote(key, 4)}m`,
@@ -74,11 +89,15 @@ export class MusicTheory {
74
89
  'vi': `${this.getNote(key, 9)}m`,
75
90
  'VI': this.getNote(key, 9),
76
91
  'VII': this.getNote(key, 11),
92
+ 'bIII': this.getNote(key, 3),
93
+ 'bVI': this.getNote(key, 8),
77
94
  'bVII': this.getNote(key, 10),
78
95
  'IM7': `${key}maj7`
79
96
  };
80
97
  return progression
81
- .map(chord => chordMap[chord] || key)
98
+ // hasOwn: chordMap is a plain object literal, so chordMap['constructor']
99
+ // returns Object — truthy — and the `|| key` fallback never fires (#318).
100
+ .map(chord => (Object.hasOwn(chordMap, chord) ? chordMap[chord] : key))
82
101
  .join(' ');
83
102
  }
84
103
  /**
@@ -103,16 +122,69 @@ export class MusicTheory {
103
122
  */
104
123
  generateEuclideanRhythm(hits, steps) {
105
124
  if (hits > steps) {
106
- throw new Error('Hits cannot exceed steps');
125
+ throw new ValidationError('Hits cannot exceed steps');
107
126
  }
108
- const pattern = new Array(steps).fill(false);
109
- const interval = steps / hits;
110
- for (let i = 0; i < hits; i++) {
111
- const index = Math.floor(i * interval);
112
- pattern[index] = true;
127
+ if (hits <= 0 || steps <= 0) {
128
+ return new Array(Math.max(0, steps)).fill('~').join(' ');
113
129
  }
130
+ // Bjorklund, not `floor(i * steps / hits)`.
131
+ //
132
+ // The old formula produced maximally-even necklaces — an exhaustive
133
+ // check over every (k, n) with n <= 24 found 136 exact matches, 163
134
+ // rotation-only differences and 0 genuinely wrong necklaces — but
135
+ // often starting on the wrong step. That is user-visible and
136
+ // self-contradictory, because the same session can run both:
137
+ //
138
+ // generate_rhythm euclid(3,8) -> 1 ~ 1 ~ ~ 1 ~ ~
139
+ // s("bd").euclid(3,8) -> 1 ~ ~ 1 ~ ~ 1 ~
140
+ //
141
+ // E(3,8) is the tresillo; we were emitting a rotation of one of the
142
+ // most recognisable rhythms there is (#319).
143
+ const pattern = MusicTheory.bjorklund(hits, steps);
114
144
  return pattern.map(hit => hit ? '1' : '~').join(' ');
115
145
  }
146
+ /**
147
+ * Bjorklund's algorithm: distributes `hits` onsets as evenly as
148
+ * possible over `steps`, with the first onset on step 0.
149
+ *
150
+ * @param hits - Number of onsets
151
+ * @param steps - Total steps in the cycle
152
+ * @returns One boolean per step
153
+ * @example
154
+ * MusicTheory.bjorklund(3, 8); // 1 0 0 1 0 0 1 0 — the tresillo
155
+ */
156
+ static bjorklund(hits, steps) {
157
+ if (steps <= 0)
158
+ return [];
159
+ // hits = 0 leaves `groups` empty, so `pairs` is 0, nothing merges
160
+ // and the remainder never shrinks — the loop below spins forever.
161
+ // Found by the exhaustive onset-count test, not by inspection.
162
+ if (hits <= 0)
163
+ return new Array(steps).fill(false);
164
+ if (hits >= steps)
165
+ return new Array(steps).fill(true);
166
+ // Start with `hits` groups of [true] and `steps - hits` of [false],
167
+ // then repeatedly fold the remainder into the front groups until at
168
+ // most one group is left over.
169
+ let groups = [];
170
+ for (let i = 0; i < hits; i++)
171
+ groups.push([true]);
172
+ let remainder = [];
173
+ for (let i = 0; i < steps - hits; i++)
174
+ remainder.push([false]);
175
+ while (remainder.length > 1) {
176
+ const pairs = Math.min(groups.length, remainder.length);
177
+ const merged = [];
178
+ for (let i = 0; i < pairs; i++)
179
+ merged.push([...groups[i], ...remainder[i]]);
180
+ // Whichever side had extras becomes the new remainder.
181
+ const leftoverGroups = groups.slice(pairs);
182
+ const leftoverRemainder = remainder.slice(pairs);
183
+ groups = merged;
184
+ remainder = leftoverGroups.length > 0 ? leftoverGroups : leftoverRemainder;
185
+ }
186
+ return [...groups, ...remainder].flat();
187
+ }
116
188
  /**
117
189
  * Generates a polyrhythm from two Euclidean patterns
118
190
  * @param pattern1 - Number of hits for first rhythm
@@ -149,7 +221,7 @@ export class MusicTheory {
149
221
  'updown': [0, 1, 2, 3, 2, 1],
150
222
  'random': [0, 2, 1, 3, 0, 3, 1, 2]
151
223
  };
152
- const arpPattern = patterns[pattern] || patterns['up'];
224
+ const arpPattern = lookup(patterns, pattern, patterns['up']);
153
225
  const notes = this.getChordNotes(chord);
154
226
  return arpPattern
155
227
  .map(index => notes[index % notes.length])
@@ -177,7 +249,7 @@ export class MusicTheory {
177
249
  'dim': [0, 3, 6], // diminished
178
250
  'aug': [0, 4, 8] // augmented
179
251
  };
180
- const chordIntervals = intervals[type] || intervals[''];
252
+ const chordIntervals = lookup(intervals, type, intervals['']);
181
253
  return chordIntervals.map(interval => {
182
254
  const note = this.getNote(root, interval);
183
255
  return `${note.toLowerCase()}3`;