@libraz/libcantus 0.9.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 +201 -0
- package/README.md +291 -0
- package/dist/index.cjs +6465 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2621 -0
- package/dist/index.d.ts +2621 -0
- package/dist/index.js +6297 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
- package/src/analysis/index.ts +261 -0
- package/src/arrange/index.ts +409 -0
- package/src/bass/index.ts +301 -0
- package/src/chord/index.ts +292 -0
- package/src/chordscale/index.ts +316 -0
- package/src/countermelody/index.ts +349 -0
- package/src/counterpoint/index.ts +209 -0
- package/src/detect/index.ts +176 -0
- package/src/drums/beat.ts +266 -0
- package/src/drums/euclid.ts +58 -0
- package/src/drums/fills.ts +345 -0
- package/src/drums/ghost.ts +119 -0
- package/src/drums/hihat.ts +285 -0
- package/src/drums/hit.ts +31 -0
- package/src/drums/index.ts +211 -0
- package/src/drums/internal.ts +251 -0
- package/src/drums/kick.ts +160 -0
- package/src/drums/percussion.ts +121 -0
- package/src/drums/rng.ts +7 -0
- package/src/drums/swing.ts +66 -0
- package/src/functional/index.ts +602 -0
- package/src/groove/index.ts +230 -0
- package/src/harmonize/index.ts +367 -0
- package/src/harmony/index.ts +75 -0
- package/src/index.ts +233 -0
- package/src/interval/index.ts +67 -0
- package/src/meter/index.ts +179 -0
- package/src/model/index.ts +957 -0
- package/src/motif/index.ts +321 -0
- package/src/pitch/index.ts +256 -0
- package/src/progression/index.ts +336 -0
- package/src/random/index.ts +41 -0
- package/src/reharmony/index.ts +299 -0
- package/src/rhythm/index.ts +131 -0
- package/src/safety/index.ts +372 -0
- package/src/scale/index.ts +215 -0
- package/src/spelling/index.ts +164 -0
- package/src/symbol/index.ts +213 -0
- package/src/timeline/index.ts +352 -0
- package/src/tuning/index.ts +129 -0
- package/src/types.ts +29 -0
- package/src/voicing/index.ts +523 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { KeyScale } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a 12-bit mode mask from a list of semitone offsets above the root.
|
|
5
|
+
*
|
|
6
|
+
* Bit 0 (the root) is always set, enforcing the `KeyScale` invariant that the
|
|
7
|
+
* root is a scale tone even when the offset list omits 0.
|
|
8
|
+
*/
|
|
9
|
+
export function maskFromOffsets(offsets: readonly number[]): number {
|
|
10
|
+
let mask = 1;
|
|
11
|
+
for (const offset of offsets) {
|
|
12
|
+
mask |= 1 << (((offset % 12) + 12) % 12);
|
|
13
|
+
}
|
|
14
|
+
return mask;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mode mask for the major (Ionian) scale: offsets {0, 2, 4, 5, 7, 9, 11}. */
|
|
18
|
+
export const MAJOR_MASK = 0b101010110101;
|
|
19
|
+
|
|
20
|
+
/** Mode mask for the natural minor (Aeolian) scale: offsets {0, 2, 3, 5, 7, 8, 10}. */
|
|
21
|
+
export const NATURAL_MINOR_MASK = 0b010110101101;
|
|
22
|
+
|
|
23
|
+
/** Harmonic minor: natural minor with a raised seventh — offsets {0,2,3,5,7,8,11}. */
|
|
24
|
+
export const HARMONIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 8, 11]);
|
|
25
|
+
|
|
26
|
+
/** Ascending melodic minor: offsets {0,2,3,5,7,9,11}. */
|
|
27
|
+
export const MELODIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 11]);
|
|
28
|
+
|
|
29
|
+
/** Dorian mode: offsets {0,2,3,5,7,9,10}. */
|
|
30
|
+
export const DORIAN_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 10]);
|
|
31
|
+
|
|
32
|
+
/** Phrygian mode: offsets {0,1,3,5,7,8,10}. */
|
|
33
|
+
export const PHRYGIAN_MASK = maskFromOffsets([0, 1, 3, 5, 7, 8, 10]);
|
|
34
|
+
|
|
35
|
+
/** Lydian mode: offsets {0,2,4,6,7,9,11}. */
|
|
36
|
+
export const LYDIAN_MASK = maskFromOffsets([0, 2, 4, 6, 7, 9, 11]);
|
|
37
|
+
|
|
38
|
+
/** Mixolydian mode: offsets {0,2,4,5,7,9,10}. */
|
|
39
|
+
export const MIXOLYDIAN_MASK = maskFromOffsets([0, 2, 4, 5, 7, 9, 10]);
|
|
40
|
+
|
|
41
|
+
/** Locrian mode: offsets {0,1,3,5,6,8,10}. */
|
|
42
|
+
export const LOCRIAN_MASK = maskFromOffsets([0, 1, 3, 5, 6, 8, 10]);
|
|
43
|
+
|
|
44
|
+
/** Major pentatonic: offsets {0,2,4,7,9}. */
|
|
45
|
+
export const MAJOR_PENTATONIC_MASK = maskFromOffsets([0, 2, 4, 7, 9]);
|
|
46
|
+
|
|
47
|
+
/** Minor pentatonic: offsets {0,3,5,7,10}. */
|
|
48
|
+
export const MINOR_PENTATONIC_MASK = maskFromOffsets([0, 3, 5, 7, 10]);
|
|
49
|
+
|
|
50
|
+
/** Hexatonic blues scale: minor pentatonic plus the flat-fifth — {0,3,5,6,7,10}. */
|
|
51
|
+
export const BLUES_MASK = maskFromOffsets([0, 3, 5, 6, 7, 10]);
|
|
52
|
+
|
|
53
|
+
/** Whole-tone scale: offsets {0,2,4,6,8,10}. */
|
|
54
|
+
export const WHOLE_TONE_MASK = maskFromOffsets([0, 2, 4, 6, 8, 10]);
|
|
55
|
+
|
|
56
|
+
/** Octatonic (half-step first): offsets {0,1,3,4,6,7,9,10}. */
|
|
57
|
+
export const OCTATONIC_HALF_WHOLE_MASK = maskFromOffsets([0, 1, 3, 4, 6, 7, 9, 10]);
|
|
58
|
+
|
|
59
|
+
/** Octatonic (whole-step first): offsets {0,2,3,5,6,8,9,11}. */
|
|
60
|
+
export const OCTATONIC_WHOLE_HALF_MASK = maskFromOffsets([0, 2, 3, 5, 6, 8, 9, 11]);
|
|
61
|
+
|
|
62
|
+
/** Chromatic scale: all twelve pitch classes. */
|
|
63
|
+
export const CHROMATIC_MASK = 0b111111111111;
|
|
64
|
+
|
|
65
|
+
/** Named scale masks addressable by {@link scaleByName}. */
|
|
66
|
+
export const NAMED_SCALES: Record<string, number> = {
|
|
67
|
+
major: MAJOR_MASK,
|
|
68
|
+
ionian: MAJOR_MASK,
|
|
69
|
+
naturalMinor: NATURAL_MINOR_MASK,
|
|
70
|
+
aeolian: NATURAL_MINOR_MASK,
|
|
71
|
+
harmonicMinor: HARMONIC_MINOR_MASK,
|
|
72
|
+
melodicMinor: MELODIC_MINOR_MASK,
|
|
73
|
+
dorian: DORIAN_MASK,
|
|
74
|
+
phrygian: PHRYGIAN_MASK,
|
|
75
|
+
lydian: LYDIAN_MASK,
|
|
76
|
+
mixolydian: MIXOLYDIAN_MASK,
|
|
77
|
+
locrian: LOCRIAN_MASK,
|
|
78
|
+
majorPentatonic: MAJOR_PENTATONIC_MASK,
|
|
79
|
+
minorPentatonic: MINOR_PENTATONIC_MASK,
|
|
80
|
+
blues: BLUES_MASK,
|
|
81
|
+
wholeTone: WHOLE_TONE_MASK,
|
|
82
|
+
octatonicHalfWhole: OCTATONIC_HALF_WHOLE_MASK,
|
|
83
|
+
octatonicWholeHalf: OCTATONIC_WHOLE_HALF_MASK,
|
|
84
|
+
chromatic: CHROMATIC_MASK,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Build a `KeyScale` for a major key on the given root pitch class. */
|
|
88
|
+
export function majorKey(rootPc: number): KeyScale {
|
|
89
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build a `KeyScale` for a natural-minor key on the given root pitch class. */
|
|
93
|
+
export function minorKey(rootPc: number): KeyScale {
|
|
94
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a `KeyScale` from a named scale (see {@link NAMED_SCALES}).
|
|
99
|
+
*
|
|
100
|
+
* @param name The scale name, e.g. `'dorian'` or `'harmonicMinor'`.
|
|
101
|
+
* @param rootPc The root pitch class.
|
|
102
|
+
* @returns The key/scale.
|
|
103
|
+
* @throws If the name is not a known scale.
|
|
104
|
+
*/
|
|
105
|
+
export function scaleByName(name: string, rootPc: number): KeyScale {
|
|
106
|
+
const mask = NAMED_SCALES[name];
|
|
107
|
+
if (mask === undefined) {
|
|
108
|
+
throw new Error(`Unknown scale: ${name}`);
|
|
109
|
+
}
|
|
110
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: mask };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reduce a pitch (or pitch class) to a pitch class in [0, 11]. */
|
|
114
|
+
function pitchClass(pitch: number): number {
|
|
115
|
+
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Scale offset of a pitch relative to the key root, in [0, 11]. */
|
|
119
|
+
function scaleOffset(pitch: number, key: KeyScale): number {
|
|
120
|
+
return (pitchClass(pitch) - pitchClass(key.rootPc) + 12) % 12;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Test whether a pitch belongs to the scale.
|
|
125
|
+
*
|
|
126
|
+
* @param pitch MIDI pitch or bare pitch class.
|
|
127
|
+
* @param key The key/scale to test against.
|
|
128
|
+
* @returns True if the pitch class is a member of the scale.
|
|
129
|
+
*/
|
|
130
|
+
export function isScaleTone(pitch: number, key: KeyScale): boolean {
|
|
131
|
+
return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Find the nearest MIDI pitch whose pitch class is in the scale.
|
|
136
|
+
*
|
|
137
|
+
* The search expands symmetrically outward from `pitch`. On a tie (equal
|
|
138
|
+
* distance above and below) the lower pitch is returned.
|
|
139
|
+
*
|
|
140
|
+
* @param pitch MIDI pitch to snap.
|
|
141
|
+
* @param key The key/scale to snap to.
|
|
142
|
+
* @returns The nearest in-scale MIDI pitch.
|
|
143
|
+
*/
|
|
144
|
+
export function nearestScaleTone(pitch: number, key: KeyScale): number {
|
|
145
|
+
const base = Math.round(pitch);
|
|
146
|
+
for (let distance = 0; distance < 12; distance += 1) {
|
|
147
|
+
const lower = base - distance;
|
|
148
|
+
if (isScaleTone(lower, key)) {
|
|
149
|
+
return lower;
|
|
150
|
+
}
|
|
151
|
+
const higher = base + distance;
|
|
152
|
+
if (isScaleTone(higher, key)) {
|
|
153
|
+
return higher;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return base;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the 0-based scale degree of a pitch.
|
|
161
|
+
*
|
|
162
|
+
* @param pitch MIDI pitch or bare pitch class.
|
|
163
|
+
* @param key The key/scale to measure against.
|
|
164
|
+
* @returns The 0-based degree, or -1 if the pitch is not in the scale.
|
|
165
|
+
*/
|
|
166
|
+
export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
|
|
167
|
+
const offset = scaleOffset(pitch, key);
|
|
168
|
+
if (((key.modeMask12 >> offset) & 1) === 0) {
|
|
169
|
+
return -1;
|
|
170
|
+
}
|
|
171
|
+
let degree = 0;
|
|
172
|
+
for (let n = 0; n < offset; n += 1) {
|
|
173
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
174
|
+
degree += 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return degree;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* List the pitch classes of a key's scale.
|
|
182
|
+
*
|
|
183
|
+
* @param key The key/scale to enumerate.
|
|
184
|
+
* @returns The member pitch classes, sorted ascending in [0, 11].
|
|
185
|
+
*/
|
|
186
|
+
export function diatonicPitchClasses(key: KeyScale): number[] {
|
|
187
|
+
const root = pitchClass(key.rootPc);
|
|
188
|
+
const pcs: number[] = [];
|
|
189
|
+
for (let n = 0; n < 12; n += 1) {
|
|
190
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
191
|
+
pcs.push((root + n) % 12);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return pcs.sort((a, b) => a - b);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* List the pitch classes of a key's scale in ascending scale-degree order.
|
|
199
|
+
*
|
|
200
|
+
* Degree 0 is the root; degrees follow the mask bits in offset order rather
|
|
201
|
+
* than sorted pitch-class order.
|
|
202
|
+
*
|
|
203
|
+
* @param key The key/scale to enumerate.
|
|
204
|
+
* @returns The member pitch classes ordered by scale degree.
|
|
205
|
+
*/
|
|
206
|
+
export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
|
|
207
|
+
const root = pitchClass(key.rootPc);
|
|
208
|
+
const pcs: number[] = [];
|
|
209
|
+
for (let n = 0; n < 12; n += 1) {
|
|
210
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
211
|
+
pcs.push((root + n) % 12);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return pcs;
|
|
215
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spelling: derive letter-name notes for scales and chords from a spelled tonic.
|
|
3
|
+
*
|
|
4
|
+
* The theory core works in pitch classes, which cannot choose between (say) a
|
|
5
|
+
* G# and an Ab. Given a spelled tonic, this module assigns diatonic letters to
|
|
6
|
+
* scale degrees and chord tones so a C major scale spells as C D E F G A B and
|
|
7
|
+
* A harmonic minor spells its seventh as G#.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Chord } from '../chord/index.js';
|
|
11
|
+
import type { Note } from '../pitch/index.js';
|
|
12
|
+
import { formatNote } from '../pitch/index.js';
|
|
13
|
+
import { scaleTonesInDegreeOrder } from '../scale/index.js';
|
|
14
|
+
import type { KeyScale } from '../types.js';
|
|
15
|
+
|
|
16
|
+
/** Semitone offset of each natural letter above C: C D E F G A B. */
|
|
17
|
+
const LETTER_SEMITONES = [0, 2, 4, 5, 7, 9, 11] as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Conventional degree assignment for each chromatic offset above the tonic.
|
|
21
|
+
*
|
|
22
|
+
* The letter is derived from the tonic's letter plus `degreeOffset` (so the
|
|
23
|
+
* spelling follows the key's diatonic letters), and the accidental is recomputed
|
|
24
|
+
* for that letter. This covers the flat-side alterations (b2, b3, b6, b7), the
|
|
25
|
+
* raised fourth (#4), and the raised degrees of a minor key: the raised sixth
|
|
26
|
+
* (#6) and the raised leading tone (#7). The latter two keep sharp-side minor
|
|
27
|
+
* keys spelling their raised degrees with the key-correct letter (e.g. F# minor
|
|
28
|
+
* spells its leading tone E#, not F).
|
|
29
|
+
*
|
|
30
|
+
* The `alter` field is illustrative only; `spellPitchClass` recomputes the
|
|
31
|
+
* accidental from the chosen letter.
|
|
32
|
+
*/
|
|
33
|
+
const CHROMATIC_SPELLING: Record<number, { degreeOffset: number; alter: number }> = {
|
|
34
|
+
1: { degreeOffset: 1, alter: -1 }, // b2
|
|
35
|
+
3: { degreeOffset: 2, alter: -1 }, // b3
|
|
36
|
+
6: { degreeOffset: 3, alter: 1 }, // #4
|
|
37
|
+
8: { degreeOffset: 5, alter: -1 }, // b6
|
|
38
|
+
9: { degreeOffset: 5, alter: 1 }, // #6 (raised sixth of a minor key)
|
|
39
|
+
10: { degreeOffset: 6, alter: -1 }, // b7
|
|
40
|
+
11: { degreeOffset: 6, alter: 1 }, // #7 (raised leading tone of a minor key)
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function mod12(n: number): number {
|
|
44
|
+
return ((n % 12) + 12) % 12;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mod7(n: number): number {
|
|
48
|
+
return ((n % 7) + 7) % 7;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Natural pitch class of a letter (0 = C .. 6 = B). */
|
|
52
|
+
function naturalPc(letter: number): number {
|
|
53
|
+
return LETTER_SEMITONES[mod7(letter)] ?? 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shortest signed alteration (in [-6, 6]) taking a letter's natural pc to `pc`. */
|
|
57
|
+
function alterFor(letter: number, pc: number): number {
|
|
58
|
+
let d = mod12(pc - naturalPc(letter));
|
|
59
|
+
if (d > 6) {
|
|
60
|
+
d -= 12;
|
|
61
|
+
}
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the key's scale is a seven-note (heptatonic) scale. */
|
|
66
|
+
function isHeptatonic(key: KeyScale): boolean {
|
|
67
|
+
return scaleTonesInDegreeOrder(key).length === 7;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Spell a single pitch class relative to a spelled tonic and key.
|
|
72
|
+
*
|
|
73
|
+
* Diatonic pitch classes take the scale's letter for their degree; the common
|
|
74
|
+
* chromatic tones (b2, b3, #4, b6, b7) and a minor key's raised degrees (#6, #7)
|
|
75
|
+
* take their conventional, key-correct accidental spelling; anything else falls
|
|
76
|
+
* back to a sharp spelling of the nearest natural.
|
|
77
|
+
*
|
|
78
|
+
* @param pc The pitch class to spell.
|
|
79
|
+
* @param tonic The spelled tonic (its letter anchors the spelling).
|
|
80
|
+
* @param key The key/scale.
|
|
81
|
+
* @returns The spelled note (without octave).
|
|
82
|
+
*/
|
|
83
|
+
export function spellPitchClass(pc: number, tonic: Note, key: KeyScale): Note {
|
|
84
|
+
const tonicPc = mod12(naturalPc(tonic.letter) + tonic.alter);
|
|
85
|
+
const offset = mod12(pc - tonicPc);
|
|
86
|
+
const tones = scaleTonesInDegreeOrder(key);
|
|
87
|
+
|
|
88
|
+
if (isHeptatonic(key)) {
|
|
89
|
+
const degree = tones.indexOf(mod12(pc));
|
|
90
|
+
if (degree >= 0) {
|
|
91
|
+
const letter = mod7(tonic.letter + degree);
|
|
92
|
+
return { letter, alter: alterFor(letter, pc) };
|
|
93
|
+
}
|
|
94
|
+
const chromatic = CHROMATIC_SPELLING[offset];
|
|
95
|
+
if (chromatic) {
|
|
96
|
+
const letter = mod7(tonic.letter + chromatic.degreeOffset);
|
|
97
|
+
return { letter, alter: alterFor(letter, pc) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fallback: name the pitch class from the nearest natural, preferring a sharp.
|
|
102
|
+
for (let letter = 0; letter < 7; letter += 1) {
|
|
103
|
+
if (naturalPc(letter) === mod12(pc)) {
|
|
104
|
+
return { letter, alter: 0 };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const belowLetter = mod7([0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6][mod12(pc)] ?? 0);
|
|
108
|
+
return { letter: belowLetter, alter: alterFor(belowLetter, pc) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Spell every pitch class of a scale, in ascending scale-degree order.
|
|
113
|
+
*
|
|
114
|
+
* Correct for heptatonic scales (each degree gets the next letter). Non-standard
|
|
115
|
+
* scales are spelled tone-by-tone with a sharp preference.
|
|
116
|
+
*
|
|
117
|
+
* @param tonic The spelled tonic.
|
|
118
|
+
* @param key The key/scale.
|
|
119
|
+
* @returns Spelled notes, one per scale degree.
|
|
120
|
+
*/
|
|
121
|
+
export function spellScale(tonic: Note, key: KeyScale): Note[] {
|
|
122
|
+
return scaleTonesInDegreeOrder(key).map((pc) => spellPitchClass(pc, tonic, key));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Spell an arbitrary list of pitch classes relative to a key.
|
|
127
|
+
*
|
|
128
|
+
* @param pcs The pitch classes.
|
|
129
|
+
* @param tonic The spelled tonic.
|
|
130
|
+
* @param key The key/scale.
|
|
131
|
+
* @returns Spelled notes, in input order.
|
|
132
|
+
*/
|
|
133
|
+
export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): Note[] {
|
|
134
|
+
return pcs.map((pc) => spellPitchClass(pc, tonic, key));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Spell a chord's tones, in the chord's own (tertian) order, relative to a key.
|
|
139
|
+
*
|
|
140
|
+
* Diatonic chords spell exactly (e.g. G7 in C major -> G B D F). Chromatic chord
|
|
141
|
+
* tones take their conventional spelling; enharmonically ambiguous altered
|
|
142
|
+
* tensions may be spelled by the general convention rather than by chord
|
|
143
|
+
* function.
|
|
144
|
+
*
|
|
145
|
+
* @param chord The chord.
|
|
146
|
+
* @param tonic The spelled tonic of the key.
|
|
147
|
+
* @param key The key/scale.
|
|
148
|
+
* @returns Spelled chord tones, root first.
|
|
149
|
+
*/
|
|
150
|
+
export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
|
|
151
|
+
return chord.intervals.map((interval) =>
|
|
152
|
+
spellPitchClass(mod12(chord.rootPc + interval), tonic, key),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convenience: render spelled notes as letter-name strings.
|
|
158
|
+
*
|
|
159
|
+
* @param notes The notes.
|
|
160
|
+
* @returns Their formatted names.
|
|
161
|
+
*/
|
|
162
|
+
export function noteNames(notes: Note[]): string[] {
|
|
163
|
+
return notes.map(formatNote);
|
|
164
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lead-sheet chord symbol parsing and formatting.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the informal `<root><quality>[/<bass>]` notation musicians write
|
|
5
|
+
* (`Cmaj7`, `F#m7b5`, `Bb7`, `C/G`) and the library's structured {@link Chord}
|
|
6
|
+
* type, so callers can accept/emit plain text at the edges of the API.
|
|
7
|
+
*/
|
|
8
|
+
import { type Chord, type ChordQuality, makeChord } from '../chord/index.js';
|
|
9
|
+
import { formatNote, midiToNote, noteToPitchClass, parseNote } from '../pitch/index.js';
|
|
10
|
+
|
|
11
|
+
/** Matches a chord root: a letter A-G with an optional single-direction accidental. */
|
|
12
|
+
const ROOT_RE = /^[A-G](?:#{1,2}|b{1,2})?/;
|
|
13
|
+
|
|
14
|
+
/** Matches a bare note usable as a slash-chord bass (no octave). */
|
|
15
|
+
const BASS_RE = /^[A-G](?:#{1,2}|b{1,2})?$/;
|
|
16
|
+
|
|
17
|
+
/** Recognized quality suffixes, keyed by their exact lead-sheet spelling. */
|
|
18
|
+
const QUALITY_MAP: Record<string, ChordQuality> = {
|
|
19
|
+
'': 'maj',
|
|
20
|
+
m: 'min',
|
|
21
|
+
min: 'min',
|
|
22
|
+
'-': 'min',
|
|
23
|
+
dim: 'dim',
|
|
24
|
+
'°': 'dim',
|
|
25
|
+
aug: 'aug',
|
|
26
|
+
'+': 'aug',
|
|
27
|
+
maj7: 'maj7',
|
|
28
|
+
M7: 'maj7',
|
|
29
|
+
Δ: 'maj7',
|
|
30
|
+
Δ7: 'maj7',
|
|
31
|
+
m7: 'min7',
|
|
32
|
+
min7: 'min7',
|
|
33
|
+
'-7': 'min7',
|
|
34
|
+
'7': 'dom7',
|
|
35
|
+
dim7: 'dim7',
|
|
36
|
+
'°7': 'dim7',
|
|
37
|
+
m7b5: 'm7b5',
|
|
38
|
+
ø: 'm7b5',
|
|
39
|
+
ø7: 'm7b5',
|
|
40
|
+
min7b5: 'm7b5',
|
|
41
|
+
mMaj7: 'minMaj7',
|
|
42
|
+
mM7: 'minMaj7',
|
|
43
|
+
minMaj7: 'minMaj7',
|
|
44
|
+
aug7: 'aug7',
|
|
45
|
+
'+7': 'aug7',
|
|
46
|
+
'7#5': 'aug7',
|
|
47
|
+
augMaj7: 'augMaj7',
|
|
48
|
+
augM7: 'augMaj7',
|
|
49
|
+
'+maj7': 'augMaj7',
|
|
50
|
+
'+M7': 'augMaj7',
|
|
51
|
+
majb5: 'majb5',
|
|
52
|
+
'maj(b5)': 'majb5',
|
|
53
|
+
'(b5)': 'majb5',
|
|
54
|
+
'6': '6',
|
|
55
|
+
m6: 'min6',
|
|
56
|
+
min6: 'min6',
|
|
57
|
+
'6/9': '6/9',
|
|
58
|
+
'69': '6/9',
|
|
59
|
+
sus2: 'sus2',
|
|
60
|
+
sus: 'sus4',
|
|
61
|
+
sus4: 'sus4',
|
|
62
|
+
add9: 'add9',
|
|
63
|
+
add11: 'add11',
|
|
64
|
+
maj9: 'maj9',
|
|
65
|
+
M9: 'maj9',
|
|
66
|
+
m9: 'min9',
|
|
67
|
+
min9: 'min9',
|
|
68
|
+
'9': 'dom9',
|
|
69
|
+
'7b9': '7b9',
|
|
70
|
+
'7#9': '7#9',
|
|
71
|
+
'7#11': '7#11',
|
|
72
|
+
'7b13': '7b13',
|
|
73
|
+
'11': '11',
|
|
74
|
+
'13': '13',
|
|
75
|
+
'5': '5',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Canonical lead-sheet suffix emitted for each chord quality when formatting. */
|
|
79
|
+
const CANONICAL_SUFFIX: Record<ChordQuality, string> = {
|
|
80
|
+
maj: '',
|
|
81
|
+
min: 'm',
|
|
82
|
+
dim: 'dim',
|
|
83
|
+
aug: 'aug',
|
|
84
|
+
maj7: 'maj7',
|
|
85
|
+
min7: 'm7',
|
|
86
|
+
dom7: '7',
|
|
87
|
+
dim7: 'dim7',
|
|
88
|
+
m7b5: 'm7b5',
|
|
89
|
+
minMaj7: 'mMaj7',
|
|
90
|
+
aug7: 'aug7',
|
|
91
|
+
augMaj7: 'augMaj7',
|
|
92
|
+
majb5: 'maj(b5)',
|
|
93
|
+
'6': '6',
|
|
94
|
+
min6: 'm6',
|
|
95
|
+
'6/9': '6/9',
|
|
96
|
+
sus2: 'sus2',
|
|
97
|
+
sus4: 'sus4',
|
|
98
|
+
add9: 'add9',
|
|
99
|
+
add11: 'add11',
|
|
100
|
+
maj9: 'maj9',
|
|
101
|
+
min9: 'm9',
|
|
102
|
+
dom9: '9',
|
|
103
|
+
'7b9': '7b9',
|
|
104
|
+
'7#9': '7#9',
|
|
105
|
+
'7#11': '7#11',
|
|
106
|
+
'7b13': '7b13',
|
|
107
|
+
'11': '11',
|
|
108
|
+
'13': '13',
|
|
109
|
+
'5': '5',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Reduce a value to a pitch class in [0, 11]. */
|
|
113
|
+
function pitchClass(value: number): number {
|
|
114
|
+
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pitch class of a bare note token (letter plus optional accidental). */
|
|
118
|
+
function bassPitchClass(token: string): number {
|
|
119
|
+
return noteToPitchClass(parseNote(token));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a lead-sheet chord symbol into a {@link Chord}.
|
|
124
|
+
*
|
|
125
|
+
* Accepts `<root><quality>[/<bass>]`, e.g. `Cmaj7`, `F#m7b5`, `Bb7`, `C/G`,
|
|
126
|
+
* `C6/9`. The `/` after a quality is ambiguous between a slash bass and the
|
|
127
|
+
* `6/9` quality; it resolves to a bass only when the token following it is
|
|
128
|
+
* itself a valid note.
|
|
129
|
+
*
|
|
130
|
+
* @param text The chord symbol text.
|
|
131
|
+
* @returns The parsed chord.
|
|
132
|
+
* @throws If the root or quality is not recognized.
|
|
133
|
+
*/
|
|
134
|
+
export function parseChordSymbol(text: string): Chord {
|
|
135
|
+
const trimmed = text.trim();
|
|
136
|
+
const rootMatch = ROOT_RE.exec(trimmed);
|
|
137
|
+
if (!rootMatch) {
|
|
138
|
+
throw new Error(`Invalid chord symbol: ${text}`);
|
|
139
|
+
}
|
|
140
|
+
const rootToken = rootMatch[0];
|
|
141
|
+
const rootPc = noteToPitchClass(parseNote(rootToken));
|
|
142
|
+
const rest = trimmed.slice(rootToken.length);
|
|
143
|
+
|
|
144
|
+
const slashIndex = rest.indexOf('/');
|
|
145
|
+
if (slashIndex === -1) {
|
|
146
|
+
const quality = QUALITY_MAP[rest];
|
|
147
|
+
if (quality === undefined) {
|
|
148
|
+
throw new Error(`Unrecognized chord quality: ${text}`);
|
|
149
|
+
}
|
|
150
|
+
return makeChord(rootPc, quality);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const before = rest.slice(0, slashIndex);
|
|
154
|
+
const after = rest.slice(slashIndex + 1);
|
|
155
|
+
const beforeQuality = QUALITY_MAP[before];
|
|
156
|
+
if (beforeQuality !== undefined && BASS_RE.test(after)) {
|
|
157
|
+
return makeChord(rootPc, beforeQuality, bassPitchClass(after));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const combinedQuality = QUALITY_MAP[rest];
|
|
161
|
+
if (combinedQuality !== undefined) {
|
|
162
|
+
return makeChord(rootPc, combinedQuality);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new Error(`Unrecognized chord quality: ${text}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Format a {@link Chord} as a lead-sheet chord symbol.
|
|
170
|
+
*
|
|
171
|
+
* The inverse of {@link parseChordSymbol}: each quality maps to one canonical
|
|
172
|
+
* suffix spelling, and a slash bass is appended only when it differs from the
|
|
173
|
+
* root.
|
|
174
|
+
*
|
|
175
|
+
* @param chord The chord to format.
|
|
176
|
+
* @param opts Formatting options.
|
|
177
|
+
* @param opts.flats Prefer flat spellings over sharps for altered roots/basses.
|
|
178
|
+
* @returns The chord symbol text.
|
|
179
|
+
*/
|
|
180
|
+
export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): string {
|
|
181
|
+
const spelling = opts?.flats ? 'flat' : 'sharp';
|
|
182
|
+
const rootNote = midiToNote(60 + pitchClass(chord.rootPc), spelling);
|
|
183
|
+
const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
|
|
184
|
+
const suffix = CANONICAL_SUFFIX[chord.quality];
|
|
185
|
+
let symbol = `${rootName}${suffix}`;
|
|
186
|
+
if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== pitchClass(chord.rootPc)) {
|
|
187
|
+
const bassNote = midiToNote(60 + pitchClass(chord.bassPc), spelling);
|
|
188
|
+
symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
|
|
189
|
+
}
|
|
190
|
+
return symbol;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Transpose a chord symbol by a number of semitones.
|
|
195
|
+
*
|
|
196
|
+
* @param text The chord symbol text.
|
|
197
|
+
* @param semitones Signed semitone offset to apply to the root and bass.
|
|
198
|
+
* @param opts Formatting options for the result; see {@link formatChordSymbol}.
|
|
199
|
+
* @returns The transposed chord symbol text.
|
|
200
|
+
* @throws If `text` does not parse as a chord symbol.
|
|
201
|
+
*/
|
|
202
|
+
export function transposeChordSymbol(
|
|
203
|
+
text: string,
|
|
204
|
+
semitones: number,
|
|
205
|
+
opts?: { flats?: boolean },
|
|
206
|
+
): string {
|
|
207
|
+
const chord = parseChordSymbol(text);
|
|
208
|
+
const transposed: Chord = { ...chord, rootPc: pitchClass(chord.rootPc + semitones) };
|
|
209
|
+
if (chord.bassPc !== undefined) {
|
|
210
|
+
transposed.bassPc = pitchClass(chord.bassPc + semitones);
|
|
211
|
+
}
|
|
212
|
+
return formatChordSymbol(transposed, opts);
|
|
213
|
+
}
|