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