@libraz/libcantus 0.9.1 → 0.9.3

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 (160) hide show
  1. package/README.md +19 -3
  2. package/dist/analyze/index.cjs +1970 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +341 -0
  5. package/dist/analyze/index.d.ts +341 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-2ZWMSJH6.js +30 -0
  9. package/dist/chunk-2ZWMSJH6.js.map +1 -0
  10. package/dist/chunk-4MHA432G.js +2854 -0
  11. package/dist/chunk-4MHA432G.js.map +1 -0
  12. package/dist/chunk-62QZKCJQ.js +49 -0
  13. package/dist/chunk-62QZKCJQ.js.map +1 -0
  14. package/dist/chunk-AKLYPWIN.js +83 -0
  15. package/dist/chunk-AKLYPWIN.js.map +1 -0
  16. package/dist/chunk-C5XYKUVG.js +904 -0
  17. package/dist/chunk-C5XYKUVG.js.map +1 -0
  18. package/dist/chunk-CHMXELDS.js +549 -0
  19. package/dist/chunk-CHMXELDS.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-EH6EM37U.js +315 -0
  23. package/dist/chunk-EH6EM37U.js.map +1 -0
  24. package/dist/chunk-FBQK3ZQN.js +131 -0
  25. package/dist/chunk-FBQK3ZQN.js.map +1 -0
  26. package/dist/chunk-GOAW4VYL.js +719 -0
  27. package/dist/chunk-GOAW4VYL.js.map +1 -0
  28. package/dist/chunk-I5R7ZBJS.js +202 -0
  29. package/dist/chunk-I5R7ZBJS.js.map +1 -0
  30. package/dist/chunk-IT2O4XFE.js +154 -0
  31. package/dist/chunk-IT2O4XFE.js.map +1 -0
  32. package/dist/chunk-MIWO425M.js +412 -0
  33. package/dist/chunk-MIWO425M.js.map +1 -0
  34. package/dist/chunk-N6IHQUOX.js +1 -0
  35. package/dist/chunk-N6IHQUOX.js.map +1 -0
  36. package/dist/chunk-SGU7SPLK.js +924 -0
  37. package/dist/chunk-SGU7SPLK.js.map +1 -0
  38. package/dist/core/index.cjs +648 -0
  39. package/dist/core/index.cjs.map +1 -0
  40. package/dist/core/index.d.cts +262 -0
  41. package/dist/core/index.d.ts +262 -0
  42. package/dist/core/index.js +93 -0
  43. package/dist/core/index.js.map +1 -0
  44. package/dist/function-DVH4Eusr.d.ts +187 -0
  45. package/dist/function-DYt4iKsn.d.cts +187 -0
  46. package/dist/generate/index.cjs +4123 -0
  47. package/dist/generate/index.cjs.map +1 -0
  48. package/dist/generate/index.d.cts +986 -0
  49. package/dist/generate/index.d.ts +986 -0
  50. package/dist/generate/index.js +52 -0
  51. package/dist/generate/index.js.map +1 -0
  52. package/dist/index-BNlGP86A.d.cts +157 -0
  53. package/dist/index-Bg5dYMmZ.d.ts +56 -0
  54. package/dist/index-Bt4tOUD9.d.ts +157 -0
  55. package/dist/index-CGMZNCx6.d.cts +140 -0
  56. package/dist/index-CGMZNCx6.d.ts +140 -0
  57. package/dist/index-Cl7mhOFM.d.cts +164 -0
  58. package/dist/index-Cl7mhOFM.d.ts +164 -0
  59. package/dist/index-CnZCo29u.d.cts +108 -0
  60. package/dist/index-CpOsP1y9.d.cts +151 -0
  61. package/dist/index-CxqBQtH1.d.cts +128 -0
  62. package/dist/index-Dikst44v.d.cts +56 -0
  63. package/dist/index-DxvARIP2.d.ts +151 -0
  64. package/dist/index-Jc0Ryi79.d.ts +128 -0
  65. package/dist/index-JnF4v9bx.d.ts +108 -0
  66. package/dist/index.cjs +3710 -3143
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.cts +15 -3982
  69. package/dist/index.d.ts +15 -3982
  70. package/dist/index.js +191 -6504
  71. package/dist/index.js.map +1 -1
  72. package/dist/model/index.cjs +2574 -0
  73. package/dist/model/index.cjs.map +1 -0
  74. package/dist/model/index.d.cts +631 -0
  75. package/dist/model/index.d.ts +631 -0
  76. package/dist/model/index.js +21 -0
  77. package/dist/model/index.js.map +1 -0
  78. package/dist/styled-DZ7GjShb.d.ts +282 -0
  79. package/dist/styled-ZTbdaVFM.d.cts +282 -0
  80. package/dist/theory/index.cjs +1823 -0
  81. package/dist/theory/index.cjs.map +1 -0
  82. package/dist/theory/index.d.cts +535 -0
  83. package/dist/theory/index.d.ts +535 -0
  84. package/dist/theory/index.js +150 -0
  85. package/dist/theory/index.js.map +1 -0
  86. package/dist/types-MGP29r3t.d.cts +30 -0
  87. package/dist/types-MGP29r3t.d.ts +30 -0
  88. package/package.json +61 -5
  89. package/src/analyze/arrange/index.ts +21 -0
  90. package/src/analyze/arrange/internal.ts +127 -0
  91. package/src/analyze/arrange/tension.ts +199 -0
  92. package/src/analyze/arrange/tracks.ts +312 -0
  93. package/src/{detect → analyze/detect}/index.ts +50 -13
  94. package/src/analyze/functional/borrowed.ts +84 -0
  95. package/src/analyze/functional/cadence.ts +66 -0
  96. package/src/analyze/functional/function.ts +184 -0
  97. package/src/analyze/functional/index.ts +23 -0
  98. package/src/analyze/functional/internal.ts +29 -0
  99. package/src/{functional/index.ts → analyze/functional/roman.ts} +23 -332
  100. package/src/analyze/index.ts +49 -0
  101. package/src/{timeline → analyze/timeline}/index.ts +50 -18
  102. package/src/{analysis → analyze/voice}/index.ts +19 -7
  103. package/src/core/event-index/index.ts +104 -0
  104. package/src/core/index.ts +61 -0
  105. package/src/{meter → core/meter}/index.ts +24 -4
  106. package/src/{random → core/random}/index.ts +26 -3
  107. package/src/{tuning → core/tuning}/index.ts +36 -6
  108. package/src/core/validation/index.ts +119 -0
  109. package/src/{bass → generate/bass}/index.ts +28 -5
  110. package/src/{countermelody → generate/countermelody}/index.ts +176 -85
  111. package/src/{drums → generate/drums}/euclid.ts +10 -3
  112. package/src/{drums → generate/drums}/index.ts +58 -26
  113. package/src/{drums → generate/drums}/percussion.ts +1 -1
  114. package/src/{drums → generate/drums}/rng.ts +2 -2
  115. package/src/{groove → generate/groove}/index.ts +28 -3
  116. package/src/{harmonize → generate/harmonize}/index.ts +68 -57
  117. package/src/generate/index.ts +58 -0
  118. package/src/{motif → generate/motif}/index.ts +52 -28
  119. package/src/{progression → generate/progression}/index.ts +19 -41
  120. package/src/{reharmony → generate/reharmony}/index.ts +12 -19
  121. package/src/{rhythm → generate/rhythm}/index.ts +13 -3
  122. package/src/index.ts +13 -234
  123. package/src/model/chord.ts +442 -0
  124. package/src/model/index.ts +5 -1096
  125. package/src/model/interval.ts +82 -0
  126. package/src/model/key.ts +266 -0
  127. package/src/model/note.ts +176 -0
  128. package/src/model/progression.ts +161 -0
  129. package/src/model/shared.ts +12 -0
  130. package/src/{chord → theory/chord}/index.ts +23 -3
  131. package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
  132. package/src/{harmony → theory/harmony}/index.ts +6 -21
  133. package/src/theory/index.ts +112 -0
  134. package/src/{safety → theory/safety}/index.ts +72 -3
  135. package/src/theory/scale/degrees.ts +123 -0
  136. package/src/theory/scale/index.ts +28 -0
  137. package/src/theory/scale/key.ts +56 -0
  138. package/src/theory/scale/masks.ts +153 -0
  139. package/src/{spelling → theory/spelling}/index.ts +73 -6
  140. package/src/{symbol → theory/symbol}/index.ts +8 -1
  141. package/src/theory/voicing/index.ts +5 -0
  142. package/src/theory/voicing/internal.ts +168 -0
  143. package/src/theory/voicing/leading.ts +112 -0
  144. package/src/theory/voicing/satb.ts +216 -0
  145. package/src/theory/voicing/styled.ts +199 -0
  146. package/src/arrange/index.ts +0 -571
  147. package/src/scale/index.ts +0 -331
  148. package/src/voicing/index.ts +0 -623
  149. /package/src/{interval → core/interval}/index.ts +0 -0
  150. /package/src/{pitch → core/pitch}/index.ts +0 -0
  151. /package/src/{types.ts → core/types.ts} +0 -0
  152. /package/src/{drums → generate/drums}/beat.ts +0 -0
  153. /package/src/{drums → generate/drums}/fills.ts +0 -0
  154. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  155. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  156. /package/src/{drums → generate/drums}/hit.ts +0 -0
  157. /package/src/{drums → generate/drums}/internal.ts +0 -0
  158. /package/src/{drums → generate/drums}/kick.ts +0 -0
  159. /package/src/{drums → generate/drums}/swing.ts +0 -0
  160. /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
