@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.
@@ -6,10 +6,21 @@
6
6
 
7
7
  import type { Chord, ChordQuality } from '../chord/index.js';
8
8
  import { chordPitchClasses, chordQualities, makeChord } from '../chord/index.js';
9
- import { majorKey, minorKey } from '../scale/index.js';
9
+ import {
10
+ HARMONIC_MINOR_MASK,
11
+ MAJOR_MASK,
12
+ MELODIC_MINOR_MASK,
13
+ majorKey,
14
+ minorKey,
15
+ NATURAL_MINOR_MASK,
16
+ } from '../scale/index.js';
10
17
  import type { KeyScale } from '../types.js';
11
18
 
12
- /** A candidate chord interpretation of a pitch set. */
19
+ /**
20
+ * A candidate chord interpretation of a pitch set.
21
+ *
22
+ * @category Recognition
23
+ */
13
24
  export type ChordMatch = {
14
25
  rootPc: number;
15
26
  quality: ChordQuality;
@@ -25,7 +36,11 @@ export type ChordMatch = {
25
36
  bassPc?: number;
26
37
  };
27
38
 
28
- /** A candidate key interpretation of a pitch-class set. */
39
+ /**
40
+ * A candidate key interpretation of a pitch-class set.
41
+ *
42
+ * @category Recognition
43
+ */
29
44
  export type KeyMatch = {
30
45
  key: KeyScale;
31
46
  mode: 'major' | 'minor';
@@ -55,6 +70,13 @@ function uniquePitchClasses(pitches: number[]): number[] {
55
70
  *
56
71
  * @param pitches MIDI pitches or bare pitch classes (octave-agnostic).
57
72
  * @returns Ranked chord interpretations (may be empty).
73
+ * @example
74
+ * ```ts
75
+ * import { detectChord } from '@libraz/libcantus';
76
+ * const matches = detectChord([60, 64, 67]); // C E G
77
+ * matches[0]; // { rootPc: 0, quality: 'maj', exact: true, ... }
78
+ * ```
79
+ * @category Recognition
58
80
  */
59
81
  export function detectChord(pitches: number[]): ChordMatch[] {
60
82
  const input = uniquePitchClasses(pitches);
@@ -85,7 +107,7 @@ export function detectChord(pitches: number[]): ChordMatch[] {
85
107
  quality,
86
108
  missingPcs,
87
109
  extraPcs,
88
- exact: extraPcs.length === 0,
110
+ exact: extraPcs.length === 0 && missingPcs.length === 0,
89
111
  inversion,
90
112
  };
91
113
  if (inversion > 0) {
@@ -117,6 +139,12 @@ export function detectChord(pitches: number[]): ChordMatch[] {
117
139
  *
118
140
  * @param pitches MIDI pitches or bare pitch classes.
119
141
  * @returns The top-ranked chord, or null when nothing matches.
142
+ * @example
143
+ * ```ts
144
+ * import { detectChordBest } from '@libraz/libcantus';
145
+ * detectChordBest([60, 64, 67]); // C major triad: { rootPc: 0, quality: 'maj', ... }
146
+ * ```
147
+ * @category Recognition
120
148
  */
121
149
  export function detectChordBest(pitches: number[]): Chord | null {
122
150
  const best = detectChord(pitches)[0];
@@ -126,47 +154,78 @@ export function detectChordBest(pitches: number[]): Chord | null {
126
154
  return makeChord(best.rootPc, best.quality, best.bassPc);
127
155
  }
128
156
 
157
+ /**
158
+ * Minor-scale variants scored for each minor-key candidate. Scoring against all
159
+ * three lets the raised sixth and seventh (e.g. the leading tone G# in A minor)
160
+ * count toward their own tonic instead of only penalizing it.
161
+ */
162
+ const MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MINOR_MASK];
163
+
129
164
  /**
130
165
  * Rank major and minor keys by how well they contain a set of pitch classes.
131
166
  *
132
167
  * The tonic is weighted so that, among equally-fitting keys, the one whose root
133
- * appears in the input is preferred. Returns all 24 keys ranked best-first.
168
+ * appears in the input is preferred. Minor candidates are scored against the
169
+ * natural, harmonic, and melodic minor variants and keep the best of the three,
170
+ * so a minor cadence containing the leading tone still resolves to its own
171
+ * tonic; the returned key is always the natural-minor scale. Returns all 24
172
+ * keys ranked best-first, or an empty array for an empty input (mirroring
173
+ * {@link detectChord}).
134
174
  *
135
175
  * @param pitches MIDI pitches or bare pitch classes.
136
- * @returns Ranked key interpretations.
176
+ * @returns Ranked key interpretations (empty for an empty input).
177
+ * @example
178
+ * ```ts
179
+ * import { detectKey } from '@libraz/libcantus';
180
+ * const keys = detectKey([60, 62, 64, 65, 67, 69, 71]); // C major scale
181
+ * keys[0].mode; // 'major', with keys[0].key.rootPc === 0
182
+ * ```
183
+ * @category Recognition
137
184
  */
138
185
  export function detectKey(pitches: number[]): KeyMatch[] {
139
186
  const input = uniquePitchClasses(pitches);
187
+ if (input.length === 0) {
188
+ return [];
189
+ }
140
190
  const counts = new Map<number, number>();
141
191
  for (const pc of pitches.map(pitchClass)) {
142
192
  counts.set(pc, (counts.get(pc) ?? 0) + 1);
143
193
  }
144
- const total = pitches.length || 1;
194
+ const total = pitches.length;
145
195
  const results: (KeyMatch & { score: number })[] = [];
146
196
  for (let tonic = 0; tonic < 12; tonic += 1) {
147
197
  for (const mode of ['major', 'minor'] as const) {
148
198
  const key = mode === 'major' ? majorKey(tonic) : minorKey(tonic);
199
+ const masks = mode === 'major' ? [MAJOR_MASK] : MINOR_MASK_VARIANTS;
200
+ // Score each scale variant and keep the best; ties keep the earlier
201
+ // (more diatonic) variant.
149
202
  let inScale = 0;
150
- let weighted = 0;
151
- for (const pc of input) {
152
- const offset = (pc - tonic + 12) % 12;
153
- if ((key.modeMask12 >> offset) & 1) {
154
- inScale += 1;
203
+ let weighted = Number.NEGATIVE_INFINITY;
204
+ for (const mask of masks) {
205
+ let variantInScale = 0;
206
+ let variantWeighted = 0;
207
+ for (const pc of input) {
208
+ const offset = (pc - tonic + 12) % 12;
209
+ if ((mask >> offset) & 1) {
210
+ variantInScale += 1;
211
+ }
155
212
  }
156
- }
157
- for (const [pc, count] of counts) {
158
- const offset = (pc - tonic + 12) % 12;
159
- if ((key.modeMask12 >> offset) & 1) {
160
- weighted += count;
213
+ for (const [pc, count] of counts) {
214
+ const offset = (pc - tonic + 12) % 12;
215
+ if ((mask >> offset) & 1) {
216
+ variantWeighted += count;
217
+ }
161
218
  }
162
- if (pc === tonic) {
163
- weighted += count * 0.5;
219
+ if (variantWeighted > weighted) {
220
+ weighted = variantWeighted;
221
+ inScale = variantInScale;
164
222
  }
165
223
  }
224
+ weighted += (counts.get(tonic) ?? 0) * 0.5;
166
225
  results.push({
167
226
  key,
168
227
  mode,
169
- fit: input.length === 0 ? 0 : inScale / input.length,
228
+ fit: inScale / input.length,
170
229
  score: weighted / total,
171
230
  });
172
231
  }
@@ -32,7 +32,19 @@ export function getFillStartBeat(energy: SectionEnergy): number {
32
32
  }
33
33
  }
34
34
 
35
- /** Pick a fill archetype from the transition context. */
35
+ /**
36
+ * Pick a fill archetype for a section transition.
37
+ *
38
+ * The transition-specific archetypes (into a chorus, out of an intro) are
39
+ * checked before the generic energy fills so every variation stays reachable;
40
+ * with `from === to` the caller effectively asks for a within-section fill.
41
+ *
42
+ * @param from Section the fill leaves.
43
+ * @param to Section the fill leads into.
44
+ * @param style Internal drum style.
45
+ * @param nextEnergy Energy of the section the fill leads into.
46
+ * @param rng Deterministic PRNG.
47
+ */
36
48
  export function selectFillType(
37
49
  from: SectionType,
38
50
  to: SectionType,
@@ -44,6 +56,11 @@ export function selectFillType(
44
56
  return rng.range(0, 1) === 0 ? 'simpleCrash' : 'breakdownFill';
45
57
  }
46
58
 
59
+ const toChorus = to === 'chorus';
60
+ const fromIntro = from === 'intro';
61
+ const highEnergy = style === 'rock' || style === 'fourOnFloor';
62
+
63
+ // Dropping into a low-energy section: keep the phrase end gentle.
47
64
  if (nextEnergy === 'low') {
48
65
  switch (rng.range(0, 2)) {
49
66
  case 0:
@@ -55,25 +72,9 @@ export function selectFillType(
55
72
  }
56
73
  }
57
74
 
58
- if (nextEnergy === 'peak') {
59
- switch (rng.range(0, 3)) {
60
- case 0:
61
- return 'tomDescend';
62
- case 1:
63
- return 'snareRoll';
64
- case 2:
65
- return 'linearFill';
66
- default:
67
- return 'flamsAndDrags';
68
- }
69
- }
70
-
71
- const toChorus = to === 'chorus';
72
- const fromIntro = from === 'intro';
73
- const highEnergy = style === 'rock' || style === 'fourOnFloor';
74
- const choice = rng.range(0, 7);
75
-
75
+ // Big lead-ins to a chorus take precedence over the generic energy fills.
76
76
  if (toChorus) {
77
+ const choice = rng.range(0, 7);
77
78
  if (highEnergy) {
78
79
  switch (choice) {
79
80
  case 0:
@@ -110,8 +111,9 @@ export function selectFillType(
110
111
  }
111
112
  }
112
113
 
114
+ // Leaving the intro: lighter, building character.
113
115
  if (fromIntro) {
114
- switch (choice) {
116
+ switch (rng.range(0, 5)) {
115
117
  case 0:
116
118
  case 1:
117
119
  return 'snareRoll';
@@ -126,6 +128,8 @@ export function selectFillType(
126
128
  }
127
129
  }
128
130
 
131
+ // Generic medium/high/peak fills, split by style energy.
132
+ const choice = rng.range(0, 7);
129
133
  if (highEnergy) {
130
134
  switch (choice) {
131
135
  case 0:
@@ -339,6 +343,13 @@ export function generateFill(
339
343
  if (beat === 2) {
340
344
  track.add(GM.SD, beatTick, 1, accentVel);
341
345
  track.add(GM.BD, beatTick, 1, fillVel);
346
+ } else if (beat === 3) {
347
+ // At low energy the fill spans only beat 3, so this branch keeps the
348
+ // phrase end from going silent: a broad half-time backbeat snare with a
349
+ // light pickup into the next section.
350
+ track.add(GM.SD, beatTick, EIGHTH, accentVel);
351
+ track.add(GM.SD, beatTick + E, S, fillVel * 0.6);
352
+ track.add(GM.SD, beatTick + E + S, S, accentVel);
342
353
  }
343
354
  break;
344
355
  }
@@ -7,6 +7,7 @@ import {
7
7
  generateSnareForBeat,
8
8
  type SectionCtx,
9
9
  } from './beat.js';
10
+ import { euclideanRhythm, patternToMask } from './euclid.js';
10
11
  import { type FillType, generateFill, getFillStartBeat, selectFillType } from './fills.js';
11
12
  import {
12
13
  footHiHatVelocity,
@@ -34,28 +35,106 @@ import {
34
35
  sectionDensityMultiplier,
35
36
  sectionEnergy,
36
37
  } from './internal.js';
37
- import { getKickPattern, isInPreChorusLift, type KickPattern } from './kick.js';
38
+ import {
39
+ euclideanToKickPattern,
40
+ getKickPattern,
41
+ isInPreChorusLift,
42
+ type KickPattern,
43
+ } from './kick.js';
38
44
  import { generateAuxPercussionForBar, getPercussionConfig } from './percussion.js';
39
45
  import { createRng } from './rng.js';
40
46
 
47
+ /**
48
+ * A single drum onset emitted by {@link generateDrums}.
49
+ *
50
+ * @category Composition
51
+ */
41
52
  export type { DrumHit } from './hit.js';
53
+
54
+ /**
55
+ * Groove feel (the swing/straight rhythmic character) for {@link generateDrums}.
56
+ *
57
+ * @category Composition
58
+ */
42
59
  export type GrooveFeel = Feel;
60
+
61
+ /**
62
+ * Drum voicing role and groove style identifiers for {@link generateDrums}.
63
+ *
64
+ * @category Composition
65
+ */
43
66
  export type { DrumRole, GrooveStyle } from './internal.js';
44
67
 
45
- /** Public section identifiers for {@link generateDrums}. */
68
+ /**
69
+ * Public section identifiers for {@link generateDrums}.
70
+ *
71
+ * @category Composition
72
+ */
46
73
  export type Section = PublicSection;
47
74
 
48
- /** Options controlling {@link generateDrums}. */
75
+ /**
76
+ * A Euclidean (Bjorklund) kick pattern for {@link generateDrums}.
77
+ *
78
+ * @category Composition
79
+ */
80
+ export type EuclideanKick = {
81
+ /** Number of kick onsets, clamped to `[0, steps]`. */
82
+ pulses: number;
83
+ /**
84
+ * Total steps in the bar (1..16).
85
+ *
86
+ * @defaultValue 16
87
+ */
88
+ steps?: number;
89
+ /**
90
+ * Steps to rotate the onsets toward later positions.
91
+ *
92
+ * @defaultValue 0
93
+ */
94
+ rotation?: number;
95
+ };
96
+
97
+ /**
98
+ * Options controlling {@link generateDrums}.
99
+ *
100
+ * @category Composition
101
+ */
49
102
  export type DrumGenOptions = {
50
103
  bars: number;
51
104
  bpm: number;
52
105
  style: GrooveStyle;
53
106
  section: Section;
54
107
  density: number;
108
+ /**
109
+ * Replace the final bar with a fill.
110
+ *
111
+ * @defaultValue false
112
+ */
55
113
  fills?: boolean;
56
114
  feel?: GrooveFeel;
115
+ /**
116
+ * Voicing role; `'fxOnly'` suppresses the main kick/snare/ghost/fill voices.
117
+ *
118
+ * @defaultValue `'full'`
119
+ */
57
120
  role?: DrumRole;
121
+ /**
122
+ * Seed for the deterministic PRNG.
123
+ *
124
+ * @defaultValue 0
125
+ */
58
126
  seed?: number;
127
+ /**
128
+ * Section the final-bar fill leads into. Shapes which fill archetype is
129
+ * chosen (into-chorus and out-of-intro fills differ from generic ones).
130
+ * Defaults to `section`, i.e. a within-section fill.
131
+ */
132
+ nextSection?: Section;
133
+ /**
134
+ * When set, the kick follows this Euclidean rhythm instead of the
135
+ * style/section pattern, giving direct access to evenly spread onsets.
136
+ */
137
+ euclideanKick?: EuclideanKick;
59
138
  };
60
139
 
61
140
  /**
@@ -66,11 +145,23 @@ export type DrumGenOptions = {
66
145
  * by its General MIDI pitch. Groove style selects an internal style and feel;
67
146
  * `density` sets the backing-density level; `section` shapes kick, hi-hat, ghost,
68
147
  * and percussion density. 16th-note hi-hats drop to 8ths at or above 150 BPM.
69
- * When `fills` is true the final bar is replaced with a fill. Output is fully
148
+ * When `fills` is true the final bar is replaced with a fill whose archetype is
149
+ * shaped by `nextSection`; a fill that would emit nothing on its beat falls back
150
+ * to the normal groove so the phrase end is never silent. `euclideanKick`
151
+ * overrides the kick with an evenly-spread Euclidean pattern. Output is fully
70
152
  * determined by the options plus `seed`.
71
153
  *
72
154
  * @param opts Generation options.
73
155
  * @returns Percussion onsets in bar order.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * import { generateDrums } from '@libraz/libcantus';
160
+ * const hits = generateDrums({ bars: 4, bpm: 120, style: 'standard', section: 'chorus', density: 0.6, fills: true });
161
+ * // Fully determined by the options plus seed (defaults to 0).
162
+ * ```
163
+ *
164
+ * @category Composition
74
165
  */
75
166
  export function generateDrums(opts: DrumGenOptions): DrumHit[] {
76
167
  const track = new HitList();
@@ -114,6 +205,23 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
114
205
  const fillStartBeat = getFillStartBeat(energy);
115
206
  const percMood = percMoodCategory(style);
116
207
 
208
+ // The final-bar fill is shaped by the section it leads into. When no next
209
+ // section is given the fill is treated as within-section (from === to).
210
+ const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
211
+ const nextEnergy = sectionEnergy(nextSection);
212
+
213
+ const euclidKick: KickPattern | undefined = opts.euclideanKick
214
+ ? euclideanToKickPattern(
215
+ patternToMask(
216
+ euclideanRhythm(
217
+ opts.euclideanKick.pulses,
218
+ opts.euclideanKick.steps ?? 16,
219
+ opts.euclideanKick.rotation ?? 0,
220
+ ),
221
+ ),
222
+ )
223
+ : undefined;
224
+
117
225
  const reuseSectionKick = (section === 'b' || section === 'chorus') && style !== 'sparse';
118
226
  let sectionKick: KickPattern | undefined;
119
227
 
@@ -133,7 +241,9 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
133
241
  }
134
242
 
135
243
  let kick: KickPattern;
136
- if (reuseSectionKick) {
244
+ if (euclidKick) {
245
+ kick = euclidKick;
246
+ } else if (reuseSectionKick) {
137
247
  sectionKick ??= getKickPattern(section, style, 0, rng);
138
248
  kick = sectionKick;
139
249
  } else {
@@ -149,12 +259,20 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
149
259
 
150
260
  if (opts.fills && isLastBar && !inLift && beat >= fillStartBeat) {
151
261
  if (beat === fillStartBeat) {
152
- currentFill = selectFillType(section, section, style, energy, rng);
262
+ currentFill = selectFillType(section, nextSection, style, nextEnergy, rng);
153
263
  }
154
264
  if (playMainVoices) {
265
+ const before = track.hits.length;
155
266
  generateFill(track, beatTick, beat, currentFill, velocity);
267
+ if (track.hits.length > before) {
268
+ continue;
269
+ }
270
+ // Safety net: an archetype that contributes nothing on this beat would
271
+ // leave a silent phrase end, so fall through to the normal groove.
272
+ } else {
273
+ // fxOnly deliberately emits no main voices on the fill beats.
274
+ continue;
156
275
  }
157
- continue;
158
276
  }
159
277
 
160
278
  const ctx: BeatCtx = {
@@ -67,7 +67,9 @@ export type GrooveStyle =
67
67
  | 'bossa'
68
68
  | 'trap'
69
69
  | 'halftime'
70
- | 'breakbeat';
70
+ | 'breakbeat'
71
+ | 'house'
72
+ | 'synthpop';
71
73
 
72
74
  /** Public role identifiers gating which voices are present. */
73
75
  export type DrumRole = 'full' | 'ambient' | 'minimal' | 'fxOnly';
@@ -92,6 +94,10 @@ const STYLE_MAP: Record<GrooveStyle, StyleMapping> = {
92
94
  trap: { style: 'trap', feel: 'straight', ghostBoost: false, snareBeat3: true },
93
95
  halftime: { style: 'sparse', feel: 'straight', ghostBoost: false, snareBeat3: true },
94
96
  breakbeat: { style: 'rock', feel: 'straight', ghostBoost: false, snareBeat3: false },
97
+ // Four-on-the-floor dance kick with idol-style auxiliary percussion.
98
+ house: { style: 'fourOnFloor', feel: 'straight', ghostBoost: false, snareBeat3: false },
99
+ // Synth-pop pulse with denser 16th hi-hats and busy kick syncopation.
100
+ synthpop: { style: 'synth', feel: 'straight', ghostBoost: true, snareBeat3: false },
95
101
  };
96
102
 
97
103
  /** Resolve the internal style/feel parameters for a public groove style. */
@@ -53,8 +53,13 @@ export function quantizeSwing(
53
53
  return base + EIGHTH + DELTA_8 * clamped;
54
54
  }
55
55
  if (offset >= EIGHTH + HALF_16TH && offset < 3 * SIXTEENTH + HALF_16TH) {
56
- const result = base + EIGHTH + DELTA_8 * clamped + SIXTEENTH + DELTA_16 * clamped;
57
- return Math.min(result, base + 1 - 1 / 480);
56
+ // The "a" 16th is the midpoint of the compressed second half of the beat
57
+ // (between the swung "and" and the next downbeat), mirroring the "e" 16th
58
+ // in the first half. Because DELTA_8 = 2 * DELTA_16, that midpoint reduces
59
+ // to a single DELTA_16 delay off the straight position, so the "a" never
60
+ // overshoots the downbeat and needs no clamp. At the default shuffle feel
61
+ // (swing 0.75) this lands near 0.8125 rather than the doubly-swung 0.9375.
62
+ return base + 3 * SIXTEENTH + DELTA_16 * clamped;
58
63
  }
59
64
  return startBeat;
60
65
  }