@libraz/libcantus 0.9.3 → 0.9.5

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 (201) hide show
  1. package/README.md +29 -20
  2. package/README_ja.md +268 -0
  3. package/dist/analyze/index.cjs +39 -1947
  4. package/dist/analyze/index.cjs.map +1 -1
  5. package/dist/analyze/index.d.cts +161 -91
  6. package/dist/analyze/index.d.ts +161 -91
  7. package/dist/analyze/index.js +19 -8
  8. package/dist/chunk-2A2QMA74.cjs +261 -0
  9. package/dist/chunk-2A2QMA74.cjs.map +1 -0
  10. package/dist/chunk-2PTFGVD7.js +261 -0
  11. package/dist/chunk-2PTFGVD7.js.map +1 -0
  12. package/dist/chunk-357ZDPVU.cjs +162 -0
  13. package/dist/chunk-357ZDPVU.cjs.map +1 -0
  14. package/dist/chunk-3KX76RLG.js +1548 -0
  15. package/dist/chunk-3KX76RLG.js.map +1 -0
  16. package/dist/chunk-4VZ5MZAM.cjs +889 -0
  17. package/dist/chunk-4VZ5MZAM.cjs.map +1 -0
  18. package/dist/{chunk-C5XYKUVG.js → chunk-7EONYVI5.js} +471 -171
  19. package/dist/chunk-7EONYVI5.js.map +1 -0
  20. package/dist/{chunk-IT2O4XFE.js → chunk-7JEP3MFY.js} +42 -34
  21. package/dist/chunk-7JEP3MFY.js.map +1 -0
  22. package/dist/{chunk-4MHA432G.js → chunk-AID75RN3.js} +572 -250
  23. package/dist/chunk-AID75RN3.js.map +1 -0
  24. package/dist/chunk-BSLTLGM3.cjs +497 -0
  25. package/dist/chunk-BSLTLGM3.cjs.map +1 -0
  26. package/dist/chunk-CU6VG2YM.cjs +109 -0
  27. package/dist/chunk-CU6VG2YM.cjs.map +1 -0
  28. package/dist/{chunk-EH6EM37U.js → chunk-DZMHIGI4.js} +81 -59
  29. package/dist/chunk-DZMHIGI4.js.map +1 -0
  30. package/dist/{chunk-AKLYPWIN.js → chunk-EDOTEABK.js} +45 -19
  31. package/dist/chunk-EDOTEABK.js.map +1 -0
  32. package/dist/{chunk-2ZWMSJH6.js → chunk-HN2OEUN2.js} +7 -4
  33. package/dist/chunk-HN2OEUN2.js.map +1 -0
  34. package/dist/{chunk-MIWO425M.js → chunk-KBTTEX7Q.js} +128 -43
  35. package/dist/chunk-KBTTEX7Q.js.map +1 -0
  36. package/dist/chunk-KQN2ZYND.cjs +724 -0
  37. package/dist/chunk-KQN2ZYND.cjs.map +1 -0
  38. package/dist/chunk-MPTVJIVG.cjs +3176 -0
  39. package/dist/chunk-MPTVJIVG.cjs.map +1 -0
  40. package/dist/chunk-MTMZTMWM.cjs +53 -0
  41. package/dist/chunk-MTMZTMWM.cjs.map +1 -0
  42. package/dist/{chunk-CHMXELDS.js → chunk-NNB67XA2.js} +372 -197
  43. package/dist/chunk-NNB67XA2.js.map +1 -0
  44. package/dist/chunk-OL3IHHSJ.cjs +1204 -0
  45. package/dist/chunk-OL3IHHSJ.cjs.map +1 -0
  46. package/dist/chunk-PI3UXRWK.cjs +528 -0
  47. package/dist/chunk-PI3UXRWK.cjs.map +1 -0
  48. package/dist/chunk-R2VVFAIN.js +528 -0
  49. package/dist/chunk-R2VVFAIN.js.map +1 -0
  50. package/dist/chunk-T64C7ZD7.cjs +1 -0
  51. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  52. package/dist/chunk-WFDXOLUB.cjs +337 -0
  53. package/dist/chunk-WFDXOLUB.cjs.map +1 -0
  54. package/dist/chunk-WTAN4ACG.cjs +1548 -0
  55. package/dist/chunk-WTAN4ACG.cjs.map +1 -0
  56. package/dist/chunk-YFJXS3AU.cjs +33 -0
  57. package/dist/chunk-YFJXS3AU.cjs.map +1 -0
  58. package/dist/{chunk-GOAW4VYL.js → chunk-YUE6TJ4M.js} +315 -145
  59. package/dist/chunk-YUE6TJ4M.js.map +1 -0
  60. package/dist/{chunk-62QZKCJQ.js → chunk-ZGLJM4A4.js} +10 -6
  61. package/dist/chunk-ZGLJM4A4.js.map +1 -0
  62. package/dist/core/index.cjs +140 -647
  63. package/dist/core/index.cjs.map +1 -1
  64. package/dist/core/index.d.cts +313 -41
  65. package/dist/core/index.d.ts +313 -41
  66. package/dist/core/index.js +64 -16
  67. package/dist/{function-DVH4Eusr.d.ts → function-CxixTB02.d.cts} +122 -10
  68. package/dist/{function-DYt4iKsn.d.cts → function-DTCt675z.d.ts} +122 -10
  69. package/dist/generate/index.cjs +28 -4085
  70. package/dist/generate/index.cjs.map +1 -1
  71. package/dist/generate/index.d.cts +374 -66
  72. package/dist/generate/index.d.ts +374 -66
  73. package/dist/generate/index.js +23 -9
  74. package/dist/{index-Jc0Ryi79.d.ts → index-B2Ulxxpo.d.cts} +21 -5
  75. package/dist/{index-CpOsP1y9.d.cts → index-BkYJ0xMq.d.cts} +18 -17
  76. package/dist/{index-CxqBQtH1.d.cts → index-BuaB-ypt.d.ts} +21 -5
  77. package/dist/index-CAN7v-kw.d.ts +199 -0
  78. package/dist/{index-Bt4tOUD9.d.ts → index-COw1Ky9g.d.ts} +93 -8
  79. package/dist/{index-Dikst44v.d.cts → index-Cfx8xgH0.d.ts} +14 -11
  80. package/dist/{index-DxvARIP2.d.ts → index-DRtIhlJj.d.ts} +18 -17
  81. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  82. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  83. package/dist/index-alrRninU.d.cts +199 -0
  84. package/dist/{index-BNlGP86A.d.cts → index-oTQbYYIs.d.cts} +93 -8
  85. package/dist/index-tR9jTn26.d.cts +275 -0
  86. package/dist/index-tR9jTn26.d.ts +275 -0
  87. package/dist/{index-Bg5dYMmZ.d.ts → index-x2vcsduE.d.cts} +14 -11
  88. package/dist/index.cjs +429 -7383
  89. package/dist/index.cjs.map +1 -1
  90. package/dist/index.d.cts +15 -15
  91. package/dist/index.d.ts +15 -15
  92. package/dist/index.js +121 -27
  93. package/dist/model/index.cjs +8 -2562
  94. package/dist/model/index.cjs.map +1 -1
  95. package/dist/model/index.d.cts +471 -81
  96. package/dist/model/index.d.ts +471 -81
  97. package/dist/model/index.js +6 -7
  98. package/dist/styled-Bl0ZK28h.d.ts +543 -0
  99. package/dist/styled-Crv6SA4S.d.cts +543 -0
  100. package/dist/theory/index.cjs +172 -1822
  101. package/dist/theory/index.cjs.map +1 -1
  102. package/dist/theory/index.d.cts +151 -233
  103. package/dist/theory/index.d.ts +151 -233
  104. package/dist/theory/index.js +30 -7
  105. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  106. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  107. package/package.json +34 -13
  108. package/dist/chunk-2ZWMSJH6.js.map +0 -1
  109. package/dist/chunk-4MHA432G.js.map +0 -1
  110. package/dist/chunk-62QZKCJQ.js.map +0 -1
  111. package/dist/chunk-AKLYPWIN.js.map +0 -1
  112. package/dist/chunk-C5XYKUVG.js.map +0 -1
  113. package/dist/chunk-CHMXELDS.js.map +0 -1
  114. package/dist/chunk-DYWFNC4A.js +0 -152
  115. package/dist/chunk-DYWFNC4A.js.map +0 -1
  116. package/dist/chunk-EH6EM37U.js.map +0 -1
  117. package/dist/chunk-FBQK3ZQN.js +0 -131
  118. package/dist/chunk-FBQK3ZQN.js.map +0 -1
  119. package/dist/chunk-GOAW4VYL.js.map +0 -1
  120. package/dist/chunk-I5R7ZBJS.js +0 -202
  121. package/dist/chunk-I5R7ZBJS.js.map +0 -1
  122. package/dist/chunk-IT2O4XFE.js.map +0 -1
  123. package/dist/chunk-MIWO425M.js.map +0 -1
  124. package/dist/chunk-SGU7SPLK.js +0 -924
  125. package/dist/chunk-SGU7SPLK.js.map +0 -1
  126. package/dist/index-CGMZNCx6.d.cts +0 -140
  127. package/dist/index-CGMZNCx6.d.ts +0 -140
  128. package/dist/index-CnZCo29u.d.cts +0 -108
  129. package/dist/index-JnF4v9bx.d.ts +0 -108
  130. package/dist/styled-DZ7GjShb.d.ts +0 -282
  131. package/dist/styled-ZTbdaVFM.d.cts +0 -282
  132. package/src/analyze/arrange/index.ts +0 -21
  133. package/src/analyze/arrange/internal.ts +0 -127
  134. package/src/analyze/arrange/tension.ts +0 -199
  135. package/src/analyze/arrange/tracks.ts +0 -312
  136. package/src/analyze/detect/index.ts +0 -272
  137. package/src/analyze/functional/borrowed.ts +0 -84
  138. package/src/analyze/functional/cadence.ts +0 -66
  139. package/src/analyze/functional/function.ts +0 -184
  140. package/src/analyze/functional/index.ts +0 -23
  141. package/src/analyze/functional/internal.ts +0 -29
  142. package/src/analyze/functional/roman.ts +0 -402
  143. package/src/analyze/index.ts +0 -49
  144. package/src/analyze/timeline/index.ts +0 -451
  145. package/src/analyze/voice/index.ts +0 -339
  146. package/src/core/event-index/index.ts +0 -104
  147. package/src/core/index.ts +0 -61
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -302
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -76
  152. package/src/core/tuning/index.ts +0 -186
  153. package/src/core/types.ts +0 -29
  154. package/src/core/validation/index.ts +0 -119
  155. package/src/generate/bass/index.ts +0 -373
  156. package/src/generate/countermelody/index.ts +0 -470
  157. package/src/generate/drums/beat.ts +0 -266
  158. package/src/generate/drums/euclid.ts +0 -65
  159. package/src/generate/drums/fills.ts +0 -356
  160. package/src/generate/drums/ghost.ts +0 -119
  161. package/src/generate/drums/hihat.ts +0 -285
  162. package/src/generate/drums/hit.ts +0 -31
  163. package/src/generate/drums/index.ts +0 -361
  164. package/src/generate/drums/internal.ts +0 -257
  165. package/src/generate/drums/kick.ts +0 -160
  166. package/src/generate/drums/percussion.ts +0 -121
  167. package/src/generate/drums/rng.ts +0 -7
  168. package/src/generate/drums/swing.ts +0 -71
  169. package/src/generate/groove/index.ts +0 -351
  170. package/src/generate/harmonize/index.ts +0 -451
  171. package/src/generate/index.ts +0 -58
  172. package/src/generate/motif/index.ts +0 -422
  173. package/src/generate/progression/index.ts +0 -362
  174. package/src/generate/reharmony/index.ts +0 -323
  175. package/src/generate/rhythm/index.ts +0 -175
  176. package/src/index.ts +0 -13
  177. package/src/model/chord.ts +0 -442
  178. package/src/model/index.ts +0 -15
  179. package/src/model/interval.ts +0 -82
  180. package/src/model/key.ts +0 -266
  181. package/src/model/note.ts +0 -176
  182. package/src/model/progression.ts +0 -161
  183. package/src/model/shared.ts +0 -12
  184. package/src/theory/chord/index.ts +0 -375
  185. package/src/theory/chordscale/index.ts +0 -374
  186. package/src/theory/counterpoint/index.ts +0 -230
  187. package/src/theory/harmony/index.ts +0 -77
  188. package/src/theory/index.ts +0 -112
  189. package/src/theory/safety/index.ts +0 -488
  190. package/src/theory/scale/degrees.ts +0 -123
  191. package/src/theory/scale/index.ts +0 -28
  192. package/src/theory/scale/key.ts +0 -56
  193. package/src/theory/scale/masks.ts +0 -153
  194. package/src/theory/spelling/index.ts +0 -249
  195. package/src/theory/symbol/index.ts +0 -268
  196. package/src/theory/voicing/index.ts +0 -5
  197. package/src/theory/voicing/internal.ts +0 -168
  198. package/src/theory/voicing/leading.ts +0 -112
  199. package/src/theory/voicing/satb.ts +0 -216
  200. package/src/theory/voicing/styled.ts +0 -199
  201. package/tsconfig.json +0 -23