@@ -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;
@@ -143,6 +143,74 @@ export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): No
143
143
  return pcs.map((pc) => spellPitchClass(pc, tonic, key));
144
144
  }
145
145
 
146
+ /**
147
+ * Diatonic letter distance implied by a chord interval.
148
+ *
149
+ * Chord intervals are not merely pitch-class distances: 6 is a diminished
150
+ * fifth in the basic chord templates while 18 is an augmented eleventh, and 9
151
+ * is a diminished seventh in `dim7` but a sixth elsewhere. Keeping that
152
+ * distinction is what prevents functional chord tones from being respelled by
153
+ * their nearest key pitch (for example G# becoming Ab in E7).
154
+ */
155
+ function chordLetterOffset(interval: number, chord: Chord): number {
156
+ switch (interval) {
157
+ case 0:
158
+ case 12:
159
+ return 0;
160
+ case 1:
161
+ case 2:
162
+ case 13:
163
+ case 14:
164
+ return 1;
165
+ case 3:
166
+ case 4:
167
+ case 15:
168
+ case 16:
169
+ return 2;
170
+ case 5:
171
+ case 17:
172
+ case 18:
173
+ return 3;
174
+ case 6:
175
+ case 7:
176
+ case 8:
177
+ case 19:
178
+ return 4;
179
+ case 9:
180
+ return chord.quality === 'dim7' ? 6 : 5;
181
+ case 20:
182
+ case 21:
183
+ return 5;
184
+ case 10:
185
+ case 11:
186
+ case 22:
187
+ case 23:
188
+ return 6;
189
+ default: {
190
+ // Unknown/custom interval data gets the nearest conventional diatonic
191
+ // distance. Public builders use the explicit cases above.
192
+ const octaves = Math.floor(Math.max(0, interval) / 12);
193
+ const simple = mod12(interval);
194
+ const simpleOffset = [0, 1, 1, 2, 2, 3, 4, 4, 4, 5, 6, 6][simple] ?? 0;
195
+ return octaves * 7 + simpleOffset;
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Spell chord tones from an already chosen root spelling.
202
+ *
203
+ * This is exported for the model layer's inversion support; the package
204
+ * barrels intentionally expose the key-aware {@link spellChord} entry point.
205
+ */
206
+ export function spellChordFromRoot(chord: Chord, root: Note): Note[] {
207
+ return chord.intervals.map((interval) => {
208
+ const letter = mod7(root.letter + chordLetterOffset(interval, chord));
209
+ const pc = mod12(chord.rootPc + interval);
210
+ return { letter, alter: alterFor(letter, pc) };
211
+ });
212
+ }
213
+
146
214
  /**
147
215
  * Spell a chord's tones, in the chord's own (tertian) order, relative to a key.
148
216
  *
@@ -165,9 +233,8 @@ export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): No
165
233
  * @category Pitch & Intervals
166
234
  */
167
235
  export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
168
- return chord.intervals.map((interval) =>
169
- spellPitchClass(mod12(chord.rootPc + interval), tonic, key),
170
- );
236
+ const root = spellPitchClass(chord.rootPc, tonic, key);
237
+ return spellChordFromRoot(chord, root);
171
238
  }
172
239
 
173
240
  /**
@@ -5,8 +5,15 @@
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
+
9
+ import {
10
+ formatNote,
11
+ midiToNote,
12
+ type Note,
13
+ noteToPitchClass,
14
+ parseNote,
15
+ } from '../../core/pitch/index.js';
8
16
  import { type Chord, type ChordQuality, makeChord, type PitchSpelling } from '../chord/index.js';
9
- import { formatNote, midiToNote, type Note, noteToPitchClass, parseNote } from '../pitch/index.js';
10
17
 
11
18
  /**
12
19
  * Matches a chord root: a letter A-G (case-insensitive, like {@link parseNote})
@@ -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';
@@ -0,0 +1,168 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordPitchClasses } from '../chord/index.js';
3
+ import {
4
+ createsParallelPerfect,
5
+ createsVoiceCrossing,
6
+ createsVoiceOverlap,
7
+ exceedsSpacing,
8
+ } from '../counterpoint/index.js';
9
+ import type { VoiceRange } from './satb.js';
10
+
11
+ /** Default maximum spacing between adjacent upper voices (one octave). */
12
+ export const DEFAULT_MAX_SPACING = 12;
13
+
14
+ /** Score penalty per counterpoint violation between consecutive voicings. */
15
+ export const VIOLATION_PENALTY = 1000;
16
+ /** Score penalty per chord tone absent from a voicing. */
17
+ const MISSING_TONE_PENALTY = 500;
18
+ /** Score penalty per doubled tone that is neither the root nor the fifth. */
19
+ const POOR_DOUBLING_PENALTY = 4;
20
+ /** Hard cap on candidate voicings evaluated per chord, keeping the search bounded. */
21
+ const MAX_CANDIDATES = 4000;
22
+
23
+ /** Reduce a value to a pitch class in [0, 11]. */
24
+ export function pitchClass(value: number): number {
25
+ return ((Math.trunc(value) % 12) + 12) % 12;
26
+ }
27
+
28
+ /**
29
+ * All MIDI pitches of a pitch class inside an inclusive range, ordered from the
30
+ * centre of the range outward (ties break toward the lower pitch). Enumerating
31
+ * centre-outward keeps the candidate set balanced around the register when it
32
+ * is truncated at {@link MAX_CANDIDATES}, instead of skewing to the low octaves
33
+ * that a plain ascending scan would visit first.
34
+ */
35
+ function pitchesForPc(pc: number, range: VoiceRange): number[] {
36
+ const result: number[] = [];
37
+ for (let midi = Math.ceil(range.min); midi <= range.max; midi += 1) {
38
+ if (pitchClass(midi) === pc) {
39
+ result.push(midi);
40
+ }
41
+ }
42
+ const center = (range.min + range.max) / 2;
43
+ return result.sort((a, b) => {
44
+ const da = Math.abs(a - center);
45
+ const db = Math.abs(b - center);
46
+ return da === db ? a - b : da - db;
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Enumerate candidate voicings for a chord: the bass takes the chord's bass
52
+ * (or root) pitch class at each available octave, and each upper voice takes
53
+ * any chord pitch class within its range. Candidates are built in ascending
54
+ * voice order and pruned to exclude voice crossings and over-wide adjacent
55
+ * spacing (the bass–tenor pair is allowed an extra octave, per convention).
56
+ * Enumeration is deterministic and capped at {@link MAX_CANDIDATES}.
57
+ */
58
+ export function enumerateVoicings(
59
+ chord: Chord,
60
+ ranges: VoiceRange[],
61
+ maxSpacing: number,
62
+ ): number[][] {
63
+ const chordPcs = chordPitchClasses(chord);
64
+ const bassPc = pitchClass(chord.bassPc ?? chord.rootPc);
65
+ const results: number[][] = [];
66
+ const current: number[] = [];
67
+ const build = (voice: number): void => {
68
+ if (results.length >= MAX_CANDIDATES) {
69
+ return;
70
+ }
71
+ if (voice === ranges.length) {
72
+ results.push([...current]);
73
+ return;
74
+ }
75
+ const range = ranges[voice];
76
+ if (range === undefined) {
77
+ return;
78
+ }
79
+ const pcs = voice === 0 ? [bassPc] : chordPcs;
80
+ const prev = current[voice - 1];
81
+ for (const pc of pcs) {
82
+ for (const pitch of pitchesForPc(pc, range)) {
83
+ if (prev !== undefined) {
84
+ if (pitch < prev) {
85
+ continue; // would cross below the next lower voice
86
+ }
87
+ const spacingLimit = voice === 1 ? maxSpacing + 12 : maxSpacing;
88
+ if (pitch - prev > spacingLimit) {
89
+ continue;
90
+ }
91
+ }
92
+ current.push(pitch);
93
+ build(voice + 1);
94
+ current.pop();
95
+ }
96
+ }
97
+ };
98
+ build(0);
99
+ return results;
100
+ }
101
+
102
+ /**
103
+ * Structural quality penalty of a single voicing: heavily penalize missing
104
+ * chord tones, mildly penalize doubling anything other than the root or fifth.
105
+ */
106
+ export function structuralPenalty(pitches: number[], chord: Chord): number {
107
+ const counts = new Map<number, number>();
108
+ for (const pitch of pitches) {
109
+ const pc = pitchClass(pitch);
110
+ counts.set(pc, (counts.get(pc) ?? 0) + 1);
111
+ }
112
+ let penalty = 0;
113
+ for (const pc of chordPitchClasses(chord)) {
114
+ if (!counts.has(pc)) {
115
+ penalty += MISSING_TONE_PENALTY;
116
+ }
117
+ }
118
+ const rootPc = pitchClass(chord.rootPc);
119
+ const fifthPc = pitchClass(chord.rootPc + 7);
120
+ for (const [pc, count] of counts) {
121
+ if (count > 1 && pc !== rootPc && pc !== fifthPc) {
122
+ penalty += (count - 1) * POOR_DOUBLING_PENALTY;
123
+ }
124
+ }
125
+ return penalty;
126
+ }
127
+
128
+ /**
129
+ * Count counterpoint violations between two consecutive voicings of equal
130
+ * length: parallel perfects (fifths and octaves alike, since octaves are the
131
+ * perfect-class-zero case of {@link createsParallelPerfect}) on every voice
132
+ * pair, voice crossings in the new voicing, and — on adjacent pairs — voice
133
+ * overlaps and over-wide spacing between upper voices.
134
+ */
135
+ export function violationCount(prev: number[], cur: number[], maxSpacing: number): number {
136
+ let count = 0;
137
+ for (let lower = 0; lower < cur.length; lower += 1) {
138
+ for (let upper = lower + 1; upper < cur.length; upper += 1) {
139
+ const prevLower = prev[lower];
140
+ const prevUpper = prev[upper];
141
+ const curLower = cur[lower];
142
+ const curUpper = cur[upper];
143
+ if (
144
+ prevLower === undefined ||
145
+ prevUpper === undefined ||
146
+ curLower === undefined ||
147
+ curUpper === undefined
148
+ ) {
149
+ continue;
150
+ }
151
+ if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
152
+ count += 1;
153
+ }
154
+ if (createsVoiceCrossing(curUpper, curLower)) {
155
+ count += 1;
156
+ }
157
+ if (upper === lower + 1) {
158
+ if (createsVoiceOverlap(prevUpper, curUpper, prevLower, curLower)) {
159
+ count += 1;
160
+ }
161
+ if (lower > 0 && exceedsSpacing(curUpper, curLower, maxSpacing)) {
162
+ count += 1;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ return count;
168
+ }
@@ -0,0 +1,112 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { createsHiddenParallelPerfect } from '../counterpoint/index.js';
3
+ import {
4
+ DEFAULT_MAX_SPACING,
5
+ enumerateVoicings,
6
+ structuralPenalty,
7
+ VIOLATION_PENALTY,
8
+ violationCount,
9
+ } from './internal.js';
10
+ import type { VoicingOptions } from './satb.js';
11
+ import { resolveRanges } from './satb.js';
12
+
13
+ /**
14
+ * Moderate penalty for a hidden/direct perfect fifth or octave reached on the
15
+ * outer-voice (bass–soprano) pair. Unlike a true parallel perfect it is
16
+ * discouraged rather than forbidden, so the weight sits alongside voice-leading
17
+ * motion rather than the hard {@link VIOLATION_PENALTY}.
18
+ */
19
+ const HIDDEN_PERFECT_PENALTY = 6;
20
+
21
+ /**
22
+ * Total voice-leading cost between two voicings: the sum of absolute semitone
23
+ * motion across voices, plus a moderate {@link HIDDEN_PERFECT_PENALTY} when the
24
+ * outer-voice (bass–soprano) pair reaches a hidden/direct perfect fifth or
25
+ * octave by similar motion. The arrays must be the same length; when they
26
+ * differ the voicings are not comparable and the cost is `Infinity`.
27
+ *
28
+ * @param from The previous voicing, one MIDI pitch per voice.
29
+ * @param to The next voicing, one MIDI pitch per voice.
30
+ * @returns The summed absolute motion (plus any hidden-perfect penalty), or
31
+ * `Infinity` when lengths differ.
32
+ * @category Voicing & Counterpoint
33
+ */
34
+ export function voiceLeadingCost(from: number[], to: number[]): number {
35
+ if (from.length !== to.length) {
36
+ return Number.POSITIVE_INFINITY;
37
+ }
38
+ let total = 0;
39
+ for (let i = 0; i < from.length; i += 1) {
40
+ const a = from[i];
41
+ const b = to[i];
42
+ if (a === undefined || b === undefined) {
43
+ continue;
44
+ }
45
+ total += Math.abs(b - a);
46
+ }
47
+ // Discourage hidden/direct perfects between the outermost voices, where they
48
+ // are most audible. True parallels are handled (and forbidden) elsewhere.
49
+ if (from.length >= 2) {
50
+ const bassPrev = from[0];
51
+ const bassCur = to[0];
52
+ const sopPrev = from[from.length - 1];
53
+ const sopCur = to[to.length - 1];
54
+ if (
55
+ bassPrev !== undefined &&
56
+ bassCur !== undefined &&
57
+ sopPrev !== undefined &&
58
+ sopCur !== undefined &&
59
+ createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)
60
+ ) {
61
+ total += HIDDEN_PERFECT_PENALTY;
62
+ }
63
+ }
64
+ return total;
65
+ }
66
+
67
+ /**
68
+ * Voice a single chord to follow smoothly from an arbitrary current voicing.
69
+ *
70
+ * Candidate voicings of `chord` are enumerated within ranges taken from `opts`,
71
+ * or — when neither `voices` nor `ranges` is given — from a one-octave window
72
+ * around each pitch of `current` (so the result matches `current`'s voice
73
+ * count). Each candidate is scored by structural quality, voice-leading motion
74
+ * from `current`, and a large penalty per counterpoint violation; the lowest
75
+ * scoring candidate is returned, ascending.
76
+ *
77
+ * @param current The current voicing to lead from, ascending (index 0 = lowest).
78
+ * @param chord The next chord to voice.
79
+ * @param opts Voicing options; when omitted, ranges follow `current`'s span.
80
+ * @returns The chosen voicing, ascending with one MIDI pitch per voice.
81
+ * @throws If no voicing fits the derived ranges.
82
+ * @category Voicing & Counterpoint
83
+ */
84
+ export function nextVoicing(current: number[], chord: Chord, opts?: VoicingOptions): number[] {
85
+ const ranges =
86
+ opts?.ranges !== undefined || opts?.voices !== undefined
87
+ ? resolveRanges(opts)
88
+ : current.map((pitch) => {
89
+ // Window one octave around each current pitch, clamped so extreme-low
90
+ // or extreme-high input can never yield MIDI outside [0, 127].
91
+ const centre = Math.min(127, Math.max(0, pitch));
92
+ return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
93
+ });
94
+ const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
95
+ const candidates = enumerateVoicings(chord, ranges, maxSpacing);
96
+ let best: number[] | undefined;
97
+ let bestScore = Number.POSITIVE_INFINITY;
98
+ for (const candidate of candidates) {
99
+ const score =
100
+ structuralPenalty(candidate, chord) +
101
+ voiceLeadingCost(current, candidate) +
102
+ VIOLATION_PENALTY * violationCount(current, candidate, maxSpacing);
103
+ if (score < bestScore) {
104
+ bestScore = score;
105
+ best = candidate;
106
+ }
107
+ }
108
+ if (best === undefined) {
109
+ throw new Error('no voicing satisfies the given ranges');
110
+ }
111
+ return best;
112
+ }