@leafo/lml 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/dist/music.js ADDED
@@ -0,0 +1,704 @@
1
+ export const MIDDLE_C_PITCH = 60;
2
+ export const OCTAVE_SIZE = 12;
3
+ export const OFFSETS = {
4
+ 0: "C",
5
+ 2: "D",
6
+ 4: "E",
7
+ 5: "F",
8
+ 7: "G",
9
+ 9: "A",
10
+ 11: "B",
11
+ "C": 0,
12
+ "D": 2,
13
+ "E": 4,
14
+ "F": 5,
15
+ "G": 7,
16
+ "A": 9,
17
+ "B": 11
18
+ };
19
+ export const LETTER_OFFSETS = {
20
+ 0: 0,
21
+ 2: 1,
22
+ 4: 2,
23
+ 5: 3,
24
+ 7: 4,
25
+ 9: 5,
26
+ 11: 6
27
+ };
28
+ export const NOTE_NAME_OFFSETS = {
29
+ "C": 0,
30
+ "D": 1,
31
+ "E": 2,
32
+ "F": 3,
33
+ "G": 4,
34
+ "A": 5,
35
+ "B": 6,
36
+ };
37
+ export function noteName(pitch, sharpen = true) {
38
+ const octave = Math.floor(pitch / OCTAVE_SIZE);
39
+ const offset = pitch - octave * OCTAVE_SIZE;
40
+ let name = OFFSETS[offset];
41
+ if (!name) {
42
+ if (sharpen) {
43
+ name = OFFSETS[offset - 1] + "#";
44
+ }
45
+ else {
46
+ name = OFFSETS[offset + 1] + "b";
47
+ }
48
+ }
49
+ return `${name}${octave}`;
50
+ }
51
+ function parseNoteAccidentals(note) {
52
+ const match = note.match(/^([A-G])(#|b)?/);
53
+ if (!match) {
54
+ throw new Error(`Invalid note format: ${note}`);
55
+ }
56
+ const [, , accidental] = match;
57
+ let n = 0;
58
+ if (accidental == "#") {
59
+ n += 1;
60
+ }
61
+ if (accidental == "b") {
62
+ n -= 1;
63
+ }
64
+ return n;
65
+ }
66
+ // get the octave independent offset in halfsteps (from C), used for comparison
67
+ function parseNoteOffset(note) {
68
+ const match = note.match(/^([A-G])(#|b)?/);
69
+ if (!match) {
70
+ throw new Error(`Invalid note format: ${note}`);
71
+ }
72
+ const [, letter, accidental] = match;
73
+ if (OFFSETS[letter] === undefined) {
74
+ throw new Error(`Invalid note letter: ${letter}`);
75
+ }
76
+ let n = OFFSETS[letter];
77
+ if (accidental == "#") {
78
+ n += 1;
79
+ }
80
+ if (accidental == "b") {
81
+ n -= 1;
82
+ }
83
+ return (n + 12) % 12; // wrap around for Cb and B#
84
+ }
85
+ export function parseNote(note) {
86
+ const parsed = note.match(/^([A-G])(#|b)?(\d+)$/);
87
+ if (!parsed) {
88
+ throw new Error(`parseNote: invalid note format '${note}'`);
89
+ }
90
+ const [, letter, accidental, octave] = parsed;
91
+ if (OFFSETS[letter] === undefined) {
92
+ throw new Error(`Invalid note letter: ${letter}`);
93
+ }
94
+ let n = OFFSETS[letter] + parseInt(octave, 10) * OCTAVE_SIZE;
95
+ if (accidental == "#") {
96
+ n += 1;
97
+ }
98
+ if (accidental == "b") {
99
+ n -= 1;
100
+ }
101
+ return n;
102
+ }
103
+ export function noteStaffOffset(note) {
104
+ const match = note.match(/(\w)[#b]?(\d+)/);
105
+ if (!match) {
106
+ throw new Error("Invalid note");
107
+ }
108
+ const [, name, octave] = match;
109
+ return +octave * 7 + NOTE_NAME_OFFSETS[name];
110
+ }
111
+ // octaveless note comparison
112
+ export function notesSame(a, b) {
113
+ return parseNoteOffset(a) == parseNoteOffset(b);
114
+ }
115
+ export function addInterval(note, halfSteps) {
116
+ return noteName(parseNote(note) + halfSteps);
117
+ }
118
+ // returns 0 if notes are same
119
+ // returns < 0 if a < b
120
+ // returns > 0 if a > b
121
+ export function compareNotes(a, b) {
122
+ return parseNote(a) - parseNote(b);
123
+ }
124
+ export function notesLessThan(a, b) {
125
+ return compareNotes(a, b) < 0;
126
+ }
127
+ export function notesGreaterThan(a, b) {
128
+ return compareNotes(a, b) > 0;
129
+ }
130
+ export class KeySignature {
131
+ // excludes the chromatic option
132
+ static allKeySignatures() {
133
+ return [
134
+ 0, 1, 2, 3, 4, 5, -1, -2, -3, -4, -5, -6
135
+ ].map(key => new KeySignature(key));
136
+ }
137
+ static forCount(count) {
138
+ if (!this.cache) {
139
+ this.cache = this.allKeySignatures();
140
+ }
141
+ for (const key of this.cache) {
142
+ if (key.count == count) {
143
+ return key;
144
+ }
145
+ }
146
+ }
147
+ // count: the number of accidentals in the key
148
+ constructor(count) {
149
+ this.count = count;
150
+ }
151
+ getCount() {
152
+ return this.count;
153
+ }
154
+ isChromatic() {
155
+ return false;
156
+ }
157
+ isSharp() {
158
+ return this.count > 0;
159
+ }
160
+ isFlat() {
161
+ return this.count < 0;
162
+ }
163
+ name() {
164
+ let offset = this.count + 1;
165
+ if (offset < 0) {
166
+ offset += KeySignature.FIFTHS.length;
167
+ }
168
+ return KeySignature.FIFTHS[offset];
169
+ }
170
+ toString() {
171
+ return this.name();
172
+ }
173
+ // the default scale root for building scales from key signature
174
+ scaleRoot() {
175
+ return this.name();
176
+ }
177
+ // the scale used on the random note generator
178
+ defaultScale() {
179
+ return new MajorScale(this);
180
+ }
181
+ // convert note to enharmonic equivalent that fits into this key signature
182
+ enharmonic(note) {
183
+ if (this.isFlat()) {
184
+ if (note.indexOf("#") != -1) {
185
+ return noteName(parseNote(note), false);
186
+ }
187
+ }
188
+ if (this.isSharp()) {
189
+ if (note.indexOf("b") != -1) {
190
+ return noteName(parseNote(note), true);
191
+ }
192
+ }
193
+ return note;
194
+ }
195
+ // which notes have accidentals in this key
196
+ accidentalNotes() {
197
+ const fifths = KeySignature.FIFTHS_TRUNCATED;
198
+ if (this.count > 0) {
199
+ return fifths.slice(0, this.count);
200
+ }
201
+ else {
202
+ return fifths.slice(fifths.length + this.count).reverse();
203
+ }
204
+ }
205
+ // key note -> raw note
206
+ unconvertNote(note) {
207
+ if (this.count == 0) {
208
+ return typeof note === "number" ? noteName(note) : note;
209
+ }
210
+ if (typeof note == "number") {
211
+ note = noteName(note);
212
+ }
213
+ const match = note.match(/^([A-G])(\d+)?/);
214
+ if (!match) {
215
+ throw new Error("can't unconvert note with accidental");
216
+ }
217
+ const [, name, octave] = match;
218
+ for (const modifiedNote of this.accidentalNotes()) {
219
+ if (modifiedNote == name) {
220
+ return `${name}${this.isSharp() ? "#" : "b"}${octave}`;
221
+ }
222
+ }
223
+ return note;
224
+ }
225
+ // how many accidentals should display on note for this key
226
+ // null: nothing
227
+ // 0: a natural
228
+ // 1: a sharp
229
+ // -1: a flat
230
+ // 2: double sharp, etc.
231
+ accidentalsForNote(note) {
232
+ if (typeof note == "number") {
233
+ note = noteName(note);
234
+ }
235
+ const match = note.match(/^([A-G])(#|b)?/);
236
+ if (!match) {
237
+ throw new Error(`Invalid note format: ${note}`);
238
+ }
239
+ const [, name, a] = match;
240
+ if (a == "#") {
241
+ return this.isSharp() && this.accidentalNotes().includes(name) ? null : 1;
242
+ }
243
+ if (a == "b") {
244
+ return this.isFlat() && this.accidentalNotes().includes(name) ? null : -1;
245
+ }
246
+ for (const modifiedNote of this.accidentalNotes()) {
247
+ if (modifiedNote == name) {
248
+ return 0; // natural needed
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+ // the notes to give accidentals to within the range [min, max], the returned
254
+ // notes will not be sharp or flat
255
+ notesInRange(min, max) {
256
+ if (this.count == 0) {
257
+ return [];
258
+ }
259
+ if (typeof max == "string") {
260
+ max = parseNote(max);
261
+ }
262
+ if (typeof min == "string") {
263
+ min = parseNote(min);
264
+ }
265
+ const octave = 5; // TODO: pick something close to min/max
266
+ let notes = null;
267
+ if (this.count > 0) {
268
+ let count = this.count;
269
+ notes = [parseNote(`F${octave}`)];
270
+ while (count > 1) {
271
+ count -= 1;
272
+ notes.push(notes[notes.length - 1] + 7);
273
+ }
274
+ }
275
+ if (this.count < 0) {
276
+ let count = -1 * this.count;
277
+ notes = [parseNote(`B${octave}`)];
278
+ while (count > 1) {
279
+ count -= 1;
280
+ notes.push(notes[notes.length - 1] - 7);
281
+ }
282
+ }
283
+ if (!notes)
284
+ return [];
285
+ return notes.map(function (n) {
286
+ while (n <= min) {
287
+ n += 12;
288
+ }
289
+ while (n > max) {
290
+ n -= 12;
291
+ }
292
+ return noteName(n);
293
+ });
294
+ }
295
+ }
296
+ KeySignature.FIFTHS = [
297
+ "F", "C", "G", "D", "A", "E", "B", "Gb", "Db", "Ab", "Eb", "Bb"
298
+ ];
299
+ KeySignature.FIFTHS_TRUNCATED = [
300
+ "F", "C", "G", "D", "A", "E", "B"
301
+ ];
302
+ KeySignature.cache = null;
303
+ export class ChromaticKeySignature extends KeySignature {
304
+ constructor() {
305
+ super(0); // render as c major
306
+ }
307
+ isChromatic() {
308
+ return true;
309
+ }
310
+ name() {
311
+ return "Chromatic";
312
+ }
313
+ scaleRoot() {
314
+ return "C";
315
+ }
316
+ defaultScale() {
317
+ return new ChromaticScale(this);
318
+ }
319
+ }
320
+ export class Scale {
321
+ constructor(root) {
322
+ this.steps = [];
323
+ this.minor = false;
324
+ this.chromatic = false;
325
+ if (root instanceof KeySignature) {
326
+ root = root.scaleRoot();
327
+ }
328
+ if (!root.match(/^[A-G][b#]?$/)) {
329
+ throw new Error("scale root not properly formed: " + root);
330
+ }
331
+ this.root = root;
332
+ }
333
+ getFullRange() {
334
+ return this.getRange(0, (this.steps.length + 1) * 8);
335
+ }
336
+ getLooseRange(min, max) {
337
+ const fullRange = this.getFullRange();
338
+ const minPitch = parseNote(min);
339
+ const maxPitch = parseNote(max);
340
+ return fullRange.filter(note => {
341
+ const pitch = parseNote(note);
342
+ return pitch >= minPitch && pitch <= maxPitch;
343
+ });
344
+ }
345
+ getRange(octave, count = this.steps.length + 1, offset = 0) {
346
+ let current = parseNote(`${this.root}${octave}`);
347
+ const isFlat = this.isFlat();
348
+ const range = [];
349
+ let k = 0;
350
+ while (offset < 0) {
351
+ k--;
352
+ if (k < 0) {
353
+ k += this.steps.length;
354
+ }
355
+ current -= this.steps[k % this.steps.length];
356
+ offset++;
357
+ }
358
+ for (let i = 0; i < count + offset; i++) {
359
+ if (i >= offset) {
360
+ range.push(noteName(current, this.chromatic || !isFlat));
361
+ }
362
+ current += this.steps[k++ % this.steps.length];
363
+ }
364
+ return range;
365
+ }
366
+ isFlat() {
367
+ let idx = KeySignature.FIFTHS.indexOf(this.root);
368
+ if (idx == -1) {
369
+ // the root is sharp
370
+ let letter = this.root.charCodeAt(0) + 1;
371
+ if (letter > 71) {
372
+ letter -= 5;
373
+ }
374
+ const realRoot = String.fromCharCode(letter) + "#";
375
+ idx = KeySignature.FIFTHS.indexOf(realRoot);
376
+ }
377
+ if (this.minor) {
378
+ idx -= 3;
379
+ if (idx < 0) {
380
+ idx += KeySignature.FIFTHS.length;
381
+ }
382
+ }
383
+ return idx < 1 || idx > 6;
384
+ }
385
+ containsNote(note) {
386
+ let pitch = parseNoteOffset(note);
387
+ const rootPitch = parseNoteOffset(this.root);
388
+ // move note within an octave of root
389
+ while (pitch < rootPitch) {
390
+ pitch += OCTAVE_SIZE;
391
+ }
392
+ while (pitch >= rootPitch + OCTAVE_SIZE) {
393
+ pitch -= OCTAVE_SIZE;
394
+ }
395
+ let currentPitch = rootPitch;
396
+ let i = 0;
397
+ // keep incrementing until we hit it, or pass it
398
+ while (currentPitch <= pitch) {
399
+ if (currentPitch == pitch) {
400
+ return true;
401
+ }
402
+ currentPitch += this.steps[i % this.steps.length];
403
+ i++;
404
+ }
405
+ return false;
406
+ }
407
+ // degrees are 1 indexed
408
+ degreeToName(degree) {
409
+ // truncate to reasonable range
410
+ degree = (degree - 1) % this.steps.length + 1;
411
+ const range = this.getRange(0, degree);
412
+ const note = range[range.length - 1];
413
+ const m = note.match(/^[^\d]+/);
414
+ return m ? m[0] : note;
415
+ }
416
+ // degrees are 1 indexed
417
+ getDegree(note) {
418
+ let pitch = parseNoteOffset(note);
419
+ const rootPitch = parseNoteOffset(this.root);
420
+ // move note within an octave of root
421
+ while (pitch < rootPitch) {
422
+ pitch += OCTAVE_SIZE;
423
+ }
424
+ while (pitch >= rootPitch + OCTAVE_SIZE) {
425
+ pitch -= OCTAVE_SIZE;
426
+ }
427
+ let degree = 1;
428
+ let currentPitch = rootPitch;
429
+ if (currentPitch == pitch) {
430
+ return degree;
431
+ }
432
+ for (const offset of this.steps) {
433
+ currentPitch += offset;
434
+ degree += 1;
435
+ if (currentPitch == pitch) {
436
+ return degree;
437
+ }
438
+ if (currentPitch > pitch) {
439
+ break;
440
+ }
441
+ }
442
+ throw new Error(`${note} is not in scale ${this.root}`);
443
+ }
444
+ // degree is one indexed
445
+ // new MajorScale().buildChordSteps(1, 2) -> major chord
446
+ buildChordSteps(degree, count) {
447
+ let idx = degree - 1;
448
+ const out = [];
449
+ while (count > 0) {
450
+ let stride = 2;
451
+ let step = 0;
452
+ while (stride > 0) {
453
+ step += this.steps[idx % this.steps.length];
454
+ idx++;
455
+ stride--;
456
+ }
457
+ out.push(step);
458
+ count--;
459
+ }
460
+ return out;
461
+ }
462
+ // all chords with count notes
463
+ allChords(noteCount = 3) {
464
+ const out = [];
465
+ for (let i = 0; i < this.steps.length; i++) {
466
+ const degree = i + 1;
467
+ const root = this.degreeToName(degree);
468
+ const steps = this.buildChordSteps(degree, noteCount - 1);
469
+ out.push(new Chord(root, steps));
470
+ }
471
+ return out;
472
+ }
473
+ }
474
+ export class MajorScale extends Scale {
475
+ constructor(root) {
476
+ super(root);
477
+ this.steps = [2, 2, 1, 2, 2, 2, 1];
478
+ }
479
+ }
480
+ // natural minor
481
+ export class MinorScale extends Scale {
482
+ constructor(root) {
483
+ super(root);
484
+ this.minor = true;
485
+ this.steps = [2, 1, 2, 2, 1, 2, 2];
486
+ }
487
+ }
488
+ export class HarmonicMinorScale extends Scale {
489
+ constructor(root) {
490
+ super(root);
491
+ this.minor = true;
492
+ this.steps = [2, 1, 2, 2, 1, 3, 1];
493
+ }
494
+ }
495
+ export class AscendingMelodicMinorScale extends Scale {
496
+ constructor(root) {
497
+ super(root);
498
+ this.minor = true;
499
+ this.steps = [2, 1, 2, 2, 2, 2, 1];
500
+ }
501
+ }
502
+ export class MajorBluesScale extends Scale {
503
+ constructor(root) {
504
+ super(root);
505
+ // C, D, D#/Eb, E, G, A
506
+ this.steps = [2, 1, 1, 3, 2, 3];
507
+ }
508
+ }
509
+ export class MinorBluesScale extends Scale {
510
+ constructor(root) {
511
+ super(root);
512
+ this.minor = true;
513
+ // C, D#/Eb, F, F#/Gb, G, Bb
514
+ this.steps = [3, 2, 1, 1, 3, 2];
515
+ }
516
+ }
517
+ export class ChromaticScale extends Scale {
518
+ constructor(root) {
519
+ super(root);
520
+ this.chromatic = true;
521
+ this.steps = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
522
+ }
523
+ }
524
+ export class Chord extends Scale {
525
+ // Chord.notes("C5", "M", 1) -> first inversion C major chord
526
+ static notes(note, chordName, inversion = 0, notesCount = 0) {
527
+ const match = note.match(/^([^\d]+)(\d+)$/);
528
+ if (!match) {
529
+ throw new Error(`Invalid note format: ${note}`);
530
+ }
531
+ const [, root, octaveStr] = match;
532
+ const octave = +octaveStr;
533
+ const intervals = Chord.SHAPES[chordName];
534
+ if (notesCount == 0) {
535
+ notesCount = intervals.length + 1;
536
+ }
537
+ return new Chord(root, intervals).getRange(octave, notesCount, inversion);
538
+ }
539
+ constructor(root, intervals) {
540
+ super(root);
541
+ if (typeof intervals === "string") {
542
+ const shape = Chord.SHAPES[intervals];
543
+ if (!shape) {
544
+ throw new Error(`Unknown chord shape: ${intervals}`);
545
+ }
546
+ intervals = shape;
547
+ }
548
+ if (!intervals) {
549
+ throw new Error("Missing intervals for chord");
550
+ }
551
+ this.steps = [...intervals];
552
+ // add wrapping interval to get back to octave
553
+ let sum = 0;
554
+ for (const i of this.steps) {
555
+ sum += i;
556
+ }
557
+ let rest = -sum;
558
+ while (rest < 0) {
559
+ rest += OCTAVE_SIZE;
560
+ }
561
+ this.steps.push(rest);
562
+ }
563
+ // is major or dom7 chord
564
+ isDominant() {
565
+ const shapeName = this.chordShapeName();
566
+ if (shapeName == "M" || shapeName == "7") {
567
+ return true;
568
+ }
569
+ return false;
570
+ }
571
+ // can point to a chord that's a 4th below (third above)
572
+ // the target chord can either be major or minor (2,3,5,6) in new key
573
+ getSecondaryDominantTargets(noteCount = 3) {
574
+ if (!this.isDominant()) {
575
+ throw new Error(`chord is not dominant to begin with: ${this.chordShapeName()}`);
576
+ }
577
+ // new root is 5 halfsteps above the current (or 7 below)
578
+ const match = noteName(parseNote(`${this.root}5`) + 5).match(/^([^\d]+)(\d+)$/);
579
+ if (!match) {
580
+ throw new Error("Failed to compute secondary dominant target");
581
+ }
582
+ const [, newRoot] = match;
583
+ // triads
584
+ if (noteCount == 3) {
585
+ return ["M", "m"].map(quality => new Chord(newRoot, quality));
586
+ }
587
+ // sevenths
588
+ if (noteCount == 4) {
589
+ return ["M7", "m7"].map(quality => new Chord(newRoot, quality));
590
+ }
591
+ throw new Error(`don't know how to get secondary dominant for note count: ${noteCount}`);
592
+ }
593
+ chordShapeName() {
594
+ for (const shape in Chord.SHAPES) {
595
+ const intervals = Chord.SHAPES[shape];
596
+ if (this.steps.length - 1 != intervals.length) {
597
+ continue;
598
+ }
599
+ let match = true;
600
+ for (let k = 0; k < intervals.length; k++) {
601
+ if (intervals[k] != this.steps[k]) {
602
+ match = false;
603
+ break;
604
+ }
605
+ }
606
+ if (match) {
607
+ return shape;
608
+ }
609
+ }
610
+ }
611
+ // do all the notes fit this chord
612
+ containsNotes(notes) {
613
+ if (!notes.length) {
614
+ return false;
615
+ }
616
+ for (const note of notes) {
617
+ if (!this.containsNote(note)) {
618
+ return false;
619
+ }
620
+ }
621
+ return true;
622
+ }
623
+ // how many notes do the two chords share
624
+ countSharedNotes(otherChord) {
625
+ const myNotes = this.getRange(5, this.steps.length);
626
+ const theirNotes = otherChord.getRange(5, this.steps.length);
627
+ let count = 0;
628
+ const noteNames = {};
629
+ const normalizeNote = (note) => note.replace(/\d+$/, "");
630
+ for (const note of myNotes) {
631
+ noteNames[normalizeNote(note)] = true;
632
+ }
633
+ for (const note of theirNotes) {
634
+ const normalized = normalizeNote(note);
635
+ if (noteNames[normalized]) {
636
+ count += 1;
637
+ }
638
+ delete noteNames[normalized];
639
+ }
640
+ return count;
641
+ }
642
+ toString() {
643
+ let name = this.chordShapeName();
644
+ if (!name) {
645
+ console.warn("don't know name of chord", this.root, this.steps, this.getRange(5, 3));
646
+ name = "";
647
+ }
648
+ if (name == "M") {
649
+ name = "";
650
+ }
651
+ return `${this.root}${name}`;
652
+ }
653
+ }
654
+ Chord.SHAPES = {
655
+ "M": [4, 3],
656
+ "m": [3, 4],
657
+ "dim": [3, 3], // diminished
658
+ "dimM7": [3, 3, 5],
659
+ "dim7": [3, 3, 3],
660
+ "aug": [4, 4],
661
+ "augM7": [4, 4, 3],
662
+ "M6": [4, 3, 2],
663
+ "m6": [3, 4, 2],
664
+ "M7": [4, 3, 4],
665
+ "7": [4, 3, 3],
666
+ "m7": [3, 4, 3],
667
+ "m7b5": [3, 3, 4],
668
+ "mM7": [3, 4, 4],
669
+ // exotic
670
+ "Q": [5, 5], // quartal
671
+ "Qb4": [4, 5],
672
+ };
673
+ export class Staff {
674
+ static forName(name) {
675
+ if (!this.cache) {
676
+ this.cache = Object.fromEntries(this.allStaves().map(s => [s.name, s]));
677
+ }
678
+ return this.cache[name];
679
+ }
680
+ static allStaves() {
681
+ return [
682
+ new Staff("treble", "E5", "F6", "G5"),
683
+ new Staff("bass", "G3", "A4", "F4")
684
+ // TODO: alto, middle C center
685
+ ];
686
+ }
687
+ // upper and lower note are the notes for the lines on the top and bottom
688
+ constructor(name, lowerNote, upperNote, clefNote) {
689
+ this.name = name;
690
+ this.lowerNote = lowerNote;
691
+ this.upperNote = upperNote;
692
+ this.clefNote = clefNote;
693
+ }
694
+ // F, G, etc
695
+ clefName() {
696
+ const match = this.clefNote.match(/^([A-G])/);
697
+ if (!match) {
698
+ throw new Error(`Invalid clef note: ${this.clefNote}`);
699
+ }
700
+ return match[1];
701
+ }
702
+ }
703
+ Staff.cache = null;
704
+ //# sourceMappingURL=music.js.map