@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.
- package/LICENSE +201 -0
- package/README.md +291 -0
- package/dist/index.cjs +6465 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2621 -0
- package/dist/index.d.ts +2621 -0
- package/dist/index.js +6297 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
- package/src/analysis/index.ts +261 -0
- package/src/arrange/index.ts +409 -0
- package/src/bass/index.ts +301 -0
- package/src/chord/index.ts +292 -0
- package/src/chordscale/index.ts +316 -0
- package/src/countermelody/index.ts +349 -0
- package/src/counterpoint/index.ts +209 -0
- package/src/detect/index.ts +176 -0
- package/src/drums/beat.ts +266 -0
- package/src/drums/euclid.ts +58 -0
- package/src/drums/fills.ts +345 -0
- package/src/drums/ghost.ts +119 -0
- package/src/drums/hihat.ts +285 -0
- package/src/drums/hit.ts +31 -0
- package/src/drums/index.ts +211 -0
- package/src/drums/internal.ts +251 -0
- package/src/drums/kick.ts +160 -0
- package/src/drums/percussion.ts +121 -0
- package/src/drums/rng.ts +7 -0
- package/src/drums/swing.ts +66 -0
- package/src/functional/index.ts +602 -0
- package/src/groove/index.ts +230 -0
- package/src/harmonize/index.ts +367 -0
- package/src/harmony/index.ts +75 -0
- package/src/index.ts +233 -0
- package/src/interval/index.ts +67 -0
- package/src/meter/index.ts +179 -0
- package/src/model/index.ts +957 -0
- package/src/motif/index.ts +321 -0
- package/src/pitch/index.ts +256 -0
- package/src/progression/index.ts +336 -0
- package/src/random/index.ts +41 -0
- package/src/reharmony/index.ts +299 -0
- package/src/rhythm/index.ts +131 -0
- package/src/safety/index.ts +372 -0
- package/src/scale/index.ts +215 -0
- package/src/spelling/index.ts +164 -0
- package/src/symbol/index.ts +213 -0
- package/src/timeline/index.ts +352 -0
- package/src/tuning/index.ts +129 -0
- package/src/types.ts +29 -0
- package/src/voicing/index.ts +523 -0
- package/tsconfig.json +23 -0
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@libraz/libcantus",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Pure-TypeScript music-theory and composition primitives (intervals, scales, chords).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "libraz",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/libraz/libcantus.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/libraz/libcantus#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/libraz/libcantus/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"music-theory",
|
|
18
|
+
"music",
|
|
19
|
+
"chords",
|
|
20
|
+
"scales",
|
|
21
|
+
"intervals",
|
|
22
|
+
"harmony",
|
|
23
|
+
"composition"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22 <23"
|
|
27
|
+
},
|
|
28
|
+
"packageManager": "yarn@4.17.0",
|
|
29
|
+
"volta": {
|
|
30
|
+
"node": "22.22.3",
|
|
31
|
+
"yarn": "4.17.0"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.cjs",
|
|
34
|
+
"module": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js",
|
|
40
|
+
"require": "./dist/index.cjs"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src",
|
|
46
|
+
"tsconfig.json",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"sideEffects": false,
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"lint": "biome check .",
|
|
55
|
+
"lint:fix": "biome check --write .",
|
|
56
|
+
"format": "biome format --write .",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"test:coverage": "vitest run --coverage",
|
|
61
|
+
"prepare": "[ -d node_modules/typescript ] && tsup || true",
|
|
62
|
+
"prepublishOnly": "yarn build"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@biomejs/biome": "2.5.2",
|
|
66
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
67
|
+
"tsup": "8.5.1",
|
|
68
|
+
"typescript": "6.0.3",
|
|
69
|
+
"vitest": "4.1.9"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { Chord } from '../chord/index.js';
|
|
2
|
+
import { chordPitchClasses, chordToneRole } from '../chord/index.js';
|
|
3
|
+
import { createsVerticalDissonance, isLeadingToneResolution } from '../counterpoint/index.js';
|
|
4
|
+
import type { VoiceSnapshot } from '../safety/index.js';
|
|
5
|
+
import type { KeyScale, NoteEvent } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/** A theory annotation attached to a note. */
|
|
8
|
+
export type TheoryLabel =
|
|
9
|
+
| { kind: 'chordTone'; role: 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' }
|
|
10
|
+
| { kind: 'tension'; degree: 9 | 11 | 13 }
|
|
11
|
+
| { kind: 'avoid' }
|
|
12
|
+
| { kind: 'passing' }
|
|
13
|
+
| { kind: 'neighbor' }
|
|
14
|
+
| { kind: 'suspension'; type: 'sus4-3' | 'sus7-6' | 'sus9-8' | 'sus2-3'; resolveTo: number }
|
|
15
|
+
| { kind: 'anticipation' }
|
|
16
|
+
| { kind: 'escape' }
|
|
17
|
+
| { kind: 'needsResolution'; resolveTo: number }
|
|
18
|
+
| { kind: 'leadingTone'; resolveTo: number };
|
|
19
|
+
|
|
20
|
+
/** A note with its theory labels and a short rationale. */
|
|
21
|
+
export type AnalyzedNote = {
|
|
22
|
+
noteId: number;
|
|
23
|
+
labels: TheoryLabel[];
|
|
24
|
+
rationale?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A single note in a monophonic voice: a {@link NoteEvent} with a stable id. */
|
|
28
|
+
export type VoiceNote = NoteEvent & { id: number };
|
|
29
|
+
|
|
30
|
+
function pitchClass(pitch: number): number {
|
|
31
|
+
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function intervalAboveRoot(pitch: number, chord: Chord): number {
|
|
35
|
+
return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map an interval class above the root to its extended-tension scale degree:
|
|
40
|
+
* a flat/natural ninth (1 or 2) to 9, an eleventh (5 or 6) to 11, and a
|
|
41
|
+
* thirteenth (8 or 9) to 13.
|
|
42
|
+
*/
|
|
43
|
+
function tensionDegree(ic: number): 9 | 11 | 13 {
|
|
44
|
+
if (ic === 1 || ic === 2) {
|
|
45
|
+
return 9;
|
|
46
|
+
}
|
|
47
|
+
if (ic === 5 || ic === 6) {
|
|
48
|
+
return 11;
|
|
49
|
+
}
|
|
50
|
+
return 13;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isChordMember(pitch: number, chord: Chord | null): boolean {
|
|
54
|
+
return chord ? chordPitchClasses(chord).includes(pitchClass(pitch)) : false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isStep(a: number, b: number): boolean {
|
|
58
|
+
const d = Math.abs(a - b);
|
|
59
|
+
return d === 1 || d === 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function suspensionType(ic: number, delta: number): TheoryLabel & { kind: 'suspension' } {
|
|
63
|
+
let type: 'sus4-3' | 'sus7-6' | 'sus9-8' | 'sus2-3';
|
|
64
|
+
if (delta > 0) {
|
|
65
|
+
type = 'sus2-3';
|
|
66
|
+
} else if (ic === 2) {
|
|
67
|
+
type = 'sus9-8';
|
|
68
|
+
} else if (ic === 10 || ic === 11) {
|
|
69
|
+
type = 'sus7-6';
|
|
70
|
+
} else {
|
|
71
|
+
type = 'sus4-3';
|
|
72
|
+
}
|
|
73
|
+
return { kind: 'suspension', type, resolveTo: 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stepResolution(pitch: number, chord: Chord): number | undefined {
|
|
77
|
+
for (let delta = 1; delta <= 2; delta += 1) {
|
|
78
|
+
if (isChordMember(pitch - delta, chord)) {
|
|
79
|
+
return pitch - delta;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Label every note of a voice with its theory roles.
|
|
87
|
+
*
|
|
88
|
+
* Each note is classified against the chord sounding at its beat. Chord tones
|
|
89
|
+
* get a role label; non-chord tones are matched, in order, as suspensions
|
|
90
|
+
* (prepared by an identical consonant pitch and resolving by step), passing
|
|
91
|
+
* tones, neighbors, anticipations, and escape tones, then fall back to tension,
|
|
92
|
+
* avoid, or an unresolved-dissonance label. Leading-tone resolutions are noted
|
|
93
|
+
* additionally.
|
|
94
|
+
*
|
|
95
|
+
* @param voice The monophonic voice, in time order.
|
|
96
|
+
* @param chordAtBeat Chord sounding at a given beat, or null.
|
|
97
|
+
* @param key Key context for leading-tone detection.
|
|
98
|
+
* @param otherVoicesAtBeat Other sounding voices at a given beat.
|
|
99
|
+
* @returns One annotation per input note.
|
|
100
|
+
*/
|
|
101
|
+
export function analyzeVoice(
|
|
102
|
+
voice: VoiceNote[],
|
|
103
|
+
chordAtBeat: (beat: number) => Chord | null,
|
|
104
|
+
key: KeyScale,
|
|
105
|
+
otherVoicesAtBeat: (beat: number) => VoiceSnapshot[],
|
|
106
|
+
): AnalyzedNote[] {
|
|
107
|
+
const result: AnalyzedNote[] = [];
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < voice.length; i += 1) {
|
|
110
|
+
const note = voice[i];
|
|
111
|
+
if (!note) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const prev = i > 0 ? voice[i - 1] : undefined;
|
|
115
|
+
const next = i + 1 < voice.length ? voice[i + 1] : undefined;
|
|
116
|
+
const chord = chordAtBeat(note.startBeat);
|
|
117
|
+
const labels: TheoryLabel[] = [];
|
|
118
|
+
const member = isChordMember(note.pitch, chord);
|
|
119
|
+
let handled = false;
|
|
120
|
+
|
|
121
|
+
if (chord && member) {
|
|
122
|
+
const role = chordToneRole(note.pitch, chord);
|
|
123
|
+
if (role) {
|
|
124
|
+
labels.push({ kind: 'chordTone', role });
|
|
125
|
+
} else {
|
|
126
|
+
const ic = intervalAboveRoot(note.pitch, chord);
|
|
127
|
+
labels.push({ kind: 'tension', degree: tensionDegree(ic) });
|
|
128
|
+
}
|
|
129
|
+
handled = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!handled && chord && prev && next) {
|
|
133
|
+
const prevChord = chordAtBeat(prev.startBeat);
|
|
134
|
+
const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
|
|
135
|
+
const resolves = isStep(next.pitch, note.pitch);
|
|
136
|
+
const verticallyDissonant = otherVoicesAtBeat(note.startBeat).some((ov) =>
|
|
137
|
+
createsVerticalDissonance(note.pitch, ov.pitch, true),
|
|
138
|
+
);
|
|
139
|
+
if (prepared && verticallyDissonant && resolves) {
|
|
140
|
+
const ic = intervalAboveRoot(note.pitch, chord);
|
|
141
|
+
const sus = suspensionType(ic, next.pitch - note.pitch);
|
|
142
|
+
sus.resolveTo = next.pitch;
|
|
143
|
+
labels.push(sus);
|
|
144
|
+
handled = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!handled && chord && prev && next) {
|
|
149
|
+
const prevMember = isChordMember(prev.pitch, chordAtBeat(prev.startBeat));
|
|
150
|
+
const nextMember = isChordMember(next.pitch, chordAtBeat(next.startBeat));
|
|
151
|
+
const up = note.pitch - prev.pitch > 0;
|
|
152
|
+
const contInto = next.pitch - note.pitch > 0;
|
|
153
|
+
const sameDir = note.pitch !== prev.pitch && next.pitch !== note.pitch && up === contInto;
|
|
154
|
+
if (
|
|
155
|
+
prevMember &&
|
|
156
|
+
nextMember &&
|
|
157
|
+
!member &&
|
|
158
|
+
isStep(note.pitch, prev.pitch) &&
|
|
159
|
+
isStep(next.pitch, note.pitch) &&
|
|
160
|
+
sameDir
|
|
161
|
+
) {
|
|
162
|
+
labels.push({ kind: 'passing' });
|
|
163
|
+
handled = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (
|
|
168
|
+
!handled &&
|
|
169
|
+
chord &&
|
|
170
|
+
prev &&
|
|
171
|
+
next &&
|
|
172
|
+
!member &&
|
|
173
|
+
prev.pitch === next.pitch &&
|
|
174
|
+
isChordMember(prev.pitch, chordAtBeat(prev.startBeat)) &&
|
|
175
|
+
isStep(note.pitch, prev.pitch)
|
|
176
|
+
) {
|
|
177
|
+
labels.push({ kind: 'neighbor' });
|
|
178
|
+
handled = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!handled && chord && next && !member && note.pitch === next.pitch) {
|
|
182
|
+
const nextChord = chordAtBeat(next.startBeat);
|
|
183
|
+
if (isChordMember(next.pitch, nextChord)) {
|
|
184
|
+
labels.push({ kind: 'anticipation' });
|
|
185
|
+
handled = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!handled && chord && prev && next && !member) {
|
|
190
|
+
const prevMember = isChordMember(prev.pitch, chordAtBeat(prev.startBeat));
|
|
191
|
+
const nextMember = isChordMember(next.pitch, chordAtBeat(next.startBeat));
|
|
192
|
+
const stepFromPrev = isStep(note.pitch, prev.pitch);
|
|
193
|
+
const leapToNext = Math.abs(next.pitch - note.pitch) >= 3;
|
|
194
|
+
const opposite = note.pitch - prev.pitch > 0 !== next.pitch - note.pitch > 0;
|
|
195
|
+
if (prevMember && nextMember && stepFromPrev && leapToNext && opposite) {
|
|
196
|
+
labels.push({ kind: 'escape' });
|
|
197
|
+
handled = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!handled && chord && !member) {
|
|
202
|
+
const ic = intervalAboveRoot(note.pitch, chord);
|
|
203
|
+
const isTension = ic === 2 || ic === 5 || ic === 9;
|
|
204
|
+
const avoid =
|
|
205
|
+
(ic === 5 && chord.intervals.includes(4) && !chord.intervals.includes(5)) ||
|
|
206
|
+
(ic === 11 && chord.intervals.includes(4) && chord.intervals.includes(10));
|
|
207
|
+
if (avoid) {
|
|
208
|
+
labels.push({ kind: 'avoid' });
|
|
209
|
+
const resolveTo = stepResolution(note.pitch, chord);
|
|
210
|
+
if (resolveTo !== undefined) {
|
|
211
|
+
labels.push({ kind: 'needsResolution', resolveTo });
|
|
212
|
+
}
|
|
213
|
+
} else if (isTension) {
|
|
214
|
+
labels.push({ kind: 'tension', degree: tensionDegree(ic) });
|
|
215
|
+
} else {
|
|
216
|
+
const resolveTo = stepResolution(note.pitch, chord);
|
|
217
|
+
if (resolveTo !== undefined) {
|
|
218
|
+
labels.push({ kind: 'needsResolution', resolveTo });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (next && isLeadingToneResolution(note.pitch, next.pitch, key)) {
|
|
224
|
+
labels.push({ kind: 'leadingTone', resolveTo: next.pitch });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
result.push({ noteId: note.id, labels, rationale: describe(labels) });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Build a short rationale from a note's primary label. */
|
|
234
|
+
function describe(labels: TheoryLabel[]): string {
|
|
235
|
+
const primary = labels[0];
|
|
236
|
+
if (!primary) {
|
|
237
|
+
return 'Unclassified note';
|
|
238
|
+
}
|
|
239
|
+
switch (primary.kind) {
|
|
240
|
+
case 'chordTone':
|
|
241
|
+
return `Chord ${primary.role}`;
|
|
242
|
+
case 'tension':
|
|
243
|
+
return `${primary.degree}th tension`;
|
|
244
|
+
case 'avoid':
|
|
245
|
+
return 'Avoid note';
|
|
246
|
+
case 'passing':
|
|
247
|
+
return 'Passing tone between chord tones';
|
|
248
|
+
case 'neighbor':
|
|
249
|
+
return 'Neighbor tone';
|
|
250
|
+
case 'suspension':
|
|
251
|
+
return `Suspension (${primary.type})`;
|
|
252
|
+
case 'anticipation':
|
|
253
|
+
return 'Anticipation of the next chord';
|
|
254
|
+
case 'escape':
|
|
255
|
+
return 'Escape tone';
|
|
256
|
+
case 'needsResolution':
|
|
257
|
+
return 'Unresolved dissonance';
|
|
258
|
+
case 'leadingTone':
|
|
259
|
+
return 'Leading tone resolving to the tonic';
|
|
260
|
+
}
|
|
261
|
+
}
|