@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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +291 -0
  3. package/dist/index.cjs +6465 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2621 -0
  6. package/dist/index.d.ts +2621 -0
  7. package/dist/index.js +6297 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +71 -0
  10. package/src/analysis/index.ts +261 -0
  11. package/src/arrange/index.ts +409 -0
  12. package/src/bass/index.ts +301 -0
  13. package/src/chord/index.ts +292 -0
  14. package/src/chordscale/index.ts +316 -0
  15. package/src/countermelody/index.ts +349 -0
  16. package/src/counterpoint/index.ts +209 -0
  17. package/src/detect/index.ts +176 -0
  18. package/src/drums/beat.ts +266 -0
  19. package/src/drums/euclid.ts +58 -0
  20. package/src/drums/fills.ts +345 -0
  21. package/src/drums/ghost.ts +119 -0
  22. package/src/drums/hihat.ts +285 -0
  23. package/src/drums/hit.ts +31 -0
  24. package/src/drums/index.ts +211 -0
  25. package/src/drums/internal.ts +251 -0
  26. package/src/drums/kick.ts +160 -0
  27. package/src/drums/percussion.ts +121 -0
  28. package/src/drums/rng.ts +7 -0
  29. package/src/drums/swing.ts +66 -0
  30. package/src/functional/index.ts +602 -0
  31. package/src/groove/index.ts +230 -0
  32. package/src/harmonize/index.ts +367 -0
  33. package/src/harmony/index.ts +75 -0
  34. package/src/index.ts +233 -0
  35. package/src/interval/index.ts +67 -0
  36. package/src/meter/index.ts +179 -0
  37. package/src/model/index.ts +957 -0
  38. package/src/motif/index.ts +321 -0
  39. package/src/pitch/index.ts +256 -0
  40. package/src/progression/index.ts +336 -0
  41. package/src/random/index.ts +41 -0
  42. package/src/reharmony/index.ts +299 -0
  43. package/src/rhythm/index.ts +131 -0
  44. package/src/safety/index.ts +372 -0
  45. package/src/scale/index.ts +215 -0
  46. package/src/spelling/index.ts +164 -0
  47. package/src/symbol/index.ts +213 -0
  48. package/src/timeline/index.ts +352 -0
  49. package/src/tuning/index.ts +129 -0
  50. package/src/types.ts +29 -0
  51. package/src/voicing/index.ts +523 -0
  52. package/tsconfig.json +23 -0
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Functional harmony: Roman-numeral parsing and formatting, harmonic function
3
+ * (tonic / subdominant / dominant), and cadence detection.
4
+ *
5
+ * Roots are pitch classes measured against the key tonic, so borrowed and
6
+ * chromatic chords are handled by their semitone offset rather than requiring a
7
+ * spelled key signature.
8
+ */
9
+
10
+ import type { Chord, ChordQuality } from '../chord/index.js';
11
+ import { chordPitchClasses, makeChord } from '../chord/index.js';
12
+ import {
13
+ HARMONIC_MINOR_MASK,
14
+ isScaleTone,
15
+ MAJOR_MASK,
16
+ majorKey,
17
+ NATURAL_MINOR_MASK,
18
+ scaleTonesInDegreeOrder,
19
+ } from '../scale/index.js';
20
+ import type { KeyScale } from '../types.js';
21
+
22
+ /** The three broad harmonic functions of tonal music. */
23
+ export type HarmonicFunction = 'tonic' | 'subdominant' | 'dominant';
24
+
25
+ /** A recognized cadence type, or null when a chord pair forms none. */
26
+ export type Cadence = 'authentic' | 'plagal' | 'half' | 'deceptive' | null;
27
+
28
+ /** Roman numeral glyphs indexed by degree number - 1. */
29
+ const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'] as const;
30
+
31
+ /** Roman glyph -> degree number (1..7). */
32
+ const ROMAN_TO_DEGREE: Record<string, number> = {
33
+ I: 1,
34
+ II: 2,
35
+ III: 3,
36
+ IV: 4,
37
+ V: 5,
38
+ VI: 6,
39
+ VII: 7,
40
+ };
41
+
42
+ /** Major-key Roman spelling for each semitone offset above the tonic. */
43
+ const OFFSET_SPELLING: readonly [number, string][] = [
44
+ [1, ''],
45
+ [2, 'b'],
46
+ [2, ''],
47
+ [3, 'b'],
48
+ [3, ''],
49
+ [4, ''],
50
+ [4, '#'],
51
+ [5, ''],
52
+ [6, 'b'],
53
+ [6, ''],
54
+ [7, 'b'],
55
+ [7, ''],
56
+ ];
57
+
58
+ /** Harmonic function of each semitone offset above the tonic (major context). */
59
+ const FUNCTION_BY_OFFSET: readonly HarmonicFunction[] = [
60
+ 'tonic', // 0 I
61
+ 'subdominant', // 1 bII (Neapolitan)
62
+ 'subdominant', // 2 ii
63
+ 'tonic', // 3 bIII
64
+ 'tonic', // 4 iii
65
+ 'subdominant', // 5 IV
66
+ 'dominant', // 6 #IV / bV
67
+ 'dominant', // 7 V
68
+ 'subdominant', // 8 bVI
69
+ 'tonic', // 9 vi
70
+ 'subdominant', // 10 bVII
71
+ 'dominant', // 11 vii
72
+ ];
73
+
74
+ function mod12(n: number): number {
75
+ return ((n % 12) + 12) % 12;
76
+ }
77
+
78
+ /** Whether a key's scale has a minor third and no major third (a minor key). */
79
+ export function isMinorKey(key: KeyScale): boolean {
80
+ const hasMinorThird = (key.modeMask12 >> 3) & 1;
81
+ const hasMajorThird = (key.modeMask12 >> 4) & 1;
82
+ return Boolean(hasMinorThird) && !hasMajorThird;
83
+ }
84
+
85
+ /** Diatonic pitch class of a 1-based scale degree in a key. */
86
+ function degreeRootPc(degreeNumber: number, key: KeyScale): number {
87
+ const tones = scaleTonesInDegreeOrder(key);
88
+ if (tones.length === 0) {
89
+ return mod12(key.rootPc);
90
+ }
91
+ return tones[(degreeNumber - 1) % tones.length] ?? mod12(key.rootPc);
92
+ }
93
+
94
+ /** Choose a seventh-chord quality from a triad label and the numeral case. */
95
+ function seventhQuality(base: 'maj' | 'min' | 'dim' | 'aug', halfDim: boolean): ChordQuality {
96
+ if (halfDim) {
97
+ return 'm7b5';
98
+ }
99
+ if (base === 'dim') {
100
+ return 'dim7';
101
+ }
102
+ if (base === 'aug') {
103
+ return 'aug7';
104
+ }
105
+ return base === 'maj' ? 'dom7' : 'min7';
106
+ }
107
+
108
+ /** Figure strings that denote a recognized figured-bass inversion. */
109
+ const INVERSION_FIGURES = new Set(['6', '64', '65', '43', '42', '2', '7']);
110
+
111
+ /** Figure strings that denote a fixed root-position extension quality. */
112
+ const EXTENSION_FIGURES: Record<string, ChordQuality> = {
113
+ '9': 'dom9',
114
+ '11': '11',
115
+ '13': '13',
116
+ };
117
+
118
+ /** Read figured-bass digits into a chord inversion and whether a seventh is implied. */
119
+ function parseInversion(figures: string): { inversion: number; seventh: boolean } {
120
+ if (figures === '65') {
121
+ return { inversion: 1, seventh: true };
122
+ }
123
+ if (figures === '43') {
124
+ return { inversion: 2, seventh: true };
125
+ }
126
+ if (figures === '42' || figures === '2') {
127
+ return { inversion: 3, seventh: true };
128
+ }
129
+ if (figures === '7') {
130
+ return { inversion: 0, seventh: true };
131
+ }
132
+ if (figures === '64') {
133
+ return { inversion: 2, seventh: false };
134
+ }
135
+ if (figures === '6') {
136
+ return { inversion: 1, seventh: false };
137
+ }
138
+ return { inversion: 0, seventh: false };
139
+ }
140
+
141
+ /** Parse a Roman numeral (no secondary '/') into a root, quality, and inversion. */
142
+ function parseSimpleRoman(
143
+ text: string,
144
+ key: KeyScale,
145
+ ): { rootPc: number; quality: ChordQuality; inversion: number } {
146
+ const match = /^([b#]?)([iIvV]+)(.*)$/.exec(text.trim());
147
+ if (!match) {
148
+ throw new Error(`Invalid Roman numeral: ${text}`);
149
+ }
150
+ const accidental = match[1] === 'b' ? -1 : match[1] === '#' ? 1 : 0;
151
+ const glyph = (match[2] ?? '').toUpperCase();
152
+ const degreeNumber = ROMAN_TO_DEGREE[glyph];
153
+ if (degreeNumber === undefined) {
154
+ throw new Error(`Invalid Roman numeral: ${text}`);
155
+ }
156
+ const isUpper = (match[2] ?? '')[0] === (match[2] ?? '')[0]?.toUpperCase();
157
+ const suffix = match[3] ?? '';
158
+ const rootPc = mod12(degreeRootPc(degreeNumber, key) + accidental);
159
+
160
+ const isDim = /o|°|dim/.test(suffix);
161
+ const isAug = /\+|aug/.test(suffix);
162
+ const isHalfDim = /ø/.test(suffix);
163
+ const explicitMaj7 = /maj7|M7/.test(suffix);
164
+ const figures = suffix.replace(/maj7|M7/g, '').replace(/[^0-9]/g, '');
165
+
166
+ // Extension figures (9/11/13) denote a fixed root-position tension quality,
167
+ // not a figured-bass inversion, so they short-circuit the case/quality logic.
168
+ const extension = EXTENSION_FIGURES[figures];
169
+ if (extension !== undefined) {
170
+ return { rootPc, quality: extension, inversion: 0 };
171
+ }
172
+ // Reject figure strings that are neither a known figured-bass inversion nor a
173
+ // supported extension rather than silently degrading to a root triad.
174
+ if (figures !== '' && !INVERSION_FIGURES.has(figures)) {
175
+ throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
176
+ }
177
+
178
+ const { inversion, seventh } = parseInversion(figures);
179
+ // The ø glyph is the half-diminished seventh by definition (there is no
180
+ // half-diminished triad), so it always implies a seventh.
181
+ const hasSeventh = explicitMaj7 || seventh || isHalfDim;
182
+
183
+ let base: 'maj' | 'min' | 'dim' | 'aug';
184
+ if (isDim || isHalfDim) {
185
+ base = 'dim';
186
+ } else if (isAug) {
187
+ base = 'aug';
188
+ } else {
189
+ base = isUpper ? 'maj' : 'min';
190
+ }
191
+
192
+ let quality: ChordQuality;
193
+ if (explicitMaj7) {
194
+ quality = base === 'min' ? 'minMaj7' : 'maj7';
195
+ } else if (hasSeventh) {
196
+ quality = seventhQuality(base, isHalfDim);
197
+ } else if (base === 'dim') {
198
+ quality = 'dim';
199
+ } else if (base === 'aug') {
200
+ quality = 'aug';
201
+ } else {
202
+ quality = base;
203
+ }
204
+ return { rootPc, quality, inversion };
205
+ }
206
+
207
+ /** Build a chord from a parsed Roman numeral, attaching a bass for inversions. */
208
+ function chordFromParsed(parsed: {
209
+ rootPc: number;
210
+ quality: ChordQuality;
211
+ inversion: number;
212
+ }): Chord {
213
+ const chord = makeChord(parsed.rootPc, parsed.quality);
214
+ if (parsed.inversion > 0 && parsed.inversion < chord.intervals.length) {
215
+ chord.bassPc = mod12(parsed.rootPc + (chord.intervals[parsed.inversion] ?? 0));
216
+ }
217
+ return chord;
218
+ }
219
+
220
+ /**
221
+ * Build the chord denoted by a Roman numeral in a key.
222
+ *
223
+ * Supports accidentals (`bVII`, `#iv`), case-based triad quality, the `o`/`ø`/`+`
224
+ * suffixes, sevenths (`V7`, `viio7`, `Imaj7`), figured-bass inversions
225
+ * (`V6`, `V64`, `V65`, `V43`, `V42`), and applied/secondary chords via a slash
226
+ * (`V7/V`, `viio/ii`). The target after the slash is read as a scale degree
227
+ * whose root becomes a local major tonic for the applied chord. Inverted chords
228
+ * carry a `bassPc`.
229
+ *
230
+ * The `ø` glyph always denotes the half-diminished seventh (`iiø` == `iiø7` ==
231
+ * `m7b5`), since no half-diminished triad exists. Root-position extension
232
+ * figures map to fixed qualities (`V9` -> `dom9`, `V11` -> `11`, `V13` -> `13`).
233
+ * A figure string that is neither a recognized inversion nor a supported
234
+ * extension throws rather than silently degrading to a triad.
235
+ *
236
+ * @param text The Roman numeral.
237
+ * @param key The prevailing key.
238
+ * @returns The chord.
239
+ */
240
+ export function romanToChord(text: string, key: KeyScale): Chord {
241
+ const trimmed = text.trim();
242
+ const slash = trimmed.indexOf('/');
243
+ if (slash >= 0) {
244
+ const applied = trimmed.slice(0, slash);
245
+ const target = trimmed.slice(slash + 1);
246
+ const targetRoot = parseSimpleRoman(target, key).rootPc;
247
+ const localKey = majorKey(targetRoot);
248
+ return chordFromParsed(parseSimpleRoman(applied, localKey));
249
+ }
250
+ return chordFromParsed(parseSimpleRoman(trimmed, key));
251
+ }
252
+
253
+ /** Case and suffix for rendering a chord quality as a Roman numeral. */
254
+ function romanStyle(quality: ChordQuality): { lower: boolean; suffix: string } {
255
+ switch (quality) {
256
+ case 'min':
257
+ return { lower: true, suffix: '' };
258
+ case 'dim':
259
+ return { lower: true, suffix: 'o' };
260
+ case 'aug':
261
+ return { lower: false, suffix: '+' };
262
+ case 'maj7':
263
+ return { lower: false, suffix: 'maj7' };
264
+ case 'dom7':
265
+ return { lower: false, suffix: '7' };
266
+ case 'min7':
267
+ return { lower: true, suffix: '7' };
268
+ case 'm7b5':
269
+ return { lower: true, suffix: 'ø7' };
270
+ case 'dim7':
271
+ return { lower: true, suffix: 'o7' };
272
+ case 'minMaj7':
273
+ return { lower: true, suffix: 'maj7' };
274
+ case 'maj':
275
+ return { lower: false, suffix: '' };
276
+ default:
277
+ return { lower: false, suffix: quality };
278
+ }
279
+ }
280
+
281
+ /** Choose the degree number and accidental to spell a root as a Roman numeral. */
282
+ function romanSpelling(
283
+ rootPc: number,
284
+ key: KeyScale,
285
+ ): { degreeNumber: number; accidental: string } {
286
+ const tones = scaleTonesInDegreeOrder(key);
287
+ const diatonic = tones.indexOf(mod12(rootPc));
288
+ if (diatonic >= 0) {
289
+ return { degreeNumber: diatonic + 1, accidental: '' };
290
+ }
291
+ // Chromatic root: prefer a flat of the diatonic degree a semitone above it,
292
+ // then a sharp of the degree a semitone below. Degree 1 (the tonic) and
293
+ // degree 5 (the dominant) are skipped in the flat pass, so a raised leading
294
+ // tone spells as `#vii` rather than a flat tonic `bI`, and the tritone above
295
+ // the tonic spells as `#iv` rather than a flat fifth `bV` (matching the
296
+ // conventional `OFFSET_SPELLING` for offset 6).
297
+ for (let i = 0; i < tones.length; i += 1) {
298
+ const degreeNumber = i + 1;
299
+ if (degreeNumber === 1 || degreeNumber === 5) {
300
+ continue;
301
+ }
302
+ if (mod12((tones[i] ?? 0) - 1) === mod12(rootPc)) {
303
+ return { degreeNumber, accidental: 'b' };
304
+ }
305
+ }
306
+ for (let i = 0; i < tones.length; i += 1) {
307
+ if (mod12((tones[i] ?? 0) + 1) === mod12(rootPc)) {
308
+ return { degreeNumber: i + 1, accidental: '#' };
309
+ }
310
+ }
311
+ const [degreeNumber, accidental] = OFFSET_SPELLING[mod12(rootPc - key.rootPc)] ?? [1, ''];
312
+ return { degreeNumber, accidental };
313
+ }
314
+
315
+ /** Quality marker (without the seventh digit) used when a figure carries the 7. */
316
+ function baseMarker(quality: ChordQuality): string {
317
+ if (quality === 'dim' || quality === 'dim7') {
318
+ return 'o';
319
+ }
320
+ if (quality === 'm7b5') {
321
+ return 'ø';
322
+ }
323
+ if (quality === 'aug' || quality === 'aug7') {
324
+ return '+';
325
+ }
326
+ if (quality === 'maj7' || quality === 'minMaj7') {
327
+ return 'maj7';
328
+ }
329
+ return '';
330
+ }
331
+
332
+ const TRIAD_FIGURES: Record<number, string> = { 1: '6', 2: '64' };
333
+ const SEVENTH_FIGURES: Record<number, string> = { 1: '65', 2: '43', 3: '42' };
334
+
335
+ /**
336
+ * Render a chord as a Roman numeral relative to a key.
337
+ *
338
+ * Diatonic roots take their scale-degree numeral directly, so numerals are
339
+ * correct in both major and minor keys (and any custom scale). The quality
340
+ * selects the case and suffix; chromatic roots receive a flat/sharp spelling by
341
+ * convention. When the chord carries a `bassPc` on a chord tone, a figured-bass
342
+ * inversion (`6`, `64`, `65`, `43`, `42`) is emitted.
343
+ *
344
+ * @param chord The chord to name.
345
+ * @param key The prevailing key.
346
+ * @returns The Roman numeral string.
347
+ */
348
+ export function chordToRoman(chord: Chord, key: KeyScale): string {
349
+ const { degreeNumber, accidental } = romanSpelling(chord.rootPc, key);
350
+ const { lower, suffix } = romanStyle(chord.quality);
351
+ const numeral: string = ROMAN[degreeNumber - 1] ?? 'I';
352
+ const cased = lower ? numeral.toLowerCase() : numeral;
353
+
354
+ let inversion = 0;
355
+ if (chord.bassPc !== undefined) {
356
+ const idx = chord.intervals.findIndex((iv) => mod12(chord.rootPc + iv) === chord.bassPc);
357
+ if (idx > 0) {
358
+ inversion = idx;
359
+ }
360
+ }
361
+ if (inversion > 0) {
362
+ const isSeventh = chord.intervals.length >= 4;
363
+ const figure = (isSeventh ? SEVENTH_FIGURES : TRIAD_FIGURES)[inversion];
364
+ if (figure !== undefined) {
365
+ return `${accidental}${cased}${baseMarker(chord.quality)}${figure}`;
366
+ }
367
+ // The bass falls on a tension beyond the seventh (e.g. a ninth in the bass);
368
+ // no figured-bass symbol exists for it, so fall back to root-position
369
+ // rendering with the quality suffix rather than emitting a bare numeral.
370
+ }
371
+ return `${accidental}${cased}${suffix}`;
372
+ }
373
+
374
+ /**
375
+ * The harmonic function of a chord in a key, from its root's offset above the
376
+ * tonic. The mapping follows common-practice major-key function and is a useful
377
+ * approximation in minor and for borrowed chords.
378
+ *
379
+ * @param chord The chord.
380
+ * @param key The prevailing key.
381
+ * @returns The harmonic function.
382
+ */
383
+ export function functionOf(chord: Chord, key: KeyScale): HarmonicFunction {
384
+ return FUNCTION_BY_OFFSET[mod12(chord.rootPc - key.rootPc)] ?? 'tonic';
385
+ }
386
+
387
+ /** The origin of a recognized non-diatonic chord, or null when none applies. */
388
+ export type BorrowedSource = 'parallel-minor' | 'parallel-major' | 'neapolitan' | null;
389
+
390
+ /** The result of {@link analyzeChord}: function, borrowing, and Roman numeral. */
391
+ export type ChordAnalysis = {
392
+ function: HarmonicFunction;
393
+ borrowed: boolean;
394
+ source: BorrowedSource;
395
+ roman: string;
396
+ };
397
+
398
+ /**
399
+ * Whether every pitch class of a chord belongs to the key's scale.
400
+ *
401
+ * The test is strict against the key's own mode mask: in a natural-minor key
402
+ * the harmonic-minor dominant (major V) is *not* diatonic, since the raised
403
+ * leading tone lies outside the mask. Borrowing predicates treat that case as
404
+ * an in-key alteration separately (see {@link isBorrowedChord}).
405
+ *
406
+ * @param chord The chord to test.
407
+ * @param key The prevailing key.
408
+ * @returns True if all chord pitch classes are scale tones.
409
+ */
410
+ export function isDiatonic(chord: Chord, key: KeyScale): boolean {
411
+ return chordPitchClasses(chord).every((pc) => isScaleTone(pc, key));
412
+ }
413
+
414
+ /**
415
+ * The parallel key: same tonic, opposite mode.
416
+ *
417
+ * A key with a minor third (natural/harmonic/melodic minor, dorian, phrygian)
418
+ * maps to the parallel major; any other key maps to the parallel natural minor.
419
+ *
420
+ * @param key The key to mirror.
421
+ * @returns The parallel major or natural-minor key on the same tonic.
422
+ */
423
+ export function parallelKey(key: KeyScale): KeyScale {
424
+ return {
425
+ rootPc: mod12(key.rootPc),
426
+ modeMask12: isMinorKey(key) ? MAJOR_MASK : NATURAL_MINOR_MASK,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * In a minor key, whether a non-diatonic chord is explained by the harmonic
432
+ * minor scale on the same tonic (major V, V7, the raised-leading-tone viio).
433
+ * Such chords are in-key chromatic alterations, not modal interchange.
434
+ */
435
+ function isHarmonicMinorAlteration(chord: Chord, key: KeyScale): boolean {
436
+ return (
437
+ isMinorKey(key) &&
438
+ isDiatonic(chord, { rootPc: mod12(key.rootPc), modeMask12: HARMONIC_MINOR_MASK })
439
+ );
440
+ }
441
+
442
+ /** Whether a chord is the Neapolitan: a major triad on the flat second degree. */
443
+ function isNeapolitan(chord: Chord, key: KeyScale): boolean {
444
+ return mod12(chord.rootPc - key.rootPc) === 1 && chord.quality === 'maj';
445
+ }
446
+
447
+ /**
448
+ * Whether a chord is borrowed from the parallel mode (modal interchange).
449
+ *
450
+ * True when the chord is not diatonic to `key` but is diatonic to its
451
+ * {@link parallelKey} — e.g. iv, bVI, or bVII in a major key, or the Picardy
452
+ * tonic and major IV in a minor key. Two non-diatonic families are excluded:
453
+ * chords diatonic to neither mode (they are chromatic, not borrowed), and, in
454
+ * a minor key, chords explained by the harmonic minor scale (the major
455
+ * dominant and raised-leading-tone chords), which are in-key alterations
456
+ * rather than interchange even though they happen to fit the parallel major.
457
+ *
458
+ * @param chord The chord to test.
459
+ * @param key The prevailing key.
460
+ * @returns True if the chord is borrowed from the parallel mode.
461
+ */
462
+ export function isBorrowedChord(chord: Chord, key: KeyScale): boolean {
463
+ if (isDiatonic(chord, key)) {
464
+ return false;
465
+ }
466
+ if (isHarmonicMinorAlteration(chord, key)) {
467
+ return false;
468
+ }
469
+ return isDiatonic(chord, parallelKey(key));
470
+ }
471
+
472
+ /**
473
+ * Identify where a non-diatonic chord comes from.
474
+ *
475
+ * The Neapolitan (major triad on b2) is recognized first, since it sits
476
+ * outside both parallel modes; parallel-mode borrowing follows the
477
+ * {@link isBorrowedChord} rules. Diatonic chords, harmonic-minor alterations,
478
+ * and unrecognized chromatic chords all yield null.
479
+ *
480
+ * @param chord The chord to classify.
481
+ * @param key The prevailing key.
482
+ * @returns The borrowing source, or null.
483
+ */
484
+ export function borrowedSource(chord: Chord, key: KeyScale): BorrowedSource {
485
+ if (isDiatonic(chord, key)) {
486
+ return null;
487
+ }
488
+ if (isNeapolitan(chord, key)) {
489
+ return 'neapolitan';
490
+ }
491
+ if (isBorrowedChord(chord, key)) {
492
+ return isMinorKey(key) ? 'parallel-major' : 'parallel-minor';
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /** Diminished-family qualities: diminished triad, dim7, half-diminished. */
498
+ function isDiminishedQuality(quality: ChordQuality): boolean {
499
+ return quality === 'dim' || quality === 'dim7' || quality === 'm7b5';
500
+ }
501
+
502
+ /** Whether the chord's interval template carries a major third above the root. */
503
+ function hasMajorThird(chord: Chord): boolean {
504
+ return chord.intervals.some((interval) => mod12(interval) === 4);
505
+ }
506
+
507
+ /**
508
+ * Quality-aware harmonic function, refining the offset table where chord
509
+ * quality disambiguates: diminished chords on the leading tone or the raised
510
+ * subdominant resolve by semitone and act as dominants; the Neapolitan and the
511
+ * major-third chords on bVI/bVII borrowed from the parallel minor act as
512
+ * subdominant (predominant) harmony. All other chords keep {@link functionOf}.
513
+ */
514
+ function qualityAwareFunction(chord: Chord, key: KeyScale): HarmonicFunction {
515
+ const offset = mod12(chord.rootPc - key.rootPc);
516
+ if (isDiminishedQuality(chord.quality) && (offset === 11 || offset === 6)) {
517
+ return 'dominant';
518
+ }
519
+ if (isNeapolitan(chord, key)) {
520
+ return 'subdominant';
521
+ }
522
+ if (!isMinorKey(key) && hasMajorThird(chord) && (offset === 8 || offset === 10)) {
523
+ return 'subdominant';
524
+ }
525
+ return functionOf(chord, key);
526
+ }
527
+
528
+ /**
529
+ * Analyze a chord in a key: harmonic function, borrowing, and Roman numeral.
530
+ *
531
+ * The function is quality-aware (see the predicates behind it), the source
532
+ * follows {@link borrowedSource}, and the numeral comes from
533
+ * {@link chordToRoman}. `borrowed` is true whenever a source is identified —
534
+ * including the Neapolitan, which the stricter parallel-mode predicate
535
+ * {@link isBorrowedChord} does not count.
536
+ *
537
+ * @param chord The chord to analyze.
538
+ * @param key The prevailing key.
539
+ * @returns The chord analysis.
540
+ */
541
+ export function analyzeChord(chord: Chord, key: KeyScale): ChordAnalysis {
542
+ const source = borrowedSource(chord, key);
543
+ return {
544
+ function: qualityAwareFunction(chord, key),
545
+ borrowed: source !== null,
546
+ source,
547
+ roman: chordToRoman(chord, key),
548
+ };
549
+ }
550
+
551
+ /**
552
+ * Classify the cadence formed by moving from one chord to the next.
553
+ *
554
+ * - authentic: V (dominant a fifth above the tonic) to I
555
+ * - plagal: IV (a fourth above the tonic) to I
556
+ * - deceptive: V to the submediant (diatonic vi or borrowed bVI in major, VI in
557
+ * minor)
558
+ * - half: any chord other than the dominant itself to V
559
+ *
560
+ * A static V-to-V repeat with no root motion is not a cadence and yields null.
561
+ *
562
+ * @param from The penultimate chord.
563
+ * @param to The final chord.
564
+ * @param key The prevailing key.
565
+ * @returns The cadence type, or null.
566
+ */
567
+ export function detectCadence(from: Chord, to: Chord, key: KeyScale): Cadence {
568
+ const tonic = mod12(key.rootPc);
569
+ const fromOffset = mod12(from.rootPc - tonic);
570
+ const toOffset = mod12(to.rootPc - tonic);
571
+ // Deceptive targets: the diatonic submediant (vi at offset 9 in major, VI at
572
+ // offset 8 in minor) plus, in a major key, the borrowed flat-submediant bVI
573
+ // at offset 8.
574
+ const deceptiveTargets = isMinorKey(key) ? [8] : [8, 9];
575
+ if (fromOffset === 7 && toOffset === 0) {
576
+ return 'authentic';
577
+ }
578
+ if (fromOffset === 5 && toOffset === 0) {
579
+ return 'plagal';
580
+ }
581
+ if (fromOffset === 7 && deceptiveTargets.includes(toOffset)) {
582
+ return 'deceptive';
583
+ }
584
+ // A move to the dominant is a half cadence, but a no-root-motion V-to-V repeat
585
+ // is not a cadence at all.
586
+ if (toOffset === 7 && fromOffset !== 7) {
587
+ return 'half';
588
+ }
589
+ return null;
590
+ }
591
+
592
+ /**
593
+ * The secondary dominant (V7) that tonicizes a scale degree.
594
+ *
595
+ * @param targetDegree 0-based scale degree to tonicize.
596
+ * @param key The prevailing key.
597
+ * @returns A dominant-seventh chord a fifth above the target's root.
598
+ */
599
+ export function secondaryDominant(targetDegree: number, key: KeyScale): Chord {
600
+ const targetRoot = degreeRootPc(targetDegree + 1, key);
601
+ return makeChord(mod12(targetRoot + 7), 'dom7');
602
+ }