@libraz/libcantus 0.9.0

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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +291 -0
  3. package/dist/index.cjs +6465 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2621 -0
  6. package/dist/index.d.ts +2621 -0
  7. package/dist/index.js +6297 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +71 -0
  10. package/src/analysis/index.ts +261 -0
  11. package/src/arrange/index.ts +409 -0
  12. package/src/bass/index.ts +301 -0
  13. package/src/chord/index.ts +292 -0
  14. package/src/chordscale/index.ts +316 -0
  15. package/src/countermelody/index.ts +349 -0
  16. package/src/counterpoint/index.ts +209 -0
  17. package/src/detect/index.ts +176 -0
  18. package/src/drums/beat.ts +266 -0
  19. package/src/drums/euclid.ts +58 -0
  20. package/src/drums/fills.ts +345 -0
  21. package/src/drums/ghost.ts +119 -0
  22. package/src/drums/hihat.ts +285 -0
  23. package/src/drums/hit.ts +31 -0
  24. package/src/drums/index.ts +211 -0
  25. package/src/drums/internal.ts +251 -0
  26. package/src/drums/kick.ts +160 -0
  27. package/src/drums/percussion.ts +121 -0
  28. package/src/drums/rng.ts +7 -0
  29. package/src/drums/swing.ts +66 -0
  30. package/src/functional/index.ts +602 -0
  31. package/src/groove/index.ts +230 -0
  32. package/src/harmonize/index.ts +367 -0
  33. package/src/harmony/index.ts +75 -0
  34. package/src/index.ts +233 -0
  35. package/src/interval/index.ts +67 -0
  36. package/src/meter/index.ts +179 -0
  37. package/src/model/index.ts +957 -0
  38. package/src/motif/index.ts +321 -0
  39. package/src/pitch/index.ts +256 -0
  40. package/src/progression/index.ts +336 -0
  41. package/src/random/index.ts +41 -0
  42. package/src/reharmony/index.ts +299 -0
  43. package/src/rhythm/index.ts +131 -0
  44. package/src/safety/index.ts +372 -0
  45. package/src/scale/index.ts +215 -0
  46. package/src/spelling/index.ts +164 -0
  47. package/src/symbol/index.ts +213 -0
  48. package/src/timeline/index.ts +352 -0
  49. package/src/tuning/index.ts +129 -0
  50. package/src/types.ts +29 -0
  51. package/src/voicing/index.ts +523 -0
  52. package/tsconfig.json +23 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Deterministic rhythm generation over a metric grid.
