@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.
- package/README.md +10 -2
- package/dist/index.cjs +577 -225
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1533 -172
- package/dist/index.d.ts +1533 -172
- package/dist/index.js +577 -225
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/analysis/index.ts +73 -7
- package/src/arrange/index.ts +241 -79
- package/src/bass/index.ts +61 -12
- package/src/chord/index.ts +66 -3
- package/src/chordscale/index.ts +67 -9
- package/src/countermelody/index.ts +44 -14
- package/src/counterpoint/index.ts +21 -0
- package/src/detect/index.ts +79 -20
- package/src/drums/fills.ts +31 -20
- package/src/drums/index.ts +125 -7
- package/src/drums/internal.ts +7 -1
- package/src/drums/swing.ts +7 -2
- package/src/functional/index.ts +160 -51
- package/src/groove/index.ts +116 -20
- package/src/harmonize/index.ts +85 -12
- package/src/harmony/index.ts +19 -2
- package/src/index.ts +1 -0
- package/src/interval/index.ts +15 -1
- package/src/meter/index.ts +108 -5
- package/src/model/index.ts +161 -12
- package/src/motif/index.ts +87 -10
- package/src/pitch/index.ts +58 -3
- package/src/progression/index.ts +56 -8
- package/src/random/index.ts +13 -1
- package/src/reharmony/index.ts +35 -4
- package/src/rhythm/index.ts +45 -11
- package/src/safety/index.ts +58 -11
- package/src/scale/index.ts +135 -19
- package/src/spelling/index.ts +18 -0
- package/src/symbol/index.ts +84 -36
- package/src/timeline/index.ts +80 -13
- package/src/tuning/index.ts +28 -1
- package/src/voicing/index.ts +121 -21
package/src/model/index.ts
CHANGED
|
@@ -81,7 +81,14 @@ function copyNote(data: NoteData): NoteData {
|
|
|
81
81
|
return copy;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Defensive copy of a plain chord.
|
|
86
|
+
*
|
|
87
|
+
* Any enharmonic spelling hints (`rootSpelling`/`bassSpelling`, populated by
|
|
88
|
+
* `parseChordSymbol`) are carried through so a flat-named chord round-trips
|
|
89
|
+
* through the class API; a hint that no longer matches its pitch class is
|
|
90
|
+
* simply ignored by the formatter.
|
|
91
|
+
*/
|
|
85
92
|
function copyChord(data: ChordData): ChordData {
|
|
86
93
|
const copy: ChordData = {
|
|
87
94
|
rootPc: data.rootPc,
|
|
@@ -91,6 +98,12 @@ function copyChord(data: ChordData): ChordData {
|
|
|
91
98
|
if (data.bassPc !== undefined) {
|
|
92
99
|
copy.bassPc = data.bassPc;
|
|
93
100
|
}
|
|
101
|
+
if (data.rootSpelling !== undefined) {
|
|
102
|
+
copy.rootSpelling = { letter: data.rootSpelling.letter, alter: data.rootSpelling.alter };
|
|
103
|
+
}
|
|
104
|
+
if (data.bassSpelling !== undefined) {
|
|
105
|
+
copy.bassSpelling = { letter: data.bassSpelling.letter, alter: data.bassSpelling.alter };
|
|
106
|
+
}
|
|
94
107
|
return copy;
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -104,6 +117,13 @@ function spellPitchClassBare(pc: number, spelling: 'sharp' | 'flat'): NoteData {
|
|
|
104
117
|
* An immutable spelled note: a diatonic letter plus a chromatic alteration and
|
|
105
118
|
* an optional octave. Wraps the plain note object and delegates to the pitch
|
|
106
119
|
* module; every transformation returns a new instance.
|
|
120
|
+
*
|
|
121
|
+
* @category Class API
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* import { Note } from '@libraz/libcantus';
|
|
125
|
+
* Note.of('C4').transpose(7).name; // 'G4'
|
|
126
|
+
* ```
|
|
107
127
|
*/
|
|
108
128
|
export class Note {
|
|
109
129
|
readonly #data: NoteData;
|
|
@@ -196,12 +216,16 @@ export class Note {
|
|
|
196
216
|
*
|
|
197
217
|
* When the note carries an octave the transposition happens in MIDI space and
|
|
198
218
|
* the result is spelled with a sharp preference. An octave-less note stays
|
|
199
|
-
* octave-less: only its pitch class is moved.
|
|
219
|
+
* octave-less: only its pitch class is moved. Transposing by zero is the
|
|
220
|
+
* identity: the original spelling is preserved (no enharmonic respelling).
|
|
200
221
|
*
|
|
201
222
|
* @param semitones The signed semitone offset.
|
|
202
223
|
* @returns The transposed note.
|
|
203
224
|
*/
|
|
204
225
|
transpose(semitones: number): Note {
|
|
226
|
+
if (semitones === 0) {
|
|
227
|
+
return new Note(this.#data);
|
|
228
|
+
}
|
|
205
229
|
if (this.#data.octave !== undefined) {
|
|
206
230
|
return Note.fromMidi(this.midi + semitones);
|
|
207
231
|
}
|
|
@@ -231,12 +255,31 @@ export class Note {
|
|
|
231
255
|
this.#data.octave === other.#data.octave
|
|
232
256
|
);
|
|
233
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The plain note data, for JSON serialization.
|
|
261
|
+
*
|
|
262
|
+
* Private class fields do not serialize, so an explicit `toJSON` keeps
|
|
263
|
+
* `JSON.stringify(note)` from collapsing to `{}`.
|
|
264
|
+
*
|
|
265
|
+
* @returns A copy of the underlying plain note object.
|
|
266
|
+
*/
|
|
267
|
+
toJSON(): NoteData {
|
|
268
|
+
return this.data;
|
|
269
|
+
}
|
|
234
270
|
}
|
|
235
271
|
|
|
236
272
|
/**
|
|
237
273
|
* An immutable spelled interval value: a diatonic number, a quality label, and
|
|
238
274
|
* a signed semitone span. A thin convenience wrapper over the pitch module's
|
|
239
275
|
* plain interval result.
|
|
276
|
+
*
|
|
277
|
+
* @category Class API
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* import { Interval, Note } from '@libraz/libcantus';
|
|
281
|
+
* Interval.between(Note.of('C4'), Note.of('G4')).name; // 'P5'
|
|
282
|
+
* ```
|
|
240
283
|
*/
|
|
241
284
|
export class Interval {
|
|
242
285
|
readonly #number: number;
|
|
@@ -292,6 +335,18 @@ export class Interval {
|
|
|
292
335
|
get name(): string {
|
|
293
336
|
return `${this.#quality}${this.#number}`;
|
|
294
337
|
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The plain interval data, for JSON serialization.
|
|
341
|
+
*
|
|
342
|
+
* Private class fields do not serialize, so an explicit `toJSON` keeps
|
|
343
|
+
* `JSON.stringify(interval)` from collapsing to `{}`.
|
|
344
|
+
*
|
|
345
|
+
* @returns The diatonic number, quality, and semitone span.
|
|
346
|
+
*/
|
|
347
|
+
toJSON(): SpelledInterval {
|
|
348
|
+
return { number: this.#number, quality: this.#quality, semitones: this.#semitones };
|
|
349
|
+
}
|
|
295
350
|
}
|
|
296
351
|
|
|
297
352
|
/** Resolve a string or numeric key root into a spelled tonic note. */
|
|
@@ -299,10 +354,38 @@ function resolveTonic(root: string | number, spelling: 'sharp' | 'flat'): Note {
|
|
|
299
354
|
return typeof root === 'string' ? Note.of(root) : new Note(spellPitchClassBare(root, spelling));
|
|
300
355
|
}
|
|
301
356
|
|
|
357
|
+
/** Total accidentals a spelled tonic produces across a key's whole scale. */
|
|
358
|
+
function accidentalLoad(tonic: NoteData, scale: KeyScale): number {
|
|
359
|
+
return spellScale(tonic, scale).reduce((sum, note) => sum + Math.abs(note.alter), 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Choose the tonic spelling (sharp- or flat-side) that spells `scale` with the
|
|
364
|
+
* fewest accidentals, so a numeric root never yields a double-flat/double-sharp
|
|
365
|
+
* scale (e.g. pitch class 6 minor spells as F# minor, not Gb minor with Bbb).
|
|
366
|
+
*/
|
|
367
|
+
function bestTonicForScale(rootPc: number, scale: KeyScale): Note {
|
|
368
|
+
const sharp = spellPitchClassBare(rootPc, 'sharp');
|
|
369
|
+
const flat = spellPitchClassBare(rootPc, 'flat');
|
|
370
|
+
if (sharp.letter === flat.letter && sharp.alter === flat.alter) {
|
|
371
|
+
return new Note(sharp);
|
|
372
|
+
}
|
|
373
|
+
return accidentalLoad(flat, scale) <= accidentalLoad(sharp, scale)
|
|
374
|
+
? new Note(flat)
|
|
375
|
+
: new Note(sharp);
|
|
376
|
+
}
|
|
377
|
+
|
|
302
378
|
/**
|
|
303
379
|
* An immutable key/scale: a `KeyScale` (root pitch class plus mode mask) paired
|
|
304
380
|
* with a spelled tonic that anchors letter-name spelling. Acts as the factory
|
|
305
381
|
* for key-aware chords.
|
|
382
|
+
*
|
|
383
|
+
* @category Class API
|
|
384
|
+
* @example
|
|
385
|
+
* ```ts
|
|
386
|
+
* import { Key } from '@libraz/libcantus';
|
|
387
|
+
* Key.major('C').chord(4).symbol(); // 'G' (the diatonic triad on scale degree 4)
|
|
388
|
+
* ```
|
|
306
389
|
*/
|
|
307
390
|
export class Key {
|
|
308
391
|
readonly #scale: KeyScale;
|
|
@@ -322,25 +405,35 @@ export class Key {
|
|
|
322
405
|
/**
|
|
323
406
|
* A major key.
|
|
324
407
|
*
|
|
325
|
-
* @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; numeric
|
|
326
|
-
*
|
|
408
|
+
* @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; a numeric
|
|
409
|
+
* root is spelled with whichever accidental side yields the fewest
|
|
410
|
+
* accidentals across the scale.
|
|
327
411
|
* @returns The major key.
|
|
328
412
|
*/
|
|
329
413
|
static major(root: string | number): Key {
|
|
330
|
-
|
|
331
|
-
|
|
414
|
+
if (typeof root === 'string') {
|
|
415
|
+
const tonic = Note.of(root);
|
|
416
|
+
return new Key(majorKey(tonic.pitchClass), tonic);
|
|
417
|
+
}
|
|
418
|
+
const scale = majorKey(root);
|
|
419
|
+
return new Key(scale, bestTonicForScale(root, scale));
|
|
332
420
|
}
|
|
333
421
|
|
|
334
422
|
/**
|
|
335
423
|
* A natural-minor key.
|
|
336
424
|
*
|
|
337
|
-
* @param root Tonic as a note name or a pitch class; numeric
|
|
338
|
-
* spelled with
|
|
425
|
+
* @param root Tonic as a note name or a pitch class; a numeric root is
|
|
426
|
+
* spelled with whichever accidental side yields the fewest accidentals
|
|
427
|
+
* across the scale.
|
|
339
428
|
* @returns The minor key.
|
|
340
429
|
*/
|
|
341
430
|
static minor(root: string | number): Key {
|
|
342
|
-
|
|
343
|
-
|
|
431
|
+
if (typeof root === 'string') {
|
|
432
|
+
const tonic = Note.of(root);
|
|
433
|
+
return new Key(minorKey(tonic.pitchClass), tonic);
|
|
434
|
+
}
|
|
435
|
+
const scale = minorKey(root);
|
|
436
|
+
return new Key(scale, bestTonicForScale(root, scale));
|
|
344
437
|
}
|
|
345
438
|
|
|
346
439
|
/**
|
|
@@ -486,6 +579,20 @@ export class Key {
|
|
|
486
579
|
contains(x: number | Note): boolean {
|
|
487
580
|
return isScaleTone(typeof x === 'number' ? x : x.pitchClass, this.#scale);
|
|
488
581
|
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The plain key data, for JSON serialization.
|
|
585
|
+
*
|
|
586
|
+
* Private class fields do not serialize, so an explicit `toJSON` keeps
|
|
587
|
+
* `JSON.stringify(key)` from collapsing to `{}`. The result pairs the
|
|
588
|
+
* `KeyScale` with the spelled tonic, enough to reconstruct the key via
|
|
589
|
+
* {@link Key.of}.
|
|
590
|
+
*
|
|
591
|
+
* @returns The key/scale and its spelled tonic.
|
|
592
|
+
*/
|
|
593
|
+
toJSON(): { scale: KeyScale; tonic: NoteData } {
|
|
594
|
+
return { scale: this.scale, tonic: this.#tonic.data };
|
|
595
|
+
}
|
|
489
596
|
}
|
|
490
597
|
|
|
491
598
|
/**
|
|
@@ -493,6 +600,13 @@ export class Key {
|
|
|
493
600
|
* optional slash bass, optionally carrying a {@link Key} context. Analysis
|
|
494
601
|
* methods (`roman`, `function`, `analyze`, ...) use an explicitly passed key
|
|
495
602
|
* first and fall back to the carried context.
|
|
603
|
+
*
|
|
604
|
+
* @category Class API
|
|
605
|
+
* @example
|
|
606
|
+
* ```ts
|
|
607
|
+
* import { Chord } from '@libraz/libcantus';
|
|
608
|
+
* Chord.parse('Cmaj7').invert(1).symbol(); // 'Cmaj7/E' (third in the bass)
|
|
609
|
+
* ```
|
|
496
610
|
*/
|
|
497
611
|
export class Chord {
|
|
498
612
|
readonly #data: ChordData;
|
|
@@ -712,7 +826,10 @@ export class Chord {
|
|
|
712
826
|
* @throws If no key is given and none is carried.
|
|
713
827
|
*/
|
|
714
828
|
negativeHarmony(key?: Key): Chord {
|
|
715
|
-
|
|
829
|
+
const resolved = this.#resolveKey(key);
|
|
830
|
+
// Retain the key that anchored the reflection (explicit first, then carried)
|
|
831
|
+
// so a later no-arg analysis method still has a key context.
|
|
832
|
+
return new Chord(negativeHarmonyMirror(this.#data, resolved.scale), key ?? this.#key);
|
|
716
833
|
}
|
|
717
834
|
|
|
718
835
|
/**
|
|
@@ -720,6 +837,9 @@ export class Chord {
|
|
|
720
837
|
* root in the interval template (`invert(1)` puts the third in the bass).
|
|
721
838
|
* `n` wraps around the template length; negative values count backwards.
|
|
722
839
|
*
|
|
840
|
+
* `invert(0)` (and any `n` that wraps to it) is root position, so it carries
|
|
841
|
+
* no slash bass and equals the original chord.
|
|
842
|
+
*
|
|
723
843
|
* @param n The inversion number.
|
|
724
844
|
* @returns The inverted chord, keeping any key context.
|
|
725
845
|
* @throws If the chord has no intervals.
|
|
@@ -732,7 +852,11 @@ export class Chord {
|
|
|
732
852
|
}
|
|
733
853
|
const index = ((n % length) + length) % length;
|
|
734
854
|
const data = copyChord(this.#data);
|
|
735
|
-
|
|
855
|
+
if (index === 0) {
|
|
856
|
+
delete data.bassPc;
|
|
857
|
+
} else {
|
|
858
|
+
data.bassPc = mod12(this.#data.rootPc + (intervals[index] ?? 0));
|
|
859
|
+
}
|
|
736
860
|
return new Chord(data, this.#key);
|
|
737
861
|
}
|
|
738
862
|
|
|
@@ -835,6 +959,15 @@ export class Chord {
|
|
|
835
959
|
/**
|
|
836
960
|
* An immutable ordered sequence of chords, optionally carrying a {@link Key}
|
|
837
961
|
* context shared by its analysis methods.
|
|
962
|
+
*
|
|
963
|
+
* @category Class API
|
|
964
|
+
* @example
|
|
965
|
+
* ```ts
|
|
966
|
+
* import { Key } from '@libraz/libcantus';
|
|
967
|
+
* const key = Key.major('C');
|
|
968
|
+
* key.chord(1).progressionTo(key.chord(4), key.chord(0)).roman();
|
|
969
|
+
* // ['ii', 'V', 'I']
|
|
970
|
+
* ```
|
|
838
971
|
*/
|
|
839
972
|
export class Progression {
|
|
840
973
|
readonly #chords: readonly Chord[];
|
|
@@ -946,6 +1079,22 @@ export class Progression {
|
|
|
946
1079
|
return { chords, cadence };
|
|
947
1080
|
}
|
|
948
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* The plain progression data, for JSON serialization.
|
|
1084
|
+
*
|
|
1085
|
+
* Private class fields do not serialize, so an explicit `toJSON` keeps
|
|
1086
|
+
* `JSON.stringify(progression)` from collapsing to `{}`. The chords are
|
|
1087
|
+
* emitted as plain data and the carried key, when present, as its own data.
|
|
1088
|
+
*
|
|
1089
|
+
* @returns The chord data sequence and the carried key, if any.
|
|
1090
|
+
*/
|
|
1091
|
+
toJSON(): { chords: ChordData[]; key: { scale: KeyScale; tonic: NoteData } | undefined } {
|
|
1092
|
+
return {
|
|
1093
|
+
chords: this.#chords.map((chord) => chord.data),
|
|
1094
|
+
key: this.#key?.toJSON(),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
949
1098
|
/** Resolve the key for an analysis method: explicit first, then carried. */
|
|
950
1099
|
#resolveKey(key?: Key): Key {
|
|
951
1100
|
const resolved = key ?? this.#key;
|
package/src/motif/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { isScaleTone, nearestScaleTone } from '../scale/index.js';
|
|
|
4
4
|
import type { ChordTimeline } from '../timeline/index.js';
|
|
5
5
|
import type { KeyScale } from '../types.js';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A transformation applicable to a motif cell.
|
|
9
|
+
*
|
|
10
|
+
* @category Composition
|
|
11
|
+
*/
|
|
8
12
|
export type MotifTransform =
|
|
9
13
|
| 'transposeDiatonic'
|
|
10
14
|
| 'transposeChromatic'
|
|
@@ -14,27 +18,62 @@ export type MotifTransform =
|
|
|
14
18
|
| 'diminish'
|
|
15
19
|
| 'sequence';
|
|
16
20
|
|
|
17
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A single note within a motif cell.
|
|
23
|
+
*
|
|
24
|
+
* @category Composition
|
|
25
|
+
*/
|
|
18
26
|
export type MotifNote = {
|
|
19
27
|
pitch: number;
|
|
20
28
|
startBeat: number;
|
|
21
29
|
durationBeat: number;
|
|
22
30
|
};
|
|
23
31
|
|
|
24
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* A short melodic cell.
|
|
34
|
+
*
|
|
35
|
+
* @category Composition
|
|
36
|
+
*/
|
|
25
37
|
export type MotifCell = {
|
|
26
38
|
notes: MotifNote[];
|
|
27
39
|
};
|
|
28
40
|
|
|
29
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Melodic contour shape for {@link generateMotif}.
|
|
43
|
+
*
|
|
44
|
+
* @category Composition
|
|
45
|
+
*/
|
|
30
46
|
export type MotifContour = 'arch' | 'ascending' | 'descending' | 'wave';
|
|
31
47
|
|
|
32
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Options controlling {@link generateMotif}.
|
|
50
|
+
*
|
|
51
|
+
* @category Composition
|
|
52
|
+
*/
|
|
33
53
|
export type GenerateMotifOptions = {
|
|
34
54
|
key: KeyScale;
|
|
35
55
|
chord?: Chord | null;
|
|
36
56
|
bars: number;
|
|
57
|
+
/**
|
|
58
|
+
* Melodic contour shape the line follows.
|
|
59
|
+
*
|
|
60
|
+
* @defaultValue `'arch'`
|
|
61
|
+
*/
|
|
37
62
|
contour?: MotifContour;
|
|
63
|
+
/**
|
|
64
|
+
* Probability in [0, 1] that a note is nudged by a single diatonic step. The
|
|
65
|
+
* nudge direction is balanced (up or down with equal odds), so it adds
|
|
66
|
+
* variety without biasing the line off its contour. Default 0, which
|
|
67
|
+
* reproduces the requested contour exactly (no drift, tails return to tonic).
|
|
68
|
+
*
|
|
69
|
+
* @defaultValue 0
|
|
70
|
+
*/
|
|
71
|
+
jitter?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Seed for the deterministic PRNG.
|
|
74
|
+
*
|
|
75
|
+
* @defaultValue 0
|
|
76
|
+
*/
|
|
38
77
|
seed?: number;
|
|
39
78
|
};
|
|
40
79
|
|
|
@@ -126,7 +165,6 @@ function mulberry32(seed: number): () => number {
|
|
|
126
165
|
/** Diatonic scale-degree offsets shaping a contour of `count` notes. */
|
|
127
166
|
function contourOffsets(contour: MotifContour, count: number): number[] {
|
|
128
167
|
const offsets: number[] = [];
|
|
129
|
-
const half = Math.floor(count / 2);
|
|
130
168
|
for (let i = 0; i < count; i += 1) {
|
|
131
169
|
switch (contour) {
|
|
132
170
|
case 'ascending':
|
|
@@ -136,7 +174,9 @@ function contourOffsets(contour: MotifContour, count: number): number[] {
|
|
|
136
174
|
offsets.push(-i);
|
|
137
175
|
break;
|
|
138
176
|
case 'arch':
|
|
139
|
-
|
|
177
|
+
// Symmetric rise-and-fall: a palindrome that starts and ends on the
|
|
178
|
+
// tonic (offset 0) for both odd and even lengths.
|
|
179
|
+
offsets.push(Math.min(i, count - 1 - i));
|
|
140
180
|
break;
|
|
141
181
|
case 'wave':
|
|
142
182
|
offsets.push([0, 1, 0, -1][i % 4] ?? 0);
|
|
@@ -151,10 +191,21 @@ function contourOffsets(contour: MotifContour, count: number): number[] {
|
|
|
151
191
|
*
|
|
152
192
|
* The melody follows the requested contour in diatonic steps from the tonic,
|
|
153
193
|
* snapped to the key. When a chord is supplied, notes landing on bar downbeats
|
|
154
|
-
* are pulled to the nearest chord tone.
|
|
194
|
+
* are pulled to the nearest chord tone. An optional `jitter` adds balanced
|
|
195
|
+
* per-note variation without biasing the contour; it is off by default, so the
|
|
196
|
+
* contour is reproduced exactly. Output is deterministic for a given seed.
|
|
155
197
|
*
|
|
156
|
-
* @param opts Key, optional chord, length, contour, and seed.
|
|
198
|
+
* @param opts Key, optional chord, length, contour, jitter, and seed.
|
|
157
199
|
* @returns The generated motif cell.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```ts
|
|
203
|
+
* import { generateMotif, majorKey } from '@libraz/libcantus';
|
|
204
|
+
* const cell = generateMotif({ key: majorKey(0), bars: 2, contour: 'arch' });
|
|
205
|
+
* // cell.notes is a deterministic MotifCell (seed defaults to 0)
|
|
206
|
+
* ```
|
|
207
|
+
*
|
|
208
|
+
* @category Composition
|
|
158
209
|
*/
|
|
159
210
|
export function generateMotif(opts: GenerateMotifOptions): MotifCell {
|
|
160
211
|
const contour = opts.contour ?? 'arch';
|
|
@@ -163,12 +214,19 @@ export function generateMotif(opts: GenerateMotifOptions): MotifCell {
|
|
|
163
214
|
const noteCount = Math.max(3, bars * 2);
|
|
164
215
|
const beatsPerNote = totalBeats / noteCount;
|
|
165
216
|
const rng = mulberry32(opts.seed ?? 0);
|
|
217
|
+
const jitterProb = Math.min(1, Math.max(0, opts.jitter ?? 0));
|
|
166
218
|
const tonic = pitchClass(opts.key.rootPc) + 60;
|
|
167
219
|
const offsets = contourOffsets(contour, noteCount);
|
|
168
220
|
|
|
169
221
|
const notes: MotifNote[] = [];
|
|
170
222
|
for (let i = 0; i < noteCount; i += 1) {
|
|
171
|
-
|
|
223
|
+
// Opt-in, direction-balanced jitter: disabled by default so the contour is
|
|
224
|
+
// preserved exactly. When enabled it nudges up or down with equal odds,
|
|
225
|
+
// avoiding the upward bias that used to drift arch/wave tails off the tonic.
|
|
226
|
+
let jitter = 0;
|
|
227
|
+
if (jitterProb > 0 && rng() < jitterProb) {
|
|
228
|
+
jitter = rng() < 0.5 ? 1 : -1;
|
|
229
|
+
}
|
|
172
230
|
let pitch = stepDiatonic(tonic, (offsets[i] ?? 0) + jitter, opts.key);
|
|
173
231
|
const startBeat = i * beatsPerNote;
|
|
174
232
|
if (opts.chord && startBeat % 4 === 0) {
|
|
@@ -197,6 +255,15 @@ export function generateMotif(opts: GenerateMotifOptions): MotifCell {
|
|
|
197
255
|
* @param key Key context for the diatonic transforms; without it,
|
|
198
256
|
* `transposeDiatonic` and `sequence` shift chromatically by semitones.
|
|
199
257
|
* @returns The transformed cell.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* import { generateMotif, transformMotif, majorKey } from '@libraz/libcantus';
|
|
262
|
+
* const cell = generateMotif({ key: majorKey(0), bars: 1 });
|
|
263
|
+
* const inverted = transformMotif(cell, 'invert');
|
|
264
|
+
* ```
|
|
265
|
+
*
|
|
266
|
+
* @category Composition
|
|
200
267
|
*/
|
|
201
268
|
export function transformMotif(
|
|
202
269
|
cell: MotifCell,
|
|
@@ -279,6 +346,16 @@ function scaleTime(cell: MotifCell, factor: number): MotifCell {
|
|
|
279
346
|
* @param key Key context (used to keep snapped pitches sensible).
|
|
280
347
|
* @param bars Number of four-beat bars to fill.
|
|
281
348
|
* @returns The developed, harmony-aware cell.
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* ```ts
|
|
352
|
+
* import { chordTimelineFromChords, developMotif, generateMotif, majorKey } from '@libraz/libcantus';
|
|
353
|
+
* const key = majorKey(0);
|
|
354
|
+
* const timeline = chordTimelineFromChords([{ rootPc: 0, quality: 'maj', startBeat: 0 }], 8);
|
|
355
|
+
* const developed = developMotif(generateMotif({ key, bars: 1 }), timeline, key, 2);
|
|
356
|
+
* ```
|
|
357
|
+
*
|
|
358
|
+
* @category Composition
|
|
282
359
|
*/
|
|
283
360
|
export function developMotif(
|
|
284
361
|
cell: MotifCell,
|
package/src/pitch/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ const LETTER_NAMES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const;
|
|
|
24
24
|
* 0 natural, +1 sharp, +2 double-sharp.
|
|
25
25
|
* - `octave` follows scientific pitch notation (middle C = C4 = MIDI 60). When
|
|
26
26
|
* absent, the note denotes a bare pitch class.
|
|
27
|
+
*
|
|
28
|
+
* @category Pitch & Intervals
|
|
27
29
|
*/
|
|
28
30
|
export type Note = {
|
|
29
31
|
letter: number;
|
|
@@ -31,7 +33,11 @@ export type Note = {
|
|
|
31
33
|
octave?: number;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* A spelled interval: a diatonic number, a quality label, and its semitone span.
|
|
38
|
+
*
|
|
39
|
+
* @category Pitch & Intervals
|
|
40
|
+
*/
|
|
35
41
|
export type SpelledInterval = {
|
|
36
42
|
/** Diatonic size: 1 = unison, 2 = second, ... 8 = octave, and beyond. */
|
|
37
43
|
number: number;
|
|
@@ -62,6 +68,13 @@ function mod7(n: number): number {
|
|
|
62
68
|
* @param text The note text.
|
|
63
69
|
* @returns The parsed note.
|
|
64
70
|
* @throws If the text is not a valid note.
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { parseNote, noteToMidi } from '@libraz/libcantus';
|
|
74
|
+
* const n = parseNote('C#4');
|
|
75
|
+
* noteToMidi(n); // 61
|
|
76
|
+
* ```
|
|
77
|
+
* @category Pitch & Intervals
|
|
65
78
|
*/
|
|
66
79
|
export function parseNote(text: string): Note {
|
|
67
80
|
const match = /^([A-Ga-g])([#x]*|b*)(-?\d+)?$/.exec(text.trim());
|
|
@@ -103,6 +116,12 @@ function formatAlter(alter: number): string {
|
|
|
103
116
|
*
|
|
104
117
|
* @param note The note to format.
|
|
105
118
|
* @returns The note text, including the octave when present.
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* import { formatNote, parseNote } from '@libraz/libcantus';
|
|
122
|
+
* formatNote(parseNote('C#4')); // 'C#4'
|
|
123
|
+
* ```
|
|
124
|
+
* @category Pitch & Intervals
|
|
106
125
|
*/
|
|
107
126
|
export function formatNote(note: Note): string {
|
|
108
127
|
const glyph = LETTER_NAMES[mod7(note.letter)] ?? 'C';
|
|
@@ -115,6 +134,12 @@ export function formatNote(note: Note): string {
|
|
|
115
134
|
*
|
|
116
135
|
* @param note The note.
|
|
117
136
|
* @returns The pitch class.
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* import { noteToPitchClass, parseNote } from '@libraz/libcantus';
|
|
140
|
+
* noteToPitchClass(parseNote('Db')); // 1
|
|
141
|
+
* ```
|
|
142
|
+
* @category Pitch & Intervals
|
|
118
143
|
*/
|
|
119
144
|
export function noteToPitchClass(note: Note): number {
|
|
120
145
|
const natural = LETTER_SEMITONES[mod7(note.letter)] ?? 0;
|
|
@@ -127,6 +152,12 @@ export function noteToPitchClass(note: Note): number {
|
|
|
127
152
|
* @param note The note; must carry an octave.
|
|
128
153
|
* @returns The MIDI number.
|
|
129
154
|
* @throws If the note has no octave.
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* import { noteToMidi, parseNote } from '@libraz/libcantus';
|
|
158
|
+
* noteToMidi(parseNote('A4')); // 69
|
|
159
|
+
* ```
|
|
160
|
+
* @category Pitch & Intervals
|
|
130
161
|
*/
|
|
131
162
|
export function noteToMidi(note: Note): number {
|
|
132
163
|
if (note.octave === undefined) {
|
|
@@ -170,9 +201,20 @@ const FLAT_SPELLING: readonly [number, number][] = [
|
|
|
170
201
|
/**
|
|
171
202
|
* Name a MIDI number as a {@link Note}, choosing sharp or flat spelling.
|
|
172
203
|
*
|
|
204
|
+
* The input is rounded to the nearest integer but is not clamped to the MIDI
|
|
205
|
+
* range 0..127: out-of-range values extrapolate linearly (e.g. `-1` -> `B-2`,
|
|
206
|
+
* `128` -> `G#9`) and remain an exact inverse of {@link noteToMidi}.
|
|
207
|
+
*
|
|
173
208
|
* @param midi The MIDI number.
|
|
174
209
|
* @param spelling Whether to prefer sharps or flats for black keys.
|
|
175
210
|
* @returns The spelled note, with octave.
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { midiToNote, formatNote } from '@libraz/libcantus';
|
|
214
|
+
* formatNote(midiToNote(61)); // 'C#4'
|
|
215
|
+
* formatNote(midiToNote(61, 'flat')); // 'Db4'
|
|
216
|
+
* ```
|
|
217
|
+
* @category Pitch & Intervals
|
|
176
218
|
*/
|
|
177
219
|
export function midiToNote(midi: number, spelling: 'sharp' | 'flat' = 'sharp'): Note {
|
|
178
220
|
const rounded = Math.round(midi);
|
|
@@ -227,6 +269,13 @@ function diatonicIndex(note: Note): number {
|
|
|
227
269
|
* @param a The lower/first note.
|
|
228
270
|
* @param b The upper/second note.
|
|
229
271
|
* @returns The diatonic number, quality, and semitone span.
|
|
272
|
+
* @example
|
|
273
|
+
* ```ts
|
|
274
|
+
* import { spelledInterval, parseNote } from '@libraz/libcantus';
|
|
275
|
+
* spelledInterval(parseNote('C4'), parseNote('G4'));
|
|
276
|
+
* // { number: 5, quality: 'P', semitones: 7 }
|
|
277
|
+
* ```
|
|
278
|
+
* @category Pitch & Intervals
|
|
230
279
|
*/
|
|
231
280
|
export function spelledInterval(a: Note, b: Note): SpelledInterval {
|
|
232
281
|
const octaved = a.octave !== undefined && b.octave !== undefined;
|
|
@@ -240,11 +289,17 @@ export function spelledInterval(a: Note, b: Note): SpelledInterval {
|
|
|
240
289
|
letterSteps = mod7(b.letter - a.letter);
|
|
241
290
|
let rawSemis = mod12(noteToPitchClass(b) - noteToPitchClass(a));
|
|
242
291
|
// Lift the chromatic span into the octave nearest the diatonic reference so
|
|
243
|
-
// wraparound intervals
|
|
244
|
-
//
|
|
292
|
+
// wraparound intervals stay consistent with the diatonic number instead of
|
|
293
|
+
// collapsing modulo 12. The correction is bidirectional: an upward wrap
|
|
294
|
+
// (e.g. Ab -> G# = augmented seventh) adds an octave, while a same-letter or
|
|
295
|
+
// downward chromatic step (e.g. E -> Eb, F# -> F, C -> Cb = descending
|
|
296
|
+
// diminished/augmented unison) subtracts one so the sign follows pitch
|
|
297
|
+
// direction instead of returning a garbage stack of augmentations.
|
|
245
298
|
const reference = SIMPLE_REFERENCE[letterSteps + 1] ?? 0;
|
|
246
299
|
if (reference - rawSemis > 6) {
|
|
247
300
|
rawSemis += 12;
|
|
301
|
+
} else if (rawSemis - reference > 6) {
|
|
302
|
+
rawSemis -= 12;
|
|
248
303
|
}
|
|
249
304
|
semitones = rawSemis;
|
|
250
305
|
}
|