@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,123 @@
1
+ import type { KeyScale } from '../../core/types.js';
2
+
3
+ /** Reduce a pitch (or pitch class) to a pitch class in [0, 11]. */
4
+ function pitchClass(pitch: number): number {
5
+ return ((Math.trunc(pitch) % 12) + 12) % 12;
6
+ }
7
+
8
+ /** Scale offset of a pitch relative to the key root, in [0, 11]. */
9
+ function scaleOffset(pitch: number, key: KeyScale): number {
10
+ return (pitchClass(pitch) - pitchClass(key.rootPc) + 12) % 12;
11
+ }
12
+
13
+ /**
14
+ * Test whether a pitch belongs to the scale.
15
+ *
16
+ * @param pitch MIDI pitch or bare pitch class.
17
+ * @param key The key/scale to test against.
18
+ * @returns True if the pitch class is a member of the scale.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { majorKey, isScaleTone } from '@libraz/libcantus';
23
+ * const key = majorKey(0); // C major
24
+ * isScaleTone(64, key); // true — E is in C major
25
+ * isScaleTone(61, key); // false — C# is not
26
+ * ```
27
+ *
28
+ * @category Scales
29
+ */
30
+ export function isScaleTone(pitch: number, key: KeyScale): boolean {
31
+ return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
32
+ }
33
+
34
+ /**
35
+ * Find the nearest MIDI pitch whose pitch class is in the scale.
36
+ *
37
+ * The search expands symmetrically outward from `pitch`. On a tie (equal
38
+ * distance above and below) the lower pitch is returned.
39
+ *
40
+ * @param pitch MIDI pitch to snap.
41
+ * @param key The key/scale to snap to.
42
+ * @returns The nearest in-scale MIDI pitch.
43
+ *
44
+ * @category Scales
45
+ */
46
+ export function nearestScaleTone(pitch: number, key: KeyScale): number {
47
+ const base = Math.round(pitch);
48
+ for (let distance = 0; distance < 12; distance += 1) {
49
+ const lower = base - distance;
50
+ if (isScaleTone(lower, key)) {
51
+ return lower;
52
+ }
53
+ const higher = base + distance;
54
+ if (isScaleTone(higher, key)) {
55
+ return higher;
56
+ }
57
+ }
58
+ return base;
59
+ }
60
+
61
+ /**
62
+ * Get the 0-based scale degree of a pitch.
63
+ *
64
+ * @param pitch MIDI pitch or bare pitch class.
65
+ * @param key The key/scale to measure against.
66
+ * @returns The 0-based degree, or -1 if the pitch is not in the scale.
67
+ *
68
+ * @category Scales
69
+ */
70
+ export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
71
+ const offset = scaleOffset(pitch, key);
72
+ if (((key.modeMask12 >> offset) & 1) === 0) {
73
+ return -1;
74
+ }
75
+ let degree = 0;
76
+ for (let n = 0; n < offset; n += 1) {
77
+ if (((key.modeMask12 >> n) & 1) === 1) {
78
+ degree += 1;
79
+ }
80
+ }
81
+ return degree;
82
+ }
83
+
84
+ /**
85
+ * List the pitch classes of a key's scale.
86
+ *
87
+ * @param key The key/scale to enumerate.
88
+ * @returns The member pitch classes, sorted ascending in [0, 11].
89
+ *
90
+ * @category Scales
91
+ */
92
+ export function diatonicPitchClasses(key: KeyScale): number[] {
93
+ const root = pitchClass(key.rootPc);
94
+ const pcs: number[] = [];
95
+ for (let n = 0; n < 12; n += 1) {
96
+ if (((key.modeMask12 >> n) & 1) === 1) {
97
+ pcs.push((root + n) % 12);
98
+ }
99
+ }
100
+ return pcs.sort((a, b) => a - b);
101
+ }
102
+
103
+ /**
104
+ * List the pitch classes of a key's scale in ascending scale-degree order.
105
+ *
106
+ * Degree 0 is the root; degrees follow the mask bits in offset order rather
107
+ * than sorted pitch-class order.
108
+ *
109
+ * @param key The key/scale to enumerate.
110
+ * @returns The member pitch classes ordered by scale degree.
111
+ *
112
+ * @category Scales
113
+ */
114
+ export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
115
+ const root = pitchClass(key.rootPc);
116
+ const pcs: number[] = [];
117
+ for (let n = 0; n < 12; n += 1) {
118
+ if (((key.modeMask12 >> n) & 1) === 1) {
119
+ pcs.push((root + n) % 12);
120
+ }
121
+ }
122
+ return pcs;
123
+ }
@@ -0,0 +1,28 @@
1
+ export {
2
+ diatonicPitchClasses,
3
+ isScaleTone,
4
+ nearestScaleTone,
5
+ pitchToScaleDegree,
6
+ scaleTonesInDegreeOrder,
7
+ } from './degrees.js';
8
+ export { majorKey, minorKey, scaleByName } from './key.js';
9
+ export {
10
+ BLUES_MASK,
11
+ CHROMATIC_MASK,
12
+ DORIAN_MASK,
13
+ HARMONIC_MINOR_MASK,
14
+ LOCRIAN_MASK,
15
+ LYDIAN_MASK,
16
+ MAJOR_MASK,
17
+ MAJOR_PENTATONIC_MASK,
18
+ MELODIC_MINOR_MASK,
19
+ MINOR_PENTATONIC_MASK,
20
+ MIXOLYDIAN_MASK,
21
+ maskFromOffsets,
22
+ NAMED_SCALES,
23
+ NATURAL_MINOR_MASK,
24
+ OCTATONIC_HALF_WHOLE_MASK,
25
+ OCTATONIC_WHOLE_HALF_MASK,
26
+ PHRYGIAN_MASK,
27
+ WHOLE_TONE_MASK,
28
+ } from './masks.js';
@@ -0,0 +1,56 @@
1
+ import type { KeyScale } from '../../core/types.js';
2
+ import { MAJOR_MASK, NAMED_SCALES, NATURAL_MINOR_MASK } from './masks.js';
3
+
4
+ /**
5
+ * Build a `KeyScale` for a major key on the given root pitch class.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { majorKey } from '@libraz/libcantus';
10
+ * const cMajor = majorKey(0); // C major: { rootPc: 0, modeMask12: MAJOR_MASK }
11
+ * ```
12
+ *
13
+ * @category Scales
14
+ */
15
+ export function majorKey(rootPc: number): KeyScale {
16
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
17
+ }
18
+
19
+ /**
20
+ * Build a `KeyScale` for a natural-minor key on the given root pitch class.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { minorKey } from '@libraz/libcantus';
25
+ * const aMinor = minorKey(9); // A natural minor: { rootPc: 9, modeMask12: NATURAL_MINOR_MASK }
26
+ * ```
27
+ *
28
+ * @category Scales
29
+ */
30
+ export function minorKey(rootPc: number): KeyScale {
31
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
32
+ }
33
+
34
+ /**
35
+ * Build a `KeyScale` from a named scale (see {@link NAMED_SCALES}).
36
+ *
37
+ * @param name The scale name, e.g. `'dorian'` or `'harmonicMinor'`.
38
+ * @param rootPc The root pitch class.
39
+ * @returns The key/scale.
40
+ * @throws If the name is not a known scale.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { scaleByName } from '@libraz/libcantus';
45
+ * const dDorian = scaleByName('dorian', 2); // D Dorian, rootPc 2
46
+ * ```
47
+ *
48
+ * @category Scales
49
+ */
50
+ export function scaleByName(name: string, rootPc: number): KeyScale {
51
+ const mask = NAMED_SCALES[name];
52
+ if (mask === undefined) {
53
+ throw new Error(`Unknown scale: ${name}`);
54
+ }
55
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: mask };
56
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Build a 12-bit mode mask from a list of semitone offsets above the root.
3
+ *
4
+ * Bit 0 (the root) is always set, enforcing the `KeyScale` invariant that the
5
+ * root is a scale tone even when the offset list omits 0.
6
+ *
7
+ * @category Scales
8
+ */
9
+ export function maskFromOffsets(offsets: readonly number[]): number {
10
+ let mask = 1;
11
+ for (const offset of offsets) {
12
+ mask |= 1 << (((offset % 12) + 12) % 12);
13
+ }
14
+ return mask;
15
+ }
16
+
17
+ /**
18
+ * Mode mask for the major (Ionian) scale: offsets {0, 2, 4, 5, 7, 9, 11}.
19
+ *
20
+ * @category Scales
21
+ */
22
+ export const MAJOR_MASK = 0b101010110101;
23
+
24
+ /**
25
+ * Mode mask for the natural minor (Aeolian) scale: offsets {0, 2, 3, 5, 7, 8, 10}.
26
+ *
27
+ * @category Scales
28
+ */
29
+ export const NATURAL_MINOR_MASK = 0b010110101101;
30
+
31
+ /**
32
+ * Harmonic minor: natural minor with a raised seventh — offsets {0,2,3,5,7,8,11}.
33
+ *
34
+ * @category Scales
35
+ */
36
+ export const HARMONIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 8, 11]);
37
+
38
+ /**
39
+ * Ascending melodic minor: offsets {0,2,3,5,7,9,11}.
40
+ *
41
+ * @category Scales
42
+ */
43
+ export const MELODIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 11]);
44
+
45
+ /**
46
+ * Dorian mode: offsets {0,2,3,5,7,9,10}.
47
+ *
48
+ * @category Scales
49
+ */
50
+ export const DORIAN_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 10]);
51
+
52
+ /**
53
+ * Phrygian mode: offsets {0,1,3,5,7,8,10}.
54
+ *
55
+ * @category Scales
56
+ */
57
+ export const PHRYGIAN_MASK = maskFromOffsets([0, 1, 3, 5, 7, 8, 10]);
58
+
59
+ /**
60
+ * Lydian mode: offsets {0,2,4,6,7,9,11}.
61
+ *
62
+ * @category Scales
63
+ */
64
+ export const LYDIAN_MASK = maskFromOffsets([0, 2, 4, 6, 7, 9, 11]);
65
+
66
+ /**
67
+ * Mixolydian mode: offsets {0,2,4,5,7,9,10}.
68
+ *
69
+ * @category Scales
70
+ */
71
+ export const MIXOLYDIAN_MASK = maskFromOffsets([0, 2, 4, 5, 7, 9, 10]);
72
+
73
+ /**
74
+ * Locrian mode: offsets {0,1,3,5,6,8,10}.
75
+ *
76
+ * @category Scales
77
+ */
78
+ export const LOCRIAN_MASK = maskFromOffsets([0, 1, 3, 5, 6, 8, 10]);
79
+
80
+ /**
81
+ * Major pentatonic: offsets {0,2,4,7,9}.
82
+ *
83
+ * @category Scales
84
+ */
85
+ export const MAJOR_PENTATONIC_MASK = maskFromOffsets([0, 2, 4, 7, 9]);
86
+
87
+ /**
88
+ * Minor pentatonic: offsets {0,3,5,7,10}.
89
+ *
90
+ * @category Scales
91
+ */
92
+ export const MINOR_PENTATONIC_MASK = maskFromOffsets([0, 3, 5, 7, 10]);
93
+
94
+ /**
95
+ * Hexatonic blues scale: minor pentatonic plus the flat-fifth — {0,3,5,6,7,10}.
96
+ *
97
+ * @category Scales
98
+ */
99
+ export const BLUES_MASK = maskFromOffsets([0, 3, 5, 6, 7, 10]);
100
+
101
+ /**
102
+ * Whole-tone scale: offsets {0,2,4,6,8,10}.
103
+ *
104
+ * @category Scales
105
+ */
106
+ export const WHOLE_TONE_MASK = maskFromOffsets([0, 2, 4, 6, 8, 10]);
107
+
108
+ /**
109
+ * Octatonic (half-step first): offsets {0,1,3,4,6,7,9,10}.
110
+ *
111
+ * @category Scales
112
+ */
113
+ export const OCTATONIC_HALF_WHOLE_MASK = maskFromOffsets([0, 1, 3, 4, 6, 7, 9, 10]);
114
+
115
+ /**
116
+ * Octatonic (whole-step first): offsets {0,2,3,5,6,8,9,11}.
117
+ *
118
+ * @category Scales
119
+ */
120
+ export const OCTATONIC_WHOLE_HALF_MASK = maskFromOffsets([0, 2, 3, 5, 6, 8, 9, 11]);
121
+
122
+ /**
123
+ * Chromatic scale: all twelve pitch classes.
124
+ *
125
+ * @category Scales
126
+ */
127
+ export const CHROMATIC_MASK = 0b111111111111;
128
+
129
+ /**
130
+ * Named scale masks addressable by {@link scaleByName}.
131
+ *
132
+ * @category Scales
133
+ */
134
+ export const NAMED_SCALES: Record<string, number> = {
135
+ major: MAJOR_MASK,
136
+ ionian: MAJOR_MASK,
137
+ naturalMinor: NATURAL_MINOR_MASK,
138
+ aeolian: NATURAL_MINOR_MASK,
139
+ harmonicMinor: HARMONIC_MINOR_MASK,
140
+ melodicMinor: MELODIC_MINOR_MASK,
141
+ dorian: DORIAN_MASK,
142
+ phrygian: PHRYGIAN_MASK,
143
+ lydian: LYDIAN_MASK,
144
+ mixolydian: MIXOLYDIAN_MASK,
145
+ locrian: LOCRIAN_MASK,
146
+ majorPentatonic: MAJOR_PENTATONIC_MASK,
147
+ minorPentatonic: MINOR_PENTATONIC_MASK,
148
+ blues: BLUES_MASK,
149
+ wholeTone: WHOLE_TONE_MASK,
150
+ octatonicHalfWhole: OCTATONIC_HALF_WHOLE_MASK,
151
+ octatonicWholeHalf: OCTATONIC_WHOLE_HALF_MASK,
152
+ chromatic: CHROMATIC_MASK,
153
+ };
@@ -7,11 +7,11 @@
7
7
  * A harmonic minor spells its seventh as G#.
