@libraz/libcantus 0.9.0 → 0.9.1

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.
package/src/bass/index.ts CHANGED
@@ -18,29 +18,57 @@ import { createRng } from '../random/index.js';
18
18
  import { nearestScaleTone } from '../scale/index.js';
19
19
  import type { KeyScale, NoteEvent } from '../types.js';
20
20
 
21
- /** A chord sounding over a half-open beat span `[startBeat, endBeat)`. */
21
+ /**
22
+ * A chord sounding over a half-open beat span `[startBeat, endBeat)`.
23
+ *
24
+ * @category Composition
25
+ */
22
26
  export type BassSegment = {
23
27
  startBeat: number;
24
28
  endBeat: number;
25
29
  chord: Chord;
26
30
  };
27
31
 
28
- /** The bass-line idiom to generate. */
32
+ /**
33
+ * The bass-line idiom to generate.
34
+ *
35
+ * @category Composition
36
+ */
29
37
  export type BassStyle = 'root' | 'rootFifth' | 'pop' | 'walking' | 'arpeggio';
30
38
 
31
- /** Options controlling {@link generateBassLine}. */
39
+ /**
40
+ * Options controlling {@link generateBassLine}.
41
+ *
42
+ * @category Composition
43
+ */
32
44
  export type BassLineOptions = {
33
45
  /** Chord placement to follow; need not be pre-sorted. */
34
46
  segments: BassSegment[];
35
47
  /** Key/scale context, used for diatonic approach tones in `walking`. */
36
48
  key: KeyScale;
37
- /** Time signature; used for metric accents. Default 4/4. */
49
+ /**
50
+ * Time signature; used for metric accents.
51
+ *
52
+ * @defaultValue `{ numerator: 4, denominator: 4 }`
53
+ */
38
54
  ts?: TimeSignature;
39
- /** Bass-line idiom. Default `'root'`. */
55
+ /**
56
+ * Bass-line idiom.
57
+ *
58
+ * @defaultValue `'root'`
59
+ */
40
60
  style?: BassStyle;
41
- /** Target register as a base MIDI octave; roots land around `octave*12+12`. Default 2. */
61
+ /**
62
+ * Target register as a base MIDI octave; roots land around `octave*12+12`.
63
+ *
64
+ * @defaultValue 2
65
+ */
42
66
  octave?: number;
43
- /** Seed for the deterministic PRNG. Default 0. */
67
+ /**
68
+ * Seed for the deterministic PRNG.
69
+ *
70
+ * @defaultValue 0
71
+ */
44
72
  seed?: number;
45
73
  };
46
74
 
@@ -98,10 +126,18 @@ function chordTonePcs(chord: Chord): number[] {
98
126
  return pcs;
99
127
  }
100
128
 
101
- /** The chord's perfect-fifth pitch class, falling back to the root when absent. */
129
+ /**
130
+ * The chord's fifth pitch class.
131
+ *
132
+ * Uses the perfect fifth when present, otherwise the chord's actual altered
133
+ * fifth (diminished = 6, augmented = 8 semitones above the root), so dim/aug/
134
+ * m7b5 chords sound their real fifth rather than a repeated root. Falls back to
135
+ * the root only when the chord has no fifth degree at all.
136
+ */
102
137
  function fifthPcOf(chord: Chord): number {
103
- const hasFifth = chord.intervals.some((i) => pitchClass(i) === 7);
104
- return hasFifth ? pitchClass(chord.rootPc + 7) : pitchClass(chord.rootPc);
138
+ const fifths = new Set(chord.intervals.map((i) => pitchClass(i)));
139
+ const chosen = fifths.has(7) ? 7 : fifths.has(6) ? 6 : fifths.has(8) ? 8 : undefined;
140
+ return chosen === undefined ? pitchClass(chord.rootPc) : pitchClass(chord.rootPc + chosen);
105
141
  }
106
142
 
107
143
  /** The sounding bass pitch class of a chord: its slash bass, else its root. */
