@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,349 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordToneRole } from '../chord/index.js';
3
+ import {
4
+ createsParallelOctave,
5
+ createsParallelPerfect,
6
+ isForbiddenMelodicLeap,
7
+ } from '../counterpoint/index.js';
8
+ import type { TimeSignature } from '../meter/index.js';
9
+ import { isStrongBeat } from '../meter/index.js';
10
+ import type { Rng } from '../random/index.js';
11
+ import { createRng } from '../random/index.js';
12
+ import type { VoiceSnapshot } from '../safety/index.js';
13
+ import { enumerateSafePitches } from '../safety/index.js';
14
+ import { nearestScaleTone } from '../scale/index.js';
15
+ import type { KeyScale, NoteEvent } from '../types.js';
16
+
17
+ /** Options controlling {@link generateCounterMelody}. */
18
+ export type CounterMelodyOptions = {
19
+ /** The lead line to write against, in ascending onset order. */
20
+ melody: NoteEvent[];
21
+ /** Chord sounding at an absolute beat position, or null when none applies. */
22
+ chordAt: (beat: number) => Chord | null;
23
+ /** Key/scale context for scale-tone decisions. */
24
+ key: KeyScale;
25
+ /** Meter used for strong-beat decisions (default 4/4). */
26
+ ts?: TimeSignature;
27
+ /** Which side of the melody the counter line occupies (default `'below'`). */
28
+ register?: 'above' | 'below';
29
+ /**
30
+ * Onset strategy: `'complement'` (default) moves where the melody holds or
31
+ * rests and reinforces some strong beats; `'follow'` mirrors melody onsets.
32
+ */
33
+ rhythm?: 'complement' | 'follow';
34
+ /** Safety profile applied to candidate pitches (default `'pop'`). */
35
+ profile?: 'strict' | 'pop';
36
+ /** Lowest MIDI pitch the counter line may use (default derived from `register`). */
37
+ pitchLow?: number;
38
+ /** Highest MIDI pitch the counter line may use (default derived from `register`). */
39
+ pitchHigh?: number;
40
+ /** PRNG seed; the same seed always yields the same line (default 0). */
41
+ seed?: number;
42
+ };
43
+
44
+ /** Meter assumed when none is supplied. */
45
+ const DEFAULT_TS: TimeSignature = { numerator: 4, denominator: 4 };
46
+
47
+ /** Tolerance for beat-position comparisons. */
48
+ const EPS = 1e-9;
49
+
50
+ /** Grid resolution, in quarter-note beats, scanned for complement onsets. */
51
+ const GRID_STEP = 0.5;
52
+
53
+ /** Minimum spacing between counter onsets, keeping the line uncluttered. */
54
+ const MIN_ONSET_GAP = 1;
55
+
56
+ /** Semitone offset of the counter register's centre from the melody's mean pitch. */
57
+ const REGISTER_OFFSET = 12;
58
+
59
+ /** Half-width, in semitones, of the default counter register. */
60
+ const DEFAULT_HALF_RANGE = 10;
61
+
62
+ /** Probability of moving on a whole beat while the melody sustains. */
63
+ const HOLD_ON_BEAT_PROB = 0.55;
64
+
65
+ /** Probability of moving on an off-beat subdivision while the melody sustains. */
66
+ const HOLD_OFF_BEAT_PROB = 0.2;
67
+
68
+ /** Probability of reinforcing a strong beat that the melody also attacks. */
69
+ const REINFORCE_PROB = 0.35;
70
+
71
+ /** Velocity assumed for melody notes that carry none. */
72
+ const DEFAULT_MELODY_VELOCITY = 96;
73
+
74
+ /** How far below the concurrent melody velocity the counter line sits. */
75
+ const VELOCITY_DROP = 16;
76
+
77
+ /** Whether a beat position falls on a whole quarter-note beat. */
78
+ function isWholeBeat(beat: number): boolean {
79
+ return Math.abs(beat - Math.round(beat)) < EPS;
80
+ }
81
+
82
+ /** The melody note sounding at a beat (latest onset wins on overlaps). */
83
+ function melodyNoteAt(melody: NoteEvent[], beat: number): NoteEvent | undefined {
84
+ let found: NoteEvent | undefined;
85
+ for (const note of melody) {
86
+ if (note.startBeat - EPS <= beat && beat < note.startBeat + note.durationBeat - EPS) {
87
+ if (!found || note.startBeat > found.startBeat) {
88
+ found = note;
89
+ }
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+
95
+ /** Whether any melody note attacks exactly at a beat. */
96
+ function melodyAttacksAt(melody: NoteEvent[], beat: number): boolean {
97
+ return melody.some((note) => Math.abs(note.startBeat - beat) < EPS);
98
+ }
99
+
100
+ /** Counter onsets mirroring the melody's own onsets, deduplicated and sorted. */
101
+ function followOnsets(melody: NoteEvent[]): number[] {
102
+ return [...new Set(melody.map((note) => note.startBeat))].sort((a, b) => a - b);
103
+ }
104
+
105
+ /**
106
+ * Counter onsets complementing the melody: whole beats inside rests are always
107
+ * taken, positions where the melody sustains are taken probabilistically, and
108
+ * strong beats the melody also attacks are occasionally reinforced. A minimum
109
+ * gap between accepted onsets keeps the line sparse.
110
+ */
111
+ function complementOnsets(
112
+ melody: NoteEvent[],
113
+ ts: TimeSignature,
114
+ spanStart: number,
115
+ spanEnd: number,
116
+ rng: Rng,
117
+ ): number[] {
118
+ const onsets: number[] = [];
119
+ const gridStart = Math.floor(spanStart / GRID_STEP) * GRID_STEP;
120
+ let last = Number.NEGATIVE_INFINITY;
121
+ for (let beat = gridStart; beat < spanEnd - EPS; beat += GRID_STEP) {
122
+ const attacked = melodyAttacksAt(melody, beat);
123
+ const sounding = melodyNoteAt(melody, beat) !== undefined;
124
+ let place = false;
125
+ if (!attacked && !sounding) {
126
+ place = isWholeBeat(beat);
127
+ } else if (!attacked) {
128
+ place = rng.prob(isWholeBeat(beat) ? HOLD_ON_BEAT_PROB : HOLD_OFF_BEAT_PROB);
129
+ } else if (isStrongBeat(beat, ts)) {
130
+ place = rng.prob(REINFORCE_PROB);
131
+ }
132
+ if (place && beat - last >= MIN_ONSET_GAP - EPS) {
133
+ onsets.push(beat);
134
+ last = beat;
135
+ }
136
+ }
137
+ return onsets;
138
+ }
139
+
140
+ /**
141
+ * Preference score for one candidate counter pitch. Rewards imperfect
142
+ * consonance with the melody, chord-tone membership, contrary or oblique
143
+ * motion, and stepwise movement; penalizes wide or forbidden leaps, weak-beat
144
+ * clashes, and drifting from the register centre.
145
+ */
146
+ function scoreCandidate(
147
+ pitch: number,
148
+ melPitch: number | undefined,
149
+ melPrev: number | undefined,
150
+ prevPitch: number | undefined,
151
+ chord: Chord | null,
152
+ center: number,
153
+ ): number {
154
+ let score = 0;
155
+ if (melPitch !== undefined) {
156
+ const ic = Math.abs(pitch - melPitch) % 12;
157
+ if (ic === 3 || ic === 4 || ic === 8 || ic === 9) {
158
+ score += 2; // imperfect consonance: thirds and sixths
159
+ } else if (ic === 0 || ic === 7) {
160
+ score += 0.5; // perfect consonance: allowed but less colourful
161
+ } else if (ic === 1 || ic === 2 || ic === 6 || ic === 10 || ic === 11) {
162
+ score -= 2; // dissonant even on weak beats
163
+ }
164
+ }
165
+ if (chord && chordToneRole(pitch, chord) !== null) {
166
+ score += 1;
167
+ }
168
+ if (prevPitch !== undefined) {
169
+ const move = pitch - prevPitch;
170
+ const dist = Math.abs(move);
171
+ score -= dist * 0.3;
172
+ if (dist > 0 && dist <= 2) {
173
+ score += 1; // stepwise motion
174
+ }
175
+ if (isForbiddenMelodicLeap(prevPitch, pitch)) {
176
+ score -= 6;
177
+ }
178
+ if (melPitch !== undefined && melPrev !== undefined) {
179
+ const melMove = melPitch - melPrev;
180
+ if ((move > 0 && melMove < 0) || (move < 0 && melMove > 0)) {
181
+ score += 2; // contrary motion
182
+ } else if (move === 0 || melMove === 0) {
183
+ score += 1; // oblique motion
184
+ } else {
185
+ score -= 1; // similar motion
186
+ }
187
+ }
188
+ }
189
+ score -= Math.abs(pitch - center) * 0.02;
190
+ return score;
191
+ }
192
+
193
+ /**
194
+ * Last-resort pitch when no safe candidate survives filtering: the scale tone
195
+ * nearest the previous pitch (or the register centre), pushed onto the correct
196
+ * side of the sounding melody note.
197
+ */
198
+ function fallbackPitch(
199
+ melPitch: number | undefined,
200
+ prevPitch: number | undefined,
201
+ center: number,
202
+ register: 'above' | 'below',
203
+ low: number,
204
+ high: number,
205
+ key: KeyScale,
206
+ ): number {
207
+ let target = prevPitch ?? Math.round(center);
208
+ if (melPitch !== undefined) {
209
+ if (register === 'below' && target >= melPitch) {
210
+ target = melPitch - 5;
211
+ } else if (register === 'above' && target <= melPitch) {
212
+ target = melPitch + 5;
213
+ }
214
+ }
215
+ target = Math.min(Math.max(target, low), high);
216
+ let pitch = nearestScaleTone(target, key);
217
+ // Push onto the requested side of the melody, but only by whole octaves that
218
+ // keep the pitch inside [low, high]; the declared range is a hard bound while
219
+ // the register side is a soft preference. A final clamp guarantees the range.
220
+ if (melPitch !== undefined) {
221
+ while (register === 'below' && pitch >= melPitch && pitch - 12 >= low) {
222
+ pitch -= 12;
223
+ }
224
+ while (register === 'above' && pitch <= melPitch && pitch + 12 <= high) {
225
+ pitch += 12;
226
+ }
227
+ }
228
+ return Math.min(Math.max(pitch, low), high);
229
+ }
230
+
231
+ /**
232
+ * Generate a counter melody against a lead line.
233
+ *
234
+ * Onsets are chosen per `rhythm`: `'follow'` places one counter note on each
235
+ * melody onset; `'complement'` fills melody rests and sustains and reinforces
236
+ * an occasional strong beat, spaced at least a beat apart. Each onset's pitch
237
+ * is picked from the safe pitches in the counter register (chord tones and
238
+ * consonant tensions against the sounding melody note), rejecting candidates
239
+ * that would form a parallel perfect interval with the melody or sit on the
240
+ * wrong side of it, then scored to favour contrary or oblique motion, imperfect
241
+ * consonance, and stepwise movement. Ties break toward the lower pitch, so a
242
+ * given seed always yields the same line. Notes extend to the next counter
243
+ * onset (the last to the melody's end) at a velocity slightly under the melody.
244
+ *
245
+ * @param opts The lead line, chord context callback, key, and generation knobs.
246
+ * @returns The counter line as note events sorted by onset; `[]` for an empty melody.
247
+ */
248
+ export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
249
+ const melody = [...opts.melody].sort((a, b) => a.startBeat - b.startBeat);
250
+ if (melody.length === 0) {
251
+ return [];
252
+ }
253
+ const ts = opts.ts ?? DEFAULT_TS;
254
+ const register = opts.register ?? 'below';
255
+ const rhythm = opts.rhythm ?? 'complement';
256
+ const profile = opts.profile ?? 'pop';
257
+ const rng = createRng(opts.seed ?? 0);
258
+
259
+ const spanStart = melody[0]?.startBeat ?? 0;
260
+ const spanEnd = melody.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), spanStart);
261
+ const meanPitch = melody.reduce((sum, n) => sum + n.pitch, 0) / melody.length;
262
+ const defaultCenter =
263
+ register === 'below' ? meanPitch - REGISTER_OFFSET : meanPitch + REGISTER_OFFSET;
264
+ const low = opts.pitchLow ?? Math.round(defaultCenter) - DEFAULT_HALF_RANGE;
265
+ const high = opts.pitchHigh ?? Math.round(defaultCenter) + DEFAULT_HALF_RANGE;
266
+ const center = (low + high) / 2;
267
+
268
+ const onsets =
269
+ rhythm === 'follow'
270
+ ? followOnsets(melody)
271
+ : complementOnsets(melody, ts, spanStart, spanEnd, rng);
272
+
273
+ const out: NoteEvent[] = [];
274
+ let prevPitch: number | undefined;
275
+ let prevOnset: number | undefined;
276
+ for (const beat of onsets) {
277
+ const melNote = melodyNoteAt(melody, beat);
278
+ const melPitch = melNote?.pitch;
279
+ const melPrev = prevOnset !== undefined ? melodyNoteAt(melody, prevOnset)?.pitch : undefined;
280
+ const chord = opts.chordAt(beat);
281
+ const otherVoices: VoiceSnapshot[] =
282
+ melPitch !== undefined ? [{ pitch: melPitch, prevPitch: melPrev }] : [];
283
+
284
+ const candidates = enumerateSafePitches(
285
+ {
286
+ profile,
287
+ prevPitch,
288
+ chord,
289
+ key: opts.key,
290
+ otherVoices,
291
+ strongBeat: isStrongBeat(beat, ts),
292
+ vocalLow: low,
293
+ vocalHigh: high,
294
+ },
295
+ low,
296
+ high,
297
+ );
298
+
299
+ let bestPitch: number | undefined;
300
+ let bestScore = Number.NEGATIVE_INFINITY;
301
+ for (const candidate of candidates) {
302
+ if (melPitch !== undefined) {
303
+ if (register === 'below' && candidate >= melPitch) {
304
+ continue;
305
+ }
306
+ if (register === 'above' && candidate <= melPitch) {
307
+ continue;
308
+ }
309
+ if (
310
+ prevPitch !== undefined &&
311
+ melPrev !== undefined &&
312
+ (createsParallelPerfect(prevPitch, candidate, melPrev, melPitch) ||
313
+ createsParallelOctave(prevPitch, candidate, melPrev, melPitch))
314
+ ) {
315
+ continue;
316
+ }
317
+ }
318
+ const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
319
+ const tie =
320
+ Math.abs(score - bestScore) <= EPS && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
321
+ if (score > bestScore + EPS || tie) {
322
+ bestScore = score;
323
+ bestPitch = candidate;
324
+ }
325
+ }
326
+
327
+ const pitch =
328
+ bestPitch ?? fallbackPitch(melPitch, prevPitch, center, register, low, high, opts.key);
329
+ const velocity = Math.max(
330
+ 1,
331
+ Math.min(127, Math.round((melNote?.velocity ?? DEFAULT_MELODY_VELOCITY) - VELOCITY_DROP)),
332
+ );
333
+ out.push({ pitch, startBeat: beat, durationBeat: GRID_STEP, velocity });
334
+ prevPitch = pitch;
335
+ prevOnset = beat;
336
+ }
337
+
338
+ // Extend each note to the next counter onset; the last to the melody's end.
339
+ for (let i = 0; i < out.length; i += 1) {
340
+ const note = out[i];
341
+ if (!note) {
342
+ continue;
343
+ }
344
+ const next = out[i + 1];
345
+ const end = next ? next.startBeat : Math.max(spanEnd, note.startBeat + GRID_STEP);
346
+ note.durationBeat = end - note.startBeat;
347
+ }
348
+ return out;
349
+ }
@@ -0,0 +1,209 @@
1
+ import { isConsonantInterval } from '../interval/index.js';
2
+ import type { KeyScale } from '../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
+ */
16
+ export function createsVoiceCrossing(upperPitch: number, lowerPitch: number): boolean {
17
+ return upperPitch < lowerPitch;
18
+ }
19
+
20
+ /**
21
+ * Whether two simultaneous pitches form a dissonance.
22
+ *
23
+ * @param a First pitch.
24
+ * @param b Second pitch.
25
+ * @param twoVoice When true, the perfect fourth counts as dissonant.
26
+ * @returns True when the vertical interval is dissonant.
27
+ */
28
+ export function createsVerticalDissonance(a: number, b: number, twoVoice: boolean): boolean {
29
+ return !isConsonantInterval(a - b, twoVoice);
30
+ }
31
+
32
+ /**
33
+ * Whether a melodic move is a forbidden leap: a tritone, either seventh (minor
34
+ * or major), or any leap wider than an octave.
35
+ *
36
+ * Operates on the raw absolute distance, so compound leaps are not reduced to
37
+ * their simple class before the octave check.
38
+ *
39
+ * @param prev Starting pitch.
40
+ * @param cur Ending pitch.
41
+ * @returns True when the leap is forbidden in strict counterpoint.
42
+ */
43
+ export function isForbiddenMelodicLeap(prev: number, cur: number): boolean {
44
+ const semis = Math.abs(cur - prev);
45
+ return semis === 6 || semis === 10 || semis === 11 || semis > 12;
46
+ }
47
+
48
+ /** Reduce an interval to its simple class in [0, 11]. */
49
+ function simpleClass(semitones: number): number {
50
+ return Math.abs(semitones) % 12;
51
+ }
52
+
53
+ /** Whether a simple interval class is a perfect kind (unison/octave or fifth). */
54
+ function isPerfectClass(cls: number): boolean {
55
+ return cls === 0 || cls === 7;
56
+ }
57
+
58
+ /** Whether two voices move in the same direction (similar or parallel motion). */
59
+ function similarMotion(aMove: number, bMove: number): boolean {
60
+ return aMove !== 0 && bMove !== 0 && aMove > 0 === bMove > 0;
61
+ }
62
+
63
+ /** Whether both voices actually move (neither is stationary — excludes oblique motion). */
64
+ function bothVoicesMove(aMove: number, bMove: number): boolean {
65
+ return aMove !== 0 && bMove !== 0;
66
+ }
67
+
68
+ /**
69
+ * Whether two voices move into consecutive perfect intervals of the same kind
70
+ * (fifth-to-fifth, octave-to-octave, unison-to-unison).
71
+ *
72
+ * Both true parallels (similar motion) and anti-parallels — the same perfect
73
+ * class reached by contrary motion, e.g. octave to octave with the voices moving
74
+ * in opposite directions — are flagged, as both are forbidden in strict two-voice
75
+ * counterpoint. The rule requires that both voices actually move: oblique motion
76
+ * (either voice stationary) and the no-change case (identical pitches) are excluded.
77
+ *
78
+ * A fifth expanding to a twelfth counts (same perfect class); a fifth moving to
79
+ * an octave does not (different perfect kinds — the direct/hidden case owned by
80
+ * {@link createsHiddenParallelPerfect}).
81
+ */
82
+ export function createsParallelPerfect(
83
+ aPrev: number,
84
+ aCur: number,
85
+ bPrev: number,
86
+ bCur: number,
87
+ ): boolean {
88
+ if (!bothVoicesMove(aCur - aPrev, bCur - bPrev)) {
89
+ return false;
90
+ }
91
+ const nowClass = simpleClass(aCur - bCur);
92
+ const prevClass = simpleClass(aPrev - bPrev);
93
+ return isPerfectClass(nowClass) && nowClass === prevClass;
94
+ }
95
+
96
+ /**
97
+ * Whether two voices move in consecutive parallel octaves (or unisons) by
98
+ * similar motion.
99
+ */
100
+ export function createsParallelOctave(
101
+ aPrev: number,
102
+ aCur: number,
103
+ bPrev: number,
104
+ bCur: number,
105
+ ): boolean {
106
+ if (!similarMotion(aCur - aPrev, bCur - bPrev)) {
107
+ return false;
108
+ }
109
+ return simpleClass(aCur - bCur) === 0 && simpleClass(aPrev - bPrev) === 0;
110
+ }
111
+
112
+ /**
113
+ * Whether two voices move in consecutive parallel unisons — both landing on the
114
+ * same pitch, having shared a pitch on the previous move.
115
+ */
116
+ export function createsParallelUnison(
117
+ aPrev: number,
118
+ aCur: number,
119
+ bPrev: number,
120
+ bCur: number,
121
+ ): boolean {
122
+ if (aCur === aPrev || bCur === bPrev) {
123
+ return false;
124
+ }
125
+ return aCur === bCur && aPrev === bPrev;
126
+ }
127
+
128
+ /**
129
+ * Whether two voices reach a perfect interval by similar motion from an
130
+ * imperfect one (a hidden/direct fifth or octave).
131
+ *
132
+ * The traditional step exception is applied: the approach is allowed when the
133
+ * upper of the two voices moves by step, so only leaps into the perfect
134
+ * interval are flagged.
135
+ */
136
+ export function createsHiddenParallelPerfect(
137
+ aPrev: number,
138
+ aCur: number,
139
+ bPrev: number,
140
+ bCur: number,
141
+ ): boolean {
142
+ const aMove = aCur - aPrev;
143
+ const bMove = bCur - bPrev;
144
+ if (!similarMotion(aMove, bMove)) {
145
+ return false;
146
+ }
147
+ const nowClass = simpleClass(aCur - bCur);
148
+ const prevClass = simpleClass(aPrev - bPrev);
149
+ // Approaching the same perfect class (e.g. fifth to fifth) is a true parallel
150
+ // owned by createsParallelPerfect; approaching a different perfect interval
151
+ // (fifth to octave, or vice versa) is the hidden/direct case flagged here.
152
+ if (!isPerfectClass(nowClass) || prevClass === nowClass) {
153
+ return false;
154
+ }
155
+ const upperMove = aCur >= bCur ? aMove : bMove;
156
+ if (Math.abs(upperMove) <= 2) {
157
+ return false; // upper voice moves by step — direct interval is acceptable
158
+ }
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Whether two voices overlap: the upper voice descends below where the lower
164
+ * voice just was, or the lower voice rises above where the upper voice just was.
165
+ * Distinct from a simultaneous voice crossing.
166
+ *
167
+ * @param upperPrev Previous pitch of the upper voice.
168
+ * @param upperCur Current pitch of the upper voice.
169
+ * @param lowerPrev Previous pitch of the lower voice.
170
+ * @param lowerCur Current pitch of the lower voice.
171
+ */
172
+ export function createsVoiceOverlap(
173
+ upperPrev: number,
174
+ upperCur: number,
175
+ lowerPrev: number,
176
+ lowerCur: number,
177
+ ): boolean {
178
+ return upperCur < lowerPrev || lowerCur > upperPrev;
179
+ }
180
+
181
+ /**
182
+ * Whether two adjacent upper voices are spaced more than a maximum apart
183
+ * (commonly an octave). Bass-to-tenor spacing is conventionally exempt, so this
184
+ * is meant for the upper voice pairs.
185
+ *
186
+ * @param upperPitch Pitch of the higher voice.
187
+ * @param lowerPitch Pitch of the lower voice.
188
+ * @param maxSemitones Maximum allowed spacing in semitones (default an octave).
189
+ */
190
+ export function exceedsSpacing(upperPitch: number, lowerPitch: number, maxSemitones = 12): boolean {
191
+ return Math.abs(upperPitch - lowerPitch) > maxSemitones;
192
+ }
193
+
194
+ /**
195
+ * Whether a leading tone resolves correctly upward to the tonic.
196
+ *
197
+ * @param prev The leading-tone pitch.
198
+ * @param cur The following pitch.
199
+ * @param key Key context supplying the tonic.
200
+ * @returns True when `prev` is the leading tone and `cur` is the tonic a step above.
201
+ */
202
+ export function isLeadingToneResolution(prev: number, cur: number, key: KeyScale): boolean {
203
+ const tonic = pitchClass(key.rootPc);
204
+ const leading = (tonic + 11) % 12;
205
+ if (pitchClass(prev) !== leading || pitchClass(cur) !== tonic) {
206
+ return false;
207
+ }
208
+ return cur > prev && cur - prev <= 2;
209
+ }