8
8
  */
9
9
 
10
+ import type { Note } from '../../core/pitch/index.js';
11
+ import { formatNote } from '../../core/pitch/index.js';
12
+ import type { KeyScale } from '../../core/types.js';
10
13
  import type { Chord } from '../chord/index.js';
11
- import type { Note } from '../pitch/index.js';
12
- import { formatNote } from '../pitch/index.js';
13
14
  import { scaleTonesInDegreeOrder } from '../scale/index.js';
14
- import type { KeyScale } from '../types.js';
15
15
 
16
16
  /** Semitone offset of each natural letter above C: C D E F G A B. */
17
17
  const LETTER_SEMITONES = [0, 2, 4, 5, 7, 9, 11] as const;
@@ -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);
@@ -5,14 +5,24 @@
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';
10
8
 
11
- /** Matches a chord root: a letter A-G with an optional single-direction accidental. */
12
- const ROOT_RE = /^[A-G](?:#{1,2}|b{1,2})?/;
9
+ import {
10
+ formatNote,
11
+ midiToNote,
12
+ type Note,
13
+ noteToPitchClass,
14
+ parseNote,
15
+ } from '../../core/pitch/index.js';
16
+ import { type Chord, type ChordQuality, makeChord, type PitchSpelling } from '../chord/index.js';
17
+
18
+ /**
19
+ * Matches a chord root: a letter A-G (case-insensitive, like {@link parseNote})
20
+ * with an optional single-direction accidental.
21
+ */
22
+ const ROOT_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?/;
13
23
 
14
24
  /** Matches a bare note usable as a slash-chord bass (no octave). */
15
- const BASS_RE = /^[A-G](?:#{1,2}|b{1,2})?$/;
25
+ const BASS_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?$/;
16
26
 
17
27
  /** Recognized quality suffixes, keyed by their exact lead-sheet spelling. */
18
28
  const QUALITY_MAP: Record<string, ChordQuality> = {
@@ -114,22 +124,44 @@ function pitchClass(value: number): number {
114
124
  return ((Math.trunc(value) % 12) + 12) % 12;
115
125
  }
116
126
 
117
- /** Pitch class of a bare note token (letter plus optional accidental). */
118
- function bassPitchClass(token: string): number {
119
- return noteToPitchClass(parseNote(token));
127
+ /** Build a chord and record the parsed spellings as enharmonic hints. */
128
+ function makeSpelledChord(root: Note, quality: ChordQuality, bass?: Note): Chord {
129
+ const chord = makeChord(
130
+ noteToPitchClass(root),
131
+ quality,
132
+ bass === undefined ? undefined : noteToPitchClass(bass),
133
+ );
134
+ chord.rootSpelling = { letter: root.letter, alter: root.alter };
135
+ if (bass !== undefined) {
136
+ chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
137
+ }
138
+ return chord;
120
139
  }
121
140
 
122
141
  /**
123
142
  * Parse a lead-sheet chord symbol into a {@link Chord}.
124
143
  *
125
144
  * Accepts `<root><quality>[/<bass>]`, e.g. `Cmaj7`, `F#m7b5`, `Bb7`, `C/G`,
126
- * `C6/9`. The `/` after a quality is ambiguous between a slash bass and the
127
- * `6/9` quality; it resolves to a bass only when the token following it is
128
- * itself a valid note.
145
+ * `C6/9`, `C6/9/E`. Root and bass letters are case-insensitive, matching
146
+ * {@link parseNote}. A `/` is ambiguous between a slash bass and the `6/9`
147
+ * quality; the text splits at the last `/` whose right-hand side is a valid
148
+ * bare note preceded by a recognized quality, so `6/9` (with or without a
149
+ * further slash bass) parses as a quality.
150
+ *
151
+ * The parsed chord carries the root/bass spellings as enharmonic hints
152
+ * (`rootSpelling`/`bassSpelling`) so {@link formatChordSymbol} can reproduce
153
+ * flat spellings such as `Bbmaj7` instead of respelling them with sharps.
129
154
  *
130
155
  * @param text The chord symbol text.
131
156
  * @returns The parsed chord.
132
157
  * @throws If the root or quality is not recognized.
158
+ * @example
159
+ * ```ts
160
+ * import { parseChordSymbol, formatChordSymbol } from '@libraz/libcantus';
161
+ * const c = parseChordSymbol('Bbmaj7');
162
+ * formatChordSymbol(c); // 'Bbmaj7' — flat spelling is preserved on round-trip
163
+ * ```
164
+ * @category Chords
133
165
  */
134
166
  export function parseChordSymbol(text: string): Chord {
135
167
  const trimmed = text.trim();
@@ -138,31 +170,41 @@ export function parseChordSymbol(text: string): Chord {
138
170
  throw new Error(`Invalid chord symbol: ${text}`);
139
171
  }
140
172
  const rootToken = rootMatch[0];
141
- const rootPc = noteToPitchClass(parseNote(rootToken));
173
+ const rootNote = parseNote(rootToken);
142
174
  const rest = trimmed.slice(rootToken.length);
143
175
 
144
- const slashIndex = rest.indexOf('/');
145
- if (slashIndex === -1) {
146
- const quality = QUALITY_MAP[rest];
147
- if (quality === undefined) {
148
- throw new Error(`Unrecognized chord quality: ${text}`);
176
+ // Split at the last '/' so the '6/9' quality's own slash never masks a
177
+ // trailing slash bass (e.g. 'C6/9/E').
178
+ const slashIndex = rest.lastIndexOf('/');
179
+ if (slashIndex !== -1) {
180
+ const before = rest.slice(0, slashIndex);
181
+ const after = rest.slice(slashIndex + 1);
182
+ const beforeQuality = QUALITY_MAP[before];
183
+ if (beforeQuality !== undefined && BASS_RE.test(after)) {
184
+ return makeSpelledChord(rootNote, beforeQuality, parseNote(after));
149
185
  }
150
- return makeChord(rootPc, quality);
151
186
  }
152
187
 
153
- const before = rest.slice(0, slashIndex);
154
- const after = rest.slice(slashIndex + 1);
155
- const beforeQuality = QUALITY_MAP[before];
156
- if (beforeQuality !== undefined && BASS_RE.test(after)) {
157
- return makeChord(rootPc, beforeQuality, bassPitchClass(after));
188
+ const quality = QUALITY_MAP[rest];
189
+ if (quality === undefined) {
190
+ throw new Error(`Unrecognized chord quality: ${text}`);
158
191
  }
192
+ return makeSpelledChord(rootNote, quality);
193
+ }
159
194
 
160
- const combinedQuality = QUALITY_MAP[rest];
161
- if (combinedQuality !== undefined) {
162
- return makeChord(rootPc, combinedQuality);
195
+ /**
196
+ * Name a pitch class, preferring a spelling hint when it is still valid.
197
+ *
198
+ * A hint is used only when no explicit sharp/flat preference was given and the
199
+ * hint still resolves to `pc` (a stale hint left over after transposition is
200
+ * ignored). Otherwise the pitch class is respelled from the requested table.
201
+ */
202
+ function pitchClassName(pc: number, hint: PitchSpelling | undefined, flats?: boolean): string {
203
+ if (flats === undefined && hint !== undefined && noteToPitchClass(hint) === pc) {
204
+ return formatNote(hint);
163
205
  }
164
-
165
- throw new Error(`Unrecognized chord quality: ${text}`);
206
+ const note = midiToNote(60 + pc, flats ? 'flat' : 'sharp');
207
+ return formatNote({ letter: note.letter, alter: note.alter });
166
208
  }
167
209
 
168
210
  /**
@@ -170,22 +212,29 @@ export function parseChordSymbol(text: string): Chord {
170
212
  *
171
213
  * The inverse of {@link parseChordSymbol}: each quality maps to one canonical
172
214
  * suffix spelling, and a slash bass is appended only when it differs from the
173
- * root.
215
+ * root. When the chord carries `rootSpelling`/`bassSpelling` hints (as chords
216
+ * from {@link parseChordSymbol} do) and no explicit `flats` preference is
217
+ * given, the hints are reused so flat symbols round-trip unchanged.
174
218
  *
175
219
  * @param chord The chord to format.
176
220
  * @param opts Formatting options.
177
- * @param opts.flats Prefer flat spellings over sharps for altered roots/basses.
221
+ * @param opts.flats Prefer flat spellings over sharps for altered roots/basses;
222
+ * passing either `true` or `false` overrides any spelling hints.
178
223
  * @returns The chord symbol text.
224
+ * @example
225
+ * ```ts
226
+ * import { makeChord, formatChordSymbol } from '@libraz/libcantus';
227
+ * formatChordSymbol(makeChord(0, 'min7')); // 'Cm7'
228
+ * ```
229
+ * @category Chords
179
230
  */
180
231
  export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): string {
181
- const spelling = opts?.flats ? 'flat' : 'sharp';
182
- const rootNote = midiToNote(60 + pitchClass(chord.rootPc), spelling);
183
- const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
232
+ const rootPc = pitchClass(chord.rootPc);
233
+ const rootName = pitchClassName(rootPc, chord.rootSpelling, opts?.flats);
184
234
  const suffix = CANONICAL_SUFFIX[chord.quality];
185
235
  let symbol = `${rootName}${suffix}`;
186
- if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== pitchClass(chord.rootPc)) {
187
- const bassNote = midiToNote(60 + pitchClass(chord.bassPc), spelling);
188
- symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
236
+ if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== rootPc) {
237
+ symbol += `/${pitchClassName(pitchClass(chord.bassPc), chord.bassSpelling, opts?.flats)}`;
189
238
  }
190
239
  return symbol;
191
240
  }
@@ -198,6 +247,12 @@ export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): str
198
247
  * @param opts Formatting options for the result; see {@link formatChordSymbol}.
199
248
  * @returns The transposed chord symbol text.
200
249
  * @throws If `text` does not parse as a chord symbol.
250
+ * @example
251
+ * ```ts
252
+ * import { transposeChordSymbol } from '@libraz/libcantus';
253
+ * transposeChordSymbol('C/G', 2); // 'D/A'
254
+ * ```
255
+ * @category Chords
201
256
  */
202
257
  export function transposeChordSymbol(
203
258
  text: string,
@@ -0,0 +1,5 @@
1
+ export { nextVoicing, voiceLeadingCost } from './leading.js';
2
+ export type { VoiceRange, VoicingOptions } from './satb.js';
3
+ export { SATB_RANGES, voiceChord, voiceProgression } from './satb.js';
4
+ export type { StyledVoicingOptions, VoicingStyle } from './styled.js';
5
+ export { voiceChordStyled } from './styled.js';