@libraz/libcantus 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +291 -0
  3. package/dist/index.cjs +6465 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2621 -0
  6. package/dist/index.d.ts +2621 -0
  7. package/dist/index.js +6297 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +71 -0
  10. package/src/analysis/index.ts +261 -0
  11. package/src/arrange/index.ts +409 -0
  12. package/src/bass/index.ts +301 -0
  13. package/src/chord/index.ts +292 -0
  14. package/src/chordscale/index.ts +316 -0
  15. package/src/countermelody/index.ts +349 -0
  16. package/src/counterpoint/index.ts +209 -0
  17. package/src/detect/index.ts +176 -0
  18. package/src/drums/beat.ts +266 -0
  19. package/src/drums/euclid.ts +58 -0
  20. package/src/drums/fills.ts +345 -0
  21. package/src/drums/ghost.ts +119 -0
  22. package/src/drums/hihat.ts +285 -0
  23. package/src/drums/hit.ts +31 -0
  24. package/src/drums/index.ts +211 -0
  25. package/src/drums/internal.ts +251 -0
  26. package/src/drums/kick.ts +160 -0
  27. package/src/drums/percussion.ts +121 -0
  28. package/src/drums/rng.ts +7 -0
  29. package/src/drums/swing.ts +66 -0
  30. package/src/functional/index.ts +602 -0
  31. package/src/groove/index.ts +230 -0
  32. package/src/harmonize/index.ts +367 -0
  33. package/src/harmony/index.ts +75 -0
  34. package/src/index.ts +233 -0
  35. package/src/interval/index.ts +67 -0
  36. package/src/meter/index.ts +179 -0
  37. package/src/model/index.ts +957 -0
  38. package/src/motif/index.ts +321 -0
  39. package/src/pitch/index.ts +256 -0
  40. package/src/progression/index.ts +336 -0
  41. package/src/random/index.ts +41 -0
  42. package/src/reharmony/index.ts +299 -0
  43. package/src/rhythm/index.ts +131 -0
  44. package/src/safety/index.ts +372 -0
  45. package/src/scale/index.ts +215 -0
  46. package/src/spelling/index.ts +164 -0
  47. package/src/symbol/index.ts +213 -0
  48. package/src/timeline/index.ts +352 -0
  49. package/src/tuning/index.ts +129 -0
  50. package/src/types.ts +29 -0
  51. package/src/voicing/index.ts +523 -0
  52. package/tsconfig.json +23 -0