3
+ *
4
+ * Builds a fixed-resolution grid across one or more bars of a time signature
5
+ * and decides, per grid slot, whether it becomes a note onset. Onset
6
+ * probability follows the metric-accent hierarchy from the `meter` module, so
7
+ * strong positions (downbeats, secondary pulses) are favored over weak
8
+ * off-pulse subdivisions. Given a seed the output is fully reproducible.
9
+ *
10
+ * Positions and durations are measured in quarter-note beats, matching the
11
+ * library-wide convention.
12
+ */
13
+
14
+ import { beatsPerBar, metricWeight, type TimeSignature } from '../meter/index.js';
15
+ import { createRng } from '../random/index.js';
16
+
17
+ /** A single rhythmic note: an onset position and how long it sounds. */
18
+ export type RhythmEvent = {
19
+ /** Onset position in quarter-note beats, absolute from the start of the span. */
20
+ position: number;
21
+ /** Duration in quarter-note beats, extending to the next onset. */
22
+ duration: number;
23
+ };
24
+
25
+ /** Options controlling {@link generateRhythm}. */
26
+ export type RhythmOptions = {
27
+ /** Seed for the deterministic PRNG. Default 0. */
28
+ seed?: number;
29
+ /** Number of bars to generate. Default 1. */
30
+ bars?: number;
31
+ /**
32
+ * Grid resolution as the number of equal grid steps per quarter-note beat.
33
+ * The default of 2 yields an eighth-note grid; 4 yields a sixteenth-note
34
+ * grid, 3 an eighth-note triplet grid, and so on.
35
+ */
36
+ subdivision?: number;
37
+ /**
38
+ * Overall onset density in [0, 1]. Scales the per-slot onset probability, so
39
+ * higher values fill more grid slots. Default 0.5.
40
+ */
41
+ density?: number;
42
+ };
43
+
44
+ const DEFAULT_SUBDIVISION = 2;
45
+ const DEFAULT_DENSITY = 0.5;
46
+ const DEFAULT_BARS = 1;
47
+
48
+ /**
49
+ * Map a metric weight (0–3 from {@link metricWeight}) to a base onset
50
+ * probability before density scaling. Strong positions are much more likely to
51
+ * carry an onset than weak ones; the curve is intentionally small and explicit
52
+ * so its behavior is easy to inspect and test.
53
+ *
54
+ * @param weight The metric weight, 0 (off-pulse) to 3 (downbeat).
55
+ * @returns A base onset probability in [0, 1].
56
+ */
57
+ export function onsetWeightCurve(weight: number): number {
58
+ switch (weight) {
59
+ case 3:
60
+ return 1;
61
+ case 2:
62
+ return 0.8;
63
+ case 1:
64
+ return 0.55;
65
+ default:
66
+ return 0.25;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Generate a deterministic rhythmic pattern over `bars` bars of a time
72
+ * signature. A grid is built at the chosen subdivision; each slot becomes an
73
+ * 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.
77
+ *
78
+ * @param ts The time signature.
79
+ * @param opts Generation options.
80
+ * @returns Onset events sorted by position, non-overlapping, covering the span.
81
+ */
82
+ export function generateRhythm(ts: TimeSignature, opts: RhythmOptions = {}): RhythmEvent[] {
83
+ const seed = opts.seed ?? 0;
84
+ const bars = opts.bars ?? DEFAULT_BARS;
85
+ const subdivision = opts.subdivision ?? DEFAULT_SUBDIVISION;
86
+ const density = opts.density ?? DEFAULT_DENSITY;
87
+
88
+ const spanBeats = beatsPerBar(ts) * bars;
89
+ const step = 1 / subdivision;
90
+ const slotCount = Math.round(spanBeats * subdivision);
91
+ const rng = createRng(seed);
92
+
93
+ const positions: number[] = [];
94
+ for (let i = 0; i < slotCount; i += 1) {
95
+ const position = i * step;
96
+ if (i === 0) {
97
+ positions.push(position);
98
+ continue;
99
+ }
100
+ const weight = metricWeight(position, ts);
101
+ const probability = onsetWeightCurve(weight) * density;
102
+ if (rng.prob(probability)) {
103
+ positions.push(position);
104
+ }
105
+ }
106
+
107
+ const events: RhythmEvent[] = [];
108
+ for (let i = 0; i < positions.length; i += 1) {
109
+ const position = positions[i] ?? 0;
110
+ const nextPosition = positions[i + 1] ?? spanBeats;
111
+ events.push({ position, duration: nextPosition - position });
112
+ }
113
+ return events;
114
+ }
115
+
116
+ /**
117
+ * Onset density of a generated pattern: the mean number of onsets per bar.
118
+ *
119
+ * @param events Events from {@link generateRhythm}.
120
+ * @param ts The time signature.
121
+ * @returns The onset count divided by the number of bars the events span.
122
+ */
123
+ export function rhythmDensity(events: RhythmEvent[], ts: TimeSignature): number {
124
+ if (events.length === 0) {
125
+ return 0;
126
+ }
127
+ const last = events[events.length - 1];
128
+ const spanBeats = (last?.position ?? 0) + (last?.duration ?? 0);
129
+ const bars = spanBeats / beatsPerBar(ts);
130
+ return bars > 0 ? events.length / bars : 0;
131
+ }
@@ -0,0 +1,372 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordPitchClasses } from '../chord/index.js';
3
+ import {
4
+ createsHiddenParallelPerfect,
5
+ createsParallelOctave,
6
+ createsParallelPerfect,
7
+ createsVerticalDissonance,
8
+ isForbiddenMelodicLeap,
9
+ } from '../counterpoint/index.js';
10
+ import { isScaleTone } from '../scale/index.js';
11
+ import type { KeyScale } from '../types.js';
12
+
13
+ /** Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading. */
14
+ export type SafetyProfile = 'strict' | 'pop';
15
+
16
+ /** Overall placeability verdict for a candidate pitch. */
17
+ export enum NoteSafety {
18
+ Safe = 0,
19
+ Warning = 1,
20
+ Dissonant = 2,
21
+ }
22
+
23
+ /** Bit flags describing why a pitch received its verdict. */
24
+ export enum ReasonFlag {
25
+ ChordTone = 1 << 0,
26
+ Tension = 1 << 1,
27
+ AvoidNote = 1 << 2,
28
+ ScaleTone = 1 << 3,
29
+ NonScale = 1 << 4,
30
+ OutOfRange = 1 << 5,
31
+ Tritone = 1 << 6,
32
+ LargeLeap = 1 << 7,
33
+ MinorSecond = 1 << 8,
34
+ MajorSeventh = 1 << 9,
35
+ VerticalDissonance = 1 << 10,
36
+ ParallelPerfect = 1 << 11,
37
+ HiddenParallel = 1 << 12,
38
+ VoiceCrossing = 1 << 13,
39
+ Suspension = 1 << 14,
40
+ NeedsResolution = 1 << 15,
41
+ }
42
+
43
+ /** A neighbouring voice's current and previous pitch. */
44
+ export type VoiceSnapshot = {
45
+ pitch: number;
46
+ prevPitch?: number;
47
+ };
48
+
49
+ /** Everything needed to evaluate one candidate pitch. */
50
+ export type SafetyQuery = {
51
+ profile: SafetyProfile;
52
+ candidatePitch: number;
53
+ prevPitch?: number;
54
+ chord: Chord | null;
55
+ key: KeyScale;
56
+ otherVoices: VoiceSnapshot[];
57
+ strongBeat: boolean;
58
+ vocalLow?: number;
59
+ vocalHigh?: number;
60
+ };
61
+
62
+ /** The verdict, reason flags, and optional resolution guidance. */
63
+ export type SafetyResult = {
64
+ safety: NoteSafety;
65
+ reasons: number;
66
+ resolveTo?: number;
67
+ suggestions?: number[];
68
+ rationale?: string;
69
+ };
70
+
71
+ /** Semitone radius searched around a rejected candidate for safe alternatives. */
72
+ const SUGGESTION_WINDOW = 12;
73
+ /** Maximum number of alternative pitches returned in `SafetyResult.suggestions`. */
74
+ const MAX_SUGGESTIONS = 3;
75
+
76
+ function pitchClass(pitch: number): number {
77
+ return ((Math.trunc(pitch) % 12) + 12) % 12;
78
+ }
79
+
80
+ function intervalAboveRoot(pitch: number, chord: Chord): number {
81
+ return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
82
+ }
83
+
84
+ function isChordMember(pitch: number, chord: Chord): boolean {
85
+ return chordPitchClasses(chord).includes(pitchClass(pitch));
86
+ }
87
+
88
+ /** Whether the perfect fourth is an avoid note over this chord. */
89
+ function avoidsFourth(chord: Chord): boolean {
90
+ return chord.intervals.includes(4) && !chord.intervals.includes(5);
91
+ }
92
+
93
+ /** Whether the major seventh is an avoid note over this chord (dominant). */
94
+ function avoidsMajorSeventh(chord: Chord): boolean {
95
+ return chord.intervals.includes(4) && chord.intervals.includes(10);
96
+ }
97
+
98
+ /** Nearest chord tone at or below the candidate, within two semitones. */
99
+ function stepResolution(pitch: number, chord: Chord): number | undefined {
100
+ for (let delta = 1; delta <= 2; delta += 1) {
101
+ if (isChordMember(pitch - delta, chord)) {
102
+ return pitch - delta;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * Evaluate the safety of placing a candidate pitch in a voice.
110
+ *
111
+ * Reasons are merged from harmonic context (chord tone / tension / avoid / scale)
112
+ * and counterpoint (vertical dissonance, parallels, voice crossing). The `strict`
113
+ * profile treats avoid notes and parallels as dissonant; `pop` treats them as
114
+ * warnings. The final verdict is the worst severity among the reasons.
115
+ *
116
+ * @param q The candidate and its harmonic/voice-leading context.
117
+ * @returns The verdict, reason bitmask, and optional resolution guidance.
118
+ */
119
+ export function evaluateSafety(q: SafetyQuery): SafetyResult {
120
+ return evaluateInternal(q, true);
121
+ }
122
+
123
+ /**
124
+ * Core safety evaluation.
125
+ *
126
+ * @param q The candidate and its harmonic/voice-leading context.
127
+ * @param collectSuggestions Whether to search for safe alternatives on a
128
+ * non-Safe verdict. Set false during that search itself to bound recursion.
129
+ * @returns The verdict, reason bitmask, and optional guidance.
130
+ */
131
+ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyResult {
132
+ let reasons = 0;
133
+ let level = NoteSafety.Safe;
134
+ let resolveTo: number | undefined;
135
+ const raise = (l: NoteSafety) => {
136
+ if (l > level) {
137
+ level = l;
138
+ }
139
+ };
140
+ const avoidSeverity = q.profile === 'strict' ? NoteSafety.Dissonant : NoteSafety.Warning;
141
+ const parallelSeverity = avoidSeverity;
142
+ const pitch = q.candidatePitch;
143
+
144
+ if (
145
+ (q.vocalLow !== undefined && pitch < q.vocalLow) ||
146
+ (q.vocalHigh !== undefined && pitch > q.vocalHigh)
147
+ ) {
148
+ reasons |= ReasonFlag.OutOfRange;
149
+ raise(NoteSafety.Warning);
150
+ }
151
+
152
+ const inScale = isScaleTone(pitch, q.key);
153
+ const chord = q.chord;
154
+
155
+ if (chord && isChordMember(pitch, chord)) {
156
+ reasons |= ReasonFlag.ChordTone;
157
+ } else if (chord) {
158
+ const ic = intervalAboveRoot(pitch, chord);
159
+ const isTension = ic === 2 || ic === 5 || ic === 9;
160
+ const avoid = (ic === 5 && avoidsFourth(chord)) || (ic === 11 && avoidsMajorSeventh(chord));
161
+ if (avoid) {
162
+ reasons |= ReasonFlag.AvoidNote;
163
+ if (isTension) {
164
+ reasons |= ReasonFlag.Tension;
165
+ }
166
+ reasons |= ReasonFlag.NeedsResolution;
167
+ resolveTo = stepResolution(pitch, chord);
168
+ raise(avoidSeverity);
169
+ } else if (isTension && inScale) {
170
+ reasons |= ReasonFlag.Tension;
171
+ raise(NoteSafety.Warning);
172
+ } else if (inScale) {
173
+ reasons |= ReasonFlag.ScaleTone;
174
+ raise(NoteSafety.Warning);
175
+ } else {
176
+ reasons |= ReasonFlag.NonScale;
177
+ raise(NoteSafety.Dissonant);
178
+ }
179
+ } else if (inScale) {
180
+ reasons |= ReasonFlag.ScaleTone;
181
+ raise(NoteSafety.Warning);
182
+ } else {
183
+ reasons |= ReasonFlag.NonScale;
184
+ raise(NoteSafety.Dissonant);
185
+ }
186
+
187
+ // Harmonic tritone against a chord tone (except in dominant chords, where the
188
+ // tritone is a defining, stable colour).
189
+ if (chord && !(chord.intervals.includes(4) && chord.intervals.includes(10))) {
190
+ const ic = intervalAboveRoot(pitch, chord);
191
+ const toneOffsets = chord.intervals.map((i) => ((i % 12) + 12) % 12);
192
+ if (toneOffsets.some((t) => Math.abs(ic - t) === 6)) {
193
+ reasons |= ReasonFlag.Tritone;
194
+ }
195
+ }
196
+
197
+ if (q.prevPitch !== undefined) {
198
+ // Quality flags reduce the melodic interval to its pitch-class interval so
199
+ // compound intervals are treated uniformly with their simple form. The
200
+ // forbidden-leap flag defers to the counterpoint rule, which preserves the
201
+ // octave boundary (an octave leap is allowed, wider leaps are not).
202
+ const pc = Math.abs(pitch - q.prevPitch) % 12;
203
+ if (pc === 6) {
204
+ reasons |= ReasonFlag.Tritone;
205
+ }
206
+ if (isForbiddenMelodicLeap(q.prevPitch, pitch)) {
207
+ reasons |= ReasonFlag.LargeLeap;
208
+ }
209
+ if (pc === 1) {
210
+ reasons |= ReasonFlag.MinorSecond;
211
+ }
212
+ if (pc === 11) {
213
+ reasons |= ReasonFlag.MajorSeventh;
214
+ }
215
+ }
216
+
217
+ if (q.strongBeat) {
218
+ const twoVoice = q.otherVoices.length === 1;
219
+ const prev = q.prevPitch;
220
+ for (const ov of q.otherVoices) {
221
+ if (!createsVerticalDissonance(pitch, ov.pitch, twoVoice)) {
222
+ continue;
223
+ }
224
+ reasons |= ReasonFlag.VerticalDissonance;
225
+ raise(NoteSafety.Dissonant);
226
+ // A held pitch that was consonant on the previous step and is now
227
+ // dissonant against the same voice is a prepared suspension.
228
+ if (
229
+ prev !== undefined &&
230
+ pitch === prev &&
231
+ ov.prevPitch !== undefined &&
232
+ !createsVerticalDissonance(prev, ov.prevPitch, twoVoice)
233
+ ) {
234
+ reasons |= ReasonFlag.Suspension;
235
+ }
236
+ }
237
+ }
238
+
239
+ if (q.prevPitch !== undefined) {
240
+ const prev = q.prevPitch;
241
+ for (const ov of q.otherVoices) {
242
+ if (ov.prevPitch === undefined) {
243
+ continue;
244
+ }
245
+ if (
246
+ createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch) ||
247
+ createsParallelOctave(prev, pitch, ov.prevPitch, ov.pitch)
248
+ ) {
249
+ reasons |= ReasonFlag.ParallelPerfect;
250
+ raise(parallelSeverity);
251
+ }
252
+ if (createsHiddenParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
253
+ reasons |= ReasonFlag.HiddenParallel;
254
+ raise(parallelSeverity);
255
+ }
256
+ const crossedNow = pitch - ov.pitch;
257
+ const crossedPrev = prev - ov.prevPitch;
258
+ if (crossedNow !== 0 && crossedPrev !== 0 && crossedNow > 0 !== crossedPrev > 0) {
259
+ reasons |= ReasonFlag.VoiceCrossing;
260
+ raise(parallelSeverity);
261
+ }
262
+ }
263
+ }
264
+
265
+ const result: SafetyResult = { safety: level, reasons, rationale: describe(reasons, q) };
266
+ if (resolveTo !== undefined) {
267
+ result.resolveTo = resolveTo;
268
+ }
269
+ if (collectSuggestions && level !== NoteSafety.Safe) {
270
+ const suggestions = findSafeNearby(q, pitch);
271
+ if (suggestions.length > 0) {
272
+ result.suggestions = suggestions;
273
+ }
274
+ }
275
+ return result;
276
+ }
277
+
278
+ /**
279
+ * Find safe alternative pitches near a rejected candidate.
280
+ *
281
+ * Searches outward from the candidate within `SUGGESTION_WINDOW` semitones,
282
+ * probing the pitch below before the one above at each distance so the result
283
+ * is deterministic and ordered by nearness.
284
+ *
285
+ * @param q The safety context of the rejected candidate.
286
+ * @param candidate The candidate pitch that was not Safe.
287
+ * @returns Up to `MAX_SUGGESTIONS` safe pitches, nearest first.
288
+ */
289
+ function findSafeNearby(q: SafetyQuery, candidate: number): number[] {
290
+ const out: number[] = [];
291
+ for (let d = 1; d <= SUGGESTION_WINDOW; d += 1) {
292
+ for (const p of [candidate - d, candidate + d]) {
293
+ if (evaluateInternal({ ...q, candidatePitch: p }, false).safety === NoteSafety.Safe) {
294
+ out.push(p);
295
+ if (out.length >= MAX_SUGGESTIONS) {
296
+ return out;
297
+ }
298
+ }
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** Build a short human explanation from the reason flags. */
305
+ function describe(reasons: number, q: SafetyQuery): string {
306
+ if (reasons & ReasonFlag.OutOfRange) {
307
+ return 'Outside the target vocal range';
308
+ }
309
+ if (reasons & ReasonFlag.Suspension) {
310
+ return 'Prepared suspension — a held consonance now dissonant, awaiting resolution';
311
+ }
312
+ if (reasons & ReasonFlag.VerticalDissonance) {
313
+ return 'Dissonant against a sounding voice on a strong beat';
314
+ }
315
+ if (reasons & ReasonFlag.AvoidNote) {
316
+ return 'Avoid note — clashes with a chord tone a semitone away';
317
+ }
318
+ if (reasons & ReasonFlag.ParallelPerfect) {
319
+ return 'Parallel perfect interval with another voice';
320
+ }
321
+ if (reasons & ReasonFlag.HiddenParallel) {
322
+ return 'Hidden perfect interval by similar motion';
323
+ }
324
+ if (reasons & ReasonFlag.VoiceCrossing) {
325
+ return 'Crosses another voice';
326
+ }
327
+ if (reasons & ReasonFlag.ChordTone) {
328
+ return 'Chord tone';
329
+ }
330
+ if (reasons & ReasonFlag.Tension) {
331
+ return 'Chord tension';
332
+ }
333
+ if (reasons & ReasonFlag.NonScale) {
334
+ return 'Outside the key';
335
+ }
336
+ if (reasons & ReasonFlag.ScaleTone) {
337
+ return 'Scale tone, not in the chord';
338
+ }
339
+ return q.chord ? 'Placeable' : 'No chord context';
340
+ }
341
+
342
+ /**
343
+ * Enumerate placeable pitches in a range, chord tones first, descending.
344
+ *
345
+ * @param q The safety context minus the candidate pitch.
346
+ * @param pitchLow Lowest MIDI pitch to consider (inclusive).
347
+ * @param pitchHigh Highest MIDI pitch to consider (inclusive).
348
+ * @returns Placeable pitches (non-dissonant), chord tones before others, each group descending.
349
+ */
350
+ export function enumerateSafePitches(
351
+ q: Omit<SafetyQuery, 'candidatePitch'>,
352
+ pitchLow: number,
353
+ pitchHigh: number,
354
+ ): number[] {
355
+ const chordTones: number[] = [];
356
+ const others: number[] = [];
357
+ if (!Number.isFinite(pitchLow) || !Number.isFinite(pitchHigh)) {
358
+ return [];
359
+ }
360
+ for (let pitch = pitchHigh; pitch >= pitchLow; pitch -= 1) {
361
+ const result = evaluateInternal({ ...q, candidatePitch: pitch }, false);
362
+ if (result.safety === NoteSafety.Dissonant) {
363
+ continue;
364
+ }
365
+ if (result.reasons & ReasonFlag.ChordTone) {
366
+ chordTones.push(pitch);
367
+ } else {
368
+ others.push(pitch);
369
+ }
370
+ }
371
+ return [...chordTones, ...others];
372
+ }