audio-mixer-engine 0.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/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/audio-mixer-engine.cjs.js +1 -0
- package/dist/audio-mixer-engine.es.js +1669 -0
- package/package.json +54 -0
- package/src/assets/stick-4cs.mp3 +0 -0
- package/src/assets/stick-4d.mp3 +0 -0
- package/src/index.js +18 -0
- package/src/lib/audio-engine.js +526 -0
- package/src/lib/beat-mapper.js +155 -0
- package/src/lib/midi-parser.js +718 -0
- package/src/lib/midi-player.js +700 -0
- package/src/lib/playback-manager.js +1257 -0
- package/src/lib/spessasynth-audio-engine.js +310 -0
- package/src/lib/spessasynth-channel-handle.js +151 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MidiParser - A class for parsing MIDI files
|
|
3
|
+
* Extracts parts, bar structure, and metadata
|
|
4
|
+
*/
|
|
5
|
+
class MidiParser {
|
|
6
|
+
constructor() {
|
|
7
|
+
// Common part names to detect
|
|
8
|
+
this.partNames = [
|
|
9
|
+
'soprano', 'alto', 'tenor', 'bass',
|
|
10
|
+
'treble', 'mezzo', 'baritone',
|
|
11
|
+
's', 'a', 't', 'b', 'satb'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Store the parsed data
|
|
15
|
+
this.parsedData = {
|
|
16
|
+
parts: {}, // Will contain separate arrays for each vocal part
|
|
17
|
+
barStructure: [], // Will contain bar timing information
|
|
18
|
+
metadata: {} // Will contain title, composer, etc.
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main method to parse a MIDI file
|
|
24
|
+
* @param {ArrayBuffer} midiFileBuffer - The MIDI file as an ArrayBuffer
|
|
25
|
+
* @returns {Object} Parsed data with parts, barStructure and metadata
|
|
26
|
+
*/
|
|
27
|
+
async parse(midiFileBuffer) {
|
|
28
|
+
try {
|
|
29
|
+
const midi = await this._parseMidiBuffer(midiFileBuffer);
|
|
30
|
+
|
|
31
|
+
// Extract metadata
|
|
32
|
+
this._extractMetadata(midi);
|
|
33
|
+
|
|
34
|
+
// Extract bar structure (time signatures, tempo changes)
|
|
35
|
+
this._extractBarStructure(midi);
|
|
36
|
+
|
|
37
|
+
// Extract parts
|
|
38
|
+
this._extractParts(midi);
|
|
39
|
+
|
|
40
|
+
return this.parsedData;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Error parsing MIDI file:', error);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse the MIDI buffer into a workable format
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
async _parseMidiBuffer(midiFileBuffer) {
|
|
52
|
+
// Convert ArrayBuffer to Uint8Array
|
|
53
|
+
const data = new Uint8Array(midiFileBuffer);
|
|
54
|
+
|
|
55
|
+
// Check if it's a valid MIDI file
|
|
56
|
+
if (!(data[0] === 0x4D && data[1] === 0x54 && data[2] === 0x68 && data[3] === 0x64)) {
|
|
57
|
+
throw new Error('Not a valid MIDI file');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse header chunk
|
|
61
|
+
const headerLength = this._bytesToNumber(data.slice(4, 8));
|
|
62
|
+
const format = this._bytesToNumber(data.slice(8, 10));
|
|
63
|
+
const tracksCount = this._bytesToNumber(data.slice(10, 12));
|
|
64
|
+
const division = this._bytesToNumber(data.slice(12, 14));
|
|
65
|
+
|
|
66
|
+
// MIDI time division (ticks per quarter note or SMPTE)
|
|
67
|
+
const ticksPerBeat = division & 0x8000 ? null : division;
|
|
68
|
+
|
|
69
|
+
const midi = {
|
|
70
|
+
format,
|
|
71
|
+
ticksPerBeat,
|
|
72
|
+
tracks: [],
|
|
73
|
+
duration: 0
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Parse each track
|
|
77
|
+
let currentPosition = 8 + headerLength;
|
|
78
|
+
for (let i = 0; i < tracksCount; i++) {
|
|
79
|
+
if (data[currentPosition] === 0x4D && data[currentPosition + 1] === 0x54 &&
|
|
80
|
+
data[currentPosition + 2] === 0x72 && data[currentPosition + 3] === 0x6B) {
|
|
81
|
+
|
|
82
|
+
const trackLength = this._bytesToNumber(data.slice(currentPosition + 4, currentPosition + 8));
|
|
83
|
+
const trackData = data.slice(currentPosition + 8, currentPosition + 8 + trackLength);
|
|
84
|
+
|
|
85
|
+
const track = this._parseTrack(trackData);
|
|
86
|
+
midi.tracks.push(track);
|
|
87
|
+
|
|
88
|
+
currentPosition += 8 + trackLength;
|
|
89
|
+
} else {
|
|
90
|
+
throw new Error(`Invalid track header at position ${currentPosition}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return midi;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse a single MIDI track
|
|
99
|
+
* @private
|
|
100
|
+
*/
|
|
101
|
+
_parseTrack(data) {
|
|
102
|
+
const track = {
|
|
103
|
+
notes: [],
|
|
104
|
+
name: null,
|
|
105
|
+
lyrics: [],
|
|
106
|
+
events: [],
|
|
107
|
+
duration: 0
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let currentPosition = 0;
|
|
111
|
+
let currentTick = 0;
|
|
112
|
+
let runningStatus = null;
|
|
113
|
+
|
|
114
|
+
while (currentPosition < data.length) {
|
|
115
|
+
// Parse delta time
|
|
116
|
+
let deltaTime = 0;
|
|
117
|
+
let byte = 0;
|
|
118
|
+
// noinspection JSBitwiseOperatorUsage
|
|
119
|
+
do {
|
|
120
|
+
byte = data[currentPosition++];
|
|
121
|
+
deltaTime = (deltaTime << 7) | (byte & 0x7F);
|
|
122
|
+
} while (byte & 0x80);
|
|
123
|
+
|
|
124
|
+
currentTick += deltaTime;
|
|
125
|
+
|
|
126
|
+
// Get event type
|
|
127
|
+
byte = data[currentPosition++];
|
|
128
|
+
let eventType = byte;
|
|
129
|
+
|
|
130
|
+
// Handle running status
|
|
131
|
+
if ((byte & 0x80) === 0) {
|
|
132
|
+
if (runningStatus === null) {
|
|
133
|
+
throw new Error("Running status byte encountered before status byte");
|
|
134
|
+
}
|
|
135
|
+
eventType = runningStatus;
|
|
136
|
+
currentPosition--; // We need to reread the current byte as a data byte
|
|
137
|
+
} else {
|
|
138
|
+
runningStatus = eventType;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle different event types
|
|
142
|
+
if (eventType === 0xFF) { // Meta event
|
|
143
|
+
const metaType = data[currentPosition++];
|
|
144
|
+
const metaLength = this._readVariableLengthValue(data, currentPosition);
|
|
145
|
+
currentPosition += metaLength.bytesRead;
|
|
146
|
+
const metaData = data.slice(currentPosition, currentPosition + metaLength.value);
|
|
147
|
+
currentPosition += metaLength.value;
|
|
148
|
+
|
|
149
|
+
// Handle meta events
|
|
150
|
+
switch (metaType) {
|
|
151
|
+
case 0x03: // Track name
|
|
152
|
+
track.name = this._bytesToString(metaData);
|
|
153
|
+
break;
|
|
154
|
+
case 0x01: // Text event
|
|
155
|
+
track.events.push({
|
|
156
|
+
type: 'text',
|
|
157
|
+
text: this._bytesToString(metaData),
|
|
158
|
+
tick: currentTick
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
case 0x05: // Lyrics
|
|
162
|
+
track.lyrics.push({
|
|
163
|
+
text: this._bytesToString(metaData),
|
|
164
|
+
tick: currentTick
|
|
165
|
+
});
|
|
166
|
+
break;
|
|
167
|
+
case 0x51: // Tempo
|
|
168
|
+
const microsecondsPerBeat = this._bytesToNumber(metaData);
|
|
169
|
+
const tempo = Math.round(60000000 / microsecondsPerBeat);
|
|
170
|
+
track.events.push({
|
|
171
|
+
type: 'tempo',
|
|
172
|
+
bpm: tempo,
|
|
173
|
+
tick: currentTick
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
case 0x58: // Time signature
|
|
177
|
+
track.events.push({
|
|
178
|
+
type: 'timeSignature',
|
|
179
|
+
numerator: metaData[0],
|
|
180
|
+
denominator: Math.pow(2, metaData[1]),
|
|
181
|
+
tick: currentTick
|
|
182
|
+
});
|
|
183
|
+
break;
|
|
184
|
+
case 0x2F: // End of track
|
|
185
|
+
track.duration = currentTick;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if ((eventType & 0xF0) === 0x90) { // Note on
|
|
190
|
+
const channel = eventType & 0x0F;
|
|
191
|
+
const noteNumber = data[currentPosition++];
|
|
192
|
+
const velocity = data[currentPosition++];
|
|
193
|
+
|
|
194
|
+
if (velocity > 0) { // Note on with velocity > 0
|
|
195
|
+
track.notes.push({
|
|
196
|
+
type: 'noteOn',
|
|
197
|
+
noteNumber,
|
|
198
|
+
velocity,
|
|
199
|
+
tick: currentTick,
|
|
200
|
+
channel
|
|
201
|
+
});
|
|
202
|
+
} else { // Note on with velocity = 0 is equivalent to note off
|
|
203
|
+
track.notes.push({
|
|
204
|
+
type: 'noteOff',
|
|
205
|
+
noteNumber,
|
|
206
|
+
tick: currentTick,
|
|
207
|
+
channel
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if ((eventType & 0xF0) === 0x80) { // Note off
|
|
212
|
+
const channel = eventType & 0x0F;
|
|
213
|
+
const noteNumber = data[currentPosition++];
|
|
214
|
+
// noinspection JSUnusedLocalSymbols
|
|
215
|
+
const velocity = data[currentPosition++]; // Release velocity, intentionally ignored (maybe for now)
|
|
216
|
+
|
|
217
|
+
track.notes.push({
|
|
218
|
+
type: 'noteOff',
|
|
219
|
+
noteNumber,
|
|
220
|
+
tick: currentTick,
|
|
221
|
+
channel
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else if (eventType === 0xF0 || eventType === 0xF7) { // SysEx events
|
|
225
|
+
const length = this._readVariableLengthValue(data, currentPosition);
|
|
226
|
+
currentPosition += length.bytesRead + length.value;
|
|
227
|
+
}
|
|
228
|
+
else if ((eventType & 0xF0) === 0xB0) { // Controller change
|
|
229
|
+
const channel = eventType & 0x0F;
|
|
230
|
+
const controllerNumber = data[currentPosition++];
|
|
231
|
+
const value = data[currentPosition++];
|
|
232
|
+
|
|
233
|
+
track.events.push({
|
|
234
|
+
type: 'controller',
|
|
235
|
+
controllerNumber,
|
|
236
|
+
value,
|
|
237
|
+
channel,
|
|
238
|
+
tick: currentTick
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else if ((eventType & 0xF0) === 0xC0) { // Program change
|
|
242
|
+
const channel = eventType & 0x0F;
|
|
243
|
+
const programNumber = data[currentPosition++];
|
|
244
|
+
|
|
245
|
+
track.events.push({
|
|
246
|
+
type: 'programChange',
|
|
247
|
+
programNumber,
|
|
248
|
+
channel,
|
|
249
|
+
tick: currentTick
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
else if ((eventType & 0xF0) === 0xD0) { // Channel aftertouch
|
|
253
|
+
const channel = eventType & 0x0F;
|
|
254
|
+
const pressure = data[currentPosition++];
|
|
255
|
+
|
|
256
|
+
track.events.push({
|
|
257
|
+
type: 'channelAftertouch',
|
|
258
|
+
pressure,
|
|
259
|
+
channel,
|
|
260
|
+
tick: currentTick
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else if ((eventType & 0xF0) === 0xE0) { // Pitch bend
|
|
264
|
+
const channel = eventType & 0x0F;
|
|
265
|
+
const lsb = data[currentPosition++];
|
|
266
|
+
const msb = data[currentPosition++];
|
|
267
|
+
const value = ((msb << 7) | lsb) - 8192; // Center value is 8192 (0x2000)
|
|
268
|
+
|
|
269
|
+
track.events.push({
|
|
270
|
+
type: 'pitchBend',
|
|
271
|
+
value,
|
|
272
|
+
channel,
|
|
273
|
+
tick: currentTick
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
else if ((eventType & 0xF0) === 0xA0) { // Note aftertouch (poly pressure)
|
|
277
|
+
const channel = eventType & 0x0F;
|
|
278
|
+
const noteNumber = data[currentPosition++];
|
|
279
|
+
const pressure = data[currentPosition++];
|
|
280
|
+
|
|
281
|
+
track.events.push({
|
|
282
|
+
type: 'noteAftertouch',
|
|
283
|
+
noteNumber,
|
|
284
|
+
pressure,
|
|
285
|
+
channel,
|
|
286
|
+
tick: currentTick
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Skip unknown event types
|
|
291
|
+
console.warn(`Unknown event type: ${eventType.toString(16)} at position ${currentPosition - 1}`);
|
|
292
|
+
// Try to recover by skipping to the next event
|
|
293
|
+
currentPosition++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return track;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Extract metadata from the MIDI object
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_extractMetadata(midi) {
|
|
305
|
+
const metadata = {
|
|
306
|
+
title: null,
|
|
307
|
+
composer: null,
|
|
308
|
+
partNames: [],
|
|
309
|
+
format: midi.format,
|
|
310
|
+
ticksPerBeat: midi.ticksPerBeat
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Look for title, composer and part names in track names and text events
|
|
314
|
+
midi.tracks.forEach((track, index) => {
|
|
315
|
+
// First track with name is often the title
|
|
316
|
+
if (track.name && !metadata.title) {
|
|
317
|
+
metadata.title = track.name;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check for common metadata in text events
|
|
321
|
+
track.events.filter(e => e.type === 'text').forEach(event => {
|
|
322
|
+
const text = event.text.toLowerCase();
|
|
323
|
+
if ((text.includes('compos') || text.includes('by')) && !metadata.composer) {
|
|
324
|
+
metadata.composer = event.text;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Store track name as potential part name
|
|
329
|
+
if (track.name) {
|
|
330
|
+
// Check if this track name matches a common choir part name
|
|
331
|
+
const trackNameLower = track.name.toLowerCase();
|
|
332
|
+
for (const partName of this.partNames) {
|
|
333
|
+
if (trackNameLower.includes(partName)) {
|
|
334
|
+
metadata.partNames.push({
|
|
335
|
+
index,
|
|
336
|
+
name: track.name
|
|
337
|
+
});
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
this.parsedData.metadata = metadata;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Extract bar structure (time signatures, tempo changes)
|
|
349
|
+
* New format: {"sig": [numerator, denominator], "bpm": tempo_or_array}
|
|
350
|
+
* @private
|
|
351
|
+
*/
|
|
352
|
+
_extractBarStructure(midi) {
|
|
353
|
+
// Get ticks per beat for calculations
|
|
354
|
+
const ticksPerBeat = midi.ticksPerBeat || 480;
|
|
355
|
+
|
|
356
|
+
// Collect all time signature and tempo changes
|
|
357
|
+
const allEvents = [];
|
|
358
|
+
midi.tracks.forEach(track => {
|
|
359
|
+
track.events.forEach(event => {
|
|
360
|
+
if (event.type === 'timeSignature' || event.type === 'tempo') {
|
|
361
|
+
allEvents.push(event);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Sort events by tick
|
|
367
|
+
allEvents.sort((a, b) => a.tick - b.tick);
|
|
368
|
+
|
|
369
|
+
// Find total duration of the MIDI file (latest note end tick)
|
|
370
|
+
let totalDurationTicks = 0;
|
|
371
|
+
midi.tracks.forEach(track => {
|
|
372
|
+
if (track.notes) {
|
|
373
|
+
track.notes.forEach(note => {
|
|
374
|
+
if (note.type === 'noteOff' && note.tick > totalDurationTicks) {
|
|
375
|
+
totalDurationTicks = note.tick;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// If no notes found, use a reasonable default
|
|
382
|
+
if (totalDurationTicks === 0) {
|
|
383
|
+
totalDurationTicks = ticksPerBeat * 8; // Default to 8 beats
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Build bar structure based on time signature changes
|
|
387
|
+
const barStructure = [];
|
|
388
|
+
|
|
389
|
+
// Get all time signature changes with their positions
|
|
390
|
+
const timeSignatureChanges = allEvents
|
|
391
|
+
.filter(event => event.type === 'timeSignature')
|
|
392
|
+
.sort((a, b) => a.tick - b.tick);
|
|
393
|
+
|
|
394
|
+
// Start with the default (will be overridden if there is a time signature at tick 0)
|
|
395
|
+
let currentTimeSignature = { numerator: 4, denominator: 4 };
|
|
396
|
+
|
|
397
|
+
let currentTempo = 120; // Default tempo
|
|
398
|
+
let currentTick = 0;
|
|
399
|
+
let timeSignatureIndex = 0;
|
|
400
|
+
|
|
401
|
+
while (currentTick < totalDurationTicks) {
|
|
402
|
+
// Update time signature for the current position
|
|
403
|
+
while (timeSignatureIndex < timeSignatureChanges.length &&
|
|
404
|
+
timeSignatureChanges[timeSignatureIndex].tick <= currentTick) {
|
|
405
|
+
currentTimeSignature = timeSignatureChanges[timeSignatureIndex];
|
|
406
|
+
timeSignatureIndex++;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// For anacrusis/pickup bars, the bar should extend to the next time signature change
|
|
410
|
+
// For normal bars, use standard bar length
|
|
411
|
+
let barEndTick;
|
|
412
|
+
|
|
413
|
+
// Regular bar - use standard time signature-based length
|
|
414
|
+
barEndTick = currentTick + (ticksPerBeat * 4 * currentTimeSignature.numerator / currentTimeSignature.denominator);
|
|
415
|
+
|
|
416
|
+
// Find tempo changes within this bar
|
|
417
|
+
const barTempoChanges = allEvents.filter(event =>
|
|
418
|
+
event.type === 'tempo' &&
|
|
419
|
+
event.tick >= currentTick &&
|
|
420
|
+
event.tick < barEndTick
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// Build tempo array for each beat in the bar
|
|
424
|
+
const beatsInBar = currentTimeSignature.numerator;
|
|
425
|
+
const bpmArray = [];
|
|
426
|
+
|
|
427
|
+
for (let beat = 0; beat < beatsInBar; beat++) {
|
|
428
|
+
const beatStartTick = currentTick + (beat * ticksPerBeat);
|
|
429
|
+
|
|
430
|
+
// Find the effective tempo for this beat
|
|
431
|
+
let effectiveTempo = currentTempo; // Start with current tempo
|
|
432
|
+
|
|
433
|
+
// Check for tempo changes that affect this beat
|
|
434
|
+
for (let i = barTempoChanges.length - 1; i >= 0; i--) {
|
|
435
|
+
const tempoChange = barTempoChanges[i];
|
|
436
|
+
if (tempoChange.tick <= beatStartTick) {
|
|
437
|
+
effectiveTempo = tempoChange.bpm;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Also check for global tempo changes before this beat
|
|
443
|
+
for (let i = allEvents.length - 1; i >= 0; i--) {
|
|
444
|
+
const event = allEvents[i];
|
|
445
|
+
if (event.type === 'tempo' && event.tick <= beatStartTick) {
|
|
446
|
+
effectiveTempo = event.bpm;
|
|
447
|
+
// Update current tempo for next iterations
|
|
448
|
+
if (event.tick <= currentTick) {
|
|
449
|
+
currentTempo = event.bpm;
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The bpm from the midi is crotchets (quarter notes) per minute, but we want
|
|
456
|
+
// time signature notes per minute, (i.e. if an x/2 signature then minims (half notes) per minute)
|
|
457
|
+
bpmArray.push(effectiveTempo * currentTimeSignature.denominator / 4);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// If all beats have the same tempo, use single number; otherwise use array
|
|
461
|
+
const allSameTempo = bpmArray.every(tempo => tempo === bpmArray[0]);
|
|
462
|
+
const bpm = allSameTempo ? bpmArray[0] : bpmArray;
|
|
463
|
+
|
|
464
|
+
barStructure.push({
|
|
465
|
+
sig: [currentTimeSignature.numerator, currentTimeSignature.denominator],
|
|
466
|
+
bpm: bpm
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Move to next bar
|
|
470
|
+
currentTick = barEndTick;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
this.parsedData.barStructure = barStructure;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Extract notes for each voice part
|
|
478
|
+
* @private
|
|
479
|
+
*/
|
|
480
|
+
_extractParts(midi) {
|
|
481
|
+
const parts = {};
|
|
482
|
+
const ticksPerBeat = midi.ticksPerBeat;
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
// Try to identify vocal parts by track name
|
|
486
|
+
midi.tracks.forEach((track, trackIndex) => {
|
|
487
|
+
if (!track.notes.length) return; // Skip tracks with no notes (e.g., conductor track)
|
|
488
|
+
|
|
489
|
+
let partName = null;
|
|
490
|
+
|
|
491
|
+
// Try to identify part name from track name
|
|
492
|
+
if (track.name) {
|
|
493
|
+
const lowerName = track.name.toLowerCase();
|
|
494
|
+
for (const name of this.partNames) {
|
|
495
|
+
// For single letters, require exact match; for words, allow includes
|
|
496
|
+
if (name.length === 1) {
|
|
497
|
+
if (lowerName === name) {
|
|
498
|
+
partName = name;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
if (lowerName.includes(name)) {
|
|
503
|
+
partName = name;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// If no recognized part name, use track name or track number
|
|
511
|
+
if (!partName) {
|
|
512
|
+
partName = track.name || `Track ${trackIndex + 1}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Normalize part name
|
|
516
|
+
if (partName === 's') partName = 'soprano';
|
|
517
|
+
if (partName === 'a') partName = 'alto';
|
|
518
|
+
if (partName === 't') partName = 'tenor';
|
|
519
|
+
if (partName === 'b') partName = 'bass';
|
|
520
|
+
|
|
521
|
+
// Ensure unique part names by appending counter if name already exists
|
|
522
|
+
let uniquePartName = partName;
|
|
523
|
+
let counter = 2; // Start at 2 for first duplicate
|
|
524
|
+
while (parts[uniquePartName]) {
|
|
525
|
+
uniquePartName = `${partName} ${counter}`;
|
|
526
|
+
counter++;
|
|
527
|
+
}
|
|
528
|
+
partName = uniquePartName;
|
|
529
|
+
|
|
530
|
+
// Process notes
|
|
531
|
+
const notes = [];
|
|
532
|
+
const noteOns = {};
|
|
533
|
+
|
|
534
|
+
track.notes.forEach(noteEvent => {
|
|
535
|
+
if (noteEvent.type === 'noteOn') {
|
|
536
|
+
// Store note start information
|
|
537
|
+
noteOns[noteEvent.noteNumber] = {
|
|
538
|
+
tick: noteEvent.tick,
|
|
539
|
+
velocity: noteEvent.velocity
|
|
540
|
+
};
|
|
541
|
+
} else if (noteEvent.type === 'noteOff') {
|
|
542
|
+
// If we have a matching note on, create a complete note
|
|
543
|
+
if (noteOns[noteEvent.noteNumber]) {
|
|
544
|
+
const start = noteOns[noteEvent.noteNumber];
|
|
545
|
+
const duration = noteEvent.tick - start.tick;
|
|
546
|
+
|
|
547
|
+
notes.push({
|
|
548
|
+
pitch: noteEvent.noteNumber,
|
|
549
|
+
name: this._midiNoteToName(noteEvent.noteNumber),
|
|
550
|
+
startTick: start.tick,
|
|
551
|
+
endTick: noteEvent.tick,
|
|
552
|
+
duration,
|
|
553
|
+
// Convert ticks to actual time considering tempo changes
|
|
554
|
+
startTime: this._ticksToTime(start.tick, midi),
|
|
555
|
+
endTime: this._ticksToTime(noteEvent.tick, midi),
|
|
556
|
+
velocity: start.velocity
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Remove from active notes
|
|
560
|
+
delete noteOns[noteEvent.noteNumber];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Add any lyrics associated with this track
|
|
566
|
+
const lyrics = track.lyrics.map(lyric => ({
|
|
567
|
+
text: lyric.text,
|
|
568
|
+
tick: lyric.tick,
|
|
569
|
+
time: lyric.tick / ticksPerBeat // Time in quarter notes
|
|
570
|
+
}));
|
|
571
|
+
|
|
572
|
+
// Sort notes by start time
|
|
573
|
+
notes.sort((a, b) => a.startTick - b.startTick);
|
|
574
|
+
|
|
575
|
+
// Extract program changes for this track
|
|
576
|
+
const programChanges = track.events
|
|
577
|
+
.filter(event => event.type === 'programChange')
|
|
578
|
+
.map(event => ({
|
|
579
|
+
programNumber: event.programNumber,
|
|
580
|
+
tick: event.tick,
|
|
581
|
+
time: this._ticksToTime(event.tick, midi)
|
|
582
|
+
}))
|
|
583
|
+
.sort((a, b) => a.tick - b.tick);
|
|
584
|
+
|
|
585
|
+
// Determine default instrument (first program change or 0 for piano)
|
|
586
|
+
const defaultInstrument = programChanges.length > 0 ? programChanges[0].programNumber : 0;
|
|
587
|
+
|
|
588
|
+
parts[partName] = {
|
|
589
|
+
notes,
|
|
590
|
+
lyrics,
|
|
591
|
+
trackIndex,
|
|
592
|
+
programChanges,
|
|
593
|
+
defaultInstrument
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
this.parsedData.parts = parts;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Convert a MIDI note number to note name
|
|
602
|
+
* @private
|
|
603
|
+
*/
|
|
604
|
+
_midiNoteToName(noteNumber) {
|
|
605
|
+
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
606
|
+
const octave = Math.floor(noteNumber / 12) - 1;
|
|
607
|
+
const note = notes[noteNumber % 12];
|
|
608
|
+
return `${note}${octave}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Convert bytes to number
|
|
613
|
+
* @private
|
|
614
|
+
*/
|
|
615
|
+
_bytesToNumber(bytes) {
|
|
616
|
+
let value = 0;
|
|
617
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
618
|
+
value = (value << 8) | bytes[i];
|
|
619
|
+
}
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Convert bytes to string
|
|
625
|
+
* @private
|
|
626
|
+
*/
|
|
627
|
+
_bytesToString(bytes) {
|
|
628
|
+
return new TextDecoder().decode(bytes);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Read variable-length value from MIDI data
|
|
633
|
+
* @private
|
|
634
|
+
*/
|
|
635
|
+
_readVariableLengthValue(data, position) {
|
|
636
|
+
let value = 0;
|
|
637
|
+
let byte;
|
|
638
|
+
let bytesRead = 0;
|
|
639
|
+
|
|
640
|
+
// noinspection JSBitwiseOperatorUsage
|
|
641
|
+
do {
|
|
642
|
+
byte = data[position + bytesRead++];
|
|
643
|
+
value = (value << 7) | (byte & 0x7F);
|
|
644
|
+
} while (byte & 0x80);
|
|
645
|
+
|
|
646
|
+
return { value, bytesRead };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Convert ticks to time in seconds considering tempo changes within bars
|
|
651
|
+
* @private
|
|
652
|
+
*/
|
|
653
|
+
_ticksToTime(targetTick, midi) {
|
|
654
|
+
const ticksPerBeat = midi.ticksPerBeat || 480;
|
|
655
|
+
|
|
656
|
+
// Get all tempo events sorted by tick
|
|
657
|
+
const tempoEvents = [];
|
|
658
|
+
midi.tracks.forEach(track => {
|
|
659
|
+
track.events.forEach(event => {
|
|
660
|
+
if (event.type === 'tempo') {
|
|
661
|
+
tempoEvents.push(event);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
tempoEvents.sort((a, b) => a.tick - b.tick);
|
|
666
|
+
|
|
667
|
+
let totalTime = 0;
|
|
668
|
+
let currentTick = 0;
|
|
669
|
+
let currentTempo = 120; // Default tempo
|
|
670
|
+
|
|
671
|
+
// Process tempo changes up to the target tick
|
|
672
|
+
for (const tempoEvent of tempoEvents) {
|
|
673
|
+
if (tempoEvent.tick > targetTick) break;
|
|
674
|
+
|
|
675
|
+
// Add time for the segment from currentTick to this tempo change
|
|
676
|
+
if (tempoEvent.tick > currentTick) {
|
|
677
|
+
const segmentTicks = tempoEvent.tick - currentTick;
|
|
678
|
+
const segmentTime = (segmentTicks / ticksPerBeat) * (60 / currentTempo);
|
|
679
|
+
totalTime += segmentTime;
|
|
680
|
+
currentTick = tempoEvent.tick;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Update current tempo
|
|
684
|
+
currentTempo = tempoEvent.bpm;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Add time for remaining ticks at current tempo
|
|
688
|
+
if (targetTick > currentTick) {
|
|
689
|
+
const remainingTicks = targetTick - currentTick;
|
|
690
|
+
const remainingTime = (remainingTicks / ticksPerBeat) * (60 / currentTempo);
|
|
691
|
+
totalTime += remainingTime;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return totalTime;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Export the class
|
|
699
|
+
export default MidiParser;
|
|
700
|
+
|
|
701
|
+
// Example usage:
|
|
702
|
+
// const parser = new MidiParser();
|
|
703
|
+
// const fileInput = document.getElementById('midiFileInput');
|
|
704
|
+
//
|
|
705
|
+
// fileInput.addEventListener('change', async (event) => {
|
|
706
|
+
// const file = event.target.files[0];
|
|
707
|
+
// const arrayBuffer = await file.arrayBuffer();
|
|
708
|
+
//
|
|
709
|
+
// try {
|
|
710
|
+
// const parsedData = await parser.parse(arrayBuffer);
|
|
711
|
+
// console.log('Parsed MIDI data:', parsedData);
|
|
712
|
+
// console.log('Parts:', parsedData.parts);
|
|
713
|
+
// console.log('Bar structure:', parsedData.barStructure);
|
|
714
|
+
// console.log('Metadata:', parsedData.metadata);
|
|
715
|
+
// } catch (error) {
|
|
716
|
+
// console.error('Error parsing MIDI file:', error);
|
|
717
|
+
// }
|
|
718
|
+
// });
|