@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
327
|
+
const parsedChords = this.parseChordString(chordContent, laneStart);
|
|
171
328
|
notes.push(...parsedChords);
|
|
172
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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(
|
|
970
|
+
writeFileSync(target.path, Buffer.from(midiArray));
|
|
349
971
|
return {
|
|
350
972
|
success: true,
|
|
351
|
-
output:
|
|
352
|
-
noteCount:
|
|
353
|
-
bars: options.bars
|
|
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
|
-
|
|
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:
|
|
395
|
-
bars: options.bars
|
|
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) {
|