@@ -1,374 +0,0 @@
1
- import type { Chord } from '../chord/index.js';
2
- import { chordPitchClasses } from '../chord/index.js';
3
- import { NAMED_SCALES } from '../scale/index.js';
4
-
5
- /** Reduce a value to a pitch class in [0, 11]. */
6
- function pitchClass(value: number): number {
7
- return ((Math.trunc(value) % 12) + 12) % 12;
8
- }
9
-
10
- /** Count the set bits (scale tones) in a 12-bit mode mask. */
11
- function popcount12(mask: number): number {
12
- let count = 0;
13
- for (let n = 0; n < 12; n += 1) {
14
- count += (mask >> n) & 1;
15
- }
16
- return count;
17
- }
18
-
19
- /** Test whether a pitch class belongs to a mode mask rooted on `scaleRootPc`. */
20
- function maskHasPitchClass(mask: number, scaleRootPc: number, pc: number): boolean {
21
- const offset = pitchClass(pc - scaleRootPc);
22
- return ((mask >> offset) & 1) === 1;
23
- }
24
-
25
- /**
26
- * Test whether every chord pitch class is contained in a scale.
27
- *
28
- * @param chordPcs The chord's pitch classes (0..11).
29
- * @param scaleMask The scale's 12-bit mode mask.
30
- * @param scaleRootPc The pitch class the mask is rooted on.
31
- * @returns True if the scale is a superset of the chord.
32
- *
33
- * @category Scales
34
- */
35
- export function scaleMatchesChord(
36
- chordPcs: number[],
37
- scaleMask: number,
38
- scaleRootPc: number,
39
- ): boolean {
40
- for (const pc of chordPcs) {
41
- if (!maskHasPitchClass(scaleMask, scaleRootPc, pc)) {
42
- return false;
43
- }
44
- }
45
- return true;
46
- }
47
-
48
- /**
49
- * A named scale rooted on a pitch class that fits over a chord.
50
- *
51
- * @category Scales
52
- */
53
- export type ChordScaleMatch = {
54
- name: string;
55
- rootPc: number;
56
- };
57
-
58
- /**
59
- * Scale names whose mask duplicates another {@link NAMED_SCALES} entry
60
- * (major/ionian, naturalMinor/aeolian). Chord-scale analysis conventionally
61
- * speaks in mode names, so these aliases are skipped and only the modal name
62
- * is reported.
63
- */
64
- const ALIASED_SCALE_NAMES = new Set(['major', 'naturalMinor']);
65
-
66
- /** Number of tones in a heptatonic (seven-note) scale. */
67
- const HEPTATONIC_SIZE = 7;
68
-
69
- /**
70
- * List the scales that fit over a chord, best fit first.
71
- *
72
- * Only the chord root is considered as the scale root, matching the
73
- * conventional chord-scale relationship. Every entry of {@link NAMED_SCALES}
74
- * whose pitch-class set is a superset of the chord's is returned once per
75
- * distinct pitch-class set (aliased masks such as major/ionian report only the
76
- * modal name), ranked by fewest extra scale tones beyond the chord, then by
77
- * scale size (heptatonic before larger scales), then by scale name. For bare
78
- * triads and smaller chords, heptatonic scales rank before pentatonics and
79
- * other sizes: a pentatonic adds no modal color over a triad, so the seven-note
80
- * modes are the more useful answer. The chromatic scale is only returned as a
81
- * fallback when no other scale contains the chord.
82
- *
83
- * @param chord The chord to fit scales over.
84
- * @returns The matching scales rooted on the chord root, best fit first.
85
- *
86
- * @example
87
- * ```ts
88
- * import { makeChord, chordScales } from '@libraz/libcantus';
89
- * const scales = chordScales(makeChord(0, 'maj7'));
90
- * // scales for Cmaj7, best fit first; each is { name, rootPc: 0 }
91
- * ```
92
- *
93
- * @category Scales
94
- */
95
- export function chordScales(chord: Chord): ChordScaleMatch[] {
96
- const chordPcs = chordPitchClasses(chord);
97
- const rootPc = pitchClass(chord.rootPc);
98
- const ranked: { name: string; extra: number; size: number }[] = [];
99
- const seenMasks = new Set<number>();
100
- for (const name of Object.keys(NAMED_SCALES)) {
101
- if (name === 'chromatic' || ALIASED_SCALE_NAMES.has(name)) {
102
- continue;
103
- }
104
- const mask = NAMED_SCALES[name];
105
- if (mask === undefined || seenMasks.has(mask)) {
106
- continue;
107
- }
108
- seenMasks.add(mask);
109
- if (scaleMatchesChord(chordPcs, mask, rootPc)) {
110
- const size = popcount12(mask);
111
- ranked.push({ name, extra: size - chordPcs.length, size });
112
- }
113
- }
114
- if (ranked.length === 0) {
115
- // No diatonic-style scale contains the chord; fall back to the chromatic
116
- // scale, which trivially contains every pitch class.
117
- if (NAMED_SCALES.chromatic !== undefined) {
118
- return [{ name: 'chromatic', rootPc }];
119
- }
120
- return [];
121
- }
122
- const preferHeptatonic = chordPcs.length <= 3;
123
- ranked.sort((a, b) => {
124
- if (preferHeptatonic && (a.size === HEPTATONIC_SIZE) !== (b.size === HEPTATONIC_SIZE)) {
125
- return a.size === HEPTATONIC_SIZE ? -1 : 1;
126
- }
127
- if (a.extra !== b.extra) {
128
- return a.extra - b.extra;
129
- }
130
- if (a.size !== b.size) {
131
- return a.size - b.size;
132
- }
133
- return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
134
- });
135
- return ranked.map((entry) => ({ name: entry.name, rootPc }));
136
- }
137
-
138
- /**
139
- * List a scale's avoid notes over a chord.
140
- *
141
- * An avoid note is a non-chord scale tone that lies a semitone directly above a
142
- * chord tone; sounding it against the chord clashes. The scale is rooted on the
143
- * chord root. If the scale does not contain the chord, no avoid notes exist.
144
- *
145
- * @param chord The chord providing the chord tones.
146
- * @param scaleName A key of {@link NAMED_SCALES}.
147
- * @returns The avoid-note pitch classes, sorted ascending in [0, 11].
148
- *
149
- * @category Scales
150
- */
151
- export function avoidNotes(chord: Chord, scaleName: string): number[] {
152
- const mask = NAMED_SCALES[scaleName];
153
- if (mask === undefined) {
154
- return [];
155
- }
156
- const rootPc = pitchClass(chord.rootPc);
157
- const chordPcs = chordPitchClasses(chord);
158
- if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
159
- return [];
160
- }
161
- const chordSet = new Set(chordPcs);
162
- const avoid: number[] = [];
163
- for (let pc = 0; pc < 12; pc += 1) {
164
- if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc)) {
165
- continue;
166
- }
167
- if (chordSet.has(pitchClass(pc - 1))) {
168
- avoid.push(pc);
169
- }
170
- }
171
- return avoid.sort((a, b) => a - b);
172
- }
173
-
174
- /**
175
- * List a scale's available tensions over a chord.
176
- *
177
- * Available tensions are scale tones that are neither chord tones nor avoid
178
- * notes, i.e. the usable color tones (typically the 9/11/13 region). The scale
179
- * is rooted on the chord root. If the scale does not contain the chord, there
180
- * are no available tensions.
181
- *
182
- * @param chord The chord providing the chord tones.
183
- * @param scaleName A key of {@link NAMED_SCALES}.
184
- * @returns The available-tension pitch classes, sorted ascending in [0, 11].
185
- *
186
- * @example
187
- * ```ts
188
- * import { makeChord, availableTensions } from '@libraz/libcantus';
189
- * availableTensions(makeChord(0, 'maj7'), 'ionian'); // [2, 9] — color tones over Cmaj7
190
- * ```
191
- *
192
- * @category Scales
193
- */
194
- export function availableTensions(chord: Chord, scaleName: string): number[] {
195
- const mask = NAMED_SCALES[scaleName];
196
- if (mask === undefined) {
197
- return [];
198
- }
199
- const rootPc = pitchClass(chord.rootPc);
200
- const chordPcs = chordPitchClasses(chord);
201
- if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
202
- return [];
203
- }
204
- const chordSet = new Set(chordPcs);
205
- const avoidSet = new Set(avoidNotes(chord, scaleName));
206
- const tensions: number[] = [];
207
- for (let pc = 0; pc < 12; pc += 1) {
208
- if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc) || avoidSet.has(pc)) {
209
- continue;
210
- }
211
- tensions.push(pc);
212
- }
213
- return tensions.sort((a, b) => a - b);
214
- }
215
-
216
- /**
217
- * A scale fit over a chord together with its avoid notes and tensions.
218
- *
219
- * @category Scales
220
- */
221
- export type ChordScaleReportEntry = {
222
- name: string;
223
- rootPc: number;
224
- avoid: number[];
225
- tensions: number[];
226
- };
227
-
228
- /**
229
- * Report the best-fitting scales for a chord with their avoid notes and tensions.
230
- *
231
- * Combines {@link chordScales}, {@link avoidNotes}, and {@link availableTensions}
232
- * into a single ergonomic result, ordered best fit first.
233
- *
234
- * @param chord The chord to analyze.
235
- * @param limit Optional maximum number of scales to report; all by default.
236
- * @returns One entry per reported scale, best fit first.
237
- *
238
- * @category Scales
239
- */
240
- export function chordScaleReport(chord: Chord, limit?: number): ChordScaleReportEntry[] {
241
- const matches = chordScales(chord);
242
- const chosen = limit === undefined ? matches : matches.slice(0, Math.max(0, limit));
243
- return chosen.map((match) => ({
244
- name: match.name,
245
- rootPc: match.rootPc,
246
- avoid: avoidNotes(chord, match.name),
247
- tensions: availableTensions(chord, match.name),
248
- }));
249
- }
250
-
251
- /**
252
- * A chord paired with the scale chosen for it by {@link scalesForChanges}.
253
- *
254
- * @category Scales
255
- */
256
- export type ScaleChoice = {
257
- chord: Chord;
258
- scale: ChordScaleMatch;
259
- };
260
-
261
- /**
262
- * Small per-candidate penalty added in {@link scalesForChanges} so that, among
263
- * choices of otherwise-equal transition cost, the tighter best-fit scale wins.
264
- */
265
- const RANK_PENALTY = 0.01;
266
-
267
- /** Build the pitch-class set of a named scale rooted on `rootPc`. */
268
- function scalePitchClassSet(name: string, rootPc: number): Set<number> {
269
- const mask = NAMED_SCALES[name];
270
- const pcs = new Set<number>();
271
- if (mask === undefined) {
272
- return pcs;
273
- }
274
- for (let n = 0; n < 12; n += 1) {
275
- if (((mask >> n) & 1) === 1) {
276
- pcs.add(pitchClass(rootPc + n));
277
- }
278
- }
279
- return pcs;
280
- }
281
-
282
- /** Count pitch classes that belong to exactly one of two sets. */
283
- function symmetricDifferenceSize(a: Set<number>, b: Set<number>): number {
284
- let count = 0;
285
- for (const pc of a) {
286
- if (!b.has(pc)) {
287
- count += 1;
288
- }
289
- }
290
- for (const pc of b) {
291
- if (!a.has(pc)) {
292
- count += 1;
293
- }
294
- }
295
- return count;
296
- }
297
-
298
- /**
299
- * Choose one scale per chord across a progression, favoring continuity.
300
- *
301
- * Each chord's {@link chordScales} candidates form a stage in a Viterbi-style
302
- * dynamic program. The transition cost between adjacent choices is the number
303
- * of pitch classes that differ between their pitch-class sets (the symmetric
304
- * difference), plus a small penalty for straying from a chord's best-fit scale
305
- * so that ties break toward the tighter fit. The minimum-total-cost path is
306
- * returned, one {@link ScaleChoice} per input chord in the original order.
307
- *
308
- * @param chords The chord sequence to choose scales for.
309
- * @returns One scale choice per chord, in input order.
310
- *
311
- * @category Scales
312
- */
313
- export function scalesForChanges(chords: Chord[]): ScaleChoice[] {
314
- if (chords.length === 0) {
315
- return [];
316
- }
317
- const candidateLists = chords.map((chord) => chordScales(chord));
318
- const pcSets = candidateLists.map((candidates) =>
319
- candidates.map((match) => scalePitchClassSet(match.name, match.rootPc)),
320
- );
321
-
322
- const first = candidateLists[0] ?? [];
323
- const dp: number[][] = [first.map((_, j) => j * RANK_PENALTY)];
324
- const back: number[][] = [first.map(() => -1)];
325
-
326
- for (let i = 1; i < candidateLists.length; i += 1) {
327
- const candidates = candidateLists[i] ?? [];
328
- const prevCosts = dp[i - 1] ?? [];
329
- const prevSets = pcSets[i - 1] ?? [];
330
- const curSets = pcSets[i] ?? [];
331
- const stageCosts: number[] = [];
332
- const stageBack: number[] = [];
333
- for (let j = 0; j < candidates.length; j += 1) {
334
- let bestCost = Number.POSITIVE_INFINITY;
335
- let bestPrev = -1;
336
- for (let k = 0; k < prevCosts.length; k += 1) {
337
- const transition = symmetricDifferenceSize(
338
- prevSets[k] ?? new Set(),
339
- curSets[j] ?? new Set(),
340
- );
341
- const cost = (prevCosts[k] ?? 0) + transition;
342
- if (cost < bestCost) {
343
- bestCost = cost;
344
- bestPrev = k;
345
- }
346
- }
347
- stageCosts.push(bestCost + j * RANK_PENALTY);
348
- stageBack.push(bestPrev);
349
- }
350
- dp.push(stageCosts);
351
- back.push(stageBack);
352
- }
353
-
354
- const lastCosts = dp[dp.length - 1] ?? [];
355
- let bestFinal = 0;
356
- for (let j = 1; j < lastCosts.length; j += 1) {
357
- if ((lastCosts[j] ?? Number.POSITIVE_INFINITY) < (lastCosts[bestFinal] ?? 0)) {
358
- bestFinal = j;
359
- }
360
- }
361
-
362
- const chosenIndices: number[] = new Array(candidateLists.length);
363
- let current = bestFinal;
364
- for (let i = candidateLists.length - 1; i >= 0; i -= 1) {
365
- chosenIndices[i] = current;
366
- current = back[i]?.[current] ?? -1;
367
- }
368
-
369
- return chords.map((chord, i) => {
370
- const candidates = candidateLists[i] ?? [];
371
- const scale = candidates[chosenIndices[i] ?? 0] ?? { name: 'chromatic', rootPc: chord.rootPc };
372
- return { chord, scale };
373
- });
374
- }
@@ -1,230 +0,0 @@
1
- import { isConsonantInterval } from '../../core/interval/index.js';
2
- import type { KeyScale } from '../../core/types.js';
3
-
4
- /** Reduce a MIDI pitch (or pitch class) to a pitch class in [0, 11]. */
5
- function pitchClass(pitch: number): number {
6
- return ((Math.trunc(pitch) % 12) + 12) % 12;
7
- }
8
-
9
- /**
10
- * Whether an upper voice has crossed below a lower voice.
11
- *
12
- * @param upperPitch Pitch of the nominally higher voice.
13
- * @param lowerPitch Pitch of the nominally lower voice.
14
- * @returns True when the upper voice sits below the lower voice.
15
- * @category Voicing & Counterpoint
16
- */
17
- export function createsVoiceCrossing(upperPitch: number, lowerPitch: number): boolean {
18
- return upperPitch < lowerPitch;
19
- }
20
-
21
- /**
22
- * Whether two simultaneous pitches form a dissonance.
23
- *
24
- * @param a First pitch.
25
- * @param b Second pitch.
26
- * @param twoVoice When true, the perfect fourth counts as dissonant.
27
- * @returns True when the vertical interval is dissonant.
28
- * @category Voicing & Counterpoint
29
- */
30
- export function createsVerticalDissonance(a: number, b: number, twoVoice: boolean): boolean {
31
- return !isConsonantInterval(a - b, twoVoice);
32
- }
33
-
34
- /**
35
- * Whether a melodic move is a forbidden leap: a tritone, either seventh (minor
36
- * or major), or any leap wider than an octave.
37
- *
38
- * Operates on the raw absolute distance, so compound leaps are not reduced to
39
- * their simple class before the octave check.
40
- *
41
- * @param prev Starting pitch.
42
- * @param cur Ending pitch.
43
- * @returns True when the leap is forbidden in strict counterpoint.
44
- * @category Voicing & Counterpoint
45
- */
46
- export function isForbiddenMelodicLeap(prev: number, cur: number): boolean {
47
- const semis = Math.abs(cur - prev);
48
- return semis === 6 || semis === 10 || semis === 11 || semis > 12;
49
- }
50
-
51
- /** Reduce an interval to its simple class in [0, 11]. */
52
- function simpleClass(semitones: number): number {
53
- return Math.abs(semitones) % 12;
54
- }
55
-
56
- /** Whether a simple interval class is a perfect kind (unison/octave or fifth). */
57
- function isPerfectClass(cls: number): boolean {
58
- return cls === 0 || cls === 7;
59
- }
60
-
61
- /** Whether two voices move in the same direction (similar or parallel motion). */
62
- function similarMotion(aMove: number, bMove: number): boolean {
63
- return aMove !== 0 && bMove !== 0 && aMove > 0 === bMove > 0;
64
- }
65
-
66
- /** Whether both voices actually move (neither is stationary — excludes oblique motion). */
67
- function bothVoicesMove(aMove: number, bMove: number): boolean {
68
- return aMove !== 0 && bMove !== 0;
69
- }
70
-
71
- /**
72
- * Whether two voices move into consecutive perfect intervals of the same kind
73
- * (fifth-to-fifth, octave-to-octave, unison-to-unison).
74
- *
75
- * Both true parallels (similar motion) and anti-parallels — the same perfect
76
- * class reached by contrary motion, e.g. octave to octave with the voices moving
77
- * in opposite directions — are flagged, as both are forbidden in strict two-voice
78
- * counterpoint. The rule requires that both voices actually move: oblique motion
79
- * (either voice stationary) and the no-change case (identical pitches) are excluded.
80
- *
81
- * A fifth expanding to a twelfth counts (same perfect class); a fifth moving to
82
- * an octave does not (different perfect kinds — the direct/hidden case owned by
83
- * {@link createsHiddenParallelPerfect}).
84
- *
85
- * @category Voicing & Counterpoint
86
- */
87
- export function createsParallelPerfect(
88
- aPrev: number,
89
- aCur: number,
90
- bPrev: number,
91
- bCur: number,
92
- ): boolean {
93
- if (!bothVoicesMove(aCur - aPrev, bCur - bPrev)) {
94
- return false;
95
- }
96
- const nowClass = simpleClass(aCur - bCur);
97
- const prevClass = simpleClass(aPrev - bPrev);
98
- return isPerfectClass(nowClass) && nowClass === prevClass;
99
- }
100
-
101
- /**
102
- * Whether two voices move in consecutive parallel octaves (or unisons) by
103
- * similar motion.
104
- *
105
- * This is a strict subset of {@link createsParallelPerfect}: a similar-motion
106
- * octave-to-octave is the perfect-class-zero case that predicate already flags
107
- * (and it additionally catches the contrary-motion anti-parallel). Callers that
108
- * tally parallel violations should therefore use {@link createsParallelPerfect}
109
- * alone to avoid double counting; this predicate remains for callers wanting a
110
- * dedicated similar-motion octave test.
111
- *
112
- * @category Voicing & Counterpoint
113
- */
114
- export function createsParallelOctave(
115
- aPrev: number,
116
- aCur: number,
117
- bPrev: number,
118
- bCur: number,
119
- ): boolean {
120
- if (!similarMotion(aCur - aPrev, bCur - bPrev)) {
121
- return false;
122
- }
123
- return simpleClass(aCur - bCur) === 0 && simpleClass(aPrev - bPrev) === 0;
124
- }
125
-
126
- /**
127
- * Whether two voices move in consecutive parallel unisons — both landing on the
128
- * same pitch, having shared a pitch on the previous move.
129
- *
130
- * @category Voicing & Counterpoint
131
- */
132
- export function createsParallelUnison(
133
- aPrev: number,
134
- aCur: number,
135
- bPrev: number,
136
- bCur: number,
137
- ): boolean {
138
- if (aCur === aPrev || bCur === bPrev) {
139
- return false;
140
- }
141
- return aCur === bCur && aPrev === bPrev;
142
- }
143
-
144
- /**
145
- * Whether two voices reach a perfect interval by similar motion from an
146
- * imperfect one (a hidden/direct fifth or octave).
147
- *
148
- * The traditional step exception is applied: the approach is allowed when the
149
- * upper of the two voices moves by step, so only leaps into the perfect
150
- * interval are flagged.
151
- *
152
- * @category Voicing & Counterpoint
153
- */
154
- export function createsHiddenParallelPerfect(
155
- aPrev: number,
156
- aCur: number,
157
- bPrev: number,
158
- bCur: number,
159
- ): boolean {
160
- const aMove = aCur - aPrev;
161
- const bMove = bCur - bPrev;
162
- if (!similarMotion(aMove, bMove)) {
163
- return false;
164
- }
165
- const nowClass = simpleClass(aCur - bCur);
166
- const prevClass = simpleClass(aPrev - bPrev);
167
- // Approaching the same perfect class (e.g. fifth to fifth) is a true parallel
168
- // owned by createsParallelPerfect; approaching a different perfect interval
169
- // (fifth to octave, or vice versa) is the hidden/direct case flagged here.
170
- if (!isPerfectClass(nowClass) || prevClass === nowClass) {
171
- return false;
172
- }
173
- const upperMove = aCur >= bCur ? aMove : bMove;
174
- if (Math.abs(upperMove) <= 2) {
175
- return false; // upper voice moves by step — direct interval is acceptable
176
- }
177
- return true;
178
- }
179
-
180
- /**
181
- * Whether two voices overlap: the upper voice descends below where the lower
182
- * voice just was, or the lower voice rises above where the upper voice just was.
183
- * Distinct from a simultaneous voice crossing.
184
- *
185
- * @param upperPrev Previous pitch of the upper voice.
186
- * @param upperCur Current pitch of the upper voice.
187
- * @param lowerPrev Previous pitch of the lower voice.
188
- * @param lowerCur Current pitch of the lower voice.
189
- * @category Voicing & Counterpoint
190
- */
191
- export function createsVoiceOverlap(
192
- upperPrev: number,
193
- upperCur: number,
194
- lowerPrev: number,
195
- lowerCur: number,
196
- ): boolean {
197
- return upperCur < lowerPrev || lowerCur > upperPrev;
198
- }
199
-
200
- /**
201
- * Whether two adjacent upper voices are spaced more than a maximum apart
202
- * (commonly an octave). Bass-to-tenor spacing is conventionally exempt, so this
203
- * is meant for the upper voice pairs.
204
- *
205
- * @param upperPitch Pitch of the higher voice.
206
- * @param lowerPitch Pitch of the lower voice.
207
- * @param maxSemitones Maximum allowed spacing in semitones (default an octave).
208
- * @category Voicing & Counterpoint
209
- */
210
- export function exceedsSpacing(upperPitch: number, lowerPitch: number, maxSemitones = 12): boolean {
211
- return Math.abs(upperPitch - lowerPitch) > maxSemitones;
212
- }
213
-
214
- /**
215
- * Whether a leading tone resolves correctly upward to the tonic.
216
- *
217
- * @param prev The leading-tone pitch.
218
- * @param cur The following pitch.
219
- * @param key Key context supplying the tonic.
220
- * @returns True when `prev` is the leading tone and `cur` is the tonic a step above.
221
- * @category Voicing & Counterpoint
222
- */
223
- export function isLeadingToneResolution(prev: number, cur: number, key: KeyScale): boolean {
224
- const tonic = pitchClass(key.rootPc);
225
- const leading = (tonic + 11) % 12;
226
- if (pitchClass(prev) !== leading || pitchClass(cur) !== tonic) {
227
- return false;
228
- }
229
- return cur > prev && cur - prev <= 2;
230
- }
@@ -1,77 +0,0 @@
1
- import { type Chord, chordToneRole } from '../chord/index.js';
2
-
3
- /**
4
- * Harmonic role a pitch plays within a chord.
5
- *
6
- * @category Functional Harmony
7
- */
8
- export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'tension' | 'doubling';
9
-
10
- /**
11
- * How firmly a pitch is locked to the chord's identity.
12
- *
13
- * - `identity`: moving it produces a different chord (the root).
14
- * - `quality`: moving it flips the chord quality (the third).
15
- * - `voicing`: it can move freely without changing chord identity or quality.
16
- *
17
- * @category Functional Harmony
18
- */
19
- export type LockLevel = 'identity' | 'quality' | 'voicing';
20
-
21
- /**
22
- * A pitch's role, lock level, and owning chord.
23
- *
24
- * @category Functional Harmony
25
- */
26
- export type VoicedRole = {
27
- role: HarmonyRole;
28
- lock: LockLevel;
29
- belongsToChordId: number;
30
- };
31
-
32
- /**
33
- * Classify a pitch's harmonic role and lock level within a chord.
34
- *
35
- * The role comes from the pitch's interval class above the chord root: root (0),
36
- * third (3/4), fifth (6/7/8), seventh (10/11), and 9/11/13 tensions (2/5/9, and
37
- * the flat ninth 1). The root locks the chord identity, the third locks its
38
- * quality, and everything else is free voicing. In a suspended chord (a chord
39
- * whose intervals include a 4th (5) or 2nd (2) but no third), the suspended tone
40
- * takes the third's place as the quality-defining tone and is locked to
41
- * `quality`, since moving it changes the chord. Detecting an octave doubling
42
- * requires the surrounding voicing, which this single-pitch query does not carry,
43
- * so `doubling` is part of the type but not returned here.
44
- *
45
- * @param pitch MIDI pitch or bare pitch class.
46
- * @param chord The chord providing the root reference.
47
- * @param chordId Identifier stored on the result (defaults to 0).
48
- * @returns The pitch's role, lock level, and owning chord id.
49
- * @example
50
- * ```ts
51
- * import { roleOf, makeChord } from '@libraz/libcantus';
52
- * roleOf(64, makeChord(0, 'maj')); // E over C major
53
- * // { role: 'third', lock: 'quality', belongsToChordId: 0 }
54
- * ```
55
- * @category Functional Harmony
56
- */
57
- export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
58
- const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
59
- const tones = new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
60
- const hasThird = tones.has(3) || tones.has(4);
61
- const isSuspendedTone =
62
- !hasThird && ((interval === 5 && tones.has(5)) || (interval === 2 && tones.has(2)));
63
- const chordRole = chordToneRole(pitch, chord);
64
- let role: HarmonyRole;
65
- let lock: LockLevel;
66
- if (isSuspendedTone) {
67
- role = 'third'; // suspended tone occupies the third's quality-defining slot
68
- lock = 'quality';
69
- } else if (chordRole !== null) {
70
- role = chordRole;
71
- lock = chordRole === 'root' ? 'identity' : chordRole === 'third' ? 'quality' : 'voicing';
72
- } else {
73
- role = 'tension';
74
- lock = 'voicing';
75
- }
76
- return { role, lock, belongsToChordId: chordId };
77
- }