@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.
@@ -5,23 +5,46 @@ import { roleOf } from '../harmony/index.js';
5
5
  import type { TimeSignature } from '../meter/index.js';
6
6
  import { isStrongBeat } from '../meter/index.js';
7
7
  import type { GeneratedChord } from '../progression/index.js';
8
- import { isScaleTone, majorKey, scaleTonesInDegreeOrder } from '../scale/index.js';
8
+ import {
9
+ isScaleTone,
10
+ majorKey,
11
+ minorKey,
12
+ NATURAL_MINOR_MASK,
13
+ scaleTonesInDegreeOrder,
14
+ } from '../scale/index.js';
9
15
  import type { KeyScale, NoteEvent } from '../types.js';
10
16
 
11
- /** A melody note supplied to the harmonizer: a {@link NoteEvent}. */
17
+ /**
18
+ * A melody note supplied to the harmonizer: a {@link NoteEvent}.
19
+ *
20
+ * @category Reharmonization
21
+ */
12
22
  export type MelodyNote = NoteEvent;
13
23
 
14
- /** Options controlling {@link harmonizeMelody}. */
24
+ /**
25
+ * Options controlling {@link harmonizeMelody}.
26
+ *
27
+ * @category Reharmonization
28
+ */
15
29
  export type HarmonizeOptions = {
16
30
  melody: MelodyNote[];
17
31
  key: KeyScale | 'infer';
18
32
  harmonicRhythm: number;
19
33
  reharmonize: 'diatonic' | 'secondaryDominant' | 'borrowed';
20
34
  placement: { transposeSearch: boolean; octaveSearch: boolean };
35
+ /**
36
+ * Seed for the deterministic tie-break perturbation.
37
+ *
38
+ * @defaultValue 0
39
+ */
21
40
  seed?: number;
22
41
  };
23
42
 
24
- /** The chosen transpose, key, chord path, and per-note roles. */
43
+ /**
44
+ * The chosen transpose, key, chord path, and per-note roles.
45
+ *
46
+ * @category Reharmonization
47
+ */
25
48
  export type HarmonizeResult = {
26
49
  transposeSemitones: number;
27
50
  key: KeyScale;
@@ -50,6 +73,15 @@ const COMFORT_LOW = 55;
50
73
  const COMFORT_HIGH = 79;
51
74
  const TESSITURA_WEIGHT = 0.001;
52
75
 
76
+ /**
77
+ * Magnitude of the seed-driven per-candidate perturbation. Kept far below the
78
+ * smallest real cost difference (transition/emission terms are on the order of
79
+ * 0.3 and up), so it can only decide between candidates or paths whose costs are
80
+ * otherwise exactly equal. The seed therefore breaks ties deterministically and
81
+ * never overrides melody fit or functional flow.
82
+ */
83
+ const TIE_BREAK_JITTER = 1e-6;
84
+
53
85
  function pitchClass(pitch: number): number {
54
86
  return ((Math.trunc(pitch) % 12) + 12) % 12;
55
87
  }
@@ -65,28 +97,47 @@ function mulberry32(seed: number): () => number {
65
97
  };
66
98
  }
67
99
 