@@ -166,9 +202,10 @@ function buildPop(ctx: BuildContext, seg: BassSegment): void {
166
202
  emitted = true;
167
203
  } else if (ctx.rng.prob(PICKUP_PROB)) {
168
204
  if (ctx.rng.prob(0.5)) {
169
- // Octave pickup: the root an octave below the band anchor.
205
+ // Octave pickup: the root dropped toward the bottom of the register,
206
+ // clamped so it never falls below the declared band.
170
207
  const base = placePc(rootPc, ctx.prevMidi, ctx.low);
171
- emit(ctx, pos, rootPc, base - 12);
208
+ emit(ctx, pos, rootPc, Math.max(ctx.low, base - 12));
172
209
  } else {
173
210
  ctx.prevMidi = emit(ctx, pos, fifthPcOf(seg.chord));
174
211
  }
@@ -231,6 +268,18 @@ function buildWalking(ctx: BuildContext, seg: BassSegment, next: BassSegment | u
231
268
  *
232
269
  * @param opts Segments, key, and generation options.
233
270
  * @returns Bass notes sorted by onset, non-overlapping.
271
+ *
272
+ * @example
273
+ * ```ts
274
+ * import { generateBassLine, makeChord, majorKey } from '@libraz/libcantus';
275
+ * const segments = [
276
+ * { startBeat: 0, endBeat: 4, chord: makeChord(0, 'maj') },
277
+ * { startBeat: 4, endBeat: 8, chord: makeChord(7, 'maj') },
278
+ * ];
279
+ * const notes = generateBassLine({ segments, key: majorKey(0), style: 'walking' });
280
+ * ```
281
+ *
282
+ * @category Composition
234
283
  */
235
284
  export function generateBassLine(opts: BassLineOptions): NoteEvent[] {
236
285
  const segments = [...opts.segments].sort((a, b) => a.startBeat - b.startBeat);
@@ -1,7 +1,11 @@
1
1
  import { scaleTonesInDegreeOrder } from '../scale/index.js';
2
2
  import type { KeyScale } from '../types.js';
3
3
 
4
- /** Chord quality identifiers understood by the chord builder. */
4
+ /**
5
+ * Chord quality identifiers understood by the chord builder.
6
+ *
7
+ * @category Chords
8
+ */
5
9
  export type ChordQuality =
6
10
  | 'maj'
7
11
  | 'min'
@@ -34,12 +38,36 @@ export type ChordQuality =
34
38
  | '13'
35
39
  | '5';
36
40
 
37
- /** A chord expressed as a root pitch class plus semitone offsets. */
41
+ /**
42
+ * A bare spelled pitch used as an enharmonic hint: a diatonic letter
43
+ * (0..6 = C..B) plus a chromatic alteration (-2 double-flat .. +2 double-sharp).
44
+ * Mirrors the letter/alter half of the pitch module's `Note` without an octave.
45
+ *
46
+ * @category Chords
47
+ */
48
+ export type PitchSpelling = {
49
+ letter: number;
50
+ alter: number;
51
+ };
52
+
53
+ /**
54
+ * A chord expressed as a root pitch class plus semitone offsets.
55
+ *
56
+ * `rootSpelling`/`bassSpelling` are optional enharmonic hints recorded by
57
+ * parsers (e.g. `parseChordSymbol('Bb7')`) so formatters can reproduce the
58
+ * original spelling instead of defaulting to sharps. Consumers may ignore
59
+ * them; a hint is only trusted when its pitch class still matches the
60
+ * corresponding `rootPc`/`bassPc`.
61
+ *
62
+ * @category Chords
63
+ */
38
64
  export type Chord = {
39
65
  rootPc: number;
40
66
  quality: ChordQuality;
41
67
  intervals: number[];
42
68
  bassPc?: number;
69
+ rootSpelling?: PitchSpelling;
70
+ bassSpelling?: PitchSpelling;
43
71
  };
44
72
 
45
73
  /** Semitone offsets from the root for each supported chord quality. */
@@ -81,7 +109,11 @@ function pitchClass(value: number): number {
81
109
  return ((Math.trunc(value) % 12) + 12) % 12;
82
110
  }
83
111
 
84
- /** All chord qualities the builder understands, longest templates last. */
112
+ /**
113
+ * All chord qualities the builder understands, longest templates last.
114
+ *
115
+ * @category Chords
116
+ */
85
117
  export function chordQualities(): ChordQuality[] {
86
118
  return Object.keys(QUALITY_INTERVALS) as ChordQuality[];
87
119
  }
@@ -97,6 +129,8 @@ export function chordQualities(): ChordQuality[] {
97
129
  * @param ext Chord quality to apply.
98
130
  * @param key Key context supplying the diatonic root.
99
131
  * @returns The constructed chord.
132
+ *
133
+ * @category Chords
100
134
  */
101
135
  export function chordFromDegree(degree: number, ext: ChordQuality, key: KeyScale): Chord {
102
136
  const tones = scaleTonesInDegreeOrder(key);
@@ -113,6 +147,15 @@ export function chordFromDegree(degree: number, ext: ChordQuality, key: KeyScale
113
147
  * @param quality Chord quality supplying the interval template.
114
148
  * @param bassPc Optional slash-chord bass pitch class.
115
149
  * @returns The constructed chord.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * import { makeChord } from '@libraz/libcantus';
154
+ * const cmaj7 = makeChord(0, 'maj7');
155
+ * // { rootPc: 0, quality: 'maj7', intervals: [0, 4, 7, 11] }
156
+ * ```
157
+ *
158
+ * @category Chords
116
159
  */
117
160
  export function makeChord(rootPc: number, quality: ChordQuality, bassPc?: number): Chord {
118
161
  const chord: Chord = {
@@ -131,6 +174,14 @@ export function makeChord(rootPc: number, quality: ChordQuality, bassPc?: number
131
174
  *
132
175
  * @param chord The chord to enumerate.
133
176
  * @returns The chord's pitch classes, sorted ascending in [0, 11].
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * import { makeChord, chordPitchClasses } from '@libraz/libcantus';
181
+ * chordPitchClasses(makeChord(0, 'maj7')); // [0, 4, 7, 11]
182
+ * ```
183
+ *
184
+ * @category Chords
134
185
  */
135
186
  export function chordPitchClasses(chord: Chord): number[] {
136
187
  const set = new Set<number>();
@@ -156,6 +207,8 @@ function chordToneOffsets(chord: Chord): Set<number> {
156
207
  * @param pitch MIDI pitch or bare pitch class.
157
208
  * @param chord The chord providing the root reference.
158
209
  * @returns The chord-tone role, or null if the pitch has no basic role.
210
+ *
211
+ * @category Chords
159
212
  */
160
213
  export function chordToneRole(
161
214
  pitch: number,
@@ -275,6 +328,14 @@ function stackThirds(degree: number, key: KeyScale, size: 3 | 4): Chord {
275
328
  * @param degree 0-based scale degree of the chord root.
276
329
  * @param key Key/scale context.
277
330
  * @returns The diatonic triad.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * import { majorKey, diatonicTriad } from '@libraz/libcantus';
335
+ * const tonic = diatonicTriad(0, majorKey(0)); // C major triad (degree 0 of C major)
336
+ * ```
337
+ *
338
+ * @category Chords
278
339
  */
279
340
  export function diatonicTriad(degree: number, key: KeyScale): Chord {
280
341
  return stackThirds(degree, key, 3);
@@ -286,6 +347,8 @@ export function diatonicTriad(degree: number, key: KeyScale): Chord {
286
347
  * @param degree 0-based scale degree of the chord root.
287
348
  * @param key Key/scale context.
288
349
  * @returns The diatonic seventh chord.
350
+ *
351
+ * @category Chords
289
352
  */
290
353
  export function diatonicSeventh(degree: number, key: KeyScale): Chord {
291
354
  return stackThirds(degree, key, 4);
@@ -29,6 +29,8 @@ function maskHasPitchClass(mask: number, scaleRootPc: number, pc: number): boole
29
29
  * @param scaleMask The scale's 12-bit mode mask.
30
30
  * @param scaleRootPc The pitch class the mask is rooted on.
31
31
  * @returns True if the scale is a superset of the chord.
32
+ *
33
+ * @category Scales
32
34
  */
33
35
  export function scaleMatchesChord(
34
36
  chordPcs: number[],
@@ -43,37 +45,67 @@ export function scaleMatchesChord(
43
45
  return true;
44
46
  }
45
47
 
46
- /** A named scale rooted on a pitch class that fits over a chord. */
48
+ /**
49
+ * A named scale rooted on a pitch class that fits over a chord.
50
+ *
51
+ * @category Scales
52
+ */
47
53
  export type ChordScaleMatch = {
48
54
  name: string;
49
55
  rootPc: number;
50
56
  };
51
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
+
52
69
  /**
53
70
  * List the scales that fit over a chord, best fit first.
54
71
  *
55
72
  * Only the chord root is considered as the scale root, matching the
56
73
  * 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.
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.
61
82
  *
62
83
  * @param chord The chord to fit scales over.
63
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
64
94
  */
65
95
  export function chordScales(chord: Chord): ChordScaleMatch[] {
66
96
  const chordPcs = chordPitchClasses(chord);
67
97
  const rootPc = pitchClass(chord.rootPc);
68
98
  const ranked: { name: string; extra: number; size: number }[] = [];
99
+ const seenMasks = new Set<number>();
69
100
  for (const name of Object.keys(NAMED_SCALES)) {
70
- if (name === 'chromatic') {
101
+ if (name === 'chromatic' || ALIASED_SCALE_NAMES.has(name)) {
71
102
  continue;
72
103
  }
73
104
  const mask = NAMED_SCALES[name];
74
- if (mask === undefined) {
105
+ if (mask === undefined || seenMasks.has(mask)) {
75
106
  continue;
76
107
  }
108
+ seenMasks.add(mask);
77
109
  if (scaleMatchesChord(chordPcs, mask, rootPc)) {
78
110
  const size = popcount12(mask);
79
111
  ranked.push({ name, extra: size - chordPcs.length, size });
@@ -87,7 +119,11 @@ export function chordScales(chord: Chord): ChordScaleMatch[] {
87
119
  }
88
120
  return [];
89
121
  }
122
+ const preferHeptatonic = chordPcs.length <= 3;
90
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
+ }
91
127
  if (a.extra !== b.extra) {
92
128
  return a.extra - b.extra;
93
129
  }
@@ -109,6 +145,8 @@ export function chordScales(chord: Chord): ChordScaleMatch[] {
109
145
  * @param chord The chord providing the chord tones.
110
146
  * @param scaleName A key of {@link NAMED_SCALES}.
111
147
  * @returns The avoid-note pitch classes, sorted ascending in [0, 11].
148
+ *
149
+ * @category Scales
112
150
  */
113
151
  export function avoidNotes(chord: Chord, scaleName: string): number[] {
114
152
  const mask = NAMED_SCALES[scaleName];
@@ -144,6 +182,14 @@ export function avoidNotes(chord: Chord, scaleName: string): number[] {
144
182
  * @param chord The chord providing the chord tones.
145
183
  * @param scaleName A key of {@link NAMED_SCALES}.
146
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
147
193
  */
148
194
  export function availableTensions(chord: Chord, scaleName: string): number[] {
149
195
  const mask = NAMED_SCALES[scaleName];
@@ -167,7 +213,11 @@ export function availableTensions(chord: Chord, scaleName: string): number[] {
167
213
  return tensions.sort((a, b) => a - b);
168
214
  }
169
215
 
170
- /** A scale fit over a chord together with its avoid notes and tensions. */
216
+ /**
217
+ * A scale fit over a chord together with its avoid notes and tensions.
218
+ *
219
+ * @category Scales
220
+ */
171
221
  export type ChordScaleReportEntry = {
172
222
  name: string;
173
223
  rootPc: number;
@@ -184,6 +234,8 @@ export type ChordScaleReportEntry = {
184
234
  * @param chord The chord to analyze.
185
235
  * @param limit Optional maximum number of scales to report; all by default.
186
236
  * @returns One entry per reported scale, best fit first.
237
+ *
238
+ * @category Scales
187
239
  */
188
240
  export function chordScaleReport(chord: Chord, limit?: number): ChordScaleReportEntry[] {
189
241
  const matches = chordScales(chord);
@@ -196,7 +248,11 @@ export function chordScaleReport(chord: Chord, limit?: number): ChordScaleReport
196
248
  }));
197
249
  }
198
250
 
199
- /** A chord paired with the scale chosen for it by {@link scalesForChanges}. */
251
+ /**
252
+ * A chord paired with the scale chosen for it by {@link scalesForChanges}.
253
+ *
254
+ * @category Scales
255
+ */
200
256
  export type ScaleChoice = {
201
257
  chord: Chord;
202
258
  scale: ChordScaleMatch;
@@ -251,6 +307,8 @@ function symmetricDifferenceSize(a: Set<number>, b: Set<number>): number {
251
307
  *
252
308
  * @param chords The chord sequence to choose scales for.
253
309
  * @returns One scale choice per chord, in input order.
310
+ *
311
+ * @category Scales
254
312
  */
255
313
  export function scalesForChanges(chords: Chord[]): ScaleChoice[] {
256
314
  if (chords.length === 0) {
@@ -1,10 +1,6 @@
1
1
  import type { Chord } from '../chord/index.js';
2
2
  import { chordToneRole } from '../chord/index.js';
3
- import {
4
- createsParallelOctave,
5
- createsParallelPerfect,
6
- isForbiddenMelodicLeap,
7
- } from '../counterpoint/index.js';
3
+ import { createsParallelPerfect, isForbiddenMelodicLeap } from '../counterpoint/index.js';
8
4
  import type { TimeSignature } from '../meter/index.js';
9
5
  import { isStrongBeat } from '../meter/index.js';
10
6
  import type { Rng } from '../random/index.js';
@@ -14,7 +10,11 @@ import { enumerateSafePitches } from '../safety/index.js';
14
10
  import { nearestScaleTone } from '../scale/index.js';
15
11
  import type { KeyScale, NoteEvent } from '../types.js';
16
12
 
17
- /** Options controlling {@link generateCounterMelody}. */
13
+ /**
14
+ * Options controlling {@link generateCounterMelody}.
15
+ *
16
+ * @category Voicing & Counterpoint
17
+ */
18
18
  export type CounterMelodyOptions = {
19
19
  /** The lead line to write against, in ascending onset order. */
20
20
  melody: NoteEvent[];
@@ -22,22 +22,40 @@ export type CounterMelodyOptions = {
22
22
  chordAt: (beat: number) => Chord | null;
23
23
  /** Key/scale context for scale-tone decisions. */
24
24
  key: KeyScale;
25
- /** Meter used for strong-beat decisions (default 4/4). */
25
+ /**
26
+ * Meter used for strong-beat decisions.
27
+ *
28
+ * @defaultValue 4/4
29
+ */
26
30
  ts?: TimeSignature;
27
- /** Which side of the melody the counter line occupies (default `'below'`). */
31
+ /**
32
+ * Which side of the melody the counter line occupies.
33
+ *
34
+ * @defaultValue 'below'
35
+ */
28
36
  register?: 'above' | 'below';
29
37
  /**
30
- * Onset strategy: `'complement'` (default) moves where the melody holds or
31
- * rests and reinforces some strong beats; `'follow'` mirrors melody onsets.
38
+ * Onset strategy: `'complement'` moves where the melody holds or rests and
39
+ * reinforces some strong beats; `'follow'` mirrors melody onsets.
40
+ *
41
+ * @defaultValue 'complement'
32
42
  */
33
43
  rhythm?: 'complement' | 'follow';
34
- /** Safety profile applied to candidate pitches (default `'pop'`). */
44
+ /**
45
+ * Safety profile applied to candidate pitches.
46
+ *
47
+ * @defaultValue 'pop'
48
+ */
35
49
  profile?: 'strict' | 'pop';
36
50
  /** Lowest MIDI pitch the counter line may use (default derived from `register`). */
37
51
  pitchLow?: number;
38
52
  /** Highest MIDI pitch the counter line may use (default derived from `register`). */
39
53
  pitchHigh?: number;
40
- /** PRNG seed; the same seed always yields the same line (default 0). */
54
+ /**
55
+ * PRNG seed; the same seed always yields the same line.
56
+ *
57
+ * @defaultValue 0
58
+ */
41
59
  seed?: number;
42
60
  };
43
61
 
@@ -244,6 +262,17 @@ function fallbackPitch(
244
262
  *
245
263
  * @param opts The lead line, chord context callback, key, and generation knobs.
246
264
  * @returns The counter line as note events sorted by onset; `[]` for an empty melody.
265
+ * @example
266
+ * ```ts
267
+ * import { generateCounterMelody, majorKey, parseChordSymbol } from '@libraz/libcantus';
268
+ * const melody = [{ pitch: 72, startBeat: 0, durationBeat: 2 }];
269
+ * const counter = generateCounterMelody({
270
+ * melody,
271
+ * chordAt: () => parseChordSymbol('C'),
272
+ * key: majorKey(0),
273
+ * }); // note events below the melody, in onset order
274
+ * ```
275
+ * @category Voicing & Counterpoint
247
276
  */
248
277
  export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
249
278
  const melody = [...opts.melody].sort((a, b) => a.startBeat - b.startBeat);
@@ -309,8 +338,9 @@ export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
309
338
  if (
310
339
  prevPitch !== undefined &&
311
340
  melPrev !== undefined &&
312
- (createsParallelPerfect(prevPitch, candidate, melPrev, melPitch) ||
313
- createsParallelOctave(prevPitch, candidate, melPrev, melPitch))
341
+ // Parallel octaves are the perfect-class-zero case of this predicate,
342
+ // so a single check covers both parallel fifths and octaves.
343
+ createsParallelPerfect(prevPitch, candidate, melPrev, melPitch)
314
344
  ) {
315
345
  continue;
316
346
  }
@@ -12,6 +12,7 @@ function pitchClass(pitch: number): number {
12
12
  * @param upperPitch Pitch of the nominally higher voice.
13
13
  * @param lowerPitch Pitch of the nominally lower voice.
14
14
  * @returns True when the upper voice sits below the lower voice.
15
+ * @category Voicing & Counterpoint
15
16
  */
16
17
  export function createsVoiceCrossing(upperPitch: number, lowerPitch: number): boolean {
17
18
  return upperPitch < lowerPitch;
@@ -24,6 +25,7 @@ export function createsVoiceCrossing(upperPitch: number, lowerPitch: number): bo
24
25
  * @param b Second pitch.
25
26
  * @param twoVoice When true, the perfect fourth counts as dissonant.
26
27
  * @returns True when the vertical interval is dissonant.
28
+ * @category Voicing & Counterpoint
27
29
  */
28
30
  export function createsVerticalDissonance(a: number, b: number, twoVoice: boolean): boolean {
29
31
  return !isConsonantInterval(a - b, twoVoice);
@@ -39,6 +41,7 @@ export function createsVerticalDissonance(a: number, b: number, twoVoice: boolea
39
41
  * @param prev Starting pitch.
40
42
  * @param cur Ending pitch.
41
43
  * @returns True when the leap is forbidden in strict counterpoint.
44
+ * @category Voicing & Counterpoint
42
45
  */
43
46
  export function isForbiddenMelodicLeap(prev: number, cur: number): boolean {
44
47
  const semis = Math.abs(cur - prev);
@@ -78,6 +81,8 @@ function bothVoicesMove(aMove: number, bMove: number): boolean {
78
81
  * A fifth expanding to a twelfth counts (same perfect class); a fifth moving to
79
82
  * an octave does not (different perfect kinds — the direct/hidden case owned by
80
83
  * {@link createsHiddenParallelPerfect}).
84
+ *
85
+ * @category Voicing & Counterpoint
81
86
  */
82
87
  export function createsParallelPerfect(
83
88
  aPrev: number,
@@ -96,6 +101,15 @@ export function createsParallelPerfect(
96
101
  /**
97
102
  * Whether two voices move in consecutive parallel octaves (or unisons) by
98
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
99
113
  */
100
114
  export function createsParallelOctave(
101
115
  aPrev: number,
@@ -112,6 +126,8 @@ export function createsParallelOctave(
112
126
  /**
113
127
  * Whether two voices move in consecutive parallel unisons — both landing on the
114
128
  * same pitch, having shared a pitch on the previous move.
129
+ *
130
+ * @category Voicing & Counterpoint
115
131
  */
116
132
  export function createsParallelUnison(
117
133
  aPrev: number,
@@ -132,6 +148,8 @@ export function createsParallelUnison(
132
148
  * The traditional step exception is applied: the approach is allowed when the
133
149
  * upper of the two voices moves by step, so only leaps into the perfect
134
150
  * interval are flagged.
151
+ *
152
+ * @category Voicing & Counterpoint
135
153
  */
136
154
  export function createsHiddenParallelPerfect(
137
155
  aPrev: number,
@@ -168,6 +186,7 @@ export function createsHiddenParallelPerfect(
168
186
  * @param upperCur Current pitch of the upper voice.
169
187
  * @param lowerPrev Previous pitch of the lower voice.
170
188
  * @param lowerCur Current pitch of the lower voice.
189
+ * @category Voicing & Counterpoint
171
190
  */
172
191
  export function createsVoiceOverlap(
173
192
  upperPrev: number,
@@ -186,6 +205,7 @@ export function createsVoiceOverlap(
186
205
  * @param upperPitch Pitch of the higher voice.
187
206
  * @param lowerPitch Pitch of the lower voice.
188
207
  * @param maxSemitones Maximum allowed spacing in semitones (default an octave).
208
+ * @category Voicing & Counterpoint
189
209
  */
190
210
  export function exceedsSpacing(upperPitch: number, lowerPitch: number, maxSemitones = 12): boolean {
191
211
  return Math.abs(upperPitch - lowerPitch) > maxSemitones;
@@ -198,6 +218,7 @@ export function exceedsSpacing(upperPitch: number, lowerPitch: number, maxSemito
198
218
  * @param cur The following pitch.
199
219
  * @param key Key context supplying the tonic.
200
220
  * @returns True when `prev` is the leading tone and `cur` is the tonic a step above.
221
+ * @category Voicing & Counterpoint
201
222
  */
202
223
  export function isLeadingToneResolution(prev: number, cur: number, key: KeyScale): boolean {
203
224
  const tonic = pitchClass(key.rootPc);