@libraz/libcantus 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +291 -0
- package/dist/index.cjs +6465 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2621 -0
- package/dist/index.d.ts +2621 -0
- package/dist/index.js +6297 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
- package/src/analysis/index.ts +261 -0
- package/src/arrange/index.ts +409 -0
- package/src/bass/index.ts +301 -0
- package/src/chord/index.ts +292 -0
- package/src/chordscale/index.ts +316 -0
- package/src/countermelody/index.ts +349 -0
- package/src/counterpoint/index.ts +209 -0
- package/src/detect/index.ts +176 -0
- package/src/drums/beat.ts +266 -0
- package/src/drums/euclid.ts +58 -0
- package/src/drums/fills.ts +345 -0
- package/src/drums/ghost.ts +119 -0
- package/src/drums/hihat.ts +285 -0
- package/src/drums/hit.ts +31 -0
- package/src/drums/index.ts +211 -0
- package/src/drums/internal.ts +251 -0
- package/src/drums/kick.ts +160 -0
- package/src/drums/percussion.ts +121 -0
- package/src/drums/rng.ts +7 -0
- package/src/drums/swing.ts +66 -0
- package/src/functional/index.ts +602 -0
- package/src/groove/index.ts +230 -0
- package/src/harmonize/index.ts +367 -0
- package/src/harmony/index.ts +75 -0
- package/src/index.ts +233 -0
- package/src/interval/index.ts +67 -0
- package/src/meter/index.ts +179 -0
- package/src/model/index.ts +957 -0
- package/src/motif/index.ts +321 -0
- package/src/pitch/index.ts +256 -0
- package/src/progression/index.ts +336 -0
- package/src/random/index.ts +41 -0
- package/src/reharmony/index.ts +299 -0
- package/src/rhythm/index.ts +131 -0
- package/src/safety/index.ts +372 -0
- package/src/scale/index.ts +215 -0
- package/src/spelling/index.ts +164 -0
- package/src/symbol/index.ts +213 -0
- package/src/timeline/index.ts +352 -0
- package/src/tuning/index.ts +129 -0
- package/src/types.ts +29 -0
- package/src/voicing/index.ts +523 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognition: infer a chord name from a set of pitches, or a key from a set of
|
|
3
|
+
* pitch classes. This is the inverse direction of the chord/scale builders,
|
|
4
|
+
* which only go name -> notes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Chord, ChordQuality } from '../chord/index.js';
|
|
8
|
+
import { chordPitchClasses, chordQualities, makeChord } from '../chord/index.js';
|
|
9
|
+
import { majorKey, minorKey } from '../scale/index.js';
|
|
10
|
+
import type { KeyScale } from '../types.js';
|
|
11
|
+
|
|
12
|
+
/** A candidate chord interpretation of a pitch set. */
|
|
13
|
+
export type ChordMatch = {
|
|
14
|
+
rootPc: number;
|
|
15
|
+
quality: ChordQuality;
|
|
16
|
+
/** Chord tones absent from the input (an incomplete voicing). */
|
|
17
|
+
missingPcs: number[];
|
|
18
|
+
/** Input pitch classes not belonging to the chord. */
|
|
19
|
+
extraPcs: number[];
|
|
20
|
+
/** True when the input pitch-class set equals the chord exactly. */
|
|
21
|
+
exact: boolean;
|
|
22
|
+
/** Inversion implied by the lowest note: 0 root position, 1 first, ... */
|
|
23
|
+
inversion: number;
|
|
24
|
+
/** Bass pitch class when inverted (the lowest note is not the root). */
|
|
25
|
+
bassPc?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A candidate key interpretation of a pitch-class set. */
|
|
29
|
+
export type KeyMatch = {
|
|
30
|
+
key: KeyScale;
|
|
31
|
+
mode: 'major' | 'minor';
|
|
32
|
+
/** Fraction of input pitch classes that are in the scale, in [0, 1]. */
|
|
33
|
+
fit: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Reduce any integer to a pitch class in [0, 11]. */
|
|
37
|
+
function pitchClass(value: number): number {
|
|
38
|
+
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Unique pitch classes of the input, sorted ascending. */
|
|
42
|
+
function uniquePitchClasses(pitches: number[]): number[] {
|
|
43
|
+
return [...new Set(pitches.map(pitchClass))].sort((a, b) => a - b);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Identify chords matching a set of pitches.
|
|
48
|
+
*
|
|
49
|
+
* Every input pitch class is tried as a root against every known chord quality.
|
|
50
|
+
* A match is reported when all of the chord's tones are present in the input, or
|
|
51
|
+
* when the only absent tone is the perfect fifth (a common omission in shell
|
|
52
|
+
* voicings); matches are ranked best-first by fewest extra notes, then fewest
|
|
53
|
+
* missing notes, then most specific (largest) chord. An exact match (no extras,
|
|
54
|
+
* no missing) is flagged and ranked first.
|
|
55
|
+
*
|
|
56
|
+
* @param pitches MIDI pitches or bare pitch classes (octave-agnostic).
|
|
57
|
+
* @returns Ranked chord interpretations (may be empty).
|
|
58
|
+
*/
|
|
59
|
+
export function detectChord(pitches: number[]): ChordMatch[] {
|
|
60
|
+
const input = uniquePitchClasses(pitches);
|
|
61
|
+
if (input.length === 0) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const bassPc = pitchClass(Math.min(...pitches));
|
|
65
|
+
const inputSet = new Set(input);
|
|
66
|
+
const matches: ChordMatch[] = [];
|
|
67
|
+
const qualities = chordQualities();
|
|
68
|
+
for (const rootPc of input) {
|
|
69
|
+
for (const quality of qualities) {
|
|
70
|
+
const chord = makeChord(rootPc, quality);
|
|
71
|
+
const tones = chordPitchClasses(chord);
|
|
72
|
+
const toneSet = new Set(tones);
|
|
73
|
+
const missingPcs = tones.filter((pc) => !inputSet.has(pc));
|
|
74
|
+
// Accept an exact-tone match, or one whose only absent tone is the perfect
|
|
75
|
+
// fifth; any other missing tone (third, sus tone, seventh, ...) rejects.
|
|
76
|
+
const fifthPc = pitchClass(rootPc + 7);
|
|
77
|
+
if (missingPcs.length > 1 || (missingPcs.length === 1 && missingPcs[0] !== fifthPc)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const extraPcs = input.filter((pc) => !toneSet.has(pc));
|
|
81
|
+
const bassIndex = chord.intervals.findIndex((iv) => pitchClass(rootPc + iv) === bassPc);
|
|
82
|
+
const inversion = bassIndex > 0 ? bassIndex : 0;
|
|
83
|
+
const match: ChordMatch = {
|
|
84
|
+
rootPc,
|
|
85
|
+
quality,
|
|
86
|
+
missingPcs,
|
|
87
|
+
extraPcs,
|
|
88
|
+
exact: extraPcs.length === 0,
|
|
89
|
+
inversion,
|
|
90
|
+
};
|
|
91
|
+
if (inversion > 0) {
|
|
92
|
+
match.bassPc = bassPc;
|
|
93
|
+
}
|
|
94
|
+
matches.push(match);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
matches.sort((a, b) => {
|
|
98
|
+
if (a.extraPcs.length !== b.extraPcs.length) {
|
|
99
|
+
return a.extraPcs.length - b.extraPcs.length;
|
|
100
|
+
}
|
|
101
|
+
if (a.missingPcs.length !== b.missingPcs.length) {
|
|
102
|
+
return a.missingPcs.length - b.missingPcs.length;
|
|
103
|
+
}
|
|
104
|
+
// Prefer root position (the bass is the chord root) on a tie.
|
|
105
|
+
if ((a.inversion === 0) !== (b.inversion === 0)) {
|
|
106
|
+
return a.inversion === 0 ? -1 : 1;
|
|
107
|
+
}
|
|
108
|
+
const aSize = chordPitchClasses(makeChord(a.rootPc, a.quality)).length;
|
|
109
|
+
const bSize = chordPitchClasses(makeChord(b.rootPc, b.quality)).length;
|
|
110
|
+
return bSize - aSize;
|
|
111
|
+
});
|
|
112
|
+
return matches;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The single best chord interpretation of a pitch set, or null if none.
|
|
117
|
+
*
|
|
118
|
+
* @param pitches MIDI pitches or bare pitch classes.
|
|
119
|
+
* @returns The top-ranked chord, or null when nothing matches.
|
|
120
|
+
*/
|
|
121
|
+
export function detectChordBest(pitches: number[]): Chord | null {
|
|
122
|
+
const best = detectChord(pitches)[0];
|
|
123
|
+
if (!best) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return makeChord(best.rootPc, best.quality, best.bassPc);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rank major and minor keys by how well they contain a set of pitch classes.
|
|
131
|
+
*
|
|
132
|
+
* The tonic is weighted so that, among equally-fitting keys, the one whose root
|
|
133
|
+
* appears in the input is preferred. Returns all 24 keys ranked best-first.
|
|
134
|
+
*
|
|
135
|
+
* @param pitches MIDI pitches or bare pitch classes.
|
|
136
|
+
* @returns Ranked key interpretations.
|
|
137
|
+
*/
|
|
138
|
+
export function detectKey(pitches: number[]): KeyMatch[] {
|
|
139
|
+
const input = uniquePitchClasses(pitches);
|
|
140
|
+
const counts = new Map<number, number>();
|
|
141
|
+
for (const pc of pitches.map(pitchClass)) {
|
|
142
|
+
counts.set(pc, (counts.get(pc) ?? 0) + 1);
|
|
143
|
+
}
|
|
144
|
+
const total = pitches.length || 1;
|
|
145
|
+
const results: (KeyMatch & { score: number })[] = [];
|
|
146
|
+
for (let tonic = 0; tonic < 12; tonic += 1) {
|
|
147
|
+
for (const mode of ['major', 'minor'] as const) {
|
|
148
|
+
const key = mode === 'major' ? majorKey(tonic) : minorKey(tonic);
|
|
149
|
+
let inScale = 0;
|
|
150
|
+
let weighted = 0;
|
|
151
|
+
for (const pc of input) {
|
|
152
|
+
const offset = (pc - tonic + 12) % 12;
|
|
153
|
+
if ((key.modeMask12 >> offset) & 1) {
|
|
154
|
+
inScale += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [pc, count] of counts) {
|
|
158
|
+
const offset = (pc - tonic + 12) % 12;
|
|
159
|
+
if ((key.modeMask12 >> offset) & 1) {
|
|
160
|
+
weighted += count;
|
|
161
|
+
}
|
|
162
|
+
if (pc === tonic) {
|
|
163
|
+
weighted += count * 0.5;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
results.push({
|
|
167
|
+
key,
|
|
168
|
+
mode,
|
|
169
|
+
fit: input.length === 0 ? 0 : inScale / input.length,
|
|
170
|
+
score: weighted / total,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
results.sort((a, b) => b.score - a.score);
|
|
175
|
+
return results.map(({ key, mode, fit }) => ({ key, mode, fit }));
|
|
176
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getGhostDensity,
|
|
3
|
+
getGhostProbabilityAtPosition,
|
|
4
|
+
getGhostVelocity,
|
|
5
|
+
selectGhostPositions,
|
|
6
|
+
} from './ghost.js';
|
|
7
|
+
import {
|
|
8
|
+
footHiHatVelocity,
|
|
9
|
+
type HiHatLevel,
|
|
10
|
+
hiHatNote,
|
|
11
|
+
hiHatTypeVelocityMultiplier,
|
|
12
|
+
hiHatVelocityMultiplier,
|
|
13
|
+
OHH_VEL_BOOST,
|
|
14
|
+
roleHiHatInstrument,
|
|
15
|
+
sectionHiHatType,
|
|
16
|
+
shouldAddOpenHHAccent,
|
|
17
|
+
shouldPlayHiHat,
|
|
18
|
+
shouldUseBridgeCrossStick,
|
|
19
|
+
} from './hihat.js';
|
|
20
|
+
import type { HitList } from './hit.js';
|
|
21
|
+
import type { BackingDensity, DrumRole, DrumStyle, Feel, SectionType } from './internal.js';
|
|
22
|
+
import { EIGHTH, GM, type MoodCategory, SIXTEENTH } from './internal.js';
|
|
23
|
+
import type { KickPattern } from './kick.js';
|
|
24
|
+
import type { DrumRng } from './rng.js';
|
|
25
|
+
import { effectiveSwing, quantizeSwing } from './swing.js';
|
|
26
|
+
|
|
27
|
+
/** Per-section drum state shared across the bars of a section. */
|
|
28
|
+
export type SectionCtx = {
|
|
29
|
+
style: DrumStyle;
|
|
30
|
+
feel: Feel;
|
|
31
|
+
densityMult: number;
|
|
32
|
+
backingDensity: BackingDensity;
|
|
33
|
+
hhLevel: HiHatLevel;
|
|
34
|
+
useGhostNotes: boolean;
|
|
35
|
+
ghostBoost: boolean;
|
|
36
|
+
useRide: boolean;
|
|
37
|
+
useFootHh: boolean;
|
|
38
|
+
role: DrumRole;
|
|
39
|
+
ghostMood: MoodCategory;
|
|
40
|
+
snareBeat3: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Per-beat context passed to every beat processor. */
|
|
44
|
+
export type BeatCtx = {
|
|
45
|
+
track: HitList;
|
|
46
|
+
beatTick: number;
|
|
47
|
+
beat: number;
|
|
48
|
+
velocity: number;
|
|
49
|
+
section: SectionType;
|
|
50
|
+
bpm: number;
|
|
51
|
+
bar: number;
|
|
52
|
+
sectionBars: number;
|
|
53
|
+
inPrechorusLift: boolean;
|
|
54
|
+
swingAmount: number;
|
|
55
|
+
barHasOpenHh: boolean;
|
|
56
|
+
openHhBeat: number;
|
|
57
|
+
rng: DrumRng;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function swing16(tick: number, feel: Feel, swingAmount: number): number {
|
|
61
|
+
return quantizeSwing(tick, effectiveSwing(feel, swingAmount), 'sixteenth');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hatSwingFactor(style: DrumStyle): number {
|
|
65
|
+
if (style === 'trap') {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (style === 'latin') {
|
|
69
|
+
return 0.35;
|
|
70
|
+
}
|
|
71
|
+
return 0.5;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Emit the kick for one beat. */
|
|
75
|
+
export function generateKickForBeat(ctx: BeatCtx, sec: SectionCtx, kick: KickPattern): void {
|
|
76
|
+
if (ctx.inPrechorusLift) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const on = [kick.beat1, kick.beat2, kick.beat3, kick.beat4][ctx.beat] ?? false;
|
|
80
|
+
const and = [kick.beat1and, kick.beat2and, kick.beat3and, kick.beat4and][ctx.beat] ?? false;
|
|
81
|
+
|
|
82
|
+
if (on) {
|
|
83
|
+
ctx.track.add(GM.BD, ctx.beatTick, EIGHTH, ctx.velocity);
|
|
84
|
+
}
|
|
85
|
+
if (and) {
|
|
86
|
+
const andTick = swing16(ctx.beatTick + EIGHTH, sec.feel, ctx.swingAmount);
|
|
87
|
+
ctx.track.add(GM.BD, andTick, EIGHTH, ctx.velocity * 0.85);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Emit the backbeat snare (or side-stick) for one beat. */
|
|
92
|
+
export function generateSnareForBeat(ctx: BeatCtx, sec: SectionCtx, isIntroFirst: boolean): void {
|
|
93
|
+
if (ctx.inPrechorusLift) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const snareOn = sec.snareBeat3 ? ctx.beat === 2 : ctx.beat === 1 || ctx.beat === 3;
|
|
97
|
+
if (!snareOn || isIntroFirst) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (sec.useRide && shouldUseBridgeCrossStick(ctx.section, ctx.beat)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const backbeatVel = Math.min(127, ctx.velocity + 16);
|
|
105
|
+
const promoteSparseChorus =
|
|
106
|
+
sec.style === 'sparse' && ctx.section === 'chorus' && sec.role === 'full';
|
|
107
|
+
|
|
108
|
+
if (promoteSparseChorus) {
|
|
109
|
+
ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, backbeatVel);
|
|
110
|
+
} else if (sec.style === 'sparse' || sec.role === 'ambient') {
|
|
111
|
+
if (sec.role !== 'fxOnly' && sec.role !== 'minimal') {
|
|
112
|
+
ctx.track.add(GM.SIDESTICK, ctx.beatTick, EIGHTH, ctx.velocity * 0.8);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, backbeatVel);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Emit ghost snares at the "e"/"a" 16ths of beats 1 and 3. */
|
|
120
|
+
export function generateGhostNotesForBeat(ctx: BeatCtx, sec: SectionCtx): void {
|
|
121
|
+
if (ctx.beat !== 0 && ctx.beat !== 2) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const positions = selectGhostPositions(sec.ghostMood, ctx.rng);
|
|
125
|
+
let ghostProb = getGhostDensity(sec.ghostMood, ctx.section, sec.backingDensity, ctx.bpm);
|
|
126
|
+
if (sec.ghostBoost) {
|
|
127
|
+
ghostProb = Math.min(1, ghostProb * 1.4);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const pos of positions) {
|
|
131
|
+
const sixteenthInBeat = pos === 'e' ? 1 : 3;
|
|
132
|
+
const posProb = getGhostProbabilityAtPosition(ctx.beat, sixteenthInBeat);
|
|
133
|
+
if (!ctx.rng.prob(ghostProb * posProb)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const variation = ctx.rng.float(0.85, 1.15);
|
|
137
|
+
const ghostBase = getGhostVelocity(ctx.section, ctx.beat / 2, false);
|
|
138
|
+
let ghostVel = ctx.velocity * ghostBase * variation;
|
|
139
|
+
if (pos === 'a') {
|
|
140
|
+
ghostVel *= 0.9;
|
|
141
|
+
}
|
|
142
|
+
const offset = pos === 'e' ? SIXTEENTH : 3 * SIXTEENTH;
|
|
143
|
+
const tick = swing16(ctx.beatTick + offset, sec.feel, ctx.swingAmount);
|
|
144
|
+
ctx.track.add(GM.SD, tick, SIXTEENTH, ghostVel);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Emit the pre-chorus lift buildup for one beat. */
|
|
149
|
+
export function generatePreChorusBuildup(
|
|
150
|
+
ctx: BeatCtx,
|
|
151
|
+
sec: SectionCtx,
|
|
152
|
+
isSectionLastBar: boolean,
|
|
153
|
+
): void {
|
|
154
|
+
if (sec.style === 'sparse') {
|
|
155
|
+
if (isSectionLastBar && ctx.beat === 3) {
|
|
156
|
+
ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, Math.max(45, ctx.velocity * 0.75));
|
|
157
|
+
ctx.track.add(GM.CRASH, ctx.beatTick + EIGHTH + SIXTEENTH, SIXTEENTH, ctx.velocity * 0.9);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const barsInLift = 2;
|
|
162
|
+
const barInLift = ctx.bar - (ctx.sectionBars - barsInLift);
|
|
163
|
+
const progress = (barInLift * 4 + ctx.beat) / (barsInLift * 4);
|
|
164
|
+
const buildupVel = ctx.velocity * (0.5 + 0.5 * progress);
|
|
165
|
+
ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, buildupVel);
|
|
166
|
+
ctx.track.add(GM.SD, ctx.beatTick + EIGHTH, EIGHTH, buildupVel * 0.85);
|
|
167
|
+
if (isSectionLastBar && ctx.beat === 3) {
|
|
168
|
+
ctx.track.add(GM.CRASH, ctx.beatTick + EIGHTH + SIXTEENTH, SIXTEENTH, ctx.velocity * 1.1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Emit the timekeeping hi-hat (or ride/foot) for one beat. */
|
|
173
|
+
export function generateHiHatForBeat(ctx: BeatCtx, sec: SectionCtx): void {
|
|
174
|
+
if (!shouldPlayHiHat(sec.role)) {
|
|
175
|
+
if (sec.useFootHh && (ctx.beat === 0 || ctx.beat === 2)) {
|
|
176
|
+
ctx.track.add(GM.FHH, ctx.beatTick, EIGHTH, footHiHatVelocity(ctx.rng));
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const crossStick = sec.useRide && shouldUseBridgeCrossStick(ctx.section, ctx.beat);
|
|
182
|
+
const hhInstrument = crossStick ? GM.SIDESTICK : roleHiHatInstrument(sec.role, sec.useRide);
|
|
183
|
+
const hhType = sectionHiHatType(ctx.section, sec.role);
|
|
184
|
+
const typeMult = hiHatTypeVelocityMultiplier(hhType);
|
|
185
|
+
const isDynamicOpen = ctx.barHasOpenHh && ctx.beat === ctx.openHhBeat;
|
|
186
|
+
const dm = sec.densityMult;
|
|
187
|
+
|
|
188
|
+
if (sec.hhLevel === 'quarter') {
|
|
189
|
+
const introRest = ctx.section === 'intro' && ctx.beat !== 0;
|
|
190
|
+
if (!introRest) {
|
|
191
|
+
if (isDynamicOpen) {
|
|
192
|
+
ctx.track.add(
|
|
193
|
+
GM.OHH,
|
|
194
|
+
ctx.beatTick,
|
|
195
|
+
EIGHTH,
|
|
196
|
+
ctx.velocity * dm * 0.75 * typeMult + OHH_VEL_BOOST,
|
|
197
|
+
);
|
|
198
|
+
} else {
|
|
199
|
+
ctx.track.add(
|
|
200
|
+
hhInstrument,
|
|
201
|
+
ctx.beatTick,
|
|
202
|
+
EIGHTH,
|
|
203
|
+
Math.max(20, ctx.velocity * dm * 0.75 * typeMult),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
} else if (sec.useFootHh) {
|
|
207
|
+
ctx.track.add(GM.FHH, ctx.beatTick, EIGHTH, footHiHatVelocity(ctx.rng));
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (sec.hhLevel === 'eighth') {
|
|
213
|
+
for (let eighth = 0; eighth < 2; eighth += 1) {
|
|
214
|
+
let hhTick = ctx.beatTick + eighth * EIGHTH;
|
|
215
|
+
if (eighth === 1) {
|
|
216
|
+
hhTick = swing16(hhTick, sec.feel, ctx.swingAmount);
|
|
217
|
+
}
|
|
218
|
+
if (ctx.section === 'intro' && eighth === 1) {
|
|
219
|
+
if (sec.useFootHh && ctx.beat % 2 === 0) {
|
|
220
|
+
ctx.track.add(GM.FHH, hhTick, EIGHTH, footHiHatVelocity(ctx.rng));
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const hhVel = Math.max(20, ctx.velocity * dm * typeMult * (eighth === 0 ? 0.9 : 0.65));
|
|
225
|
+
if (isDynamicOpen && eighth === 0) {
|
|
226
|
+
ctx.track.add(GM.OHH, hhTick, EIGHTH, hhVel + OHH_VEL_BOOST);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let useOpen = false;
|
|
230
|
+
if (sec.style === 'fourOnFloor' && eighth === 1) {
|
|
231
|
+
const openProb = Math.max(0.15, Math.min(0.8, 45 / ctx.bpm));
|
|
232
|
+
useOpen = (ctx.beat === 1 || ctx.beat === 3) && ctx.rng.prob(openProb);
|
|
233
|
+
} else if (eighth === 0) {
|
|
234
|
+
useOpen = shouldAddOpenHHAccent(ctx.section, ctx.beat, ctx.bar, ctx.rng);
|
|
235
|
+
}
|
|
236
|
+
if (useOpen) {
|
|
237
|
+
ctx.track.add(hiHatNote('open'), hhTick, EIGHTH, Math.max(20, hhVel * 1.1));
|
|
238
|
+
} else {
|
|
239
|
+
ctx.track.add(hhInstrument, hhTick, EIGHTH / 2, hhVel);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (let sixteenth = 0; sixteenth < 4; sixteenth += 1) {
|
|
246
|
+
let hhTick = ctx.beatTick + sixteenth * SIXTEENTH;
|
|
247
|
+
if (sixteenth === 1 || sixteenth === 3) {
|
|
248
|
+
const swung = effectiveSwing(sec.feel, ctx.swingAmount) * hatSwingFactor(sec.style);
|
|
249
|
+
hhTick = quantizeSwing(hhTick, swung, 'sixteenth');
|
|
250
|
+
}
|
|
251
|
+
const metricVel = hiHatVelocityMultiplier(sixteenth, ctx.rng);
|
|
252
|
+
const hhVel = Math.max(20, ctx.velocity * dm * typeMult * metricVel);
|
|
253
|
+
if (isDynamicOpen && sixteenth === 0) {
|
|
254
|
+
ctx.track.add(GM.OHH, hhTick, SIXTEENTH, hhVel + OHH_VEL_BOOST);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (ctx.beat === 3 && sixteenth === 3) {
|
|
258
|
+
const openProb = Math.max(0.1, Math.min(0.4, 30 / ctx.bpm));
|
|
259
|
+
if (ctx.rng.prob(openProb)) {
|
|
260
|
+
ctx.track.add(GM.OHH, hhTick, SIXTEENTH, Math.max(20, hhVel * 1.2));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
ctx.track.add(hhInstrument, hhTick, SIXTEENTH / 2, hhVel);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a euclidean onset pattern (Bjorklund's algorithm), distributing
|
|
3
|
+
* `pulses` as evenly as possible across `steps`, then rotating.
|
|
4
|
+
*
|
|
5
|
+
* @param pulses Number of onsets, clamped to [0, steps].
|
|
6
|
+
* @param steps Total steps (>= 1).
|
|
7
|
+
* @param rotation Steps to rotate onsets toward later positions.
|
|
8
|
+
* @returns One boolean per step; true marks an onset.
|
|
9
|
+
*/
|
|
10
|
+
export function euclideanRhythm(pulses: number, steps: number, rotation = 0): boolean[] {
|
|
11
|
+
if (steps < 1) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const p = Math.max(0, Math.min(pulses, steps));
|
|
15
|
+
const base = bjorklund(p, steps);
|
|
16
|
+
const shift = ((rotation % steps) + steps) % steps;
|
|
17
|
+
if (shift === 0) {
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
return base.map((_, i) => base[(i - shift + steps) % steps] ?? false);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when the 16-step bitmask has an onset at `step`. */
|
|
24
|
+
export function hasHit(mask: number, step: number): boolean {
|
|
25
|
+
return ((mask >> step) & 1) === 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Convert a euclidean boolean pattern to a 16-step bitmask (LSB = step 0). */
|
|
29
|
+
export function patternToMask(pattern: boolean[]): number {
|
|
30
|
+
let mask = 0;
|
|
31
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
32
|
+
if (pattern[i]) {
|
|
33
|
+
mask |= 1 << i;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return mask;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bjorklund(pulses: number, steps: number): boolean[] {
|
|
40
|
+
if (pulses === 0) {
|
|
41
|
+
return Array.from({ length: steps }, () => false);
|
|
42
|
+
}
|
|
43
|
+
if (pulses >= steps) {
|
|
44
|
+
return Array.from({ length: steps }, () => true);
|
|
45
|
+
}
|
|
46
|
+
let head: boolean[][] = Array.from({ length: pulses }, () => [true]);
|
|
47
|
+
let remainder: boolean[][] = Array.from({ length: steps - pulses }, () => [false]);
|
|
48
|
+
while (remainder.length > 1) {
|
|
49
|
+
const count = Math.min(head.length, remainder.length);
|
|
50
|
+
const next: boolean[][] = [];
|
|
51
|
+
for (let i = 0; i < count; i += 1) {
|
|
52
|
+
next.push([...(head[i] ?? []), ...(remainder[i] ?? [])]);
|
|
53
|
+
}
|
|
54
|
+
remainder = head.length > count ? head.slice(count) : remainder.slice(count);
|
|
55
|
+
head = next;
|
|
56
|
+
}
|
|
57
|
+
return [...head.flat(), ...remainder.flat()];
|
|
58
|
+
}
|