@libraz/libcantus 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/index.cjs +577 -225
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1533 -172
- package/dist/index.d.ts +1533 -172
- package/dist/index.js +577 -225
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/analysis/index.ts +73 -7
- package/src/arrange/index.ts +241 -79
- package/src/bass/index.ts +61 -12
- package/src/chord/index.ts +66 -3
- package/src/chordscale/index.ts +67 -9
- package/src/countermelody/index.ts +44 -14
- package/src/counterpoint/index.ts +21 -0
- package/src/detect/index.ts +79 -20
- package/src/drums/fills.ts +31 -20
- package/src/drums/index.ts +125 -7
- package/src/drums/internal.ts +7 -1
- package/src/drums/swing.ts +7 -2
- package/src/functional/index.ts +160 -51
- package/src/groove/index.ts +116 -20
- package/src/harmonize/index.ts +85 -12
- package/src/harmony/index.ts +19 -2
- package/src/index.ts +1 -0
- package/src/interval/index.ts +15 -1
- package/src/meter/index.ts +108 -5
- package/src/model/index.ts +161 -12
- package/src/motif/index.ts +87 -10
- package/src/pitch/index.ts +58 -3
- package/src/progression/index.ts +56 -8
- package/src/random/index.ts +13 -1
- package/src/reharmony/index.ts +35 -4
- package/src/rhythm/index.ts +45 -11
- package/src/safety/index.ts +58 -11
- package/src/scale/index.ts +135 -19
- package/src/spelling/index.ts +18 -0
- package/src/symbol/index.ts +84 -36
- package/src/timeline/index.ts +80 -13
- package/src/tuning/index.ts +28 -1
- package/src/voicing/index.ts +121 -21
package/src/scale/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { KeyScale } from '../types.js';
|
|
|
5
5
|
*
|
|
6
6
|
* Bit 0 (the root) is always set, enforcing the `KeyScale` invariant that the
|
|
7
7
|
* root is a scale tone even when the offset list omits 0.
|
|
8
|
+
*
|
|
9
|
+
* @category Scales
|
|
8
10
|
*/
|
|
9
11
|
export function maskFromOffsets(offsets: readonly number[]): number {
|
|
10
12
|
let mask = 1;
|
|
@@ -14,55 +16,123 @@ export function maskFromOffsets(offsets: readonly number[]): number {
|
|
|
14
16
|
return mask;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Mode mask for the major (Ionian) scale: offsets {0, 2, 4, 5, 7, 9, 11}.
|
|
21
|
+
*
|
|
22
|
+
* @category Scales
|
|
23
|
+
*/
|
|
18
24
|
export const MAJOR_MASK = 0b101010110101;
|
|
19
25
|
|
|
20
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Mode mask for the natural minor (Aeolian) scale: offsets {0, 2, 3, 5, 7, 8, 10}.
|
|
28
|
+
*
|
|
29
|
+
* @category Scales
|
|
30
|
+
*/
|
|
21
31
|
export const NATURAL_MINOR_MASK = 0b010110101101;
|
|
22
32
|
|
|
23
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Harmonic minor: natural minor with a raised seventh — offsets {0,2,3,5,7,8,11}.
|
|
35
|
+
*
|
|
36
|
+
* @category Scales
|
|
37
|
+
*/
|
|
24
38
|
export const HARMONIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 8, 11]);
|
|
25
39
|
|
|
26
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Ascending melodic minor: offsets {0,2,3,5,7,9,11}.
|
|
42
|
+
*
|
|
43
|
+
* @category Scales
|
|
44
|
+
*/
|
|
27
45
|
export const MELODIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 11]);
|
|
28
46
|
|
|
29
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Dorian mode: offsets {0,2,3,5,7,9,10}.
|
|
49
|
+
*
|
|
50
|
+
* @category Scales
|
|
51
|
+
*/
|
|
30
52
|
export const DORIAN_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 10]);
|
|
31
53
|
|
|
32
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Phrygian mode: offsets {0,1,3,5,7,8,10}.
|
|
56
|
+
*
|
|
57
|
+
* @category Scales
|
|
58
|
+
*/
|
|
33
59
|
export const PHRYGIAN_MASK = maskFromOffsets([0, 1, 3, 5, 7, 8, 10]);
|
|
34
60
|
|
|
35
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Lydian mode: offsets {0,2,4,6,7,9,11}.
|
|
63
|
+
*
|
|
64
|
+
* @category Scales
|
|
65
|
+
*/
|
|
36
66
|
export const LYDIAN_MASK = maskFromOffsets([0, 2, 4, 6, 7, 9, 11]);
|
|
37
67
|
|
|
38
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Mixolydian mode: offsets {0,2,4,5,7,9,10}.
|
|
70
|
+
*
|
|
71
|
+
* @category Scales
|
|
72
|
+
*/
|
|
39
73
|
export const MIXOLYDIAN_MASK = maskFromOffsets([0, 2, 4, 5, 7, 9, 10]);
|
|
40
74
|
|
|
41
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Locrian mode: offsets {0,1,3,5,6,8,10}.
|
|
77
|
+
*
|
|
78
|
+
* @category Scales
|
|
79
|
+
*/
|
|
42
80
|
export const LOCRIAN_MASK = maskFromOffsets([0, 1, 3, 5, 6, 8, 10]);
|
|
43
81
|
|
|
44
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Major pentatonic: offsets {0,2,4,7,9}.
|
|
84
|
+
*
|
|
85
|
+
* @category Scales
|
|
86
|
+
*/
|
|
45
87
|
export const MAJOR_PENTATONIC_MASK = maskFromOffsets([0, 2, 4, 7, 9]);
|
|
46
88
|
|
|
47
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Minor pentatonic: offsets {0,3,5,7,10}.
|
|
91
|
+
*
|
|
92
|
+
* @category Scales
|
|
93
|
+
*/
|
|
48
94
|
export const MINOR_PENTATONIC_MASK = maskFromOffsets([0, 3, 5, 7, 10]);
|
|
49
95
|
|
|
50
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Hexatonic blues scale: minor pentatonic plus the flat-fifth — {0,3,5,6,7,10}.
|
|
98
|
+
*
|
|
99
|
+
* @category Scales
|
|
100
|
+
*/
|
|
51
101
|
export const BLUES_MASK = maskFromOffsets([0, 3, 5, 6, 7, 10]);
|
|
52
102
|
|
|
53
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Whole-tone scale: offsets {0,2,4,6,8,10}.
|
|
105
|
+
*
|
|
106
|
+
* @category Scales
|
|
107
|
+
*/
|
|
54
108
|
export const WHOLE_TONE_MASK = maskFromOffsets([0, 2, 4, 6, 8, 10]);
|
|
55
109
|
|
|
56
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Octatonic (half-step first): offsets {0,1,3,4,6,7,9,10}.
|
|
112
|
+
*
|
|
113
|
+
* @category Scales
|
|
114
|
+
*/
|
|
57
115
|
export const OCTATONIC_HALF_WHOLE_MASK = maskFromOffsets([0, 1, 3, 4, 6, 7, 9, 10]);
|
|
58
116
|
|
|
59
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Octatonic (whole-step first): offsets {0,2,3,5,6,8,9,11}.
|
|
119
|
+
*
|
|
120
|
+
* @category Scales
|
|
121
|
+
*/
|
|
60
122
|
export const OCTATONIC_WHOLE_HALF_MASK = maskFromOffsets([0, 2, 3, 5, 6, 8, 9, 11]);
|
|
61
123
|
|
|
62
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Chromatic scale: all twelve pitch classes.
|
|
126
|
+
*
|
|
127
|
+
* @category Scales
|
|
128
|
+
*/
|
|
63
129
|
export const CHROMATIC_MASK = 0b111111111111;
|
|
64
130
|
|
|
65
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Named scale masks addressable by {@link scaleByName}.
|
|
133
|
+
*
|
|
134
|
+
* @category Scales
|
|
135
|
+
*/
|
|
66
136
|
export const NAMED_SCALES: Record<string, number> = {
|
|
67
137
|
major: MAJOR_MASK,
|
|
68
138
|
ionian: MAJOR_MASK,
|
|
@@ -84,12 +154,32 @@ export const NAMED_SCALES: Record<string, number> = {
|
|
|
84
154
|
chromatic: CHROMATIC_MASK,
|
|
85
155
|
};
|
|
86
156
|
|
|
87
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Build a `KeyScale` for a major key on the given root pitch class.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* import { majorKey } from '@libraz/libcantus';
|
|
163
|
+
* const cMajor = majorKey(0); // C major: { rootPc: 0, modeMask12: MAJOR_MASK }
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* @category Scales
|
|
167
|
+
*/
|
|
88
168
|
export function majorKey(rootPc: number): KeyScale {
|
|
89
169
|
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
|
|
90
170
|
}
|
|
91
171
|
|
|
92
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Build a `KeyScale` for a natural-minor key on the given root pitch class.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* import { minorKey } from '@libraz/libcantus';
|
|
178
|
+
* const aMinor = minorKey(9); // A natural minor: { rootPc: 9, modeMask12: NATURAL_MINOR_MASK }
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* @category Scales
|
|
182
|
+
*/
|
|
93
183
|
export function minorKey(rootPc: number): KeyScale {
|
|
94
184
|
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
|
|
95
185
|
}
|
|
@@ -101,6 +191,14 @@ export function minorKey(rootPc: number): KeyScale {
|
|
|
101
191
|
* @param rootPc The root pitch class.
|
|
102
192
|
* @returns The key/scale.
|
|
103
193
|
* @throws If the name is not a known scale.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* import { scaleByName } from '@libraz/libcantus';
|
|
198
|
+
* const dDorian = scaleByName('dorian', 2); // D Dorian, rootPc 2
|
|
199
|
+
* ```
|
|
200
|
+
*
|
|
201
|
+
* @category Scales
|
|
104
202
|
*/
|
|
105
203
|
export function scaleByName(name: string, rootPc: number): KeyScale {
|
|
106
204
|
const mask = NAMED_SCALES[name];
|
|
@@ -126,6 +224,16 @@ function scaleOffset(pitch: number, key: KeyScale): number {
|
|
|
126
224
|
* @param pitch MIDI pitch or bare pitch class.
|
|
127
225
|
* @param key The key/scale to test against.
|
|
128
226
|
* @returns True if the pitch class is a member of the scale.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```ts
|
|
230
|
+
* import { majorKey, isScaleTone } from '@libraz/libcantus';
|
|
231
|
+
* const key = majorKey(0); // C major
|
|
232
|
+
* isScaleTone(64, key); // true — E is in C major
|
|
233
|
+
* isScaleTone(61, key); // false — C# is not
|
|
234
|
+
* ```
|
|
235
|
+
*
|
|
236
|
+
* @category Scales
|
|
129
237
|
*/
|
|
130
238
|
export function isScaleTone(pitch: number, key: KeyScale): boolean {
|
|
131
239
|
return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
|
|
@@ -140,6 +248,8 @@ export function isScaleTone(pitch: number, key: KeyScale): boolean {
|
|
|
140
248
|
* @param pitch MIDI pitch to snap.
|
|
141
249
|
* @param key The key/scale to snap to.
|
|
142
250
|
* @returns The nearest in-scale MIDI pitch.
|
|
251
|
+
*
|
|
252
|
+
* @category Scales
|
|
143
253
|
*/
|
|
144
254
|
export function nearestScaleTone(pitch: number, key: KeyScale): number {
|
|
145
255
|
const base = Math.round(pitch);
|
|
@@ -162,6 +272,8 @@ export function nearestScaleTone(pitch: number, key: KeyScale): number {
|
|
|
162
272
|
* @param pitch MIDI pitch or bare pitch class.
|
|
163
273
|
* @param key The key/scale to measure against.
|
|
164
274
|
* @returns The 0-based degree, or -1 if the pitch is not in the scale.
|
|
275
|
+
*
|
|
276
|
+
* @category Scales
|
|
165
277
|
*/
|
|
166
278
|
export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
|
|
167
279
|
const offset = scaleOffset(pitch, key);
|
|
@@ -182,6 +294,8 @@ export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
|
|
|
182
294
|
*
|
|
183
295
|
* @param key The key/scale to enumerate.
|
|
184
296
|
* @returns The member pitch classes, sorted ascending in [0, 11].
|
|
297
|
+
*
|
|
298
|
+
* @category Scales
|
|
185
299
|
*/
|
|
186
300
|
export function diatonicPitchClasses(key: KeyScale): number[] {
|
|
187
301
|
const root = pitchClass(key.rootPc);
|
|
@@ -202,6 +316,8 @@ export function diatonicPitchClasses(key: KeyScale): number[] {
|
|
|
202
316
|
*
|
|
203
317
|
* @param key The key/scale to enumerate.
|
|
204
318
|
* @returns The member pitch classes ordered by scale degree.
|
|
319
|
+
*
|
|
320
|
+
* @category Scales
|
|
205
321
|
*/
|
|
206
322
|
export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
|
|
207
323
|
const root = pitchClass(key.rootPc);
|
package/src/spelling/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ function isHeptatonic(key: KeyScale): boolean {
|
|
|
79
79
|
* @param tonic The spelled tonic (its letter anchors the spelling).
|
|
80
80
|
* @param key The key/scale.
|
|
81
81
|
* @returns The spelled note (without octave).
|
|
82
|
+
* @category Pitch & Intervals
|
|
82
83
|
*/
|
|
83
84
|
export function spellPitchClass(pc: number, tonic: Note, key: KeyScale): Note {
|
|
84
85
|
const tonicPc = mod12(naturalPc(tonic.letter) + tonic.alter);
|
|
@@ -117,6 +118,13 @@ export function spellPitchClass(pc: number, tonic: Note, key: KeyScale): Note {
|
|
|
117
118
|
* @param tonic The spelled tonic.
|
|
118
119
|
* @param key The key/scale.
|
|
119
120
|
* @returns Spelled notes, one per scale degree.
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { spellScale, noteNames, parseNote, majorKey } from '@libraz/libcantus';
|
|
124
|
+
* noteNames(spellScale(parseNote('C'), majorKey(0)));
|
|
125
|
+
* // ['C', 'D', 'E', 'F', 'G', 'A', 'B']
|
|
126
|
+
* ```
|
|
127
|
+
* @category Pitch & Intervals
|
|
120
128
|
*/
|
|
121
129
|
export function spellScale(tonic: Note, key: KeyScale): Note[] {
|
|
122
130
|
return scaleTonesInDegreeOrder(key).map((pc) => spellPitchClass(pc, tonic, key));
|
|
@@ -129,6 +137,7 @@ export function spellScale(tonic: Note, key: KeyScale): Note[] {
|
|
|
129
137
|
* @param tonic The spelled tonic.
|
|
130
138
|
* @param key The key/scale.
|
|
131
139
|
* @returns Spelled notes, in input order.
|
|
140
|
+
* @category Pitch & Intervals
|
|
132
141
|
*/
|
|
133
142
|
export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): Note[] {
|
|
134
143
|
return pcs.map((pc) => spellPitchClass(pc, tonic, key));
|
|
@@ -146,6 +155,14 @@ export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): No
|
|
|
146
155
|
* @param tonic The spelled tonic of the key.
|
|
147
156
|
* @param key The key/scale.
|
|
148
157
|
* @returns Spelled chord tones, root first.
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* import { spellChord, noteNames, parseNote, majorKey, makeChord } from '@libraz/libcantus';
|
|
161
|
+
* // G7 in C major
|
|
162
|
+
* noteNames(spellChord(makeChord(7, 'dom7'), parseNote('C'), majorKey(0)));
|
|
163
|
+
* // ['G', 'B', 'D', 'F']
|
|
164
|
+
* ```
|
|
165
|
+
* @category Pitch & Intervals
|
|
149
166
|
*/
|
|
150
167
|
export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
|
|
151
168
|
return chord.intervals.map((interval) =>
|
|
@@ -158,6 +175,7 @@ export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
|
|
|
158
175
|
*
|
|
159
176
|
* @param notes The notes.
|
|
160
177
|
* @returns Their formatted names.
|
|
178
|
+
* @category Pitch & Intervals
|
|
161
179
|
*/
|
|
162
180
|
export function noteNames(notes: Note[]): string[] {
|
|
163
181
|
return notes.map(formatNote);
|
package/src/symbol/index.ts
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
* (`Cmaj7`, `F#m7b5`, `Bb7`, `C/G`) and the library's structured {@link Chord}
|
|
6
6
|
* type, so callers can accept/emit plain text at the edges of the API.
|
|
7
7
|
*/
|
|
8
|
-
import { type Chord, type ChordQuality, makeChord } from '../chord/index.js';
|
|
9
|
-
import { formatNote, midiToNote, noteToPitchClass, parseNote } from '../pitch/index.js';
|
|
8
|
+
import { type Chord, type ChordQuality, makeChord, type PitchSpelling } from '../chord/index.js';
|
|
9
|
+
import { formatNote, midiToNote, type Note, noteToPitchClass, parseNote } from '../pitch/index.js';
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Matches a chord root: a letter A-G (case-insensitive, like {@link parseNote})
|
|
13
|
+
* with an optional single-direction accidental.
|
|
14
|
+
*/
|
|
15
|
+
const ROOT_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?/;
|
|
13
16
|
|
|
14
17
|
/** Matches a bare note usable as a slash-chord bass (no octave). */
|
|
15
|
-
const BASS_RE = /^[A-
|
|
18
|
+
const BASS_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?$/;
|
|
16
19
|
|
|
17
20
|
/** Recognized quality suffixes, keyed by their exact lead-sheet spelling. */
|
|
18
21
|
const QUALITY_MAP: Record<string, ChordQuality> = {
|
|
@@ -114,22 +117,44 @@ function pitchClass(value: number): number {
|
|
|
114
117
|
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
/**
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
+
/** Build a chord and record the parsed spellings as enharmonic hints. */
|
|
121
|
+
function makeSpelledChord(root: Note, quality: ChordQuality, bass?: Note): Chord {
|
|
122
|
+
const chord = makeChord(
|
|
123
|
+
noteToPitchClass(root),
|
|
124
|
+
quality,
|
|
125
|
+
bass === undefined ? undefined : noteToPitchClass(bass),
|
|
126
|
+
);
|
|
127
|
+
chord.rootSpelling = { letter: root.letter, alter: root.alter };
|
|
128
|
+
if (bass !== undefined) {
|
|
129
|
+
chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
|
|
130
|
+
}
|
|
131
|
+
return chord;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
/**
|
|
123
135
|
* Parse a lead-sheet chord symbol into a {@link Chord}.
|
|
124
136
|
*
|
|
125
137
|
* Accepts `<root><quality>[/<bass>]`, e.g. `Cmaj7`, `F#m7b5`, `Bb7`, `C/G`,
|
|
126
|
-
* `C6/9`.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
138
|
+
* `C6/9`, `C6/9/E`. Root and bass letters are case-insensitive, matching
|
|
139
|
+
* {@link parseNote}. A `/` is ambiguous between a slash bass and the `6/9`
|
|
140
|
+
* quality; the text splits at the last `/` whose right-hand side is a valid
|
|
141
|
+
* bare note preceded by a recognized quality, so `6/9` (with or without a
|
|
142
|
+
* further slash bass) parses as a quality.
|
|
143
|
+
*
|
|
144
|
+
* The parsed chord carries the root/bass spellings as enharmonic hints
|
|
145
|
+
* (`rootSpelling`/`bassSpelling`) so {@link formatChordSymbol} can reproduce
|
|
146
|
+
* flat spellings such as `Bbmaj7` instead of respelling them with sharps.
|
|
129
147
|
*
|
|
130
148
|
* @param text The chord symbol text.
|
|
131
149
|
* @returns The parsed chord.
|
|
132
150
|
* @throws If the root or quality is not recognized.
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* import { parseChordSymbol, formatChordSymbol } from '@libraz/libcantus';
|
|
154
|
+
* const c = parseChordSymbol('Bbmaj7');
|
|
155
|
+
* formatChordSymbol(c); // 'Bbmaj7' — flat spelling is preserved on round-trip
|
|
156
|
+
* ```
|
|
157
|
+
* @category Chords
|
|
133
158
|
*/
|
|
134
159
|
export function parseChordSymbol(text: string): Chord {
|
|
135
160
|
const trimmed = text.trim();
|
|
@@ -138,31 +163,41 @@ export function parseChordSymbol(text: string): Chord {
|
|
|
138
163
|
throw new Error(`Invalid chord symbol: ${text}`);
|
|
139
164
|
}
|
|
140
165
|
const rootToken = rootMatch[0];
|
|
141
|
-
const
|
|
166
|
+
const rootNote = parseNote(rootToken);
|
|
142
167
|
const rest = trimmed.slice(rootToken.length);
|
|
143
168
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
// Split at the last '/' so the '6/9' quality's own slash never masks a
|
|
170
|
+
// trailing slash bass (e.g. 'C6/9/E').
|
|
171
|
+
const slashIndex = rest.lastIndexOf('/');
|
|
172
|
+
if (slashIndex !== -1) {
|
|
173
|
+
const before = rest.slice(0, slashIndex);
|
|
174
|
+
const after = rest.slice(slashIndex + 1);
|
|
175
|
+
const beforeQuality = QUALITY_MAP[before];
|
|
176
|
+
if (beforeQuality !== undefined && BASS_RE.test(after)) {
|
|
177
|
+
return makeSpelledChord(rootNote, beforeQuality, parseNote(after));
|
|
149
178
|
}
|
|
150
|
-
return makeChord(rootPc, quality);
|
|
151
179
|
}
|
|
152
180
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (beforeQuality !== undefined && BASS_RE.test(after)) {
|
|
157
|
-
return makeChord(rootPc, beforeQuality, bassPitchClass(after));
|
|
181
|
+
const quality = QUALITY_MAP[rest];
|
|
182
|
+
if (quality === undefined) {
|
|
183
|
+
throw new Error(`Unrecognized chord quality: ${text}`);
|
|
158
184
|
}
|
|
185
|
+
return makeSpelledChord(rootNote, quality);
|
|
186
|
+
}
|
|
159
187
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Name a pitch class, preferring a spelling hint when it is still valid.
|
|
190
|
+
*
|
|
191
|
+
* A hint is used only when no explicit sharp/flat preference was given and the
|
|
192
|
+
* hint still resolves to `pc` (a stale hint left over after transposition is
|
|
193
|
+
* ignored). Otherwise the pitch class is respelled from the requested table.
|
|
194
|
+
*/
|
|
195
|
+
function pitchClassName(pc: number, hint: PitchSpelling | undefined, flats?: boolean): string {
|
|
196
|
+
if (flats === undefined && hint !== undefined && noteToPitchClass(hint) === pc) {
|
|
197
|
+
return formatNote(hint);
|
|
163
198
|
}
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
const note = midiToNote(60 + pc, flats ? 'flat' : 'sharp');
|
|
200
|
+
return formatNote({ letter: note.letter, alter: note.alter });
|
|
166
201
|
}
|
|
167
202
|
|
|
168
203
|
/**
|
|
@@ -170,22 +205,29 @@ export function parseChordSymbol(text: string): Chord {
|
|
|
170
205
|
*
|
|
171
206
|
* The inverse of {@link parseChordSymbol}: each quality maps to one canonical
|
|
172
207
|
* suffix spelling, and a slash bass is appended only when it differs from the
|
|
173
|
-
* root.
|
|
208
|
+
* root. When the chord carries `rootSpelling`/`bassSpelling` hints (as chords
|
|
209
|
+
* from {@link parseChordSymbol} do) and no explicit `flats` preference is
|
|
210
|
+
* given, the hints are reused so flat symbols round-trip unchanged.
|
|
174
211
|
*
|
|
175
212
|
* @param chord The chord to format.
|
|
176
213
|
* @param opts Formatting options.
|
|
177
|
-
* @param opts.flats Prefer flat spellings over sharps for altered roots/basses
|
|
214
|
+
* @param opts.flats Prefer flat spellings over sharps for altered roots/basses;
|
|
215
|
+
* passing either `true` or `false` overrides any spelling hints.
|
|
178
216
|
* @returns The chord symbol text.
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* import { makeChord, formatChordSymbol } from '@libraz/libcantus';
|
|
220
|
+
* formatChordSymbol(makeChord(0, 'min7')); // 'Cm7'
|
|
221
|
+
* ```
|
|
222
|
+
* @category Chords
|
|
179
223
|
*/
|
|
180
224
|
export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): string {
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
|
|
225
|
+
const rootPc = pitchClass(chord.rootPc);
|
|
226
|
+
const rootName = pitchClassName(rootPc, chord.rootSpelling, opts?.flats);
|
|
184
227
|
const suffix = CANONICAL_SUFFIX[chord.quality];
|
|
185
228
|
let symbol = `${rootName}${suffix}`;
|
|
186
|
-
if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !==
|
|
187
|
-
|
|
188
|
-
symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
|
|
229
|
+
if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== rootPc) {
|
|
230
|
+
symbol += `/${pitchClassName(pitchClass(chord.bassPc), chord.bassSpelling, opts?.flats)}`;
|
|
189
231
|
}
|
|
190
232
|
return symbol;
|
|
191
233
|
}
|
|
@@ -198,6 +240,12 @@ export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): str
|
|
|
198
240
|
* @param opts Formatting options for the result; see {@link formatChordSymbol}.
|
|
199
241
|
* @returns The transposed chord symbol text.
|
|
200
242
|
* @throws If `text` does not parse as a chord symbol.
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* import { transposeChordSymbol } from '@libraz/libcantus';
|
|
246
|
+
* transposeChordSymbol('C/G', 2); // 'D/A'
|
|
247
|
+
* ```
|
|
248
|
+
* @category Chords
|
|
201
249
|
*/
|
|
202
250
|
export function transposeChordSymbol(
|
|
203
251
|
text: string,
|
package/src/timeline/index.ts
CHANGED
|
@@ -10,14 +10,22 @@ import type { GeneratedChord } from '../progression/index.js';
|
|
|
10
10
|
import { isScaleTone, majorKey } from '../scale/index.js';
|
|
11
11
|
import type { KeyScale, NoteEvent } from '../types.js';
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* A chord occupying a half-open beat span.
|
|
15
|
+
*
|
|
16
|
+
* @category Arrangement & Analysis
|
|
17
|
+
*/
|
|
14
18
|
export type ChordSegment = {
|
|
15
19
|
startBeat: number;
|
|
16
20
|
endBeat: number;
|
|
17
21
|
chord: Chord;
|
|
18
22
|
};
|
|
19
23
|
|
|
20
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A beat-indexed sequence of chord segments.
|
|
26
|
+
*
|
|
27
|
+
* @category Arrangement & Analysis
|
|
28
|
+
*/
|
|
21
29
|
export type ChordTimeline = {
|
|
22
30
|
/** The chord sounding at a beat, or null when no segment covers it. */
|
|
23
31
|
at: (beat: number) => Chord | null;
|
|
@@ -35,6 +43,7 @@ export type ChordTimeline = {
|
|
|
35
43
|
* @param chords Placed chords in time order.
|
|
36
44
|
* @param totalBeats End of the timeline in beats.
|
|
37
45
|
* @returns A queryable chord timeline.
|
|
46
|
+
* @category Arrangement & Analysis
|
|
38
47
|
*/
|
|
39
48
|
export function chordTimelineFromChords(
|
|
40
49
|
chords: GeneratedChord[],
|
|
@@ -96,19 +105,39 @@ function pitchClass(value: number): number {
|
|
|
96
105
|
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Options controlling {@link chordTimelineFromNotes}.
|
|
110
|
+
*
|
|
111
|
+
* @category Arrangement & Analysis
|
|
112
|
+
*/
|
|
100
113
|
export type ChordTimelineOptions = {
|
|
101
114
|
/** Key context; inferred from the notes with {@link detectKey} when omitted. */
|
|
102
115
|
key?: KeyScale;
|
|
103
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Time signature used for metric accents; defaults to 4/4.
|
|
118
|
+
*
|
|
119
|
+
* @defaultValue `4/4`
|
|
120
|
+
*/
|
|
104
121
|
ts?: TimeSignature;
|
|
105
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Window length in beats per chord slot; defaults to one bar of `ts`.
|
|
124
|
+
*
|
|
125
|
+
* @defaultValue one bar of `ts`
|
|
126
|
+
*/
|
|
106
127
|
harmonicRhythm?: number;
|
|
107
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* End of the analyzed span in beats; defaults to the end of the last note.
|
|
130
|
+
*
|
|
131
|
+
* @defaultValue the end of the last note
|
|
132
|
+
*/
|
|
108
133
|
totalBeats?: number;
|
|
109
134
|
};
|
|
110
135
|
|
|
111
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* The result of {@link chordTimelineFromNotes}.
|
|
138
|
+
*
|
|
139
|
+
* @category Arrangement & Analysis
|
|
140
|
+
*/
|
|
112
141
|
export type ChordTimelineResult = {
|
|
113
142
|
/** The inferred timeline, with adjacent identical chords merged. */
|
|
114
143
|
timeline: ChordTimeline;
|
|
@@ -266,10 +295,26 @@ function sameChord(a: Chord, b: Chord): boolean {
|
|
|
266
295
|
* the fraction of the window weight explained by chord tones, reduced when the
|
|
267
296
|
* match is inexact, and duration-weighted across merged windows.
|
|
268
297
|
*
|
|
298
|
+
* Notes with a zero or negative duration never sound, so they are dropped at
|
|
299
|
+
* ingest: they contribute to neither the key inference, the span, nor any
|
|
300
|
+
* window's histogram.
|
|
301
|
+
*
|
|
269
302
|
* @param notes The notes to analyze (any number of tracks, flattened).
|
|
270
303
|
* @param opts Analysis options; see {@link ChordTimelineOptions}.
|
|
271
304
|
* @returns The inferred timeline, the key used, and per-segment confidences.
|
|
272
305
|
* @throws If `harmonicRhythm` is not positive.
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* import { chordTimelineFromNotes } from '@libraz/libcantus';
|
|
309
|
+
* const notes = [
|
|
310
|
+
* { pitch: 60, startBeat: 0, durationBeat: 2 }, // C
|
|
311
|
+
* { pitch: 64, startBeat: 0, durationBeat: 2 }, // E
|
|
312
|
+
* { pitch: 67, startBeat: 0, durationBeat: 2 }, // G
|
|
313
|
+
* ];
|
|
314
|
+
* const { timeline, key } = chordTimelineFromNotes(notes);
|
|
315
|
+
* timeline.at(0); // the chord inferred over beat 0, or null
|
|
316
|
+
* ```
|
|
317
|
+
* @category Arrangement & Analysis
|
|
273
318
|
*/
|
|
274
319
|
export function chordTimelineFromNotes(
|
|
275
320
|
notes: NoteEvent[],
|
|
@@ -280,9 +325,11 @@ export function chordTimelineFromNotes(
|
|
|
280
325
|
if (!(harmonicRhythm > 0)) {
|
|
281
326
|
throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
|
|
282
327
|
}
|
|
283
|
-
|
|
328
|
+
// Zero/negative-length notes never sound; drop them before any inference.
|
|
329
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
330
|
+
const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
284
331
|
const totalBeats = opts.totalBeats ?? lastNoteEnd;
|
|
285
|
-
const key = opts.key ?? detectKey(
|
|
332
|
+
const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
|
|
286
333
|
|
|
287
334
|
const segments: ChordSegment[] = [];
|
|
288
335
|
const segmentConfidence: number[] = [];
|
|
@@ -290,7 +337,7 @@ export function chordTimelineFromNotes(
|
|
|
290
337
|
for (let i = 0; i < windowCount; i += 1) {
|
|
291
338
|
const start = i * harmonicRhythm;
|
|
292
339
|
const end = Math.min(start + harmonicRhythm, totalBeats);
|
|
293
|
-
const inferred = analyzeWindow(
|
|
340
|
+
const inferred = analyzeWindow(sounding, start, end, ts, key);
|
|
294
341
|
if (!inferred) {
|
|
295
342
|
continue;
|
|
296
343
|
}
|
|
@@ -316,7 +363,11 @@ export function chordTimelineFromNotes(
|
|
|
316
363
|
};
|
|
317
364
|
}
|
|
318
365
|
|
|
319
|
-
/**
|
|
366
|
+
/**
|
|
367
|
+
* A cadence found between two consecutive timeline segments.
|
|
368
|
+
*
|
|
369
|
+
* @category Arrangement & Analysis
|
|
370
|
+
*/
|
|
320
371
|
export type CadenceHit = {
|
|
321
372
|
/** The beat where the cadence arrives (the second chord's onset). */
|
|
322
373
|
atBeat: number;
|
|
@@ -328,12 +379,25 @@ export type CadenceHit = {
|
|
|
328
379
|
/**
|
|
329
380
|
* Find cadences between consecutive segments of a chord timeline.
|
|
330
381
|
*
|
|
331
|
-
* Each adjacent segment pair is classified with
|
|
332
|
-
* forming no cadence are skipped.
|
|
382
|
+
* Each temporally adjacent segment pair is classified with
|
|
383
|
+
* {@link detectCadence}; pairs forming no cadence are skipped. Segments
|
|
384
|
+
* separated by a gap (a rest in the timeline) are not a chord-to-chord
|
|
385
|
+
* progression, so they are never paired.
|
|
333
386
|
*
|
|
334
387
|
* @param timeline The chord timeline to scan.
|
|
335
388
|
* @param key The prevailing key.
|
|
336
389
|
* @returns The cadences found, in time order.
|
|
390
|
+
* @example
|
|
391
|
+
* ```ts
|
|
392
|
+
* import { chordTimelineFromNotes, detectCadences } from '@libraz/libcantus';
|
|
393
|
+
* const notes = [
|
|
394
|
+
* { pitch: 67, startBeat: 0, durationBeat: 4 }, // G, a dominant
|
|
395
|
+
* { pitch: 60, startBeat: 4, durationBeat: 4 }, // C, the tonic
|
|
396
|
+
* ];
|
|
397
|
+
* const { timeline, key } = chordTimelineFromNotes(notes);
|
|
398
|
+
* const cadences = detectCadences(timeline, key);
|
|
399
|
+
* ```
|
|
400
|
+
* @category Arrangement & Analysis
|
|
337
401
|
*/
|
|
338
402
|
export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
|
|
339
403
|
const hits: CadenceHit[] = [];
|
|
@@ -343,6 +407,9 @@ export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceH
|
|
|
343
407
|
if (!prev || !cur) {
|
|
344
408
|
continue;
|
|
345
409
|
}
|
|
410
|
+
if (Math.abs(cur.startBeat - prev.endBeat) > EPS) {
|
|
411
|
+
continue; // A rest separates the chords; no cadential motion across it.
|
|
412
|
+
}
|
|
346
413
|
const type = detectCadence(prev.chord, cur.chord, key);
|
|
347
414
|
if (type !== null) {
|
|
348
415
|
hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
|