@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
generateSnareForBeat,
|
|
8
8
|
type SectionCtx,
|
|
9
9
|
} from './beat.js';
|
|
10
|
+
import { euclideanRhythm, patternToMask } from './euclid.js';
|
|
10
11
|
import { type FillType, generateFill, getFillStartBeat, selectFillType } from './fills.js';
|
|
11
12
|
import {
|
|
12
13
|
footHiHatVelocity,
|
|
@@ -34,28 +35,106 @@ import {
|
|
|
34
35
|
sectionDensityMultiplier,
|
|
35
36
|
sectionEnergy,
|
|
36
37
|
} from './internal.js';
|
|
37
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
euclideanToKickPattern,
|
|
40
|
+
getKickPattern,
|
|
41
|
+
isInPreChorusLift,
|
|
42
|
+
type KickPattern,
|
|
43
|
+
} from './kick.js';
|
|
38
44
|
import { generateAuxPercussionForBar, getPercussionConfig } from './percussion.js';
|
|
39
45
|
import { createRng } from './rng.js';
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A single drum onset emitted by {@link generateDrums}.
|
|
49
|
+
*
|
|
50
|
+
* @category Composition
|
|
51
|
+
*/
|
|
41
52
|
export type { DrumHit } from './hit.js';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Groove feel (the swing/straight rhythmic character) for {@link generateDrums}.
|
|
56
|
+
*
|
|
57
|
+
* @category Composition
|
|
58
|
+
*/
|
|
42
59
|
export type GrooveFeel = Feel;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drum voicing role and groove style identifiers for {@link generateDrums}.
|
|
63
|
+
*
|
|
64
|
+
* @category Composition
|
|
65
|
+
*/
|
|
43
66
|
export type { DrumRole, GrooveStyle } from './internal.js';
|
|
44
67
|
|
|
45
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Public section identifiers for {@link generateDrums}.
|
|
70
|
+
*
|
|
71
|
+
* @category Composition
|
|
72
|
+
*/
|
|
46
73
|
export type Section = PublicSection;
|
|
47
74
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
75
|
+
/**
|
|
76
|
+
* A Euclidean (Bjorklund) kick pattern for {@link generateDrums}.
|
|
77
|
+
*
|
|
78
|
+
* @category Composition
|
|
79
|
+
*/
|
|
80
|
+
export type EuclideanKick = {
|
|
81
|
+
/** Number of kick onsets, clamped to `[0, steps]`. */
|
|
82
|
+
pulses: number;
|
|
83
|
+
/**
|
|
84
|
+
* Total steps in the bar (1..16).
|
|
85
|
+
*
|
|
86
|
+
* @defaultValue 16
|
|
87
|
+
*/
|
|
88
|
+
steps?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Steps to rotate the onsets toward later positions.
|
|
91
|
+
*
|
|
92
|
+
* @defaultValue 0
|
|
93
|
+
*/
|
|
94
|
+
rotation?: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Options controlling {@link generateDrums}.
|
|
99
|
+
*
|
|
100
|
+
* @category Composition
|
|
101
|
+
*/
|
|
102
|
+
export type DrumsOptions = {
|
|
50
103
|
bars: number;
|
|
51
104
|
bpm: number;
|
|
52
105
|
style: GrooveStyle;
|
|
53
106
|
section: Section;
|
|
54
107
|
density: number;
|
|
108
|
+
/**
|
|
109
|
+
* Replace the final bar with a fill.
|
|
110
|
+
*
|
|
111
|
+
* @defaultValue false
|
|
112
|
+
*/
|
|
55
113
|
fills?: boolean;
|
|
56
114
|
feel?: GrooveFeel;
|
|
115
|
+
/**
|
|
116
|
+
* Voicing role; `'fxOnly'` suppresses the main kick/snare/ghost/fill voices.
|
|
117
|
+
*
|
|
118
|
+
* @defaultValue `'full'`
|
|
119
|
+
*/
|
|
57
120
|
role?: DrumRole;
|
|
121
|
+
/**
|
|
122
|
+
* Seed for the deterministic PRNG.
|
|
123
|
+
*
|
|
124
|
+
* @defaultValue 0
|
|
125
|
+
*/
|
|
58
126
|
seed?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Section the final-bar fill leads into. Shapes which fill archetype is
|
|
129
|
+
* chosen (into-chorus and out-of-intro fills differ from generic ones).
|
|
130
|
+
* Defaults to `section`, i.e. a within-section fill.
|
|
131
|
+
*/
|
|
132
|
+
nextSection?: Section;
|
|
133
|
+
/**
|
|
134
|
+
* When set, the kick follows this Euclidean rhythm instead of the
|
|
135
|
+
* style/section pattern, giving direct access to evenly spread onsets.
|
|
136
|
+
*/
|
|
137
|
+
euclideanKick?: EuclideanKick;
|
|
59
138
|
};
|
|
60
139
|
|
|
61
140
|
/**
|
|
@@ -66,13 +145,25 @@ export type DrumGenOptions = {
|
|
|
66
145
|
* by its General MIDI pitch. Groove style selects an internal style and feel;
|
|
67
146
|
* `density` sets the backing-density level; `section` shapes kick, hi-hat, ghost,
|
|
68
147
|
* and percussion density. 16th-note hi-hats drop to 8ths at or above 150 BPM.
|
|
69
|
-
* When `fills` is true the final bar is replaced with a fill
|
|
148
|
+
* When `fills` is true the final bar is replaced with a fill whose archetype is
|
|
149
|
+
* shaped by `nextSection`; a fill that would emit nothing on its beat falls back
|
|
150
|
+
* to the normal groove so the phrase end is never silent. `euclideanKick`
|
|
151
|
+
* overrides the kick with an evenly-spread Euclidean pattern. Output is fully
|
|
70
152
|
* determined by the options plus `seed`.
|
|
71
153
|
*
|
|
72
154
|
* @param opts Generation options.
|
|
73
155
|
* @returns Percussion onsets in bar order.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* import { generateDrums } from '@libraz/libcantus';
|
|
160
|
+
* const hits = generateDrums({ bars: 4, bpm: 120, style: 'standard', section: 'chorus', density: 0.6, fills: true });
|
|
161
|
+
* // Fully determined by the options plus seed (defaults to 0).
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* @category Composition
|
|
74
165
|
*/
|
|
75
|
-
export function generateDrums(opts:
|
|
166
|
+
export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
76
167
|
const track = new HitList();
|
|
77
168
|
const rng = createRng(opts.seed ?? 0);
|
|
78
169
|
const mapping = mapStyle(opts.style);
|
|
@@ -114,6 +205,23 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
|
|
|
114
205
|
const fillStartBeat = getFillStartBeat(energy);
|
|
115
206
|
const percMood = percMoodCategory(style);
|
|
116
207
|
|
|
208
|
+
// The final-bar fill is shaped by the section it leads into. When no next
|
|
209
|
+
// section is given the fill is treated as within-section (from === to).
|
|
210
|
+
const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
|
|
211
|
+
const nextEnergy = sectionEnergy(nextSection);
|
|
212
|
+
|
|
213
|
+
const euclidKick: KickPattern | undefined = opts.euclideanKick
|
|
214
|
+
? euclideanToKickPattern(
|
|
215
|
+
patternToMask(
|
|
216
|
+
euclideanRhythm(
|
|
217
|
+
opts.euclideanKick.pulses,
|
|
218
|
+
opts.euclideanKick.steps ?? 16,
|
|
219
|
+
opts.euclideanKick.rotation ?? 0,
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
: undefined;
|
|
224
|
+
|
|
117
225
|
const reuseSectionKick = (section === 'b' || section === 'chorus') && style !== 'sparse';
|
|
118
226
|
let sectionKick: KickPattern | undefined;
|
|
119
227
|
|
|
@@ -133,7 +241,9 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
|
|
|
133
241
|
}
|
|
134
242
|
|
|
135
243
|
let kick: KickPattern;
|
|
136
|
-
if (
|
|
244
|
+
if (euclidKick) {
|
|
245
|
+
kick = euclidKick;
|
|
246
|
+
} else if (reuseSectionKick) {
|
|
137
247
|
sectionKick ??= getKickPattern(section, style, 0, rng);
|
|
138
248
|
kick = sectionKick;
|
|
139
249
|
} else {
|
|
@@ -149,12 +259,20 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
|
|
|
149
259
|
|
|
150
260
|
if (opts.fills && isLastBar && !inLift && beat >= fillStartBeat) {
|
|
151
261
|
if (beat === fillStartBeat) {
|
|
152
|
-
currentFill = selectFillType(section,
|
|
262
|
+
currentFill = selectFillType(section, nextSection, style, nextEnergy, rng);
|
|
153
263
|
}
|
|
154
264
|
if (playMainVoices) {
|
|
265
|
+
const before = track.hits.length;
|
|
155
266
|
generateFill(track, beatTick, beat, currentFill, velocity);
|
|
267
|
+
if (track.hits.length > before) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Safety net: an archetype that contributes nothing on this beat would
|
|
271
|
+
// leave a silent phrase end, so fall through to the normal groove.
|
|
272
|
+
} else {
|
|
273
|
+
// fxOnly deliberately emits no main voices on the fill beats.
|
|
274
|
+
continue;
|
|
156
275
|
}
|
|
157
|
-
continue;
|
|
158
276
|
}
|
|
159
277
|
|
|
160
278
|
const ctx: BeatCtx = {
|
|
@@ -67,7 +67,9 @@ export type GrooveStyle =
|
|
|
67
67
|
| 'bossa'
|
|
68
68
|
| 'trap'
|
|
69
69
|
| 'halftime'
|
|
70
|
-
| 'breakbeat'
|
|
70
|
+
| 'breakbeat'
|
|
71
|
+
| 'house'
|
|
72
|
+
| 'synthpop';
|
|
71
73
|
|
|
72
74
|
/** Public role identifiers gating which voices are present. */
|
|
73
75
|
export type DrumRole = 'full' | 'ambient' | 'minimal' | 'fxOnly';
|
|
@@ -92,6 +94,10 @@ const STYLE_MAP: Record<GrooveStyle, StyleMapping> = {
|
|
|
92
94
|
trap: { style: 'trap', feel: 'straight', ghostBoost: false, snareBeat3: true },
|
|
93
95
|
halftime: { style: 'sparse', feel: 'straight', ghostBoost: false, snareBeat3: true },
|
|
94
96
|
breakbeat: { style: 'rock', feel: 'straight', ghostBoost: false, snareBeat3: false },
|
|
97
|
+
// Four-on-the-floor dance kick with idol-style auxiliary percussion.
|
|
98
|
+
house: { style: 'fourOnFloor', feel: 'straight', ghostBoost: false, snareBeat3: false },
|
|
99
|
+
// Synth-pop pulse with denser 16th hi-hats and busy kick syncopation.
|
|
100
|
+
synthpop: { style: 'synth', feel: 'straight', ghostBoost: true, snareBeat3: false },
|
|
95
101
|
};
|
|
96
102
|
|
|
97
103
|
/** Resolve the internal style/feel parameters for a public groove style. */
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* `DrumRng` is retained as the drum-facing name of the shared {@link Rng} type.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export type { Rng as DrumRng } from '
|
|
7
|
-
export { createRng } from '
|
|
6
|
+
export type { Rng as DrumRng } from '../../core/random/index.js';
|
|
7
|
+
export { createRng } from '../../core/random/index.js';
|
|
@@ -53,8 +53,13 @@ export function quantizeSwing(
|
|
|
53
53
|
return base + EIGHTH + DELTA_8 * clamped;
|
|
54
54
|
}
|
|
55
55
|
if (offset >= EIGHTH + HALF_16TH && offset < 3 * SIXTEENTH + HALF_16TH) {
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// The "a" 16th is the midpoint of the compressed second half of the beat
|
|
57
|
+
// (between the swung "and" and the next downbeat), mirroring the "e" 16th
|
|
58
|
+
// in the first half. Because DELTA_8 = 2 * DELTA_16, that midpoint reduces
|
|
59
|
+
// to a single DELTA_16 delay off the straight position, so the "a" never
|
|
60
|
+
// overshoots the downbeat and needs no clamp. At the default shuffle feel
|
|
61
|
+
// (swing 0.75) this lands near 0.8125 rather than the doubly-swung 0.9375.
|
|
62
|
+
return base + 3 * SIXTEENTH + DELTA_16 * clamped;
|
|
58
63
|
}
|
|
59
64
|
return startBeat;
|
|
60
65
|
}
|
|
@@ -8,23 +8,56 @@
|
|
|
8
8
|
* seeded PRNG from the `random` module so results are fully reproducible.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
import {
|
|
12
|
+
beatsPerBar,
|
|
13
|
+
formatTimeSignature,
|
|
14
|
+
metricWeight,
|
|
15
|
+
type TimeSignature,
|
|
16
|
+
} from '../../core/meter/index.js';
|
|
17
|
+
import { createRng } from '../../core/random/index.js';
|
|
18
|
+
import type { NoteEvent } from '../../core/types.js';
|
|
14
19
|
|
|
15
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Options controlling {@link humanize}.
|
|
22
|
+
*
|
|
23
|
+
* @category Rhythm & Meter
|
|
24
|
+
*/
|
|
16
25
|
export type HumanizeOptions = {
|
|
17
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Time signature used to derive metric accents.
|
|
28
|
+
*
|
|
29
|
+
* @defaultValue 4/4
|
|
30
|
+
*/
|
|
18
31
|
ts?: TimeSignature;
|
|
19
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Maximum timing jitter in quarter-note beats, applied as ±this value.
|
|
34
|
+
*
|
|
35
|
+
* @defaultValue 0.02
|
|
36
|
+
*/
|
|
20
37
|
timing?: number;
|
|
21
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Maximum velocity jitter (MIDI units), applied as ±this value.
|
|
40
|
+
*
|
|
41
|
+
* @defaultValue 8
|
|
42
|
+
*/
|
|
22
43
|
velocity?: number;
|
|
23
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* How much louder strong beats get relative to weak ones, in MIDI velocity units.
|
|
46
|
+
*
|
|
47
|
+
* @defaultValue 12
|
|
48
|
+
*/
|
|
24
49
|
accent?: number;
|
|
25
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Velocity assumed for events with no `velocity` of their own.
|
|
52
|
+
*
|
|
53
|
+
* @defaultValue 80
|
|
54
|
+
*/
|
|
26
55
|
baseVelocity?: number;
|
|
27
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Seed for the deterministic PRNG.
|
|
58
|
+
*
|
|
59
|
+
* @defaultValue 0
|
|
60
|
+
*/
|
|
28
61
|
seed?: number;
|
|
29
62
|
};
|
|
30
63
|
|
|
@@ -56,6 +89,13 @@ const MAX_VELOCITY = 127;
|
|
|
56
89
|
* @param events The events to humanize.
|
|
57
90
|
* @param opts Humanization options.
|
|
58
91
|
* @returns Humanized copies of `events`, in input order.
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* import { humanize } from '@libraz/libcantus';
|
|
95
|
+
* const events = [{ pitch: 60, startBeat: 0, durationBeat: 1, velocity: 80 }];
|
|
96
|
+
* humanize(events, { seed: 1, timing: 0.03 }); // copies with jittered timing and accented velocity
|
|
97
|
+
* ```
|
|
98
|
+
* @category Rhythm & Meter
|
|
59
99
|
*/
|
|
60
100
|
export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteEvent[] {
|
|
61
101
|
const ts = opts.ts ?? DEFAULT_TS;
|
|
@@ -84,15 +124,27 @@ export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteE
|
|
|
84
124
|
});
|
|
85
125
|
}
|
|
86
126
|
|
|
87
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* The recorded feel at a single grid slot: an average timing deviation and velocity.
|
|
129
|
+
*
|
|
130
|
+
* @category Rhythm & Meter
|
|
131
|
+
*/
|
|
88
132
|
export type GrooveSlot = {
|
|
89
133
|
/** Average `actualStartBeat - quantizedBeat` (in quarter-note beats) for events landing on this slot. */
|
|
90
134
|
timingOffset: number;
|
|
91
|
-
/**
|
|
92
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Average velocity of events landing on this slot, or `null` if no event
|
|
137
|
+
* with a velocity did. `null` (not 0) is the "unrecorded" sentinel so that a
|
|
138
|
+
* genuine velocity of 0 is preserved and applied like any other value.
|
|
139
|
+
*/
|
|
140
|
+
velocity: number | null;
|
|
93
141
|
};
|
|
94
142
|
|
|
95
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* A per-bar grid of timing and velocity deviations captured from a performance.
|
|
145
|
+
*
|
|
146
|
+
* @category Rhythm & Meter
|
|
147
|
+
*/
|
|
96
148
|
export type GrooveTemplate = {
|
|
97
149
|
/** Grid resolution: equal grid steps per quarter-note beat. */
|
|
98
150
|
subdivision: number;
|
|
@@ -100,6 +152,13 @@ export type GrooveTemplate = {
|
|
|
100
152
|
slotsPerBar: number;
|
|
101
153
|
/** One entry per grid slot, in slot-index order (slot 0 is the downbeat). */
|
|
102
154
|
slots: GrooveSlot[];
|
|
155
|
+
/**
|
|
156
|
+
* The time signature the template was extracted under. {@link applyGrooveTemplate}
|
|
157
|
+
* requires the apply-time meter to match this so the per-bar grid lines up
|
|
158
|
+
* instead of silently drifting. Optional for backward compatibility with
|
|
159
|
+
* templates constructed without it (in which case no check is performed).
|
|
160
|
+
*/
|
|
161
|
+
ts?: TimeSignature;
|
|
103
162
|
};
|
|
104
163
|
|
|
105
164
|
/**
|
|
@@ -137,13 +196,16 @@ function quantizeToGrid(
|
|
|
137
196
|
/**
|
|
138
197
|
* Extract a groove template from a set of note events: for each grid slot,
|
|
139
198
|
* the average timing deviation from the quantized grid and the average
|
|
140
|
-
* velocity of events landing on it. Slots with no
|
|
141
|
-
* `
|
|
199
|
+
* velocity of events landing on it. Slots with no velocity-carrying event
|
|
200
|
+
* record `velocity: null` (the "unrecorded" sentinel); empty slots default to
|
|
201
|
+
* `{ timingOffset: 0, velocity: null }`. The time signature is stored on the
|
|
202
|
+
* template so {@link applyGrooveTemplate} can verify the meter matches.
|
|
142
203
|
*
|
|
143
204
|
* @param events The (typically "groovy", human-played) events to analyze.
|
|
144
205
|
* @param ts The time signature, used to compute the bar length.
|
|
145
206
|
* @param subdivision Grid resolution: equal grid steps per quarter-note beat.
|
|
146
207
|
* @returns The extracted groove template.
|
|
208
|
+
* @category Rhythm & Meter
|
|
147
209
|
*/
|
|
148
210
|
export function extractGrooveTemplate(
|
|
149
211
|
events: NoteEvent[],
|
|
@@ -178,36 +240,67 @@ export function extractGrooveTemplate(
|
|
|
178
240
|
for (let i = 0; i < slotsPerBar; i += 1) {
|
|
179
241
|
const count = counts[i] ?? 0;
|
|
180
242
|
if (count === 0) {
|
|
181
|
-
slots.push({ timingOffset: 0, velocity:
|
|
243
|
+
slots.push({ timingOffset: 0, velocity: null });
|
|
182
244
|
continue;
|
|
183
245
|
}
|
|
184
246
|
const velocityCount = velocityCounts[i] ?? 0;
|
|
185
247
|
slots.push({
|
|
186
248
|
timingOffset: (offsetSums[i] ?? 0) / count,
|
|
187
|
-
velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount :
|
|
249
|
+
velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : null,
|
|
188
250
|
});
|
|
189
251
|
}
|
|
190
252
|
|
|
191
|
-
return { subdivision, slotsPerBar, slots };
|
|
253
|
+
return { subdivision, slotsPerBar, slots, ts };
|
|
192
254
|
}
|
|
193
255
|
|
|
194
256
|
/**
|
|
195
257
|
* Apply a groove template to a set of (typically quantized) note events: each
|
|
196
258
|
* event is snapped to its grid slot and then offset by that slot's recorded
|
|
197
259
|
* timing deviation, pushing stiff timing toward the template's feel.
|
|
198
|
-
* Velocity is set to the slot's recorded average when it recorded
|
|
199
|
-
*
|
|
260
|
+
* Velocity is set to the slot's recorded average when it recorded one (a
|
|
261
|
+
* non-`null` velocity, including a genuine 0); otherwise the event's own
|
|
262
|
+
* velocity is left untouched.
|
|
263
|
+
*
|
|
264
|
+
* If the template carries a time signature (see {@link GrooveTemplate.ts}) it
|
|
265
|
+
* must match `ts`, since a differing bar length would misalign the per-bar
|
|
266
|
+
* grid and drift the feel; a mismatch throws rather than corrupting timing
|
|
267
|
+
* silently.
|
|
200
268
|
*
|
|
201
269
|
* @param events The events to reshape.
|
|
202
270
|
* @param template The groove template, from {@link extractGrooveTemplate}.
|
|
203
271
|
* @param ts The time signature, used to compute the bar length.
|
|
204
272
|
* @returns Reshaped copies of `events`, in input order.
|
|
273
|
+
* @throws If `template.ts` is set and does not match `ts`.
|
|
274
|
+
* @example
|
|
275
|
+
* ```ts
|
|
276
|
+
* import { extractGrooveTemplate, applyGrooveTemplate, parseTimeSignature } from '@libraz/libcantus';
|
|
277
|
+
* const ts = parseTimeSignature('4/4');
|
|
278
|
+
* // Learn the feel from a loosely played performance...
|
|
279
|
+
* const groovy = [
|
|
280
|
+
* { pitch: 36, startBeat: 0.02, durationBeat: 1, velocity: 100 },
|
|
281
|
+
* { pitch: 38, startBeat: 1.06, durationBeat: 1, velocity: 70 },
|
|
282
|
+
* ];
|
|
283
|
+
* const template = extractGrooveTemplate(groovy, ts, 4);
|
|
284
|
+
* // ...then impose it on stiff, quantized events.
|
|
285
|
+
* const quantized = [{ pitch: 36, startBeat: 0, durationBeat: 1, velocity: 80 }];
|
|
286
|
+
* applyGrooveTemplate(quantized, template, ts); // events nudged toward the captured feel
|
|
287
|
+
* ```
|
|
288
|
+
* @category Rhythm & Meter
|
|
205
289
|
*/
|
|
206
290
|
export function applyGrooveTemplate(
|
|
207
291
|
events: NoteEvent[],
|
|
208
292
|
template: GrooveTemplate,
|
|
209
293
|
ts: TimeSignature,
|
|
210
294
|
): NoteEvent[] {
|
|
295
|
+
if (
|
|
296
|
+
template.ts &&
|
|
297
|
+
(template.ts.numerator !== ts.numerator || template.ts.denominator !== ts.denominator)
|
|
298
|
+
) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Groove template meter ${formatTimeSignature(template.ts)} does not match ` +
|
|
301
|
+
`apply-time meter ${formatTimeSignature(ts)}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
211
304
|
const barBeats = beatsPerBar(ts);
|
|
212
305
|
return events.map((event) => {
|
|
213
306
|
const { quantizedBeat, slotIndex } = quantizeToGrid(
|
|
@@ -218,7 +311,10 @@ export function applyGrooveTemplate(
|
|
|
218
311
|
);
|
|
219
312
|
const slot = template.slots[slotIndex];
|
|
220
313
|
const startBeat = quantizedBeat + (slot?.timingOffset ?? 0);
|
|
221
|
-
const velocity =
|
|
314
|
+
const velocity =
|
|
315
|
+
slot && slot.velocity !== null && slot.velocity !== undefined
|
|
316
|
+
? Math.round(slot.velocity)
|
|
317
|
+
: event.velocity;
|
|
222
318
|
|
|
223
319
|
return {
|
|
224
320
|
pitch: event.pitch,
|
|
@@ -1,31 +1,54 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
1
|
+
import type { TimeSignature } from '../../core/meter/index.js';
|
|
2
|
+
import { isStrongBeat } from '../../core/meter/index.js';
|
|
3
|
+
import type { KeyScale, NoteEvent } from '../../core/types.js';
|
|
4
|
+
import type { Chord, ChordQuality } from '../../theory/chord/index.js';
|
|
5
|
+
import { chordPitchClasses, diatonicTriad, makeChord } from '../../theory/chord/index.js';
|
|
6
|
+
import type { HarmonyRole } from '../../theory/harmony/index.js';
|
|
7
|
+
import { roleOf } from '../../theory/harmony/index.js';
|
|
8
|
+
import {
|
|
9
|
+
isScaleTone,
|
|
10
|
+
majorKey,
|
|
11
|
+
minorKey,
|
|
12
|
+
NATURAL_MINOR_MASK,
|
|
13
|
+
scaleTonesInDegreeOrder,
|
|
14
|
+
} from '../../theory/scale/index.js';
|
|
15
|
+
import type { ChordSpan } from '../progression/index.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;
|
|
28
|
-
chords:
|
|
51
|
+
chords: ChordSpan[];
|
|
29
52
|
melodyRoles: { noteIndex: number; role: HarmonyRole }[];
|
|
30
53
|
};
|
|
31
54
|
|
|
@@ -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 ChordSpan 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);
|
|
@@ -340,9 +413,9 @@ export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
|
|
|
340
413
|
}
|
|
341
414
|
}
|
|
342
415
|
|
|
343
|
-
const chords:
|
|
416
|
+
const chords: ChordSpan[] = bestPath.map((ci, s) => {
|
|
344
417
|
const cand = candAt(ci);
|
|
345
|
-
const chord:
|
|
418
|
+
const chord: ChordSpan = {
|
|
346
419
|
rootPc: cand.rootPc,
|
|
347
420
|
quality: cand.quality,
|
|
348
421
|
startBeat: segments[s]?.startBeat ?? s * hr,
|