@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.
@@ -3,13 +3,25 @@ import { diatonicTriad } from '../chord/index.js';
3
3
  import { scaleTonesInDegreeOrder } from '../scale/index.js';
4
4
  import type { KeyScale } from '../types.js';
5
5
 
6
- /** Broad production style a progression preset suits. */
6
+ /**
7
+ * Broad production style a progression preset suits.
8
+ *
9
+ * @category Composition
10
+ */
7
11
  export type ProgStyle = 'minimal' | 'dance' | 'idol' | 'rock';
8
12
 
9
- /** Harmonic-function role of a progression as a whole. */
13
+ /**
14
+ * Harmonic-function role of a progression as a whole.
15
+ *
16
+ * @category Composition
17
+ */
10
18
  export type ProgFunction = 'loop' | 'tensionBuild' | 'cadenceStrong' | 'stable';
11
19
 
12
- /** A named chord-progression preset expressed in scale degrees. */
20
+ /**
21
+ * A named chord-progression preset expressed in scale degrees.
22
+ *
23
+ * @category Composition
24
+ */
13
25
  export type ProgressionPreset = {
14
26
  id: string;
15
27
  name: string;
@@ -18,7 +30,11 @@ export type ProgressionPreset = {
18
30
  styles: ProgStyle[];
19
31
  };
20
32
 
21
- /** A chord placed on the timeline by {@link generateProgression}. */
33
+ /**
34
+ * A chord placed on the timeline by {@link generateProgression}.
35
+ *
36
+ * @category Composition
37
+ */
22
38
  export type GeneratedChord = {
23
39
  rootPc: number;
24
40
  quality: ChordQuality;
@@ -30,7 +46,11 @@ export type GeneratedChord = {
30
46
  secondaryDominant?: boolean;
31
47
  };
32
48
 
33
- /** Options controlling {@link generateProgression}. */
49
+ /**
50
+ * Options controlling {@link generateProgression}.
51
+ *
52
+ * @category Composition
53
+ */
34
54
  export type GenerateProgressionOptions = {
35
55
  key: KeyScale;
36
56
  style: ProgStyle;
@@ -38,6 +58,11 @@ export type GenerateProgressionOptions = {
38
58
  presetId?: string;
39
59
  ext?: ChordQuality | 'auto';
40
60
  reharmonize?: boolean;
61
+ /**
62
+ * Seed for the deterministic preset choice and reharmonization.
63
+ *
64
+ * @defaultValue 0
65
+ */
41
66
  seed?: number;
42
67
  };
43
68
 
@@ -208,12 +233,20 @@ const BORROWED_OFFSET: Record<number, number> = {
208
233
  14: 6, // #IV
209
234
  };
210
235
 
211
- /** All built-in progression presets. */
236
+ /**
237
+ * All built-in progression presets.
238
+ *
239
+ * @category Composition
240
+ */
212
241
  export function progressions(): ProgressionPreset[] {
213
242
  return PRESETS.map((p) => ({ ...p, degrees: [...p.degrees], styles: [...p.styles] }));
214
243
  }
215
244
 
216
- /** Presets whose style list includes the given style. */
245
+ /**
246
+ * Presets whose style list includes the given style.
247
+ *
248
+ * @category Composition
249
+ */
217
250
  export function progressionsByStyle(style: ProgStyle): ProgressionPreset[] {
218
251
  return progressions().filter((p) => p.styles.includes(style));
219
252
  }
@@ -264,7 +297,9 @@ function autoQuality(degree: number, key: KeyScale): ChordQuality {
264
297
  * Generate a chord progression laid out one chord per bar.
265
298
  *
266
299
  * A preset is chosen by `presetId` when given, otherwise deterministically from
267
- * the presets matching `style`, seeded by `seed`. The preset's degrees cycle to
300
+ * the presets matching `style`, seeded by `seed`. An unknown `presetId` is a
301
+ * caller error and throws rather than silently falling back to a random preset.
302
+ * The preset's degrees cycle to
268
303
  * fill `bars`; each bar is four beats, so `startBeat` is `barIndex * 4`. Chord
269
304
  * roots come from the key's diatonic scale-degree mapping. When `ext` is
270
305
  * omitted or `'auto'`, each chord takes its diatonic triad quality; otherwise
@@ -274,12 +309,25 @@ function autoQuality(degree: number, key: KeyScale): ChordQuality {
274
309
  *
275
310
  * @param opts Generation options.
276
311
  * @returns One chord per bar in timeline order.
312
+ * @throws If `presetId` is given but does not match any built-in preset.
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * import { generateProgression, majorKey } from '@libraz/libcantus';
317
+ * const chords = generateProgression({ key: majorKey(0), style: 'dance', bars: 4 });
318
+ * // Deterministic for a given seed (defaults to 0); one GeneratedChord per bar.
319
+ * ```
320
+ *
321
+ * @category Composition
277
322
  */
278
323
  export function generateProgression(opts: GenerateProgressionOptions): GeneratedChord[] {
279
324
  const seed = opts.seed ?? 0;
280
325
  let preset: ProgressionPreset | undefined;
281
326
  if (opts.presetId !== undefined) {
282
327
  preset = PRESETS.find((p) => p.id === opts.presetId);
328
+ if (preset === undefined) {
329
+ throw new Error(`Unknown progression preset: ${opts.presetId}`);
330
+ }
283
331
  }
284
332
  if (preset === undefined) {
285
333
  const pool = PRESETS.filter((p) => p.styles.includes(opts.style));
@@ -5,7 +5,11 @@
5
5
  * seed the stream is fully reproducible.
6
6
  */
7
7
 
8
- /** A deterministic PRNG with the sampling helpers the generators need. */
8
+ /**
9
+ * A deterministic PRNG with the sampling helpers the generators need.
10
+ *
11
+ * @category Utilities
12
+ */
9
13
  export type Rng = {
10
14
  /** Next float in [0, 1). */
11
15
  next: () => number;
@@ -22,6 +26,14 @@ export type Rng = {
22
26
  *
23
27
  * @param seed The 32-bit seed; the same seed always yields the same stream.
24
28
  * @returns The seeded generator.
29
+ * @example
30
+ * ```ts
31
+ * import { createRng } from '@libraz/libcantus';
32
+ * const rng = createRng(42);
33
+ * rng.next(); // deterministic float in [0, 1)
34
+ * rng.range(1, 6); // deterministic integer in [1, 6]
35
+ * ```
36
+ * @category Utilities
25
37
  */
26
38
  export function createRng(seed: number): Rng {
27
39
  let state = seed >>> 0;
@@ -32,7 +32,11 @@ function mod12(n: number): number {
32
32
  return ((n % 12) + 12) % 12;
33
33
  }
34
34
 
35
- /** The kind of substitution relationship a candidate realizes. */
35
+ /**
36
+ * The kind of substitution relationship a candidate realizes.
37
+ *
38
+ * @category Reharmonization
39
+ */
36
40
  export type SubstitutionType =
37
41
  | 'tritone'
38
42
  | 'relative'
@@ -40,7 +44,11 @@ export type SubstitutionType =
40
44
  | 'chromatic-mediant'
41
45
  | 'secondary-dominant';
42
46
 
43
- /** A proposed chord substitution with its relationship, numeral, and function. */
47
+ /**
48
+ * A proposed chord substitution with its relationship, numeral, and function.
49
+ *
50
+ * @category Reharmonization
51
+ */
44
52
  export type Substitution = {
45
53
  chord: Chord;
46
54
  type: SubstitutionType;
@@ -48,7 +56,11 @@ export type Substitution = {
48
56
  function: HarmonicFunction;
49
57
  };
50
58
 
51
- /** Options controlling which substitutions {@link substituteChord} returns. */
59
+ /**
60
+ * Options controlling which substitutions {@link substituteChord} returns.
61
+ *
62
+ * @category Reharmonization
63
+ */
52
64
  export type SubstituteOptions = {
53
65
  /**
54
66
  * Melody pitch classes that must remain chord tones. When given, only
@@ -132,6 +144,13 @@ const THIRD_OFFSETS = [3, 4, 8, 9] as const;
132
144
  * @param key The prevailing key.
133
145
  * @param opts Optional melody-preservation constraint.
134
146
  * @returns The deduplicated substitution candidates.
147
+ * @example
148
+ * ```ts
149
+ * import { substituteChord, makeChord, majorKey } from '@libraz/libcantus';
150
+ * const subs = substituteChord(makeChord(7, 'dom7'), majorKey(0));
151
+ * // includes the tritone sub Db7 (rootPc 1, quality 'dom7')
152
+ * ```
153
+ * @category Reharmonization
135
154
  */
136
155
  export function substituteChord(
137
156
  chord: Chord,
@@ -214,7 +233,11 @@ export function substituteChord(
214
233
  return results;
215
234
  }
216
235
 
217
- /** A borrowed chord in the modal-interchange palette of a key. */
236
+ /**
237
+ * A borrowed chord in the modal-interchange palette of a key.
238
+ *
239
+ * @category Reharmonization
240
+ */
218
241
  export type BorrowedChord = {
219
242
  chord: Chord;
220
243
  roman: string;
@@ -231,6 +254,7 @@ export type BorrowedChord = {
231
254
  *
232
255
  * @param key The prevailing key.
233
256
  * @returns The borrowed-chord palette.
257
+ * @category Reharmonization
234
258
  */
235
259
  export function modalInterchangePalette(key: KeyScale): BorrowedChord[] {
236
260
  const palette: BorrowedChord[] = [];
@@ -275,6 +299,13 @@ export function modalInterchangePalette(key: KeyScale): BorrowedChord[] {
275
299
  * @param chord The chord to reflect.
276
300
  * @param key The prevailing key.
277
301
  * @returns The negative-harmony counterpart of the chord.
302
+ * @example
303
+ * ```ts
304
+ * import { negativeHarmonyMirror, makeChord, majorKey } from '@libraz/libcantus';
305
+ * negativeHarmonyMirror(makeChord(7, 'maj'), majorKey(0));
306
+ * // G major reflected across C's axis => F minor (rootPc 5, quality 'min')
307
+ * ```
308
+ * @category Reharmonization
278
309
  */
279
310
  export function negativeHarmonyMirror(chord: Chord, key: KeyScale): Chord {
280
311
  const tonic = mod12(key.rootPc);
@@ -14,7 +14,11 @@
14
14
  import { beatsPerBar, metricWeight, type TimeSignature } from '../meter/index.js';
15
15
  import { createRng } from '../random/index.js';
16
16
 
17
- /** A single rhythmic note: an onset position and how long it sounds. */
17
+ /**
18
+ * A single rhythmic note: an onset position and how long it sounds.
19
+ *
20
+ * @category Rhythm & Meter
21
+ */
18
22
  export type RhythmEvent = {
19
23
  /** Onset position in quarter-note beats, absolute from the start of the span. */
20
24
  position: number;
@@ -22,21 +26,38 @@ export type RhythmEvent = {
22
26
  duration: number;
23
27
  };
24
28
 
25
- /** Options controlling {@link generateRhythm}. */
29
+ /**
30
+ * Options controlling {@link generateRhythm}.
31
+ *
32
+ * @category Rhythm & Meter
33
+ */
26
34
  export type RhythmOptions = {
27
- /** Seed for the deterministic PRNG. Default 0. */
35
+ /**
36
+ * Seed for the deterministic PRNG.
37
+ *
38
+ * @defaultValue 0
39
+ */
28
40
  seed?: number;
29
- /** Number of bars to generate. Default 1. */
41
+ /**
42
+ * Number of bars to generate.
43
+ *
44
+ * @defaultValue 1
45
+ */
30
46
  bars?: number;
31
47
  /**
32
48
  * Grid resolution as the number of equal grid steps per quarter-note beat.
33
49
  * The default of 2 yields an eighth-note grid; 4 yields a sixteenth-note
34
50
  * grid, 3 an eighth-note triplet grid, and so on.
51
+ *
52
+ * @defaultValue 2
35
53
  */
36
54
  subdivision?: number;
37
55
  /**
38
56
  * Overall onset density in [0, 1]. Scales the per-slot onset probability, so
39
- * higher values fill more grid slots. Default 0.5.
57
+ * higher values fill more grid slots. Values outside [0, 1] are clamped into
58
+ * range.
59
+ *
60
+ * @defaultValue 0.5
40
61
  */
41
62
  density?: number;
42
63
  };
@@ -53,6 +74,7 @@ const DEFAULT_BARS = 1;
53
74
  *
54
75
  * @param weight The metric weight, 0 (off-pulse) to 3 (downbeat).
55
76
  * @returns A base onset probability in [0, 1].
77
+ * @category Rhythm & Meter
56
78
  */
57
79
  export function onsetWeightCurve(weight: number): number {
58
80
  switch (weight) {
@@ -71,29 +93,40 @@ export function onsetWeightCurve(weight: number): number {
71
93
  * Generate a deterministic rhythmic pattern over `bars` bars of a time
72
94
  * signature. A grid is built at the chosen subdivision; each slot becomes an
73
95
  * onset with a probability derived from its metric weight scaled by `density`
74
- * and sampled from the seeded PRNG. The first slot (the downbeat) is always an
75
- * onset. Each event's duration extends to the next onset, and the last event
76
- * extends to the end of the span.
96
+ * and sampled from the seeded PRNG. Every bar's downbeat is always an onset, so
97
+ * a pattern of any length has a reliable pulse independent of the seed. Each
98
+ * event's duration extends to the next onset, and the last event extends to the
99
+ * end of the span. `density` is clamped to [0, 1].
77
100
  *
78
101
  * @param ts The time signature.
79
102
  * @param opts Generation options.
80
103
  * @returns Onset events sorted by position, non-overlapping, covering the span.
104
+ * @example
105
+ * ```ts
106
+ * import { parseTimeSignature, generateRhythm } from '@libraz/libcantus';
107
+ * const ts = parseTimeSignature('4/4');
108
+ * generateRhythm(ts, { seed: 42, density: 0.6 }); // onset events over one bar
109
+ * ```
110
+ * @category Rhythm & Meter
81
111
  */
82
112
  export function generateRhythm(ts: TimeSignature, opts: RhythmOptions = {}): RhythmEvent[] {
83
113
  const seed = opts.seed ?? 0;
84
114
  const bars = opts.bars ?? DEFAULT_BARS;
85
115
  const subdivision = opts.subdivision ?? DEFAULT_SUBDIVISION;
86
- const density = opts.density ?? DEFAULT_DENSITY;
116
+ const density = Math.min(1, Math.max(0, opts.density ?? DEFAULT_DENSITY));
87
117
 
88
- const spanBeats = beatsPerBar(ts) * bars;
118
+ const barBeats = beatsPerBar(ts);
119
+ const spanBeats = barBeats * bars;
89
120
  const step = 1 / subdivision;
90
121
  const slotCount = Math.round(spanBeats * subdivision);
122
+ const slotsPerBar = Math.round(barBeats * subdivision);
91
123
  const rng = createRng(seed);
92
124
 
93
125
  const positions: number[] = [];
94
126
  for (let i = 0; i < slotCount; i += 1) {
95
127
  const position = i * step;
96
- if (i === 0) {
128
+ // Force every bar's downbeat so the pulse is reliable regardless of seed.
129
+ if (i % slotsPerBar === 0) {
97
130
  positions.push(position);
98
131
  continue;
99
132
  }
@@ -119,6 +152,7 @@ export function generateRhythm(ts: TimeSignature, opts: RhythmOptions = {}): Rhy
119
152
  * @param events Events from {@link generateRhythm}.
120
153
  * @param ts The time signature.
121
154
  * @returns The onset count divided by the number of bars the events span.
155
+ * @category Rhythm & Meter
122
156
  */
123
157
  export function rhythmDensity(events: RhythmEvent[], ts: TimeSignature): number {
124
158
  if (events.length === 0) {
@@ -2,7 +2,6 @@ import type { Chord } from '../chord/index.js';
2
2
  import { chordPitchClasses } from '../chord/index.js';
3
3
  import {
4
4
  createsHiddenParallelPerfect,
5
- createsParallelOctave,
6
5
  createsParallelPerfect,
7
6
  createsVerticalDissonance,
8
7
  isForbiddenMelodicLeap,
@@ -10,17 +9,29 @@ import {
10
9
  import { isScaleTone } from '../scale/index.js';
11
10
  import type { KeyScale } from '../types.js';
12
11
 
13
- /** Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading. */
12
+ /**
13
+ * Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading.
14
+ *
15
+ * @category Arrangement & Analysis
16
+ */
14
17
  export type SafetyProfile = 'strict' | 'pop';
15
18
 
16
- /** Overall placeability verdict for a candidate pitch. */
19
+ /**
20
+ * Overall placeability verdict for a candidate pitch.
21
+ *
22
+ * @category Arrangement & Analysis
23
+ */
17
24
  export enum NoteSafety {
18
25
  Safe = 0,
19
26
  Warning = 1,
20
27
  Dissonant = 2,
21
28
  }
22
29
 
23
- /** Bit flags describing why a pitch received its verdict. */
30
+ /**
31
+ * Bit flags describing why a pitch received its verdict.
32
+ *
33
+ * @category Arrangement & Analysis
34
+ */
24
35
  export enum ReasonFlag {
25
36
  ChordTone = 1 << 0,
26
37
  Tension = 1 << 1,
@@ -40,13 +51,21 @@ export enum ReasonFlag {
40
51
  NeedsResolution = 1 << 15,
41
52
  }
42
53
 
43
- /** A neighbouring voice's current and previous pitch. */
54
+ /**
55
+ * A neighbouring voice's current and previous pitch.
56
+ *
57
+ * @category Arrangement & Analysis
58
+ */
44
59
  export type VoiceSnapshot = {
45
60
  pitch: number;
46
61
  prevPitch?: number;
47
62
  };
48
63
 
49
- /** Everything needed to evaluate one candidate pitch. */
64
+ /**
65
+ * Everything needed to evaluate one candidate pitch.
66
+ *
67
+ * @category Arrangement & Analysis
68
+ */
50
69
  export type SafetyQuery = {
51
70
  profile: SafetyProfile;
52
71
  candidatePitch: number;
@@ -59,7 +78,11 @@ export type SafetyQuery = {
59
78
  vocalHigh?: number;
60
79
  };
61
80
 
62
- /** The verdict, reason flags, and optional resolution guidance. */
81
+ /**
82
+ * The verdict, reason flags, and optional resolution guidance.
83
+ *
84
+ * @category Arrangement & Analysis
85
+ */
63
86
  export type SafetyResult = {
64
87
  safety: NoteSafety;
65
88
  reasons: number;
@@ -115,6 +138,20 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
115
138
  *
116
139
  * @param q The candidate and its harmonic/voice-leading context.
117
140
  * @returns The verdict, reason bitmask, and optional resolution guidance.
141
+ * @example
142
+ * ```ts
143
+ * import { evaluateSafety, makeChord, majorKey, NoteSafety } from '@libraz/libcantus';
144
+ * const result = evaluateSafety({
145
+ * profile: 'pop',
146
+ * candidatePitch: 60, // C over a C major chord
147
+ * chord: makeChord(0, 'maj'),
148
+ * key: majorKey(0),
149
+ * otherVoices: [],
150
+ * strongBeat: true,
151
+ * });
152
+ * result.safety === NoteSafety.Safe; // true — C is a chord tone
153
+ * ```
154
+ * @category Arrangement & Analysis
118
155
  */
119
156
  export function evaluateSafety(q: SafetyQuery): SafetyResult {
120
157
  return evaluateInternal(q, true);
@@ -242,10 +279,9 @@ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyRe
242
279
  if (ov.prevPitch === undefined) {
243
280
  continue;
244
281
  }
245
- if (
246
- createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch) ||
247
- createsParallelOctave(prev, pitch, ov.prevPitch, ov.pitch)
248
- ) {
282
+ // createsParallelPerfect covers every perfect class (unison/octave and
283
+ // fifth), so no separate octave check is needed.
284
+ if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
249
285
  reasons |= ReasonFlag.ParallelPerfect;
250
286
  raise(parallelSeverity);
251
287
  }
@@ -346,6 +382,17 @@ function describe(reasons: number, q: SafetyQuery): string {
346
382
  * @param pitchLow Lowest MIDI pitch to consider (inclusive).
347
383
  * @param pitchHigh Highest MIDI pitch to consider (inclusive).
348
384
  * @returns Placeable pitches (non-dissonant), chord tones before others, each group descending.
385
+ * @example
386
+ * ```ts
387
+ * import { enumerateSafePitches, makeChord, majorKey } from '@libraz/libcantus';
388
+ * const pitches = enumerateSafePitches(
389
+ * { profile: 'pop', chord: makeChord(0, 'maj'), key: majorKey(0), otherVoices: [], strongBeat: true },
390
+ * 60,
391
+ * 72,
392
+ * );
393
+ * pitches; // placeable pitches in [60, 72], chord tones first, each group descending
394
+ * ```
395
+ * @category Arrangement & Analysis
349
396
  */
350
397
  export function enumerateSafePitches(
351
398
  q: Omit<SafetyQuery, 'candidatePitch'>,