-
-
Notifications
You must be signed in to change notification settings - Fork 115
/
Utilities.js
589 lines (504 loc) · 20.5 KB
/
Utilities.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
import {Note} from "./Note.js";
import {WebMidi} from "./WebMidi.js";
import {Enumerations} from "./Enumerations.js";
/**
* The `Utilities` class contains general-purpose utility methods. All methods are static and
* should be called using the class name. For example: `Utilities.getNoteDetails("C4")`.
*
* @license Apache-2.0
* @since 3.0.0
*/
export class Utilities {
/**
* Returns a MIDI note number matching the identifier passed in the form of a string. The
* identifier must include the octave number. The identifier also optionally include a sharp (#),
* a double sharp (##), a flat (b) or a double flat (bb) symbol. For example, these are all valid
* identifiers: C5, G4, D#-1, F0, Gb7, Eb-1, Abb4, B##6, etc.
*
* When converting note identifiers to numbers, C4 is considered to be middle C (MIDI note number
* 60) as per the scientific pitch notation standard.
*
* The resulting note number can be offset by using the `octaveOffset` parameter.
*
* @param identifier {string} The identifier in the form of a letter, followed by an optional "#",
* "##", "b" or "bb" followed by the octave number. For exemple: C5, G4, D#-1, F0, Gb7, Eb-1,
* Abb4, B##6, etc.
*
* @param {number} [octaveOffset=0] A integer to offset the octave by.
*
* @returns {number} The MIDI note number (an integer between 0 and 127).
*
* @throws RangeError Invalid 'octaveOffset' value
*
* @throws TypeError Invalid note identifier
*
* @license Apache-2.0
* @since 3.0.0
* @static
*/
static toNoteNumber(identifier, octaveOffset = 0) {
// Validation
octaveOffset = octaveOffset == undefined ? 0 : parseInt(octaveOffset);
if (isNaN(octaveOffset)) throw new RangeError("Invalid 'octaveOffset' value");
if (typeof identifier !== "string") identifier = "";
const fragments = this.getNoteDetails(identifier);
if (!fragments) throw new TypeError("Invalid note identifier");
const notes = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
let result = (fragments.octave + 1 + octaveOffset) * 12;
result += notes[fragments.name];
if (fragments.accidental) {
if (fragments.accidental.startsWith("b")) {
result -= fragments.accidental.length;
} else {
result += fragments.accidental.length;
}
}
if (result < 0 || result > 127) throw new RangeError("Invalid octaveOffset value");
return result;
}
/**
* Given a proper note identifier (`C#4`, `Gb-1`, etc.) or a valid MIDI note number (0-127), this
* method returns an object containing broken down details about the specified note (uppercase
* letter, accidental and octave).
*
* When a number is specified, the translation to note is done using a value of 60 for middle C
* (C4 = middle C).
*
* @param value {string|number} A note identifier A atring ("C#4", "Gb-1", etc.) or a MIDI note
* number (0-127).
*
* @returns {{accidental: string, identifier: string, name: string, octave: number }}
*
* @throws TypeError Invalid note identifier
*
* @since 3.0.0
* @static
*/
static getNoteDetails(value) {
if (Number.isInteger(value)) value = this.toNoteIdentifier(value);
const matches = value.match(/^([CDEFGAB])(#{0,2}|b{0,2})(-?\d+)$/i);
if (!matches) throw new TypeError("Invalid note identifier");
const name = matches[1].toUpperCase();
const octave = parseInt(matches[3]);
let accidental = matches[2].toLowerCase();
accidental = accidental === "" ? undefined : accidental;
const fragments = {
accidental: accidental,
identifier: name + (accidental || "") + octave,
name: name,
octave: octave
};
return fragments;
}
/**
* Returns a sanitized array of valid MIDI channel numbers (1-16). The parameter should be a
* single integer or an array of integers.
*
* For backwards-compatibility, passing `undefined` as a parameter to this method results in all
* channels being returned (1-16). Otherwise, parameters that cannot successfully be parsed to
* integers between 1 and 16 are silently ignored.
*
* @param [channel] {number|number[]} An integer or an array of integers to parse as channel
* numbers.
*
* @returns {number[]} An array of 0 or more valid MIDI channel numbers.
*
* @since 3.0.0
* @static
*/
static sanitizeChannels(channel) {
let channels;
if (WebMidi.validation) {
if (channel === "all") { // backwards-compatibility
channels = ["all"];
} else if (channel === "none") { // backwards-compatibility
return [];
}
}
if (!Array.isArray(channel)) {
channels = [channel];
} else {
channels = channel;
}
// In order to preserve backwards-compatibility, we let this assignment as it is.
if (channels.indexOf("all") > -1) {
channels = Enumerations.MIDI_CHANNEL_NUMBERS;
}
return channels
.map(function(ch) {
return parseInt(ch);
})
.filter(function(ch) {
return (ch >= 1 && ch <= 16);
});
}
/**
* Returns a valid timestamp, relative to the navigation start of the document, derived from the
* `time` parameter. If the parameter is a string starting with the "+" sign and followed by a
* number, the resulting timestamp will be the sum of the current timestamp plus that number. If
* the parameter is a positive number, it will be returned as is. Otherwise, false will be
* returned.
*
* @param [time] {number|string} The time string (e.g. `"+2000"`) or number to parse
* @return {number|false} A positive number or `false` (if the time cannot be converted)
*
* @since 3.0.0
* @static
*/
static toTimestamp(time) {
let value = false;
const parsed = parseFloat(time);
if (isNaN(parsed)) return false;
if (typeof time === "string" && time.substring(0, 1) === "+") {
if (parsed >= 0) value = WebMidi.time + parsed;
} else {
if (parsed >= 0) value = parsed;
}
return value;
}
/**
* Returns a valid MIDI note number (0-127) given the specified input. The input usually is a
* string containing a note identifier (`"C3"`, `"F#4"`, `"D-2"`, `"G8"`, etc.). If an integer
* between 0 and 127 is passed, it will simply be returned as is (for convenience). Other strings
* will be parsed for integer value, if possible.
*
* If the input is an identifier, the resulting note number is offset by the `octaveOffset`
* parameter. For example, if you pass in "C4" (note number 60) and the `octaveOffset` value is
* -2, the resulting MIDI note number will be 36.
*
* @param input {string|number} A string or number to extract the MIDI note number from.
* @param octaveOffset {number} An integer to offset the octave by
*
* @returns {number|false} A valid MIDI note number (0-127) or `false` if the input could not
* successfully be parsed to a note number.
*
* @since 3.0.0
* @static
*/
static guessNoteNumber(input, octaveOffset) {
// Validate and, if necessary, assign default
octaveOffset = parseInt(octaveOffset) || 0;
let output = false;
// Check input type
if (Number.isInteger(input) && input >= 0 && input <= 127) { // uint
output = parseInt(input);
} else if (parseInt(input) >= 0 && parseInt(input) <= 127) { // float or uint as string
output = parseInt(input);
} else if (typeof input === "string" || input instanceof String) { // string
try {
output = this.toNoteNumber(input.trim(), octaveOffset);
} catch (e) {
return false;
}
}
return output;
}
/**
* Returns an identifier string representing a note name (with optional accidental) followed by an
* octave number. The octave can be offset by using the `octaveOffset` parameter.
*
* @param {number} number The MIDI note number to convert to a note identifier
* @param {number} octaveOffset An offset to apply to the resulting octave
*
* @returns {string}
*
* @throws RangeError Invalid note number
* @throws RangeError Invalid octaveOffset value
*
* @since 3.0.0
* @static
*/
static toNoteIdentifier(number, octaveOffset) {
number = parseInt(number);
if (isNaN(number) || number < 0 || number > 127) throw new RangeError("Invalid note number");
octaveOffset = octaveOffset == undefined ? 0 : parseInt(octaveOffset);
if (isNaN(octaveOffset)) throw new RangeError("Invalid octaveOffset value");
const notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const octave = Math.floor(number / 12 - 1) + octaveOffset;
return notes[number % 12] + octave.toString();
}
/**
* Converts the `input` parameter to a valid [`Note`]{@link Note} object. The input usually is an
* unsigned integer (0-127) or a note identifier (`"C4"`, `"G#5"`, etc.). If the input is a
* [`Note`]{@link Note} object, it will be returned as is.
*
* If the input is a note number or identifier, it is possible to specify options by providing the
* `options` parameter.
*
* @param [input] {number|string|Note}
*
* @param {object} [options={}]
*
* @param {number} [options.duration=Infinity] The number of milliseconds before the note should
* be explicitly stopped.
*
* @param {number} [options.attack=0.5] The note's attack velocity as a float between 0 and 1. If
* you wish to use an integer between 0 and 127, use the `rawAttack` option instead. If both
* `attack` and `rawAttack` are specified, the latter has precedence.
*
* @param {number} [options.release=0.5] The note's release velocity as a float between 0 and 1. If
* you wish to use an integer between 0 and 127, use the `rawRelease` option instead. If both
* `release` and `rawRelease` are specified, the latter has precedence.
*
* @param {number} [options.rawAttack=64] The note's attack velocity as an integer between 0 and
* 127. If you wish to use a float between 0 and 1, use the `release` option instead. If both
* `attack` and `rawAttack` are specified, the latter has precedence.
*
* @param {number} [options.rawRelease=64] The note's release velocity as an integer between 0 and
* 127. If you wish to use a float between 0 and 1, use the `release` option instead. If both
* `release` and `rawRelease` are specified, the latter has precedence.
*
* @param {number} [options.octaveOffset=0] An integer to offset the octave by. **This is only
* used when the input value is a note identifier.**
*
* @returns {Note}
*
* @throws TypeError The input could not be parsed to a note
*
* @since version 3.0.0
* @static
*/
static buildNote(input, options= {}) {
options.octaveOffset = parseInt(options.octaveOffset) || 0;
// If it's already a Note, we're done
if (input instanceof Note) return input;
let number = this.guessNoteNumber(input, options.octaveOffset);
if (number === false) { // We use a comparison b/c the note can be 0 (which equates to false)
throw new TypeError(`The input could not be parsed as a note (${input})`);
}
// If we got here, we have a proper note number. Before creating the new note, we strip out
// 'octaveOffset' because it has already been factored in when calling guessNoteNumber().
options.octaveOffset = undefined;
return new Note(number, options);
}
/**
* Converts an input value, which can be an unsigned integer (0-127), a note identifier, a
* [`Note`]{@link Note} object or an array of the previous types, to an array of
* [`Note`]{@link Note} objects.
*
* [`Note`]{@link Note} objects are returned as is. For note numbers and identifiers, a
* [`Note`]{@link Note} object is created with the options specified. An error will be thrown when
* encountering invalid input.
*
* Note: if both the `attack` and `rawAttack` options are specified, the later has priority. The
* same goes for `release` and `rawRelease`.
*
* @param [notes] {number|string|Note|number[]|string[]|Note[]}
*
* @param {object} [options={}]
*
* @param {number} [options.duration=Infinity] The number of milliseconds before the note should
* be explicitly stopped.
*
* @param {number} [options.attack=0.5] The note's attack velocity as a float between 0 and 1. If
* you wish to use an integer between 0 and 127, use the `rawAttack` option instead. If both
* `attack` and `rawAttack` are specified, the latter has precedence.
*
* @param {number} [options.release=0.5] The note's release velocity as a float between 0 and 1. If
* you wish to use an integer between 0 and 127, use the `rawRelease` option instead. If both
* `release` and `rawRelease` are specified, the latter has precedence.
*
* @param {number} [options.rawAttack=64] The note's attack velocity as an integer between 0 and
* 127. If you wish to use a float between 0 and 1, use the `release` option instead. If both
* `attack` and `rawAttack` are specified, the latter has precedence.
*
* @param {number} [options.rawRelease=64] The note's release velocity as an integer between 0 and
* 127. If you wish to use a float between 0 and 1, use the `release` option instead. If both
* `release` and `rawRelease` are specified, the latter has precedence.
*
* @param {number} [options.octaveOffset=0] An integer to offset the octave by. **This is only
* used when the input value is a note identifier.**
*
* @returns {Note[]}
*
* @throws TypeError An element could not be parsed as a note.
*
* @since 3.0.0
* @static
*/
static buildNoteArray(notes, options = {}) {
let result = [];
if (!Array.isArray(notes)) notes = [notes];
notes.forEach(note => {
result.push(this.buildNote(note, options));
});
return result;
}
/**
* Returns a number between 0 and 1 representing the ratio of the input value divided by 127 (7
* bit). The returned value is restricted between 0 and 1 even if the input is greater than 127 or
* smaller than 0.
*
* Passing `Infinity` will return `1` and passing `-Infinity` will return `0`. Otherwise, when the
* input value cannot be converted to an integer, the method returns 0.
*
* @param value {number} A positive integer between 0 and 127 (inclusive)
* @returns {number} A number between 0 and 1 (inclusive)
* @static
*/
static from7bitToFloat(value) {
if (value === Infinity) value = 127;
value = parseInt(value) || 0;
return Math.min(Math.max(value / 127, 0), 1);
}
/**
* Returns an integer between 0 and 127 which is the result of multiplying the input value by
* 127. The input value should be a number between 0 and 1 (inclusively). The returned value is
* restricted between 0 and 127 even if the input is greater than 1 or smaller than 0.
*
* Passing `Infinity` will return `127` and passing `-Infinity` will return `0`. Otherwise, when
* the input value cannot be converted to a number, the method returns 0.
*
* @param value {number} A positive float between 0 and 1 (inclusive)
* @returns {number} A number between 0 and 127 (inclusive)
* @static
*/
static fromFloatTo7Bit(value) {
if (value === Infinity) value = 1;
value = parseFloat(value) || 0;
return Math.min(Math.max(Math.round(value * 127), 0), 127);
}
/**
* Combines and converts MSB and LSB values (0-127) to a float between 0 and 1. The returned value
* is within between 0 and 1 even if the result is greater than 1 or smaller than 0.
*
* @param msb {number} The most significant byte as a integer between 0 and 127.
* @param [lsb=0] {number} The least significant byte as a integer between 0 and 127.
* @returns {number} A float between 0 and 1.
*/
static fromMsbLsbToFloat(msb, lsb = 0) {
if (WebMidi.validation) {
msb = Math.min(Math.max(parseInt(msb) || 0, 0), 127);
lsb = Math.min(Math.max(parseInt(lsb) || 0, 0), 127);
}
const value = ((msb << 7) + lsb) / 16383;
return Math.min(Math.max(value, 0), 1);
}
/**
* Extracts 7bit MSB and LSB values from the supplied float.
*
* @param value {number} A float between 0 and 1
* @returns {{lsb: number, msb: number}}
*/
static fromFloatToMsbLsb(value) {
if (WebMidi.validation) {
value = Math.min(Math.max(parseFloat(value) || 0, 0), 1);
}
const multiplied = Math.round(value * 16383);
return {
msb: multiplied >> 7,
lsb: multiplied & 0x7F
};
}
/**
* Returns the supplied MIDI note number offset by the requested octave and semitone values. If
* the calculated value is less than 0, 0 will be returned. If the calculated value is more than
* 127, 127 will be returned. If an invalid offset value is supplied, 0 will be used.
*
* @param number {number} The MIDI note to offset as an integer between 0 and 127.
* @param octaveOffset {number} An integer to offset the note by (in octave)
* @param octaveOffset {number} An integer to offset the note by (in semitones)
* @returns {number} An integer between 0 and 127
*
* @throws {Error} Invalid note number
* @static
*/
static offsetNumber(number, octaveOffset = 0, semitoneOffset = 0) {
if (WebMidi.validation) {
number = parseInt(number);
if (isNaN(number)) throw new Error("Invalid note number");
octaveOffset = parseInt(octaveOffset) || 0;
semitoneOffset = parseInt(semitoneOffset) || 0;
}
return Math.min(Math.max(number + (octaveOffset * 12) + semitoneOffset, 0), 127);
}
/**
* Returns the name of the first property of the supplied object whose value is equal to the one
* supplied. If nothing is found, `undefined` is returned.
*
* @param object {object} The object to look for the property in.
* @param value {*} Any value that can be expected to be found in the object's properties.
* @returns {string|undefined} The name of the matching property or `undefined` if nothing is
* found.
* @static
*/
static getPropertyByValue(object, value) {
return Object.keys(object).find(key => object[key] === value);
}
/**
* Returns the name of a control change message matching the specified number (0-127). Some valid
* control change numbers do not have a specific name or purpose assigned in the MIDI
* [spec](https://midi.org/specifications-old/item/table-3-control-change-messages-data-bytes-2).
* In these cases, the method returns `controllerXXX` (where XXX is the number).
*
* @param {number} number An integer (0-127) representing the control change message
* @returns {string|undefined} The matching control change name or `undefined` if no match was
* found.
*
* @static
*/
static getCcNameByNumber(number) {
if (WebMidi.validation) {
number = parseInt(number);
if (!(number >= 0 && number <= 127)) return undefined;
}
return Enumerations.CONTROL_CHANGE_MESSAGES[number].name;
}
/**
* Returns the number of a control change message matching the specified name.
*
* @param {string} name A string representing the control change message
* @returns {string|undefined} The matching control change number or `undefined` if no match was
* found.
*
* @since 3.1
* @static
*/
static getCcNumberByName(name) {
let message = Enumerations.CONTROL_CHANGE_MESSAGES.find(element => element.name === name);
if (message) {
return message.number;
} else {
// Legacy (remove in v4)
return Enumerations.MIDI_CONTROL_CHANGE_MESSAGES[name];
}
}
/**
* Returns the channel mode name matching the specified number. If no match is found, the function
* returns `false`.
*
* @param {number} number An integer representing the channel mode message (120-127)
* @returns {string|false} The name of the matching channel mode or `false` if no match could be
* found.
*
* @since 2.0.0
*/
static getChannelModeByNumber(number) {
if ( !(number >= 120 && number <= 127) ) return false;
for (let cm in Enumerations.CHANNEL_MODE_MESSAGES) {
if (
Enumerations.CHANNEL_MODE_MESSAGES.hasOwnProperty(cm) &&
number === Enumerations.CHANNEL_MODE_MESSAGES[cm]
) {
return cm;
}
}
return false;
}
/**
* Indicates whether the execution environment is Node.js (`true`) or not (`false`)
* @type {boolean}
*/
static get isNode() {
return typeof process !== "undefined" &&
process.versions != null &&
process.versions.node != null;
}
/**
* Indicates whether the execution environment is a browser (`true`) or not (`false`)
* @type {boolean}
*/
static get isBrowser() {
return typeof window !== "undefined" && typeof window.document !== "undefined";
}
}