@@ -0,0 +1,292 @@
1
+ import { scaleTonesInDegreeOrder } from '../scale/index.js';
2
+ import type { KeyScale } from '../types.js';
3
+
4
+ /** Chord quality identifiers understood by the chord builder. */
5
+ export type ChordQuality =
6
+ | 'maj'
7
+ | 'min'
8
+ | 'dim'
9
+ | 'aug'
10
+ | 'maj7'
11
+ | 'min7'
12
+ | 'dom7'
13
+ | 'dim7'
14
+ | 'm7b5'
15
+ | 'minMaj7'
16
+ | 'aug7'
17
+ | 'augMaj7'
18
+ | 'majb5'
19
+ | '6'
20
+ | 'min6'
21
+ | '6/9'
22
+ | 'sus2'
23
+ | 'sus4'
24
+ | 'add9'
25
+ | 'add11'
26
+ | 'maj9'
27
+ | 'min9'
28
+ | 'dom9'
29
+ | '7b9'
30
+ | '7#9'
31
+ | '7#11'
32
+ | '7b13'
33
+ | '11'
34
+ | '13'
35
+ | '5';
36
+
37
+ /** A chord expressed as a root pitch class plus semitone offsets. */
38
+ export type Chord = {
39
+ rootPc: number;
40
+ quality: ChordQuality;
41
+ intervals: number[];
42
+ bassPc?: number;
43
+ };
44
+
45
+ /** Semitone offsets from the root for each supported chord quality. */
46
+ const QUALITY_INTERVALS: Record<ChordQuality, number[]> = {
47
+ maj: [0, 4, 7],
48
+ min: [0, 3, 7],
49
+ dim: [0, 3, 6],
50
+ aug: [0, 4, 8],
51
+ majb5: [0, 4, 6],
52
+ maj7: [0, 4, 7, 11],
53
+ min7: [0, 3, 7, 10],
54
+ dom7: [0, 4, 7, 10],
55
+ dim7: [0, 3, 6, 9],
56
+ m7b5: [0, 3, 6, 10],
57
+ minMaj7: [0, 3, 7, 11],
58
+ aug7: [0, 4, 8, 10],
59
+ augMaj7: [0, 4, 8, 11],
60
+ '6': [0, 4, 7, 9],
61
+ min6: [0, 3, 7, 9],
62
+ '6/9': [0, 4, 7, 9, 14],
63
+ sus2: [0, 2, 7],
64
+ sus4: [0, 5, 7],
65
+ add9: [0, 4, 7, 14],
66
+ add11: [0, 4, 7, 17],
67
+ maj9: [0, 4, 7, 11, 14],
68
+ min9: [0, 3, 7, 10, 14],
69
+ dom9: [0, 4, 7, 10, 14],
70
+ '7b9': [0, 4, 7, 10, 13],
71
+ '7#9': [0, 4, 7, 10, 15],
72
+ '7#11': [0, 4, 7, 10, 18],
73
+ '7b13': [0, 4, 7, 10, 20],
74
+ '11': [0, 7, 10, 14, 17],
75
+ '13': [0, 4, 7, 10, 14, 21],
76
+ '5': [0, 7],
77
+ };
78
+
79
+ /** Reduce a value to a pitch class in [0, 11]. */
80
+ function pitchClass(value: number): number {
81
+ return ((Math.trunc(value) % 12) + 12) % 12;
82
+ }
83
+
84
+ /** All chord qualities the builder understands, longest templates last. */
85
+ export function chordQualities(): ChordQuality[] {
86
+ return Object.keys(QUALITY_INTERVALS) as ChordQuality[];
87
+ }
88
+
89
+ /**
90
+ * Build a chord rooted on a diatonic scale degree.
91
+ *
92
+ * The root pitch class is the degree's diatonic pitch class in `key`; the
93
+ * quality's interval template is attached unchanged. Degrees beyond the scale
94
+ * length wrap around.
95
+ *
96
+ * @param degree 0-based scale degree of the chord root.
97
+ * @param ext Chord quality to apply.
98
+ * @param key Key context supplying the diatonic root.
99
+ * @returns The constructed chord.
100
+ */
101
+ export function chordFromDegree(degree: number, ext: ChordQuality, key: KeyScale): Chord {
102
+ const tones = scaleTonesInDegreeOrder(key);
103
+ const length = tones.length;
104
+ const index = length > 0 ? ((degree % length) + length) % length : 0;
105
+ const rootPc = tones[index] ?? pitchClass(key.rootPc);
106
+ return { rootPc, quality: ext, intervals: [...QUALITY_INTERVALS[ext]] };
107
+ }
108
+
109
+ /**
110
+ * Build a chord from an explicit root pitch class and quality.
111
+ *
112
+ * @param rootPc Root pitch class (0..11).
113
+ * @param quality Chord quality supplying the interval template.
114
+ * @param bassPc Optional slash-chord bass pitch class.
115
+ * @returns The constructed chord.
116
+ */
117
+ export function makeChord(rootPc: number, quality: ChordQuality, bassPc?: number): Chord {
118
+ const chord: Chord = {
119
+ rootPc: pitchClass(rootPc),
120
+ quality,
121
+ intervals: [...QUALITY_INTERVALS[quality]],
122
+ };
123
+ if (bassPc !== undefined) {
124
+ chord.bassPc = pitchClass(bassPc);
125
+ }
126
+ return chord;
127
+ }
128
+
129
+ /**
130
+ * Get the sorted, deduplicated pitch classes of a chord.
131
+ *
132
+ * @param chord The chord to enumerate.
133
+ * @returns The chord's pitch classes, sorted ascending in [0, 11].
134
+ */
135
+ export function chordPitchClasses(chord: Chord): number[] {
136
+ const set = new Set<number>();
137
+ for (const interval of chord.intervals) {
138
+ set.add(pitchClass(chord.rootPc + interval));
139
+ }
140
+ return [...set].sort((a, b) => a - b);
141
+ }
142
+
143
+ /** The chord-tone offsets present in a chord, reduced to pitch-class space. */
144
+ function chordToneOffsets(chord: Chord): Set<number> {
145
+ return new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
146
+ }
147
+
148
+ /**
149
+ * Determine a pitch's harmonic role within a chord.
150
+ *
151
+ * The role is derived from the pitch's interval above the chord root, reduced
152
+ * modulo 12. A major sixth (9) reads as a `sixth` for sixth chords but as a
153
+ * `seventh` for a diminished-seventh chord; ninths and other tensions have no
154
+ * basic role and return null.
155
+ *
156
+ * @param pitch MIDI pitch or bare pitch class.
157
+ * @param chord The chord providing the root reference.
158
+ * @returns The chord-tone role, or null if the pitch has no basic role.
159
+ */
160
+ export function chordToneRole(
161
+ pitch: number,
162
+ chord: Chord,
163
+ ): 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | null {
164
+ const interval = (pitchClass(pitch) - pitchClass(chord.rootPc) + 12) % 12;
165
+ const tones = chordToneOffsets(chord);
166
+ if (interval === 0) {
167
+ return 'root';
168
+ }
169
+ if (interval === 3 || interval === 4) {
170
+ return tones.has(interval) ? 'third' : null;
171
+ }
172
+ if (interval === 7) {
173
+ return tones.has(7) ? 'fifth' : null;
174
+ }
175
+ if (interval === 6) {
176
+ // A diminished fifth is the chord's fifth only when no perfect fifth is
177
+ // present; alongside a perfect fifth it is a #11 tension, not a fifth.
178
+ return tones.has(6) && !tones.has(7) ? 'fifth' : null;
179
+ }
180
+ if (interval === 8) {
181
+ // An augmented fifth is the chord's fifth only without a perfect fifth;
182
+ // alongside a perfect fifth it is a b13 tension, not a fifth.
183
+ return tones.has(8) && !tones.has(7) ? 'fifth' : null;
184
+ }
185
+ if (interval === 9) {
186
+ const hasHigherSeventh = tones.has(10) || tones.has(11);
187
+ const isDiminishedSeventh = tones.has(3) && tones.has(6) && !hasHigherSeventh;
188
+ if (isDiminishedSeventh) {
189
+ return 'seventh';
190
+ }
191
+ if (tones.has(9) && !hasHigherSeventh) {
192
+ return 'sixth';
193
+ }
194
+ return null;
195
+ }
196
+ if (interval === 10 || interval === 11) {
197
+ return tones.has(interval) ? 'seventh' : null;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** Classify a stacked-thirds triad into a chord quality from its interval set. */
203
+ function classifyTriad(thirdIc: number, fifthIc: number): ChordQuality {
204
+ if (thirdIc === 4 && fifthIc === 8) {
205
+ return 'aug';
206
+ }
207
+ if (thirdIc === 4 && fifthIc === 6) {
208
+ return 'majb5';
209
+ }
210
+ if (thirdIc === 3 && fifthIc === 6) {
211
+ return 'dim';
212
+ }
213
+ if (thirdIc === 3) {
214
+ return 'min';
215
+ }
216
+ return 'maj';
217
+ }
218
+
219
+ /** Classify a stacked-thirds seventh chord into a chord quality. */
220
+ function classifySeventh(thirdIc: number, fifthIc: number, seventhIc: number): ChordQuality {
221
+ if (thirdIc === 3 && fifthIc === 6 && seventhIc === 9) {
222
+ return 'dim7';
223
+ }
224
+ if (thirdIc === 3 && fifthIc === 6 && seventhIc === 10) {
225
+ return 'm7b5';
226
+ }
227
+ if (thirdIc === 3 && seventhIc === 11) {
228
+ return 'minMaj7';
229
+ }
230
+ if (thirdIc === 3) {
231
+ return 'min7';
232
+ }
233
+ if (fifthIc === 8 && seventhIc === 10) {
234
+ return 'aug7';
235
+ }
236
+ if (fifthIc === 8 && seventhIc === 11) {
237
+ return 'augMaj7';
238
+ }
239
+ if (seventhIc === 11) {
240
+ return 'maj7';
241
+ }
242
+ return 'dom7';
243
+ }
244
+
245
+ /** Stack scale thirds from a scale degree into a chord of `size` notes. */
246
+ function stackThirds(degree: number, key: KeyScale, size: 3 | 4): Chord {
247
+ const tones = scaleTonesInDegreeOrder(key);
248
+ const length = tones.length;
249
+ if (length === 0) {
250
+ return { rootPc: pitchClass(key.rootPc), quality: 'maj', intervals: [0, 4, 7] };
251
+ }
252
+ const idx = (step: number) => (((degree + step) % length) + length) % length;
253
+ const rootPc = tones[idx(0)] ?? pitchClass(key.rootPc);
254
+ const offsets = [0, 2, 4, 6].slice(0, size).map((step) => {
255
+ const pc = tones[idx(step)] ?? rootPc;
256
+ return (((pc - rootPc) % 12) + 12) % 12;
257
+ });
258
+ const thirdIc = offsets[1] ?? 4;
259
+ const fifthIc = offsets[2] ?? 7;
260
+ const quality =
261
+ size === 3
262
+ ? classifyTriad(thirdIc, fifthIc)
263
+ : classifySeventh(thirdIc, fifthIc, offsets[3] ?? 11);
264
+ return { rootPc, quality, intervals: offsets };
265
+ }
266
+
267
+ /**
268
+ * Build the diatonic triad rooted on a scale degree by stacking scale thirds.
269
+ *
270
+ * Unlike {@link chordFromDegree}, the chord quality is derived from the scale
271
+ * rather than supplied, so degrees yield their scale-correct triads (e.g. a
272
+ * diminished triad on the leading tone of a major key). Intended for heptatonic
273
+ * scales; other scales stack by scale step regardless.
274
+ *
275
+ * @param degree 0-based scale degree of the chord root.
276
+ * @param key Key/scale context.
277
+ * @returns The diatonic triad.
278
+ */
279
+ export function diatonicTriad(degree: number, key: KeyScale): Chord {
280
+ return stackThirds(degree, key, 3);
281
+ }
282
+
283
+ /**
284
+ * Build the diatonic seventh chord rooted on a scale degree.
285
+ *
286
+ * @param degree 0-based scale degree of the chord root.
287
+ * @param key Key/scale context.
288
+ * @returns The diatonic seventh chord.
289
+ */
290
+ export function diatonicSeventh(degree: number, key: KeyScale): Chord {
291
+ return stackThirds(degree, key, 4);
292
+ }
@@ -0,0 +1,316 @@
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
+ export function scaleMatchesChord(
34
+ chordPcs: number[],
35
+ scaleMask: number,
36
+ scaleRootPc: number,
37
+ ): boolean {
38
+ for (const pc of chordPcs) {
39
+ if (!maskHasPitchClass(scaleMask, scaleRootPc, pc)) {
40
+ return false;
41
+ }
42
+ }
43
+ return true;
44
+ }
45
+
46
+ /** A named scale rooted on a pitch class that fits over a chord. */
47
+ export type ChordScaleMatch = {
48
+ name: string;
49
+ rootPc: number;
50
+ };
51
+
52
+ /**
53
+ * List the scales that fit over a chord, best fit first.
54
+ *
55
+ * Only the chord root is considered as the scale root, matching the
56
+ * conventional chord-scale relationship. Every entry of {@link NAMED_SCALES}
57
+ * whose pitch-class set is a superset of the chord's is returned, ranked by
58
+ * fewest extra scale tones beyond the chord, then by scale size (heptatonic
59
+ * before larger scales), then by scale name. The chromatic scale is only
60
+ * returned as a fallback when no other scale contains the chord.
61
+ *
62
+ * @param chord The chord to fit scales over.
63
+ * @returns The matching scales rooted on the chord root, best fit first.
64
+ */
65
+ export function chordScales(chord: Chord): ChordScaleMatch[] {
66
+ const chordPcs = chordPitchClasses(chord);
67
+ const rootPc = pitchClass(chord.rootPc);
68
+ const ranked: { name: string; extra: number; size: number }[] = [];
69
+ for (const name of Object.keys(NAMED_SCALES)) {
70
+ if (name === 'chromatic') {
71
+ continue;
72
+ }
73
+ const mask = NAMED_SCALES[name];
74
+ if (mask === undefined) {
75
+ continue;
76
+ }
77
+ if (scaleMatchesChord(chordPcs, mask, rootPc)) {
78
+ const size = popcount12(mask);
79
+ ranked.push({ name, extra: size - chordPcs.length, size });
80
+ }
81
+ }
82
+ if (ranked.length === 0) {
83
+ // No diatonic-style scale contains the chord; fall back to the chromatic
84
+ // scale, which trivially contains every pitch class.
85
+ if (NAMED_SCALES.chromatic !== undefined) {
86
+ return [{ name: 'chromatic', rootPc }];
87
+ }
88
+ return [];
89
+ }
90
+ ranked.sort((a, b) => {
91
+ if (a.extra !== b.extra) {
92
+ return a.extra - b.extra;
93
+ }
94
+ if (a.size !== b.size) {
95
+ return a.size - b.size;
96
+ }
97
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
98
+ });
99
+ return ranked.map((entry) => ({ name: entry.name, rootPc }));
100
+ }
101
+
102
+ /**
103
+ * List a scale's avoid notes over a chord.
104
+ *
105
+ * An avoid note is a non-chord scale tone that lies a semitone directly above a
106
+ * chord tone; sounding it against the chord clashes. The scale is rooted on the
107
+ * chord root. If the scale does not contain the chord, no avoid notes exist.
108
+ *
109
+ * @param chord The chord providing the chord tones.
110
+ * @param scaleName A key of {@link NAMED_SCALES}.
111
+ * @returns The avoid-note pitch classes, sorted ascending in [0, 11].
112
+ */
113
+ export function avoidNotes(chord: Chord, scaleName: string): number[] {
114
+ const mask = NAMED_SCALES[scaleName];
115
+ if (mask === undefined) {
116
+ return [];
117
+ }
118
+ const rootPc = pitchClass(chord.rootPc);
119
+ const chordPcs = chordPitchClasses(chord);
120
+ if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
121
+ return [];
122
+ }
123
+ const chordSet = new Set(chordPcs);
124
+ const avoid: number[] = [];
125
+ for (let pc = 0; pc < 12; pc += 1) {
126
+ if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc)) {
127
+ continue;
128
+ }
129
+ if (chordSet.has(pitchClass(pc - 1))) {
130
+ avoid.push(pc);
131
+ }
132
+ }
133
+ return avoid.sort((a, b) => a - b);
134
+ }
135
+
136
+ /**
137
+ * List a scale's available tensions over a chord.
138
+ *
139
+ * Available tensions are scale tones that are neither chord tones nor avoid
140
+ * notes, i.e. the usable color tones (typically the 9/11/13 region). The scale
141
+ * is rooted on the chord root. If the scale does not contain the chord, there
142
+ * are no available tensions.
143
+ *
144
+ * @param chord The chord providing the chord tones.
145
+ * @param scaleName A key of {@link NAMED_SCALES}.
146
+ * @returns The available-tension pitch classes, sorted ascending in [0, 11].
147
+ */
148
+ export function availableTensions(chord: Chord, scaleName: string): number[] {
149
+ const mask = NAMED_SCALES[scaleName];
150
+ if (mask === undefined) {
151
+ return [];
152
+ }
153
+ const rootPc = pitchClass(chord.rootPc);
154
+ const chordPcs = chordPitchClasses(chord);
155
+ if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
156
+ return [];
157
+ }
158
+ const chordSet = new Set(chordPcs);
159
+ const avoidSet = new Set(avoidNotes(chord, scaleName));
160
+ const tensions: number[] = [];
161
+ for (let pc = 0; pc < 12; pc += 1) {
162
+ if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc) || avoidSet.has(pc)) {
163
+ continue;
164
+ }
165
+ tensions.push(pc);
166
+ }
167
+ return tensions.sort((a, b) => a - b);
168
+ }
169
+
170
+ /** A scale fit over a chord together with its avoid notes and tensions. */
171
+ export type ChordScaleReportEntry = {
172
+ name: string;
173
+ rootPc: number;
174
+ avoid: number[];
175
+ tensions: number[];
176
+ };
177
+
178
+ /**
179
+ * Report the best-fitting scales for a chord with their avoid notes and tensions.
180
+ *
181
+ * Combines {@link chordScales}, {@link avoidNotes}, and {@link availableTensions}
182
+ * into a single ergonomic result, ordered best fit first.
183
+ *
184
+ * @param chord The chord to analyze.
185
+ * @param limit Optional maximum number of scales to report; all by default.
186
+ * @returns One entry per reported scale, best fit first.
187
+ */
188
+ export function chordScaleReport(chord: Chord, limit?: number): ChordScaleReportEntry[] {
189
+ const matches = chordScales(chord);
190
+ const chosen = limit === undefined ? matches : matches.slice(0, Math.max(0, limit));
191
+ return chosen.map((match) => ({
192
+ name: match.name,
193
+ rootPc: match.rootPc,
194
+ avoid: avoidNotes(chord, match.name),
195
+ tensions: availableTensions(chord, match.name),
196
+ }));
197
+ }
198
+
199
+ /** A chord paired with the scale chosen for it by {@link scalesForChanges}. */
200
+ export type ScaleChoice = {
201
+ chord: Chord;
202
+ scale: ChordScaleMatch;
203
+ };
204
+
205
+ /**
206
+ * Small per-candidate penalty added in {@link scalesForChanges} so that, among
207
+ * choices of otherwise-equal transition cost, the tighter best-fit scale wins.
208
+ */
209
+ const RANK_PENALTY = 0.01;
210
+
211
+ /** Build the pitch-class set of a named scale rooted on `rootPc`. */
212
+ function scalePitchClassSet(name: string, rootPc: number): Set<number> {
213
+ const mask = NAMED_SCALES[name];
214
+ const pcs = new Set<number>();
215
+ if (mask === undefined) {
216
+ return pcs;
217
+ }
218
+ for (let n = 0; n < 12; n += 1) {
219
+ if (((mask >> n) & 1) === 1) {
220
+ pcs.add(pitchClass(rootPc + n));
221
+ }
222
+ }
223
+ return pcs;
224
+ }
225
+
226
+ /** Count pitch classes that belong to exactly one of two sets. */
227
+ function symmetricDifferenceSize(a: Set<number>, b: Set<number>): number {
228
+ let count = 0;
229
+ for (const pc of a) {
230
+ if (!b.has(pc)) {
231
+ count += 1;
232
+ }
233
+ }
234
+ for (const pc of b) {
235
+ if (!a.has(pc)) {
236
+ count += 1;
237
+ }
238
+ }
239
+ return count;
240
+ }
241
+
242
+ /**
243
+ * Choose one scale per chord across a progression, favoring continuity.
244
+ *
245
+ * Each chord's {@link chordScales} candidates form a stage in a Viterbi-style
246
+ * dynamic program. The transition cost between adjacent choices is the number
247
+ * of pitch classes that differ between their pitch-class sets (the symmetric
248
+ * difference), plus a small penalty for straying from a chord's best-fit scale
249
+ * so that ties break toward the tighter fit. The minimum-total-cost path is
250
+ * returned, one {@link ScaleChoice} per input chord in the original order.
251
+ *
252
+ * @param chords The chord sequence to choose scales for.
253
+ * @returns One scale choice per chord, in input order.
254
+ */
255
+ export function scalesForChanges(chords: Chord[]): ScaleChoice[] {
256
+ if (chords.length === 0) {
257
+ return [];
258
+ }
259
+ const candidateLists = chords.map((chord) => chordScales(chord));
260
+ const pcSets = candidateLists.map((candidates) =>
261
+ candidates.map((match) => scalePitchClassSet(match.name, match.rootPc)),
262
+ );
263
+
264
+ const first = candidateLists[0] ?? [];
265
+ const dp: number[][] = [first.map((_, j) => j * RANK_PENALTY)];
266
+ const back: number[][] = [first.map(() => -1)];
267
+
268
+ for (let i = 1; i < candidateLists.length; i += 1) {
269
+ const candidates = candidateLists[i] ?? [];
270
+ const prevCosts = dp[i - 1] ?? [];
271
+ const prevSets = pcSets[i - 1] ?? [];
272
+ const curSets = pcSets[i] ?? [];
273
+ const stageCosts: number[] = [];
274
+ const stageBack: number[] = [];
275
+ for (let j = 0; j < candidates.length; j += 1) {
276
+ let bestCost = Number.POSITIVE_INFINITY;
277
+ let bestPrev = -1;
278
+ for (let k = 0; k < prevCosts.length; k += 1) {
279
+ const transition = symmetricDifferenceSize(
280
+ prevSets[k] ?? new Set(),
281
+ curSets[j] ?? new Set(),
282
+ );
283
+ const cost = (prevCosts[k] ?? 0) + transition;
284
+ if (cost < bestCost) {
285
+ bestCost = cost;
286
+ bestPrev = k;
287
+ }
288
+ }
289
+ stageCosts.push(bestCost + j * RANK_PENALTY);
290
+ stageBack.push(bestPrev);
291
+ }
292
+ dp.push(stageCosts);
293
+ back.push(stageBack);
294
+ }
295
+
296
+ const lastCosts = dp[dp.length - 1] ?? [];
297
+ let bestFinal = 0;
298
+ for (let j = 1; j < lastCosts.length; j += 1) {
299
+ if ((lastCosts[j] ?? Number.POSITIVE_INFINITY) < (lastCosts[bestFinal] ?? 0)) {
300
+ bestFinal = j;
301
+ }
302
+ }
303
+
304
+ const chosenIndices: number[] = new Array(candidateLists.length);
305
+ let current = bestFinal;
306
+ for (let i = candidateLists.length - 1; i >= 0; i -= 1) {
307
+ chosenIndices[i] = current;
308
+ current = back[i]?.[current] ?? -1;
309
+ }
310
+
311
+ return chords.map((chord, i) => {
312
+ const candidates = candidateLists[i] ?? [];
313
+ const scale = candidates[chosenIndices[i] ?? 0] ?? { name: 'chromatic', rootPc: chord.rootPc };
314
+ return { chord, scale };
315
+ });
316
+ }