@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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 (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 +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -12,10 +12,12 @@
12
12
  */
13
13
  // @tonejs/midi is CommonJS - use dynamic import approach
14
14
  import * as midiModule from '@tonejs/midi';
15
+ import { BEATS_PER_BAR } from '../utils/Tempo.js';
16
+ import { MusicTheory } from './MusicTheory.js';
15
17
  // Handle both ESM and CJS interop
16
18
  const Midi = midiModule.Midi || midiModule.default?.Midi;
17
- import { writeFileSync } from 'fs';
18
- import { resolve } from 'path';
19
+ import { writeFileSync, mkdirSync } from 'fs';
20
+ import { resolveSafeOutputPath, resolveExportDirectory } from '../utils/SafePath.js';
19
21
  /**
20
22
  * Note name to MIDI number mapping.
21
23
  * C4 = 60 (middle C)
@@ -49,9 +51,98 @@ const CHORD_INTERVALS = {
49
51
  'm9': [0, 3, 7, 10, 14], // minor 9
50
52
  'add9': [0, 4, 7, 14], // add 9
51
53
  '6': [0, 4, 7, 9], // major 6
52
- 'm6': [0, 3, 7, 9] // minor 6
54
+ 'm6': [0, 3, 7, 9], // minor 6
55
+ // Aliases people actually write. Without these they fell through to
56
+ // the major-triad fallback, so `Cmin` and `Cm7b5` both came back as
57
+ // C major — a wrong third, silently, which is worse than the unknown
58
+ // suffix the fallback was written for (#336).
59
+ 'min': [0, 3, 7], // minor, spelled out
60
+ 'minor': [0, 3, 7],
61
+ 'maj': [0, 4, 7], // major, spelled out
62
+ 'major': [0, 4, 7],
63
+ 'sus': [0, 5, 7], // bare sus is conventionally sus4
64
+ 'm7b5': [0, 3, 6, 10], // half-diminished
65
+ 'm11': [0, 3, 7, 10, 14, 17],
66
+ '11': [0, 4, 7, 10, 14, 17],
67
+ '13': [0, 4, 7, 10, 14, 21],
68
+ 'maj13': [0, 4, 7, 11, 14, 21],
69
+ 'm13': [0, 3, 7, 10, 14, 21],
70
+ '7sus4': [0, 5, 7, 10],
71
+ 'add11': [0, 4, 7, 17],
72
+ '5': [0, 7], // power chord
53
73
  };
74
+ /**
75
+ * Suffixes whose meaning depends on case, checked before lowercasing.
76
+ *
77
+ * `M7` is standard notation for a major seventh, but the lookup
78
+ * lowercased first, turning it into the `m7` key — so `CM7` produced a
79
+ * C MINOR 7 while `Cmaj7` produced the correct major 7. Two spellings
80
+ * of the same chord, a semitone apart (#336).
81
+ */
82
+ const CASE_SENSITIVE_CHORDS = {
83
+ 'M7': [0, 4, 7, 11], // major 7
84
+ 'M9': [0, 4, 7, 11, 14],
85
+ 'M6': [0, 4, 7, 9],
86
+ 'M13': [0, 4, 7, 11, 14, 21],
87
+ };
88
+ /**
89
+ * Strudel sample name -> GM percussion note.
90
+ *
91
+ * The inverse of `MIDIImportService.GM_DRUM_MAP`. Import could read a
92
+ * GM channel-9 track into clean `s(...)` lanes; export had no
93
+ * counterpart at all, so `s("bd*4")` returned "No notes found in
94
+ * pattern" and drums were import-only. Import's own output could not be
95
+ * re-exported (#335).
96
+ *
97
+ * Where import maps several notes onto one sample (35 and 36 both ->
98
+ * bd), the canonical one is used here.
99
+ */
100
+ export const SAMPLE_TO_MIDI = {
101
+ bd: 36, // bass drum 1
102
+ rim: 37, // side stick
103
+ sd: 38, // acoustic snare
104
+ cp: 39, // hand clap
105
+ lt: 41, // low floor tom
106
+ hh: 42, // closed hi-hat
107
+ mt: 45, // low tom
108
+ oh: 46, // open hi-hat
109
+ ht: 48, // hi-mid tom
110
+ cr: 49, // crash cymbal 1
111
+ rd: 51, // ride cymbal 1
112
+ tb: 54, // tambourine
113
+ cb: 56, // cowbell
114
+ };
115
+ /** The General MIDI percussion channel. */
116
+ export const GM_PERCUSSION_CHANNEL = 9;
54
117
  export class MIDIExportService {
118
+ /**
119
+ * Mini-notation tokens the parser could not represent, collected
120
+ * during the most recent conversion.
121
+ *
122
+ * `parseNoteString` handles bare note names separated by whitespace
123
+ * and nothing else. Everything it cannot read was previously dropped
124
+ * without a word, so an export that lost most of its material still
125
+ * reported plain success (#335).
126
+ */
127
+ unrepresented = new Set();
128
+ /**
129
+ * Tokens that WERE exported, but lossily — an alternation whose
130
+ * later options a single bar cannot hold, or a duration weight that
131
+ * was dropped. Distinct from `unrepresented`, because "we kept the
132
+ * first of four" and "we skipped it" are different things to tell a
133
+ * caller (#335).
134
+ */
135
+ partial = new Set();
136
+ /** Notes actually written by the last convertToMidi, for noteCount. */
137
+ lastWrittenCount = 0;
138
+ /** Directory file exports are confined to. */
139
+ exportDir;
140
+ /**
141
+ * @param exportDir - Export directory (default `./exports`, or `exports_dir`)
142
+ */
143
+ constructor(exportDir) {
144
+ this.exportDir = resolveExportDirectory(exportDir);
145
+ }
55
146
  /**
56
147
  * Converts a note name (e.g., "C4", "D#5", "Bb3") to MIDI number
57
148
  * @param noteName - Note name with optional accidental and octave
@@ -80,7 +171,18 @@ export class MIDIExportService {
80
171
  return null;
81
172
  }
82
173
  // Default octave is 4 (middle C area)
83
- const octave = octaveStr !== undefined ? parseInt(octaveStr, 10) : 4;
174
+ let octave = octaveStr !== undefined ? parseInt(octaveStr, 10) : 4;
175
+ // Cb and B# cross the octave boundary. NOTE_NAMES maps 'cb' to 11
176
+ // and 'b#' to 0, which are the right pitch CLASSES, but the octave
177
+ // has to move with them: Cb4 is B3 (59), not B4 (71), and B#4 is C5
178
+ // (72), not C4 (60). The semitone was right and the octave was not
179
+ // — the classic off-by-one-octave (#336).
180
+ if (noteKey === 'cb') {
181
+ octave -= 1;
182
+ }
183
+ else if (noteKey === 'b#') {
184
+ octave += 1;
185
+ }
84
186
  // MIDI note = (octave + 1) * 12 + semitone
85
187
  // C4 = 60, so octave 4 base = 60, but 60 = (4+1)*12 + 0
86
188
  const midi = (octave + 1) * 12 + semitone;
@@ -124,8 +226,46 @@ export class MIDIExportService {
124
226
  return [];
125
227
  }
126
228
  // Get chord intervals, default to major triad
127
- const intervals = CHORD_INTERVALS[chordType.toLowerCase()] || CHORD_INTERVALS[''];
128
- const rootMidi = (octave + 1) * 12 + rootSemitone;
229
+ // hasOwn, not `||`: CHORD_INTERVALS is a plain object literal, so
230
+ // CHORD_INTERVALS['constructor'] inherits Object — truthy — and the
231
+ // fallback never fired. `intervals.map` then threw a TypeError that
232
+ // failed the whole export, including any valid chords beside it
233
+ // (#308).
234
+ // Case-sensitive suffixes first: 'M7' means major 7 and must not be
235
+ // lowercased into the 'm7' (minor 7) key (#336).
236
+ const key = chordType.toLowerCase();
237
+ const known = Object.hasOwn(CASE_SENSITIVE_CHORDS, chordType)
238
+ || Object.hasOwn(CHORD_INTERVALS, key);
239
+ if (!known && chordType.length > 0) {
240
+ // The major-triad fallback STAYS. #336 chose it deliberately —
241
+ // "the fallback is fine for input nobody can interpret" — and a
242
+ // triad on the root the caller named is a defensible reading of
243
+ // `chord("Cfoo")`.
244
+ //
245
+ // What was wrong is that it happened in silence. #433 reported it
246
+ // as `chord("Cxyz")` exporting C major, and the sharper case was
247
+ // `chord("<C Dm>")` exporting a D MAJOR — but that came from the
248
+ // string never being tokenized (fixed below), not from the
249
+ // fallback. What reaches here now is genuinely uninterpretable,
250
+ // and the honest response is to interpret it AND say so.
251
+ this.unrepresented.add(chordName);
252
+ }
253
+ const intervals = Object.hasOwn(CASE_SENSITIVE_CHORDS, chordType)
254
+ ? CASE_SENSITIVE_CHORDS[chordType]
255
+ : Object.hasOwn(CHORD_INTERVALS, key)
256
+ ? CHORD_INTERVALS[key]
257
+ : CHORD_INTERVALS[''];
258
+ // Cb and B# cross the octave boundary — the same correction
259
+ // `noteNameToMidi` makes and this did not, so `chord("Cb")` rooted
260
+ // on B4 (71) instead of B3 (59), a whole octave out (#433).
261
+ let rootOctave = octave;
262
+ if (rootKey === 'cb') {
263
+ rootOctave -= 1;
264
+ }
265
+ else if (rootKey === 'b#') {
266
+ rootOctave += 1;
267
+ }
268
+ const rootMidi = (rootOctave + 1) * 12 + rootSemitone;
129
269
  return intervals.map(interval => {
130
270
  const midi = rootMidi + interval;
131
271
  return midi >= 0 && midi <= 127 ? midi : -1;
@@ -141,45 +281,88 @@ export class MIDIExportService {
141
281
  return [];
142
282
  }
143
283
  const notes = [];
144
- let currentTime = 0;
284
+ // Lanes layer; they do not follow one another.
285
+ //
286
+ // `currentTime += BEATS_PER_BAR` after each match meant lane 2
287
+ // started a bar AFTER lane 1, so stack(note(a), note(b)) exported
288
+ // as a then b instead of a over b — parallelism destroyed at export,
289
+ // before import had a chance to preserve it (#335).
290
+ //
291
+ // Every lane therefore starts at bar 0. That is right for stack(),
292
+ // which is how Strudel expresses simultaneity, and for a single
293
+ // pattern. It is NOT right for cat()/seq(), which this regex-based
294
+ // parser does not recognise as sequential either way — those were
295
+ // already flattened before this change.
296
+ const laneStart = 0;
145
297
  // Extract note() function calls
146
298
  // Matches: note("c4 e4 g4"), note("c4", "e4"), note(`c4`)
147
299
  const noteRegex = /note\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
148
300
  let noteMatch;
149
301
  while ((noteMatch = noteRegex.exec(pattern)) !== null) {
150
302
  const noteContent = noteMatch[1];
151
- const parsedNotes = this.parseNoteString(noteContent, currentTime);
303
+ const parsedNotes = this.parseNoteString(noteContent, laneStart);
152
304
  notes.push(...parsedNotes);
153
- currentTime += parsedNotes.length > 0 ? 1 : 0;
154
305
  }
155
306
  // Extract n() function calls (Strudel shorthand)
156
- const nRegex = /\bn\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
307
+ //
308
+ // The lookbehind keeps this from also matching the `.n(...)` in
309
+ // `s("piano").n("0 1 2")`, which the pass below already handles.
310
+ // Both fired on the same text, so every note in that form was
311
+ // counted twice: s("bd").n("0 1 2") produced 6 notes, not 3 — and
312
+ // that is the exact form the code's own comment advertises as
313
+ // supported (#335).
314
+ const nRegex = /(?<![)\w$.])\bn\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
157
315
  let nMatch;
158
316
  while ((nMatch = nRegex.exec(pattern)) !== null) {
159
317
  const noteContent = nMatch[1];
160
318
  // n() uses MIDI numbers directly
161
- const parsedNotes = this.parseNoteString(noteContent, currentTime, true);
319
+ const parsedNotes = this.parseNoteString(noteContent, laneStart, true);
162
320
  notes.push(...parsedNotes);
163
- currentTime += parsedNotes.length > 0 ? 1 : 0;
164
321
  }
165
322
  // Extract chord patterns
166
323
  const chordRegex = /chord\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
167
324
  let chordMatch;
168
325
  while ((chordMatch = chordRegex.exec(pattern)) !== null) {
169
326
  const chordContent = chordMatch[1];
170
- const parsedChords = this.parseChordString(chordContent, currentTime);
327
+ const parsedChords = this.parseChordString(chordContent, laneStart);
171
328
  notes.push(...parsedChords);
172
- currentTime += parsedChords.length > 0 ? 1 : 0;
329
+ }
330
+ // Drum lanes: s("bd*4"), sound("bd sd"), etc.
331
+ //
332
+ // Export had no sample->MIDI map and never set a channel, so every
333
+ // drum pattern returned "No notes found in pattern" — drums were
334
+ // import-only, and import's own output could not be re-exported
335
+ // (#335). Only lanes whose tokens are all recognised drum samples
336
+ // are taken; s("piano") is left to the .n() pass below.
337
+ // The lookahead skips `s("bd").n(...)`, which the .n() pass owns:
338
+ // in Strudel that is one lane selecting sample variants, not a kick
339
+ // plus three notes. Without it both passes claimed the same text
340
+ // and s("bd").n("0 1 2") exported 4 notes for 3 events (#335).
341
+ const drumRegex = /\b(?:s|sound)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)(?!\s*\.\s*n\s*\()/gi;
342
+ let drumMatch;
343
+ while ((drumMatch = drumRegex.exec(pattern)) !== null) {
344
+ const parsedDrums = this.parseDrumString(drumMatch[1], laneStart);
345
+ notes.push(...parsedDrums);
173
346
  }
174
347
  // Extract s() sound patterns with n() modifier for samples
175
348
  // e.g., s("piano").n("0 2 4 7")
176
- const soundNRegex = /s\s*\([^)]+\)\s*\.n\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
349
+ // `sound(...)` as well as `s(...)`.
350
+ //
351
+ // The drum pass above accepts both spellings and its lookahead skips
352
+ // either when `.n(` follows — handing it to this pass, which only
353
+ // knew `s(`. So `sound("piano").n("60 62")` matched the drum pass's
354
+ // lookahead, missed here, and was blocked from the bare `n()` pass
355
+ // by its own lookbehind: three passes, none of them taking it, zero
356
+ // notes and no error (#433). `s("piano").n("60 62")` worked.
357
+ //
358
+ // `\b(?:s|sound)` mirrors the drum regex exactly, so the two cannot
359
+ // disagree about what a lane looks like.
360
+ const soundNRegex = /\b(?:s|sound)\s*\([^)]+\)\s*\.n\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/gi;
177
361
  let soundNMatch;
178
362
  while ((soundNMatch = soundNRegex.exec(pattern)) !== null) {
179
363
  const noteContent = soundNMatch[1];
180
- const parsedNotes = this.parseNoteString(noteContent, currentTime, true);
364
+ const parsedNotes = this.parseNoteString(noteContent, laneStart, true);
181
365
  notes.push(...parsedNotes);
182
- currentTime += parsedNotes.length > 0 ? 1 : 0;
183
366
  }
184
367
  // If no notes found through function parsing, try to find inline notes
185
368
  if (notes.length === 0) {
@@ -195,45 +378,408 @@ export class MIDIExportService {
195
378
  * @param asMidiNumbers - Treat values as MIDI numbers
196
379
  * @returns Array of NoteEvent objects
197
380
  */
381
+ /**
382
+ * Splits mini-notation into tokens, treating `[...]` as one token.
383
+ *
384
+ * @param source - A mini-notation string
385
+ * @returns Tokens, with bracket groups intact
386
+ * @example
387
+ * MIDIExportService.tokenize('[c4 e4] g4'); // ['[c4 e4]', 'g4']
388
+ * MIDIExportService.tokenize('<c4 e4> g4'); // ['<c4 e4>', 'g4']
389
+ */
390
+ /**
391
+ * Splits a pattern string into its parallel lanes.
392
+ *
393
+ * A comma at depth 0 stacks lanes in Strudel — `s("bd*4, hh*8")` is a
394
+ * kick on every beat AND a hat on every eighth, both spanning the bar.
395
+ * `tokenize` treated it as an ordinary separator, so the lanes were
396
+ * concatenated and redistributed across the bar. Measured:
397
+ *
398
+ * s("bd*4") -> beats [0, 1, 2, 3] correct
399
+ * s("hh*8") -> beats [0, .5, 1, ... 3.5] correct
400
+ * s("bd*4, hh*8") -> beats [0, .33, .67, 1, ... 3.67] WRONG
401
+ *
402
+ * The note COUNT was right, which is why nothing caught it. And this
403
+ * is the shape the generator emits — `s("bd*4, ~ cp ~ cp, hh*8")` is
404
+ * its standard drum line — so every generated drum pattern exported
405
+ * at the wrong rhythm and reported success (#432).
406
+ *
407
+ * A comma inside `[...]` or `<...>` is a chord or an alternation and
408
+ * stays where it is; only depth 0 separates lanes.
409
+ *
410
+ * @param source - Pattern string, e.g. `bd*4, hh*8`
411
+ * @returns One string per lane, in order; `['']` becomes `[]`
412
+ *
413
+ * @example
414
+ * MIDIExportService.splitLanes('bd*4, hh*8'); // ['bd*4', 'hh*8']
415
+ * MIDIExportService.splitLanes('[c4,e4] g4'); // ['[c4,e4] g4']
416
+ */
417
+ static splitLanes(source) {
418
+ const lanes = [];
419
+ let current = '';
420
+ let depth = 0;
421
+ for (const char of source) {
422
+ // Parentheses group too. Without them the comma in a euclidean
423
+ // rhythm read as a lane separator: `s("bd(3,8)")` split into
424
+ // `bd(3` and `8)`, neither a sample name, so one of the most
425
+ // common idioms in Strudel — and one `generate_rhythm` emits —
426
+ // exported ZERO notes (#464).
427
+ if (char === '[' || char === '<' || char === '(')
428
+ depth++;
429
+ if (char === ']' || char === '>' || char === ')')
430
+ depth = Math.max(0, depth - 1);
431
+ if (char === ',' && depth === 0) {
432
+ lanes.push(current);
433
+ current = '';
434
+ continue;
435
+ }
436
+ current += char;
437
+ }
438
+ lanes.push(current);
439
+ return lanes.map(lane => lane.trim()).filter(lane => lane.length > 0);
440
+ }
441
+ static tokenize(source) {
442
+ const tokens = [];
443
+ let current = '';
444
+ let depth = 0;
445
+ for (const char of source) {
446
+ // Angle brackets group too. Tracking only `[` meant `<c4 e4>` was
447
+ // split on the space into ["<c4", "e4>"], neither of which is a
448
+ // note or a recognisable alternation — so the whole token was
449
+ // reported unrepresentable even after expansion existed (#335).
450
+ // Parentheses group too, for the euclid arguments (#464).
451
+ if (char === '[' || char === '<' || char === '(')
452
+ depth++;
453
+ if (char === ']' || char === '>' || char === ')')
454
+ depth = Math.max(0, depth - 1);
455
+ const isSeparator = (char === ' ' || char === '\t' || char === '\n' || char === ',');
456
+ if (isSeparator && depth === 0) {
457
+ if (current.length > 0)
458
+ tokens.push(current);
459
+ current = '';
460
+ continue;
461
+ }
462
+ current += char;
463
+ }
464
+ if (current.length > 0)
465
+ tokens.push(current);
466
+ return tokens;
467
+ }
468
+ /**
469
+ * Expands the repetition operators into plain tokens.
470
+ *
471
+ * `*n` and `!n` both mean "this token, n times" for export purposes:
472
+ * `*` subdivides the step and `!` repeats it across steps, and since
473
+ * export lays tokens out evenly across the bar the two collapse to
474
+ * the same thing here. `@n` weights a token's duration; the note is
475
+ * kept and the weight dropped, which is a smaller loss than dropping
476
+ * the note.
477
+ *
478
+ * `<a b c>` alternates across cycles. A single exported bar can only
479
+ * hold one of them, so the first is taken and the rest reported —
480
+ * previously the whole token was dropped, which is why
481
+ * note("<c3 eb3 g3 bb3>") contributed nothing (#335).
482
+ *
483
+ * @param tokens - Tokens from `tokenize`
484
+ * @param onUnrepresented - Called with anything still not expressible
485
+ * @returns Plain tokens, ready for note lookup
486
+ * @example
487
+ * MIDIExportService.expandOperators(['c4*3'], () => {}); // ['c4','c4','c4']
488
+ */
489
+ static expandOperators(tokens, onUnrepresented, onPartial = () => undefined) {
490
+ const MAX_REPEAT = 64;
491
+ const out = [];
492
+ for (const token of tokens) {
493
+ // <a b c> — alternation. Take the first; report the rest.
494
+ const alternation = /^<(.+)>$/.exec(token);
495
+ if (alternation) {
496
+ const options = MIDIExportService.tokenize(alternation[1]);
497
+ if (options.length === 0)
498
+ continue;
499
+ out.push(...MIDIExportService.expandOperators([options[0]], onUnrepresented, onPartial));
500
+ if (options.length > 1)
501
+ onPartial(token);
502
+ continue;
503
+ }
504
+ // name(k,n) or name(k,n,rot) — euclidean rhythm. Expanded here
505
+ // rather than reported unrepresentable, so `s("bd(3,8)")` exports
506
+ // the tresillo it names (#464).
507
+ const euclid = /^(.+?)\((\d+)\s*,\s*(\d+)(?:\s*,\s*(-?\d+))?\)$/.exec(token);
508
+ if (euclid) {
509
+ const hits = Number.parseInt(euclid[2], 10);
510
+ const steps = Number.parseInt(euclid[3], 10);
511
+ // Same ceiling as repetition, and for the same reason: layout
512
+ // divides the bar by token count.
513
+ if (steps < 1 || steps > MAX_REPEAT || hits > steps) {
514
+ onUnrepresented(token);
515
+ continue;
516
+ }
517
+ const rotation = euclid[4] ? Number.parseInt(euclid[4], 10) : 0;
518
+ const inner = MIDIExportService.expandOperators([euclid[1]], onUnrepresented, onPartial);
519
+ const bits = MusicTheory.bjorklund(hits, steps);
520
+ for (let i = 0; i < steps; i++) {
521
+ // Positive rotation advances the pattern, matching `.euclid`'s
522
+ // third argument.
523
+ const bit = bits[(((i + rotation) % steps) + steps) % steps];
524
+ if (bit)
525
+ out.push(...inner);
526
+ else
527
+ out.push('~');
528
+ }
529
+ continue;
530
+ }
531
+ // name*n or name!n — repetition.
532
+ const repeat = /^(.+?)[*!](\d+)$/.exec(token);
533
+ if (repeat) {
534
+ const requested = Number.parseInt(repeat[2], 10);
535
+ const count = Math.min(requested, MAX_REPEAT);
536
+ // Truncation is loss, and it was silent. `note("c4*128")`
537
+ // exported 64 notes with no `unrepresented` and no
538
+ // `partiallyExported` entry — and because layout divides the bar
539
+ // by token count, halving the repeats also changed the rhythm of
540
+ // everything beside it (#433).
541
+ if (requested > MAX_REPEAT)
542
+ onPartial(token);
543
+ if (count > 0) {
544
+ const inner = MIDIExportService.expandOperators([repeat[1]], onUnrepresented, onPartial);
545
+ for (let i = 0; i < count; i++)
546
+ out.push(...inner);
547
+ }
548
+ continue;
549
+ }
550
+ // name@n — duration weight. PRESERVED, and applied by the
551
+ // layout rather than stripped here.
552
+ //
553
+ // This used to drop the weight and report the token as partially
554
+ // exported. Dropping it does not merely lose the duration, it
555
+ // moves every note after it. Strudel divides the cycle by TOTAL
556
+ // WEIGHT, measured:
557
+ //
558
+ // mini('c4@4 e4') -> c4[0.000-0.800] e4[0.800-1.000]
559
+ // export (before) -> c4@0/2 e4@2/2
560
+ //
561
+ // — the same layout as the unweighted `c4 e4`, so `c4@3 e4@1`
562
+ // put e4 at the halfway point instead of three quarters in.
563
+ // Onsets, not just durations (#477).
564
+ const weighted = /^(.+?)@(\d+(?:\.\d+)?)$/.exec(token);
565
+ if (weighted) {
566
+ const inner = MIDIExportService.expandOperators([weighted[1]], onUnrepresented, onPartial);
567
+ // A weight applies to the token it was written on. Expanding
568
+ // `[a b]@2` yields several, and each carries the weight.
569
+ out.push(...inner.map(t => `${t}@${weighted[2]}`));
570
+ continue;
571
+ }
572
+ if (/[*!@<>?%]/.test(token)) {
573
+ onUnrepresented(token);
574
+ continue;
575
+ }
576
+ out.push(token);
577
+ }
578
+ return out;
579
+ }
580
+ /**
581
+ * Parses a drum lane into GM percussion events on channel 9.
582
+ *
583
+ * Returns an empty array if the lane contains anything that is not a
584
+ * known sample or a rest, so a melodic `s("piano")` falls through to
585
+ * the `.n()` handler instead of being silently misread as drums.
586
+ *
587
+ * @param soundString - Contents of an s(...) call
588
+ * @param startTime - Bar start, in beats
589
+ * @returns Percussion events, or [] if this is not a drum lane
590
+ */
591
+ parseDrumString(soundString, startTime) {
592
+ // Each lane spans the whole bar; they play together, not in turn
593
+ // (#432). Laid out independently and concatenated, which is what
594
+ // `parsePatternNotes` already does across separate `s(...)` calls in
595
+ // a stack — this is the same thing one nesting level down.
596
+ const unknown = [];
597
+ const events = this.layoutDrums(soundString, startTime, BEATS_PER_BAR, unknown);
598
+ if (unknown.length > 0) {
599
+ // The whole lane is dropped when any token is unrecognised — a
600
+ // deliberate choice, since a partial drum lane is a different
601
+ // rhythm rather than a quieter one. But it used to happen in
602
+ // silence: `stack(note("c4"), s("bd unknown sd"))` exported one
603
+ // note with success:true and no `unrepresented` entry, so the
604
+ // caller had no way to learn a whole lane had vanished (#433).
605
+ for (const token of unknown)
606
+ this.unrepresented.add(token);
607
+ return [];
608
+ }
609
+ return events;
610
+ }
611
+ /**
612
+ * Lays a drum fragment out across a window of `span` beats.
613
+ *
614
+ * The same rule as `layoutNotes`, one nesting level at a time: a
615
+ * COMMA separates parallel lanes, each of which gets the whole
616
+ * window; a SPACE subdivides, and `@n` weights the share.
617
+ *
618
+ * The drum path had no bracket branch at all, so `s("[bd sd] hh")` —
619
+ * an ordinary subdivided step — produced ZERO notes: `[bd sd]` is not
620
+ * a sample name, so the unknown-token rule dropped the whole lane
621
+ * (#433 item 7). The note path got this in #469; this is the same
622
+ * treatment one file over.
623
+ *
624
+ * Unknown leaves are collected rather than reported here, so the
625
+ * caller can apply the all-or-nothing rule to the lane as a whole.
626
+ *
627
+ * @param source - Drum fragment, brackets already unwrapped
628
+ * @param start - Window start, in beats
629
+ * @param span - Window width, in beats
630
+ * @param unknown - Collects leaf tokens that are not samples or rests
631
+ * @returns Percussion events laid out across the window
632
+ */
633
+ layoutDrums(source, start, span, unknown) {
634
+ const lanes = MIDIExportService.splitLanes(source);
635
+ if (lanes.length > 1) {
636
+ return lanes.flatMap(lane => this.layoutDrums(lane, start, span, unknown));
637
+ }
638
+ const parts = MIDIExportService.expandOperators(MIDIExportService.tokenize(source), token => { unknown.push(token); }, token => { this.partial.add(token); });
639
+ if (parts.length === 0)
640
+ return [];
641
+ // Weighted, as Strudel divides a cycle (#477).
642
+ const weighed = parts.map((part) => {
643
+ const match = /^(.+?)@(\d+(?:\.\d+)?)$/.exec(part);
644
+ if (!match)
645
+ return { token: part, weight: 1 };
646
+ const weight = Number.parseFloat(match[2]);
647
+ return Number.isFinite(weight) && weight > 0
648
+ ? { token: match[1], weight }
649
+ : { token: match[1], weight: 1 };
650
+ });
651
+ const totalWeight = weighed.reduce((sum, w) => sum + w.weight, 0);
652
+ if (totalWeight <= 0)
653
+ return [];
654
+ const unit = span / totalWeight;
655
+ const isRest = (t) => t === '~' || t === '-' || t === 'r';
656
+ const events = [];
657
+ let offset = 0;
658
+ for (const { token, weight } of weighed) {
659
+ const at = start + offset;
660
+ const step = weight * unit;
661
+ offset += step;
662
+ if (isRest(token))
663
+ continue;
664
+ const bracketed = /^\[(.*)\]$/.exec(token);
665
+ if (bracketed) {
666
+ events.push(...this.layoutDrums(bracketed[1], at, step, unknown));
667
+ continue;
668
+ }
669
+ const sample = token.toLowerCase();
670
+ if (!Object.hasOwn(SAMPLE_TO_MIDI, sample)) {
671
+ unknown.push(token);
672
+ continue;
673
+ }
674
+ events.push({
675
+ note: SAMPLE_TO_MIDI[sample],
676
+ time: at,
677
+ duration: step,
678
+ velocity: 100,
679
+ channel: GM_PERCUSSION_CHANNEL,
680
+ });
681
+ }
682
+ return events;
683
+ }
198
684
  parseNoteString(noteString, startTime, asMidiNumbers = false) {
685
+ // One pattern string spans one BAR, not one beat.
686
+ //
687
+ // Export treated a whole note("...") as 1 beat while import lays a
688
+ // cycle out over 4 (`secondsPerBar = secondsPerBeat * 4`), so a
689
+ // round trip compressed everything 4x: an 8-note scale came back as
690
+ // note("c4 [d4,e4] [f4,g4] [a4,b4] c5 ~ ~ ...") — collisions from
691
+ // the mismatch, which then triggered the documented chord-collapse
692
+ // and looked like a quantization loss. It was a unit disagreement
693
+ // (#336).
694
+ return this.layoutNotes(noteString, startTime, BEATS_PER_BAR, asMidiNumbers);
695
+ }
696
+ /**
697
+ * Lays a mini-notation fragment out across a window of `span` beats.
698
+ *
699
+ * One rule, applied at every depth: a COMMA separates parallel lanes,
700
+ * each of which gets the whole window; a SPACE subdivides a lane, and
701
+ * each token gets an equal share. A chord is not a special case —
702
+ * `[c4,e4,g4]` is three one-token lanes, so all three land at the
703
+ * window's start holding its full width, which is what a chord is.
704
+ *
705
+ * Bracket handling used to live inline with its own rules:
706
+ * `isChord = subContent.includes(',')` followed by
707
+ * `subContent.split(/[\s,]+/)`, which flattened both separators and
708
+ * then applied one verdict to the lot. A bracket mixing them was laid
709
+ * out wrong: `[c4 e4, g4]` is the sequence `c4 e4` played against a
710
+ * held `g4`, and it exported as a three-note chord at beat 0 — c4 and
711
+ * e4 simultaneous, and both four times too long (#469).
712
+ *
713
+ * Recursing also fixed nesting for free: the old code stripped every
714
+ * bracket in the token with `replace(/[[\]]/g, '')`, so the inner
715
+ * structure of `[[c4 e4] g4]` was erased before anything read it.
716
+ *
717
+ * @param source - Mini-notation fragment, brackets already unwrapped
718
+ * @param start - Window start, in beats
719
+ * @param span - Window width, in beats
720
+ * @param asMidiNumbers - Parse tokens as raw MIDI numbers, not names
721
+ * @returns Note events laid out across the window
722
+ */
723
+ layoutNotes(source, start, span, asMidiNumbers) {
724
+ // Parallel lanes, as in parseDrumString (#432). `note("c4 e4, g4")`
725
+ // sounds g4 with c4 at beat 0, not two thirds of the way through.
726
+ const lanes = MIDIExportService.splitLanes(source);
727
+ if (lanes.length > 1) {
728
+ return lanes.flatMap(lane => this.layoutNotes(lane, start, span, asMidiNumbers));
729
+ }
730
+ // Tokenize with brackets kept whole.
731
+ //
732
+ // Splitting on /[\s,]+/ first meant the bracket branch below could
733
+ // only ever see a bracket containing no spaces or commas — i.e.
734
+ // never a real chord. `[c4 e4] g4` became ["[c4", "e4]", "g4"], and
735
+ // "e4]" fails noteNameToMidi, so the last note of every chord was
736
+ // dropped. Import emits exactly this notation, so its own output
737
+ // could not be re-exported (#336).
738
+ const parts = MIDIExportService.expandOperators(MIDIExportService.tokenize(source), token => { this.unrepresented.add(token); }, token => { this.partial.add(token); });
739
+ if (parts.length === 0)
740
+ return [];
741
+ // Weighted layout, as Strudel does it: the window is divided by the
742
+ // TOTAL weight, and each token takes its own share. An unweighted
743
+ // token weighs 1, so an unweighted sequence divides evenly exactly
744
+ // as before.
745
+ const weighed = parts.map((part) => {
746
+ const match = /^(.+?)@(\d+(?:\.\d+)?)$/.exec(part);
747
+ if (!match)
748
+ return { token: part, weight: 1 };
749
+ const weight = Number.parseFloat(match[2]);
750
+ return Number.isFinite(weight) && weight > 0
751
+ ? { token: match[1], weight }
752
+ : { token: match[1], weight: 1 };
753
+ });
754
+ const totalWeight = weighed.reduce((sum, w) => sum + w.weight, 0);
755
+ if (totalWeight <= 0)
756
+ return [];
757
+ const unit = span / totalWeight;
199
758
  const notes = [];
200
- const parts = noteString.split(/[\s,]+/).filter(p => p.length > 0);
201
- const noteDuration = parts.length > 0 ? 1 / parts.length : 1;
202
- parts.forEach((part, index) => {
203
- // Check for rest
204
- if (part === '~' || part === '-' || part === 'r') {
759
+ let offset = 0;
760
+ weighed.forEach(({ token: part, weight }) => {
761
+ const at = start + offset;
762
+ const step = weight * unit;
763
+ offset += step;
764
+ if (part === '~' || part === '-' || part === 'r')
205
765
  return;
206
- }
207
- // Handle sub-patterns in brackets [c4 e4]
208
- if (part.startsWith('[')) {
209
- const subContent = part.replace(/[[\]]/g, '');
210
- const subParts = subContent.split(/[\s,]+/);
211
- subParts.forEach(subPart => {
212
- const midi = asMidiNumbers
213
- ? parseInt(subPart, 10)
214
- : this.noteNameToMidi(subPart);
215
- if (midi !== null && !isNaN(midi) && midi >= 0 && midi <= 127) {
216
- notes.push({
217
- note: midi,
218
- time: startTime + index * noteDuration,
219
- duration: noteDuration / subParts.length,
220
- velocity: 100
221
- });
222
- }
223
- });
766
+ const bracketed = /^\[(.*)\]$/.exec(part);
767
+ if (bracketed) {
768
+ notes.push(...this.layoutNotes(bracketed[1], at, step, asMidiNumbers));
224
769
  return;
225
770
  }
226
- const midi = asMidiNumbers
227
- ? parseInt(part, 10)
228
- : this.noteNameToMidi(part);
771
+ const midi = asMidiNumbers ? parseInt(part, 10) : this.noteNameToMidi(part);
229
772
  if (midi !== null && !isNaN(midi) && midi >= 0 && midi <= 127) {
230
- notes.push({
231
- note: midi,
232
- time: startTime + index * noteDuration,
233
- duration: noteDuration,
234
- velocity: 100
235
- });
773
+ notes.push({ note: midi, time: at, duration: step, velocity: 100 });
774
+ return;
236
775
  }
776
+ // A name this parser cannot read is LOST, and it used to be lost
777
+ // in silence: `note("c4 zz g4")` exported two notes with
778
+ // success:true, no warning and no `unrepresented` entry, so the
779
+ // caller had no way to learn a note had gone missing. The drum
780
+ // lane got exactly this fix in #433; the note lane never did
781
+ // (#469).
782
+ this.unrepresented.add(part);
237
783
  });
238
784
  return notes;
239
785
  }
@@ -245,8 +791,26 @@ export class MIDIExportService {
245
791
  */
246
792
  parseChordString(chordString, startTime) {
247
793
  const notes = [];
248
- const chords = chordString.split(/[\s,]+/).filter(c => c.length > 0);
249
- const chordDuration = chords.length > 0 ? 1 / chords.length : 1;
794
+ // Lanes first, then the shared tokenizer.
795
+ //
796
+ // This split on whitespace and commas directly, so it never saw
797
+ // brackets or alternations: `chord("<C Dm>")` became ["<C", "Dm>"],
798
+ // the first was rejected and the second read its suffix as "m>",
799
+ // which was unknown — and an unknown suffix used to mean a major
800
+ // triad, so the export was a D MAJOR, a chord in neither position
801
+ // (#433). Both halves of that are fixed; this is the half that
802
+ // stops the string being mangled in the first place.
803
+ const lanes = MIDIExportService.splitLanes(chordString);
804
+ if (lanes.length > 1) {
805
+ return lanes.flatMap(lane => this.parseChordString(lane, startTime));
806
+ }
807
+ const chords = MIDIExportService.expandOperators(MIDIExportService.tokenize(chordString), token => { this.unrepresented.add(token); }, token => { this.partial.add(token); });
808
+ // One pattern string spans a BAR, as everywhere else in this file —
809
+ // `parseNoteString` and `parseDrumString` both divide
810
+ // BEATS_PER_BAR. This divided 1, so `chord("C Dm")` packed both
811
+ // chords into beat 0-1 and left three quarters of the bar empty
812
+ // (#433 item 1).
813
+ const chordDuration = chords.length > 0 ? BEATS_PER_BAR / chords.length : BEATS_PER_BAR;
250
814
  chords.forEach((chord, index) => {
251
815
  if (chord === '~' || chord === '-' || chord === 'r') {
252
816
  return;
@@ -275,7 +839,14 @@ export class MIDIExportService {
275
839
  let match;
276
840
  while ((match = quotedRegex.exec(pattern)) !== null) {
277
841
  const noteSequence = match[1];
278
- const parsed = this.parseNoteString(noteSequence, notes.length);
842
+ // `notes.length` was passed here — a COUNT of events, used as an
843
+ // offset in BEATS. Two quoted sequences of three notes each put
844
+ // the second one three beats late, and eight notes put it eight
845
+ // beats out, past the end of the bar (#433 item 4).
846
+ //
847
+ // Each quoted sequence is its own lane over the same bar, which is
848
+ // how `parsePatternNotes` treats separate `s(...)` calls.
849
+ const parsed = this.parseNoteString(noteSequence, 0);
279
850
  notes.push(...parsed);
280
851
  }
281
852
  return notes;
@@ -288,6 +859,18 @@ export class MIDIExportService {
288
859
  */
289
860
  convertToMidi(notes, options = {}) {
290
861
  const { bpm = 120, bars = 4, trackName = 'Strudel Pattern', timeSignatureNumerator = 4, timeSignatureDenominator = 4 } = options;
862
+ // bpm <= 0 makes `time = beats / (bpm / 60)` non-finite, and
863
+ // @tonejs/midi's addNote then never returns — an infinite loop, not
864
+ // an exception, so the try/catch around this cannot catch it.
865
+ // Currently shielded by InputValidator at the tool boundary, but an
866
+ // unguarded hang at a public service method is not something to
867
+ // leave to a caller two layers up (#336).
868
+ if (!Number.isFinite(bpm) || bpm <= 0) {
869
+ throw new Error(`Invalid BPM: ${String(bpm)}. Must be a positive number.`);
870
+ }
871
+ if (!Number.isFinite(bars) || bars < 1) {
872
+ throw new Error(`Invalid bars: ${String(bars)}. Must be at least 1.`);
873
+ }
291
874
  const midi = new Midi();
292
875
  // Set tempo
293
876
  midi.header.setTempo(bpm);
@@ -297,10 +880,27 @@ export class MIDIExportService {
297
880
  timeSignature: [timeSignatureNumerator, timeSignatureDenominator],
298
881
  measures: 0
299
882
  });
300
- // Create track
301
- const track = midi.addTrack();
302
- track.name = trackName;
303
- // Add notes to track
883
+ // One track per channel.
884
+ //
885
+ // Percussion has to land on GM channel 9 or an importer cannot tell
886
+ // a kick from a pitched C2 — MIDIImportService keys its drum
887
+ // handling on `track.channel === 9` exactly. Everything melodic
888
+ // shares the default track, as before.
889
+ const tracksByChannel = new Map();
890
+ const trackFor = (channel) => {
891
+ let track = tracksByChannel.get(channel);
892
+ if (!track) {
893
+ track = midi.addTrack();
894
+ track.name = channel === GM_PERCUSSION_CHANNEL ? `${trackName} (drums)` : trackName;
895
+ track.channel = channel;
896
+ tracksByChannel.set(channel, track);
897
+ }
898
+ return track;
899
+ };
900
+ // Keep a melodic track first even for a drums-only export, so the
901
+ // file shape does not change depending on content.
902
+ trackFor(0);
903
+ let written = 0;
304
904
  notes.forEach(noteEvent => {
305
905
  // Only add notes within the specified bar range
306
906
  const maxTime = bars * timeSignatureNumerator;
@@ -310,13 +910,18 @@ export class MIDIExportService {
310
910
  // Convert beat time to seconds at the given BPM
311
911
  const timeInSeconds = (noteEvent.time / (bpm / 60));
312
912
  const durationInSeconds = (noteEvent.duration / (bpm / 60));
313
- track.addNote({
913
+ trackFor(noteEvent.channel ?? 0).addNote({
314
914
  midi: noteEvent.note,
315
915
  time: timeInSeconds,
316
916
  duration: durationInSeconds,
317
917
  velocity: noteEvent.velocity / 127
318
918
  });
919
+ written++;
319
920
  });
921
+ // `noteCount` reported parsed notes, not written ones, so a pattern
922
+ // truncated by `bars` still claimed the full count — 40 note() calls
923
+ // at bars=1 reported 40 with 4 in the file (#335).
924
+ this.lastWrittenCount = written;
320
925
  return midi;
321
926
  }
322
927
  /**
@@ -328,30 +933,53 @@ export class MIDIExportService {
328
933
  */
329
934
  exportToFile(pattern, filename = 'pattern.mid', options = {}) {
330
935
  try {
936
+ this.unrepresented.clear();
937
+ this.partial.clear();
331
938
  const notes = this.parsePatternNotes(pattern);
332
939
  if (notes.length === 0) {
940
+ // The loss report is dropped exactly where it is most useful.
941
+ //
942
+ // `note("c4?")` records `c4?` as unrepresented and then returns
943
+ // here, so the caller got the generic "use note(), n(), or
944
+ // chord()" — advice to use the function they already used —
945
+ // instead of the list of what actually failed (#433).
946
+ const loss = this.lossReport();
333
947
  return {
334
948
  success: false,
335
949
  output: '',
336
950
  noteCount: 0,
337
951
  bars: options.bars || 4,
338
952
  bpm: options.bpm || 120,
339
- error: 'No notes found in pattern. Use note(), n(), or chord() functions.'
953
+ ...loss,
954
+ error: loss.unrepresented !== undefined
955
+ ? `No notes could be exported. ${loss.warning ?? ''}`.trim()
956
+ : 'No notes found in pattern. Use note(), n(), or chord() functions.',
340
957
  };
341
958
  }
342
959
  const midi = this.convertToMidi(notes, options);
343
- // Ensure filename has .mid extension
344
- const finalFilename = filename.endsWith('.mid') ? filename : `${filename}.mid`;
345
- const outputPath = resolve(finalFilename);
346
- // Write MIDI data
960
+ // Confine the write to the export directory. `filename` comes from
961
+ // MCP tool arguments, so it is model-generated and may be hostile;
962
+ // this used to be a bare resolve() that wrote anywhere (#224).
963
+ const target = resolveSafeOutputPath(filename, {
964
+ directory: this.exportDir,
965
+ extension: '.mid',
966
+ defaultName: 'pattern.mid',
967
+ });
968
+ mkdirSync(this.exportDir, { recursive: true });
347
969
  const midiArray = midi.toArray();
348
- writeFileSync(outputPath, Buffer.from(midiArray));
970
+ writeFileSync(target.path, Buffer.from(midiArray));
349
971
  return {
350
972
  success: true,
351
- output: outputPath,
352
- noteCount: notes.length,
353
- bars: options.bars || 4,
354
- bpm: options.bpm || 120
973
+ output: target.path,
974
+ noteCount: this.lastWrittenCount,
975
+ bars: this.barsProduced(notes, options.bars ?? 4),
976
+ bpm: options.bpm || 120,
977
+ // Surfaced so the caller knows the file did not land where it
978
+ // asked, rather than silently believing the traversal worked.
979
+ ...(target.wasModified
980
+ ? { sanitizedFilename: target.filename, requestedFilename: target.requested }
981
+ : {}),
982
+ ...this.lossReport(),
355
983
  };
356
984
  }
357
985
  catch (error) {
@@ -371,17 +999,82 @@ export class MIDIExportService {
371
999
  * @param options - MIDI export options
372
1000
  * @returns Export result with base64 data
373
1001
  */
1002
+ /**
1003
+ * Describes anything the parser could not represent.
1004
+ *
1005
+ * Spread into a successful result, so a caller that checks only
1006
+ * `success` is unchanged while one that reads the response sees what
1007
+ * was lost. An export that silently drops most of a pattern is the
1008
+ * shape #274 and #287 were about, one layer down (#335).
1009
+ *
1010
+ * @returns `{}` when everything was representable
1011
+ */
1012
+ /**
1013
+ * Bars the exported notes actually occupy.
1014
+ *
1015
+ * `bars: options.bars || 4` reported the CAP, so `note("c4")` — one
1016
+ * beat of content — came back `bars: 4`. A caller sizing a timeline
1017
+ * from that gets four times the music (#433).
1018
+ *
1019
+ * Never more than the cap, since notes past it are not written, and
1020
+ * never less than one: a file with content occupies a bar even if the
1021
+ * content is a single note.
1022
+ *
1023
+ * @param notes - The events actually written
1024
+ * @param cap - The requested `bars` limit
1025
+ * @returns Bars occupied, 1..cap
1026
+ */
1027
+ barsProduced(notes, cap) {
1028
+ if (notes.length === 0)
1029
+ return 0;
1030
+ const end = Math.max(...notes.map(n => n.time + n.duration));
1031
+ return Math.max(1, Math.min(cap, Math.ceil(end / BEATS_PER_BAR)));
1032
+ }
1033
+ lossReport() {
1034
+ const skipped = [...this.unrepresented].sort();
1035
+ const lossy = [...this.partial].sort();
1036
+ if (skipped.length === 0 && lossy.length === 0)
1037
+ return {};
1038
+ const parts = [];
1039
+ if (skipped.length > 0) {
1040
+ parts.push(`${String(skipped.length)} token(s) could not be exported and were skipped: ` +
1041
+ `${skipped.slice(0, 8).join(', ')}${skipped.length > 8 ? ', ...' : ''}`);
1042
+ }
1043
+ if (lossy.length > 0) {
1044
+ parts.push(`${String(lossy.length)} token(s) were exported with loss: ` +
1045
+ `${lossy.slice(0, 8).join(', ')}${lossy.length > 8 ? ', ...' : ''} ` +
1046
+ '(an alternation gives only its first option in a single bar; ' +
1047
+ 'a duration weight is dropped)');
1048
+ }
1049
+ return {
1050
+ warning: `${parts.join('. ')}.`,
1051
+ ...(skipped.length > 0 ? { unrepresented: skipped } : {}),
1052
+ ...(lossy.length > 0 ? { partiallyExported: lossy } : {}),
1053
+ };
1054
+ }
374
1055
  exportToBase64(pattern, options = {}) {
375
1056
  try {
1057
+ this.unrepresented.clear();
1058
+ this.partial.clear();
376
1059
  const notes = this.parsePatternNotes(pattern);
377
1060
  if (notes.length === 0) {
1061
+ // The loss report is dropped exactly where it is most useful.
1062
+ //
1063
+ // `note("c4?")` records `c4?` as unrepresented and then returns
1064
+ // here, so the caller got the generic "use note(), n(), or
1065
+ // chord()" — advice to use the function they already used —
1066
+ // instead of the list of what actually failed (#433).
1067
+ const loss = this.lossReport();
378
1068
  return {
379
1069
  success: false,
380
1070
  output: '',
381
1071
  noteCount: 0,
382
1072
  bars: options.bars || 4,
383
1073
  bpm: options.bpm || 120,
384
- error: 'No notes found in pattern. Use note(), n(), or chord() functions.'
1074
+ ...loss,
1075
+ error: loss.unrepresented !== undefined
1076
+ ? `No notes could be exported. ${loss.warning ?? ''}`.trim()
1077
+ : 'No notes found in pattern. Use note(), n(), or chord() functions.',
385
1078
  };
386
1079
  }
387
1080
  const midi = this.convertToMidi(notes, options);
@@ -391,9 +1084,10 @@ export class MIDIExportService {
391
1084
  return {
392
1085
  success: true,
393
1086
  output: base64,
394
- noteCount: notes.length,
395
- bars: options.bars || 4,
396
- bpm: options.bpm || 120
1087
+ noteCount: this.lastWrittenCount,
1088
+ bars: this.barsProduced(notes, options.bars ?? 4),
1089
+ bpm: options.bpm || 120,
1090
+ ...this.lossReport(),
397
1091
  };
398
1092
  }
399
1093
  catch (error) {