@libraz/libcantus 0.9.2 → 0.9.3
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 +4 -3
- package/dist/analyze/index.cjs +315 -42
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +11 -99
- package/dist/analyze/index.d.ts +11 -99
- package/dist/analyze/index.js +6 -6
- package/dist/chunk-2ZWMSJH6.js +30 -0
- package/dist/chunk-2ZWMSJH6.js.map +1 -0
- package/dist/{chunk-R3S2LUBH.js → chunk-4MHA432G.js} +316 -184
- package/dist/chunk-4MHA432G.js.map +1 -0
- package/dist/chunk-62QZKCJQ.js +49 -0
- package/dist/chunk-62QZKCJQ.js.map +1 -0
- package/dist/chunk-AKLYPWIN.js +83 -0
- package/dist/chunk-AKLYPWIN.js.map +1 -0
- package/dist/{chunk-XTC3X6MS.js → chunk-C5XYKUVG.js} +100 -12
- package/dist/chunk-C5XYKUVG.js.map +1 -0
- package/dist/{chunk-LN7ZZMD2.js → chunk-CHMXELDS.js} +33 -14
- package/dist/chunk-CHMXELDS.js.map +1 -0
- package/dist/{chunk-A4532ZFR.js → chunk-EH6EM37U.js} +60 -4
- package/dist/chunk-EH6EM37U.js.map +1 -0
- package/dist/chunk-FBQK3ZQN.js +131 -0
- package/dist/chunk-FBQK3ZQN.js.map +1 -0
- package/dist/{chunk-5HTNMQW4.js → chunk-GOAW4VYL.js} +87 -21
- package/dist/chunk-GOAW4VYL.js.map +1 -0
- package/dist/{chunk-MSWFZF7J.js → chunk-I5R7ZBJS.js} +90 -6
- package/dist/chunk-I5R7ZBJS.js.map +1 -0
- package/dist/{chunk-GQKN5KDT.js → chunk-IT2O4XFE.js} +4 -9
- package/dist/chunk-IT2O4XFE.js.map +1 -0
- package/dist/{chunk-7Y7WF64D.js → chunk-MIWO425M.js} +6 -4
- package/dist/chunk-MIWO425M.js.map +1 -0
- package/dist/chunk-N6IHQUOX.js +1 -0
- package/dist/chunk-N6IHQUOX.js.map +1 -0
- package/dist/{chunk-COKTX2G5.js → chunk-SGU7SPLK.js} +29 -12
- package/dist/chunk-SGU7SPLK.js.map +1 -0
- package/dist/core/index.cjs +230 -14
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +56 -4
- package/dist/core/index.d.ts +56 -4
- package/dist/core/index.js +24 -4
- package/dist/{function-2bJW1Zp_.d.ts → function-DVH4Eusr.d.ts} +1 -1
- package/dist/{function-r2sh-jOO.d.cts → function-DYt4iKsn.d.cts} +1 -1
- package/dist/generate/index.cjs +830 -494
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +11 -5
- package/dist/generate/index.d.ts +11 -5
- package/dist/generate/index.js +9 -9
- package/dist/{index-BOBXBNwP.d.cts → index-BNlGP86A.d.cts} +1 -1
- package/dist/{index-CP3wAfEH.d.ts → index-Bg5dYMmZ.d.ts} +1 -1
- package/dist/{index-CM5W4rcm.d.ts → index-Bt4tOUD9.d.ts} +1 -1
- package/dist/index-CnZCo29u.d.cts +108 -0
- package/dist/{index-D_ZTUNKp.d.cts → index-CpOsP1y9.d.cts} +2 -2
- package/dist/{index-DZ1M21do.d.cts → index-CxqBQtH1.d.cts} +2 -1
- package/dist/{index-BdeQ1r_p.d.cts → index-Dikst44v.d.cts} +1 -1
- package/dist/{index-BMPxwjwV.d.ts → index-DxvARIP2.d.ts} +2 -2
- package/dist/{index-BreoJRw0.d.ts → index-Jc0Ryi79.d.ts} +2 -1
- package/dist/index-JnF4v9bx.d.ts +108 -0
- package/dist/index.cjs +818 -283
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -8
- package/dist/index.d.ts +9 -8
- package/dist/index.js +34 -13
- package/dist/model/index.cjs +170 -31
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +6 -5
- package/dist/model/index.d.ts +6 -5
- package/dist/model/index.js +6 -6
- package/dist/{styled-D7BAx3S3.d.ts → styled-DZ7GjShb.d.ts} +1 -1
- package/dist/{styled-BjXtOFNk.d.cts → styled-ZTbdaVFM.d.cts} +1 -1
- package/dist/theory/index.cjs +180 -34
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +6 -6
- package/dist/theory/index.d.ts +6 -6
- package/dist/theory/index.js +6 -5
- package/package.json +50 -19
- package/src/analyze/arrange/internal.ts +3 -1
- package/src/analyze/arrange/tension.ts +15 -3
- package/src/analyze/arrange/tracks.ts +36 -3
- package/src/analyze/detect/index.ts +46 -9
- package/src/analyze/functional/roman.ts +14 -5
- package/src/analyze/index.ts +1 -1
- package/src/analyze/timeline/index.ts +43 -7
- package/src/analyze/voice/index.ts +11 -2
- package/src/core/event-index/index.ts +104 -0
- package/src/core/index.ts +14 -0
- package/src/core/meter/index.ts +24 -4
- package/src/core/random/index.ts +26 -3
- package/src/core/tuning/index.ts +36 -6
- package/src/core/validation/index.ts +119 -0
- package/src/generate/bass/index.ts +23 -0
- package/src/generate/countermelody/index.ts +167 -76
- package/src/generate/drums/euclid.ts +10 -3
- package/src/generate/drums/index.ts +56 -24
- package/src/generate/drums/percussion.ts +1 -1
- package/src/generate/groove/index.ts +25 -0
- package/src/generate/harmonize/index.ts +55 -44
- package/src/generate/index.ts +0 -1
- package/src/generate/motif/index.ts +45 -21
- package/src/generate/progression/index.ts +8 -16
- package/src/generate/reharmony/index.ts +1 -8
- package/src/generate/rhythm/index.ts +11 -1
- package/src/index.ts +13 -247
- package/src/model/chord.ts +29 -10
- package/src/theory/chord/index.ts +4 -2
- package/src/theory/harmony/index.ts +6 -21
- package/src/theory/index.ts +6 -1
- package/src/theory/safety/index.ts +71 -2
- package/src/theory/spelling/index.ts +70 -3
- package/src/theory/voicing/satb.ts +25 -4
- package/src/theory/voicing/styled.ts +26 -3
- package/dist/chunk-5HTNMQW4.js.map +0 -1
- package/dist/chunk-7Y7WF64D.js.map +0 -1
- package/dist/chunk-A4532ZFR.js.map +0 -1
- package/dist/chunk-BHUT2S5G.js +0 -22
- package/dist/chunk-BHUT2S5G.js.map +0 -1
- package/dist/chunk-CCBLVAPK.js +0 -42
- package/dist/chunk-CCBLVAPK.js.map +0 -1
- package/dist/chunk-COKTX2G5.js.map +0 -1
- package/dist/chunk-GQKN5KDT.js.map +0 -1
- package/dist/chunk-LN7ZZMD2.js.map +0 -1
- package/dist/chunk-MSWFZF7J.js.map +0 -1
- package/dist/chunk-R3S2LUBH.js.map +0 -1
- package/dist/chunk-SDM3VJJ5.js +0 -41
- package/dist/chunk-SDM3VJJ5.js.map +0 -1
- package/dist/chunk-XTC3X6MS.js.map +0 -1
- package/dist/chunk-ZXW5YIWK.js +0 -55
- package/dist/chunk-ZXW5YIWK.js.map +0 -1
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import type { NoteEventIndex } from '../../core/event-index/index.js';
|
|
2
|
+
import { createNoteEventIndex } from '../../core/event-index/index.js';
|
|
1
3
|
import type { TimeSignature } from '../../core/meter/index.js';
|
|
2
4
|
import { isStrongBeat } from '../../core/meter/index.js';
|
|
3
5
|
import type { Rng } from '../../core/random/index.js';
|
|
4
6
|
import { createRng } from '../../core/random/index.js';
|
|
5
7
|
import type { KeyScale, NoteEvent } from '../../core/types.js';
|
|
8
|
+
import {
|
|
9
|
+
assertGenerationBudget,
|
|
10
|
+
assertInteger,
|
|
11
|
+
assertRange,
|
|
12
|
+
assertTimeSignature,
|
|
13
|
+
} from '../../core/validation/index.js';
|
|
6
14
|
import type { Chord } from '../../theory/chord/index.js';
|
|
7
15
|
import { chordToneRole } from '../../theory/chord/index.js';
|
|
8
16
|
import { createsParallelPerfect, isForbiddenMelodicLeap } from '../../theory/counterpoint/index.js';
|
|
9
17
|
import type { VoiceSnapshot } from '../../theory/safety/index.js';
|
|
10
|
-
import { enumerateSafePitches } from '../../theory/safety/index.js';
|
|
11
|
-
import { nearestScaleTone } from '../../theory/scale/index.js';
|
|
18
|
+
import { enumerateSafePitches, evaluateSafety, NoteSafety } from '../../theory/safety/index.js';
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
21
|
* Options controlling {@link generateCounterMelody}.
|
|
@@ -20,6 +27,12 @@ export type CounterMelodyOptions = {
|
|
|
20
27
|
melody: NoteEvent[];
|
|
21
28
|
/** Chord sounding at an absolute beat position, or null when none applies. */
|
|
22
29
|
chordAt: (beat: number) => Chord | null;
|
|
30
|
+
/**
|
|
31
|
+
* Exact chord-change beats exposed by the caller's timeline. The generator
|
|
32
|
+
* also probes its half-beat grid, but callback discontinuities between grid
|
|
33
|
+
* points are not introspectable and should be listed here.
|
|
34
|
+
*/
|
|
35
|
+
chordChangeBeats?: number[];
|
|
23
36
|
/** Key/scale context for scale-tone decisions. */
|
|
24
37
|
key: KeyScale;
|
|
25
38
|
/**
|
|
@@ -98,26 +111,33 @@ function isWholeBeat(beat: number): boolean {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
/** The melody note sounding at a beat (latest onset wins on overlaps). */
|
|
101
|
-
function melodyNoteAt(melody:
|
|
102
|
-
|
|
103
|
-
for (const note of melody) {
|
|
104
|
-
if (note.startBeat - EPS <= beat && beat < note.startBeat + note.durationBeat - EPS) {
|
|
105
|
-
if (!found || note.startBeat > found.startBeat) {
|
|
106
|
-
found = note;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return found;
|
|
114
|
+
function melodyNoteAt(melody: NoteEventIndex, beat: number): NoteEvent | undefined {
|
|
115
|
+
return melody.at(beat)?.note;
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/** Whether any melody note attacks exactly at a beat. */
|
|
114
|
-
function melodyAttacksAt(melody:
|
|
115
|
-
return melody.
|
|
119
|
+
function melodyAttacksAt(melody: NoteEventIndex, beat: number): boolean {
|
|
120
|
+
return melody.attacksAt(beat);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Melody voice at a transition, including the pitch immediately before it. */
|
|
124
|
+
function melodySnapshotAt(melody: NoteEventIndex, beat: number): VoiceSnapshot | undefined {
|
|
125
|
+
const current = melodyNoteAt(melody, beat);
|
|
126
|
+
if (current === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const attacked = Math.abs(current.startBeat - beat) < EPS;
|
|
130
|
+
const previous = attacked ? melodyNoteAt(melody, beat - EPS * 2) : current;
|
|
131
|
+
const snapshot: VoiceSnapshot = { pitch: current.pitch };
|
|
132
|
+
if (previous !== undefined) {
|
|
133
|
+
snapshot.prevPitch = previous.pitch;
|
|
134
|
+
}
|
|
135
|
+
return snapshot;
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
/** Counter onsets mirroring the melody's own onsets, deduplicated and sorted. */
|
|
119
|
-
function followOnsets(melody:
|
|
120
|
-
return [...new Set(melody.map((note) => note.startBeat))]
|
|
139
|
+
function followOnsets(melody: NoteEventIndex): number[] {
|
|
140
|
+
return [...new Set(melody.notes.map(({ note }) => note.startBeat))];
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
/**
|
|
@@ -127,7 +147,7 @@ function followOnsets(melody: NoteEvent[]): number[] {
|
|
|
127
147
|
* gap between accepted onsets keeps the line sparse.
|
|
128
148
|
*/
|
|
129
149
|
function complementOnsets(
|
|
130
|
-
melody:
|
|
150
|
+
melody: NoteEventIndex,
|
|
131
151
|
ts: TimeSignature,
|
|
132
152
|
spanStart: number,
|
|
133
153
|
spanEnd: number,
|
|
@@ -208,42 +228,79 @@ function scoreCandidate(
|
|
|
208
228
|
return score;
|
|
209
229
|
}
|
|
210
230
|
|
|
231
|
+
/** Event boundaries at which a held counter pitch can acquire a new context. */
|
|
232
|
+
function heldNoteBoundaries(
|
|
233
|
+
melody: NoteEventIndex,
|
|
234
|
+
startBeat: number,
|
|
235
|
+
endBeat: number,
|
|
236
|
+
chordChangeBeats: number[],
|
|
237
|
+
): number[] {
|
|
238
|
+
const boundaries = new Set<number>([startBeat]);
|
|
239
|
+
for (const onset of melody.onsetsBetween(startBeat, endBeat)) {
|
|
240
|
+
boundaries.add(onset);
|
|
241
|
+
}
|
|
242
|
+
for (const beat of chordChangeBeats) {
|
|
243
|
+
if (beat > startBeat + EPS && beat < endBeat - EPS) {
|
|
244
|
+
boundaries.add(beat);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// `chordAt` is an opaque callback. Probe the same half-beat grid used by the
|
|
248
|
+
// complement rhythm so ordinary beat/bar chord changes are still found even
|
|
249
|
+
// when the caller does not provide `chordChangeBeats`.
|
|
250
|
+
const firstGrid = Math.floor(startBeat / GRID_STEP + 1) * GRID_STEP;
|
|
251
|
+
for (let beat = firstGrid; beat < endBeat - EPS; beat += GRID_STEP) {
|
|
252
|
+
boundaries.add(beat);
|
|
253
|
+
}
|
|
254
|
+
return [...boundaries].sort((a, b) => a - b);
|
|
255
|
+
}
|
|
256
|
+
|
|
211
257
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
258
|
+
* Worst safety reached while `pitch` is held through its complete interval.
|
|
259
|
+
* A wrong-side crossing is treated as unavailable. Safe candidates are later
|
|
260
|
+
* preferred over Warning candidates; Dissonant candidates are never emitted.
|
|
215
261
|
*/
|
|
216
|
-
function
|
|
217
|
-
|
|
262
|
+
function heldPitchSafety(
|
|
263
|
+
pitch: number,
|
|
264
|
+
startBeat: number,
|
|
265
|
+
endBeat: number,
|
|
218
266
|
prevPitch: number | undefined,
|
|
219
|
-
|
|
267
|
+
melody: NoteEventIndex,
|
|
268
|
+
opts: CounterMelodyOptions,
|
|
269
|
+
profile: 'strict' | 'pop',
|
|
220
270
|
register: 'above' | 'below',
|
|
221
271
|
low: number,
|
|
222
272
|
high: number,
|
|
223
|
-
|
|
224
|
-
):
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Push onto the requested side of the melody, but only by whole octaves that
|
|
236
|
-
// keep the pitch inside [low, high]; the declared range is a hard bound while
|
|
237
|
-
// the register side is a soft preference. A final clamp guarantees the range.
|
|
238
|
-
if (melPitch !== undefined) {
|
|
239
|
-
while (register === 'below' && pitch >= melPitch && pitch - 12 >= low) {
|
|
240
|
-
pitch -= 12;
|
|
273
|
+
ts: TimeSignature,
|
|
274
|
+
): NoteSafety | null {
|
|
275
|
+
let worst = NoteSafety.Safe;
|
|
276
|
+
const boundaries = heldNoteBoundaries(melody, startBeat, endBeat, opts.chordChangeBeats ?? []);
|
|
277
|
+
for (const boundary of boundaries) {
|
|
278
|
+
const melodyVoice = melodySnapshotAt(melody, boundary);
|
|
279
|
+
if (
|
|
280
|
+
melodyVoice !== undefined &&
|
|
281
|
+
((register === 'below' && pitch >= melodyVoice.pitch) ||
|
|
282
|
+
(register === 'above' && pitch <= melodyVoice.pitch))
|
|
283
|
+
) {
|
|
284
|
+
return null;
|
|
241
285
|
}
|
|
242
|
-
|
|
243
|
-
|
|
286
|
+
const atCounterOnset = Math.abs(boundary - startBeat) < EPS;
|
|
287
|
+
const result = evaluateSafety({
|
|
288
|
+
profile,
|
|
289
|
+
candidatePitch: pitch,
|
|
290
|
+
prevPitch: atCounterOnset ? prevPitch : pitch,
|
|
291
|
+
chord: opts.chordAt(boundary),
|
|
292
|
+
key: opts.key,
|
|
293
|
+
otherVoices: melodyVoice ? [melodyVoice] : [],
|
|
294
|
+
strongBeat: isStrongBeat(boundary, ts),
|
|
295
|
+
vocalLow: low,
|
|
296
|
+
vocalHigh: high,
|
|
297
|
+
});
|
|
298
|
+
worst = Math.max(worst, result.safety) as NoteSafety;
|
|
299
|
+
if (worst === NoteSafety.Dissonant) {
|
|
300
|
+
return worst;
|
|
244
301
|
}
|
|
245
302
|
}
|
|
246
|
-
return
|
|
303
|
+
return worst;
|
|
247
304
|
}
|
|
248
305
|
|
|
249
306
|
/**
|
|
@@ -275,40 +332,65 @@ function fallbackPitch(
|
|
|
275
332
|
* @category Voicing & Counterpoint
|
|
276
333
|
*/
|
|
277
334
|
export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
|
|
278
|
-
const melody =
|
|
279
|
-
if (melody.length === 0) {
|
|
335
|
+
const melody = createNoteEventIndex(opts.melody);
|
|
336
|
+
if (melody.notes.length === 0) {
|
|
280
337
|
return [];
|
|
281
338
|
}
|
|
282
339
|
const ts = opts.ts ?? DEFAULT_TS;
|
|
340
|
+
assertTimeSignature(ts);
|
|
283
341
|
const register = opts.register ?? 'below';
|
|
284
342
|
const rhythm = opts.rhythm ?? 'complement';
|
|
285
343
|
const profile = opts.profile ?? 'pop';
|
|
286
344
|
const rng = createRng(opts.seed ?? 0);
|
|
287
345
|
|
|
288
|
-
const spanStart = melody[0]?.startBeat ?? 0;
|
|
289
|
-
const spanEnd = melody.reduce((end, n) => Math.max(end, n.
|
|
290
|
-
|
|
346
|
+
const spanStart = melody.notes[0]?.note.startBeat ?? 0;
|
|
347
|
+
const spanEnd = melody.notes.reduce((end, n) => Math.max(end, n.endBeat), spanStart);
|
|
348
|
+
assertGenerationBudget(Math.ceil((spanEnd - spanStart) / GRID_STEP), 'countermelody grid');
|
|
349
|
+
const meanPitch = melody.notes.reduce((sum, n) => sum + n.note.pitch, 0) / melody.notes.length;
|
|
291
350
|
const defaultCenter =
|
|
292
351
|
register === 'below' ? meanPitch - REGISTER_OFFSET : meanPitch + REGISTER_OFFSET;
|
|
293
352
|
const low = opts.pitchLow ?? Math.round(defaultCenter) - DEFAULT_HALF_RANGE;
|
|
294
353
|
const high = opts.pitchHigh ?? Math.round(defaultCenter) + DEFAULT_HALF_RANGE;
|
|
354
|
+
assertInteger(low, 'countermelody pitchLow');
|
|
355
|
+
assertInteger(high, 'countermelody pitchHigh');
|
|
356
|
+
if (low > high) {
|
|
357
|
+
throw new RangeError(
|
|
358
|
+
`countermelody pitchLow must not exceed pitchHigh; received ${low} > ${high}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
assertGenerationBudget(high - low + 1, 'countermelody pitch candidates');
|
|
362
|
+
assertGenerationBudget(opts.chordChangeBeats?.length ?? 0, 'chord change beats');
|
|
363
|
+
for (let index = 0; index < (opts.chordChangeBeats?.length ?? 0); index += 1) {
|
|
364
|
+
assertRange(
|
|
365
|
+
opts.chordChangeBeats?.[index] ?? Number.NaN,
|
|
366
|
+
0,
|
|
367
|
+
Number.MAX_SAFE_INTEGER,
|
|
368
|
+
`chordChangeBeats[${index}]`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
295
371
|
const center = (low + high) / 2;
|
|
296
372
|
|
|
297
373
|
const onsets =
|
|
298
374
|
rhythm === 'follow'
|
|
299
375
|
? followOnsets(melody)
|
|
300
376
|
: complementOnsets(melody, ts, spanStart, spanEnd, rng);
|
|
377
|
+
assertGenerationBudget(onsets.length * (high - low + 1), 'countermelody search');
|
|
301
378
|
|
|
302
379
|
const out: NoteEvent[] = [];
|
|
303
380
|
let prevPitch: number | undefined;
|
|
304
|
-
let
|
|
305
|
-
|
|
381
|
+
for (let onsetIndex = 0; onsetIndex < onsets.length; onsetIndex += 1) {
|
|
382
|
+
const beat = onsets[onsetIndex];
|
|
383
|
+
if (beat === undefined) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const nextOnset = onsets[onsetIndex + 1];
|
|
387
|
+
const endBeat = nextOnset ?? Math.max(spanEnd, beat + GRID_STEP);
|
|
306
388
|
const melNote = melodyNoteAt(melody, beat);
|
|
307
389
|
const melPitch = melNote?.pitch;
|
|
308
|
-
const
|
|
390
|
+
const melodyVoice = melodySnapshotAt(melody, beat);
|
|
391
|
+
const melPrev = melodyVoice?.prevPitch;
|
|
309
392
|
const chord = opts.chordAt(beat);
|
|
310
|
-
const otherVoices: VoiceSnapshot[] =
|
|
311
|
-
melPitch !== undefined ? [{ pitch: melPitch, prevPitch: melPrev }] : [];
|
|
393
|
+
const otherVoices: VoiceSnapshot[] = melodyVoice !== undefined ? [melodyVoice] : [];
|
|
312
394
|
|
|
313
395
|
const candidates = enumerateSafePitches(
|
|
314
396
|
{
|
|
@@ -326,15 +408,26 @@ export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
|
|
|
326
408
|
);
|
|
327
409
|
|
|
328
410
|
let bestPitch: number | undefined;
|
|
411
|
+
let bestWorstSafety = NoteSafety.Dissonant;
|
|
329
412
|
let bestScore = Number.NEGATIVE_INFINITY;
|
|
330
413
|
for (const candidate of candidates) {
|
|
414
|
+
const worstSafety = heldPitchSafety(
|
|
415
|
+
candidate,
|
|
416
|
+
beat,
|
|
417
|
+
endBeat,
|
|
418
|
+
prevPitch,
|
|
419
|
+
melody,
|
|
420
|
+
opts,
|
|
421
|
+
profile,
|
|
422
|
+
register,
|
|
423
|
+
low,
|
|
424
|
+
high,
|
|
425
|
+
ts,
|
|
426
|
+
);
|
|
427
|
+
if (worstSafety === null || worstSafety === NoteSafety.Dissonant) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
331
430
|
if (melPitch !== undefined) {
|
|
332
|
-
if (register === 'below' && candidate >= melPitch) {
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
if (register === 'above' && candidate <= melPitch) {
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
431
|
if (
|
|
339
432
|
prevPitch !== undefined &&
|
|
340
433
|
melPrev !== undefined &&
|
|
@@ -348,32 +441,30 @@ export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
|
|
|
348
441
|
const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
|
|
349
442
|
const tie =
|
|
350
443
|
Math.abs(score - bestScore) <= EPS && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
|
|
351
|
-
if (
|
|
444
|
+
if (
|
|
445
|
+
worstSafety < bestWorstSafety ||
|
|
446
|
+
(worstSafety === bestWorstSafety && (score > bestScore + EPS || tie))
|
|
447
|
+
) {
|
|
448
|
+
bestWorstSafety = worstSafety;
|
|
352
449
|
bestScore = score;
|
|
353
450
|
bestPitch = candidate;
|
|
354
451
|
}
|
|
355
452
|
}
|
|
356
453
|
|
|
357
|
-
|
|
358
|
-
|
|
454
|
+
if (bestPitch === undefined) {
|
|
455
|
+
// Do not emit an unchecked fallback. Ending the previous note at this
|
|
456
|
+
// planned onset leaves an explicit rest when the constraints are
|
|
457
|
+
// unsatisfiable instead of returning a plausibly named unsafe line.
|
|
458
|
+
prevPitch = undefined;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const pitch = bestPitch;
|
|
359
462
|
const velocity = Math.max(
|
|
360
463
|
1,
|
|
361
464
|
Math.min(127, Math.round((melNote?.velocity ?? DEFAULT_MELODY_VELOCITY) - VELOCITY_DROP)),
|
|
362
465
|
);
|
|
363
|
-
out.push({ pitch, startBeat: beat, durationBeat:
|
|
466
|
+
out.push({ pitch, startBeat: beat, durationBeat: endBeat - beat, velocity });
|
|
364
467
|
prevPitch = pitch;
|
|
365
|
-
prevOnset = beat;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Extend each note to the next counter onset; the last to the melody's end.
|
|
369
|
-
for (let i = 0; i < out.length; i += 1) {
|
|
370
|
-
const note = out[i];
|
|
371
|
-
if (!note) {
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const next = out[i + 1];
|
|
375
|
-
const end = next ? next.startBeat : Math.max(spanEnd, note.startBeat + GRID_STEP);
|
|
376
|
-
note.durationBeat = end - note.startBeat;
|
|
377
468
|
}
|
|
378
469
|
return out;
|
|
379
470
|
}
|
|
@@ -7,10 +7,17 @@
|
|
|
7
7
|
* @param rotation Steps to rotate onsets toward later positions.
|
|
8
8
|
* @returns One boolean per step; true marks an onset.
|
|
9
9
|
*/
|
|
10
|
+
import {
|
|
11
|
+
assertGenerationBudget,
|
|
12
|
+
assertInteger,
|
|
13
|
+
assertPositiveInt,
|
|
14
|
+
} from '../../core/validation/index.js';
|
|
15
|
+
|
|
10
16
|
export function euclideanRhythm(pulses: number, steps: number, rotation = 0): boolean[] {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
assertPositiveInt(steps, 'euclidean steps');
|
|
18
|
+
assertInteger(pulses, 'euclidean pulses');
|
|
19
|
+
assertInteger(rotation, 'euclidean rotation');
|
|
20
|
+
assertGenerationBudget(steps, 'euclidean pattern steps');
|
|
14
21
|
const p = Math.max(0, Math.min(pulses, steps));
|
|
15
22
|
const base = bjorklund(p, steps);
|
|
16
23
|
const shift = ((rotation % steps) + steps) % steps;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertGenerationBudget,
|
|
3
|
+
assertInteger,
|
|
4
|
+
assertPositiveInt,
|
|
5
|
+
assertRange,
|
|
6
|
+
} from '../../core/validation/index.js';
|
|
1
7
|
import {
|
|
2
8
|
type BeatCtx,
|
|
3
9
|
generateGhostNotesForBeat,
|
|
@@ -7,7 +13,7 @@ import {
|
|
|
7
13
|
generateSnareForBeat,
|
|
8
14
|
type SectionCtx,
|
|
9
15
|
} from './beat.js';
|
|
10
|
-
import { euclideanRhythm
|
|
16
|
+
import { euclideanRhythm } from './euclid.js';
|
|
11
17
|
import { type FillType, generateFill, getFillStartBeat, selectFillType } from './fills.js';
|
|
12
18
|
import {
|
|
13
19
|
footHiHatVelocity,
|
|
@@ -35,12 +41,7 @@ import {
|
|
|
35
41
|
sectionDensityMultiplier,
|
|
36
42
|
sectionEnergy,
|
|
37
43
|
} from './internal.js';
|
|
38
|
-
import {
|
|
39
|
-
euclideanToKickPattern,
|
|
40
|
-
getKickPattern,
|
|
41
|
-
isInPreChorusLift,
|
|
42
|
-
type KickPattern,
|
|
43
|
-
} from './kick.js';
|
|
44
|
+
import { getKickPattern, isInPreChorusLift, type KickPattern } from './kick.js';
|
|
44
45
|
import { generateAuxPercussionForBar, getPercussionConfig } from './percussion.js';
|
|
45
46
|
import { createRng } from './rng.js';
|
|
46
47
|
|
|
@@ -164,6 +165,16 @@ export type DrumsOptions = {
|
|
|
164
165
|
* @category Composition
|
|
165
166
|
*/
|
|
166
167
|
export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
168
|
+
assertPositiveInt(opts.bars, 'drum bars');
|
|
169
|
+
assertRange(opts.bpm, Number.MIN_VALUE, 1000, 'drum bpm');
|
|
170
|
+
assertRange(opts.density, 0, 1, 'drum density');
|
|
171
|
+
assertGenerationBudget(opts.bars * 128, 'drum hits');
|
|
172
|
+
if (opts.euclideanKick) {
|
|
173
|
+
const steps = opts.euclideanKick.steps ?? 16;
|
|
174
|
+
assertInteger(steps, 'euclidean steps', 1, 16);
|
|
175
|
+
assertInteger(opts.euclideanKick.pulses, 'euclidean pulses');
|
|
176
|
+
assertInteger(opts.euclideanKick.rotation ?? 0, 'euclidean rotation');
|
|
177
|
+
}
|
|
167
178
|
const track = new HitList();
|
|
168
179
|
const rng = createRng(opts.seed ?? 0);
|
|
169
180
|
const mapping = mapStyle(opts.style);
|
|
@@ -210,16 +221,9 @@ export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
|
210
221
|
const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
|
|
211
222
|
const nextEnergy = sectionEnergy(nextSection);
|
|
212
223
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
euclideanRhythm(
|
|
217
|
-
opts.euclideanKick.pulses,
|
|
218
|
-
opts.euclideanKick.steps ?? 16,
|
|
219
|
-
opts.euclideanKick.rotation ?? 0,
|
|
220
|
-
),
|
|
221
|
-
),
|
|
222
|
-
)
|
|
224
|
+
const euclidSteps = opts.euclideanKick?.steps ?? 16;
|
|
225
|
+
const euclidKick = opts.euclideanKick
|
|
226
|
+
? euclideanRhythm(opts.euclideanKick.pulses, euclidSteps, opts.euclideanKick.rotation ?? 0)
|
|
223
227
|
: undefined;
|
|
224
228
|
|
|
225
229
|
const reuseSectionKick = (section === 'b' || section === 'chorus') && style !== 'sparse';
|
|
@@ -240,13 +244,11 @@ export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
|
240
244
|
barHasOpenHh = !track.hasCrashNear(barStart + openHhBeatIndex);
|
|
241
245
|
}
|
|
242
246
|
|
|
243
|
-
let kick: KickPattern;
|
|
244
|
-
if (euclidKick) {
|
|
245
|
-
kick = euclidKick;
|
|
246
|
-
} else if (reuseSectionKick) {
|
|
247
|
+
let kick: KickPattern | undefined;
|
|
248
|
+
if (!euclidKick && reuseSectionKick) {
|
|
247
249
|
sectionKick ??= getKickPattern(section, style, 0, rng);
|
|
248
250
|
kick = sectionKick;
|
|
249
|
-
} else {
|
|
251
|
+
} else if (!euclidKick) {
|
|
250
252
|
kick = getKickPattern(section, style, bar, rng);
|
|
251
253
|
}
|
|
252
254
|
|
|
@@ -295,7 +297,23 @@ export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
|
295
297
|
if (inLift) {
|
|
296
298
|
generatePreChorusBuildup(ctx, sec, isLastBar);
|
|
297
299
|
}
|
|
298
|
-
|
|
300
|
+
if (euclidKick && !inLift) {
|
|
301
|
+
const stepLength = 4 / euclidSteps;
|
|
302
|
+
for (let step = 0; step < euclidKick.length; step += 1) {
|
|
303
|
+
const onset = step * stepLength;
|
|
304
|
+
if (euclidKick[step] && Math.floor(onset) === beat) {
|
|
305
|
+
const wholeBeat = Math.abs(onset - Math.round(onset)) < 1e-9;
|
|
306
|
+
track.add(
|
|
307
|
+
GM.BD,
|
|
308
|
+
barStart + onset,
|
|
309
|
+
Math.min(0.5, stepLength),
|
|
310
|
+
velocity * (wholeBeat ? 1 : 0.85),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else if (kick) {
|
|
315
|
+
generateKickForBeat(ctx, sec, kick);
|
|
316
|
+
}
|
|
299
317
|
generateSnareForBeat(ctx, sec, section === 'intro' && bar === 0);
|
|
300
318
|
if (sec.useGhostNotes && !inLift) {
|
|
301
319
|
generateGhostNotesForBeat(ctx, sec);
|
|
@@ -314,10 +332,24 @@ export function generateDrums(opts: DrumsOptions): DrumHit[] {
|
|
|
314
332
|
}
|
|
315
333
|
}
|
|
316
334
|
|
|
335
|
+
let percussionConfig = getPercussionConfig(percMood, section);
|
|
336
|
+
if (
|
|
337
|
+
role === 'fxOnly' &&
|
|
338
|
+
!percussionConfig.tambourine &&
|
|
339
|
+
!percussionConfig.shaker &&
|
|
340
|
+
!percussionConfig.handclap
|
|
341
|
+
) {
|
|
342
|
+
percussionConfig = {
|
|
343
|
+
tambourine: false,
|
|
344
|
+
shaker: true,
|
|
345
|
+
handclap: false,
|
|
346
|
+
shaker16th: false,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
317
349
|
generateAuxPercussionForBar(
|
|
318
350
|
track,
|
|
319
351
|
barStart,
|
|
320
|
-
|
|
352
|
+
percussionConfig,
|
|
321
353
|
role,
|
|
322
354
|
densityMult,
|
|
323
355
|
rng,
|
|
@@ -16,6 +16,13 @@ import {
|
|
|
16
16
|
} from '../../core/meter/index.js';
|
|
17
17
|
import { createRng } from '../../core/random/index.js';
|
|
18
18
|
import type { NoteEvent } from '../../core/types.js';
|
|
19
|
+
import {
|
|
20
|
+
assertGenerationBudget,
|
|
21
|
+
assertNoteEvents,
|
|
22
|
+
assertPositiveInt,
|
|
23
|
+
assertRange,
|
|
24
|
+
assertTimeSignature,
|
|
25
|
+
} from '../../core/validation/index.js';
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* Options controlling {@link humanize}.
|
|
@@ -99,10 +106,16 @@ const MAX_VELOCITY = 127;
|
|
|
99
106
|
*/
|
|
100
107
|
export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteEvent[] {
|
|
101
108
|
const ts = opts.ts ?? DEFAULT_TS;
|
|
109
|
+
assertTimeSignature(ts);
|
|
110
|
+
assertNoteEvents(events, 'humanize events');
|
|
102
111
|
const timing = opts.timing ?? DEFAULT_TIMING;
|
|
103
112
|
const velocityJitter = opts.velocity ?? DEFAULT_VELOCITY_JITTER;
|
|
104
113
|
const accent = opts.accent ?? DEFAULT_ACCENT;
|
|
105
114
|
const baseVelocity = opts.baseVelocity ?? DEFAULT_BASE_VELOCITY;
|
|
115
|
+
assertRange(timing, 0, Number.MAX_SAFE_INTEGER, 'humanize timing');
|
|
116
|
+
assertRange(velocityJitter, 0, 127, 'humanize velocity jitter');
|
|
117
|
+
assertRange(accent, 0, 127, 'humanize accent');
|
|
118
|
+
assertRange(baseVelocity, 0, 127, 'humanize base velocity');
|
|
106
119
|
const rng = createRng(opts.seed ?? 0);
|
|
107
120
|
|
|
108
121
|
return events.map((event) => {
|
|
@@ -212,8 +225,12 @@ export function extractGrooveTemplate(
|
|
|
212
225
|
ts: TimeSignature,
|
|
213
226
|
subdivision: number,
|
|
214
227
|
): GrooveTemplate {
|
|
228
|
+
assertTimeSignature(ts);
|
|
229
|
+
assertNoteEvents(events, 'groove source events');
|
|
230
|
+
assertPositiveInt(subdivision, 'groove subdivision');
|
|
215
231
|
const barBeats = beatsPerBar(ts);
|
|
216
232
|
const slotsPerBar = Math.round(barBeats * subdivision);
|
|
233
|
+
assertGenerationBudget(slotsPerBar, 'groove template slots');
|
|
217
234
|
const offsetSums = new Array<number>(slotsPerBar).fill(0);
|
|
218
235
|
const velocitySums = new Array<number>(slotsPerBar).fill(0);
|
|
219
236
|
const counts = new Array<number>(slotsPerBar).fill(0);
|
|
@@ -292,6 +309,14 @@ export function applyGrooveTemplate(
|
|
|
292
309
|
template: GrooveTemplate,
|
|
293
310
|
ts: TimeSignature,
|
|
294
311
|
): NoteEvent[] {
|
|
312
|
+
assertTimeSignature(ts);
|
|
313
|
+
assertNoteEvents(events, 'groove target events');
|
|
314
|
+
assertPositiveInt(template.subdivision, 'template subdivision');
|
|
315
|
+
assertPositiveInt(template.slotsPerBar, 'template slotsPerBar');
|
|
316
|
+
assertGenerationBudget(template.slots.length, 'template slots');
|
|
317
|
+
if (template.slots.length !== template.slotsPerBar) {
|
|
318
|
+
throw new RangeError('template slots length must equal slotsPerBar');
|
|
319
|
+
}
|
|
295
320
|
if (
|
|
296
321
|
template.ts &&
|
|
297
322
|
(template.ts.numerator !== ts.numerator || template.ts.denominator !== ts.denominator)
|