@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
package/README.md CHANGED
@@ -10,7 +10,7 @@ dependencies.
10
10
  [![codecov](https://codecov.io/gh/libraz/libcantus/branch/main/graph/badge.svg)](https://codecov.io/gh/libraz/libcantus)
11
11
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](https://github.com/libraz/libcantus/blob/main/LICENSE)
12
12
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
13
- [![Node.js](https://img.shields.io/badge/Node.js-%E2%89%A522-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
13
+ [![Node.js](https://img.shields.io/badge/Node.js-22.x-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
14
14
  [![docs](https://img.shields.io/badge/docs-API%20reference-b5892e)](https://libraz.github.io/libcantus/)
15
15
 
16
16
  ## What can you do with it?
@@ -245,11 +245,12 @@ Seeded, deterministic generators for melody and accompaniment:
245
245
  ```ts
246
246
  import {
247
247
  generateBassLine, generateCounterMelody, generateDrums, generateRhythm,
248
- generateMotif, parseTimeSignature,
248
+ generateMotif, majorKey, parseTimeSignature,
249
249
  } from '@libraz/libcantus';
250
250
 
251
251
  generateRhythm(parseTimeSignature('4/4'), { seed: 1, density: 0.5 }); // strong-beat-weighted onsets
252
- generateMotif({ key: majorKey(0), length: 4, contour: 'arch', seed: 1 }); // a short melodic cell
252
+ const motif = generateMotif({ key: majorKey(0), bars: 2, contour: 'arch', seed: 1 });
253
+ motif.notes.length > 0; // true — a deterministic short melodic cell
253
254
  ```
254
255
 
255
256
  `humanize`, `extractGrooveTemplate`, and `applyGrooveTemplate` add a meter-aware
@@ -43,6 +43,86 @@ __export(analyze_exports, {
43
43
  });
44
44
  module.exports = __toCommonJS(analyze_exports);
45
45
 
46
+ // src/core/validation/index.ts
47
+ var DEFAULT_GENERATION_BUDGET = 1e6;
48
+ function assertFiniteNumber(value, name) {
49
+ if (!Number.isFinite(value)) {
50
+ throw new RangeError(`${name} must be finite; received ${value}`);
51
+ }
52
+ return value;
53
+ }
54
+ function assertInteger(value, name, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
55
+ assertFiniteNumber(value, name);
56
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
57
+ throw new RangeError(`${name} must be an integer in [${min}, ${max}]; received ${value}`);
58
+ }
59
+ return value;
60
+ }
61
+ function assertPositiveInt(value, name, max = DEFAULT_GENERATION_BUDGET) {
62
+ return assertInteger(value, name, 1, max);
63
+ }
64
+ function assertRange(value, min, max, name) {
65
+ assertFiniteNumber(value, name);
66
+ if (value < min || value > max) {
67
+ throw new RangeError(`${name} must be in [${min}, ${max}]; received ${value}`);
68
+ }
69
+ return value;
70
+ }
71
+ function assertGenerationBudget(estimated, name, limit = DEFAULT_GENERATION_BUDGET) {
72
+ assertFiniteNumber(estimated, name);
73
+ assertPositiveInt(limit, `${name} limit`, Number.MAX_SAFE_INTEGER);
74
+ if (estimated < 0 || estimated > limit) {
75
+ throw new RangeError(`${name} exceeds the generation budget ${limit}; received ${estimated}`);
76
+ }
77
+ return estimated;
78
+ }
79
+ function assertTimeSignature(ts, name = "time signature") {
80
+ assertPositiveInt(ts.numerator, `${name}.numerator`);
81
+ assertPositiveInt(ts.denominator, `${name}.denominator`);
82
+ if (ts.grouping !== void 0) {
83
+ if (ts.grouping.length === 0) {
84
+ throw new RangeError(`${name}.grouping must not be empty`);
85
+ }
86
+ let sum = 0;
87
+ for (let index = 0; index < ts.grouping.length; index += 1) {
88
+ sum += assertPositiveInt(ts.grouping[index] ?? Number.NaN, `${name}.grouping[${index}]`);
89
+ }
90
+ const compound = ts.numerator % 3 === 0 && ts.numerator > 3;
91
+ const pulses = compound ? ts.numerator / 3 : ts.numerator;
92
+ if (sum !== pulses) {
93
+ throw new RangeError(`${name}.grouping must sum to ${pulses}; received ${sum}`);
94
+ }
95
+ }
96
+ return ts;
97
+ }
98
+ function assertNoteEvent(event, name = "note event", options = {}) {
99
+ assertFiniteNumber(event.pitch, `${name}.pitch`);
100
+ assertRange(event.startBeat, 0, Number.MAX_SAFE_INTEGER, `${name}.startBeat`);
101
+ assertRange(
102
+ event.durationBeat,
103
+ options.allowNonPositiveDuration ? -Number.MAX_SAFE_INTEGER : Number.MIN_VALUE,
104
+ Number.MAX_SAFE_INTEGER,
105
+ `${name}.durationBeat`
106
+ );
107
+ if (!options.allowNonPositiveDuration && event.durationBeat <= 0) {
108
+ throw new RangeError(`${name}.durationBeat must be positive; received ${event.durationBeat}`);
109
+ }
110
+ if (event.velocity !== void 0) {
111
+ assertRange(event.velocity, 0, 127, `${name}.velocity`);
112
+ }
113
+ return event;
114
+ }
115
+ function assertNoteEvents(events, name = "note events", options = {}) {
116
+ assertGenerationBudget(events.length, `${name} count`, options.budget);
117
+ for (let index = 0; index < events.length; index += 1) {
118
+ const event = events[index];
119
+ if (event !== void 0) {
120
+ assertNoteEvent(event, `${name}[${index}]`, options);
121
+ }
122
+ }
123
+ return events;
124
+ }
125
+
46
126
  // src/core/meter/index.ts
47
127
  var EPS = 1e-9;
48
128
  function isMultiple(value, unit) {
@@ -59,21 +139,24 @@ function parseTimeSignature(text) {
59
139
  }
60
140
  const numerator = Number.parseInt(match[1] ?? "", 10);
61
141
  const denominator = Number.parseInt(match[2] ?? "", 10);
62
- if (numerator <= 0 || denominator <= 0) {
142
+ if (!Number.isSafeInteger(numerator) || !Number.isSafeInteger(denominator) || numerator <= 0 || denominator <= 0) {
63
143
  throw new Error(`Invalid time signature: ${text}`);
64
144
  }
65
145
  return { numerator, denominator };
66
146
  }
67
147
  function formatTimeSignature(ts) {
148
+ assertTimeSignature(ts);
68
149
  return `${ts.numerator}/${ts.denominator}`;
69
150
  }
70
151
  function isCompound(ts) {
152
+ assertTimeSignature(ts);
71
153
  return ts.numerator % 3 === 0 && ts.numerator > 3;
72
154
  }
73
155
  function unitBeats(ts) {
74
156
  return 4 / ts.denominator;
75
157
  }
76
158
  function beatsPerBar(ts) {
159
+ assertTimeSignature(ts);
77
160
  return ts.numerator * unitBeats(ts);
78
161
  }
79
162
  function pulseBeats(ts) {
@@ -106,11 +189,14 @@ function isGroupHead(grouping, pulseIndex) {
106
189
  return false;
107
190
  }
108
191
  function beatToBarPosition(beatInQuarters, ts) {
192
+ assertFiniteNumber(beatInQuarters, "beat");
109
193
  const barLen = beatsPerBar(ts);
110
194
  const bar = Math.floor(beatInQuarters / barLen);
111
195
  return { bar, beat: beatInQuarters - bar * barLen };
112
196
  }
113
197
  function metricWeight(beatInQuarters, ts) {
198
+ assertFiniteNumber(beatInQuarters, "beat");
199
+ assertTimeSignature(ts);
114
200
  const { beat } = beatToBarPosition(beatInQuarters, ts);
115
201
  const pulse = pulseBeats(ts);
116
202
  if (!isMultiple(beat, pulse)) {
@@ -359,6 +445,31 @@ function isLeadingToneResolution(prev, cur, key) {
359
445
  // src/theory/safety/index.ts
360
446
  var SUGGESTION_WINDOW = 12;
361
447
  var MAX_SUGGESTIONS = 3;
448
+ var REASON_SEVERITY = [
449
+ [2 /* Tension */, 1 /* Warning */, 1 /* Warning */],
450
+ [4 /* AvoidNote */, 1 /* Warning */, 2 /* Dissonant */],
451
+ [8 /* ScaleTone */, 1 /* Warning */, 1 /* Warning */],
452
+ [16 /* NonScale */, 2 /* Dissonant */, 2 /* Dissonant */],
453
+ [32 /* OutOfRange */, 1 /* Warning */, 1 /* Warning */],
454
+ [64 /* Tritone */, 2 /* Dissonant */, 2 /* Dissonant */],
455
+ [128 /* LargeLeap */, 1 /* Warning */, 2 /* Dissonant */],
456
+ [1024 /* VerticalDissonance */, 2 /* Dissonant */, 2 /* Dissonant */],
457
+ [2048 /* ParallelPerfect */, 1 /* Warning */, 2 /* Dissonant */],
458
+ [4096 /* HiddenParallel */, 1 /* Warning */, 2 /* Dissonant */],
459
+ [8192 /* VoiceCrossing */, 1 /* Warning */, 2 /* Dissonant */],
460
+ [16384 /* Suspension */, 2 /* Dissonant */, 2 /* Dissonant */],
461
+ [32768 /* NeedsResolution */, 1 /* Warning */, 2 /* Dissonant */]
462
+ ];
463
+ function minimumSafetyForReasons(reasons, profile) {
464
+ let minimum = 0 /* Safe */;
465
+ const severityIndex = profile === "strict" ? 2 : 1;
466
+ for (const entry of REASON_SEVERITY) {
467
+ if (reasons & entry[0]) {
468
+ minimum = Math.max(minimum, entry[severityIndex]);
469
+ }
470
+ }
471
+ return minimum;
472
+ }
362
473
  function pitchClass4(pitch) {
363
474
  return (Math.trunc(pitch) % 12 + 12) % 12;
364
475
  }
@@ -383,8 +494,27 @@ function stepResolution(pitch, chord) {
383
494
  return void 0;
384
495
  }
385
496
  function evaluateSafety(q) {
497
+ assertSafetyContext(q);
498
+ assertFiniteNumber(q.candidatePitch, "candidatePitch");
386
499
  return evaluateInternal(q, true);
387
500
  }
501
+ function assertSafetyContext(q) {
502
+ if (q.prevPitch !== void 0) assertFiniteNumber(q.prevPitch, "prevPitch");
503
+ if (q.vocalLow !== void 0) assertFiniteNumber(q.vocalLow, "vocalLow");
504
+ if (q.vocalHigh !== void 0) assertFiniteNumber(q.vocalHigh, "vocalHigh");
505
+ if (q.vocalLow !== void 0 && q.vocalHigh !== void 0 && q.vocalLow > q.vocalHigh) {
506
+ throw new RangeError("vocalLow must not exceed vocalHigh");
507
+ }
508
+ assertGenerationBudget(q.otherVoices.length, "other voices");
509
+ for (let index = 0; index < q.otherVoices.length; index += 1) {
510
+ const voice = q.otherVoices[index];
511
+ if (!voice) continue;
512
+ assertFiniteNumber(voice.pitch, `otherVoices[${index}].pitch`);
513
+ if (voice.prevPitch !== void 0) {
514
+ assertFiniteNumber(voice.prevPitch, `otherVoices[${index}].prevPitch`);
515
+ }
516
+ }
517
+ }
388
518
  function evaluateInternal(q, collectSuggestions) {
389
519
  let reasons = 0;
390
520
  let level = 0 /* Safe */;
@@ -445,9 +575,11 @@ function evaluateInternal(q, collectSuggestions) {
445
575
  const pc = Math.abs(pitch - q.prevPitch) % 12;
446
576
  if (pc === 6) {
447
577
  reasons |= 64 /* Tritone */;
578
+ raise(2 /* Dissonant */);
448
579
  }
449
580
  if (isForbiddenMelodicLeap(q.prevPitch, pitch)) {
450
581
  reasons |= 128 /* LargeLeap */;
582
+ raise(parallelSeverity);
451
583
  }
452
584
  if (pc === 1) {
453
585
  reasons |= 256 /* MinorSecond */;
@@ -492,6 +624,7 @@ function evaluateInternal(q, collectSuggestions) {
492
624
  }
493
625
  }
494
626
  }
627
+ raise(minimumSafetyForReasons(reasons, q.profile));
495
628
  const result = { safety: level, reasons, rationale: describe(reasons, q) };
496
629
  if (resolveTo !== void 0) {
497
630
  result.resolveTo = resolveTo;
@@ -684,11 +817,16 @@ function parseSimpleRoman(text, key) {
684
817
  if (canonical !== void 0) {
685
818
  return { rootPc, quality: canonical, inversion: 0 };
686
819
  }
687
- const isDim = /o|°|dim/.test(suffix);
688
- const isAug = /\+|aug/.test(suffix);
689
- const isHalfDim = /ø/.test(suffix);
690
- const explicitMaj7 = /maj7|M7/.test(suffix);
691
- const figures = suffix.replace(/maj7|M7/g, "").replace(/[^0-9]/g, "");
820
+ const parsedSuffix = /^(o|°|dim|ø|\+|aug)?(maj7|M7)?(65|64|43|42|7|6|2)?$/.exec(suffix);
821
+ if (!parsedSuffix) {
822
+ throw new Error(`Unsupported suffix "${suffix}" in Roman numeral: ${text}`);
823
+ }
824
+ const qualityMarker = parsedSuffix[1] ?? "";
825
+ const isDim = qualityMarker === "o" || qualityMarker === "\xB0" || qualityMarker === "dim";
826
+ const isAug = qualityMarker === "+" || qualityMarker === "aug";
827
+ const isHalfDim = qualityMarker === "\xF8";
828
+ const explicitMaj7 = parsedSuffix[2] !== void 0;
829
+ const figures = parsedSuffix[3] ?? "";
692
830
  if (figures !== "" && !INVERSION_FIGURES.has(figures)) {
693
831
  throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
694
832
  }
@@ -937,6 +1075,73 @@ function detectCadence(from, to, key) {
937
1075
  return null;
938
1076
  }
939
1077
 
1078
+ // src/core/event-index/index.ts
1079
+ var EPS2 = 1e-9;
1080
+ function upperBound(values, beat) {
1081
+ let low = 0;
1082
+ let high = values.length;
1083
+ while (low < high) {
1084
+ const middle = Math.floor((low + high) / 2);
1085
+ if ((values[middle]?.note.startBeat ?? Number.POSITIVE_INFINITY) <= beat) {
1086
+ low = middle + 1;
1087
+ } else {
1088
+ high = middle;
1089
+ }
1090
+ }
1091
+ return low;
1092
+ }
1093
+ function createNoteEventIndex(events, options = {}) {
1094
+ assertNoteEvents(events, "note events", options);
1095
+ const notes = events.map((note, originalIndex) => ({
1096
+ note,
1097
+ originalIndex,
1098
+ endBeat: note.startBeat + note.durationBeat
1099
+ })).sort((a, b) => a.note.startBeat - b.note.startBeat || a.originalIndex - b.originalIndex);
1100
+ const prefixMaxEnd = [];
1101
+ let maxEnd = Number.NEGATIVE_INFINITY;
1102
+ for (const indexed of notes) {
1103
+ maxEnd = Math.max(maxEnd, indexed.endBeat);
1104
+ prefixMaxEnd.push(maxEnd);
1105
+ }
1106
+ return {
1107
+ notes,
1108
+ at(beat) {
1109
+ let index = upperBound(notes, beat + EPS2) - 1;
1110
+ while (index >= 0) {
1111
+ if ((prefixMaxEnd[index] ?? Number.NEGATIVE_INFINITY) <= beat + EPS2) {
1112
+ return void 0;
1113
+ }
1114
+ const indexed = notes[index];
1115
+ if (indexed && indexed.note.durationBeat > 0 && indexed.note.startBeat - EPS2 <= beat && beat < indexed.endBeat - EPS2) {
1116
+ return indexed;
1117
+ }
1118
+ index -= 1;
1119
+ }
1120
+ return void 0;
1121
+ },
1122
+ attacksAt(beat) {
1123
+ const firstAfter = upperBound(notes, beat + EPS2);
1124
+ const candidate = notes[firstAfter - 1];
1125
+ return candidate !== void 0 && Math.abs(candidate.note.startBeat - beat) < EPS2;
1126
+ },
1127
+ onsetsBetween(startBeat, endBeat) {
1128
+ const result = [];
1129
+ let index = upperBound(notes, startBeat + EPS2);
1130
+ while (index < notes.length) {
1131
+ const onset = notes[index]?.note.startBeat;
1132
+ if (onset === void 0 || onset >= endBeat - EPS2) {
1133
+ break;
1134
+ }
1135
+ if (result[result.length - 1] !== onset) {
1136
+ result.push(onset);
1137
+ }
1138
+ index += 1;
1139
+ }
1140
+ return result;
1141
+ }
1142
+ };
1143
+ }
1144
+
940
1145
  // src/analyze/detect/index.ts
941
1146
  function pitchClass5(value) {
942
1147
  return (Math.trunc(value) % 12 + 12) % 12;
@@ -944,12 +1149,21 @@ function pitchClass5(value) {
944
1149
  function uniquePitchClasses(pitches) {
945
1150
  return [...new Set(pitches.map(pitchClass5))].sort((a, b) => a - b);
946
1151
  }
947
- function detectChord(pitches) {
1152
+ function assertPitches(pitches) {
1153
+ assertGenerationBudget(pitches.length, "detection pitches");
1154
+ for (let index = 0; index < pitches.length; index += 1) {
1155
+ assertFiniteNumber(pitches[index] ?? Number.NaN, `pitches[${index}]`);
1156
+ }
1157
+ }
1158
+ function detectChord(pitches, opts = {}) {
1159
+ assertPitches(pitches);
1160
+ if (opts.bassPc !== void 0) assertFiniteNumber(opts.bassPc, "bassPc");
948
1161
  const input = uniquePitchClasses(pitches);
949
1162
  if (input.length === 0) {
950
1163
  return [];
951
1164
  }
952
- const bassPc = pitchClass5(Math.min(...pitches));
1165
+ const inputKind = opts.input === void 0 || opts.input === "auto" ? pitches.every((pitch) => Number.isInteger(pitch) && pitch >= 0 && pitch <= 11) ? "pitchClass" : "midi" : opts.input;
1166
+ const bassPc = opts.bassPc !== void 0 ? pitchClass5(opts.bassPc) : inputKind === "midi" ? pitchClass5(pitches.reduce((lowest, pitch) => Math.min(lowest, pitch), Infinity)) : void 0;
953
1167
  const inputSet = new Set(input);
954
1168
  const matches = [];
955
1169
  const qualities = chordQualities();
@@ -964,8 +1178,8 @@ function detectChord(pitches) {
964
1178
  continue;
965
1179
  }
966
1180
  const extraPcs = input.filter((pc) => !toneSet.has(pc));
967
- const bassIndex = chord.intervals.findIndex((iv) => pitchClass5(rootPc + iv) === bassPc);
968
- const inversion = bassIndex > 0 ? bassIndex : 0;
1181
+ const bassIndex = bassPc === void 0 ? -1 : chord.intervals.findIndex((iv) => pitchClass5(rootPc + iv) === bassPc);
1182
+ const inversion = bassPc === void 0 ? null : bassIndex > 0 ? bassIndex : 0;
969
1183
  const match = {
970
1184
  rootPc,
971
1185
  quality,
@@ -974,7 +1188,7 @@ function detectChord(pitches) {
974
1188
  exact: extraPcs.length === 0 && missingPcs.length === 0,
975
1189
  inversion
976
1190
  };
977
- if (inversion > 0) {
1191
+ if (bassPc !== void 0 && bassPc !== rootPc) {
978
1192
  match.bassPc = bassPc;
979
1193
  }
980
1194
  matches.push(match);
@@ -996,8 +1210,8 @@ function detectChord(pitches) {
996
1210
  });
997
1211
  return matches;
998
1212
  }
999
- function detectChordBest(pitches) {
1000
- const best = detectChord(pitches)[0];
1213
+ function detectChordBest(pitches, opts = {}) {
1214
+ const best = detectChord(pitches, opts)[0];
1001
1215
  if (!best) {
1002
1216
  return null;
1003
1217
  }
@@ -1005,6 +1219,7 @@ function detectChordBest(pitches) {
1005
1219
  }
1006
1220
  var MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MINOR_MASK];
1007
1221
  function detectKey(pitches) {
1222
+ assertPitches(pitches);
1008
1223
  const input = uniquePitchClasses(pitches);
1009
1224
  if (input.length === 0) {
1010
1225
  return [];
@@ -1056,10 +1271,21 @@ function detectKey(pitches) {
1056
1271
 
1057
1272
  // src/analyze/timeline/index.ts
1058
1273
  function chordTimelineFromChords(chords, totalBeats) {
1059
- const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
1274
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "timeline totalBeats");
1275
+ assertGenerationBudget(chords.length, "timeline chords");
1276
+ for (let index = 0; index < chords.length; index += 1) {
1277
+ assertRange(
1278
+ chords[index]?.startBeat ?? Number.NaN,
1279
+ 0,
1280
+ Number.MAX_SAFE_INTEGER,
1281
+ `chords[${index}].startBeat`
1282
+ );
1283
+ }
1284
+ const sorted = [...chords].filter((chord) => chord.startBeat < totalBeats).sort((a, b) => a.startBeat - b.startBeat);
1060
1285
  const segments = sorted.map((gc, i) => {
1061
1286
  const next = sorted[i + 1];
1062
- const endBeat = Math.max(gc.startBeat, next ? next.startBeat : totalBeats);
1287
+ const nextBeat = next ? next.startBeat : totalBeats;
1288
+ const endBeat = Math.min(totalBeats, Math.max(gc.startBeat, nextBeat));
1063
1289
  return {
1064
1290
  startBeat: gc.startBeat,
1065
1291
  endBeat,
@@ -1070,6 +1296,7 @@ function chordTimelineFromChords(chords, totalBeats) {
1070
1296
  }
1071
1297
  function segmentLookup(segments) {
1072
1298
  return (beat) => {
1299
+ assertFiniteNumber(beat, "timeline query beat");
1073
1300
  for (const seg of segments) {
1074
1301
  if (beat >= seg.startBeat && beat < seg.endBeat) {
1075
1302
  return seg.chord;
@@ -1078,7 +1305,7 @@ function segmentLookup(segments) {
1078
1305
  return null;
1079
1306
  };
1080
1307
  }
1081
- var EPS2 = 1e-9;
1308
+ var EPS3 = 1e-9;
1082
1309
  var NOISE_THRESHOLD_RATIO = 0.2;
1083
1310
  var MAX_DETECTION_PCS = 6;
1084
1311
  var FALLBACK_PCS = 3;
@@ -1120,11 +1347,11 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1120
1347
  for (const note of notes) {
1121
1348
  const noteEnd = note.startBeat + note.durationBeat;
1122
1349
  const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
1123
- if (overlap <= EPS2) {
1350
+ if (overlap <= EPS3) {
1124
1351
  continue;
1125
1352
  }
1126
1353
  const velocityFactor = note.velocity !== void 0 ? note.velocity / 127 : 1;
1127
- const onsetInWindow = note.startBeat >= windowStart - EPS2 && note.startBeat < windowEnd - EPS2;
1354
+ const onsetInWindow = note.startBeat >= windowStart - EPS3 && note.startBeat < windowEnd - EPS3;
1128
1355
  const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, ts) / 3 : 1;
1129
1356
  const pc = pitchClass6(note.pitch);
1130
1357
  weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
@@ -1138,7 +1365,7 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1138
1365
  totalWeight += w;
1139
1366
  maxWeight = Math.max(maxWeight, w);
1140
1367
  }
1141
- if (totalWeight <= EPS2) {
1368
+ if (totalWeight <= EPS3) {
1142
1369
  return null;
1143
1370
  }
1144
1371
  const ranked = weights.map((weight, pc) => ({ pc, weight })).filter(({ weight }) => weight > 0).sort((a, b) => b.weight - a.weight || a.pc - b.pc);
@@ -1171,26 +1398,40 @@ function sameChord(a, b) {
1171
1398
  }
1172
1399
  function chordTimelineFromNotes(notes, opts = {}) {
1173
1400
  const ts = opts.ts ?? parseTimeSignature("4/4");
1401
+ assertTimeSignature(ts);
1174
1402
  const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBar(ts);
1175
- if (!(harmonicRhythm > 0)) {
1176
- throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
1177
- }
1178
- const sounding = notes.filter((note) => note.durationBeat > 0);
1403
+ assertRange(harmonicRhythm, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "harmonic rhythm");
1404
+ assertNoteEvents(notes, "timeline notes", { allowNonPositiveDuration: true });
1405
+ const soundingIndex = createNoteEventIndex(notes.filter((note) => note.durationBeat > 0));
1406
+ const sounding = soundingIndex.notes.map(({ note }) => note);
1179
1407
  const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1180
1408
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
1409
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "timeline totalBeats");
1181
1410
  const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
1182
1411
  const segments = [];
1183
1412
  const segmentConfidence = [];
1184
- const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS2));
1413
+ const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS3));
1414
+ assertGenerationBudget(windowCount, "timeline windows");
1415
+ const windowNotes = Array.from({ length: windowCount }, () => []);
1416
+ let memberships = 0;
1417
+ for (const indexed of soundingIndex.notes) {
1418
+ const first = Math.max(0, Math.floor(indexed.note.startBeat / harmonicRhythm));
1419
+ const lastExclusive = Math.min(windowCount, Math.ceil(indexed.endBeat / harmonicRhythm));
1420
+ memberships += Math.max(0, lastExclusive - first);
1421
+ assertGenerationBudget(memberships, "timeline note-to-window memberships");
1422
+ for (let window = first; window < lastExclusive; window += 1) {
1423
+ windowNotes[window]?.push(indexed.note);
1424
+ }
1425
+ }
1185
1426
  for (let i = 0; i < windowCount; i += 1) {
1186
1427
  const start = i * harmonicRhythm;
1187
1428
  const end = Math.min(start + harmonicRhythm, totalBeats);
1188
- const inferred = analyzeWindow(sounding, start, end, ts, key);
1429
+ const inferred = analyzeWindow(windowNotes[i] ?? [], start, end, ts, key);
1189
1430
  if (!inferred) {
1190
1431
  continue;
1191
1432
  }
1192
1433
  const last = segments[segments.length - 1];
1193
- if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS2) {
1434
+ if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS3) {
1194
1435
  const lastLength = last.endBeat - last.startBeat;
1195
1436
  const length = end - start;
1196
1437
  const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
@@ -1215,7 +1456,7 @@ function detectCadences(timeline, key) {
1215
1456
  if (!prev || !cur) {
1216
1457
  continue;
1217
1458
  }
1218
- if (Math.abs(cur.startBeat - prev.endBeat) > EPS2) {
1459
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS3) {
1219
1460
  continue;
1220
1461
  }
1221
1462
  const type = detectCadence(prev.chord, cur.chord, key);
@@ -1227,9 +1468,9 @@ function detectCadences(timeline, key) {
1227
1468
  }
1228
1469
 
1229
1470
  // src/analyze/arrange/internal.ts
1230
- var EPS3 = 1e-9;
1471
+ var EPS4 = 1e-9;
1231
1472
  function covers(note, beat) {
1232
- return beat >= note.startBeat - EPS3 && beat < note.endBeat - EPS3;
1473
+ return beat >= note.startBeat - EPS4 && beat < note.endBeat - EPS4;
1233
1474
  }
1234
1475
  function poolNotes(tracks) {
1235
1476
  const notes = [];
@@ -1247,7 +1488,7 @@ function splitIntoSubVoices(ordered) {
1247
1488
  for (const note of ordered) {
1248
1489
  const lane = lanes.find((candidate) => {
1249
1490
  const last = candidate[candidate.length - 1];
1250
- return last !== void 0 && last.startBeat + last.durationBeat <= note.startBeat + EPS3;
1491
+ return last !== void 0 && last.startBeat + last.durationBeat <= note.startBeat + EPS4;
1251
1492
  });
1252
1493
  if (lane) {
1253
1494
  lane.push(note);
@@ -1265,9 +1506,11 @@ function prepareTracks(tracks) {
1265
1506
  voice,
1266
1507
  sounding: voice.map((note, i) => {
1267
1508
  const prev = i > 0 ? voice[i - 1] : void 0;
1509
+ const prevEnd = prev === void 0 ? void 0 : prev.startBeat + prev.durationBeat;
1510
+ const contiguous = prevEnd !== void 0 && Math.abs(prevEnd - note.startBeat) <= EPS4;
1268
1511
  return {
1269
1512
  pitch: note.pitch,
1270
- prevPitch: prev?.pitch,
1513
+ prevPitch: contiguous ? prev?.pitch : void 0,
1271
1514
  startBeat: note.startBeat,
1272
1515
  endBeat: note.startBeat + note.durationBeat
1273
1516
  };
@@ -1299,11 +1542,16 @@ function clamp01(value) {
1299
1542
  }
1300
1543
  function tensionCurve(tracks, opts = {}) {
1301
1544
  const ts = opts.ts ?? parseTimeSignature("4/4");
1545
+ assertTimeSignature(ts);
1546
+ assertGenerationBudget(tracks.length, "arrangement tracks");
1547
+ for (let index = 0; index < tracks.length; index += 1) {
1548
+ assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
1549
+ allowNonPositiveDuration: true
1550
+ });
1551
+ }
1302
1552
  const profile = opts.profile ?? "pop";
1303
1553
  const step = opts.step ?? 1;
1304
- if (!(step > 0)) {
1305
- throw new Error(`Invalid tension sampling step: ${step}`);
1306
- }
1554
+ assertRange(step, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "tension sampling step");
1307
1555
  const pooled = poolNotes(tracks);
1308
1556
  const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1309
1557
  const { timeline, key } = chordTimelineFromNotes(pooled, {
@@ -1313,7 +1561,8 @@ function tensionCurve(tracks, opts = {}) {
1313
1561
  });
1314
1562
  const prepared = prepareTracks(tracks);
1315
1563
  const points = [];
1316
- const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS3));
1564
+ const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS4));
1565
+ assertGenerationBudget(sampleCount, "tension samples");
1317
1566
  for (let i = 0; i < sampleCount; i += 1) {
1318
1567
  const beat = i * step;
1319
1568
  points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
@@ -1383,7 +1632,7 @@ function sampleTension(prepared, timeline, key, ts, profile, beat) {
1383
1632
  }
1384
1633
 
1385
1634
  // src/analyze/voice/index.ts
1386
- var EPS4 = 1e-9;
1635
+ var EPS5 = 1e-9;
1387
1636
  function pitchClass8(pitch) {
1388
1637
  return (Math.trunc(pitch) % 12 + 12) % 12;
1389
1638
  }
@@ -1439,6 +1688,7 @@ function stepResolution2(pitch, chord) {
1439
1688
  return void 0;
1440
1689
  }
1441
1690
  function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
1691
+ assertNoteEvents(voice, "voice notes", { allowNonPositiveDuration: true });
1442
1692
  const result = [];
1443
1693
  for (let i = 0; i < voice.length; i += 1) {
1444
1694
  const note = voice[i];
@@ -1447,8 +1697,10 @@ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
1447
1697
  }
1448
1698
  const prevNote = i > 0 ? voice[i - 1] : void 0;
1449
1699
  const nextNote = i + 1 < voice.length ? voice[i + 1] : void 0;
1450
- const prev = prevNote !== void 0 && prevNote.startBeat < note.startBeat - EPS4 ? prevNote : void 0;
1451
- const next = nextNote !== void 0 && nextNote.startBeat > note.startBeat + EPS4 ? nextNote : void 0;
1700
+ const prevEnd = prevNote === void 0 ? Number.NEGATIVE_INFINITY : prevNote.startBeat + prevNote.durationBeat;
1701
+ const noteEnd = note.startBeat + note.durationBeat;
1702
+ const prev = prevNote !== void 0 && Math.abs(prevEnd - note.startBeat) <= EPS5 ? prevNote : void 0;
1703
+ const next = nextNote !== void 0 && Math.abs(noteEnd - nextNote.startBeat) <= EPS5 ? nextNote : void 0;
1452
1704
  const chord = chordAtBeat(note.startBeat);
1453
1705
  const labels = [];
1454
1706
  const member = isChordMember2(note.pitch, chord);
@@ -1588,8 +1840,9 @@ function otherVoicesSounding(prepared, excludeTrack, excludeVoice, beat) {
1588
1840
  continue;
1589
1841
  }
1590
1842
  const snap = { pitch: note.pitch };
1591
- if (note.prevPitch !== void 0) {
1592
- snap.prevPitch = note.prevPitch;
1843
+ const previous = Math.abs(note.startBeat - beat) <= EPS4 ? note.prevPitch : note.pitch;
1844
+ if (previous !== void 0) {
1845
+ snap.prevPitch = previous;
1593
1846
  }
1594
1847
  out.push(snap);
1595
1848
  }
@@ -1601,7 +1854,7 @@ function evaluationBeats(note, timeline) {
1601
1854
  const noteEnd = note.startBeat + note.durationBeat;
1602
1855
  const beats = [note.startBeat];
1603
1856
  for (const segment of timeline.segments) {
1604
- if (segment.startBeat > note.startBeat + EPS3 && segment.startBeat < noteEnd - EPS3) {
1857
+ if (segment.startBeat > note.startBeat + EPS4 && segment.startBeat < noteEnd - EPS4) {
1605
1858
  beats.push(segment.startBeat);
1606
1859
  }
1607
1860
  }
@@ -1609,6 +1862,13 @@ function evaluationBeats(note, timeline) {
1609
1862
  }
1610
1863
  function analyzeArrangement(tracks, opts = {}) {
1611
1864
  const ts = opts.ts ?? parseTimeSignature("4/4");
1865
+ assertTimeSignature(ts);
1866
+ assertGenerationBudget(tracks.length, "arrangement tracks");
1867
+ for (let index = 0; index < tracks.length; index += 1) {
1868
+ assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
1869
+ allowNonPositiveDuration: true
1870
+ });
1871
+ }
1612
1872
  const profile = opts.profile ?? "pop";
1613
1873
  const pooled = poolNotes(tracks);
1614
1874
  const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
@@ -1639,11 +1899,18 @@ function analyzeArrangement(tracks, opts = {}) {
1639
1899
  (beat) => otherVoicesSounding(prepared, t, v, beat)
1640
1900
  )
1641
1901
  );
1642
- for (const note of subVoice.voice) {
1902
+ for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
1903
+ const note = subVoice.voice[noteIndex];
1904
+ const preparedNote = subVoice.sounding[noteIndex];
1905
+ if (note === void 0 || preparedNote === void 0) {
1906
+ continue;
1907
+ }
1643
1908
  for (const beat of evaluationBeats(note, timeline)) {
1909
+ const atOnset = Math.abs(beat - note.startBeat) <= EPS4;
1644
1910
  const result = evaluateSafety({
1645
1911
  profile,
1646
1912
  candidatePitch: note.pitch,
1913
+ prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
1647
1914
  chord: timeline.at(beat),
1648
1915
  key,
1649
1916
  otherVoices: otherVoicesSounding(prepared, t, v, beat),
@@ -1660,6 +1927,12 @@ function analyzeArrangement(tracks, opts = {}) {
1660
1927
  if (result.rationale !== void 0) {
1661
1928
  conflict.rationale = result.rationale;
1662
1929
  }
1930
+ if (result.resolveTo !== void 0) {
1931
+ conflict.resolveTo = result.resolveTo;
1932
+ }
1933
+ if (result.suggestions !== void 0) {
1934
+ conflict.suggestions = [...result.suggestions];
1935
+ }
1663
1936
  conflicts.push(conflict);
1664
1937
  }
1665
1938
  }