68
- /** Estimate the best-fit major key from a melody's pitch-class weighting. */
100
+ /**
101
+ * Estimate the best-fit key from a melody's pitch-class weighting.
102
+ *
103
+ * Both major and natural-minor tonics are scored, so a minor melody is
104
+ * recognized as its own minor key rather than being folded into the relative
105
+ * major (which shares identical pitch-class content). Each in-scale note adds
106
+ * its duration weight; notes matching the candidate tonic add a further
107
+ * half-weight, and that tonic emphasis is what separates a minor key from its
108
+ * relative major. For minor candidates the raised leading tone (the
109
+ * harmonic-minor seventh) also counts as in-scale, so cadential leading tones do
110
+ * not penalize the true minor key. Ties are broken toward the earlier candidate,
111
+ * and major is scored before minor at each tonic.
112
+ */
69
113
  function inferKey(melody: MelodyNote[]): KeyScale {
70
- let best = 0;
114
+ let best: KeyScale = majorKey(0);
71
115
  let bestScore = Number.NEGATIVE_INFINITY;
116
+ const candidates: KeyScale[] = [];
72
117
  for (let tonic = 0; tonic < 12; tonic += 1) {
73
- const key = majorKey(tonic);
118
+ candidates.push(majorKey(tonic), minorKey(tonic));
119
+ }
120
+ for (const key of candidates) {
121
+ const tonic = pitchClass(key.rootPc);
122
+ const isMinor = key.modeMask12 === NATURAL_MINOR_MASK;
123
+ const leadingTone = (tonic + 11) % 12;
74
124
  let score = 0;
75
125
  for (const n of melody) {
76
126
  const w = Math.max(0.25, n.durationBeat);
77
- if (isScaleTone(n.pitch, key)) {
127
+ const pc = pitchClass(n.pitch);
128
+ if (isScaleTone(n.pitch, key) || (isMinor && pc === leadingTone)) {
78
129
  score += w;
79
130
  }
80
- if (pitchClass(n.pitch) === tonic) {
131
+ if (pc === tonic) {
81
132
  score += 0.5 * w;
82
133
  }
83
134
  }
84
135
  if (score > bestScore) {
85
136
  bestScore = score;
86
- best = tonic;
137
+ best = key;
87
138
  }
88
139
  }
89
- return majorKey(best);
140
+ return best;
90
141
  }
91
142
 
92
143
  /** Enumerate candidate chords for the key, gated by reharmonization strength. */
@@ -283,16 +334,38 @@ function harmonizeOnce(
283
334
  * `(transpose, progression)` pair is returned; a small tessitura cost breaks
284
335
  * ties toward placements that keep the melody in a comfortable register.
285
336
  *
337
+ * The `seed` drives a deterministic tie-break only: it perturbs candidates by a
338
+ * magnitude far below any real cost difference (see {@link TIE_BREAK_JITTER}),
339
+ * so it can decide between chords or paths of otherwise-equal cost but never
340
+ * overrides melody fit or functional flow. For a well-determined melody the
341
+ * result is identical across seeds; the same seed always yields the same result.
342
+ *
286
343
  * @param opts Melody, key (or `'infer'`), harmonic rhythm, reharmonization
287
344
  * strength, height-search flags, and seed.
288
345
  * @returns The chosen transpose, key, chord path, and per-note roles.
346
+ * @example
347
+ * ```ts
348
+ * import { harmonizeMelody, majorKey } from '@libraz/libcantus';
349
+ * const result = harmonizeMelody({
350
+ * melody: [
351
+ * { pitch: 60, startBeat: 0, durationBeat: 1 },
352
+ * { pitch: 64, startBeat: 1, durationBeat: 1 },
353
+ * ],
354
+ * key: majorKey(0),
355
+ * harmonicRhythm: 1,
356
+ * reharmonize: 'diatonic',
357
+ * placement: { transposeSearch: false, octaveSearch: false },
358
+ * });
359
+ * result.chords; // one GeneratedChord per harmonic-rhythm segment
360
+ * ```
361
+ * @category Reharmonization
289
362
  */
290
363
  export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
291
364
  const key = opts.key === 'infer' ? inferKey(opts.melody) : opts.key;
292
365
  const candidates = buildCandidates(key, opts.reharmonize);
293
366
  const candAt = (i: number): Candidate => candidates[i] ?? FALLBACK;
294
367
  const rng = mulberry32(opts.seed ?? 0);
295
- const jitter = candidates.map(() => rng() * 1e-6);
368
+ const jitter = candidates.map(() => rng() * TIE_BREAK_JITTER);
296
369
 
297
370
  const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
298
371
  const hr = Math.max(0.25, opts.harmonicRhythm);
@@ -1,6 +1,10 @@
1
1
  import type { Chord } from '../chord/index.js';
2
2
 
3
- /** Harmonic role a pitch plays within a chord. */
3
+ /**
4
+ * Harmonic role a pitch plays within a chord.
5
+ *
6
+ * @category Functional Harmony
7
+ */
4
8
  export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'tension' | 'doubling';
5
9
 
6
10
  /**
@@ -9,10 +13,16 @@ export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'te
9
13
  * - `identity`: moving it produces a different chord (the root).
10
14
  * - `quality`: moving it flips the chord quality (the third).
11
15
  * - `voicing`: it can move freely without changing chord identity or quality.
16
+ *
17
+ * @category Functional Harmony
12
18
  */
13
19
  export type LockLevel = 'identity' | 'quality' | 'voicing';
14
20
 
15
- /** A pitch's role, lock level, and owning chord. */
21
+ /**
22
+ * A pitch's role, lock level, and owning chord.
23
+ *
24
+ * @category Functional Harmony
25
+ */
16
26
  export type VoicedRole = {
17
27
  role: HarmonyRole;
18
28
  lock: LockLevel;
@@ -36,6 +46,13 @@ export type VoicedRole = {
36
46
  * @param chord The chord providing the root reference.
37
47
  * @param chordId Identifier stored on the result (defaults to 0).
38
48
  * @returns The pitch's role, lock level, and owning chord id.
49
+ * @example
50
+ * ```ts
51
+ * import { roleOf, makeChord } from '@libraz/libcantus';
52
+ * roleOf(64, makeChord(0, 'maj')); // E over C major
53
+ * // { role: 'third', lock: 'quality', belongsToChordId: 0 }
54
+ * ```
55
+ * @category Functional Harmony
39
56
  */
40
57
  export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
41
58
  const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export type {
51
51
  DrumGenOptions,
52
52
  DrumHit,
53
53
  DrumRole,
54
+ EuclideanKick,
54
55
  GrooveFeel,
55
56
  GrooveStyle,
56
57
  Section,
@@ -1,4 +1,8 @@
1
- /** Counterpoint classification of a harmonic interval. */
1
+ /**
2
+ * Counterpoint classification of a harmonic interval.
3
+ *
4
+ * @category Pitch & Intervals
5
+ */
2
6
  export enum IntervalQuality {
3
7
  PerfectConsonance = 0,
4
8
  ImperfectConsonance = 1,
@@ -21,6 +25,14 @@ function simpleInterval(semitones: number): number {
21
25
  * @param semitones Interval size in semitones (may be negative or compound).
22
26
  * @param twoVoice When true, the perfect fourth is treated as dissonant.
23
27
  * @returns The counterpoint quality of the interval.
28
+ * @example
29
+ * ```ts
30
+ * import { classifyInterval, IntervalQuality } from '@libraz/libcantus';
31
+ * classifyInterval(7); // IntervalQuality.PerfectConsonance
32
+ * classifyInterval(5); // IntervalQuality.Dissonance (two-voice)
33
+ * classifyInterval(5, false); // IntervalQuality.ImperfectConsonance
34
+ * ```
35
+ * @category Pitch & Intervals
24
36
  */
25
37
  export function classifyInterval(semitones: number, twoVoice = true): IntervalQuality {
26
38
  const pc = simpleInterval(semitones);
@@ -41,6 +53,7 @@ export function classifyInterval(semitones: number, twoVoice = true): IntervalQu
41
53
  *
42
54
  * @param semitones Interval size in semitones (may be negative or compound).
43
55
  * @returns True for the unison/octave (0) and the perfect fifth (7) mod 12.
56
+ * @category Pitch & Intervals
44
57
  */
45
58
  export function isPerfectInterval(semitones: number): boolean {
46
59
  const pc = simpleInterval(semitones);
@@ -57,6 +70,7 @@ export function isPerfectInterval(semitones: number): boolean {
57
70
  * @param semitones Interval size in semitones (may be negative or compound).
58
71
  * @param twoVoice When true, the perfect fourth is treated as dissonant.
59
72
  * @returns True if the interval is consonant in the given context.
73
+ * @category Pitch & Intervals
60
74
  */
61
75
  export function isConsonantInterval(semitones: number, twoVoice = true): boolean {
62
76
  const pc = simpleInterval(semitones);
@@ -6,13 +6,31 @@
6
6
  * of the library's beat convention (four quarter-note beats per bar in 4/4).
7
7
  */
8
8
 
9
- /** A time signature as a numerator over a note-value denominator. */
9
+ /**
10
+ * A time signature as a numerator over a note-value denominator.
11
+ *
12
+ * @category Rhythm & Meter
13
+ */
10
14
  export type TimeSignature = {
11
15
  numerator: number;
12
16
  denominator: number;
17
+ /**
18
+ * Optional additive grouping of the bar's main pulses, as the felt-beat
19
+ * lengths in pulses — e.g. `[2, 2, 3]` for a 2+2+3 reading of 7/8, or
20
+ * `[3, 2]` for 5/8. The entries must be positive integers summing to
21
+ * {@link pulsesPerBar}. When present, the head pulse of each group (other
22
+ * than the downbeat) is treated as a secondary strong pulse by
23
+ * {@link metricWeight}/{@link isStrongBeat}; when absent, all main pulses
24
+ * are weighted equally as flat, evenly divided pulses.
25
+ */
26
+ grouping?: number[];
13
27
  };
14
28
 
15
- /** A position expressed as a bar index and a quarter-note offset within the bar. */
29
+ /**
30
+ * A position expressed as a bar index and a quarter-note offset within the bar.
31
+ *
32
+ * @category Rhythm & Meter
33
+ */
16
34
  export type BarPosition = {
17
35
  bar: number;
18
36
  /** Quarter-note offset from the start of the bar. */
@@ -36,6 +54,12 @@ function isMultiple(value: number, unit: number): boolean {
36
54
  * @param text The signature text.
37
55
  * @returns The parsed time signature.
38
56
  * @throws If the text is not `n/d` with positive integers.
57
+ * @example
58
+ * ```ts
59
+ * import { parseTimeSignature } from '@libraz/libcantus';
60
+ * parseTimeSignature('6/8'); // { numerator: 6, denominator: 8 }
61
+ * ```
62
+ * @category Rhythm & Meter
39
63
  */
40
64
  export function parseTimeSignature(text: string): TimeSignature {
41
65
  const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text);
@@ -50,7 +74,11 @@ export function parseTimeSignature(text: string): TimeSignature {
50
74
  return { numerator, denominator };
51
75
  }
52
76
 
53
- /** Render a time signature as `"n/d"`. */
77
+ /**
78
+ * Render a time signature as `"n/d"`.
79
+ *
80
+ * @category Rhythm & Meter
81
+ */
54
82
  export function formatTimeSignature(ts: TimeSignature): string {
55
83
  return `${ts.numerator}/${ts.denominator}`;
56
84
  }
@@ -63,6 +91,7 @@ export function formatTimeSignature(ts: TimeSignature): string {
63
91
  *
64
92
  * @param ts The time signature.
65
93
  * @returns True for compound meters.
94
+ * @category Rhythm & Meter
66
95
  */
67
96
  export function isCompound(ts: TimeSignature): boolean {
68
97
  return ts.numerator % 3 === 0 && ts.numerator > 3;
@@ -78,6 +107,7 @@ function unitBeats(ts: TimeSignature): number {
78
107
  *
79
108
  * @param ts The time signature.
80
109
  * @returns The bar length in quarter notes.
110
+ * @category Rhythm & Meter
81
111
  */
82
112
  export function beatsPerBar(ts: TimeSignature): number {
83
113
  return ts.numerator * unitBeats(ts);
@@ -92,19 +122,63 @@ function pulseBeats(ts: TimeSignature): number {
92
122
  * Number of main pulses (felt beats) per bar: the numerator for simple meters,
93
123
  * a third of it for compound meters.
94
124
  *
125
+ * For additive/irregular meters such as 7/8 or 5/8 this counts the raw pulses
126
+ * (7 and 5 respectively), which are otherwise felt as flat, evenly divided and
127
+ * equally accented pulses. To recover a 2+2+3 or 3+2 felt-beat grouping, set
128
+ * {@link TimeSignature.grouping}; {@link metricWeight} then accents each
129
+ * group's head pulse.
130
+ *
95
131
  * @param ts The time signature.
96
132
  * @returns The pulse count per bar.
133
+ * @category Rhythm & Meter
97
134
  */
98
135
  export function pulsesPerBar(ts: TimeSignature): number {
99
136
  return beatsPerBar(ts) / pulseBeats(ts);
100
137
  }
101
138
 
139
+ /**
140
+ * Whether `grouping` is a valid additive grouping for a bar of `pulses` main
141
+ * pulses: a non-empty list of positive integers summing to `pulses`.
142
+ */
143
+ function isValidGrouping(grouping: number[], pulses: number): boolean {
144
+ if (grouping.length === 0) {
145
+ return false;
146
+ }
147
+ let sum = 0;
148
+ for (const g of grouping) {
149
+ if (!Number.isInteger(g) || g <= 0) {
150
+ return false;
151
+ }
152
+ sum += g;
153
+ }
154
+ return sum === pulses;
155
+ }
156
+
157
+ /** Whether `pulseIndex` is the head pulse of one of the additive groups. */
158
+ function isGroupHead(grouping: number[], pulseIndex: number): boolean {
159
+ let acc = 0;
160
+ for (const g of grouping) {
161
+ if (acc === pulseIndex) {
162
+ return true;
163
+ }
164
+ acc += g;
165
+ }
166
+ return false;
167
+ }
168
+
102
169
  /**
103
170
  * Convert an absolute quarter-note position to a bar index and in-bar offset.
104
171
  *
105
172
  * @param beatInQuarters Absolute position in quarter-note beats.
106
173
  * @param ts The time signature.
107
174
  * @returns The bar and in-bar quarter-note offset.
175
+ * @example
176
+ * ```ts
177
+ * import { parseTimeSignature, beatToBarPosition } from '@libraz/libcantus';
178
+ * const ts = parseTimeSignature('4/4');
179
+ * beatToBarPosition(5, ts); // { bar: 1, beat: 1 }
180
+ * ```
181
+ * @category Rhythm & Meter
108
182
  */
109
183
  export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): BarPosition {
110
184
  const barLen = beatsPerBar(ts);
@@ -119,6 +193,7 @@ export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): Ba
119
193
  * @param pos The bar position.
120
194
  * @param ts The time signature.
121
195
  * @returns The absolute position in quarter-note beats.
196
+ * @category Rhythm & Meter
122
197
  */
123
198
  export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
124
199
  return pos.bar * beatsPerBar(ts) + pos.beat;
@@ -126,12 +201,28 @@ export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
126
201
 
127
202
  /**
128
203
  * Metric weight of a position within its bar, on a 0–3 scale:
129
- * 3 the downbeat, 2 a secondary strong pulse (the bar's midpoint in even
130
- * meters), 1 any other main pulse, and 0 an off-pulse subdivision.
204
+ * 3 the downbeat, 2 a secondary strong pulse, 1 any other main pulse, and 0 an
205
+ * off-pulse subdivision.
206
+ *
207
+ * The secondary strong pulse is the bar's midpoint in even simple/compound
208
+ * meters. For an additive/irregular meter with {@link TimeSignature.grouping}
209
+ * set (e.g. `[2, 2, 3]` for 7/8), each group's head pulse other than the
210
+ * downbeat is the secondary strong pulse instead; without a grouping every
211
+ * non-downbeat main pulse of such a meter weighs 1 (flat, evenly divided
212
+ * pulses).
131
213
  *
132
214
  * @param beatInQuarters Absolute or in-bar quarter-note position.
133
215
  * @param ts The time signature.
134
216
  * @returns The metric weight (0–3).
217
+ * @throws If `ts.grouping` is present but is not a positive-integer list
218
+ * summing to {@link pulsesPerBar}.
219
+ * @example
220
+ * ```ts
221
+ * import { parseTimeSignature, metricWeight } from '@libraz/libcantus';
222
+ * const ts = parseTimeSignature('4/4');
223
+ * metricWeight(0, ts); // 3 (the downbeat)
224
+ * ```
225
+ * @category Rhythm & Meter
135
226
  */
136
227
  export function metricWeight(beatInQuarters: number, ts: TimeSignature): number {
137
228
  const { beat } = beatToBarPosition(beatInQuarters, ts);
@@ -144,6 +235,16 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
144
235
  if (pulseIndex === 0) {
145
236
  return 3;
146
237
  }
238
+ const grouping = ts.grouping;
239
+ if (grouping !== undefined) {
240
+ if (!isValidGrouping(grouping, pulses)) {
241
+ throw new Error(
242
+ `Invalid grouping [${grouping.join(', ')}] for ${formatTimeSignature(ts)}: ` +
243
+ `entries must be positive integers summing to ${pulses}`,
244
+ );
245
+ }
246
+ return isGroupHead(grouping, pulseIndex) ? 2 : 1;
247
+ }
147
248
  if (pulses % 2 === 0 && pulseIndex === pulses / 2) {
148
249
  return 2;
149
250
  }
@@ -157,6 +258,7 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
157
258
  * @param beatInQuarters Absolute or in-bar quarter-note position.
158
259
  * @param ts The time signature.
159
260
  * @returns True on strong beats.
261
+ * @category Rhythm & Meter
160
262
  */
161
263
  export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean {
162
264
  return metricWeight(beatInQuarters, ts) >= 2;
@@ -170,6 +272,7 @@ export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean
170
272
  * @param count Number of equal parts.
171
273
  * @returns `count` equal durations summing to `totalBeats`.
172
274
  * @throws If `count` is not a positive integer.
275
+ * @category Rhythm & Meter
173
276
  */
174
277
  export function tuplet(totalBeats: number, count: number): number[] {
175
278
  if (!Number.isInteger(count) || count <= 0) {