@libraz/libcantus 0.9.0 → 0.9.2

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 (155) hide show
  1. package/README.md +25 -2
  2. package/dist/analyze/index.cjs +1697 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +429 -0
  5. package/dist/analyze/index.d.ts +429 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-5HTNMQW4.js +653 -0
  9. package/dist/chunk-5HTNMQW4.js.map +1 -0
  10. package/dist/chunk-7Y7WF64D.js +410 -0
  11. package/dist/chunk-7Y7WF64D.js.map +1 -0
  12. package/dist/chunk-A4532ZFR.js +259 -0
  13. package/dist/chunk-A4532ZFR.js.map +1 -0
  14. package/dist/chunk-BHUT2S5G.js +22 -0
  15. package/dist/chunk-BHUT2S5G.js.map +1 -0
  16. package/dist/chunk-CCBLVAPK.js +42 -0
  17. package/dist/chunk-CCBLVAPK.js.map +1 -0
  18. package/dist/chunk-COKTX2G5.js +907 -0
  19. package/dist/chunk-COKTX2G5.js.map +1 -0
  20. package/dist/chunk-DYWFNC4A.js +152 -0
  21. package/dist/chunk-DYWFNC4A.js.map +1 -0
  22. package/dist/chunk-GQKN5KDT.js +159 -0
  23. package/dist/chunk-GQKN5KDT.js.map +1 -0
  24. package/dist/chunk-LN7ZZMD2.js +530 -0
  25. package/dist/chunk-LN7ZZMD2.js.map +1 -0
  26. package/dist/chunk-MSWFZF7J.js +118 -0
  27. package/dist/chunk-MSWFZF7J.js.map +1 -0
  28. package/dist/chunk-R3S2LUBH.js +2722 -0
  29. package/dist/chunk-R3S2LUBH.js.map +1 -0
  30. package/dist/chunk-SDM3VJJ5.js +41 -0
  31. package/dist/chunk-SDM3VJJ5.js.map +1 -0
  32. package/dist/chunk-XTC3X6MS.js +816 -0
  33. package/dist/chunk-XTC3X6MS.js.map +1 -0
  34. package/dist/chunk-ZXW5YIWK.js +55 -0
  35. package/dist/chunk-ZXW5YIWK.js.map +1 -0
  36. package/dist/core/index.cjs +432 -0
  37. package/dist/core/index.cjs.map +1 -0
  38. package/dist/core/index.d.cts +210 -0
  39. package/dist/core/index.d.ts +210 -0
  40. package/dist/core/index.js +73 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/function-2bJW1Zp_.d.ts +187 -0
  43. package/dist/function-r2sh-jOO.d.cts +187 -0
  44. package/dist/generate/index.cjs +3787 -0
  45. package/dist/generate/index.cjs.map +1 -0
  46. package/dist/generate/index.d.cts +980 -0
  47. package/dist/generate/index.d.ts +980 -0
  48. package/dist/generate/index.js +52 -0
  49. package/dist/generate/index.js.map +1 -0
  50. package/dist/index-BMPxwjwV.d.ts +151 -0
  51. package/dist/index-BOBXBNwP.d.cts +157 -0
  52. package/dist/index-BdeQ1r_p.d.cts +56 -0
  53. package/dist/index-BreoJRw0.d.ts +127 -0
  54. package/dist/index-CGMZNCx6.d.cts +140 -0
  55. package/dist/index-CGMZNCx6.d.ts +140 -0
  56. package/dist/index-CM5W4rcm.d.ts +157 -0
  57. package/dist/index-CP3wAfEH.d.ts +56 -0
  58. package/dist/index-Cl7mhOFM.d.cts +164 -0
  59. package/dist/index-Cl7mhOFM.d.ts +164 -0
  60. package/dist/index-DZ1M21do.d.cts +127 -0
  61. package/dist/index-D_ZTUNKp.d.cts +151 -0
  62. package/dist/index.cjs +5277 -4893
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +14 -2621
  65. package/dist/index.d.ts +14 -2621
  66. package/dist/index.js +170 -6152
  67. package/dist/index.js.map +1 -1
  68. package/dist/model/index.cjs +2435 -0
  69. package/dist/model/index.cjs.map +1 -0
  70. package/dist/model/index.d.cts +630 -0
  71. package/dist/model/index.d.ts +630 -0
  72. package/dist/model/index.js +21 -0
  73. package/dist/model/index.js.map +1 -0
  74. package/dist/styled-BjXtOFNk.d.cts +282 -0
  75. package/dist/styled-D7BAx3S3.d.ts +282 -0
  76. package/dist/theory/index.cjs +1677 -0
  77. package/dist/theory/index.cjs.map +1 -0
  78. package/dist/theory/index.d.cts +535 -0
  79. package/dist/theory/index.d.ts +535 -0
  80. package/dist/theory/index.js +149 -0
  81. package/dist/theory/index.js.map +1 -0
  82. package/dist/types-MGP29r3t.d.cts +30 -0
  83. package/dist/types-MGP29r3t.d.ts +30 -0
  84. package/package.json +31 -1
  85. package/src/analyze/arrange/index.ts +21 -0
  86. package/src/analyze/arrange/internal.ts +125 -0
  87. package/src/analyze/arrange/tension.ts +187 -0
  88. package/src/analyze/arrange/tracks.ts +279 -0
  89. package/src/{detect → analyze/detect}/index.ts +82 -23
  90. package/src/analyze/functional/borrowed.ts +84 -0
  91. package/src/analyze/functional/cadence.ts +66 -0
  92. package/src/analyze/functional/function.ts +184 -0
  93. package/src/analyze/functional/index.ts +23 -0
  94. package/src/analyze/functional/internal.ts +29 -0
  95. package/src/analyze/functional/roman.ts +393 -0
  96. package/src/analyze/index.ts +49 -0
  97. package/src/{timeline → analyze/timeline}/index.ts +87 -24
  98. package/src/{analysis → analyze/voice}/index.ts +81 -12
  99. package/src/core/index.ts +47 -0
  100. package/src/{interval → core/interval}/index.ts +15 -1
  101. package/src/{meter → core/meter}/index.ts +108 -5
  102. package/src/{pitch → core/pitch}/index.ts +58 -3
  103. package/src/{random → core/random}/index.ts +13 -1
  104. package/src/{tuning → core/tuning}/index.ts +28 -1
  105. package/src/{bass → generate/bass}/index.ts +66 -17
  106. package/src/{countermelody → generate/countermelody}/index.ts +54 -24
  107. package/src/{drums → generate/drums}/fills.ts +31 -20
  108. package/src/{drums → generate/drums}/index.ts +127 -9
  109. package/src/{drums → generate/drums}/internal.ts +7 -1
  110. package/src/{drums → generate/drums}/rng.ts +2 -2
  111. package/src/{drums → generate/drums}/swing.ts +7 -2
  112. package/src/{groove → generate/groove}/index.ts +118 -22
  113. package/src/{harmonize → generate/harmonize}/index.ts +96 -23
  114. package/src/generate/index.ts +59 -0
  115. package/src/{motif → generate/motif}/index.ts +94 -17
  116. package/src/{progression → generate/progression}/index.ts +61 -27
  117. package/src/{reharmony → generate/reharmony}/index.ts +46 -15
  118. package/src/{rhythm → generate/rhythm}/index.ts +47 -13
  119. package/src/index.ts +130 -116
  120. package/src/model/chord.ts +423 -0
  121. package/src/model/index.ts +5 -947
  122. package/src/model/interval.ts +82 -0
  123. package/src/model/key.ts +266 -0
  124. package/src/model/note.ts +176 -0
  125. package/src/model/progression.ts +161 -0
  126. package/src/model/shared.ts +12 -0
  127. package/src/{chord → theory/chord}/index.ts +85 -4
  128. package/src/{chordscale → theory/chordscale}/index.ts +67 -9
  129. package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
  130. package/src/{harmony → theory/harmony}/index.ts +19 -2
  131. package/src/theory/index.ts +107 -0
  132. package/src/{safety → theory/safety}/index.ts +59 -12
  133. package/src/theory/scale/degrees.ts +123 -0
  134. package/src/theory/scale/index.ts +28 -0
  135. package/src/theory/scale/key.ts +56 -0
  136. package/src/theory/scale/masks.ts +153 -0
  137. package/src/{spelling → theory/spelling}/index.ts +21 -3
  138. package/src/{symbol → theory/symbol}/index.ts +91 -36
  139. package/src/theory/voicing/index.ts +5 -0
  140. package/src/theory/voicing/internal.ts +168 -0
  141. package/src/theory/voicing/leading.ts +112 -0
  142. package/src/theory/voicing/satb.ts +195 -0
  143. package/src/theory/voicing/styled.ts +176 -0
  144. package/src/arrange/index.ts +0 -409
  145. package/src/functional/index.ts +0 -602
  146. package/src/scale/index.ts +0 -215
  147. package/src/voicing/index.ts +0 -523
  148. /package/src/{types.ts → core/types.ts} +0 -0
  149. /package/src/{drums → generate/drums}/beat.ts +0 -0
  150. /package/src/{drums → generate/drums}/euclid.ts +0 -0
  151. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  152. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  153. /package/src/{drums → generate/drums}/hit.ts +0 -0
  154. /package/src/{drums → generate/drums}/kick.ts +0 -0
  155. /package/src/{drums → generate/drums}/percussion.ts +0 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Harmonic function (tonic / subdominant / dominant), diatonicity, parallel-key
