@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,321 @@
|
|
|
1
|
+
import type { Chord } from '../chord/index.js';
|
|
2
|
+
import { chordPitchClasses } from '../chord/index.js';
|
|
3
|
+
import { isScaleTone, nearestScaleTone } from '../scale/index.js';
|
|
4
|
+
import type { ChordTimeline } from '../timeline/index.js';
|
|
5
|
+
import type { KeyScale } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/** A transformation applicable to a motif cell. */
|
|
8
|
+
export type MotifTransform =
|
|
9
|
+
| 'transposeDiatonic'
|
|
10
|
+
| 'transposeChromatic'
|
|
11
|
+
| 'invert'
|
|
12
|
+
| 'retrograde'
|
|
13
|
+
| 'augment'
|
|
14
|
+
| 'diminish'
|
|
15
|
+
| 'sequence';
|
|
16
|
+
|
|
17
|
+
/** A single note within a motif cell. */
|
|
18
|
+
export type MotifNote = {
|
|
19
|
+
pitch: number;
|
|
20
|
+
startBeat: number;
|
|
21
|
+
durationBeat: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A short melodic cell. */
|
|
25
|
+
export type MotifCell = {
|
|
26
|
+
notes: MotifNote[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Melodic contour shape for {@link generateMotif}. */
|
|
30
|
+
export type MotifContour = 'arch' | 'ascending' | 'descending' | 'wave';
|
|
31
|
+
|
|
32
|
+
/** Options controlling {@link generateMotif}. */
|
|
33
|
+
export type GenerateMotifOptions = {
|
|
34
|
+
key: KeyScale;
|
|
35
|
+
chord?: Chord | null;
|
|
36
|
+
bars: number;
|
|
37
|
+
contour?: MotifContour;
|
|
38
|
+
seed?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function pitchClass(pitch: number): number {
|
|
42
|
+
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Smallest span, in beats, treated as a distinct tile step. A positive but
|
|
47
|
+
* near-zero cell span is clamped to this so tiling cannot stall.
|
|
48
|
+
*/
|
|
49
|
+
const MIN_TILE_SPAN = 1 / 256;
|
|
50
|
+
|
|
51
|
+
/** Nearest in-scale pitch strictly above `pitch`. */
|
|
52
|
+
function upScaleTone(pitch: number, key: KeyScale): number {
|
|
53
|
+
for (let d = 1; d <= 12; d += 1) {
|
|
54
|
+
if (isScaleTone(pitch + d, key)) {
|
|
55
|
+
return pitch + d;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return pitch + 12;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Nearest in-scale pitch strictly below `pitch`. */
|
|
62
|
+
function downScaleTone(pitch: number, key: KeyScale): number {
|
|
63
|
+
for (let d = 1; d <= 12; d += 1) {
|
|
64
|
+
if (isScaleTone(pitch - d, key)) {
|
|
65
|
+
return pitch - d;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return pitch - 12;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shift a pitch by a number of diatonic scale degrees. */
|
|
72
|
+
function stepDiatonic(pitch: number, degrees: number, key: KeyScale): number {
|
|
73
|
+
let p = nearestScaleTone(pitch, key);
|
|
74
|
+
const steps = Math.abs(Math.trunc(degrees));
|
|
75
|
+
for (let i = 0; i < steps; i += 1) {
|
|
76
|
+
p = degrees >= 0 ? upScaleTone(p, key) : downScaleTone(p, key);
|
|
77
|
+
}
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Nearest chord tone to `pitch`, preferring the lower pitch on a tie. */
|
|
82
|
+
function nearestChordTone(pitch: number, chord: Chord): number {
|
|
83
|
+
const pcs = chordPitchClasses(chord);
|
|
84
|
+
for (let d = 0; d <= 12; d += 1) {
|
|
85
|
+
if (pcs.includes(pitchClass(pitch - d))) {
|
|
86
|
+
return pitch - d;
|
|
87
|
+
}
|
|
88
|
+
if (pcs.includes(pitchClass(pitch + d))) {
|
|
89
|
+
return pitch + d;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return pitch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Earliest onset in a cell; the time origin for timing transforms. */
|
|
96
|
+
function cellOrigin(notes: MotifNote[]): number {
|
|
97
|
+
return notes.length === 0 ? 0 : Math.min(...notes.map((n) => n.startBeat));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Total beat span covered by a cell (from first onset to last offset). */
|
|
101
|
+
function cellSpan(cell: MotifCell): number {
|
|
102
|
+
if (cell.notes.length === 0) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const start = cellOrigin(cell.notes);
|
|
106
|
+
const end = Math.max(...cell.notes.map((n) => n.startBeat + n.durationBeat));
|
|
107
|
+
return end - start;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clone(cell: MotifCell): MotifCell {
|
|
111
|
+
return { notes: cell.notes.map((n) => ({ ...n })) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Deterministic 32-bit PRNG in [0, 1). */
|
|
115
|
+
function mulberry32(seed: number): () => number {
|
|
116
|
+
let state = seed >>> 0;
|
|
117
|
+
return () => {
|
|
118
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
119
|
+
let t = state;
|
|
120
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
121
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
122
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Diatonic scale-degree offsets shaping a contour of `count` notes. */
|
|
127
|
+
function contourOffsets(contour: MotifContour, count: number): number[] {
|
|
128
|
+
const offsets: number[] = [];
|
|
129
|
+
const half = Math.floor(count / 2);
|
|
130
|
+
for (let i = 0; i < count; i += 1) {
|
|
131
|
+
switch (contour) {
|
|
132
|
+
case 'ascending':
|
|
133
|
+
offsets.push(i);
|
|
134
|
+
break;
|
|
135
|
+
case 'descending':
|
|
136
|
+
offsets.push(-i);
|
|
137
|
+
break;
|
|
138
|
+
case 'arch':
|
|
139
|
+
offsets.push(i <= half ? i : count - 1 - i);
|
|
140
|
+
break;
|
|
141
|
+
case 'wave':
|
|
142
|
+
offsets.push([0, 1, 0, -1][i % 4] ?? 0);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return offsets;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate a short seed motif over `bars`.
|
|
151
|
+
*
|
|
152
|
+
* The melody follows the requested contour in diatonic steps from the tonic,
|
|
153
|
+
* snapped to the key. When a chord is supplied, notes landing on bar downbeats
|
|
154
|
+
* are pulled to the nearest chord tone. Output is deterministic for a given seed.
|
|
155
|
+
*
|
|
156
|
+
* @param opts Key, optional chord, length, contour, and seed.
|
|
157
|
+
* @returns The generated motif cell.
|
|
158
|
+
*/
|
|
159
|
+
export function generateMotif(opts: GenerateMotifOptions): MotifCell {
|
|
160
|
+
const contour = opts.contour ?? 'arch';
|
|
161
|
+
const bars = Math.max(1, Math.trunc(opts.bars));
|
|
162
|
+
const totalBeats = bars * 4;
|
|
163
|
+
const noteCount = Math.max(3, bars * 2);
|
|
164
|
+
const beatsPerNote = totalBeats / noteCount;
|
|
165
|
+
const rng = mulberry32(opts.seed ?? 0);
|
|
166
|
+
const tonic = pitchClass(opts.key.rootPc) + 60;
|
|
167
|
+
const offsets = contourOffsets(contour, noteCount);
|
|
168
|
+
|
|
169
|
+
const notes: MotifNote[] = [];
|
|
170
|
+
for (let i = 0; i < noteCount; i += 1) {
|
|
171
|
+
const jitter = rng() < 0.25 ? 1 : 0;
|
|
172
|
+
let pitch = stepDiatonic(tonic, (offsets[i] ?? 0) + jitter, opts.key);
|
|
173
|
+
const startBeat = i * beatsPerNote;
|
|
174
|
+
if (opts.chord && startBeat % 4 === 0) {
|
|
175
|
+
pitch = nearestChordTone(pitch, opts.chord);
|
|
176
|
+
}
|
|
177
|
+
notes.push({ pitch, startBeat, durationBeat: beatsPerNote });
|
|
178
|
+
}
|
|
179
|
+
return { notes };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Apply a transformation to a motif cell.
|
|
184
|
+
*
|
|
185
|
+
* `invert` reflects pitches about the first note (chromatic, self-inverse);
|
|
186
|
+
* `retrograde` mirrors onsets about the cell span, preserving rests (self-inverse);
|
|
187
|
+
* `augment`/`diminish` scale time by `amount ?? 2` and its reciprocal;
|
|
188
|
+
* `transposeChromatic` adds `amount` semitones; `transposeDiatonic` shifts by
|
|
189
|
+
* `amount` scale degrees when a `key` is given, or — with no `key` — falls back
|
|
190
|
+
* to a chromatic shift of `amount` semitones; `sequence` appends a shifted copy
|
|
191
|
+
* (by `amount` diatonic degrees with a `key`, or by `amount` semitones without
|
|
192
|
+
* one, since it delegates to `transposeDiatonic`).
|
|
193
|
+
*
|
|
194
|
+
* @param cell The cell to transform.
|
|
195
|
+
* @param t The transformation.
|
|
196
|
+
* @param amount Optional parameter (semitones, degrees, or time factor).
|
|
197
|
+
* @param key Key context for the diatonic transforms; without it,
|
|
198
|
+
* `transposeDiatonic` and `sequence` shift chromatically by semitones.
|
|
199
|
+
* @returns The transformed cell.
|
|
200
|
+
*/
|
|
201
|
+
export function transformMotif(
|
|
202
|
+
cell: MotifCell,
|
|
203
|
+
t: MotifTransform,
|
|
204
|
+
amount?: number,
|
|
205
|
+
key?: KeyScale,
|
|
206
|
+
): MotifCell {
|
|
207
|
+
const notes = cell.notes;
|
|
208
|
+
switch (t) {
|
|
209
|
+
case 'transposeChromatic': {
|
|
210
|
+
const semis = amount ?? 0;
|
|
211
|
+
return { notes: notes.map((n) => ({ ...n, pitch: n.pitch + semis })) };
|
|
212
|
+
}
|
|
213
|
+
case 'transposeDiatonic': {
|
|
214
|
+
const degrees = amount ?? 1;
|
|
215
|
+
return {
|
|
216
|
+
notes: notes.map((n) => ({
|
|
217
|
+
...n,
|
|
218
|
+
// With a key, shift by scale degrees; without one, fall back to a
|
|
219
|
+
// chromatic shift of `degrees` semitones.
|
|
220
|
+
pitch: key ? stepDiatonic(n.pitch, degrees, key) : n.pitch + degrees,
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
case 'invert': {
|
|
225
|
+
const pivot = notes[0]?.pitch ?? 0;
|
|
226
|
+
return { notes: notes.map((n) => ({ ...n, pitch: 2 * pivot - n.pitch })) };
|
|
227
|
+
}
|
|
228
|
+
case 'retrograde': {
|
|
229
|
+
if (notes.length === 0) {
|
|
230
|
+
return { notes: [] };
|
|
231
|
+
}
|
|
232
|
+
const start = cellOrigin(notes);
|
|
233
|
+
const end = Math.max(...notes.map((n) => n.startBeat + n.durationBeat));
|
|
234
|
+
return {
|
|
235
|
+
notes: notes.map((n) => ({
|
|
236
|
+
pitch: n.pitch,
|
|
237
|
+
startBeat: start + (end - (n.startBeat + n.durationBeat)),
|
|
238
|
+
durationBeat: n.durationBeat,
|
|
239
|
+
})),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
case 'augment':
|
|
243
|
+
return scaleTime(cell, amount ?? 2);
|
|
244
|
+
case 'diminish':
|
|
245
|
+
return scaleTime(cell, 1 / (amount ?? 2));
|
|
246
|
+
case 'sequence': {
|
|
247
|
+
const degrees = amount ?? 2;
|
|
248
|
+
const span = cellSpan(cell);
|
|
249
|
+
// Delegates to transposeDiatonic: a diatonic shift when `key` is given,
|
|
250
|
+
// otherwise a chromatic shift of `degrees` semitones.
|
|
251
|
+
const copy = transformMotif(cell, 'transposeDiatonic', degrees, key);
|
|
252
|
+
const shifted = copy.notes.map((n) => ({ ...n, startBeat: n.startBeat + span }));
|
|
253
|
+
return { notes: [...clone(cell).notes, ...shifted] };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Scale a cell's timing about its origin by `factor`. */
|
|
259
|
+
function scaleTime(cell: MotifCell, factor: number): MotifCell {
|
|
260
|
+
const origin = cellOrigin(cell.notes);
|
|
261
|
+
return {
|
|
262
|
+
notes: cell.notes.map((n) => ({
|
|
263
|
+
pitch: n.pitch,
|
|
264
|
+
startBeat: origin + (n.startBeat - origin) * factor,
|
|
265
|
+
durationBeat: n.durationBeat * factor,
|
|
266
|
+
})),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Lay a motif across `bars` and snap it to a chord timeline.
|
|
272
|
+
*
|
|
273
|
+
* The cell is tiled back-to-back to fill the requested span; each note is then
|
|
274
|
+
* pulled to the nearest chord tone of the segment sounding at its onset, so the
|
|
275
|
+
* developed line spells the underlying harmony.
|
|
276
|
+
*
|
|
277
|
+
* @param cell The source motif.
|
|
278
|
+
* @param timeline Chord segments to snap against.
|
|
279
|
+
* @param key Key context (used to keep snapped pitches sensible).
|
|
280
|
+
* @param bars Number of four-beat bars to fill.
|
|
281
|
+
* @returns The developed, harmony-aware cell.
|
|
282
|
+
*/
|
|
283
|
+
export function developMotif(
|
|
284
|
+
cell: MotifCell,
|
|
285
|
+
timeline: ChordTimeline,
|
|
286
|
+
key: KeyScale,
|
|
287
|
+
bars: number,
|
|
288
|
+
): MotifCell {
|
|
289
|
+
const span = cellSpan(cell);
|
|
290
|
+
const totalBeats = Math.max(1, Math.trunc(bars)) * 4;
|
|
291
|
+
const origin = cellOrigin(cell.notes);
|
|
292
|
+
const out: MotifNote[] = [];
|
|
293
|
+
|
|
294
|
+
if (span <= 0) {
|
|
295
|
+
return clone(cell);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Clamp a degenerate near-zero span so the tile count stays finite, and step
|
|
299
|
+
// the offset multiplicatively so rounding error cannot accumulate over tiles.
|
|
300
|
+
const tileSpan = Math.max(span, MIN_TILE_SPAN);
|
|
301
|
+
const tileCount = Math.ceil(totalBeats / tileSpan);
|
|
302
|
+
for (let k = 0; k < tileCount; k += 1) {
|
|
303
|
+
const offset = k * tileSpan;
|
|
304
|
+
for (const n of cell.notes) {
|
|
305
|
+
const startBeat = offset + (n.startBeat - origin);
|
|
306
|
+
if (startBeat >= totalBeats) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const chord = timeline.at(startBeat);
|
|
310
|
+
let pitch = n.pitch;
|
|
311
|
+
if (chord) {
|
|
312
|
+
const isMember = chordPitchClasses(chord).includes(pitchClass(pitch));
|
|
313
|
+
pitch = isMember ? pitch : nearestChordTone(pitch, chord);
|
|
314
|
+
} else {
|
|
315
|
+
pitch = nearestScaleTone(pitch, key);
|
|
316
|
+
}
|
|
317
|
+
out.push({ pitch, startBeat, durationBeat: n.durationBeat });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { notes: out };
|
|
321
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitch spelling: letter-name notes, enharmonic-aware conversion, and spelled
|
|
3
|
+
* intervals.
|
|
4
|
+
*
|
|
5
|
+
* The rest of the library works in pitch classes (0..11), which cannot tell a
|
|
6
|
+
* G# from an Ab or an augmented fourth from a diminished fifth. This module adds
|
|
7
|
+
* an explicit spelling layer — a note is a diatonic letter plus a chromatic
|
|
8
|
+
* alteration — so interval quality (P/M/m/A/d) and enharmonic identity are
|
|
9
|
+
* preserved.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Semitone offset of each natural letter above C: C D E F G A B. */
|
|
13
|
+
const LETTER_SEMITONES = [0, 2, 4, 5, 7, 9, 11] as const;
|
|
14
|
+
|
|
15
|
+
/** Letter glyphs indexed by letter number (0 = C .. 6 = B). */
|
|
16
|
+
const LETTER_NAMES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A spelled note: a diatonic letter, a chromatic alteration, and an optional
|
|
20
|
+
* octave.
|
|
21
|
+
*
|
|
22
|
+
* - `letter` is 0..6 for C..B.
|
|
23
|
+
* - `alter` is the number of semitones of alteration: -2 double-flat, -1 flat,
|
|
24
|
+
* 0 natural, +1 sharp, +2 double-sharp.
|
|
25
|
+
* - `octave` follows scientific pitch notation (middle C = C4 = MIDI 60). When
|
|
26
|
+
* absent, the note denotes a bare pitch class.
|
|
27
|
+
*/
|
|
28
|
+
export type Note = {
|
|
29
|
+
letter: number;
|
|
30
|
+
alter: number;
|
|
31
|
+
octave?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A spelled interval: a diatonic number, a quality label, and its semitone span. */
|
|
35
|
+
export type SpelledInterval = {
|
|
36
|
+
/** Diatonic size: 1 = unison, 2 = second, ... 8 = octave, and beyond. */
|
|
37
|
+
number: number;
|
|
38
|
+
/** Quality label: 'P', 'M', 'm', or repeated 'A'/'d' for (multiply) aug/dim. */
|
|
39
|
+
quality: string;
|
|
40
|
+
/** Signed semitone distance from the first note to the second. */
|
|
41
|
+
semitones: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Reduce any integer to a pitch class in [0, 11]. */
|
|
45
|
+
function mod12(n: number): number {
|
|
46
|
+
return ((n % 12) + 12) % 12;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reduce any integer to [0, 7). */
|
|
50
|
+
function mod7(n: number): number {
|
|
51
|
+
return ((n % 7) + 7) % 7;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse scientific pitch notation into a {@link Note}.
|
|
56
|
+
*
|
|
57
|
+
* Accepts a letter (case-insensitive) followed by any number of same-direction
|
|
58
|
+
* accidentals and an optional octave, e.g. `"C"`, `"C#4"`, `"Bb"`, `"F##3"`,
|
|
59
|
+
* `"Ebb2"`. Both `#` and `x` (double-sharp) are accepted for sharps; `b` for
|
|
60
|
+
* flats. Mixing sharps and flats (e.g. `"C#b"`) is rejected.
|
|
61
|
+
*
|
|
62
|
+
* @param text The note text.
|
|
63
|
+
* @returns The parsed note.
|
|
64
|
+
* @throws If the text is not a valid note.
|
|
65
|
+
*/
|
|
66
|
+
export function parseNote(text: string): Note {
|
|
67
|
+
const match = /^([A-Ga-g])([#x]*|b*)(-?\d+)?$/.exec(text.trim());
|
|
68
|
+
if (!match) {
|
|
69
|
+
throw new Error(`Invalid note: ${text}`);
|
|
70
|
+
}
|
|
71
|
+
const letterGlyph = (match[1] ?? '').toUpperCase();
|
|
72
|
+
const letter = LETTER_NAMES.indexOf(letterGlyph as (typeof LETTER_NAMES)[number]);
|
|
73
|
+
let alter = 0;
|
|
74
|
+
for (const ch of match[2] ?? '') {
|
|
75
|
+
if (ch === '#') {
|
|
76
|
+
alter += 1;
|
|
77
|
+
} else if (ch === 'x') {
|
|
78
|
+
alter += 2;
|
|
79
|
+
} else if (ch === 'b') {
|
|
80
|
+
alter -= 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const note: Note = { letter, alter };
|
|
84
|
+
if (match[3] !== undefined) {
|
|
85
|
+
note.octave = Number.parseInt(match[3], 10);
|
|
86
|
+
}
|
|
87
|
+
return note;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Render an alteration as accidental glyphs (`##`, `b`, empty for natural). */
|
|
91
|
+
function formatAlter(alter: number): string {
|
|
92
|
+
if (alter > 0) {
|
|
93
|
+
return '#'.repeat(alter);
|
|
94
|
+
}
|
|
95
|
+
if (alter < 0) {
|
|
96
|
+
return 'b'.repeat(-alter);
|
|
97
|
+
}
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Render a {@link Note} as scientific pitch notation.
|
|
103
|
+
*
|
|
104
|
+
* @param note The note to format.
|
|
105
|
+
* @returns The note text, including the octave when present.
|
|
106
|
+
*/
|
|
107
|
+
export function formatNote(note: Note): string {
|
|
108
|
+
const glyph = LETTER_NAMES[mod7(note.letter)] ?? 'C';
|
|
109
|
+
const octave = note.octave === undefined ? '' : String(note.octave);
|
|
110
|
+
return `${glyph}${formatAlter(note.alter)}${octave}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pitch class (0..11) of a note, ignoring octave.
|
|
115
|
+
*
|
|
116
|
+
* @param note The note.
|
|
117
|
+
* @returns The pitch class.
|
|
118
|
+
*/
|
|
119
|
+
export function noteToPitchClass(note: Note): number {
|
|
120
|
+
const natural = LETTER_SEMITONES[mod7(note.letter)] ?? 0;
|
|
121
|
+
return mod12(natural + note.alter);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* MIDI number of a note (middle C = C4 = 60).
|
|
126
|
+
*
|
|
127
|
+
* @param note The note; must carry an octave.
|
|
128
|
+
* @returns The MIDI number.
|
|
129
|
+
* @throws If the note has no octave.
|
|
130
|
+
*/
|
|
131
|
+
export function noteToMidi(note: Note): number {
|
|
132
|
+
if (note.octave === undefined) {
|
|
133
|
+
throw new Error('noteToMidi requires an octave');
|
|
134
|
+
}
|
|
135
|
+
const natural = LETTER_SEMITONES[mod7(note.letter)] ?? 0;
|
|
136
|
+
return (note.octave + 1) * 12 + natural + note.alter;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Preferred spelling when naming a black key from a bare MIDI number. */
|
|
140
|
+
const SHARP_SPELLING: readonly [number, number][] = [
|
|
141
|
+
[0, 0],
|
|
142
|
+
[0, 1],
|
|
143
|
+
[1, 0],
|
|
144
|
+
[1, 1],
|
|
145
|
+
[2, 0],
|
|
146
|
+
[3, 0],
|
|
147
|
+
[3, 1],
|
|
148
|
+
[4, 0],
|
|
149
|
+
[4, 1],
|
|
150
|
+
[5, 0],
|
|
151
|
+
[5, 1],
|
|
152
|
+
[6, 0],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const FLAT_SPELLING: readonly [number, number][] = [
|
|
156
|
+
[0, 0],
|
|
157
|
+
[1, -1],
|
|
158
|
+
[1, 0],
|
|
159
|
+
[2, -1],
|
|
160
|
+
[2, 0],
|
|
161
|
+
[3, 0],
|
|
162
|
+
[4, -1],
|
|
163
|
+
[4, 0],
|
|
164
|
+
[5, -1],
|
|
165
|
+
[5, 0],
|
|
166
|
+
[6, -1],
|
|
167
|
+
[6, 0],
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Name a MIDI number as a {@link Note}, choosing sharp or flat spelling.
|
|
172
|
+
*
|
|
173
|
+
* @param midi The MIDI number.
|
|
174
|
+
* @param spelling Whether to prefer sharps or flats for black keys.
|
|
175
|
+
* @returns The spelled note, with octave.
|
|
176
|
+
*/
|
|
177
|
+
export function midiToNote(midi: number, spelling: 'sharp' | 'flat' = 'sharp'): Note {
|
|
178
|
+
const rounded = Math.round(midi);
|
|
179
|
+
const pc = mod12(rounded);
|
|
180
|
+
const octave = Math.floor(rounded / 12) - 1;
|
|
181
|
+
const table = spelling === 'sharp' ? SHARP_SPELLING : FLAT_SPELLING;
|
|
182
|
+
const entry = table[pc] ?? [0, 0];
|
|
183
|
+
return { letter: entry[0], alter: entry[1], octave };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Reference semitone span of a perfect/major simple interval by diatonic number. */
|
|
187
|
+
const SIMPLE_REFERENCE = [0, 0, 2, 4, 5, 7, 9, 11] as const;
|
|
188
|
+
|
|
189
|
+
/** Whether a simple diatonic number (1..7 within an octave) is a perfect kind. */
|
|
190
|
+
function isPerfectNumber(simpleNumber: number): boolean {
|
|
191
|
+
return simpleNumber === 1 || simpleNumber === 4 || simpleNumber === 5;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Quality label from a diatonic number and its actual semitone span. */
|
|
195
|
+
function qualityFromSpan(numberValue: number, semitones: number): string {
|
|
196
|
+
const octaves = Math.floor((numberValue - 1) / 7);
|
|
197
|
+
const simple = numberValue - 7 * octaves;
|
|
198
|
+
const reference = (SIMPLE_REFERENCE[simple] ?? 0) + 12 * octaves;
|
|
199
|
+
const delta = semitones - reference;
|
|
200
|
+
if (isPerfectNumber(simple)) {
|
|
201
|
+
if (delta === 0) {
|
|
202
|
+
return 'P';
|
|
203
|
+
}
|
|
204
|
+
return delta > 0 ? 'A'.repeat(delta) : 'd'.repeat(-delta);
|
|
205
|
+
}
|
|
206
|
+
if (delta === 0) {
|
|
207
|
+
return 'M';
|
|
208
|
+
}
|
|
209
|
+
if (delta === -1) {
|
|
210
|
+
return 'm';
|
|
211
|
+
}
|
|
212
|
+
return delta > 0 ? 'A'.repeat(delta) : 'd'.repeat(-delta - 1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Diatonic ladder index of a note (letter + 7 * octave when octave-bearing). */
|
|
216
|
+
function diatonicIndex(note: Note): number {
|
|
217
|
+
return mod7(note.letter) + 7 * (note.octave ?? 0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The spelled interval from one note to another.
|
|
222
|
+
*
|
|
223
|
+
* When both notes carry octaves, the interval reflects their full signed
|
|
224
|
+
* distance (so `C4 -> C5` is a perfect octave). When either lacks an octave the
|
|
225
|
+
* interval is measured within a single ascending octave from `a` to `b`.
|
|
226
|
+
*
|
|
227
|
+
* @param a The lower/first note.
|
|
228
|
+
* @param b The upper/second note.
|
|
229
|
+
* @returns The diatonic number, quality, and semitone span.
|
|
230
|
+
*/
|
|
231
|
+
export function spelledInterval(a: Note, b: Note): SpelledInterval {
|
|
232
|
+
const octaved = a.octave !== undefined && b.octave !== undefined;
|
|
233
|
+
let letterSteps: number;
|
|
234
|
+
let semitones: number;
|
|
235
|
+
if (octaved) {
|
|
236
|
+
letterSteps = diatonicIndex(b) - diatonicIndex(a);
|
|
237
|
+
// Signed pitch distance from a to b, so a note below a yields a negative span.
|
|
238
|
+
semitones = noteToMidi(b) - noteToMidi(a);
|
|
239
|
+
} else {
|
|
240
|
+
letterSteps = mod7(b.letter - a.letter);
|
|
241
|
+
let rawSemis = mod12(noteToPitchClass(b) - noteToPitchClass(a));
|
|
242
|
+
// Lift the chromatic span into the octave nearest the diatonic reference so
|
|
243
|
+
// wraparound intervals (e.g. Ab -> G# = augmented seventh) stay consistent
|
|
244
|
+
// with the ascending diatonic number instead of collapsing modulo 12.
|
|
245
|
+
const reference = SIMPLE_REFERENCE[letterSteps + 1] ?? 0;
|
|
246
|
+
if (reference - rawSemis > 6) {
|
|
247
|
+
rawSemis += 12;
|
|
248
|
+
}
|
|
249
|
+
semitones = rawSemis;
|
|
250
|
+
}
|
|
251
|
+
const absSteps = Math.abs(letterSteps);
|
|
252
|
+
const number = absSteps + 1;
|
|
253
|
+
// Quality comes from the absolute chromatic span; semitones keeps its sign.
|
|
254
|
+
const quality = qualityFromSpan(number, Math.abs(semitones));
|
|
255
|
+
return { number, quality, semitones };
|
|
256
|
+
}
|