@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.
Files changed (126) hide show
  1. package/README.md +4 -3
  2. package/dist/analyze/index.cjs +315 -42
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +11 -99
  5. package/dist/analyze/index.d.ts +11 -99
  6. package/dist/analyze/index.js +6 -6
  7. package/dist/chunk-2ZWMSJH6.js +30 -0
  8. package/dist/chunk-2ZWMSJH6.js.map +1 -0
  9. package/dist/{chunk-R3S2LUBH.js → chunk-4MHA432G.js} +316 -184
  10. package/dist/chunk-4MHA432G.js.map +1 -0
  11. package/dist/chunk-62QZKCJQ.js +49 -0
  12. package/dist/chunk-62QZKCJQ.js.map +1 -0
  13. package/dist/chunk-AKLYPWIN.js +83 -0
  14. package/dist/chunk-AKLYPWIN.js.map +1 -0
  15. package/dist/{chunk-XTC3X6MS.js → chunk-C5XYKUVG.js} +100 -12
  16. package/dist/chunk-C5XYKUVG.js.map +1 -0
  17. package/dist/{chunk-LN7ZZMD2.js → chunk-CHMXELDS.js} +33 -14
  18. package/dist/chunk-CHMXELDS.js.map +1 -0
  19. package/dist/{chunk-A4532ZFR.js → chunk-EH6EM37U.js} +60 -4
  20. package/dist/chunk-EH6EM37U.js.map +1 -0
  21. package/dist/chunk-FBQK3ZQN.js +131 -0
  22. package/dist/chunk-FBQK3ZQN.js.map +1 -0
  23. package/dist/{chunk-5HTNMQW4.js → chunk-GOAW4VYL.js} +87 -21
  24. package/dist/chunk-GOAW4VYL.js.map +1 -0
  25. package/dist/{chunk-MSWFZF7J.js → chunk-I5R7ZBJS.js} +90 -6
  26. package/dist/chunk-I5R7ZBJS.js.map +1 -0
  27. package/dist/{chunk-GQKN5KDT.js → chunk-IT2O4XFE.js} +4 -9
  28. package/dist/chunk-IT2O4XFE.js.map +1 -0
  29. package/dist/{chunk-7Y7WF64D.js → chunk-MIWO425M.js} +6 -4
  30. package/dist/chunk-MIWO425M.js.map +1 -0
  31. package/dist/chunk-N6IHQUOX.js +1 -0
  32. package/dist/chunk-N6IHQUOX.js.map +1 -0
  33. package/dist/{chunk-COKTX2G5.js → chunk-SGU7SPLK.js} +29 -12
  34. package/dist/chunk-SGU7SPLK.js.map +1 -0
  35. package/dist/core/index.cjs +230 -14
  36. package/dist/core/index.cjs.map +1 -1
  37. package/dist/core/index.d.cts +56 -4
  38. package/dist/core/index.d.ts +56 -4
  39. package/dist/core/index.js +24 -4
  40. package/dist/{function-2bJW1Zp_.d.ts → function-DVH4Eusr.d.ts} +1 -1
  41. package/dist/{function-r2sh-jOO.d.cts → function-DYt4iKsn.d.cts} +1 -1
  42. package/dist/generate/index.cjs +830 -494
  43. package/dist/generate/index.cjs.map +1 -1
  44. package/dist/generate/index.d.cts +11 -5
  45. package/dist/generate/index.d.ts +11 -5
  46. package/dist/generate/index.js +9 -9
  47. package/dist/{index-BOBXBNwP.d.cts → index-BNlGP86A.d.cts} +1 -1
  48. package/dist/{index-CP3wAfEH.d.ts → index-Bg5dYMmZ.d.ts} +1 -1
  49. package/dist/{index-CM5W4rcm.d.ts → index-Bt4tOUD9.d.ts} +1 -1
  50. package/dist/index-CnZCo29u.d.cts +108 -0
  51. package/dist/{index-D_ZTUNKp.d.cts → index-CpOsP1y9.d.cts} +2 -2
  52. package/dist/{index-DZ1M21do.d.cts → index-CxqBQtH1.d.cts} +2 -1
  53. package/dist/{index-BdeQ1r_p.d.cts → index-Dikst44v.d.cts} +1 -1
  54. package/dist/{index-BMPxwjwV.d.ts → index-DxvARIP2.d.ts} +2 -2
  55. package/dist/{index-BreoJRw0.d.ts → index-Jc0Ryi79.d.ts} +2 -1
  56. package/dist/index-JnF4v9bx.d.ts +108 -0
  57. package/dist/index.cjs +818 -283
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +9 -8
  60. package/dist/index.d.ts +9 -8
  61. package/dist/index.js +34 -13
  62. package/dist/model/index.cjs +170 -31
  63. package/dist/model/index.cjs.map +1 -1
  64. package/dist/model/index.d.cts +6 -5
  65. package/dist/model/index.d.ts +6 -5
  66. package/dist/model/index.js +6 -6
  67. package/dist/{styled-D7BAx3S3.d.ts → styled-DZ7GjShb.d.ts} +1 -1
  68. package/dist/{styled-BjXtOFNk.d.cts → styled-ZTbdaVFM.d.cts} +1 -1
  69. package/dist/theory/index.cjs +180 -34
  70. package/dist/theory/index.cjs.map +1 -1
  71. package/dist/theory/index.d.cts +6 -6
  72. package/dist/theory/index.d.ts +6 -6
  73. package/dist/theory/index.js +6 -5
  74. package/package.json +50 -19
  75. package/src/analyze/arrange/internal.ts +3 -1
  76. package/src/analyze/arrange/tension.ts +15 -3
  77. package/src/analyze/arrange/tracks.ts +36 -3
  78. package/src/analyze/detect/index.ts +46 -9
  79. package/src/analyze/functional/roman.ts +14 -5
  80. package/src/analyze/index.ts +1 -1
  81. package/src/analyze/timeline/index.ts +43 -7
  82. package/src/analyze/voice/index.ts +11 -2
  83. package/src/core/event-index/index.ts +104 -0
  84. package/src/core/index.ts +14 -0
  85. package/src/core/meter/index.ts +24 -4
  86. package/src/core/random/index.ts +26 -3
  87. package/src/core/tuning/index.ts +36 -6
  88. package/src/core/validation/index.ts +119 -0
  89. package/src/generate/bass/index.ts +23 -0
  90. package/src/generate/countermelody/index.ts +167 -76
  91. package/src/generate/drums/euclid.ts +10 -3
  92. package/src/generate/drums/index.ts +56 -24
  93. package/src/generate/drums/percussion.ts +1 -1
  94. package/src/generate/groove/index.ts +25 -0
  95. package/src/generate/harmonize/index.ts +55 -44
  96. package/src/generate/index.ts +0 -1
  97. package/src/generate/motif/index.ts +45 -21
  98. package/src/generate/progression/index.ts +8 -16
  99. package/src/generate/reharmony/index.ts +1 -8
  100. package/src/generate/rhythm/index.ts +11 -1
  101. package/src/index.ts +13 -247
  102. package/src/model/chord.ts +29 -10
  103. package/src/theory/chord/index.ts +4 -2
  104. package/src/theory/harmony/index.ts +6 -21
  105. package/src/theory/index.ts +6 -1
  106. package/src/theory/safety/index.ts +71 -2
  107. package/src/theory/spelling/index.ts +70 -3
  108. package/src/theory/voicing/satb.ts +25 -4
  109. package/src/theory/voicing/styled.ts +26 -3
  110. package/dist/chunk-5HTNMQW4.js.map +0 -1
  111. package/dist/chunk-7Y7WF64D.js.map +0 -1
  112. package/dist/chunk-A4532ZFR.js.map +0 -1
  113. package/dist/chunk-BHUT2S5G.js +0 -22
  114. package/dist/chunk-BHUT2S5G.js.map +0 -1
  115. package/dist/chunk-CCBLVAPK.js +0 -42
  116. package/dist/chunk-CCBLVAPK.js.map +0 -1
  117. package/dist/chunk-COKTX2G5.js.map +0 -1
  118. package/dist/chunk-GQKN5KDT.js.map +0 -1
  119. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  120. package/dist/chunk-MSWFZF7J.js.map +0 -1
  121. package/dist/chunk-R3S2LUBH.js.map +0 -1
  122. package/dist/chunk-SDM3VJJ5.js +0 -41
  123. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  124. package/dist/chunk-XTC3X6MS.js.map +0 -1
  125. package/dist/chunk-ZXW5YIWK.js +0 -55
  126. 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: NoteEvent[], beat: number): NoteEvent | undefined {
102
- let found: NoteEvent | undefined;
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: NoteEvent[], beat: number): boolean {
115
- return melody.some((note) => Math.abs(note.startBeat - beat) < EPS);
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: NoteEvent[]): number[] {
120
- return [...new Set(melody.map((note) => note.startBeat))].sort((a, b) => a - b);
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: NoteEvent[],
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
- * Last-resort pitch when no safe candidate survives filtering: the scale tone
213
- * nearest the previous pitch (or the register centre), pushed onto the correct
214
- * side of the sounding melody note.
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 fallbackPitch(
217
- melPitch: number | undefined,
262
+ function heldPitchSafety(
263
+ pitch: number,
264
+ startBeat: number,
265
+ endBeat: number,
218
266
  prevPitch: number | undefined,
219
- center: number,
267
+ melody: NoteEventIndex,
268
+ opts: CounterMelodyOptions,
269
+ profile: 'strict' | 'pop',
220
270
  register: 'above' | 'below',
221
271
  low: number,
222
272
  high: number,
223
- key: KeyScale,
224
- ): number {
225
- let target = prevPitch ?? Math.round(center);
226
- if (melPitch !== undefined) {
227
- if (register === 'below' && target >= melPitch) {
228
- target = melPitch - 5;
229
- } else if (register === 'above' && target <= melPitch) {
230
- target = melPitch + 5;
231
- }
232
- }
233
- target = Math.min(Math.max(target, low), high);
234
- let pitch = nearestScaleTone(target, key);
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
- while (register === 'above' && pitch <= melPitch && pitch + 12 <= high) {
243
- pitch += 12;
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 Math.min(Math.max(pitch, low), high);
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 = [...opts.melody].sort((a, b) => a.startBeat - b.startBeat);
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.startBeat + n.durationBeat), spanStart);
290
- const meanPitch = melody.reduce((sum, n) => sum + n.pitch, 0) / melody.length;
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 prevOnset: number | undefined;
305
- for (const beat of onsets) {
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 melPrev = prevOnset !== undefined ? melodyNoteAt(melody, prevOnset)?.pitch : undefined;
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 (score > bestScore + EPS || tie) {
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
- const pitch =
358
- bestPitch ?? fallbackPitch(melPitch, prevPitch, center, register, low, high, opts.key);
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: GRID_STEP, velocity });
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
- if (steps < 1) {
12
- return [];
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, patternToMask } from './euclid.js';
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 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
- )
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
- generateKickForBeat(ctx, sec, kick);
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
- getPercussionConfig(percMood, section),
352
+ percussionConfig,
321
353
  role,
322
354
  densityMult,
323
355
  rng,
@@ -70,7 +70,7 @@ export function generateAuxPercussionForBar(
70
70
  rng: DrumRng,
71
71
  bpm: number,
72
72
  ): void {
73
- if (role === 'minimal' || role === 'fxOnly') {
73
+ if (role === 'minimal') {
74
74
  return;
75
75
  }
76
76
 
@@ -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)