3
+ * mirroring, and full chord analysis.
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 { KeyScale } from '../../core/types.js';
11
+ import type { Chord, ChordQuality } from '../../theory/chord/index.js';
12
+ import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
13
+ import { isScaleTone, MAJOR_MASK, NATURAL_MINOR_MASK } from '../../theory/scale/index.js';
14
+ import { type BorrowedSource, borrowedSource } from './borrowed.js';
15
+ import { degreeRootPc, isNeapolitan, mod12 } from './internal.js';
16
+ import { chordToRoman } from './roman.js';
17
+
18
+ /**
19
+ * The three broad harmonic functions of tonal music.
20
+ *
21
+ * @category Functional Harmony
22
+ */
23
+ export type HarmonicFunction = 'tonic' | 'subdominant' | 'dominant';
24
+
25
+ /** Harmonic function of each semitone offset above the tonic (major context). */
26
+ const FUNCTION_BY_OFFSET: readonly HarmonicFunction[] = [
27
+ 'tonic', // 0 I
28
+ 'subdominant', // 1 bII (Neapolitan)
29
+ 'subdominant', // 2 ii
30
+ 'tonic', // 3 bIII
31
+ 'tonic', // 4 iii
32
+ 'subdominant', // 5 IV
33
+ 'dominant', // 6 #IV / bV
34
+ 'dominant', // 7 V
35
+ 'subdominant', // 8 bVI
36
+ 'tonic', // 9 vi
37
+ 'subdominant', // 10 bVII
38
+ 'dominant', // 11 vii
39
+ ];
40
+
41
+ /**
42
+ * Whether a key's scale has a minor third and no major third (a minor key).
43
+ *
44
+ * @category Functional Harmony
45
+ */
46
+ export function isMinorKey(key: KeyScale): boolean {
47
+ const hasMinorThird = (key.modeMask12 >> 3) & 1;
48
+ const hasMajorThird = (key.modeMask12 >> 4) & 1;
49
+ return Boolean(hasMinorThird) && !hasMajorThird;
50
+ }
51
+
52
+ /**
53
+ * The harmonic function of a chord in a key, from its root's offset above the
54
+ * tonic. The mapping follows common-practice major-key function and is a useful
55
+ * approximation in minor and for borrowed chords.
56
+ *
57
+ * @param chord The chord.
58
+ * @param key The prevailing key.
59
+ * @returns The harmonic function.
60
+ * @category Functional Harmony
61
+ */
62
+ export function functionOf(chord: Chord, key: KeyScale): HarmonicFunction {
63
+ return FUNCTION_BY_OFFSET[mod12(chord.rootPc - key.rootPc)] ?? 'tonic';
64
+ }
65
+
66
+ /**
67
+ * The result of {@link analyzeChord}: function, borrowing, and Roman numeral.
68
+ *
69
+ * @category Functional Harmony
70
+ */
71
+ export type ChordAnalysis = {
72
+ function: HarmonicFunction;
73
+ borrowed: boolean;
74
+ source: BorrowedSource;
75
+ roman: string;
76
+ };
77
+
78
+ /**
79
+ * Whether every pitch class of a chord belongs to the key's scale.
80
+ *
81
+ * The test is strict against the key's own mode mask: in a natural-minor key
82
+ * the harmonic-minor dominant (major V) is *not* diatonic, since the raised
83
+ * leading tone lies outside the mask. Borrowing predicates treat that case as
84
+ * an in-key alteration separately (see {@link isBorrowedChord}).
85
+ *
86
+ * @param chord The chord to test.
87
+ * @param key The prevailing key.
88
+ * @returns True if all chord pitch classes are scale tones.
89
+ * @category Functional Harmony
90
+ */
91
+ export function isDiatonic(chord: Chord, key: KeyScale): boolean {
92
+ return chordPitchClasses(chord).every((pc) => isScaleTone(pc, key));
93
+ }
94
+
95
+ /**
96
+ * The parallel key: same tonic, opposite mode.
97
+ *
98
+ * A key with a minor third (natural/harmonic/melodic minor, dorian, phrygian)
99
+ * maps to the parallel major; any other key maps to the parallel natural minor.
100
+ *
101
+ * @param key The key to mirror.
102
+ * @returns The parallel major or natural-minor key on the same tonic.
103
+ * @category Functional Harmony
104
+ */
105
+ export function parallelKey(key: KeyScale): KeyScale {
106
+ return {
107
+ rootPc: mod12(key.rootPc),
108
+ modeMask12: isMinorKey(key) ? MAJOR_MASK : NATURAL_MINOR_MASK,
109
+ };
110
+ }
111
+
112
+ /** Diminished-family qualities: diminished triad, dim7, half-diminished. */
113
+ function isDiminishedQuality(quality: ChordQuality): boolean {
114
+ return quality === 'dim' || quality === 'dim7' || quality === 'm7b5';
115
+ }
116
+
117
+ /** Whether the chord's interval template carries a major third above the root. */
118
+ function hasMajorThird(chord: Chord): boolean {
119
+ return chord.intervals.some((interval) => mod12(interval) === 4);
120
+ }
121
+
122
+ /**
123
+ * Quality-aware harmonic function, refining the offset table where chord
124
+ * quality disambiguates: diminished chords on the leading tone or the raised
125
+ * subdominant resolve by semitone and act as dominants; the Neapolitan and the
126
+ * major-third chords on bVI/bVII borrowed from the parallel minor act as
127
+ * subdominant (predominant) harmony. All other chords keep {@link functionOf}.
128
+ */
129
+ function qualityAwareFunction(chord: Chord, key: KeyScale): HarmonicFunction {
130
+ const offset = mod12(chord.rootPc - key.rootPc);
131
+ if (isDiminishedQuality(chord.quality) && (offset === 11 || offset === 6)) {
132
+ return 'dominant';
133
+ }
134
+ if (isNeapolitan(chord, key)) {
135
+ return 'subdominant';
136
+ }
137
+ if (!isMinorKey(key) && hasMajorThird(chord) && (offset === 8 || offset === 10)) {
138
+ return 'subdominant';
139
+ }
140
+ return functionOf(chord, key);
141
+ }
142
+
143
+ /**
144
+ * Analyze a chord in a key: harmonic function, borrowing, and Roman numeral.
145
+ *
146
+ * The function is quality-aware (see the predicates behind it), the source
147
+ * follows {@link borrowedSource}, and the numeral comes from
148
+ * {@link chordToRoman}. `borrowed` is true whenever a source is identified —
149
+ * including the Neapolitan, which the stricter parallel-mode predicate
150
+ * {@link isBorrowedChord} does not count.
151
+ *
152
+ * @param chord The chord to analyze.
153
+ * @param key The prevailing key.
154
+ * @returns The chord analysis.
155
+ * @example
156
+ * ```ts
157
+ * import { analyzeChord, makeChord, majorKey } from '@libraz/libcantus';
158
+ * analyzeChord(makeChord(7, 'dom7'), majorKey(0));
159
+ * // { function: 'dominant', borrowed: false, source: null, roman: 'V7' }
160
+ * ```
161
+ * @category Functional Harmony
162
+ */
163
+ export function analyzeChord(chord: Chord, key: KeyScale): ChordAnalysis {
164
+ const source = borrowedSource(chord, key);
165
+ return {
166
+ function: qualityAwareFunction(chord, key),
167
+ borrowed: source !== null,
168
+ source,
169
+ roman: chordToRoman(chord, key),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The secondary dominant (V7) that tonicizes a scale degree.
175
+ *
176
+ * @param targetDegree 0-based scale degree to tonicize.
177
+ * @param key The prevailing key.
178
+ * @returns A dominant-seventh chord a fifth above the target's root.
179
+ * @category Functional Harmony
180
+ */
181
+ export function secondaryDominant(targetDegree: number, key: KeyScale): Chord {
182
+ const targetRoot = degreeRootPc(targetDegree + 1, key);
183
+ return makeChord(mod12(targetRoot + 7), 'dom7');
184
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Functional harmony: Roman-numeral parsing and formatting, harmonic function
3
+ * (tonic / subdominant / dominant), cadence detection, and borrowed chords.
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
+ export type { BorrowedSource } from './borrowed.js';
11
+ export { borrowedSource, isBorrowedChord } from './borrowed.js';
12
+ export type { Cadence } from './cadence.js';
13
+ export { detectCadence } from './cadence.js';
14
+ export type { ChordAnalysis, HarmonicFunction } from './function.js';
15
+ export {
16
+ analyzeChord,
17
+ functionOf,
18
+ isDiatonic,
19
+ isMinorKey,
20
+ parallelKey,
21
+ secondaryDominant,
22
+ } from './function.js';
23
+ export { chordToRoman, romanToChord } from './roman.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared helpers for the functional-harmony modules.
3
+ *
4
+ * Roots are pitch classes measured against the key tonic, so borrowed and
5
+ * chromatic chords are handled by their semitone offset rather than requiring a
6
+ * spelled key signature.
7
+ */
8
+
9
+ import type { KeyScale } from '../../core/types.js';
10
+ import type { Chord } from '../../theory/chord/index.js';
11
+ import { scaleTonesInDegreeOrder } from '../../theory/scale/index.js';
12
+
13
+ export function mod12(n: number): number {
14
+ return ((n % 12) + 12) % 12;
15
+ }
16
+
17
+ /** Diatonic pitch class of a 1-based scale degree in a key. */
18
+ export function degreeRootPc(degreeNumber: number, key: KeyScale): number {
19
+ const tones = scaleTonesInDegreeOrder(key);
20
+ if (tones.length === 0) {
21
+ return mod12(key.rootPc);
22
+ }
23
+ return tones[(degreeNumber - 1) % tones.length] ?? mod12(key.rootPc);
24
+ }
25
+
26
+ /** Whether a chord is the Neapolitan: a major triad on the flat second degree. */
27
+ export function isNeapolitan(chord: Chord, key: KeyScale): boolean {
28
+ return mod12(chord.rootPc - key.rootPc) === 1 && chord.quality === 'maj';
29
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Roman-numeral parsing and formatting.
3
+ *
4
+ * The {@link ROMAN_STYLE} table is the single source of truth for both
5
+ * directions, so {@link chordToRoman} and {@link romanToChord} stay mutual
6
+ * inverses by construction.
7
+ */
8
+
9
+ import type { KeyScale } from '../../core/types.js';
10
+ import type { Chord, ChordQuality } from '../../theory/chord/index.js';
11
+ import { makeChord } from '../../theory/chord/index.js';
12
+ import { majorKey, scaleTonesInDegreeOrder } from '../../theory/scale/index.js';
13
+ import { degreeRootPc, mod12 } from './internal.js';
14
+
15
+ /** Roman numeral glyphs indexed by degree number - 1. */
16
+ const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'] as const;
17
+
18
+ /** Roman glyph -> degree number (1..7). */
19
+ const ROMAN_TO_DEGREE: Record<string, number> = {
20
+ I: 1,
21
+ II: 2,
22
+ III: 3,
23
+ IV: 4,
24
+ V: 5,
25
+ VI: 6,
26
+ VII: 7,
27
+ };
28
+
29
+ /** Major-key Roman spelling for each semitone offset above the tonic. */
30
+ const OFFSET_SPELLING: readonly [number, string][] = [
31
+ [1, ''],
32
+ [2, 'b'],
33
+ [2, ''],
34
+ [3, 'b'],
35
+ [3, ''],
36
+ [4, ''],
37
+ [4, '#'],
38
+ [5, ''],
39
+ [6, 'b'],
40
+ [6, ''],
41
+ [7, 'b'],
42
+ [7, ''],
43
+ ];
44
+
45
+ /** Choose a seventh-chord quality from a triad label and the numeral case. */
46
+ function seventhQuality(base: 'maj' | 'min' | 'dim' | 'aug', halfDim: boolean): ChordQuality {
47
+ if (halfDim) {
48
+ return 'm7b5';
49
+ }
50
+ if (base === 'dim') {
51
+ return 'dim7';
52
+ }
53
+ if (base === 'aug') {
54
+ return 'aug7';
55
+ }
56
+ return base === 'maj' ? 'dom7' : 'min7';
57
+ }
58
+
59
+ /** Figure strings that denote a recognized figured-bass inversion. */
60
+ const INVERSION_FIGURES = new Set(['6', '64', '65', '43', '42', '2', '7']);
61
+
62
+ /**
63
+ * Canonical Roman-numeral rendering (numeral case and quality suffix) for every
64
+ * supported chord quality. The table is the single source of truth for both
65
+ * directions: {@link chordToRoman} renders from it and {@link romanToChord}
66
+ * recognizes its exact case-sensitive suffixes, so the two stay mutual
67
+ * inverses by construction. Suffixes are chosen to never collide with
68
+ * figured-bass inversion digits (`6`, `64`, `65`, `43`, `42`, `2`) — the added
69
+ * sixth is `add6`, the six-nine chord is `69` — and each (case, suffix) pair is
70
+ * unique.
71
+ */
72
+ const ROMAN_STYLE: Record<ChordQuality, { lower: boolean; suffix: string }> = {
73
+ maj: { lower: false, suffix: '' },
74
+ min: { lower: true, suffix: '' },
75
+ dim: { lower: true, suffix: 'o' },
76
+ aug: { lower: false, suffix: '+' },
77
+ maj7: { lower: false, suffix: 'maj7' },
78
+ min7: { lower: true, suffix: '7' },
79
+ dom7: { lower: false, suffix: '7' },
80
+ dim7: { lower: true, suffix: 'o7' },
81
+ m7b5: { lower: true, suffix: 'ø7' },
82
+ minMaj7: { lower: true, suffix: 'maj7' },
83
+ aug7: { lower: false, suffix: '+7' },
84
+ augMaj7: { lower: false, suffix: '+maj7' },
85
+ majb5: { lower: false, suffix: 'b5' },
86
+ '6': { lower: false, suffix: 'add6' },
87
+ min6: { lower: true, suffix: 'add6' },
88
+ '6/9': { lower: false, suffix: '69' },
89
+ sus2: { lower: false, suffix: 'sus2' },
90
+ sus4: { lower: false, suffix: 'sus4' },
91
+ add9: { lower: false, suffix: 'add9' },
92
+ add11: { lower: false, suffix: 'add11' },
93
+ maj9: { lower: false, suffix: 'maj9' },
94
+ min9: { lower: true, suffix: '9' },
95
+ dom9: { lower: false, suffix: '9' },
96
+ '7b9': { lower: false, suffix: '7b9' },
97
+ '7#9': { lower: false, suffix: '7#9' },
98
+ '7#11': { lower: false, suffix: '7#11' },
99
+ '7b13': { lower: false, suffix: '7b13' },
100
+ '11': { lower: false, suffix: '11' },
101
+ '13': { lower: false, suffix: '13' },
102
+ '5': { lower: false, suffix: '5' },
103
+ };
104
+
105
+ /** Reverse lookup of {@link ROMAN_STYLE}: `(case, exact suffix) -> quality`. */
106
+ const SUFFIX_QUALITY: ReadonlyMap<string, ChordQuality> = new Map(
107
+ (Object.entries(ROMAN_STYLE) as [ChordQuality, { lower: boolean; suffix: string }][]).map(
108
+ ([quality, style]) => [`${style.lower ? 'l' : 'u'}:${style.suffix}`, quality],
109
+ ),
110
+ );
111
+
112
+ /** Read figured-bass digits into a chord inversion and whether a seventh is implied. */
113
+ function parseInversion(figures: string): { inversion: number; seventh: boolean } {
114
+ if (figures === '65') {
115
+ return { inversion: 1, seventh: true };
116
+ }
117
+ if (figures === '43') {
118
+ return { inversion: 2, seventh: true };
119
+ }
120
+ if (figures === '42' || figures === '2') {
121
+ return { inversion: 3, seventh: true };
122
+ }
123
+ if (figures === '7') {
124
+ return { inversion: 0, seventh: true };
125
+ }
126
+ if (figures === '64') {
127
+ return { inversion: 2, seventh: false };
128
+ }
129
+ if (figures === '6') {
130
+ return { inversion: 1, seventh: false };
131
+ }
132
+ return { inversion: 0, seventh: false };
133
+ }
134
+
135
+ /** Parse a Roman numeral (no secondary '/') into a root, quality, and inversion. */
136
+ function parseSimpleRoman(
137
+ text: string,
138
+ key: KeyScale,
139
+ ): { rootPc: number; quality: ChordQuality; inversion: number } {
140
+ const match = /^([b#]?)([iIvV]+)(.*)$/.exec(text.trim());
141
+ if (!match) {
142
+ throw new Error(`Invalid Roman numeral: ${text}`);
143
+ }
144
+ const accidental = match[1] === 'b' ? -1 : match[1] === '#' ? 1 : 0;
145
+ const glyph = (match[2] ?? '').toUpperCase();
146
+ const degreeNumber = ROMAN_TO_DEGREE[glyph];
147
+ if (degreeNumber === undefined) {
148
+ throw new Error(`Invalid Roman numeral: ${text}`);
149
+ }
150
+ const isUpper = (match[2] ?? '')[0] === (match[2] ?? '')[0]?.toUpperCase();
151
+ const suffix = match[3] ?? '';
152
+ const rootPc = mod12(degreeRootPc(degreeNumber, key) + accidental);
153
+
154
+ // Canonical quality suffixes (exact match, case-sensitive on the numeral)
155
+ // come first so every chordToRoman rendering re-parses to the same quality:
156
+ // `V9` -> dom9 but `ii9` -> min9 and `Imaj9` -> maj9, and added-tone suffixes
157
+ // whose digits would otherwise be misread as figured bass (`Iadd6`, `I69`,
158
+ // `Isus2`, `Iadd11`) resolve to their qualities in root position.
159
+ const canonical = SUFFIX_QUALITY.get(`${isUpper ? 'u' : 'l'}:${suffix}`);
160
+ if (canonical !== undefined) {
161
+ return { rootPc, quality: canonical, inversion: 0 };
162
+ }
163
+
164
+ const isDim = /o|°|dim/.test(suffix);
165
+ const isAug = /\+|aug/.test(suffix);
166
+ const isHalfDim = /ø/.test(suffix);
167
+ const explicitMaj7 = /maj7|M7/.test(suffix);
168
+ const figures = suffix.replace(/maj7|M7/g, '').replace(/[^0-9]/g, '');
169
+
170
+ // Reject figure strings that are neither a known figured-bass inversion nor a
171
+ // canonical quality suffix rather than silently degrading to a root triad.
172
+ if (figures !== '' && !INVERSION_FIGURES.has(figures)) {
173
+ throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
174
+ }
175
+
176
+ const { inversion, seventh } = parseInversion(figures);
177
+ // The ø glyph is the half-diminished seventh by definition (there is no
178
+ // half-diminished triad), so it always implies a seventh.
179
+ const hasSeventh = explicitMaj7 || seventh || isHalfDim;
180
+
181
+ let base: 'maj' | 'min' | 'dim' | 'aug';
182
+ if (isDim || isHalfDim) {
183
+ base = 'dim';
184
+ } else if (isAug) {
185
+ base = 'aug';
186
+ } else {
187
+ base = isUpper ? 'maj' : 'min';
188
+ }
189
+
190
+ let quality: ChordQuality;
191
+ if (explicitMaj7) {
192
+ quality = base === 'min' ? 'minMaj7' : base === 'aug' ? 'augMaj7' : 'maj7';
193
+ } else if (hasSeventh) {
194
+ quality = seventhQuality(base, isHalfDim);
195
+ } else if (base === 'dim') {
196
+ quality = 'dim';
197
+ } else if (base === 'aug') {
198
+ quality = 'aug';
199
+ } else {
200
+ quality = base;
201
+ }
202
+ return { rootPc, quality, inversion };
203
+ }
204
+
205
+ /** Build a chord from a parsed Roman numeral, attaching a bass for inversions. */
206
+ function chordFromParsed(parsed: {
207
+ rootPc: number;
208
+ quality: ChordQuality;
209
+ inversion: number;
210
+ }): Chord {
211
+ const chord = makeChord(parsed.rootPc, parsed.quality);
212
+ if (parsed.inversion > 0 && parsed.inversion < chord.intervals.length) {
213
+ chord.bassPc = mod12(parsed.rootPc + (chord.intervals[parsed.inversion] ?? 0));
214
+ }
215
+ return chord;
216
+ }
217
+
218
+ /**
219
+ * Build the chord denoted by a Roman numeral in a key.
220
+ *
221
+ * Supports accidentals (`bVII`, `#iv`), case-based triad quality, the `o`/`ø`/`+`
222
+ * suffixes, sevenths (`V7`, `viio7`, `Imaj7`), figured-bass inversions
223
+ * (`V6`, `V64`, `V65`, `V43`, `V42`), and applied/secondary chords via a slash
224
+ * (`V7/V`, `viio/ii`). The target after the slash is read as a scale degree
225
+ * whose root becomes a local major tonic for the applied chord. Inverted chords
226
+ * carry a `bassPc`.
227
+ *
228
+ * The `ø` glyph always denotes the half-diminished seventh (`iiø` == `iiø7` ==
229
+ * `m7b5`), since no half-diminished triad exists. Extension and added-tone
230
+ * suffixes are the canonical case-sensitive forms emitted by
231
+ * {@link chordToRoman} and honor the numeral case: `V9` -> `dom9`, `ii9` ->
232
+ * `min9`, `Imaj9` -> `maj9`, `V11` -> `11`, `V13` -> `13`, `Iadd6`/`iadd6` ->
233
+ * `6`/`min6`, `I69` -> `6/9`, `Isus2`/`Isus4`, `Iadd9`/`Iadd11`, `Ib5` ->
234
+ * `majb5`, `I5` -> `5`, and the altered dominants `V7b9`/`V7#9`/`V7#11`/`V7b13`.
235
+ * A figure string that is neither a recognized inversion nor a supported
236
+ * quality suffix throws rather than silently degrading to a triad.
237
+ *
238
+ * @param text The Roman numeral.
239
+ * @param key The prevailing key.
240
+ * @returns The chord.
241
+ * @example
242
+ * ```ts
243
+ * import { romanToChord, majorKey } from '@libraz/libcantus';
244
+ * romanToChord('V7', majorKey(0)); // G7 in C major: { rootPc: 7, quality: 'dom7', ... }
245
+ * ```
246
+ * @category Functional Harmony
247
+ */
248
+ export function romanToChord(text: string, key: KeyScale): Chord {
249
+ const trimmed = text.trim();
250
+ const slash = trimmed.indexOf('/');
251
+ if (slash >= 0) {
252
+ const applied = trimmed.slice(0, slash);
253
+ const target = trimmed.slice(slash + 1);
254
+ const targetRoot = parseSimpleRoman(target, key).rootPc;
255
+ const localKey = majorKey(targetRoot);
256
+ return chordFromParsed(parseSimpleRoman(applied, localKey));
257
+ }
258
+ return chordFromParsed(parseSimpleRoman(trimmed, key));
259
+ }
260
+
261
+ /** Case and suffix for rendering a chord quality as a Roman numeral. */
262
+ function romanStyle(quality: ChordQuality): { lower: boolean; suffix: string } {
263
+ return ROMAN_STYLE[quality];
264
+ }
265
+
266
+ /** Choose the degree number and accidental to spell a root as a Roman numeral. */
267
+ function romanSpelling(
268
+ rootPc: number,
269
+ key: KeyScale,
270
+ ): { degreeNumber: number; accidental: string } {
271
+ const tones = scaleTonesInDegreeOrder(key);
272
+ const diatonic = tones.indexOf(mod12(rootPc));
273
+ if (diatonic >= 0) {
274
+ return { degreeNumber: diatonic + 1, accidental: '' };
275
+ }
276
+ // Chromatic root: prefer a flat of the diatonic degree a semitone above it,
277
+ // then a sharp of the degree a semitone below. Degree 1 (the tonic) and
278
+ // degree 5 (the dominant) are skipped in the flat pass, so a raised leading
279
+ // tone spells as `#vii` rather than a flat tonic `bI`, and the tritone above
280
+ // the tonic spells as `#iv` rather than a flat fifth `bV` (matching the
281
+ // conventional `OFFSET_SPELLING` for offset 6).
282
+ for (let i = 0; i < tones.length; i += 1) {
283
+ const degreeNumber = i + 1;
284
+ if (degreeNumber === 1 || degreeNumber === 5) {
285
+ continue;
286
+ }
287
+ if (mod12((tones[i] ?? 0) - 1) === mod12(rootPc)) {
288
+ return { degreeNumber, accidental: 'b' };
289
+ }
290
+ }
291
+ for (let i = 0; i < tones.length; i += 1) {
292
+ if (mod12((tones[i] ?? 0) + 1) === mod12(rootPc)) {
293
+ return { degreeNumber: i + 1, accidental: '#' };
294
+ }
295
+ }
296
+ const [degreeNumber, accidental] = OFFSET_SPELLING[mod12(rootPc - key.rootPc)] ?? [1, ''];
297
+ return { degreeNumber, accidental };
298
+ }
299
+
300
+ /** Quality marker (without the seventh digit) used when a figure carries the 7. */
301
+ function baseMarker(quality: ChordQuality): string {
302
+ if (quality === 'dim' || quality === 'dim7') {
303
+ return 'o';
304
+ }
305
+ if (quality === 'm7b5') {
306
+ return 'ø';
307
+ }
308
+ if (quality === 'aug' || quality === 'aug7') {
309
+ return '+';
310
+ }
311
+ if (quality === 'augMaj7') {
312
+ return '+maj7';
313
+ }
314
+ if (quality === 'maj7' || quality === 'minMaj7') {
315
+ return 'maj7';
316
+ }
317
+ return '';
318
+ }
319
+
320
+ const TRIAD_FIGURES: Record<number, string> = { 1: '6', 2: '64' };
321
+ const SEVENTH_FIGURES: Record<number, string> = { 1: '65', 2: '43', 3: '42' };
322
+
323
+ /** Qualities whose inversions render as lossless triad figures (6, 64). */
324
+ const TRIAD_FIGURE_QUALITIES: ReadonlySet<ChordQuality> = new Set(['maj', 'min', 'dim', 'aug']);
325
+
326
+ /**
327
+ * Qualities carrying a chordal seventh, whose inversions render as seventh
328
+ * figures (65, 43, 42). Membership is decided by the quality itself — a 7th
329
+ * above the root (10 or 11 semitones, or the diminished 7th of `dim7`) — never
330
+ * by interval count, which would turn added-tone chords into false sevenths.
331
+ */
332
+ const SEVENTH_FIGURE_QUALITIES: ReadonlySet<ChordQuality> = new Set([
333
+ 'maj7',
334
+ 'min7',
335
+ 'dom7',
336
+ 'dim7',
337
+ 'm7b5',
338
+ 'minMaj7',
339
+ 'aug7',
340
+ 'augMaj7',
341
+ ]);
342
+
343
+ /**
344
+ * Render a chord as a Roman numeral relative to a key.
345
+ *
346
+ * Diatonic roots take their scale-degree numeral directly, so numerals are
347
+ * correct in both major and minor keys (and any custom scale). The quality
348
+ * selects the case and suffix; chromatic roots receive a flat/sharp spelling by
349
+ * convention. When the chord carries a `bassPc` on a chord tone, a figured-bass
350
+ * inversion (`6`, `64`, `65`, `43`, `42`) is emitted for plain triads and true
351
+ * seventh chords; added-tone and extended qualities have no lossless figure and
352
+ * render in root position instead (the bass is dropped, pitch classes are
353
+ * preserved).
354
+ *
355
+ * @param chord The chord to name.
356
+ * @param key The prevailing key.
357
+ * @returns The Roman numeral string.
358
+ * @example
359
+ * ```ts
360
+ * import { chordToRoman, makeChord, majorKey } from '@libraz/libcantus';
361
+ * chordToRoman(makeChord(7, 'dom7'), majorKey(0)); // => 'V7' (G7 in C major)
362
+ * ```
363
+ * @category Functional Harmony
364
+ */
365
+ export function chordToRoman(chord: Chord, key: KeyScale): string {
366
+ const { degreeNumber, accidental } = romanSpelling(chord.rootPc, key);
367
+ const { lower, suffix } = romanStyle(chord.quality);
368
+ const numeral: string = ROMAN[degreeNumber - 1] ?? 'I';
369
+ const cased = lower ? numeral.toLowerCase() : numeral;
370
+
371
+ let inversion = 0;
372
+ if (chord.bassPc !== undefined) {
373
+ const idx = chord.intervals.findIndex((iv) => mod12(chord.rootPc + iv) === chord.bassPc);
374
+ if (idx > 0) {
375
+ inversion = idx;
376
+ }
377
+ }
378
+ if (inversion > 0) {
379
+ const figure = SEVENTH_FIGURE_QUALITIES.has(chord.quality)
380
+ ? SEVENTH_FIGURES[inversion]
381
+ : TRIAD_FIGURE_QUALITIES.has(chord.quality)
382
+ ? TRIAD_FIGURES[inversion]
383
+ : undefined;
384
+ if (figure !== undefined) {
385
+ return `${accidental}${cased}${baseMarker(chord.quality)}${figure}`;
386
+ }
387
+ // No lossless figured-bass symbol exists — the quality is an added-tone or
388
+ // extended chord, or the bass falls on a tension beyond the seventh — so
389
+ // fall back to root-position rendering with the quality suffix rather than
390
+ // emitting a figure that would re-parse as a different chord.
391
+ }
392
+ return `${accidental}${cased}${suffix}`;
393
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Public API of the analyze layer. Re-exports every analyze-layer module;
3
+ * also available from the package root.
4
+ */
5
+ export type {
6
+ ArrangementAnalysis,
7
+ ArrangementOptions,
8
+ ArrangementTrack,
9
+ Conflict,
10
+ TensionPoint,
11
+ TrackAnalysis,
12
+ TrackRole,
13
+ } from './arrange/index.js';
14
+ export { analyzeArrangement, tensionCurve } from './arrange/index.js';
15
+ export type { ChordMatch, KeyMatch } from './detect/index.js';
16
+ export { detectChord, detectChordBest, detectKey } from './detect/index.js';
17
+ export type {
18
+ BorrowedSource,
19
+ Cadence,
20
+ ChordAnalysis,
21
+ HarmonicFunction,
22
+ } from './functional/index.js';
23
+ export {
24
+ analyzeChord,
25
+ borrowedSource,
26
+ chordToRoman,
27
+ detectCadence,
28
+ functionOf,
29
+ isBorrowedChord,
30
+ isDiatonic,
31
+ isMinorKey,
32
+ parallelKey,
33
+ romanToChord,
34
+ secondaryDominant,
35
+ } from './functional/index.js';
36
+ export type {
37
+ CadenceHit,
38
+ ChordSegment,
39
+ ChordTimeline,
40
+ ChordTimelineOptions,
41
+ ChordTimelineResult,
42
+ } from './timeline/index.js';
43
+ export {
44
+ chordTimelineFromChords,
45
+ chordTimelineFromNotes,
46
+ detectCadences,
47
+ } from './timeline/index.js';
48
+ export type { AnalyzedNote, TheoryLabel, VoiceNote } from './voice/index.js';
49
+ export { analyzeVoice } from './voice/index.js';