@libraz/libcantus 0.9.0 → 0.9.2
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 +25 -2
- package/dist/analyze/index.cjs +1697 -0
- package/dist/analyze/index.cjs.map +1 -0
- package/dist/analyze/index.d.cts +429 -0
- package/dist/analyze/index.d.ts +429 -0
- package/dist/analyze/index.js +51 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/chunk-5HTNMQW4.js +653 -0
- package/dist/chunk-5HTNMQW4.js.map +1 -0
- package/dist/chunk-7Y7WF64D.js +410 -0
- package/dist/chunk-7Y7WF64D.js.map +1 -0
- package/dist/chunk-A4532ZFR.js +259 -0
- package/dist/chunk-A4532ZFR.js.map +1 -0
- package/dist/chunk-BHUT2S5G.js +22 -0
- package/dist/chunk-BHUT2S5G.js.map +1 -0
- package/dist/chunk-CCBLVAPK.js +42 -0
- package/dist/chunk-CCBLVAPK.js.map +1 -0
- package/dist/chunk-COKTX2G5.js +907 -0
- package/dist/chunk-COKTX2G5.js.map +1 -0
- package/dist/chunk-DYWFNC4A.js +152 -0
- package/dist/chunk-DYWFNC4A.js.map +1 -0
- package/dist/chunk-GQKN5KDT.js +159 -0
- package/dist/chunk-GQKN5KDT.js.map +1 -0
- package/dist/chunk-LN7ZZMD2.js +530 -0
- package/dist/chunk-LN7ZZMD2.js.map +1 -0
- package/dist/chunk-MSWFZF7J.js +118 -0
- package/dist/chunk-MSWFZF7J.js.map +1 -0
- package/dist/chunk-R3S2LUBH.js +2722 -0
- package/dist/chunk-R3S2LUBH.js.map +1 -0
- package/dist/chunk-SDM3VJJ5.js +41 -0
- package/dist/chunk-SDM3VJJ5.js.map +1 -0
- package/dist/chunk-XTC3X6MS.js +816 -0
- package/dist/chunk-XTC3X6MS.js.map +1 -0
- package/dist/chunk-ZXW5YIWK.js +55 -0
- package/dist/chunk-ZXW5YIWK.js.map +1 -0
- package/dist/core/index.cjs +432 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +210 -0
- package/dist/core/index.d.ts +210 -0
- package/dist/core/index.js +73 -0
- package/dist/core/index.js.map +1 -0
- package/dist/function-2bJW1Zp_.d.ts +187 -0
- package/dist/function-r2sh-jOO.d.cts +187 -0
- package/dist/generate/index.cjs +3787 -0
- package/dist/generate/index.cjs.map +1 -0
- package/dist/generate/index.d.cts +980 -0
- package/dist/generate/index.d.ts +980 -0
- package/dist/generate/index.js +52 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index-BMPxwjwV.d.ts +151 -0
- package/dist/index-BOBXBNwP.d.cts +157 -0
- package/dist/index-BdeQ1r_p.d.cts +56 -0
- package/dist/index-BreoJRw0.d.ts +127 -0
- package/dist/index-CGMZNCx6.d.cts +140 -0
- package/dist/index-CGMZNCx6.d.ts +140 -0
- package/dist/index-CM5W4rcm.d.ts +157 -0
- package/dist/index-CP3wAfEH.d.ts +56 -0
- package/dist/index-Cl7mhOFM.d.cts +164 -0
- package/dist/index-Cl7mhOFM.d.ts +164 -0
- package/dist/index-DZ1M21do.d.cts +127 -0
- package/dist/index-D_ZTUNKp.d.cts +151 -0
- package/dist/index.cjs +5277 -4893
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -2621
- package/dist/index.d.ts +14 -2621
- package/dist/index.js +170 -6152
- package/dist/index.js.map +1 -1
- package/dist/model/index.cjs +2435 -0
- package/dist/model/index.cjs.map +1 -0
- package/dist/model/index.d.cts +630 -0
- package/dist/model/index.d.ts +630 -0
- package/dist/model/index.js +21 -0
- package/dist/model/index.js.map +1 -0
- package/dist/styled-BjXtOFNk.d.cts +282 -0
- package/dist/styled-D7BAx3S3.d.ts +282 -0
- package/dist/theory/index.cjs +1677 -0
- package/dist/theory/index.cjs.map +1 -0
- package/dist/theory/index.d.cts +535 -0
- package/dist/theory/index.d.ts +535 -0
- package/dist/theory/index.js +149 -0
- package/dist/theory/index.js.map +1 -0
- package/dist/types-MGP29r3t.d.cts +30 -0
- package/dist/types-MGP29r3t.d.ts +30 -0
- package/package.json +31 -1
- package/src/analyze/arrange/index.ts +21 -0
- package/src/analyze/arrange/internal.ts +125 -0
- package/src/analyze/arrange/tension.ts +187 -0
- package/src/analyze/arrange/tracks.ts +279 -0
- package/src/{detect → analyze/detect}/index.ts +82 -23
- package/src/analyze/functional/borrowed.ts +84 -0
- package/src/analyze/functional/cadence.ts +66 -0
- package/src/analyze/functional/function.ts +184 -0
- package/src/analyze/functional/index.ts +23 -0
- package/src/analyze/functional/internal.ts +29 -0
- package/src/analyze/functional/roman.ts +393 -0
- package/src/analyze/index.ts +49 -0
- package/src/{timeline → analyze/timeline}/index.ts +87 -24
- package/src/{analysis → analyze/voice}/index.ts +81 -12
- package/src/core/index.ts +47 -0
- package/src/{interval → core/interval}/index.ts +15 -1
- package/src/{meter → core/meter}/index.ts +108 -5
- package/src/{pitch → core/pitch}/index.ts +58 -3
- package/src/{random → core/random}/index.ts +13 -1
- package/src/{tuning → core/tuning}/index.ts +28 -1
- package/src/{bass → generate/bass}/index.ts +66 -17
- package/src/{countermelody → generate/countermelody}/index.ts +54 -24
- package/src/{drums → generate/drums}/fills.ts +31 -20
- package/src/{drums → generate/drums}/index.ts +127 -9
- package/src/{drums → generate/drums}/internal.ts +7 -1
- package/src/{drums → generate/drums}/rng.ts +2 -2
- package/src/{drums → generate/drums}/swing.ts +7 -2
- package/src/{groove → generate/groove}/index.ts +118 -22
- package/src/{harmonize → generate/harmonize}/index.ts +96 -23
- package/src/generate/index.ts +59 -0
- package/src/{motif → generate/motif}/index.ts +94 -17
- package/src/{progression → generate/progression}/index.ts +61 -27
- package/src/{reharmony → generate/reharmony}/index.ts +46 -15
- package/src/{rhythm → generate/rhythm}/index.ts +47 -13
- package/src/index.ts +130 -116
- package/src/model/chord.ts +423 -0
- package/src/model/index.ts +5 -947
- package/src/model/interval.ts +82 -0
- package/src/model/key.ts +266 -0
- package/src/model/note.ts +176 -0
- package/src/model/progression.ts +161 -0
- package/src/model/shared.ts +12 -0
- package/src/{chord → theory/chord}/index.ts +85 -4
- package/src/{chordscale → theory/chordscale}/index.ts +67 -9
- package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
- package/src/{harmony → theory/harmony}/index.ts +19 -2
- package/src/theory/index.ts +107 -0
- package/src/{safety → theory/safety}/index.ts +59 -12
- package/src/theory/scale/degrees.ts +123 -0
- package/src/theory/scale/index.ts +28 -0
- package/src/theory/scale/key.ts +56 -0
- package/src/theory/scale/masks.ts +153 -0
- package/src/{spelling → theory/spelling}/index.ts +21 -3
- package/src/{symbol → theory/symbol}/index.ts +91 -36
- package/src/theory/voicing/index.ts +5 -0
- package/src/theory/voicing/internal.ts +168 -0
- package/src/theory/voicing/leading.ts +112 -0
- package/src/theory/voicing/satb.ts +195 -0
- package/src/theory/voicing/styled.ts +176 -0
- package/src/arrange/index.ts +0 -409
- package/src/functional/index.ts +0 -602
- package/src/scale/index.ts +0 -215
- package/src/voicing/index.ts +0 -523
- /package/src/{types.ts → core/types.ts} +0 -0
- /package/src/{drums → generate/drums}/beat.ts +0 -0
- /package/src/{drums → generate/drums}/euclid.ts +0 -0
- /package/src/{drums → generate/drums}/ghost.ts +0 -0
- /package/src/{drums → generate/drums}/hihat.ts +0 -0
- /package/src/{drums → generate/drums}/hit.ts +0 -0
- /package/src/{drums → generate/drums}/kick.ts +0 -0
- /package/src/{drums → generate/drums}/percussion.ts +0 -0
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { TimeSignature } from '../../core/meter/index.js';
|
|
2
|
+
import { beatsPerBar, metricWeight, parseTimeSignature } from '../../core/meter/index.js';
|
|
3
|
+
import type { KeyScale, NoteEvent } from '../../core/types.js';
|
|
4
|
+
import type { Chord, ChordSpan } from '../../theory/chord/index.js';
|
|
5
|
+
import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
|
|
6
|
+
import { isScaleTone, majorKey } from '../../theory/scale/index.js';
|
|
3
7
|
import type { ChordMatch } from '../detect/index.js';
|
|
4
8
|
import { detectChord, detectChordBest, detectKey } from '../detect/index.js';
|
|
5
9
|
import type { Cadence } from '../functional/index.js';
|
|
6
10
|
import { detectCadence } from '../functional/index.js';
|
|
7
|
-
import type { TimeSignature } from '../meter/index.js';
|
|
8
|
-
import { beatsPerBar, metricWeight, parseTimeSignature } from '../meter/index.js';
|
|
9
|
-
import type { GeneratedChord } from '../progression/index.js';
|
|
10
|
-
import { isScaleTone, majorKey } from '../scale/index.js';
|
|
11
|
-
import type { KeyScale, NoteEvent } from '../types.js';
|
|
12
11
|
|
|
13
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* A chord occupying a half-open beat span.
|
|
14
|
+
*
|
|
15
|
+
* @category Arrangement & Analysis
|
|
16
|
+
*/
|
|
14
17
|
export type ChordSegment = {
|
|
15
18
|
startBeat: number;
|
|
16
19
|
endBeat: number;
|
|
17
20
|
chord: Chord;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* A beat-indexed sequence of chord segments.
|
|
25
|
+
*
|
|
26
|
+
* @category Arrangement & Analysis
|
|
27
|
+
*/
|
|
21
28
|
export type ChordTimeline = {
|
|
22
29
|
/** The chord sounding at a beat, or null when no segment covers it. */
|
|
23
30
|
at: (beat: number) => Chord | null;
|
|
@@ -35,11 +42,9 @@ export type ChordTimeline = {
|
|
|
35
42
|
* @param chords Placed chords in time order.
|
|
36
43
|
* @param totalBeats End of the timeline in beats.
|
|
37
44
|
* @returns A queryable chord timeline.
|
|
45
|
+
* @category Arrangement & Analysis
|
|
38
46
|
*/
|
|
39
|
-
export function chordTimelineFromChords(
|
|
40
|
-
chords: GeneratedChord[],
|
|
41
|
-
totalBeats: number,
|
|
42
|
-
): ChordTimeline {
|
|
47
|
+
export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number): ChordTimeline {
|
|
43
48
|
const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
|
|
44
49
|
const segments: ChordSegment[] = sorted
|
|
45
50
|
.map((gc, i) => {
|
|
@@ -96,19 +101,39 @@ function pitchClass(value: number): number {
|
|
|
96
101
|
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Options controlling {@link chordTimelineFromNotes}.
|
|
106
|
+
*
|
|
107
|
+
* @category Arrangement & Analysis
|
|
108
|
+
*/
|
|
100
109
|
export type ChordTimelineOptions = {
|
|
101
110
|
/** Key context; inferred from the notes with {@link detectKey} when omitted. */
|
|
102
111
|
key?: KeyScale;
|
|
103
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Time signature used for metric accents; defaults to 4/4.
|
|
114
|
+
*
|
|
115
|
+
* @defaultValue `4/4`
|
|
116
|
+
*/
|
|
104
117
|
ts?: TimeSignature;
|
|
105
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Window length in beats per chord slot; defaults to one bar of `ts`.
|
|
120
|
+
*
|
|
121
|
+
* @defaultValue one bar of `ts`
|
|
122
|
+
*/
|
|
106
123
|
harmonicRhythm?: number;
|
|
107
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* End of the analyzed span in beats; defaults to the end of the last note.
|
|
126
|
+
*
|
|
127
|
+
* @defaultValue the end of the last note
|
|
128
|
+
*/
|
|
108
129
|
totalBeats?: number;
|
|
109
130
|
};
|
|
110
131
|
|
|
111
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The result of {@link chordTimelineFromNotes}.
|
|
134
|
+
*
|
|
135
|
+
* @category Arrangement & Analysis
|
|
136
|
+
*/
|
|
112
137
|
export type ChordTimelineResult = {
|
|
113
138
|
/** The inferred timeline, with adjacent identical chords merged. */
|
|
114
139
|
timeline: ChordTimeline;
|
|
@@ -266,10 +291,26 @@ function sameChord(a: Chord, b: Chord): boolean {
|
|
|
266
291
|
* the fraction of the window weight explained by chord tones, reduced when the
|
|
267
292
|
* match is inexact, and duration-weighted across merged windows.
|
|
268
293
|
*
|
|
294
|
+
* Notes with a zero or negative duration never sound, so they are dropped at
|
|
295
|
+
* ingest: they contribute to neither the key inference, the span, nor any
|
|
296
|
+
* window's histogram.
|
|
297
|
+
*
|
|
269
298
|
* @param notes The notes to analyze (any number of tracks, flattened).
|
|
270
299
|
* @param opts Analysis options; see {@link ChordTimelineOptions}.
|
|
271
300
|
* @returns The inferred timeline, the key used, and per-segment confidences.
|
|
272
301
|
* @throws If `harmonicRhythm` is not positive.
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* import { chordTimelineFromNotes } from '@libraz/libcantus';
|
|
305
|
+
* const notes = [
|
|
306
|
+
* { pitch: 60, startBeat: 0, durationBeat: 2 }, // C
|
|
307
|
+
* { pitch: 64, startBeat: 0, durationBeat: 2 }, // E
|
|
308
|
+
* { pitch: 67, startBeat: 0, durationBeat: 2 }, // G
|
|
309
|
+
* ];
|
|
310
|
+
* const { timeline, key } = chordTimelineFromNotes(notes);
|
|
311
|
+
* timeline.at(0); // the chord inferred over beat 0, or null
|
|
312
|
+
* ```
|
|
313
|
+
* @category Arrangement & Analysis
|
|
273
314
|
*/
|
|
274
315
|
export function chordTimelineFromNotes(
|
|
275
316
|
notes: NoteEvent[],
|
|
@@ -280,9 +321,11 @@ export function chordTimelineFromNotes(
|
|
|
280
321
|
if (!(harmonicRhythm > 0)) {
|
|
281
322
|
throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
|
|
282
323
|
}
|
|
283
|
-
|
|
324
|
+
// Zero/negative-length notes never sound; drop them before any inference.
|
|
325
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
326
|
+
const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
284
327
|
const totalBeats = opts.totalBeats ?? lastNoteEnd;
|
|
285
|
-
const key = opts.key ?? detectKey(
|
|
328
|
+
const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
|
|
286
329
|
|
|
287
330
|
const segments: ChordSegment[] = [];
|
|
288
331
|
const segmentConfidence: number[] = [];
|
|
@@ -290,7 +333,7 @@ export function chordTimelineFromNotes(
|
|
|
290
333
|
for (let i = 0; i < windowCount; i += 1) {
|
|
291
334
|
const start = i * harmonicRhythm;
|
|
292
335
|
const end = Math.min(start + harmonicRhythm, totalBeats);
|
|
293
|
-
const inferred = analyzeWindow(
|
|
336
|
+
const inferred = analyzeWindow(sounding, start, end, ts, key);
|
|
294
337
|
if (!inferred) {
|
|
295
338
|
continue;
|
|
296
339
|
}
|
|
@@ -316,7 +359,11 @@ export function chordTimelineFromNotes(
|
|
|
316
359
|
};
|
|
317
360
|
}
|
|
318
361
|
|
|
319
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* A cadence found between two consecutive timeline segments.
|
|
364
|
+
*
|
|
365
|
+
* @category Arrangement & Analysis
|
|
366
|
+
*/
|
|
320
367
|
export type CadenceHit = {
|
|
321
368
|
/** The beat where the cadence arrives (the second chord's onset). */
|
|
322
369
|
atBeat: number;
|
|
@@ -328,12 +375,25 @@ export type CadenceHit = {
|
|
|
328
375
|
/**
|
|
329
376
|
* Find cadences between consecutive segments of a chord timeline.
|
|
330
377
|
*
|
|
331
|
-
* Each adjacent segment pair is classified with
|
|
332
|
-
* forming no cadence are skipped.
|
|
378
|
+
* Each temporally adjacent segment pair is classified with
|
|
379
|
+
* {@link detectCadence}; pairs forming no cadence are skipped. Segments
|
|
380
|
+
* separated by a gap (a rest in the timeline) are not a chord-to-chord
|
|
381
|
+
* progression, so they are never paired.
|
|
333
382
|
*
|
|
334
383
|
* @param timeline The chord timeline to scan.
|
|
335
384
|
* @param key The prevailing key.
|
|
336
385
|
* @returns The cadences found, in time order.
|
|
386
|
+
* @example
|
|
387
|
+
* ```ts
|
|
388
|
+
* import { chordTimelineFromNotes, detectCadences } from '@libraz/libcantus';
|
|
389
|
+
* const notes = [
|
|
390
|
+
* { pitch: 67, startBeat: 0, durationBeat: 4 }, // G, a dominant
|
|
391
|
+
* { pitch: 60, startBeat: 4, durationBeat: 4 }, // C, the tonic
|
|
392
|
+
* ];
|
|
393
|
+
* const { timeline, key } = chordTimelineFromNotes(notes);
|
|
394
|
+
* const cadences = detectCadences(timeline, key);
|
|
395
|
+
* ```
|
|
396
|
+
* @category Arrangement & Analysis
|
|
337
397
|
*/
|
|
338
398
|
export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
|
|
339
399
|
const hits: CadenceHit[] = [];
|
|
@@ -343,6 +403,9 @@ export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceH
|
|
|
343
403
|
if (!prev || !cur) {
|
|
344
404
|
continue;
|
|
345
405
|
}
|
|
406
|
+
if (Math.abs(cur.startBeat - prev.endBeat) > EPS) {
|
|
407
|
+
continue; // A rest separates the chords; no cadential motion across it.
|
|
408
|
+
}
|
|
346
409
|
const type = detectCadence(prev.chord, cur.chord, key);
|
|
347
410
|
if (type !== null) {
|
|
348
411
|
hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
1
|
+
import type { KeyScale, NoteEvent } from '../../core/types.js';
|
|
2
|
+
import type { Chord } from '../../theory/chord/index.js';
|
|
3
|
+
import { chordPitchClasses, chordToneRole } from '../../theory/chord/index.js';
|
|
4
|
+
import {
|
|
5
|
+
createsVerticalDissonance,
|
|
6
|
+
isLeadingToneResolution,
|
|
7
|
+
} from '../../theory/counterpoint/index.js';
|
|
8
|
+
import type { VoiceSnapshot } from '../../theory/safety/index.js';
|
|
6
9
|
|
|
7
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* A theory annotation attached to a note.
|
|
12
|
+
*
|
|
13
|
+
* @category Arrangement & Analysis
|
|
14
|
+
*/
|
|
8
15
|
export type TheoryLabel =
|
|
9
16
|
| { kind: 'chordTone'; role: 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' }
|
|
10
17
|
| { kind: 'tension'; degree: 9 | 11 | 13 }
|
|
@@ -17,16 +24,27 @@ export type TheoryLabel =
|
|
|
17
24
|
| { kind: 'needsResolution'; resolveTo: number }
|
|
18
25
|
| { kind: 'leadingTone'; resolveTo: number };
|
|
19
26
|
|
|
20
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* A note with its theory labels and a short rationale.
|
|
29
|
+
*
|
|
30
|
+
* @category Arrangement & Analysis
|
|
31
|
+
*/
|
|
21
32
|
export type AnalyzedNote = {
|
|
22
33
|
noteId: number;
|
|
23
34
|
labels: TheoryLabel[];
|
|
24
35
|
rationale?: string;
|
|
25
36
|
};
|
|
26
37
|
|
|
27
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* A single note in a monophonic voice: a {@link NoteEvent} with a stable id.
|
|
40
|
+
*
|
|
41
|
+
* @category Arrangement & Analysis
|
|
42
|
+
*/
|
|
28
43
|
export type VoiceNote = NoteEvent & { id: number };
|
|
29
44
|
|
|
45
|
+
/** Float tolerance for beat comparisons. */
|
|
46
|
+
const EPS = 1e-9;
|
|
47
|
+
|
|
30
48
|
function pitchClass(pitch: number): number {
|
|
31
49
|
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
32
50
|
}
|
|
@@ -35,6 +53,26 @@ function intervalAboveRoot(pitch: number, chord: Chord): number {
|
|
|
35
53
|
return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
|
|
36
54
|
}
|
|
37
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Interval class of a pitch above the actual sounding bass.
|
|
58
|
+
*
|
|
59
|
+
* Suspension figures (4-3, 7-6, 9-8) are named for the interval above the bass,
|
|
60
|
+
* not the chord root, so this prefers the lowest pitch among the other sounding
|
|
61
|
+
* voices when one lies below the note. Without a lower sounding voice it falls
|
|
62
|
+
* back to the chord's bass pitch class (or root in root position).
|
|
63
|
+
*/
|
|
64
|
+
function intervalAboveBass(pitch: number, others: VoiceSnapshot[], chord: Chord): number {
|
|
65
|
+
let bass = Number.POSITIVE_INFINITY;
|
|
66
|
+
for (const ov of others) {
|
|
67
|
+
bass = Math.min(bass, ov.pitch);
|
|
68
|
+
}
|
|
69
|
+
if (Number.isFinite(bass) && bass < pitch) {
|
|
70
|
+
return (((pitch - bass) % 12) + 12) % 12;
|
|
71
|
+
}
|
|
72
|
+
const ref = chord.bassPc ?? chord.rootPc;
|
|
73
|
+
return (((pitchClass(pitch) - pitchClass(ref)) % 12) + 12) % 12;
|
|
74
|
+
}
|
|
75
|
+
|
|
38
76
|
/**
|
|
39
77
|
* Map an interval class above the root to its extended-tension scale degree:
|
|
40
78
|
* a flat/natural ninth (1 or 2) to 9, an eleventh (5 or 6) to 11, and a
|
|
@@ -59,6 +97,10 @@ function isStep(a: number, b: number): boolean {
|
|
|
59
97
|
return d === 1 || d === 2;
|
|
60
98
|
}
|
|
61
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Classify a suspension figure from the interval class above the sounding bass
|
|
102
|
+
* (`ic`) and the resolution direction (`delta`, positive when resolving upward).
|
|
103
|
+
*/
|
|
62
104
|
function suspensionType(ic: number, delta: number): TheoryLabel & { kind: 'suspension' } {
|
|
63
105
|
let type: 'sus4-3' | 'sus7-6' | 'sus9-8' | 'sus2-3';
|
|
64
106
|
if (delta > 0) {
|
|
@@ -92,11 +134,30 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
|
|
|
92
134
|
* avoid, or an unresolved-dissonance label. Leading-tone resolutions are noted
|
|
93
135
|
* additionally.
|
|
94
136
|
*
|
|
137
|
+
* The voice is expected to be monophonic (one note at a time). Notes sharing an
|
|
138
|
+
* onset are treated as simultaneous cluster members, not melodic neighbors, so
|
|
139
|
+
* they receive no melodic labels (suspension, passing, neighbor, anticipation,
|
|
140
|
+
* escape) — only harmonic ones. Callers with truly polyphonic material should
|
|
141
|
+
* split it into monophonic sub-voices first, as `analyzeArrangement` does.
|
|
142
|
+
*
|
|
95
143
|
* @param voice The monophonic voice, in time order.
|
|
96
144
|
* @param chordAtBeat Chord sounding at a given beat, or null.
|
|
97
145
|
* @param key Key context for leading-tone detection.
|
|
98
146
|
* @param otherVoicesAtBeat Other sounding voices at a given beat.
|
|
99
147
|
* @returns One annotation per input note.
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { analyzeVoice, makeChord, majorKey } from '@libraz/libcantus';
|
|
151
|
+
* const voice = [
|
|
152
|
+
* { id: 0, pitch: 60, startBeat: 0, durationBeat: 1 },
|
|
153
|
+
* { id: 1, pitch: 62, startBeat: 1, durationBeat: 1 },
|
|
154
|
+
* { id: 2, pitch: 64, startBeat: 2, durationBeat: 1 },
|
|
155
|
+
* ];
|
|
156
|
+
* const cMajor = makeChord(0, 'maj');
|
|
157
|
+
* const labels = analyzeVoice(voice, () => cMajor, majorKey(0), () => []);
|
|
158
|
+
* labels; // one AnalyzedNote per input note, in the same order
|
|
159
|
+
* ```
|
|
160
|
+
* @category Arrangement & Analysis
|
|
100
161
|
*/
|
|
101
162
|
export function analyzeVoice(
|
|
102
163
|
voice: VoiceNote[],
|
|
@@ -111,8 +172,15 @@ export function analyzeVoice(
|
|
|
111
172
|
if (!note) {
|
|
112
173
|
continue;
|
|
113
174
|
}
|
|
114
|
-
const
|
|
115
|
-
const
|
|
175
|
+
const prevNote = i > 0 ? voice[i - 1] : undefined;
|
|
176
|
+
const nextNote = i + 1 < voice.length ? voice[i + 1] : undefined;
|
|
177
|
+
// A neighboring note sharing this note's onset is a simultaneous cluster
|
|
178
|
+
// member, not a melodic predecessor/successor; treating it as one would
|
|
179
|
+
// fabricate passing/suspension figures, so it is dropped here.
|
|
180
|
+
const prev =
|
|
181
|
+
prevNote !== undefined && prevNote.startBeat < note.startBeat - EPS ? prevNote : undefined;
|
|
182
|
+
const next =
|
|
183
|
+
nextNote !== undefined && nextNote.startBeat > note.startBeat + EPS ? nextNote : undefined;
|
|
116
184
|
const chord = chordAtBeat(note.startBeat);
|
|
117
185
|
const labels: TheoryLabel[] = [];
|
|
118
186
|
const member = isChordMember(note.pitch, chord);
|
|
@@ -133,11 +201,12 @@ export function analyzeVoice(
|
|
|
133
201
|
const prevChord = chordAtBeat(prev.startBeat);
|
|
134
202
|
const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
|
|
135
203
|
const resolves = isStep(next.pitch, note.pitch);
|
|
136
|
-
const
|
|
204
|
+
const others = otherVoicesAtBeat(note.startBeat);
|
|
205
|
+
const verticallyDissonant = others.some((ov) =>
|
|
137
206
|
createsVerticalDissonance(note.pitch, ov.pitch, true),
|
|
138
207
|
);
|
|
139
208
|
if (prepared && verticallyDissonant && resolves) {
|
|
140
|
-
const ic =
|
|
209
|
+
const ic = intervalAboveBass(note.pitch, others, chord);
|
|
141
210
|
const sus = suspensionType(ic, next.pitch - note.pitch);
|
|
142
211
|
sus.resolveTo = next.pitch;
|
|
143
212
|
labels.push(sus);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of the core layer. Re-exports every core-layer module;
|
|
3
|
+
* also available from the package root.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
classifyInterval,
|
|
7
|
+
IntervalQuality,
|
|
8
|
+
isConsonantInterval,
|
|
9
|
+
isPerfectInterval,
|
|
10
|
+
} from './interval/index.js';
|
|
11
|
+
export type { BarPosition, TimeSignature } from './meter/index.js';
|
|
12
|
+
export {
|
|
13
|
+
barPositionToBeat,
|
|
14
|
+
beatsPerBar,
|
|
15
|
+
beatToBarPosition,
|
|
16
|
+
formatTimeSignature,
|
|
17
|
+
isCompound,
|
|
18
|
+
isStrongBeat,
|
|
19
|
+
metricWeight,
|
|
20
|
+
parseTimeSignature,
|
|
21
|
+
pulsesPerBar,
|
|
22
|
+
tuplet,
|
|
23
|
+
} from './meter/index.js';
|
|
24
|
+
export type { Note as NoteData, SpelledInterval } from './pitch/index.js';
|
|
25
|
+
export {
|
|
26
|
+
formatNote,
|
|
27
|
+
midiToNote,
|
|
28
|
+
noteToMidi,
|
|
29
|
+
noteToPitchClass,
|
|
30
|
+
parseNote,
|
|
31
|
+
spelledInterval,
|
|
32
|
+
} from './pitch/index.js';
|
|
33
|
+
export type { Rng } from './random/index.js';
|
|
34
|
+
export { createRng } from './random/index.js';
|
|
35
|
+
export type { Tuning } from './tuning/index.js';
|
|
36
|
+
export {
|
|
37
|
+
centsBetweenFreq,
|
|
38
|
+
centsOfSteps,
|
|
39
|
+
edo,
|
|
40
|
+
frequencyOf,
|
|
41
|
+
JUST_RATIOS,
|
|
42
|
+
justDeviationCents,
|
|
43
|
+
nearestStep,
|
|
44
|
+
ratioToCents,
|
|
45
|
+
TWELVE_TET,
|
|
46
|
+
} from './tuning/index.js';
|
|
47
|
+
export type { KeyScale, NoteEvent } from './types.js';
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Counterpoint classification of a harmonic interval.
|
|
3
|
+
*
|
|
4
|
+
* @category Pitch & Intervals
|
|
5
|
+
*/
|
|
2
6
|
export enum IntervalQuality {
|
|
3
7
|
PerfectConsonance = 0,
|
|
4
8
|
ImperfectConsonance = 1,
|
|
@@ -21,6 +25,14 @@ function simpleInterval(semitones: number): number {
|
|
|
21
25
|
* @param semitones Interval size in semitones (may be negative or compound).
|
|
22
26
|
* @param twoVoice When true, the perfect fourth is treated as dissonant.
|
|
23
27
|
* @returns The counterpoint quality of the interval.
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { classifyInterval, IntervalQuality } from '@libraz/libcantus';
|
|
31
|
+
* classifyInterval(7); // IntervalQuality.PerfectConsonance
|
|
32
|
+
* classifyInterval(5); // IntervalQuality.Dissonance (two-voice)
|
|
33
|
+
* classifyInterval(5, false); // IntervalQuality.ImperfectConsonance
|
|
34
|
+
* ```
|
|
35
|
+
* @category Pitch & Intervals
|
|
24
36
|
*/
|
|
25
37
|
export function classifyInterval(semitones: number, twoVoice = true): IntervalQuality {
|
|
26
38
|
const pc = simpleInterval(semitones);
|
|
@@ -41,6 +53,7 @@ export function classifyInterval(semitones: number, twoVoice = true): IntervalQu
|
|
|
41
53
|
*
|
|
42
54
|
* @param semitones Interval size in semitones (may be negative or compound).
|
|
43
55
|
* @returns True for the unison/octave (0) and the perfect fifth (7) mod 12.
|
|
56
|
+
* @category Pitch & Intervals
|
|
44
57
|
*/
|
|
45
58
|
export function isPerfectInterval(semitones: number): boolean {
|
|
46
59
|
const pc = simpleInterval(semitones);
|
|
@@ -57,6 +70,7 @@ export function isPerfectInterval(semitones: number): boolean {
|
|
|
57
70
|
* @param semitones Interval size in semitones (may be negative or compound).
|
|
58
71
|
* @param twoVoice When true, the perfect fourth is treated as dissonant.
|
|
59
72
|
* @returns True if the interval is consonant in the given context.
|
|
73
|
+
* @category Pitch & Intervals
|
|
60
74
|
*/
|
|
61
75
|
export function isConsonantInterval(semitones: number, twoVoice = true): boolean {
|
|
62
76
|
const pc = simpleInterval(semitones);
|
|
@@ -6,13 +6,31 @@
|
|
|
6
6
|
* of the library's beat convention (four quarter-note beats per bar in 4/4).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* A time signature as a numerator over a note-value denominator.
|
|
11
|
+
*
|
|
12
|
+
* @category Rhythm & Meter
|
|
13
|
+
*/
|
|
10
14
|
export type TimeSignature = {
|
|
11
15
|
numerator: number;
|
|
12
16
|
denominator: number;
|
|
17
|
+
/**
|
|
18
|
+
* Optional additive grouping of the bar's main pulses, as the felt-beat
|
|
19
|
+
* lengths in pulses — e.g. `[2, 2, 3]` for a 2+2+3 reading of 7/8, or
|
|
20
|
+
* `[3, 2]` for 5/8. The entries must be positive integers summing to
|
|
21
|
+
* {@link pulsesPerBar}. When present, the head pulse of each group (other
|
|
22
|
+
* than the downbeat) is treated as a secondary strong pulse by
|
|
23
|
+
* {@link metricWeight}/{@link isStrongBeat}; when absent, all main pulses
|
|
24
|
+
* are weighted equally as flat, evenly divided pulses.
|
|
25
|
+
*/
|
|
26
|
+
grouping?: number[];
|
|
13
27
|
};
|
|
14
28
|
|
|
15
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* A position expressed as a bar index and a quarter-note offset within the bar.
|
|
31
|
+
*
|
|
32
|
+
* @category Rhythm & Meter
|
|
33
|
+
*/
|
|
16
34
|
export type BarPosition = {
|
|
17
35
|
bar: number;
|
|
18
36
|
/** Quarter-note offset from the start of the bar. */
|
|
@@ -36,6 +54,12 @@ function isMultiple(value: number, unit: number): boolean {
|
|
|
36
54
|
* @param text The signature text.
|
|
37
55
|
* @returns The parsed time signature.
|
|
38
56
|
* @throws If the text is not `n/d` with positive integers.
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { parseTimeSignature } from '@libraz/libcantus';
|
|
60
|
+
* parseTimeSignature('6/8'); // { numerator: 6, denominator: 8 }
|
|
61
|
+
* ```
|
|
62
|
+
* @category Rhythm & Meter
|
|
39
63
|
*/
|
|
40
64
|
export function parseTimeSignature(text: string): TimeSignature {
|
|
41
65
|
const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text);
|
|
@@ -50,7 +74,11 @@ export function parseTimeSignature(text: string): TimeSignature {
|
|
|
50
74
|
return { numerator, denominator };
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Render a time signature as `"n/d"`.
|
|
79
|
+
*
|
|
80
|
+
* @category Rhythm & Meter
|
|
81
|
+
*/
|
|
54
82
|
export function formatTimeSignature(ts: TimeSignature): string {
|
|
55
83
|
return `${ts.numerator}/${ts.denominator}`;
|
|
56
84
|
}
|
|
@@ -63,6 +91,7 @@ export function formatTimeSignature(ts: TimeSignature): string {
|
|
|
63
91
|
*
|
|
64
92
|
* @param ts The time signature.
|
|
65
93
|
* @returns True for compound meters.
|
|
94
|
+
* @category Rhythm & Meter
|
|
66
95
|
*/
|
|
67
96
|
export function isCompound(ts: TimeSignature): boolean {
|
|
68
97
|
return ts.numerator % 3 === 0 && ts.numerator > 3;
|
|
@@ -78,6 +107,7 @@ function unitBeats(ts: TimeSignature): number {
|
|
|
78
107
|
*
|
|
79
108
|
* @param ts The time signature.
|
|
80
109
|
* @returns The bar length in quarter notes.
|
|
110
|
+
* @category Rhythm & Meter
|
|
81
111
|
*/
|
|
82
112
|
export function beatsPerBar(ts: TimeSignature): number {
|
|
83
113
|
return ts.numerator * unitBeats(ts);
|
|
@@ -92,19 +122,63 @@ function pulseBeats(ts: TimeSignature): number {
|
|
|
92
122
|
* Number of main pulses (felt beats) per bar: the numerator for simple meters,
|
|
93
123
|
* a third of it for compound meters.
|
|
94
124
|
*
|
|
125
|
+
* For additive/irregular meters such as 7/8 or 5/8 this counts the raw pulses
|
|
126
|
+
* (7 and 5 respectively), which are otherwise felt as flat, evenly divided and
|
|
127
|
+
* equally accented pulses. To recover a 2+2+3 or 3+2 felt-beat grouping, set
|
|
128
|
+
* {@link TimeSignature.grouping}; {@link metricWeight} then accents each
|
|
129
|
+
* group's head pulse.
|
|
130
|
+
*
|
|
95
131
|
* @param ts The time signature.
|
|
96
132
|
* @returns The pulse count per bar.
|
|
133
|
+
* @category Rhythm & Meter
|
|
97
134
|
*/
|
|
98
135
|
export function pulsesPerBar(ts: TimeSignature): number {
|
|
99
136
|
return beatsPerBar(ts) / pulseBeats(ts);
|
|
100
137
|
}
|
|
101
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Whether `grouping` is a valid additive grouping for a bar of `pulses` main
|
|
141
|
+
* pulses: a non-empty list of positive integers summing to `pulses`.
|
|
142
|
+
*/
|
|
143
|
+
function isValidGrouping(grouping: number[], pulses: number): boolean {
|
|
144
|
+
if (grouping.length === 0) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
let sum = 0;
|
|
148
|
+
for (const g of grouping) {
|
|
149
|
+
if (!Number.isInteger(g) || g <= 0) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
sum += g;
|
|
153
|
+
}
|
|
154
|
+
return sum === pulses;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether `pulseIndex` is the head pulse of one of the additive groups. */
|
|
158
|
+
function isGroupHead(grouping: number[], pulseIndex: number): boolean {
|
|
159
|
+
let acc = 0;
|
|
160
|
+
for (const g of grouping) {
|
|
161
|
+
if (acc === pulseIndex) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
acc += g;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
102
169
|
/**
|
|
103
170
|
* Convert an absolute quarter-note position to a bar index and in-bar offset.
|
|
104
171
|
*
|
|
105
172
|
* @param beatInQuarters Absolute position in quarter-note beats.
|
|
106
173
|
* @param ts The time signature.
|
|
107
174
|
* @returns The bar and in-bar quarter-note offset.
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* import { parseTimeSignature, beatToBarPosition } from '@libraz/libcantus';
|
|
178
|
+
* const ts = parseTimeSignature('4/4');
|
|
179
|
+
* beatToBarPosition(5, ts); // { bar: 1, beat: 1 }
|
|
180
|
+
* ```
|
|
181
|
+
* @category Rhythm & Meter
|
|
108
182
|
*/
|
|
109
183
|
export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): BarPosition {
|
|
110
184
|
const barLen = beatsPerBar(ts);
|
|
@@ -119,6 +193,7 @@ export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): Ba
|
|
|
119
193
|
* @param pos The bar position.
|
|
120
194
|
* @param ts The time signature.
|
|
121
195
|
* @returns The absolute position in quarter-note beats.
|
|
196
|
+
* @category Rhythm & Meter
|
|
122
197
|
*/
|
|
123
198
|
export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
|
|
124
199
|
return pos.bar * beatsPerBar(ts) + pos.beat;
|
|
@@ -126,12 +201,28 @@ export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
|
|
|
126
201
|
|
|
127
202
|
/**
|
|
128
203
|
* Metric weight of a position within its bar, on a 0–3 scale:
|
|
129
|
-
* 3 the downbeat, 2 a secondary strong pulse
|
|
130
|
-
*
|
|
204
|
+
* 3 the downbeat, 2 a secondary strong pulse, 1 any other main pulse, and 0 an
|
|
205
|
+
* off-pulse subdivision.
|
|
206
|
+
*
|
|
207
|
+
* The secondary strong pulse is the bar's midpoint in even simple/compound
|
|
208
|
+
* meters. For an additive/irregular meter with {@link TimeSignature.grouping}
|
|
209
|
+
* set (e.g. `[2, 2, 3]` for 7/8), each group's head pulse other than the
|
|
210
|
+
* downbeat is the secondary strong pulse instead; without a grouping every
|
|
211
|
+
* non-downbeat main pulse of such a meter weighs 1 (flat, evenly divided
|
|
212
|
+
* pulses).
|
|
131
213
|
*
|
|
132
214
|
* @param beatInQuarters Absolute or in-bar quarter-note position.
|
|
133
215
|
* @param ts The time signature.
|
|
134
216
|
* @returns The metric weight (0–3).
|
|
217
|
+
* @throws If `ts.grouping` is present but is not a positive-integer list
|
|
218
|
+
* summing to {@link pulsesPerBar}.
|
|
219
|
+
* @example
|
|
220
|
+
* ```ts
|
|
221
|
+
* import { parseTimeSignature, metricWeight } from '@libraz/libcantus';
|
|
222
|
+
* const ts = parseTimeSignature('4/4');
|
|
223
|
+
* metricWeight(0, ts); // 3 (the downbeat)
|
|
224
|
+
* ```
|
|
225
|
+
* @category Rhythm & Meter
|
|
135
226
|
*/
|
|
136
227
|
export function metricWeight(beatInQuarters: number, ts: TimeSignature): number {
|
|
137
228
|
const { beat } = beatToBarPosition(beatInQuarters, ts);
|
|
@@ -144,6 +235,16 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
|
|
|
144
235
|
if (pulseIndex === 0) {
|
|
145
236
|
return 3;
|
|
146
237
|
}
|
|
238
|
+
const grouping = ts.grouping;
|
|
239
|
+
if (grouping !== undefined) {
|
|
240
|
+
if (!isValidGrouping(grouping, pulses)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Invalid grouping [${grouping.join(', ')}] for ${formatTimeSignature(ts)}: ` +
|
|
243
|
+
`entries must be positive integers summing to ${pulses}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return isGroupHead(grouping, pulseIndex) ? 2 : 1;
|
|
247
|
+
}
|
|
147
248
|
if (pulses % 2 === 0 && pulseIndex === pulses / 2) {
|
|
148
249
|
return 2;
|
|
149
250
|
}
|
|
@@ -157,6 +258,7 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
|
|
|
157
258
|
* @param beatInQuarters Absolute or in-bar quarter-note position.
|
|
158
259
|
* @param ts The time signature.
|
|
159
260
|
* @returns True on strong beats.
|
|
261
|
+
* @category Rhythm & Meter
|
|
160
262
|
*/
|
|
161
263
|
export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean {
|
|
162
264
|
return metricWeight(beatInQuarters, ts) >= 2;
|
|
@@ -170,6 +272,7 @@ export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean
|
|
|
170
272
|
* @param count Number of equal parts.
|
|
171
273
|
* @returns `count` equal durations summing to `totalBeats`.
|
|
172
274
|
* @throws If `count` is not a positive integer.
|
|
275
|
+
* @category Rhythm & Meter
|
|
173
276
|
*/
|
|
174
277
|
export function tuplet(totalBeats: number, count: number): number[] {
|
|
175
278
|
if (!Number.isInteger(count) || count <= 0) {
|