@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/harmonize/index.ts
CHANGED
|
@@ -5,23 +5,46 @@ import { roleOf } from '../harmony/index.js';
|
|
|
5
5
|
import type { TimeSignature } from '../meter/index.js';
|
|
6
6
|
import { isStrongBeat } from '../meter/index.js';
|
|
7
7
|
import type { GeneratedChord } from '../progression/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isScaleTone,
|
|
10
|
+
majorKey,
|
|
11
|
+
minorKey,
|
|
12
|
+
NATURAL_MINOR_MASK,
|
|
13
|
+
scaleTonesInDegreeOrder,
|
|
14
|
+
} from '../scale/index.js';
|
|
9
15
|
import type { KeyScale, NoteEvent } from '../types.js';
|
|
10
16
|
|
|
11
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* A melody note supplied to the harmonizer: a {@link NoteEvent}.
|
|
19
|
+
*
|
|
20
|
+
* @category Reharmonization
|
|
21
|
+
*/
|
|
12
22
|
export type MelodyNote = NoteEvent;
|
|
13
23
|
|
|
14
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Options controlling {@link harmonizeMelody}.
|
|
26
|
+
*
|
|
27
|
+
* @category Reharmonization
|
|
28
|
+
*/
|
|
15
29
|
export type HarmonizeOptions = {
|
|
16
30
|
melody: MelodyNote[];
|
|
17
31
|
key: KeyScale | 'infer';
|
|
18
32
|
harmonicRhythm: number;
|
|
19
33
|
reharmonize: 'diatonic' | 'secondaryDominant' | 'borrowed';
|
|
20
34
|
placement: { transposeSearch: boolean; octaveSearch: boolean };
|
|
35
|
+
/**
|
|
36
|
+
* Seed for the deterministic tie-break perturbation.
|
|
37
|
+
*
|
|
38
|
+
* @defaultValue 0
|
|
39
|
+
*/
|
|
21
40
|
seed?: number;
|
|
22
41
|
};
|
|
23
42
|
|
|
24
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The chosen transpose, key, chord path, and per-note roles.
|
|
45
|
+
*
|
|
46
|
+
* @category Reharmonization
|
|
47
|
+
*/
|
|
25
48
|
export type HarmonizeResult = {
|
|
26
49
|
transposeSemitones: number;
|
|
27
50
|
key: KeyScale;
|
|
@@ -50,6 +73,15 @@ const COMFORT_LOW = 55;
|
|
|
50
73
|
const COMFORT_HIGH = 79;
|
|
51
74
|
const TESSITURA_WEIGHT = 0.001;
|
|
52
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Magnitude of the seed-driven per-candidate perturbation. Kept far below the
|
|
78
|
+
* smallest real cost difference (transition/emission terms are on the order of
|
|
79
|
+
* 0.3 and up), so it can only decide between candidates or paths whose costs are
|
|
80
|
+
* otherwise exactly equal. The seed therefore breaks ties deterministically and
|
|
81
|
+
* never overrides melody fit or functional flow.
|
|
82
|
+
*/
|
|
83
|
+
const TIE_BREAK_JITTER = 1e-6;
|
|
84
|
+
|
|
53
85
|
function pitchClass(pitch: number): number {
|
|
54
86
|
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
55
87
|
}
|
|
@@ -65,28 +97,47 @@ function mulberry32(seed: number): () => number {
|
|
|
65
97
|
};
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Estimate the best-fit key from a melody's pitch-class weighting.
|
|
102
|
+
*
|
|
103
|
+
* Both major and natural-minor tonics are scored, so a minor melody is
|
|
104
|
+
* recognized as its own minor key rather than being folded into the relative
|
|
105
|
+
* major (which shares identical pitch-class content). Each in-scale note adds
|
|
106
|
+
* its duration weight; notes matching the candidate tonic add a further
|
|
107
|
+
* half-weight, and that tonic emphasis is what separates a minor key from its
|
|
108
|
+
* relative major. For minor candidates the raised leading tone (the
|
|
109
|
+
* harmonic-minor seventh) also counts as in-scale, so cadential leading tones do
|
|
110
|
+
* not penalize the true minor key. Ties are broken toward the earlier candidate,
|
|
111
|
+
* and major is scored before minor at each tonic.
|
|
112
|
+
*/
|
|
69
113
|
function inferKey(melody: MelodyNote[]): KeyScale {
|
|
70
|
-
let best = 0;
|
|
114
|
+
let best: KeyScale = majorKey(0);
|
|
71
115
|
let bestScore = Number.NEGATIVE_INFINITY;
|
|
116
|
+
const candidates: KeyScale[] = [];
|
|
72
117
|
for (let tonic = 0; tonic < 12; tonic += 1) {
|
|
73
|
-
|
|
118
|
+
candidates.push(majorKey(tonic), minorKey(tonic));
|
|
119
|
+
}
|
|
120
|
+
for (const key of candidates) {
|
|
121
|
+
const tonic = pitchClass(key.rootPc);
|
|
122
|
+
const isMinor = key.modeMask12 === NATURAL_MINOR_MASK;
|
|
123
|
+
const leadingTone = (tonic + 11) % 12;
|
|
74
124
|
let score = 0;
|
|
75
125
|
for (const n of melody) {
|
|
76
126
|
const w = Math.max(0.25, n.durationBeat);
|
|
77
|
-
|
|
127
|
+
const pc = pitchClass(n.pitch);
|
|
128
|
+
if (isScaleTone(n.pitch, key) || (isMinor && pc === leadingTone)) {
|
|
78
129
|
score += w;
|
|
79
130
|
}
|
|
80
|
-
if (
|
|
131
|
+
if (pc === tonic) {
|
|
81
132
|
score += 0.5 * w;
|
|
82
133
|
}
|
|
83
134
|
}
|
|
84
135
|
if (score > bestScore) {
|
|
85
136
|
bestScore = score;
|
|
86
|
-
best =
|
|
137
|
+
best = key;
|
|
87
138
|
}
|
|
88
139
|
}
|
|
89
|
-
return
|
|
140
|
+
return best;
|
|
90
141
|
}
|
|
91
142
|
|
|
92
143
|
/** Enumerate candidate chords for the key, gated by reharmonization strength. */
|
|
@@ -283,16 +334,38 @@ function harmonizeOnce(
|
|
|
283
334
|
* `(transpose, progression)` pair is returned; a small tessitura cost breaks
|
|
284
335
|
* ties toward placements that keep the melody in a comfortable register.
|
|
285
336
|
*
|
|
337
|
+
* The `seed` drives a deterministic tie-break only: it perturbs candidates by a
|
|
338
|
+
* magnitude far below any real cost difference (see {@link TIE_BREAK_JITTER}),
|
|
339
|
+
* so it can decide between chords or paths of otherwise-equal cost but never
|
|
340
|
+
* overrides melody fit or functional flow. For a well-determined melody the
|
|
341
|
+
* result is identical across seeds; the same seed always yields the same result.
|
|
342
|
+
*
|
|
286
343
|
* @param opts Melody, key (or `'infer'`), harmonic rhythm, reharmonization
|
|
287
344
|
* strength, height-search flags, and seed.
|
|
288
345
|
* @returns The chosen transpose, key, chord path, and per-note roles.
|
|
346
|
+
* @example
|
|
347
|
+
* ```ts
|
|
348
|
+
* import { harmonizeMelody, majorKey } from '@libraz/libcantus';
|
|
349
|
+
* const result = harmonizeMelody({
|
|
350
|
+
* melody: [
|
|
351
|
+
* { pitch: 60, startBeat: 0, durationBeat: 1 },
|
|
352
|
+
* { pitch: 64, startBeat: 1, durationBeat: 1 },
|
|
353
|
+
* ],
|
|
354
|
+
* key: majorKey(0),
|
|
355
|
+
* harmonicRhythm: 1,
|
|
356
|
+
* reharmonize: 'diatonic',
|
|
357
|
+
* placement: { transposeSearch: false, octaveSearch: false },
|
|
358
|
+
* });
|
|
359
|
+
* result.chords; // one GeneratedChord per harmonic-rhythm segment
|
|
360
|
+
* ```
|
|
361
|
+
* @category Reharmonization
|
|
289
362
|
*/
|
|
290
363
|
export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
|
|
291
364
|
const key = opts.key === 'infer' ? inferKey(opts.melody) : opts.key;
|
|
292
365
|
const candidates = buildCandidates(key, opts.reharmonize);
|
|
293
366
|
const candAt = (i: number): Candidate => candidates[i] ?? FALLBACK;
|
|
294
367
|
const rng = mulberry32(opts.seed ?? 0);
|
|
295
|
-
const jitter = candidates.map(() => rng() *
|
|
368
|
+
const jitter = candidates.map(() => rng() * TIE_BREAK_JITTER);
|
|
296
369
|
|
|
297
370
|
const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
|
|
298
371
|
const hr = Math.max(0.25, opts.harmonicRhythm);
|
package/src/harmony/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Chord } from '../chord/index.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Harmonic role a pitch plays within a chord.
|
|
5
|
+
*
|
|
6
|
+
* @category Functional Harmony
|
|
7
|
+
*/
|
|
4
8
|
export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'tension' | 'doubling';
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -9,10 +13,16 @@ export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'te
|
|
|
9
13
|
* - `identity`: moving it produces a different chord (the root).
|
|
10
14
|
* - `quality`: moving it flips the chord quality (the third).
|
|
11
15
|
* - `voicing`: it can move freely without changing chord identity or quality.
|
|
16
|
+
*
|
|
17
|
+
* @category Functional Harmony
|
|
12
18
|
*/
|
|
13
19
|
export type LockLevel = 'identity' | 'quality' | 'voicing';
|
|
14
20
|
|
|
15
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* A pitch's role, lock level, and owning chord.
|
|
23
|
+
*
|
|
24
|
+
* @category Functional Harmony
|
|
25
|
+
*/
|
|
16
26
|
export type VoicedRole = {
|
|
17
27
|
role: HarmonyRole;
|
|
18
28
|
lock: LockLevel;
|
|
@@ -36,6 +46,13 @@ export type VoicedRole = {
|
|
|
36
46
|
* @param chord The chord providing the root reference.
|
|
37
47
|
* @param chordId Identifier stored on the result (defaults to 0).
|
|
38
48
|
* @returns The pitch's role, lock level, and owning chord id.
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* import { roleOf, makeChord } from '@libraz/libcantus';
|
|
52
|
+
* roleOf(64, makeChord(0, 'maj')); // E over C major
|
|
53
|
+
* // { role: 'third', lock: 'quality', belongsToChordId: 0 }
|
|
54
|
+
* ```
|
|
55
|
+
* @category Functional Harmony
|
|
39
56
|
*/
|
|
40
57
|
export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
|
|
41
58
|
const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
|
package/src/index.ts
CHANGED
package/src/interval/index.ts
CHANGED
|
@@ -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);
|
package/src/meter/index.ts
CHANGED
|
@@ -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) {
|