@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.
@@ -11,6 +11,8 @@
11
11
  /**
12
12
  * An equal-tempered tuning: a reference pitch and a number of equal divisions of
13
13
  * the octave. With `divisions === 12` a step index is an ordinary MIDI number.
14
+ *
15
+ * @category Pitch & Intervals
14
16
  */
15
17
  export type Tuning = {
16
18
  /** Step index (MIDI number when `divisions` is 12) whose frequency is `refFreq`. */
@@ -21,7 +23,11 @@ export type Tuning = {
21
23
  divisions: number;
22
24
  };
23
25
 
24
- /** Standard twelve-tone equal temperament, A4 (MIDI 69) = 440 Hz. */
26
+ /**
27
+ * Standard twelve-tone equal temperament, A4 (MIDI 69) = 440 Hz.
28
+ *
29
+ * @category Pitch & Intervals
30
+ */
25
31
  export const TWELVE_TET: Tuning = { refStep: 69, refFreq: 440, divisions: 12 };
26
32
 
27
33
  /**
@@ -31,6 +37,13 @@ export const TWELVE_TET: Tuning = { refStep: 69, refFreq: 440, divisions: 12 };
31
37
  * @param refFreq Reference frequency in Hz (default 440).
32
38
  * @param refStep Step index of the reference (default 69).
33
39
  * @returns The tuning.
40
+ * @example
41
+ * ```ts
42
+ * import { edo, frequencyOf } from '@libraz/libcantus';
43
+ * const et19 = edo(19); // { refStep: 69, refFreq: 440, divisions: 19 }
44
+ * frequencyOf(70, et19); // one 19-EDO step above A4
45
+ * ```
46
+ * @category Pitch & Intervals
34
47
  */
35
48
  export function edo(n: number, refFreq = 440, refStep = 69): Tuning {
36
49
  return { refStep, refFreq, divisions: n };
@@ -42,6 +55,13 @@ export function edo(n: number, refFreq = 440, refStep = 69): Tuning {
42
55
  * @param step Step index (a MIDI number under 12-EDO).
43
56
  * @param tuning The tuning (default 12-TET).
44
57
  * @returns The frequency in Hz.
58
+ * @example
59
+ * ```ts
60
+ * import { frequencyOf } from '@libraz/libcantus';
61
+ * frequencyOf(69); // 440 (A4 in 12-TET)
62
+ * frequencyOf(60); // middle C in Hz
63
+ * ```
64
+ * @category Pitch & Intervals
45
65
  */
46
66
  export function frequencyOf(step: number, tuning: Tuning = TWELVE_TET): number {
47
67
  return tuning.refFreq * 2 ** ((step - tuning.refStep) / tuning.divisions);
@@ -54,6 +74,7 @@ export function frequencyOf(step: number, tuning: Tuning = TWELVE_TET): number {
54
74
  * @param freq Frequency in Hz.
55
75
  * @param tuning The tuning (default 12-TET).
56
76
  * @returns The nearest step index.
77
+ * @category Pitch & Intervals
57
78
  */
58
79
  export function nearestStep(freq: number, tuning: Tuning = TWELVE_TET): number {
59
80
  return Math.round(tuning.refStep + tuning.divisions * Math.log2(freq / tuning.refFreq));
@@ -65,6 +86,7 @@ export function nearestStep(freq: number, tuning: Tuning = TWELVE_TET): number {
65
86
  * @param a Lower/first frequency in Hz.
66
87
  * @param b Upper/second frequency in Hz.
67
88
  * @returns Cents from `a` to `b` (negative if `b` is lower).
89
+ * @category Pitch & Intervals
68
90
  */
69
91
  export function centsBetweenFreq(a: number, b: number): number {
70
92
  return 1200 * Math.log2(b / a);
@@ -76,6 +98,7 @@ export function centsBetweenFreq(a: number, b: number): number {
76
98
  * @param steps Number of steps.
77
99
  * @param tuning The tuning (default 12-TET).
78
100
  * @returns The cents.
101
+ * @category Pitch & Intervals
79
102
  */
80
103
  export function centsOfSteps(steps: number, tuning: Tuning = TWELVE_TET): number {
81
104
  return (steps * 1200) / tuning.divisions;
@@ -88,6 +111,7 @@ export function centsOfSteps(steps: number, tuning: Tuning = TWELVE_TET): number
88
111
  * @param numerator Ratio numerator.
89
112
  * @param denominator Ratio denominator.
90
113
  * @returns The interval in cents.
114
+ * @category Pitch & Intervals
91
115
  */
92
116
  export function ratioToCents(numerator: number, denominator: number): number {
93
117
  return 1200 * Math.log2(numerator / denominator);
@@ -96,6 +120,8 @@ export function ratioToCents(numerator: number, denominator: number): number {
96
120
  /**
97
121
  * Five-limit just-intonation ratios for the twelve interval classes above a
98
122
  * unison, indexed by semitone class (0..12).
123
+ *
124
+ * @category Pitch & Intervals
99
125
  */
100
126
  export const JUST_RATIOS: Record<number, [number, number]> = {
101
127
  0: [1, 1],
@@ -119,6 +145,7 @@ export const JUST_RATIOS: Record<number, [number, number]> = {
119
145
  *
120
146
  * @param semitoneClass Semitone class in [0, 12].
121
147
  * @returns The deviation in cents, or NaN if the class has no listed ratio.
148
+ * @category Pitch & Intervals
122
149
  */
123
150
  export function justDeviationCents(semitoneClass: number): number {
124
151
  const ratio = JUST_RATIOS[semitoneClass];
@@ -1,14 +1,18 @@
1
1
  import type { Chord } from '../chord/index.js';
2
2
  import { chordPitchClasses, chordToneRole } from '../chord/index.js';
3
3
  import {
4
- createsParallelOctave,
4
+ createsHiddenParallelPerfect,
5
5
  createsParallelPerfect,
6
6
  createsVoiceCrossing,
7
7
  createsVoiceOverlap,
8
8
  exceedsSpacing,
9
9
  } from '../counterpoint/index.js';
10
10
 
11
- /** An inclusive MIDI pitch range for a single voice. */
11
+ /**
12
+ * An inclusive MIDI pitch range for a single voice.
13
+ *
14
+ * @category Voicing & Counterpoint
15
+ */
12
16
  export type VoiceRange = {
13
17
  min: number;
14
18
  max: number;
@@ -18,6 +22,8 @@ export type VoiceRange = {
18
22
  * Default four-voice SATB ranges, ascending (index 0 = lowest):
19
23
  * bass E2–C4 (40–60), tenor C3–G4 (48–67), alto G3–D5 (55–74),
20
24
  * soprano C4–G5 (60–79).
25
+ *
26
+ * @category Voicing & Counterpoint
21
27
  */
22
28
  export const SATB_RANGES: readonly VoiceRange[] = [
23
29
  { min: 40, max: 60 },
@@ -26,13 +32,25 @@ export const SATB_RANGES: readonly VoiceRange[] = [
26
32
  { min: 60, max: 79 },
27
33
  ];
28
34
 
29
- /** Options controlling {@link voiceChord} and {@link voiceProgression}. */
35
+ /**
36
+ * Options controlling {@link voiceChord} and {@link voiceProgression}.
37
+ *
38
+ * @category Voicing & Counterpoint
39
+ */
30
40
  export type VoicingOptions = {
31
- /** Number of voices to realize (default 4). Ignored when `ranges` is given. */
41
+ /**
42
+ * Number of voices to realize. Ignored when `ranges` is given.
43
+ *
44
+ * @defaultValue 4
45
+ */
32
46
  voices?: number;
33
47
  /** Explicit per-voice ranges, ascending (index 0 = lowest). Takes precedence over `voices`. */
34
48
  ranges?: VoiceRange[];
35
- /** Maximum spacing in semitones between adjacent upper voices (default 12). */
49
+ /**
50
+ * Maximum spacing in semitones between adjacent upper voices.
51
+ *
52
+ * @defaultValue 12
53
+ */
36
54
  maxSpacing?: number;
37
55
  };
38
56
 
@@ -51,6 +69,13 @@ const VIOLATION_PENALTY = 1000;
51
69
  const MISSING_TONE_PENALTY = 500;
52
70
  /** Score penalty per doubled tone that is neither the root nor the fifth. */
53
71
  const POOR_DOUBLING_PENALTY = 4;
72
+ /**
73
+ * Moderate penalty for a hidden/direct perfect fifth or octave reached on the
74
+ * outer-voice (bass–soprano) pair. Unlike a true parallel perfect it is
75
+ * discouraged rather than forbidden, so the weight sits alongside voice-leading
76
+ * motion rather than the hard {@link VIOLATION_PENALTY}.
77
+ */
78
+ const HIDDEN_PERFECT_PENALTY = 6;
54
79
  /** Hard cap on candidate voicings evaluated per chord, keeping the search bounded. */
55
80
  const MAX_CANDIDATES = 4000;
56
81
 
@@ -91,7 +116,13 @@ function resolveRanges(opts?: VoicingOptions): VoiceRange[] {
91
116
  return ranges;
92
117
  }
93
118
 
94
- /** All MIDI pitches of a pitch class inside an inclusive range, ascending. */
119
+ /**
120
+ * All MIDI pitches of a pitch class inside an inclusive range, ordered from the
121
+ * centre of the range outward (ties break toward the lower pitch). Enumerating
122
+ * centre-outward keeps the candidate set balanced around the register when it
123
+ * is truncated at {@link MAX_CANDIDATES}, instead of skewing to the low octaves
124
+ * that a plain ascending scan would visit first.
125
+ */
95
126
  function pitchesForPc(pc: number, range: VoiceRange): number[] {
96
127
  const result: number[] = [];
97
128
  for (let midi = Math.ceil(range.min); midi <= range.max; midi += 1) {
@@ -99,7 +130,12 @@ function pitchesForPc(pc: number, range: VoiceRange): number[] {
99
130
  result.push(midi);
100
131
  }
101
132
  }
102
- return result;
133
+ const center = (range.min + range.max) / 2;
134
+ return result.sort((a, b) => {
135
+ const da = Math.abs(a - center);
136
+ const db = Math.abs(b - center);
137
+ return da === db ? a - b : da - db;
138
+ });
103
139
  }
104
140
 
105
141
  /**
@@ -178,9 +214,10 @@ function structuralPenalty(pitches: number[], chord: Chord): number {
178
214
 
179
215
  /**
180
216
  * Count counterpoint violations between two consecutive voicings of equal
181
- * length: parallel perfects and parallel octaves on every voice pair, voice
182
- * crossings in the new voicing, and — on adjacent pairs — voice overlaps and
183
- * over-wide spacing between upper voices.
217
+ * length: parallel perfects (fifths and octaves alike, since octaves are the
218
+ * perfect-class-zero case of {@link createsParallelPerfect}) on every voice
219
+ * pair, voice crossings in the new voicing, and — on adjacent pairs — voice
220
+ * overlaps and over-wide spacing between upper voices.
184
221
  */
185
222
  function violationCount(prev: number[], cur: number[], maxSpacing: number): number {
186
223
  let count = 0;
@@ -201,9 +238,6 @@ function violationCount(prev: number[], cur: number[], maxSpacing: number): numb
201
238
  if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
202
239
  count += 1;
203
240
  }
204
- if (createsParallelOctave(prevUpper, curUpper, prevLower, curLower)) {
205
- count += 1;
206
- }
207
241
  if (createsVoiceCrossing(curUpper, curLower)) {
208
242
  count += 1;
209
243
  }
@@ -222,12 +256,16 @@ function violationCount(prev: number[], cur: number[], maxSpacing: number): numb
222
256
 
223
257
  /**
224
258
  * Total voice-leading cost between two voicings: the sum of absolute semitone
225
- * motion across voices. The arrays must be the same length; when they differ
226
- * the voicings are not comparable and the cost is `Infinity`.
259
+ * motion across voices, plus a moderate {@link HIDDEN_PERFECT_PENALTY} when the
260
+ * outer-voice (bass–soprano) pair reaches a hidden/direct perfect fifth or
261
+ * octave by similar motion. The arrays must be the same length; when they
262
+ * differ the voicings are not comparable and the cost is `Infinity`.
227
263
  *
228
264
  * @param from The previous voicing, one MIDI pitch per voice.
229
265
  * @param to The next voicing, one MIDI pitch per voice.
230
- * @returns The summed absolute motion, or `Infinity` when lengths differ.
266
+ * @returns The summed absolute motion (plus any hidden-perfect penalty), or
267
+ * `Infinity` when lengths differ.
268
+ * @category Voicing & Counterpoint
231
269
  */
232
270
  export function voiceLeadingCost(from: number[], to: number[]): number {
233
271
  if (from.length !== to.length) {
@@ -242,6 +280,23 @@ export function voiceLeadingCost(from: number[], to: number[]): number {
242
280
  }
243
281
  total += Math.abs(b - a);
244
282
  }
283
+ // Discourage hidden/direct perfects between the outermost voices, where they
284
+ // are most audible. True parallels are handled (and forbidden) elsewhere.
285
+ if (from.length >= 2) {
286
+ const bassPrev = from[0];
287
+ const bassCur = to[0];
288
+ const sopPrev = from[from.length - 1];
289
+ const sopCur = to[to.length - 1];
290
+ if (
291
+ bassPrev !== undefined &&
292
+ bassCur !== undefined &&
293
+ sopPrev !== undefined &&
294
+ sopCur !== undefined &&
295
+ createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)
296
+ ) {
297
+ total += HIDDEN_PERFECT_PENALTY;
298
+ }
299
+ }
245
300
  return total;
246
301
  }
247
302
 
@@ -257,6 +312,13 @@ export function voiceLeadingCost(from: number[], to: number[]): number {
257
312
  * @param opts Voicing options; defaults to four voices in {@link SATB_RANGES}.
258
313
  * @returns MIDI pitches, ascending, one per voice.
259
314
  * @throws If no voicing fits the given ranges.
315
+ * @example
316
+ * ```ts
317
+ * import { parseChordSymbol, voiceChord } from '@libraz/libcantus';
318
+ * const chord = parseChordSymbol('Cmaj7');
319
+ * voiceChord(chord); // four ascending MIDI pitches within the SATB ranges
320
+ * ```
321
+ * @category Voicing & Counterpoint
260
322
  */
261
323
  export function voiceChord(chord: Chord, opts?: VoicingOptions): number[] {
262
324
  const ranges = resolveRanges(opts);
@@ -299,6 +361,13 @@ export function voiceChord(chord: Chord, opts?: VoicingOptions): number[] {
299
361
  * @param opts Voicing options; defaults to four voices in {@link SATB_RANGES}.
300
362
  * @returns One voicing per chord, each ascending with one MIDI pitch per voice.
301
363
  * @throws If any chord admits no voicing within the given ranges.
364
+ * @example
365
+ * ```ts
366
+ * import { parseChordSymbol, voiceProgression } from '@libraz/libcantus';
367
+ * const chords = ['C', 'Am', 'F', 'G'].map((s) => parseChordSymbol(s));
368
+ * voiceProgression(chords); // one four-voice voicing per chord, smoothly led
369
+ * ```
370
+ * @category Voicing & Counterpoint
302
371
  */
303
372
  export function voiceProgression(chords: Chord[], opts?: VoicingOptions): number[][] {
304
373
  const ranges = resolveRanges(opts);
@@ -343,18 +412,36 @@ export function voiceProgression(chords: Chord[], opts?: VoicingOptions): number
343
412
  * root/third/fifth for triads; the fifth and tensions are omitted.
344
413
  * - `rootless`: the root omitted, keeping third/fifth/seventh and tensions
345
414
  * (a typical left-hand jazz voicing).
415
+ *
416
+ * @category Voicing & Counterpoint
346
417
  */
347
418
  export type VoicingStyle = 'close' | 'drop2' | 'drop3' | 'shell' | 'rootless';
348
419
 
349
- /** Options controlling {@link voiceChordStyled}. */
420
+ /**
421
+ * Options controlling {@link voiceChordStyled}.
422
+ *
423
+ * @category Voicing & Counterpoint
424
+ */
350
425
  export type StyledVoicingOptions = {
351
- /** Voicing style to build (default `close`). */
426
+ /**
427
+ * Voicing style to build.
428
+ *
429
+ * @defaultValue 'close'
430
+ */
352
431
  style?: VoicingStyle;
353
432
  /** Constrain the highest voice to this pitch class (0..11) when given. */
354
433
  topNote?: number;
355
- /** Base octave for the close stack; the stack begins near `12 * octave` (default 4). */
434
+ /**
435
+ * Base octave for the close stack; the stack begins near `12 * octave`.
436
+ *
437
+ * @defaultValue 4
438
+ */
356
439
  octave?: number;
357
- /** Omit the root regardless of style (relevant for jazz voicings). */
440
+ /**
441
+ * Omit the root regardless of style (relevant for jazz voicings).
442
+ *
443
+ * @defaultValue false
444
+ */
358
445
  rootless?: boolean;
359
446
  };
360
447
 
@@ -408,6 +495,13 @@ function nearestPc(target: number, pcs: number[]): number {
408
495
  * @param chord The chord to voice.
409
496
  * @param opts Styled voicing options; defaults to a close voicing at octave 4.
410
497
  * @returns MIDI pitches, ascending, one per retained voice.
498
+ * @example
499
+ * ```ts
500
+ * import { parseChordSymbol, voiceChordStyled } from '@libraz/libcantus';
501
+ * const chord = parseChordSymbol('Dm7');
502
+ * voiceChordStyled(chord, { style: 'drop2' }); // ascending MIDI pitches, drop-2 voicing
503
+ * ```
504
+ * @category Voicing & Counterpoint
411
505
  */
412
506
  export function voiceChordStyled(chord: Chord, opts?: StyledVoicingOptions): number[] {
413
507
  const style = opts?.style ?? 'close';
@@ -496,12 +590,18 @@ export function voiceChordStyled(chord: Chord, opts?: StyledVoicingOptions): num
496
590
  * @param opts Voicing options; when omitted, ranges follow `current`'s span.
497
591
  * @returns The chosen voicing, ascending with one MIDI pitch per voice.
498
592
  * @throws If no voicing fits the derived ranges.
593
+ * @category Voicing & Counterpoint
499
594
  */
500
595
  export function nextVoicing(current: number[], chord: Chord, opts?: VoicingOptions): number[] {
501
596
  const ranges =
502
597
  opts?.ranges !== undefined || opts?.voices !== undefined
503
598
  ? resolveRanges(opts)
504
- : current.map((pitch) => ({ min: pitch - 12, max: pitch + 12 }));
599
+ : current.map((pitch) => {
600
+ // Window one octave around each current pitch, clamped so extreme-low
601
+ // or extreme-high input can never yield MIDI outside [0, 127].
602
+ const centre = Math.min(127, Math.max(0, pitch));
603
+ return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
604
+ });
505
605
  const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
506
606
  const candidates = enumerateVoicings(chord, ranges, maxSpacing);
507
607
  let best: number[] | undefined;