@libraz/libcantus 0.9.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +61 -261
  2. package/README_ja.md +115 -0
  3. package/dist/analyze/index.cjs +54 -7
  4. package/dist/analyze/index.cjs.map +1 -1
  5. package/dist/analyze/index.d.cts +213 -456
  6. package/dist/analyze/index.d.ts +213 -456
  7. package/dist/analyze/index.js +56 -9
  8. package/dist/chunk-3JXCW6DX.cjs +3220 -0
  9. package/dist/chunk-3JXCW6DX.cjs.map +1 -0
  10. package/dist/chunk-4A2RMUCC.js +7647 -0
  11. package/dist/chunk-4A2RMUCC.js.map +1 -0
  12. package/dist/chunk-6XPTBE7Y.cjs +49 -0
  13. package/dist/chunk-6XPTBE7Y.cjs.map +1 -0
  14. package/dist/chunk-AFM2ULJH.cjs +5121 -0
  15. package/dist/chunk-AFM2ULJH.cjs.map +1 -0
  16. package/dist/chunk-E3F4AHAH.cjs +690 -0
  17. package/dist/chunk-E3F4AHAH.cjs.map +1 -0
  18. package/dist/chunk-ELCTZUJJ.js +379 -0
  19. package/dist/chunk-ELCTZUJJ.js.map +1 -0
  20. package/dist/chunk-FVSI36UV.js +690 -0
  21. package/dist/chunk-FVSI36UV.js.map +1 -0
  22. package/dist/chunk-GEDTJHAC.cjs +1 -0
  23. package/dist/chunk-GEDTJHAC.cjs.map +1 -0
  24. package/dist/chunk-GOZG57ER.js +3220 -0
  25. package/dist/chunk-GOZG57ER.js.map +1 -0
  26. package/dist/chunk-H3VORETI.js +2243 -0
  27. package/dist/chunk-H3VORETI.js.map +1 -0
  28. package/dist/chunk-H42UHPYW.js +1577 -0
  29. package/dist/chunk-H42UHPYW.js.map +1 -0
  30. package/dist/chunk-HFPS7JFD.js +1 -0
  31. package/dist/chunk-INHVXP6Z.js +3053 -0
  32. package/dist/chunk-INHVXP6Z.js.map +1 -0
  33. package/dist/chunk-K4ZJUGF4.cjs +379 -0
  34. package/dist/chunk-K4ZJUGF4.cjs.map +1 -0
  35. package/dist/chunk-KNJEKLKV.js +49 -0
  36. package/dist/chunk-KNJEKLKV.js.map +1 -0
  37. package/dist/chunk-M4CWTJWJ.cjs +7647 -0
  38. package/dist/chunk-M4CWTJWJ.cjs.map +1 -0
  39. package/dist/chunk-MXO72PLY.cjs +211 -0
  40. package/dist/chunk-MXO72PLY.cjs.map +1 -0
  41. package/dist/chunk-NLEU4QSE.cjs +360 -0
  42. package/dist/chunk-NLEU4QSE.cjs.map +1 -0
  43. package/dist/chunk-OE6WK2Y2.cjs +1577 -0
  44. package/dist/chunk-OE6WK2Y2.cjs.map +1 -0
  45. package/dist/chunk-Q2WGKJNN.cjs +1064 -0
  46. package/dist/chunk-Q2WGKJNN.cjs.map +1 -0
  47. package/dist/chunk-QYQQ5J6E.js +1 -0
  48. package/dist/chunk-QYQQ5J6E.js.map +1 -0
  49. package/dist/chunk-RHIJ3S4I.cjs +1 -0
  50. package/dist/chunk-RHIJ3S4I.cjs.map +1 -0
  51. package/dist/chunk-TTCLJX6D.js +211 -0
  52. package/dist/chunk-TTCLJX6D.js.map +1 -0
  53. package/dist/chunk-UADYAYAT.cjs +3053 -0
  54. package/dist/chunk-UADYAYAT.cjs.map +1 -0
  55. package/dist/chunk-VOHZB45S.js +5121 -0
  56. package/dist/chunk-VOHZB45S.js.map +1 -0
  57. package/dist/chunk-VTI6FOLB.js +1064 -0
  58. package/dist/chunk-VTI6FOLB.js.map +1 -0
  59. package/dist/chunk-WWURE4XT.cjs +2243 -0
  60. package/dist/chunk-WWURE4XT.cjs.map +1 -0
  61. package/dist/chunk-YH3BIKHH.js +360 -0
  62. package/dist/chunk-YH3BIKHH.js.map +1 -0
  63. package/dist/core/index.cjs +103 -6
  64. package/dist/core/index.cjs.map +1 -1
  65. package/dist/core/index.d.cts +96 -345
  66. package/dist/core/index.d.ts +96 -345
  67. package/dist/core/index.js +104 -7
  68. package/dist/generate/index.cjs +89 -10
  69. package/dist/generate/index.cjs.map +1 -1
  70. package/dist/generate/index.d.cts +437 -1095
  71. package/dist/generate/index.d.ts +437 -1095
  72. package/dist/generate/index.js +94 -15
  73. package/dist/index-9wcMhG73.d.ts +1095 -0
  74. package/dist/{index-D6b6na69.d.cts → index-B4o55NDr.d.cts} +13 -9
  75. package/dist/index-B9MNZboQ.d.cts +770 -0
  76. package/dist/index-B9MNZboQ.d.ts +770 -0
  77. package/dist/index-BGFW-8P2.d.cts +2307 -0
  78. package/dist/index-BO6xE9YU.d.cts +1804 -0
  79. package/dist/index-BkGmlyI3.d.cts +470 -0
  80. package/dist/index-CCNJRG-P.d.cts +1095 -0
  81. package/dist/index-CGpRv5sl.d.cts +643 -0
  82. package/dist/index-C_8o--VE.d.cts +1242 -0
  83. package/dist/index-CgHqxdps.d.ts +2307 -0
  84. package/dist/index-Chbst3Jx.d.ts +1343 -0
  85. package/dist/index-Cj49F892.d.ts +470 -0
  86. package/dist/{index-tSQh-xwY.d.ts → index-CunYKLiu.d.ts} +13 -9
  87. package/dist/index-D57QRbVn.d.ts +1804 -0
  88. package/dist/index-DInQqT8Q.d.cts +66 -0
  89. package/dist/index-DInQqT8Q.d.ts +66 -0
  90. package/dist/index-Dzr13q3d.d.cts +1343 -0
  91. package/dist/index-G1uwGGWQ.d.ts +643 -0
  92. package/dist/index-szBv3496.d.ts +1242 -0
  93. package/dist/index.cjs +358 -15
  94. package/dist/index.cjs.map +1 -1
  95. package/dist/index.d.cts +17 -15
  96. package/dist/index.d.ts +17 -15
  97. package/dist/index.js +407 -64
  98. package/dist/masks-DYbE485J.d.cts +445 -0
  99. package/dist/masks-DYbE485J.d.ts +445 -0
  100. package/dist/model/index.cjs +39 -7
  101. package/dist/model/index.cjs.map +1 -1
  102. package/dist/model/index.d.cts +4685 -243
  103. package/dist/model/index.d.ts +4685 -243
  104. package/dist/model/index.js +40 -8
  105. package/dist/positional-Cptlj-xs.d.ts +287 -0
  106. package/dist/positional-DvH_sd8u.d.cts +287 -0
  107. package/dist/theory/index.cjs +101 -7
  108. package/dist/theory/index.cjs.map +1 -1
  109. package/dist/theory/index.d.cts +513 -173
  110. package/dist/theory/index.d.ts +513 -173
  111. package/dist/theory/index.js +118 -24
  112. package/package.json +16 -11
  113. package/dist/chunk-36N73KZ3.cjs +0 -246
  114. package/dist/chunk-36N73KZ3.cjs.map +0 -1
  115. package/dist/chunk-4HLLZPXT.cjs +0 -3077
  116. package/dist/chunk-4HLLZPXT.cjs.map +0 -1
  117. package/dist/chunk-7MGZ7D62.cjs +0 -498
  118. package/dist/chunk-7MGZ7D62.cjs.map +0 -1
  119. package/dist/chunk-7NKDPF74.js +0 -3077
  120. package/dist/chunk-7NKDPF74.js.map +0 -1
  121. package/dist/chunk-CAN76NCU.cjs +0 -679
  122. package/dist/chunk-CAN76NCU.cjs.map +0 -1
  123. package/dist/chunk-D2FAJBYL.js +0 -479
  124. package/dist/chunk-D2FAJBYL.js.map +0 -1
  125. package/dist/chunk-EHVEBWM5.js +0 -1449
  126. package/dist/chunk-EHVEBWM5.js.map +0 -1
  127. package/dist/chunk-FCANXBAT.cjs +0 -30
  128. package/dist/chunk-FCANXBAT.cjs.map +0 -1
  129. package/dist/chunk-FNDWXRRI.cjs +0 -479
  130. package/dist/chunk-FNDWXRRI.cjs.map +0 -1
  131. package/dist/chunk-GQSGS2N4.js +0 -173
  132. package/dist/chunk-GQSGS2N4.js.map +0 -1
  133. package/dist/chunk-HKGYCQ34.js +0 -53
  134. package/dist/chunk-HKGYCQ34.js.map +0 -1
  135. package/dist/chunk-I24MV65U.js +0 -106
  136. package/dist/chunk-I24MV65U.js.map +0 -1
  137. package/dist/chunk-KMHOICIY.js +0 -679
  138. package/dist/chunk-KMHOICIY.js.map +0 -1
  139. package/dist/chunk-LW53AHQU.cjs +0 -1117
  140. package/dist/chunk-LW53AHQU.cjs.map +0 -1
  141. package/dist/chunk-N6IHQUOX.js +0 -1
  142. package/dist/chunk-NKIBQ6PT.js +0 -30
  143. package/dist/chunk-NKIBQ6PT.js.map +0 -1
  144. package/dist/chunk-NP4NA2FB.cjs +0 -106
  145. package/dist/chunk-NP4NA2FB.cjs.map +0 -1
  146. package/dist/chunk-NVE34YGZ.cjs +0 -1449
  147. package/dist/chunk-NVE34YGZ.cjs.map +0 -1
  148. package/dist/chunk-NYYWMV2C.js +0 -327
  149. package/dist/chunk-NYYWMV2C.js.map +0 -1
  150. package/dist/chunk-Q2XUP227.js +0 -845
  151. package/dist/chunk-Q2XUP227.js.map +0 -1
  152. package/dist/chunk-Q6OP4U43.cjs +0 -327
  153. package/dist/chunk-Q6OP4U43.cjs.map +0 -1
  154. package/dist/chunk-RC6GBMCD.cjs +0 -845
  155. package/dist/chunk-RC6GBMCD.cjs.map +0 -1
  156. package/dist/chunk-RK35RIN7.js +0 -1117
  157. package/dist/chunk-RK35RIN7.js.map +0 -1
  158. package/dist/chunk-RUMU3UA6.cjs +0 -53
  159. package/dist/chunk-RUMU3UA6.cjs.map +0 -1
  160. package/dist/chunk-T64C7ZD7.cjs +0 -1
  161. package/dist/chunk-T64C7ZD7.cjs.map +0 -1
  162. package/dist/chunk-XEBG3G53.js +0 -498
  163. package/dist/chunk-XEBG3G53.js.map +0 -1
  164. package/dist/chunk-YLEMGEFH.js +0 -246
  165. package/dist/chunk-YLEMGEFH.js.map +0 -1
  166. package/dist/chunk-ZEJR4TKL.cjs +0 -173
  167. package/dist/chunk-ZEJR4TKL.cjs.map +0 -1
  168. package/dist/function-BtiZb9i9.d.ts +0 -208
  169. package/dist/function-DNugAOrt.d.cts +0 -208
  170. package/dist/index-6VR8-kit.d.ts +0 -143
  171. package/dist/index-70p168VM.d.ts +0 -192
  172. package/dist/index-Be74mcon.d.cts +0 -152
  173. package/dist/index-BtXT6UQ0.d.cts +0 -192
  174. package/dist/index-C5UBbQ4G.d.ts +0 -152
  175. package/dist/index-C83mZI1P.d.ts +0 -242
  176. package/dist/index-DXd9JikN.d.cts +0 -246
  177. package/dist/index-DXd9JikN.d.ts +0 -246
  178. package/dist/index-OG65ocOr.d.cts +0 -272
  179. package/dist/index-OG65ocOr.d.ts +0 -272
  180. package/dist/index-tJhkmQgE.d.cts +0 -143
  181. package/dist/index-y_ak9_Hi.d.cts +0 -242
  182. package/dist/styled-DIRkk9Uz.d.ts +0 -506
  183. package/dist/styled-DJbkJQfj.d.cts +0 -506
  184. package/dist/types-BN6_Q9Kl.d.cts +0 -39
  185. package/dist/types-BN6_Q9Kl.d.ts +0 -39
  186. /package/dist/{chunk-N6IHQUOX.js.map → chunk-HFPS7JFD.js.map} +0 -0
@@ -0,0 +1,3220 @@
1
+ import {
2
+ augmentedSixthFromPitchClasses,
3
+ detectCadence,
4
+ detectChord,
5
+ functionOf,
6
+ isCadentialSixFour,
7
+ pivotChords,
8
+ profileScore,
9
+ resolveKeyProfile
10
+ } from "./chunk-H42UHPYW.js";
11
+ import {
12
+ barIndexAt,
13
+ barPositionToBeat,
14
+ beatsPerBarAt,
15
+ createNoteEventIndex,
16
+ isStrongBeat,
17
+ metricWeight,
18
+ resolveMeters
19
+ } from "./chunk-ELCTZUJJ.js";
20
+ import {
21
+ PROFILE_WEIGHTS,
22
+ chordFromSpan,
23
+ chordPitchClasses,
24
+ chordToneRole,
25
+ createsVerticalDissonance,
26
+ evaluateSafety,
27
+ intervalAboveRoot,
28
+ isChordMember,
29
+ isLeadingToneResolution,
30
+ isScaleTone,
31
+ keyRelationBetween,
32
+ keySignatureFifths,
33
+ majorKey,
34
+ makeChord,
35
+ minorKey,
36
+ scaleTonesInDegreeOrder,
37
+ spelledKeyOf
38
+ } from "./chunk-INHVXP6Z.js";
39
+ import {
40
+ InvalidInputError,
41
+ assertFiniteNumber,
42
+ assertGenerationBudget,
43
+ assertInteger,
44
+ assertNoteEvents,
45
+ assertOneOf,
46
+ assertPositiveInt,
47
+ assertRange,
48
+ midiToNote,
49
+ pitchClassOf,
50
+ spelledInterval
51
+ } from "./chunk-VTI6FOLB.js";
52
+
53
+ // src/analyze/grid.ts
54
+ var EPS = 1e-9;
55
+ var PICKUP_JITTER = 0.05;
56
+ function gridOriginOf(firstOnset, slotBeats) {
57
+ if (firstOnset >= -PICKUP_JITTER) {
58
+ return { origin: 0, startBeat: 0 };
59
+ }
60
+ return { origin: Math.floor(firstOnset / slotBeats + EPS) * slotBeats, startBeat: firstOnset };
61
+ }
62
+
63
+ // src/analyze/histogram.ts
64
+ var EPS2 = 1e-9;
65
+ function bucketNotesBySlot(notes, grid, slotCount, opts) {
66
+ const buckets = Array.from({ length: slotCount }, () => []);
67
+ let memberships = 0;
68
+ for (const note of notes) {
69
+ const first = Math.max(0, Math.floor((note.startBeat - grid.origin) / grid.slotBeats));
70
+ const lastExclusive = Math.min(
71
+ slotCount,
72
+ Math.ceil((note.startBeat + note.durationBeat - grid.origin) / grid.slotBeats)
73
+ );
74
+ memberships += Math.max(0, lastExclusive - first);
75
+ assertGenerationBudget(memberships, opts.name, opts.budget);
76
+ for (let slot = first; slot < lastExclusive; slot += 1) {
77
+ buckets[slot]?.push(note);
78
+ }
79
+ }
80
+ return buckets;
81
+ }
82
+ function windowWeights(notes, windowStart, windowEnd, meter) {
83
+ const weights = new Array(12).fill(0);
84
+ let lowestPitch = Number.POSITIVE_INFINITY;
85
+ for (const note of notes) {
86
+ const noteEnd = note.startBeat + note.durationBeat;
87
+ const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
88
+ if (overlap <= EPS2) {
89
+ continue;
90
+ }
91
+ const velocityFactor = note.velocity !== void 0 ? note.velocity / 127 : 1;
92
+ const onsetInWindow = note.startBeat >= windowStart - EPS2 && note.startBeat < windowEnd - EPS2;
93
+ const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, meter) / 3 : 1;
94
+ const pc = pitchClassOf(note.pitch);
95
+ weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
96
+ if (note.pitch < lowestPitch) {
97
+ lowestPitch = note.pitch;
98
+ }
99
+ }
100
+ let totalWeight = 0;
101
+ let maxWeight = 0;
102
+ for (const w of weights) {
103
+ totalWeight += w;
104
+ maxWeight = Math.max(maxWeight, w);
105
+ }
106
+ return { weights, totalWeight, maxWeight, lowestPitch };
107
+ }
108
+
109
+ // src/analyze/keys/index.ts
110
+ var EPS3 = 1e-9;
111
+ var PITCH_CLASSES = 12;
112
+ var EMPTY_WEIGHTS = {
113
+ weights: new Array(PITCH_CLASSES).fill(0),
114
+ totalWeight: 0,
115
+ maxWeight: 0,
116
+ lowestPitch: Number.POSITIVE_INFINITY
117
+ };
118
+ var MAX_METRIC_WEIGHT = 3;
119
+ var MODULATION_COST = 0.15;
120
+ var PAST_CONTEXT_SHARE = 0.5;
121
+ var FUTURE_CONTEXT_SHARE = 0.25;
122
+ var STRONG_BEAT_DISCOUNT = 0.4;
123
+ var FIFTHS_DISTANCE_COST = 0.18;
124
+ var KEY_CANDIDATES = (() => {
125
+ const candidates = [];
126
+ for (let rootPc = 0; rootPc < 12; rootPc += 1) {
127
+ candidates.push({ key: majorKey(rootPc), rootPc, minor: false });
128
+ candidates.push({ key: minorKey(rootPc), rootPc, minor: true });
129
+ }
130
+ return candidates;
131
+ })();
132
+ function fifthsOf(rootPc, minor) {
133
+ const majorRoot = pitchClassOf(minor ? rootPc + 3 : rootPc);
134
+ const raw = majorRoot * 7 % 12;
135
+ return raw > 6 ? raw - 12 : raw;
136
+ }
137
+ function fifthsDistance(a, b) {
138
+ const diff = Math.abs(a - b) % 12;
139
+ return Math.min(diff, 12 - diff);
140
+ }
141
+ var CANDIDATE_DISTANCE = KEY_CANDIDATES.map(
142
+ (from) => KEY_CANDIDATES.map(
143
+ (to) => fifthsDistance(fifthsOf(from.rootPc, from.minor), fifthsOf(to.rootPc, to.minor))
144
+ )
145
+ );
146
+ function chooseKeys(slots, changeCost, meter) {
147
+ const count = KEY_CANDIDATES.length;
148
+ if (slots.length === 0) {
149
+ return [];
150
+ }
151
+ let costs = (slots[0]?.scores ?? []).map((score) => -score);
152
+ const cameFrom = [];
153
+ for (let i = 1; i < slots.length; i += 1) {
154
+ const slot = slots[i];
155
+ const previous = costs;
156
+ const accent = metricWeight(slot?.startBeat ?? 0, meter);
157
+ const metricFactor = 1 - STRONG_BEAT_DISCOUNT * accent / MAX_METRIC_WEIGHT;
158
+ const next = new Array(count).fill(0);
159
+ const back = new Int32Array(count);
160
+ for (let to = 0; to < count; to += 1) {
161
+ let best2 = previous[to] ?? Number.POSITIVE_INFINITY;
162
+ let bestFrom = to;
163
+ for (let from = 0; from < count; from += 1) {
164
+ if (from === to) {
165
+ continue;
166
+ }
167
+ const distance = CANDIDATE_DISTANCE[from]?.[to] ?? 0;
168
+ const penalty = changeCost * metricFactor * (1 + FIFTHS_DISTANCE_COST * distance);
169
+ const cost = (previous[from] ?? Number.POSITIVE_INFINITY) + penalty;
170
+ if (cost < best2) {
171
+ best2 = cost;
172
+ bestFrom = from;
173
+ }
174
+ }
175
+ back[to] = bestFrom;
176
+ next[to] = best2 - (slot?.scores[to] ?? 0);
177
+ }
178
+ costs = next;
179
+ cameFrom.push(back);
180
+ }
181
+ let best = 0;
182
+ for (let c = 1; c < count; c += 1) {
183
+ if ((costs[c] ?? Number.POSITIVE_INFINITY) < (costs[best] ?? Number.POSITIVE_INFINITY)) {
184
+ best = c;
185
+ }
186
+ }
187
+ const path = new Array(slots.length);
188
+ let current = best;
189
+ for (let i = slots.length - 1; i >= 0; i -= 1) {
190
+ path[i] = current;
191
+ if (i > 0) {
192
+ current = cameFrom[i - 1]?.[current] ?? current;
193
+ }
194
+ }
195
+ return path;
196
+ }
197
+ function regionsFromPath(slots, path) {
198
+ const regions = [];
199
+ for (let i = 0; i < slots.length; i += 1) {
200
+ const last = regions[regions.length - 1];
201
+ const slot = slots[i];
202
+ if (slot === void 0) {
203
+ continue;
204
+ }
205
+ if (last !== void 0 && last.candidate === path[i]) {
206
+ last.endBeat = slot.endBeat;
207
+ last.toExclusive = i + 1;
208
+ continue;
209
+ }
210
+ regions.push({
211
+ startBeat: slot.startBeat,
212
+ endBeat: slot.endBeat,
213
+ candidate: path[i] ?? 0,
214
+ from: i,
215
+ toExclusive: i + 1
216
+ });
217
+ }
218
+ return regions;
219
+ }
220
+ function enforceMinimumRegion(slots, path, minBeats) {
221
+ const chosen = [...path];
222
+ for (; ; ) {
223
+ const regions = regionsFromPath(slots, chosen);
224
+ let shortest;
225
+ let before;
226
+ let after;
227
+ for (let i = 0; i < regions.length; i += 1) {
228
+ const region = regions[i];
229
+ if (region === void 0 || region.endBeat - region.startBeat >= minBeats - EPS3) {
230
+ continue;
231
+ }
232
+ const previous = regions[i - 1];
233
+ const next = regions[i + 1];
234
+ const left2 = previous !== void 0 && Math.abs(previous.endBeat - region.startBeat) < EPS3 ? previous : void 0;
235
+ const right2 = next !== void 0 && Math.abs(next.startBeat - region.endBeat) < EPS3 ? next : void 0;
236
+ if (left2 === void 0 && right2 === void 0) {
237
+ continue;
238
+ }
239
+ if (shortest !== void 0 && region.endBeat - region.startBeat >= shortest.endBeat - shortest.startBeat) {
240
+ continue;
241
+ }
242
+ shortest = region;
243
+ before = left2;
244
+ after = right2;
245
+ }
246
+ if (shortest === void 0) {
247
+ return chosen;
248
+ }
249
+ const merging = shortest;
250
+ const scoreOver = (candidate) => {
251
+ let total = 0;
252
+ for (let i = merging.from; i < merging.toExclusive; i += 1) {
253
+ total += slots[i]?.scores[candidate] ?? 0;
254
+ }
255
+ return total;
256
+ };
257
+ const left = before?.candidate;
258
+ const right = after?.candidate;
259
+ const winner = left === void 0 ? right ?? merging.candidate : right === void 0 || scoreOver(left) >= scoreOver(right) ? left : right;
260
+ for (let i = merging.from; i < merging.toExclusive; i += 1) {
261
+ chosen[i] = winner;
262
+ }
263
+ }
264
+ }
265
+ function withRelations(regions) {
266
+ for (let i = 1; i < regions.length; i += 1) {
267
+ const previous = regions[i - 1];
268
+ const current = regions[i];
269
+ if (previous === void 0 || current === void 0) {
270
+ continue;
271
+ }
272
+ const relation = keyRelationBetween(spelledKeyOf(previous.key), spelledKeyOf(current.key));
273
+ if (relation !== null && relation !== "same") {
274
+ current.modulation = relation;
275
+ }
276
+ }
277
+ return regions;
278
+ }
279
+ function keyTimelineFromNotes(notes, opts = {}) {
280
+ const meters = resolveMeters(opts, "key timeline meters");
281
+ const bar = beatsPerBarAt(0, meters);
282
+ const expectedKeyBeats = opts.expectedKeyBeats ?? bar * 4;
283
+ assertRange(expectedKeyBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "expectedKeyBeats");
284
+ const minKeyBeats = opts.minKeyBeats ?? bar;
285
+ assertRange(minKeyBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "minKeyBeats");
286
+ const budget = opts.budget;
287
+ assertNoteEvents(notes, "key timeline notes", { allowNonPositiveDuration: true, budget });
288
+ const sounding = notes.filter((note) => note.durationBeat > 0);
289
+ const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
290
+ const totalBeats = opts.totalBeats ?? lastEnd;
291
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "key timeline totalBeats");
292
+ const slotBeats = Math.min(minKeyBeats, expectedKeyBeats);
293
+ const firstOnset = sounding.reduce((first, n) => Math.min(first, n.startBeat), 0);
294
+ const { origin, startBeat: musicStart } = gridOriginOf(firstOnset, slotBeats);
295
+ const slotCount = Math.max(0, Math.ceil((totalBeats - origin) / slotBeats - EPS3));
296
+ assertGenerationBudget(slotCount, "key timeline slots", budget);
297
+ if (slotCount === 0 || sounding.length === 0) {
298
+ return [];
299
+ }
300
+ const profile = resolveKeyProfile(opts.profile);
301
+ const slotNotes = bucketNotesBySlot(sounding, { origin, slotBeats }, slotCount, {
302
+ name: "key timeline note-to-slot memberships",
303
+ budget
304
+ });
305
+ const slotWeights = [];
306
+ for (let i = 0; i < slotCount; i += 1) {
307
+ const startBeat = origin + i * slotBeats;
308
+ slotWeights.push(
309
+ windowWeights(
310
+ slotNotes[i] ?? [],
311
+ startBeat,
312
+ Math.min(startBeat + slotBeats, totalBeats),
313
+ meters
314
+ )
315
+ );
316
+ }
317
+ const slots = [];
318
+ let weightSum = 0;
319
+ let soundingSlots = 0;
320
+ for (let i = 0; i < slotCount; i += 1) {
321
+ const startBeat = origin + i * slotBeats;
322
+ const endBeat = Math.min(startBeat + slotBeats, totalBeats);
323
+ const own = slotWeights[i] ?? EMPTY_WEIGHTS;
324
+ const before = slotWeights[i - 1] ?? EMPTY_WEIGHTS;
325
+ const after = slotWeights[i + 1] ?? EMPTY_WEIGHTS;
326
+ const weights = own.weights.map(
327
+ (weight, pc) => weight + PAST_CONTEXT_SHARE * (before.weights[pc] ?? 0) + FUTURE_CONTEXT_SHARE * (after.weights[pc] ?? 0)
328
+ );
329
+ const totalWeight = own.totalWeight;
330
+ const scores = KEY_CANDIDATES.map(
331
+ (candidate) => profileScore(weights, candidate.minor ? profile.minor : profile.major, candidate.rootPc) * totalWeight
332
+ );
333
+ slots.push({ startBeat, endBeat, scores, weight: totalWeight });
334
+ if (totalWeight > EPS3) {
335
+ weightSum += totalWeight;
336
+ soundingSlots += 1;
337
+ }
338
+ }
339
+ if (soundingSlots === 0) {
340
+ return [];
341
+ }
342
+ const meanSlotWeight = weightSum / soundingSlots;
343
+ const changeCost = MODULATION_COST * meanSlotWeight * (expectedKeyBeats / slotBeats);
344
+ const path = chooseKeys(slots, changeCost, meters);
345
+ const grouped = regionsFromPath(slots, path);
346
+ const regions = grouped.map((region) => {
347
+ const candidate = KEY_CANDIDATES[region.candidate];
348
+ const key = candidate?.key ?? majorKey(0);
349
+ const startBeat = Math.max(region.startBeat, musicStart);
350
+ const weights = new Array(PITCH_CLASSES).fill(0);
351
+ for (let i = region.from; i < region.toExclusive; i += 1) {
352
+ const slot = slotWeights[i];
353
+ if (slot === void 0) {
354
+ continue;
355
+ }
356
+ for (let pc = 0; pc < PITCH_CLASSES; pc += 1) {
357
+ weights[pc] = (weights[pc] ?? 0) + (slot.weights[pc] ?? 0);
358
+ }
359
+ }
360
+ const correlation = profileScore(
361
+ weights,
362
+ candidate?.minor ? profile.minor : profile.major,
363
+ candidate?.rootPc ?? 0
364
+ );
365
+ return {
366
+ startBeat,
367
+ endBeat: region.endBeat,
368
+ key,
369
+ confidence: Math.max(0, correlation)
370
+ };
371
+ });
372
+ return withRelations(regions);
373
+ }
374
+ var CHORD_TONE_CREDIT = 1;
375
+ var DOMINANT_CREDIT = 0.7;
376
+ var TONIC_CREDIT = 0.4;
377
+ var CADENCE_CREDIT = 0.6;
378
+ var CHORD_MODULATION_COST = 0.6;
379
+ function chordFitsKey(chord, candidate) {
380
+ const tones = chordPitchClasses(chord);
381
+ if (tones.length === 0) {
382
+ return 0;
383
+ }
384
+ let inKey = 0;
385
+ for (const pc of tones) {
386
+ if (isScaleTone(pc, candidate.key)) {
387
+ inKey += 1;
388
+ }
389
+ }
390
+ let score = CHORD_TONE_CREDIT * ((inKey - (tones.length - inKey)) / tones.length);
391
+ const rootDegree = pitchClassOf(chord.rootPc - candidate.rootPc);
392
+ if (rootDegree === 7 && (chord.quality === "dom7" || chord.quality === "maj")) {
393
+ score += DOMINANT_CREDIT * (chord.quality === "dom7" ? 1 : 0.5);
394
+ }
395
+ if (rootDegree === 0) {
396
+ score += TONIC_CREDIT;
397
+ }
398
+ return score;
399
+ }
400
+ function detectModulations(chords, opts = {}) {
401
+ const meters = resolveMeters(opts, "modulation meters");
402
+ const bar = beatsPerBarAt(0, meters);
403
+ const expectedKeyBeats = opts.expectedKeyBeats ?? bar * 4;
404
+ assertRange(expectedKeyBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "expectedKeyBeats");
405
+ const minKeyBeats = opts.minKeyBeats ?? bar;
406
+ assertRange(minKeyBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "minKeyBeats");
407
+ assertGenerationBudget(chords.length, "modulation chord segments", opts.budget);
408
+ const sorted = [...chords].filter((segment) => segment.endBeat > segment.startBeat).sort((a, b) => a.startBeat - b.startBeat);
409
+ const lastChordEnd = sorted.reduce((end, segment) => Math.max(end, segment.endBeat), 0);
410
+ const totalBeats = opts.totalBeats ?? lastChordEnd;
411
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "modulation totalBeats");
412
+ const ordered = sorted.map((segment) => ({ ...segment, endBeat: Math.min(segment.endBeat, totalBeats) })).filter((segment) => segment.endBeat > segment.startBeat);
413
+ if (ordered.length === 0) {
414
+ return [];
415
+ }
416
+ const slots = ordered.map((segment) => {
417
+ const length = segment.endBeat - segment.startBeat;
418
+ return {
419
+ startBeat: segment.startBeat,
420
+ endBeat: segment.endBeat,
421
+ // A chord that holds for two bars argues twice as hard as one that
422
+ // passes in half a bar, exactly as a heavier slot does in the note path.
423
+ scores: KEY_CANDIDATES.map((candidate) => chordFitsKey(segment.chord, candidate) * length),
424
+ weight: length
425
+ };
426
+ });
427
+ for (let i = 1; i < slots.length; i += 1) {
428
+ const arriving = ordered[i]?.chord;
429
+ const approaching = ordered[i - 1]?.chord;
430
+ const slot = slots[i];
431
+ if (arriving === void 0 || approaching === void 0 || slot === void 0) {
432
+ continue;
433
+ }
434
+ const isDominantMotion = pitchClassOf(approaching.rootPc - arriving.rootPc) === 7 && approaching.quality === "dom7";
435
+ if (!isDominantMotion) {
436
+ continue;
437
+ }
438
+ const length = slot.endBeat - slot.startBeat;
439
+ for (let c = 0; c < KEY_CANDIDATES.length; c += 1) {
440
+ const candidate = KEY_CANDIDATES[c];
441
+ if (candidate !== void 0 && pitchClassOf(arriving.rootPc - candidate.rootPc) === 0) {
442
+ slot.scores[c] = (slot.scores[c] ?? 0) + CADENCE_CREDIT * length;
443
+ }
444
+ }
445
+ }
446
+ const changeCost = CHORD_MODULATION_COST * expectedKeyBeats;
447
+ const path = chooseKeys(slots, changeCost, meters);
448
+ const grouped = regionsFromPath(
449
+ slots,
450
+ enforceMinimumRegion(slots, path, Math.min(minKeyBeats, expectedKeyBeats))
451
+ );
452
+ const profile = resolveKeyProfile(opts.profile);
453
+ const regions = grouped.map((region) => {
454
+ const candidate = KEY_CANDIDATES[region.candidate];
455
+ const key = candidate?.key ?? majorKey(0);
456
+ const weights = new Array(PITCH_CLASSES).fill(0);
457
+ for (const segment of ordered) {
458
+ const overlap = Math.min(segment.endBeat, region.endBeat) - Math.max(segment.startBeat, region.startBeat);
459
+ if (overlap <= EPS3) {
460
+ continue;
461
+ }
462
+ for (const pc of chordPitchClasses(segment.chord)) {
463
+ weights[pc] = (weights[pc] ?? 0) + overlap;
464
+ }
465
+ }
466
+ const correlation = profileScore(
467
+ weights,
468
+ candidate?.minor ? profile.minor : profile.major,
469
+ candidate?.rootPc ?? 0
470
+ );
471
+ return {
472
+ startBeat: region.startBeat,
473
+ endBeat: region.endBeat,
474
+ key,
475
+ confidence: Math.max(0, correlation)
476
+ };
477
+ });
478
+ const last = regions[regions.length - 1];
479
+ if (last !== void 0 && totalBeats > last.endBeat) {
480
+ last.endBeat = totalBeats;
481
+ }
482
+ return attachPivots(withRelations(regions), ordered);
483
+ }
484
+ var THIRD_SEMITONES = [3, 4];
485
+ var FIFTH_SEMITONES = [6, 7, 8];
486
+ function triadCore(chord) {
487
+ const root = pitchClassOf(chord.rootPc);
488
+ let third;
489
+ let fifth;
490
+ for (const interval of chord.intervals) {
491
+ if (interval < 0 || interval >= 12) {
492
+ continue;
493
+ }
494
+ if (third === void 0 && THIRD_SEMITONES.includes(interval)) {
495
+ third = interval;
496
+ } else if (fifth === void 0 && FIFTH_SEMITONES.includes(interval)) {
497
+ fifth = interval;
498
+ }
499
+ }
500
+ if (third === void 0 || fifth === void 0) {
501
+ return null;
502
+ }
503
+ return [root, pitchClassOf(root + third), pitchClassOf(root + fifth)].sort((a, b) => a - b);
504
+ }
505
+ function sameTones(a, b) {
506
+ return a.length === b.length && a.every((pc, index) => pc === b[index]);
507
+ }
508
+ function attachPivots(regions, chords) {
509
+ for (let i = 1; i < regions.length; i += 1) {
510
+ const previous = regions[i - 1];
511
+ const current = regions[i];
512
+ if (previous === void 0 || current === void 0) {
513
+ continue;
514
+ }
515
+ let last;
516
+ for (const segment of chords) {
517
+ if (segment.endBeat <= current.startBeat + EPS3) {
518
+ last = segment;
519
+ }
520
+ }
521
+ if (last === void 0) {
522
+ continue;
523
+ }
524
+ const core = triadCore(last.chord);
525
+ if (core === null) {
526
+ continue;
527
+ }
528
+ const root = pitchClassOf(last.chord.rootPc);
529
+ const pivot = pivotChords(previous.key, current.key).find(
530
+ (entry) => entry.chord.rootPc === root && sameTones(chordPitchClasses(entry.chord), core)
531
+ );
532
+ if (pivot !== void 0) {
533
+ current.pivot = pivot;
534
+ }
535
+ }
536
+ return regions;
537
+ }
538
+ function keyLookup(regions, fallback) {
539
+ return (beat) => {
540
+ let low = 0;
541
+ let high = regions.length;
542
+ while (low < high) {
543
+ const middle = Math.floor((low + high) / 2);
544
+ if ((regions[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat) {
545
+ low = middle + 1;
546
+ } else {
547
+ high = middle;
548
+ }
549
+ }
550
+ const candidate = regions[low - 1];
551
+ return candidate !== void 0 && beat < candidate.endBeat ? candidate.key : fallback;
552
+ };
553
+ }
554
+ function prevailingKeyOf(regions) {
555
+ const totals = /* @__PURE__ */ new Map();
556
+ for (const region of regions) {
557
+ const id = `${pitchClassOf(region.key.rootPc)}:${region.key.modeMask12}`;
558
+ const entry = totals.get(id);
559
+ const length = Math.max(0, region.endBeat - region.startBeat);
560
+ if (entry === void 0) {
561
+ totals.set(id, { key: region.key, length });
562
+ } else {
563
+ entry.length += length;
564
+ }
565
+ }
566
+ let best = null;
567
+ let bestLength = -1;
568
+ for (const entry of totals.values()) {
569
+ if (entry.length > bestLength) {
570
+ bestLength = entry.length;
571
+ best = entry.key;
572
+ }
573
+ }
574
+ return best;
575
+ }
576
+
577
+ // src/analyze/timeline/index.ts
578
+ function chordTimelineFromChords(chords, totalBeats) {
579
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "timeline totalBeats");
580
+ assertGenerationBudget(chords.length, "timeline chords");
581
+ for (let index = 0; index < chords.length; index += 1) {
582
+ assertRange(
583
+ chords[index]?.startBeat ?? Number.NaN,
584
+ -Number.MAX_SAFE_INTEGER,
585
+ Number.MAX_SAFE_INTEGER,
586
+ `chords[${index}].startBeat`
587
+ );
588
+ }
589
+ const sorted = [...chords].filter((chord) => chord.startBeat < totalBeats).sort((a, b) => a.startBeat - b.startBeat);
590
+ const segments = sorted.map((gc, i) => {
591
+ const next = sorted[i + 1];
592
+ const nextBeat = next ? next.startBeat : totalBeats;
593
+ const endBeat = Math.min(totalBeats, Math.max(gc.startBeat, nextBeat));
594
+ return {
595
+ startBeat: gc.startBeat,
596
+ endBeat,
597
+ chord: chordFromSpan(gc)
598
+ };
599
+ }).filter((seg) => seg.endBeat > seg.startBeat);
600
+ return { at: segmentLookup(segments), segments };
601
+ }
602
+ function segmentLookup(segments) {
603
+ return (beat) => {
604
+ assertFiniteNumber(beat, "timeline query beat");
605
+ let low = 0;
606
+ let high = segments.length;
607
+ while (low < high) {
608
+ const middle = Math.floor((low + high) / 2);
609
+ if ((segments[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat) {
610
+ low = middle + 1;
611
+ } else {
612
+ high = middle;
613
+ }
614
+ }
615
+ const candidate = segments[low - 1];
616
+ return candidate !== void 0 && beat >= candidate.startBeat && beat < candidate.endBeat ? candidate.chord : null;
617
+ };
618
+ }
619
+ var EPS4 = 1e-9;
620
+ var NOISE_THRESHOLD_RATIO = 0.2;
621
+ var MAX_DETECTION_PCS = 6;
622
+ var INEXACT_CONFIDENCE_FACTOR = 0.85;
623
+ var DIATONIC_BONUS = 0.5;
624
+ var EXACT_BONUS = 0.5;
625
+ var MISMATCH_PENALTY = 0.3;
626
+ function scoreMatch(match, weights, maxWeight, key) {
627
+ const rootWeight = maxWeight > 0 ? (weights[match.rootPc] ?? 0) / maxWeight : 0;
628
+ const tones = chordPitchClasses(makeChord(match.rootPc, match.quality));
629
+ let score = rootWeight;
630
+ if (tones.every((pc) => isScaleTone(pc, key))) {
631
+ score += DIATONIC_BONUS;
632
+ }
633
+ if (match.exact) {
634
+ score += EXACT_BONUS;
635
+ }
636
+ score -= MISMATCH_PENALTY * (match.extraPcs.length + match.missingPcs.length);
637
+ return score;
638
+ }
639
+ function chordConfidence(chord, weights, totalWeight, exact) {
640
+ const tones = new Set(chordPitchClasses(chord));
641
+ let chordWeight = 0;
642
+ for (let pc = 0; pc < 12; pc += 1) {
643
+ if (tones.has(pc)) {
644
+ chordWeight += weights[pc] ?? 0;
645
+ }
646
+ }
647
+ const raw = totalWeight > 0 ? chordWeight / totalWeight : 0;
648
+ const confidence = Math.min(1, Math.max(0, raw));
649
+ return exact ? confidence : confidence * INEXACT_CONFIDENCE_FACTOR;
650
+ }
651
+ function analyzeWindow(notes, windowStart, windowEnd, meters, key) {
652
+ const { weights, totalWeight, maxWeight, lowestPitch } = windowWeights(
653
+ notes,
654
+ windowStart,
655
+ windowEnd,
656
+ meters
657
+ );
658
+ if (totalWeight <= EPS4) {
659
+ return null;
660
+ }
661
+ const ranked = weights.map((weight, pc) => ({ pc, weight })).filter(({ weight }) => weight > 0).sort((a, b) => b.weight - a.weight || a.pc - b.pc);
662
+ const selected = ranked.filter(({ weight }) => weight >= maxWeight * NOISE_THRESHOLD_RATIO).slice(0, MAX_DETECTION_PCS).map(({ pc }) => pc);
663
+ const bassPc = pitchClassOf(lowestPitch);
664
+ const hasBass = selected.includes(bassPc);
665
+ const detectionPitches = hasBass ? selected.map((pc) => pc === bassPc ? pc : pc + 12) : selected;
666
+ const matches = detectChord(detectionPitches, { input: hasBass ? "midi" : "pitchClass" });
667
+ let bestMatch = null;
668
+ let bestScore = Number.NEGATIVE_INFINITY;
669
+ for (const match of matches) {
670
+ const score = scoreMatch(match, weights, maxWeight, key);
671
+ if (score > bestScore) {
672
+ bestScore = score;
673
+ bestMatch = match;
674
+ }
675
+ }
676
+ if (bestMatch) {
677
+ const augmented = hasBass ? augmentedSixthFromPitchClasses(selected, bassPc, key) : null;
678
+ if (augmented !== null) {
679
+ return {
680
+ chord: augmented,
681
+ confidence: chordConfidence(augmented, weights, totalWeight, true)
682
+ };
683
+ }
684
+ const chord = makeChord(bestMatch.rootPc, bestMatch.quality, bestMatch.bassPc);
685
+ return { chord, confidence: chordConfidence(chord, weights, totalWeight, bestMatch.exact) };
686
+ }
687
+ return null;
688
+ }
689
+ function sameChord(a, b) {
690
+ return a.rootPc === b.rootPc && a.quality === b.quality && a.bassPc === b.bassPc;
691
+ }
692
+ var SEGMENTATION_QUALITIES = [
693
+ "maj",
694
+ "min",
695
+ "dim",
696
+ "aug",
697
+ "dom7",
698
+ "maj7",
699
+ "min7",
700
+ "m7b5",
701
+ "dim7",
702
+ "sus4",
703
+ "6",
704
+ "min6"
705
+ ];
706
+ var LEXICON_SIZE = 12 * SEGMENTATION_QUALITIES.length;
707
+ var LEXICON_ROOTS = new Int32Array(LEXICON_SIZE);
708
+ var LEXICON_TONE_START = new Int32Array(LEXICON_SIZE + 1);
709
+ var LEXICON_TONES = (() => {
710
+ const tones = [];
711
+ let candidate = 0;
712
+ for (let rootPc = 0; rootPc < 12; rootPc += 1) {
713
+ for (const quality of SEGMENTATION_QUALITIES) {
714
+ LEXICON_ROOTS[candidate] = rootPc;
715
+ LEXICON_TONE_START[candidate] = tones.length;
716
+ tones.push(...chordPitchClasses(makeChord(rootPc, quality)));
717
+ candidate += 1;
718
+ }
719
+ }
720
+ LEXICON_TONE_START[candidate] = tones.length;
721
+ return Int32Array.from(tones);
722
+ })();
723
+ var OUTSIDE_TONE_PENALTY = 1;
724
+ var MISSING_TONE_PENALTY = 0.5;
725
+ var ROOT_PRESENCE_BONUS = 0.3;
726
+ var CHANGE_COST = 0.35;
727
+ var STRONG_BEAT_DISCOUNT2 = 0.5;
728
+ var MAX_METRIC_WEIGHT2 = 3;
729
+ function candidateScore(candidate, weights, totalWeight) {
730
+ const first = LEXICON_TONE_START[candidate] ?? 0;
731
+ const last = LEXICON_TONE_START[candidate + 1] ?? first;
732
+ let covered = 0;
733
+ let missing = 0;
734
+ for (let index = first; index < last; index += 1) {
735
+ const weight = weights[LEXICON_TONES[index] ?? 0] ?? 0;
736
+ covered += weight;
737
+ if (weight <= EPS4) {
738
+ missing += 1;
739
+ }
740
+ }
741
+ const outside = totalWeight - covered;
742
+ const averageToneWeight = totalWeight / (last - first);
743
+ return covered - OUTSIDE_TONE_PENALTY * outside - MISSING_TONE_PENALTY * missing * averageToneWeight + ROOT_PRESENCE_BONUS * (weights[LEXICON_ROOTS[candidate] ?? 0] ?? 0);
744
+ }
745
+ function beatOfSlot(grid, index) {
746
+ return grid.origin + index * grid.slotBeats;
747
+ }
748
+ function slotOfBeat(grid, beat) {
749
+ return (beat - grid.origin) / grid.slotBeats;
750
+ }
751
+ function createBoundaryTables(slotCount) {
752
+ return {
753
+ scores: new Float64Array(slotCount * LEXICON_SIZE),
754
+ back: new Int32Array(slotCount * LEXICON_SIZE),
755
+ costs: new Float64Array(LEXICON_SIZE),
756
+ spare: new Float64Array(LEXICON_SIZE),
757
+ path: new Int32Array(slotCount)
758
+ };
759
+ }
760
+ function scoreSlot(tables, slot, index) {
761
+ const base = index * LEXICON_SIZE;
762
+ if (slot.totalWeight <= EPS4) {
763
+ tables.scores.fill(0, base, base + LEXICON_SIZE);
764
+ return;
765
+ }
766
+ for (let c = 0; c < LEXICON_SIZE; c += 1) {
767
+ tables.scores[base + c] = candidateScore(c, slot.weights, slot.totalWeight);
768
+ }
769
+ }
770
+ function chooseBoundaries(tables, from, to, grid, meters, changeCost) {
771
+ const width = to - from;
772
+ if (width <= 0) {
773
+ return [];
774
+ }
775
+ const { scores, back, path } = tables;
776
+ let costs = tables.costs;
777
+ let next = tables.spare;
778
+ for (let c = 0; c < LEXICON_SIZE; c += 1) {
779
+ costs[c] = -(scores[from * LEXICON_SIZE + c] ?? 0);
780
+ }
781
+ for (let i = 1; i < width; i += 1) {
782
+ let cheapest = Number.POSITIVE_INFINITY;
783
+ let cheapestIndex = 0;
784
+ for (let c = 0; c < LEXICON_SIZE; c += 1) {
785
+ const cost = costs[c] ?? Number.POSITIVE_INFINITY;
786
+ if (cost < cheapest) {
787
+ cheapest = cost;
788
+ cheapestIndex = c;
789
+ }
790
+ }
791
+ const slotStart = beatOfSlot(grid, from + i);
792
+ const accent = metricWeight(slotStart, meters);
793
+ const penalty = changeCost * (1 - STRONG_BEAT_DISCOUNT2 * accent / MAX_METRIC_WEIGHT2);
794
+ const rowBase = (from + i) * LEXICON_SIZE;
795
+ const change = cheapest + penalty;
796
+ for (let c = 0; c < LEXICON_SIZE; c += 1) {
797
+ const stay = costs[c] ?? Number.POSITIVE_INFINITY;
798
+ const carried = stay <= change ? stay : change;
799
+ back[rowBase + c] = stay <= change ? c : cheapestIndex;
800
+ next[c] = carried - (scores[rowBase + c] ?? 0);
801
+ }
802
+ const previous = costs;
803
+ costs = next;
804
+ next = previous;
805
+ }
806
+ let best = 0;
807
+ for (let c = 1; c < LEXICON_SIZE; c += 1) {
808
+ if ((costs[c] ?? Number.POSITIVE_INFINITY) < (costs[best] ?? Number.POSITIVE_INFINITY)) {
809
+ best = c;
810
+ }
811
+ }
812
+ let current = best;
813
+ for (let i = width - 1; i >= 0; i -= 1) {
814
+ path[from + i] = current;
815
+ if (i > 0) {
816
+ current = back[(from + i) * LEXICON_SIZE + current] ?? current;
817
+ }
818
+ }
819
+ const spans = [];
820
+ let runStart = 0;
821
+ for (let i = 1; i <= width; i += 1) {
822
+ if (i === width || path[from + i] !== path[from + runStart]) {
823
+ spans.push({
824
+ startBeat: beatOfSlot(grid, from + runStart),
825
+ endBeat: beatOfSlot(grid, from + i)
826
+ });
827
+ runStart = i;
828
+ }
829
+ }
830
+ return spans;
831
+ }
832
+ var DEFAULT_MIN_CHORD_BEATS = 1;
833
+ function slotBeatsWithin(harmonicRhythm, minChordBeats) {
834
+ const divisions = Math.max(1, Math.ceil(harmonicRhythm / minChordBeats - EPS4));
835
+ return harmonicRhythm / divisions;
836
+ }
837
+ function gridSpans(slotCount, grid) {
838
+ return Array.from({ length: slotCount }, (_, i) => ({
839
+ startBeat: beatOfSlot(grid, i),
840
+ endBeat: beatOfSlot(grid, i + 1)
841
+ }));
842
+ }
843
+ function dynamicSpans(slotWeights, tables, grid, harmonicRhythm, meters) {
844
+ const slotBeats = grid.slotBeats;
845
+ const slotCount = slotWeights.length;
846
+ let soundingSlots = 0;
847
+ let weightSum = 0;
848
+ for (let i = 0; i < slotCount; i += 1) {
849
+ const weight = slotWeights[i]?.totalWeight ?? 0;
850
+ if (weight > EPS4) {
851
+ soundingSlots += 1;
852
+ weightSum += weight;
853
+ }
854
+ }
855
+ if (soundingSlots === 0) {
856
+ return [];
857
+ }
858
+ const meanSlotWeight = weightSum / soundingSlots;
859
+ const changeCost = CHANGE_COST * meanSlotWeight * (harmonicRhythm / slotBeats);
860
+ const silent = slotWeights.map((slot) => slot.totalWeight <= EPS4);
861
+ const breaks = new Array(slotCount).fill(false);
862
+ for (let i = 0; i < slotCount; ) {
863
+ if (!silent[i]) {
864
+ i += 1;
865
+ continue;
866
+ }
867
+ let end = i;
868
+ while (end < slotCount && silent[end]) {
869
+ end += 1;
870
+ }
871
+ if ((end - i) * slotBeats >= harmonicRhythm - EPS4) {
872
+ for (let k = i; k < end; k += 1) {
873
+ breaks[k] = true;
874
+ }
875
+ }
876
+ i = end;
877
+ }
878
+ const spans = [];
879
+ for (let i = 0; i < slotCount; ) {
880
+ if (breaks[i]) {
881
+ i += 1;
882
+ continue;
883
+ }
884
+ let end = i;
885
+ while (end < slotCount && !breaks[end]) {
886
+ end += 1;
887
+ }
888
+ spans.push(...chooseBoundaries(tables, i, end, grid, meters, changeCost));
889
+ i = end;
890
+ }
891
+ return spans;
892
+ }
893
+ function notesOfSpan(slotNotes, span, grid) {
894
+ const first = Math.round(slotOfBeat(grid, span.startBeat));
895
+ const lastExclusive = Math.round(slotOfBeat(grid, span.endBeat));
896
+ if (lastExclusive - first === 1) {
897
+ return [...slotNotes[first] ?? []];
898
+ }
899
+ const seen = /* @__PURE__ */ new Set();
900
+ for (let i = first; i < lastExclusive; i += 1) {
901
+ for (const note of slotNotes[i] ?? []) {
902
+ seen.add(note);
903
+ }
904
+ }
905
+ return [...seen];
906
+ }
907
+ function chordTimelineFromNotes(notes, opts = {}) {
908
+ return analyzeTimeline(notes, opts).result;
909
+ }
910
+ var DIRTY_SLOT_MARGIN = 1;
911
+ function dirtySlotsFor(evidence, startBeat, endBeat) {
912
+ const first = Math.floor(slotOfBeat(evidence.grid, startBeat)) - DIRTY_SLOT_MARGIN;
913
+ const lastExclusive = Math.ceil(slotOfBeat(evidence.grid, endBeat)) + DIRTY_SLOT_MARGIN;
914
+ return {
915
+ from: Math.max(0, Math.min(evidence.slotCount, first)),
916
+ to: Math.max(0, Math.min(evidence.slotCount, lastExclusive))
917
+ };
918
+ }
919
+ function spanIsClean(grid, span, dirtyFrom, dirtyTo) {
920
+ const first = Math.round(slotOfBeat(grid, span.startBeat));
921
+ const lastExclusive = Math.round(slotOfBeat(grid, span.endBeat));
922
+ return lastExclusive <= dirtyFrom || first >= dirtyTo;
923
+ }
924
+ function keyId(key) {
925
+ return `${key.rootPc}:${key.modeMask12}`;
926
+ }
927
+ function windowId(startBeat, endBeat, key) {
928
+ return `${startBeat}|${endBeat}|${keyId(key)}`;
929
+ }
930
+ function evidenceFits(previous, grid, slotCount, totalBeats, segmentation, harmonicRhythm, keyGiven) {
931
+ return previous.grid.origin === grid.origin && previous.grid.slotBeats === grid.slotBeats && previous.slotCount === slotCount && previous.totalBeats === totalBeats && previous.segmentation === segmentation && previous.harmonicRhythm === harmonicRhythm && previous.keyGiven === keyGiven;
932
+ }
933
+ function analyzeTimeline(notes, opts = {}, previous, dirty) {
934
+ const meters = resolveMeters(opts, "timeline meters");
935
+ const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBarAt(0, meters);
936
+ assertRange(harmonicRhythm, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "harmonic rhythm");
937
+ const segmentation = opts.segmentation ?? "dynamic";
938
+ const minChordBeats = opts.minChordBeats ?? DEFAULT_MIN_CHORD_BEATS;
939
+ assertRange(minChordBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "minChordBeats");
940
+ const budget = opts.budget;
941
+ const noteOptions = { allowNonPositiveDuration: true, budget };
942
+ if (opts.pickupBeats !== void 0) {
943
+ assertRange(opts.pickupBeats, 0, Number.MAX_SAFE_INTEGER, "timeline pickupBeats");
944
+ noteOptions.minStartBeat = -opts.pickupBeats;
945
+ }
946
+ assertNoteEvents(notes, "timeline notes", noteOptions);
947
+ const soundingIndex = createNoteEventIndex(
948
+ notes.filter((note) => note.durationBeat > 0),
949
+ { name: "timeline notes", budget }
950
+ );
951
+ const sounding = soundingIndex.notes.map(({ note }) => note);
952
+ const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
953
+ const totalBeats = opts.totalBeats ?? lastNoteEnd;
954
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "timeline totalBeats");
955
+ const slotBeats = segmentation === "grid" ? harmonicRhythm : slotBeatsWithin(harmonicRhythm, minChordBeats);
956
+ const firstOnset = sounding.reduce((first, n) => Math.min(first, n.startBeat), 0);
957
+ const { origin, startBeat: musicStart } = gridOriginOf(firstOnset, slotBeats);
958
+ const grid = { origin, slotBeats };
959
+ const keys = opts.key !== void 0 ? [{ startBeat: musicStart, endBeat: totalBeats, key: opts.key, confidence: 1 }] : keyTimelineFromNotes(sounding, { meters, totalBeats, budget });
960
+ const prevailingKey = prevailingKeyOf(keys) ?? majorKey(0);
961
+ const keyAt = keyLookup(keys, prevailingKey);
962
+ const segments = [];
963
+ const segmentConfidence = [];
964
+ const slotCount = Math.max(0, Math.ceil((totalBeats - grid.origin) / slotBeats - EPS4));
965
+ assertGenerationBudget(slotCount, "timeline windows", budget);
966
+ const slotNotes = bucketNotesBySlot(sounding, grid, slotCount, {
967
+ name: "timeline note-to-window memberships",
968
+ budget
969
+ });
970
+ const carried = previous !== void 0 && dirty !== void 0 && evidenceFits(
971
+ previous,
972
+ grid,
973
+ slotCount,
974
+ totalBeats,
975
+ segmentation,
976
+ harmonicRhythm,
977
+ opts.key !== void 0
978
+ ) ? previous : void 0;
979
+ const dirtyFrom = carried === void 0 ? 0 : Math.max(0, dirty?.from ?? 0);
980
+ const dirtyTo = carried === void 0 ? slotCount : Math.min(slotCount, dirty?.to ?? slotCount);
981
+ const tables = createBoundaryTables(slotCount);
982
+ const slotWeights = new Array(slotCount);
983
+ if (carried !== void 0) {
984
+ tables.scores.set(carried.tables.scores);
985
+ }
986
+ for (let i = 0; i < slotCount; i += 1) {
987
+ const reusable = i < dirtyFrom || i >= dirtyTo ? carried?.slotWeights[i] : void 0;
988
+ if (reusable !== void 0) {
989
+ slotWeights[i] = reusable;
990
+ continue;
991
+ }
992
+ const start = beatOfSlot(grid, i);
993
+ const weights = windowWeights(
994
+ slotNotes[i] ?? [],
995
+ start,
996
+ Math.min(start + slotBeats, totalBeats),
997
+ meters
998
+ );
999
+ slotWeights[i] = weights;
1000
+ scoreSlot(tables, weights, i);
1001
+ }
1002
+ const spans = segmentation === "grid" ? gridSpans(slotCount, grid) : dynamicSpans(slotWeights, tables, grid, harmonicRhythm, meters);
1003
+ const windows = /* @__PURE__ */ new Map();
1004
+ for (const span of spans) {
1005
+ const start = Math.max(span.startBeat, musicStart);
1006
+ const end = Math.min(span.endBeat, totalBeats);
1007
+ const spanKey = keyAt(start);
1008
+ const id = windowId(start, end, spanKey);
1009
+ const cached = carried !== void 0 && spanIsClean(grid, span, dirtyFrom, dirtyTo) ? carried.windows.get(id) : void 0;
1010
+ const inferred = cached !== void 0 ? cached : analyzeWindow(notesOfSpan(slotNotes, span, grid), start, end, meters, spanKey);
1011
+ windows.set(id, inferred);
1012
+ if (!inferred) {
1013
+ continue;
1014
+ }
1015
+ const last = segments[segments.length - 1];
1016
+ if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS4) {
1017
+ const lastLength = last.endBeat - last.startBeat;
1018
+ const length = end - start;
1019
+ const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
1020
+ segmentConfidence[segmentConfidence.length - 1] = (lastConfidence * lastLength + inferred.confidence * length) / (lastLength + length);
1021
+ last.endBeat = end;
1022
+ } else {
1023
+ segments.push({ startBeat: start, endBeat: end, chord: inferred.chord });
1024
+ segmentConfidence.push(inferred.confidence);
1025
+ }
1026
+ }
1027
+ return {
1028
+ result: {
1029
+ timeline: { at: segmentLookup(segments), segments },
1030
+ keys: attachPivots(keys, segments),
1031
+ prevailingKey,
1032
+ segmentConfidence
1033
+ },
1034
+ evidence: {
1035
+ grid,
1036
+ slotCount,
1037
+ totalBeats,
1038
+ segmentation,
1039
+ harmonicRhythm,
1040
+ keyGiven: opts.key !== void 0,
1041
+ slotWeights,
1042
+ tables,
1043
+ windows
1044
+ }
1045
+ };
1046
+ }
1047
+ function detectCadences(timeline, key) {
1048
+ const keyAt = typeof key === "function" ? key : () => key;
1049
+ const hits = [];
1050
+ for (let i = 1; i < timeline.segments.length; i += 1) {
1051
+ const prev = timeline.segments[i - 1];
1052
+ const cur = timeline.segments[i];
1053
+ if (!prev || !cur) {
1054
+ continue;
1055
+ }
1056
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS4) {
1057
+ continue;
1058
+ }
1059
+ const before = timeline.segments[i - 2];
1060
+ const approach = before !== void 0 && Math.abs(prev.startBeat - before.endBeat) <= EPS4 ? before.chord : void 0;
1061
+ const cadence = detectCadence(prev.chord, cur.chord, keyAt(cur.startBeat), {
1062
+ ...approach === void 0 ? {} : { approach }
1063
+ });
1064
+ if (cadence.type !== null) {
1065
+ hits.push({ atBeat: cur.startBeat, cadence, from: prev.chord, to: cur.chord });
1066
+ }
1067
+ }
1068
+ return hits;
1069
+ }
1070
+
1071
+ // src/analyze/voice/index.ts
1072
+ function toVoiceNotes(events) {
1073
+ return events.map((event, index) => ({ ...event, id: index, originalIndex: index }));
1074
+ }
1075
+ var HUMANIZE_ADJACENCY = 0.05;
1076
+ function intervalAboveBass(pitch, others, chord) {
1077
+ let bass = Number.POSITIVE_INFINITY;
1078
+ for (const ov of others) {
1079
+ bass = Math.min(bass, ov.pitch);
1080
+ }
1081
+ if (Number.isFinite(bass) && bass < pitch) {
1082
+ return ((pitch - bass) % 12 + 12) % 12;
1083
+ }
1084
+ const ref = chord.bassPc ?? chord.rootPc;
1085
+ return ((pitchClassOf(pitch) - pitchClassOf(ref)) % 12 + 12) % 12;
1086
+ }
1087
+ var TENSION_DEGREE_BY_INTERVAL = {
1088
+ 1: 9,
1089
+ 2: 9,
1090
+ 3: 9,
1091
+ 5: 11,
1092
+ 6: 11,
1093
+ 8: 13,
1094
+ 9: 13
1095
+ };
1096
+ function tensionDegree(ic) {
1097
+ return TENSION_DEGREE_BY_INTERVAL[ic];
1098
+ }
1099
+ function isStep(a, b) {
1100
+ const d = Math.abs(a - b);
1101
+ return d === 1 || d === 2;
1102
+ }
1103
+ var LEAP_SEMITONES = 3;
1104
+ function suspensionType(ic, delta) {
1105
+ if (delta > 0) {
1106
+ return ic === 1 || ic === 2 ? "sus2-3" : null;
1107
+ }
1108
+ if (ic === 2) {
1109
+ return "sus9-8";
1110
+ }
1111
+ if (ic === 10 || ic === 11) {
1112
+ return "sus7-6";
1113
+ }
1114
+ if (ic === 5 || ic === 6) {
1115
+ return "sus4-3";
1116
+ }
1117
+ if (ic === 8 || ic === 9) {
1118
+ return "sus6-5";
1119
+ }
1120
+ return null;
1121
+ }
1122
+ var STEP_RESOLUTION = 2;
1123
+ var NEAREST_CHORD_TONE_REACH = 12;
1124
+ function nearestChordTone(pitch, chord, maxDistance) {
1125
+ for (let delta = 1; delta <= maxDistance; delta += 1) {
1126
+ if (isChordMember(pitch - delta, chord)) {
1127
+ return pitch - delta;
1128
+ }
1129
+ if (isChordMember(pitch + delta, chord)) {
1130
+ return pitch + delta;
1131
+ }
1132
+ }
1133
+ return void 0;
1134
+ }
1135
+ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat = () => []) {
1136
+ assertNoteEvents(voice, "voice notes", { allowNonPositiveDuration: true });
1137
+ const keyAt = typeof key === "function" ? key : () => key;
1138
+ const result = [];
1139
+ for (let i = 0; i < voice.length; i += 1) {
1140
+ const note = voice[i];
1141
+ if (!note) {
1142
+ continue;
1143
+ }
1144
+ const prevNote = i > 0 ? voice[i - 1] : void 0;
1145
+ const nextNote = i + 1 < voice.length ? voice[i + 1] : void 0;
1146
+ const prevEnd = prevNote === void 0 ? Number.NEGATIVE_INFINITY : prevNote.startBeat + prevNote.durationBeat;
1147
+ const noteEnd = note.startBeat + note.durationBeat;
1148
+ const prev = prevNote !== void 0 && Math.abs(prevEnd - note.startBeat) <= HUMANIZE_ADJACENCY ? prevNote : void 0;
1149
+ const next = nextNote !== void 0 && Math.abs(noteEnd - nextNote.startBeat) <= HUMANIZE_ADJACENCY ? nextNote : void 0;
1150
+ const chord = chordAtBeat(note.startBeat);
1151
+ const labels = [];
1152
+ const member = isChordMember(note.pitch, chord);
1153
+ let handled = false;
1154
+ if (chord && member) {
1155
+ const role = chordToneRole(note.pitch, chord);
1156
+ const degree = role === null ? tensionDegree(intervalAboveRoot(note.pitch, chord)) : void 0;
1157
+ if (role) {
1158
+ labels.push({ kind: "chordTone", role });
1159
+ handled = true;
1160
+ } else if (degree !== void 0) {
1161
+ labels.push({ kind: "tension", degree });
1162
+ handled = true;
1163
+ }
1164
+ }
1165
+ if (!handled && chord && prev && next) {
1166
+ const prevChord = chordAtBeat(prev.startBeat);
1167
+ const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
1168
+ const resolves = isStep(next.pitch, note.pitch);
1169
+ const others = otherVoicesAtBeat(note.startBeat);
1170
+ const verticallyDissonant = others.some(
1171
+ (ov) => createsVerticalDissonance(note.pitch, ov.pitch, true)
1172
+ );
1173
+ if (prepared && verticallyDissonant && resolves) {
1174
+ const ic = intervalAboveBass(note.pitch, others, chord);
1175
+ const type = suspensionType(ic, next.pitch - note.pitch);
1176
+ if (type !== null) {
1177
+ labels.push({ kind: "suspension", type, resolveTo: next.pitch });
1178
+ handled = true;
1179
+ }
1180
+ }
1181
+ }
1182
+ if (!handled && chord && prev && next) {
1183
+ const prevMember = isChordMember(prev.pitch, chordAtBeat(prev.startBeat));
1184
+ const nextMember = isChordMember(next.pitch, chordAtBeat(next.startBeat));
1185
+ const up = note.pitch - prev.pitch > 0;
1186
+ const contInto = next.pitch - note.pitch > 0;
1187
+ const sameDir = note.pitch !== prev.pitch && next.pitch !== note.pitch && up === contInto;
1188
+ if (prevMember && nextMember && !member && isStep(note.pitch, prev.pitch) && isStep(next.pitch, note.pitch) && sameDir) {
1189
+ labels.push({ kind: "passing" });
1190
+ handled = true;
1191
+ }
1192
+ }
1193
+ if (!handled && chord && prev && next && !member && prev.pitch === next.pitch && isChordMember(prev.pitch, chordAtBeat(prev.startBeat)) && isStep(note.pitch, prev.pitch)) {
1194
+ labels.push({ kind: "neighbor" });
1195
+ handled = true;
1196
+ }
1197
+ if (!handled && chord && next && !member && note.pitch === next.pitch) {
1198
+ const nextChord = chordAtBeat(next.startBeat);
1199
+ if (isChordMember(next.pitch, nextChord)) {
1200
+ labels.push({ kind: "anticipation" });
1201
+ handled = true;
1202
+ }
1203
+ }
1204
+ if (!handled && chord && prev && next && !member) {
1205
+ const prevMember = isChordMember(prev.pitch, chordAtBeat(prev.startBeat));
1206
+ const nextMember = isChordMember(next.pitch, chordAtBeat(next.startBeat));
1207
+ const leapFromPrev = Math.abs(note.pitch - prev.pitch) >= LEAP_SEMITONES;
1208
+ const stepToNext = isStep(next.pitch, note.pitch);
1209
+ const opposite = note.pitch - prev.pitch > 0 !== next.pitch - note.pitch > 0;
1210
+ if (prevMember && nextMember && leapFromPrev && stepToNext && opposite) {
1211
+ labels.push({ kind: "appoggiatura", resolveTo: next.pitch });
1212
+ handled = true;
1213
+ }
1214
+ }
1215
+ if (!handled && chord && prev && next && !member) {
1216
+ const prevMember = isChordMember(prev.pitch, chordAtBeat(prev.startBeat));
1217
+ const nextMember = isChordMember(next.pitch, chordAtBeat(next.startBeat));
1218
+ const stepFromPrev = isStep(note.pitch, prev.pitch);
1219
+ const leapToNext = Math.abs(next.pitch - note.pitch) >= LEAP_SEMITONES;
1220
+ const opposite = note.pitch - prev.pitch > 0 !== next.pitch - note.pitch > 0;
1221
+ if (prevMember && nextMember && stepFromPrev && leapToNext && opposite) {
1222
+ labels.push({ kind: "escape" });
1223
+ handled = true;
1224
+ }
1225
+ }
1226
+ if (!handled && chord && !member) {
1227
+ const ic = intervalAboveRoot(note.pitch, chord);
1228
+ const degree = tensionDegree(ic);
1229
+ const isTension = ic === 2 || ic === 5 || ic === 9;
1230
+ const avoid = ic === 5 && chord.intervals.includes(4) && !chord.intervals.includes(5) || ic === 11 && chord.intervals.includes(4) && chord.intervals.includes(10);
1231
+ if (avoid) {
1232
+ labels.push({ kind: "avoid" });
1233
+ const resolveTo = nearestChordTone(note.pitch, chord, STEP_RESOLUTION);
1234
+ if (resolveTo !== void 0) {
1235
+ labels.push({ kind: "needsResolution", resolveTo });
1236
+ }
1237
+ } else if (isTension && degree !== void 0) {
1238
+ labels.push({ kind: "tension", degree });
1239
+ } else {
1240
+ const resolveTo = nearestChordTone(note.pitch, chord, NEAREST_CHORD_TONE_REACH);
1241
+ labels.push(
1242
+ resolveTo === void 0 ? { kind: "avoid" } : { kind: "needsResolution", resolveTo }
1243
+ );
1244
+ }
1245
+ }
1246
+ if (next && isLeadingToneResolution(note.pitch, next.pitch, keyAt(next.startBeat))) {
1247
+ labels.push({ kind: "leadingTone", resolveTo: next.pitch });
1248
+ }
1249
+ const analyzed = {
1250
+ noteId: note.id ?? i,
1251
+ pitch: note.pitch,
1252
+ startBeat: note.startBeat,
1253
+ durationBeat: note.durationBeat,
1254
+ labels,
1255
+ rationale: describe(labels)
1256
+ };
1257
+ if (note.originalIndex !== void 0) {
1258
+ analyzed.originalIndex = note.originalIndex;
1259
+ }
1260
+ result.push(analyzed);
1261
+ }
1262
+ return result;
1263
+ }
1264
+ function describe(labels) {
1265
+ const primary = labels[0];
1266
+ if (!primary) {
1267
+ return "Unclassified note";
1268
+ }
1269
+ switch (primary.kind) {
1270
+ case "chordTone":
1271
+ return `Chord ${primary.role}`;
1272
+ case "tension":
1273
+ return `${primary.degree}th tension`;
1274
+ case "avoid":
1275
+ return "Avoid note";
1276
+ case "passing":
1277
+ return "Passing tone between chord tones";
1278
+ case "neighbor":
1279
+ return "Neighbor tone";
1280
+ case "suspension":
1281
+ return `Suspension (${primary.type})`;
1282
+ case "appoggiatura":
1283
+ return "Appoggiatura leaning on the harmony, resolving by step";
1284
+ case "anticipation":
1285
+ return "Anticipation of the next chord";
1286
+ case "escape":
1287
+ return "Escape tone";
1288
+ case "needsResolution":
1289
+ return "Unresolved dissonance";
1290
+ case "leadingTone":
1291
+ return "Leading tone resolving to the tonic";
1292
+ }
1293
+ }
1294
+
1295
+ // src/analyze/arrange/internal.ts
1296
+ var EPS5 = 1e-9;
1297
+ var MAX_LANE_LEAP = 24;
1298
+ function covers(note, beat) {
1299
+ return beat >= note.startBeat - EPS5 && beat < note.endBeat - EPS5;
1300
+ }
1301
+ function isPercussion(role) {
1302
+ return role === "drums";
1303
+ }
1304
+ var DEFAULT_PROFILE = "pop";
1305
+ var SAFETY_PROFILES = Object.keys(PROFILE_WEIGHTS);
1306
+ function arrangementProfile(profile) {
1307
+ return assertOneOf(profile ?? DEFAULT_PROFILE, SAFETY_PROFILES, "arrangement profile");
1308
+ }
1309
+ function assertTrackNotes(tracks, options) {
1310
+ let noteCount = 0;
1311
+ for (let index = 0; index < tracks.length; index += 1) {
1312
+ const notes = tracks[index]?.notes;
1313
+ if (!Array.isArray(notes)) {
1314
+ throw new InvalidInputError(
1315
+ `tracks[${index}].notes must be an array; received ${typeof notes}`
1316
+ );
1317
+ }
1318
+ assertNoteEvents(notes, `tracks[${index}].notes`, options);
1319
+ noteCount += notes.length;
1320
+ }
1321
+ return noteCount;
1322
+ }
1323
+ function harmonyTrackSet(harmonyTracks, trackCount, name = "harmonyTracks") {
1324
+ if (harmonyTracks === void 0) {
1325
+ return void 0;
1326
+ }
1327
+ const only = /* @__PURE__ */ new Set();
1328
+ for (let index = 0; index < harmonyTracks.length; index += 1) {
1329
+ const value = harmonyTracks[index];
1330
+ if (value === void 0 || !Number.isFinite(value)) {
1331
+ throw new InvalidInputError(`${name}[${index}] must be a track index; received ${value}`);
1332
+ }
1333
+ const track = Math.trunc(value);
1334
+ if (track < 0 || track >= trackCount) {
1335
+ throw new InvalidInputError(
1336
+ `${name}[${index}] ${track} is outside the arrangement's ${trackCount} tracks`
1337
+ );
1338
+ }
1339
+ only.add(track);
1340
+ }
1341
+ return only;
1342
+ }
1343
+ function poolNotes(tracks, only) {
1344
+ const notes = [];
1345
+ for (let index = 0; index < tracks.length; index += 1) {
1346
+ const track = tracks[index];
1347
+ if (track === void 0 || isPercussion(track.role)) {
1348
+ continue;
1349
+ }
1350
+ if (only !== void 0 && !only.has(index)) {
1351
+ continue;
1352
+ }
1353
+ for (const note of track.notes) {
1354
+ if (note.durationBeat > 0) {
1355
+ notes.push(note);
1356
+ }
1357
+ }
1358
+ }
1359
+ return notes;
1360
+ }
1361
+ function splitIntoSubVoices(ordered) {
1362
+ const lanes = [];
1363
+ const attachNearest = (note, excluded = /* @__PURE__ */ new Set()) => {
1364
+ let best = -1;
1365
+ let bestDistance = Number.POSITIVE_INFINITY;
1366
+ let bestEnd = Number.NEGATIVE_INFINITY;
1367
+ for (let lane = 0; lane < lanes.length; lane += 1) {
1368
+ if (excluded.has(lane)) {
1369
+ continue;
1370
+ }
1371
+ const last = lanes[lane]?.[(lanes[lane]?.length ?? 0) - 1];
1372
+ if (last === void 0) {
1373
+ continue;
1374
+ }
1375
+ const end = last.startBeat + last.durationBeat;
1376
+ if (end > note.startBeat + EPS5) {
1377
+ continue;
1378
+ }
1379
+ const distance = Math.abs(last.pitch - note.pitch);
1380
+ if (distance > MAX_LANE_LEAP) {
1381
+ continue;
1382
+ }
1383
+ if (distance < bestDistance || distance === bestDistance && end > bestEnd) {
1384
+ best = lane;
1385
+ bestDistance = distance;
1386
+ bestEnd = end;
1387
+ }
1388
+ }
1389
+ if (best >= 0) {
1390
+ lanes[best]?.push(note);
1391
+ return true;
1392
+ }
1393
+ return false;
1394
+ };
1395
+ for (let start = 0; start < ordered.length; ) {
1396
+ const onset = ordered[start]?.startBeat ?? 0;
1397
+ let end = start + 1;
1398
+ while (end < ordered.length && Math.abs((ordered[end]?.startBeat ?? 0) - onset) <= EPS5) {
1399
+ end += 1;
1400
+ }
1401
+ const block = ordered.slice(start, end);
1402
+ if (block.length > 1) {
1403
+ const freeLanes = lanes.map((lane, index) => ({ index, last: lane[lane.length - 1] })).filter(
1404
+ (entry) => entry.last !== void 0 && entry.last.startBeat + entry.last.durationBeat <= onset + EPS5
1405
+ ).sort((a, b) => a.last.pitch - b.last.pitch);
1406
+ const paired = /* @__PURE__ */ new Set();
1407
+ for (let index = 0; index < block.length; index += 1) {
1408
+ const note = block[index];
1409
+ const lane = freeLanes[index];
1410
+ if (note !== void 0 && lane !== void 0 && Math.abs(lane.last.pitch - note.pitch) <= MAX_LANE_LEAP) {
1411
+ lanes[lane.index]?.push(note);
1412
+ paired.add(lane.index);
1413
+ } else if (note !== void 0 && !attachNearest(note, paired)) {
1414
+ lanes.push([note]);
1415
+ }
1416
+ }
1417
+ } else {
1418
+ const note = block[0];
1419
+ if (note !== void 0 && !attachNearest(note)) {
1420
+ lanes.push([note]);
1421
+ }
1422
+ }
1423
+ start = end;
1424
+ }
1425
+ return lanes;
1426
+ }
1427
+ function lastStartingAtOrBefore(sounding, beat) {
1428
+ let low = 0;
1429
+ let high = sounding.length;
1430
+ while (low < high) {
1431
+ const middle = Math.floor((low + high) / 2);
1432
+ if ((sounding[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat + EPS5) {
1433
+ low = middle + 1;
1434
+ } else {
1435
+ high = middle;
1436
+ }
1437
+ }
1438
+ return low - 1;
1439
+ }
1440
+ function prepareTracks(tracks) {
1441
+ let nextId = 0;
1442
+ return tracks.map((track, index) => {
1443
+ const ordered = track.notes.map((note, originalIndex) => ({ note, originalIndex })).filter(({ note }) => note.durationBeat > 0).sort((a, b) => a.note.startBeat - b.note.startBeat || a.note.pitch - b.note.pitch).map(({ note, originalIndex }) => ({ ...note, id: nextId++, originalIndex }));
1444
+ const voices = splitIntoSubVoices(ordered).map((voice) => {
1445
+ const sounding = voice.map((note, i) => {
1446
+ const prev = i > 0 ? voice[i - 1] : void 0;
1447
+ const prevEnd = prev === void 0 ? void 0 : prev.startBeat + prev.durationBeat;
1448
+ const contiguous = prevEnd !== void 0 && Math.abs(prevEnd - note.startBeat) <= EPS5;
1449
+ return {
1450
+ pitch: note.pitch,
1451
+ prevPitch: contiguous ? prev?.pitch : void 0,
1452
+ startBeat: note.startBeat,
1453
+ endBeat: note.startBeat + note.durationBeat
1454
+ };
1455
+ });
1456
+ return {
1457
+ voice,
1458
+ sounding,
1459
+ at: (beat) => {
1460
+ const candidate = sounding[lastStartingAtOrBefore(sounding, beat)];
1461
+ return candidate !== void 0 && covers(candidate, beat) ? candidate : void 0;
1462
+ }
1463
+ };
1464
+ });
1465
+ return {
1466
+ name: track.name ?? `track ${index + 1}`,
1467
+ role: track.role ?? "other",
1468
+ trackIndex: index,
1469
+ voices
1470
+ };
1471
+ });
1472
+ }
1473
+
1474
+ // src/analyze/arrange/tracks.ts
1475
+ function withGivenKeys(result, given) {
1476
+ if (given === void 0) {
1477
+ return result;
1478
+ }
1479
+ return { ...result, keys: given, prevailingKey: prevailingKeyOf(given) ?? result.prevailingKey };
1480
+ }
1481
+ function callerTimeline(timeline, key, given, pooled, meters, budget) {
1482
+ const totalBeats = timeline.segments.reduce((end, segment) => Math.max(end, segment.endBeat), 0);
1483
+ const keys = given ?? attachPivots(
1484
+ key !== void 0 ? [{ startBeat: 0, endBeat: totalBeats, key, confidence: 1 }] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }),
1485
+ timeline.segments
1486
+ );
1487
+ return {
1488
+ timeline,
1489
+ keys,
1490
+ prevailingKey: prevailingKeyOf(keys) ?? majorKey(0),
1491
+ segmentConfidence: timeline.segments.map(() => 0)
1492
+ };
1493
+ }
1494
+ function soundingVoicesAt(prepared, beat) {
1495
+ const out = [];
1496
+ for (let t = 0; t < prepared.length; t += 1) {
1497
+ const track = prepared[t];
1498
+ if (!track || isPercussion(track.role)) {
1499
+ continue;
1500
+ }
1501
+ for (let v = 0; v < track.voices.length; v += 1) {
1502
+ const note = track.voices[v]?.at(beat);
1503
+ if (note === void 0) {
1504
+ continue;
1505
+ }
1506
+ const snap = { pitch: note.pitch };
1507
+ const previous = Math.abs(note.startBeat - beat) <= EPS5 ? note.prevPitch : note.pitch;
1508
+ if (previous !== void 0) {
1509
+ snap.prevPitch = previous;
1510
+ }
1511
+ out.push({ track: t, voice: v, snapshot: snap });
1512
+ }
1513
+ }
1514
+ return out;
1515
+ }
1516
+ function otherVoicesSounding(prepared, excludeTrack, excludeVoice, beat, cache) {
1517
+ const sounding = cache.get(beat) ?? soundingVoicesAt(prepared, beat);
1518
+ cache.set(beat, sounding);
1519
+ return sounding.filter(({ track, voice }) => track !== excludeTrack || voice !== excludeVoice).map(({ snapshot }) => snapshot);
1520
+ }
1521
+ function firstSegmentAfter(timeline, beat) {
1522
+ let low = 0;
1523
+ let high = timeline.segments.length;
1524
+ while (low < high) {
1525
+ const middle = Math.floor((low + high) / 2);
1526
+ if ((timeline.segments[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat + EPS5) {
1527
+ low = middle + 1;
1528
+ } else {
1529
+ high = middle;
1530
+ }
1531
+ }
1532
+ return low;
1533
+ }
1534
+ function evaluationBeats(note, timeline) {
1535
+ const noteEnd = note.startBeat + note.durationBeat;
1536
+ const beats = [note.startBeat];
1537
+ for (let i = firstSegmentAfter(timeline, note.startBeat); i < timeline.segments.length; i += 1) {
1538
+ const start = timeline.segments[i]?.startBeat;
1539
+ if (start === void 0 || start >= noteEnd - EPS5) {
1540
+ break;
1541
+ }
1542
+ beats.push(start);
1543
+ }
1544
+ return beats;
1545
+ }
1546
+ function analyzeArrangement(tracks, opts = {}) {
1547
+ return analyzeArrangementWith(tracks, opts).analysis;
1548
+ }
1549
+ function analyzeArrangementWith(tracks, opts = {}, previous, dirty) {
1550
+ const meters = resolveMeters(opts, "arrangement meters");
1551
+ const budget = opts.budget;
1552
+ const noteOptions = { allowNonPositiveDuration: true, budget };
1553
+ if (opts.pickupBeats !== void 0) {
1554
+ assertRange(opts.pickupBeats, 0, Number.MAX_SAFE_INTEGER, "arrangement pickupBeats");
1555
+ noteOptions.minStartBeat = -opts.pickupBeats;
1556
+ }
1557
+ assertGenerationBudget(tracks.length, "arrangement tracks", budget);
1558
+ const noteCount = assertTrackNotes(tracks, noteOptions);
1559
+ assertGenerationBudget(noteCount, "arrangement notes", budget);
1560
+ const profile = arrangementProfile(opts.profile);
1561
+ const minSeverity = opts.minSeverity ?? 1 /* Warning */;
1562
+ const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks.length);
1563
+ const prepared = prepareTracks(tracks);
1564
+ const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
1565
+ assertGenerationBudget(noteCount * voiceCount, "arrangement note-voice comparisons", budget);
1566
+ const pooled = poolNotes(tracks, harmonyTracks);
1567
+ let evidence;
1568
+ let inferred;
1569
+ if (opts.timeline === void 0) {
1570
+ const run = analyzeTimeline(
1571
+ pooled,
1572
+ {
1573
+ key: opts.key,
1574
+ meters,
1575
+ pickupBeats: opts.pickupBeats,
1576
+ harmonicRhythm: opts.harmonicRhythm,
1577
+ budget
1578
+ },
1579
+ previous,
1580
+ dirty
1581
+ );
1582
+ evidence = run.evidence;
1583
+ inferred = withGivenKeys(run.result, opts.keys);
1584
+ } else {
1585
+ inferred = callerTimeline(opts.timeline, opts.key, opts.keys, pooled, meters, budget);
1586
+ }
1587
+ const { timeline, keys, prevailingKey, segmentConfidence } = inferred;
1588
+ const keyAt = keyLookup(keys, prevailingKey);
1589
+ const cadences = detectCadences(timeline, keyAt);
1590
+ const soundingCache = /* @__PURE__ */ new Map();
1591
+ const trackAnalyses = [];
1592
+ const conflicts = [];
1593
+ for (let t = 0; t < prepared.length; t += 1) {
1594
+ const track = prepared[t];
1595
+ if (!track) {
1596
+ continue;
1597
+ }
1598
+ if (isPercussion(track.role)) {
1599
+ trackAnalyses.push({ name: track.name, role: track.role, notes: [] });
1600
+ continue;
1601
+ }
1602
+ const notes = [];
1603
+ for (let v = 0; v < track.voices.length; v += 1) {
1604
+ const subVoice = track.voices[v];
1605
+ if (!subVoice) {
1606
+ continue;
1607
+ }
1608
+ const analyzed = analyzeVoice(
1609
+ subVoice.voice,
1610
+ timeline.at,
1611
+ keyAt,
1612
+ (beat) => otherVoicesSounding(prepared, t, v, beat, soundingCache)
1613
+ );
1614
+ for (const note of analyzed) {
1615
+ notes.push({ ...note, trackIndex: track.trackIndex });
1616
+ }
1617
+ const labelsById = new Map(analyzed.map((note) => [note.noteId, note.labels]));
1618
+ for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
1619
+ const note = subVoice.voice[noteIndex];
1620
+ const preparedNote = subVoice.sounding[noteIndex];
1621
+ if (note === void 0 || preparedNote === void 0) {
1622
+ continue;
1623
+ }
1624
+ for (const beat of evaluationBeats(note, timeline)) {
1625
+ const atOnset = Math.abs(beat - note.startBeat) <= EPS5;
1626
+ const safetyQuery = {
1627
+ profile,
1628
+ candidatePitch: note.pitch,
1629
+ prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
1630
+ chord: timeline.at(beat),
1631
+ key: keyAt(beat),
1632
+ otherVoices: otherVoicesSounding(prepared, t, v, beat, soundingCache),
1633
+ strongBeat: isStrongBeat(beat, meters)
1634
+ };
1635
+ let result = evaluateSafety(safetyQuery, { suggestions: false });
1636
+ if (result.safety !== 0 /* Safe */ && result.safety >= minSeverity) {
1637
+ result = evaluateSafety(safetyQuery);
1638
+ const conflict = {
1639
+ beat,
1640
+ trackName: track.name,
1641
+ trackIndex: track.trackIndex,
1642
+ noteId: note.id,
1643
+ pitch: note.pitch,
1644
+ labels: labelsById.get(note.id) ?? [],
1645
+ safety: result.safety,
1646
+ reasons: result.reasons
1647
+ };
1648
+ if (note.originalIndex !== void 0) {
1649
+ conflict.originalIndex = note.originalIndex;
1650
+ }
1651
+ if (result.rationale !== void 0) {
1652
+ conflict.rationale = result.rationale;
1653
+ }
1654
+ if (result.resolveTo !== void 0) {
1655
+ conflict.resolveTo = result.resolveTo;
1656
+ }
1657
+ if (result.suggestions !== void 0) {
1658
+ conflict.suggestions = [...result.suggestions];
1659
+ }
1660
+ conflicts.push(conflict);
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+ notes.sort((a, b) => a.noteId - b.noteId);
1666
+ trackAnalyses.push({ name: track.name, role: track.role, notes });
1667
+ }
1668
+ conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
1669
+ const analysis = {
1670
+ keys,
1671
+ prevailingKey,
1672
+ timeline,
1673
+ segmentConfidence,
1674
+ cadences,
1675
+ tracks: trackAnalyses,
1676
+ conflicts
1677
+ };
1678
+ return evidence === void 0 ? { analysis } : { analysis, evidence };
1679
+ }
1680
+
1681
+ // src/analyze/arrange/session.ts
1682
+ function noteId(note) {
1683
+ return `${note.pitch}|${note.startBeat}|${note.durationBeat}|${note.velocity ?? ""}`;
1684
+ }
1685
+ function sameOrder(before, after) {
1686
+ if (before.length !== after.length) {
1687
+ return false;
1688
+ }
1689
+ for (let index = 0; index < before.length; index += 1) {
1690
+ const previous = before[index];
1691
+ const current = after[index];
1692
+ if (previous === void 0 || current === void 0 || noteId(previous) !== noteId(current)) {
1693
+ return false;
1694
+ }
1695
+ }
1696
+ return true;
1697
+ }
1698
+ function editedSpan(before, after) {
1699
+ const counts = /* @__PURE__ */ new Map();
1700
+ const tally = (notes, sign) => {
1701
+ for (const note of notes) {
1702
+ const id = noteId(note);
1703
+ const entry = counts.get(id);
1704
+ if (entry === void 0) {
1705
+ counts.set(id, {
1706
+ count: sign,
1707
+ startBeat: note.startBeat,
1708
+ endBeat: note.startBeat + note.durationBeat
1709
+ });
1710
+ } else {
1711
+ entry.count += sign;
1712
+ }
1713
+ }
1714
+ };
1715
+ tally(before, 1);
1716
+ tally(after, -1);
1717
+ let startBeat = Number.POSITIVE_INFINITY;
1718
+ let endBeat = Number.NEGATIVE_INFINITY;
1719
+ for (const entry of counts.values()) {
1720
+ if (entry.count === 0) {
1721
+ continue;
1722
+ }
1723
+ startBeat = Math.min(startBeat, entry.startBeat);
1724
+ endBeat = Math.max(endBeat, entry.endBeat, entry.startBeat);
1725
+ }
1726
+ return startBeat <= endBeat ? { startBeat, endBeat } : null;
1727
+ }
1728
+ function unionSpan(a, b) {
1729
+ if (a === null) {
1730
+ return b;
1731
+ }
1732
+ if (b === null) {
1733
+ return a;
1734
+ }
1735
+ return {
1736
+ startBeat: Math.min(a.startBeat, b.startBeat),
1737
+ endBeat: Math.max(a.endBeat, b.endBeat)
1738
+ };
1739
+ }
1740
+ function sessionOf(tracks, opts, analysis, evidence) {
1741
+ return {
1742
+ tracks,
1743
+ analysis,
1744
+ update(edits) {
1745
+ assertGenerationBudget(edits.length, "arrangement edits", opts.budget);
1746
+ const next = [...tracks];
1747
+ let edited = null;
1748
+ let reordered = false;
1749
+ for (let index = 0; index < edits.length; index += 1) {
1750
+ const edit = edits[index];
1751
+ if (edit === void 0) {
1752
+ continue;
1753
+ }
1754
+ const target = next[edit.trackIndex];
1755
+ if (target === void 0) {
1756
+ throw new InvalidInputError(
1757
+ `edits[${index}].trackIndex ${edit.trackIndex} is outside the session's ${tracks.length} tracks`
1758
+ );
1759
+ }
1760
+ if (!Array.isArray(edit.notes)) {
1761
+ throw new InvalidInputError(
1762
+ `edits[${index}].notes must be an array; received ${typeof edit.notes}`
1763
+ );
1764
+ }
1765
+ assertNoteEvents(edit.notes, `edits[${index}].notes`, {
1766
+ allowNonPositiveDuration: true,
1767
+ budget: opts.budget
1768
+ });
1769
+ edited = unionSpan(edited, editedSpan(target.notes, edit.notes));
1770
+ reordered = reordered || !sameOrder(target.notes, edit.notes);
1771
+ next[edit.trackIndex] = { ...target, notes: edit.notes };
1772
+ }
1773
+ if (edited === null && !reordered) {
1774
+ return sessionOf(next, opts, analysis, evidence);
1775
+ }
1776
+ const dirty = evidence === void 0 ? void 0 : edited === null ? { from: 0, to: 0 } : dirtySlotsFor(evidence, edited.startBeat, edited.endBeat);
1777
+ const run = analyzeArrangementWith(next, opts, evidence, dirty);
1778
+ return sessionOf(next, opts, run.analysis, run.evidence);
1779
+ }
1780
+ };
1781
+ }
1782
+ function createArrangementSession(tracks, opts = {}) {
1783
+ const run = analyzeArrangementWith(tracks, opts);
1784
+ return sessionOf([...tracks], opts, run.analysis, run.evidence);
1785
+ }
1786
+
1787
+ // src/analyze/arrange/tension.ts
1788
+ var FUNCTION_TENSION = {
1789
+ tonic: 0,
1790
+ subdominant: 0.5,
1791
+ dominant: 1
1792
+ };
1793
+ var FUNCTION_WEIGHT = 0.5;
1794
+ var DISSONANCE_WEIGHT = 0.35;
1795
+ var SPAN_WEIGHT = 0.15;
1796
+ var SPAN_SATURATION = 24;
1797
+ function clamp01(value) {
1798
+ return Math.min(1, Math.max(0, value));
1799
+ }
1800
+ function tensionCurve(tracks, opts = {}) {
1801
+ const meters = resolveMeters(opts, "arrangement meters");
1802
+ const budget = opts.budget;
1803
+ const noteOptions = { allowNonPositiveDuration: true, budget };
1804
+ if (opts.pickupBeats !== void 0) {
1805
+ assertRange(opts.pickupBeats, 0, Number.MAX_SAFE_INTEGER, "arrangement pickupBeats");
1806
+ noteOptions.minStartBeat = -opts.pickupBeats;
1807
+ }
1808
+ assertGenerationBudget(tracks.length, "arrangement tracks", budget);
1809
+ const noteCount = assertTrackNotes(tracks, noteOptions);
1810
+ assertGenerationBudget(noteCount, "arrangement notes", budget);
1811
+ const profile = arrangementProfile(opts.profile);
1812
+ const step = opts.step ?? 1;
1813
+ assertRange(step, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "tension sampling step");
1814
+ const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks.length);
1815
+ const pooled = poolNotes(tracks, harmonyTracks);
1816
+ const all = poolNotes(tracks);
1817
+ const totalBeats = all.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1818
+ const firstOnset = all.reduce((first, n) => Math.min(first, n.startBeat), 0);
1819
+ const sampleStart = Math.min(0, Math.floor(firstOnset / step + EPS5) * step);
1820
+ const suppliedKeys = opts.timeline === void 0 ? void 0 : opts.keys ?? (opts.key !== void 0 ? [{ startBeat: 0, endBeat: totalBeats, key: opts.key, confidence: 1 }] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }));
1821
+ const { timeline, keys, prevailingKey } = opts.timeline === void 0 || suppliedKeys === void 0 ? chordTimelineFromNotes(pooled, {
1822
+ key: opts.key,
1823
+ meters,
1824
+ pickupBeats: opts.pickupBeats,
1825
+ harmonicRhythm: opts.harmonicRhythm,
1826
+ budget
1827
+ }) : {
1828
+ timeline: opts.timeline,
1829
+ keys: suppliedKeys,
1830
+ prevailingKey: prevailingKeyOf(suppliedKeys) ?? majorKey(0)
1831
+ };
1832
+ const regions = opts.keys ?? keys;
1833
+ const keyAt = keyLookup(regions, prevailingKeyOf(regions) ?? prevailingKey);
1834
+ const prepared = prepareTracks(tracks);
1835
+ const points = [];
1836
+ const sampleCount = Math.max(0, Math.ceil((totalBeats - sampleStart) / step - EPS5));
1837
+ assertGenerationBudget(sampleCount, "tension samples", budget);
1838
+ const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
1839
+ assertGenerationBudget(
1840
+ sampleCount * Math.min(128, voiceCount) * voiceCount,
1841
+ "tension sample-voice comparisons",
1842
+ budget
1843
+ );
1844
+ for (let i = 0; i < sampleCount; i += 1) {
1845
+ const beat = sampleStart + i * step;
1846
+ points.push({
1847
+ beat,
1848
+ tension: sampleTension(prepared, timeline, keyAt(beat), meters, profile, beat)
1849
+ });
1850
+ }
1851
+ return points;
1852
+ }
1853
+ function tensionCurveFrom(tracks, analysis, opts = {}) {
1854
+ return tensionCurve(tracks, {
1855
+ ...opts,
1856
+ timeline: analysis.timeline,
1857
+ keys: analysis.keys
1858
+ });
1859
+ }
1860
+ function allSounding(prepared, beat) {
1861
+ const out = [];
1862
+ for (let t = 0; t < prepared.length; t += 1) {
1863
+ const track = prepared[t];
1864
+ if (!track || isPercussion(track.role)) {
1865
+ continue;
1866
+ }
1867
+ for (const subVoice of track.voices) {
1868
+ const note = subVoice.at(beat);
1869
+ if (note !== void 0) {
1870
+ out.push({ pitch: note.pitch, track: t });
1871
+ }
1872
+ }
1873
+ }
1874
+ return out;
1875
+ }
1876
+ function sampleTension(prepared, timeline, key, meters, profile, beat) {
1877
+ const sounding = allSounding(prepared, beat);
1878
+ if (sounding.length === 0) {
1879
+ return 0;
1880
+ }
1881
+ const chord = timeline.at(beat);
1882
+ const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
1883
+ const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
1884
+ const strongBeat = isStrongBeat(beat, meters);
1885
+ let nonChord = 0;
1886
+ let dissonant = 0;
1887
+ const pitches = /* @__PURE__ */ new Map();
1888
+ for (const voice of sounding) {
1889
+ pitches.set(voice.pitch, (pitches.get(voice.pitch) ?? 0) + 1);
1890
+ }
1891
+ for (const [pitch, count] of pitches) {
1892
+ if (chordPcs && !chordPcs.has(pitchClassOf(pitch))) {
1893
+ nonChord += count;
1894
+ }
1895
+ const others = [...pitches].flatMap(
1896
+ ([otherPitch, otherCount]) => Array.from({ length: otherCount - (otherPitch === pitch ? 1 : 0) }, () => ({
1897
+ pitch: otherPitch
1898
+ }))
1899
+ );
1900
+ const result = evaluateSafety(
1901
+ {
1902
+ profile,
1903
+ candidatePitch: pitch,
1904
+ chord,
1905
+ key,
1906
+ otherVoices: others,
1907
+ strongBeat
1908
+ },
1909
+ { suggestions: false }
1910
+ );
1911
+ if (result.safety === 2 /* Dissonant */) {
1912
+ dissonant += count;
1913
+ }
1914
+ }
1915
+ const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
1916
+ let low = Number.POSITIVE_INFINITY;
1917
+ let high = Number.NEGATIVE_INFINITY;
1918
+ for (const voice of sounding) {
1919
+ low = Math.min(low, voice.pitch);
1920
+ high = Math.max(high, voice.pitch);
1921
+ }
1922
+ const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
1923
+ return clamp01(
1924
+ FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore
1925
+ );
1926
+ }
1927
+
1928
+ // src/analyze/form/internal.ts
1929
+ var EPS6 = 1e-9;
1930
+ function clamp012(value) {
1931
+ return Math.min(1, Math.max(0, value));
1932
+ }
1933
+ function floorMod(value, modulus) {
1934
+ return (value % modulus + modulus) % modulus;
1935
+ }
1936
+ function firstSoundingBeat(notes, fallback = 0) {
1937
+ let first = Number.POSITIVE_INFINITY;
1938
+ for (const note of notes) {
1939
+ first = Math.min(first, note.startBeat);
1940
+ }
1941
+ return Number.isFinite(first) ? first : fallback;
1942
+ }
1943
+ function lastBarOf(meter, spanStart, spanEnd) {
1944
+ const firstBar = barIndexAt(spanStart, meter);
1945
+ const bar = barIndexAt(Math.max(spanStart, spanEnd), meter);
1946
+ if (bar > firstBar && barPositionToBeat({ bar, beat: 0 }, meter) >= spanEnd - EPS6) {
1947
+ return bar - 1;
1948
+ }
1949
+ return bar;
1950
+ }
1951
+ function sliceBars(notes, meter, spanStart, spanEnd, budget) {
1952
+ const firstBar = barIndexAt(spanStart, meter);
1953
+ const lastBar = lastBarOf(meter, spanStart, spanEnd);
1954
+ const barCount = lastBar - firstBar + 1;
1955
+ assertGenerationBudget(barCount, "form bars", budget);
1956
+ const buckets = Array.from({ length: barCount }, () => []);
1957
+ let memberships = 0;
1958
+ for (const note of notes) {
1959
+ const noteEnd = note.startBeat + note.durationBeat;
1960
+ const from = Math.max(firstBar, barIndexAt(note.startBeat, meter));
1961
+ const to = Math.min(lastBar, barIndexAt(Math.max(note.startBeat, noteEnd - EPS6), meter));
1962
+ memberships += Math.max(0, to - from + 1);
1963
+ assertGenerationBudget(memberships, "form note-to-bar memberships", budget);
1964
+ for (let bar = from; bar <= to; bar += 1) {
1965
+ buckets[bar - firstBar]?.push(note);
1966
+ }
1967
+ }
1968
+ const slices = [];
1969
+ for (let bar = firstBar; bar <= lastBar; bar += 1) {
1970
+ const startBeat = barPositionToBeat({ bar, beat: 0 }, meter);
1971
+ const endBeat = barPositionToBeat({ bar: bar + 1, beat: 0 }, meter);
1972
+ const barNotes = buckets[bar - firstBar] ?? [];
1973
+ const { weights, totalWeight } = windowWeights(barNotes, startBeat, endBeat, meter);
1974
+ slices.push({ index: bar, startBeat, endBeat, notes: barNotes, weights, totalWeight });
1975
+ }
1976
+ return slices;
1977
+ }
1978
+ function weightSimilarity(a, b) {
1979
+ let dot = 0;
1980
+ let normA = 0;
1981
+ let normB = 0;
1982
+ for (let i = 0; i < a.length; i += 1) {
1983
+ const x = a[i] ?? 0;
1984
+ const y = b[i] ?? 0;
1985
+ dot += x * y;
1986
+ normA += x * x;
1987
+ normB += y * y;
1988
+ }
1989
+ if (normA <= EPS6 && normB <= EPS6) {
1990
+ return 1;
1991
+ }
1992
+ if (normA <= EPS6 || normB <= EPS6) {
1993
+ return 0;
1994
+ }
1995
+ return clamp012(dot / Math.sqrt(normA * normB));
1996
+ }
1997
+ function harmonicNovelty(slices, index) {
1998
+ const current = slices[index];
1999
+ if (current === void 0) {
2000
+ return 0;
2001
+ }
2002
+ const previous = slices[index - 1];
2003
+ if (previous === void 0) {
2004
+ return current.index === 0 ? 1 : 0;
2005
+ }
2006
+ return 1 - weightSimilarity(previous.weights, current.weights);
2007
+ }
2008
+ function combineEvidence(strengths) {
2009
+ let remaining = 1;
2010
+ for (const strength of strengths) {
2011
+ remaining *= 1 - clamp012(strength);
2012
+ }
2013
+ return clamp012(1 - remaining);
2014
+ }
2015
+
2016
+ // src/analyze/form/hypermeter.ts
2017
+ var GROUP_CANDIDATES = [2, 3, 4, 6, 8];
2018
+ var GROUP_PRIOR = /* @__PURE__ */ new Map([
2019
+ [2, 0.9],
2020
+ [3, 0.75],
2021
+ [4, 1],
2022
+ [6, 0.7],
2023
+ [8, 0.85]
2024
+ ]);
2025
+ var CONTRAST_SHARE = 0.65;
2026
+ var CADENCE_SHARE = 0.35;
2027
+ var GROUP_FINAL_FIT = 1;
2028
+ var ELIDED_FIT = 0.5;
2029
+ var MIN_GROUPS = 2;
2030
+ var MARGIN_FLOOR = 0.7;
2031
+ function accentContrast(novelty, slices, groupBars, phase) {
2032
+ let headSum = 0;
2033
+ let headCount = 0;
2034
+ let otherSum = 0;
2035
+ let otherCount = 0;
2036
+ for (let i = 0; i < slices.length; i += 1) {
2037
+ const slice = slices[i];
2038
+ if (slice === void 0) {
2039
+ continue;
2040
+ }
2041
+ const value = novelty[i] ?? 0;
2042
+ if (floorMod(slice.index - phase, groupBars) === 0) {
2043
+ headSum += value;
2044
+ headCount += 1;
2045
+ } else {
2046
+ otherSum += value;
2047
+ otherCount += 1;
2048
+ }
2049
+ }
2050
+ if (headCount === 0 || otherCount === 0) {
2051
+ return 0;
2052
+ }
2053
+ return headSum / headCount - otherSum / otherCount;
2054
+ }
2055
+ function cadenceFitOf(cadenceBars, groupBars, phase) {
2056
+ if (cadenceBars.length === 0) {
2057
+ return 0;
2058
+ }
2059
+ let fit = 0;
2060
+ for (const bar of cadenceBars) {
2061
+ const position = floorMod(bar - phase, groupBars);
2062
+ if (position === groupBars - 1) {
2063
+ fit += GROUP_FINAL_FIT;
2064
+ } else if (position === 0) {
2065
+ fit += ELIDED_FIT;
2066
+ }
2067
+ }
2068
+ return clamp012(fit / cadenceBars.length);
2069
+ }
2070
+ function undecidedReading(slices) {
2071
+ const groupBars = Math.max(1, slices.length);
2072
+ const first = slices[0];
2073
+ return {
2074
+ groupBars,
2075
+ downbeats: first === void 0 ? [] : [first.startBeat],
2076
+ confidence: 0,
2077
+ rationale: `Span of ${slices.length} bar(s) is too short to establish a grouping; read as one group`
2078
+ };
2079
+ }
2080
+ function hypermeter(notes, meter, opts = {}) {
2081
+ const meters = meter ?? resolveMeters({});
2082
+ assertNoteEvents(notes, "hypermeter notes", {
2083
+ allowNonPositiveDuration: true,
2084
+ budget: opts.budget
2085
+ });
2086
+ const sounding = notes.filter((note) => note.durationBeat > 0);
2087
+ const firstOnset = firstSoundingBeat(sounding);
2088
+ const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
2089
+ const spanEnd = opts.totalBeats ?? lastEnd;
2090
+ assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "hypermeter totalBeats");
2091
+ const spanStart = firstOnset < 0 ? gridOriginOf(firstOnset, beatsPerBarAt(firstOnset, meters)).startBeat : firstOnset;
2092
+ const slices = sliceBars(sounding, meters, spanStart, spanEnd, opts.budget);
2093
+ const candidates = GROUP_CANDIDATES.filter(
2094
+ (groupBars) => slices.length >= groupBars * MIN_GROUPS
2095
+ );
2096
+ if (candidates.length === 0) {
2097
+ return undecidedReading(slices);
2098
+ }
2099
+ assertGenerationBudget(
2100
+ candidates.reduce((sum, groupBars) => sum + groupBars * slices.length, 0),
2101
+ "hypermeter readings",
2102
+ opts.budget
2103
+ );
2104
+ const novelty = slices.map((_, index) => harmonicNovelty(slices, index));
2105
+ const cadenceBars = (opts.cadenceBeats ?? []).map((beat) => barIndexAt(beat, meters));
2106
+ const readings = [];
2107
+ for (const groupBars of candidates) {
2108
+ for (let phase = 0; phase < groupBars; phase += 1) {
2109
+ const contrast = accentContrast(novelty, slices, groupBars, phase);
2110
+ const cadenceFit = cadenceFitOf(cadenceBars, groupBars, phase);
2111
+ const evidence = clamp012(
2112
+ cadenceBars.length === 0 ? contrast : CONTRAST_SHARE * contrast + CADENCE_SHARE * cadenceFit
2113
+ );
2114
+ readings.push({
2115
+ groupBars,
2116
+ phase,
2117
+ contrast,
2118
+ cadenceFit,
2119
+ evidence,
2120
+ score: evidence * (GROUP_PRIOR.get(groupBars) ?? 0)
2121
+ });
2122
+ }
2123
+ }
2124
+ readings.sort(
2125
+ (a, b) => b.score - a.score || (GROUP_PRIOR.get(b.groupBars) ?? 0) - (GROUP_PRIOR.get(a.groupBars) ?? 0) || a.groupBars - b.groupBars || a.phase - b.phase
2126
+ );
2127
+ const best = readings[0];
2128
+ if (best === void 0) {
2129
+ return undecidedReading(slices);
2130
+ }
2131
+ const runnerUp = readings.find((reading) => reading.groupBars !== best.groupBars);
2132
+ const margin = runnerUp === void 0 || best.score <= EPS6 ? 1 : clamp012((best.score - runnerUp.score) / best.score);
2133
+ const downbeats = slices.filter((slice) => slice.index >= 0 && floorMod(slice.index - best.phase, best.groupBars) === 0).map((slice) => slice.startBeat);
2134
+ const cadencePart = cadenceBars.length === 0 ? "no cadences given" : `${Math.round(best.cadenceFit * 100)}% of cadences arriving at a group end`;
2135
+ return {
2136
+ groupBars: best.groupBars,
2137
+ downbeats,
2138
+ // Mostly the evidence, discounted when a rival grouping explains the
2139
+ // accents nearly as well. The discount is the smaller term because the
2140
+ // runner-up is nearly always a multiple or a divisor of the winner — four
2141
+ // bars nest inside eight — and a reading of the same accents at another
2142
+ // level is weaker counter-evidence than an unrelated rival would be.
2143
+ confidence: clamp012(best.evidence * (MARGIN_FLOOR + (1 - MARGIN_FLOOR) * margin)),
2144
+ rationale: `${best.groupBars}-bar groups from bar ${best.phase}: harmonic change ${Math.round(best.contrast * 100)}% stronger on group heads, ${cadencePart}`
2145
+ };
2146
+ }
2147
+
2148
+ // src/analyze/melody/index.ts
2149
+ var EPS7 = 1e-9;
2150
+ var RHYTHM_GRID = 64;
2151
+ var MIN_CELL_NOTES = 3;
2152
+ var MAX_CELL_NOTES = 8;
2153
+ var MIN_OCCURRENCES = 2;
2154
+ var SEQUENCE_GAP = 0.05;
2155
+ var INTERVAL_TOLERANCE = 6;
2156
+ var PITCH_WEIGHT = 0.75;
2157
+ var RHYTHM_WEIGHT = 0.25;
2158
+ var DIRECTIONAL_SHARE = 0.75;
2159
+ function phraseNotes(phrase) {
2160
+ return "notes" in phrase ? phrase.notes : phrase;
2161
+ }
2162
+ function orderedNotes(notes, name, budget) {
2163
+ assertNoteEvents(notes, name, { allowNonPositiveDuration: true, budget });
2164
+ return notes.filter((note) => note.durationBeat > 0).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
2165
+ }
2166
+ function intervalsOf(notes) {
2167
+ const out = [];
2168
+ for (let i = 1; i < notes.length; i += 1) {
2169
+ out.push((notes[i]?.pitch ?? 0) - (notes[i - 1]?.pitch ?? 0));
2170
+ }
2171
+ return out;
2172
+ }
2173
+ function onsetGaps(notes) {
2174
+ const out = [];
2175
+ for (let i = 1; i < notes.length; i += 1) {
2176
+ out.push((notes[i]?.startBeat ?? 0) - (notes[i - 1]?.startBeat ?? 0));
2177
+ }
2178
+ return out;
2179
+ }
2180
+ function rhythmProfile(gaps) {
2181
+ const unit = gaps[0] ?? 0;
2182
+ if (!(unit > EPS7)) {
2183
+ return gaps.length === 0 ? [] : null;
2184
+ }
2185
+ return gaps.map((gap) => Math.round(gap / unit * RHYTHM_GRID) / RHYTHM_GRID);
2186
+ }
2187
+ function sameNumbers(a, b) {
2188
+ if (a.length !== b.length) {
2189
+ return false;
2190
+ }
2191
+ for (let i = 0; i < a.length; i += 1) {
2192
+ if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > EPS7) {
2193
+ return false;
2194
+ }
2195
+ }
2196
+ return true;
2197
+ }
2198
+ function roundTo(value, digits) {
2199
+ const scale = 10 ** digits;
2200
+ return Math.round(value * scale) / scale;
2201
+ }
2202
+ function endBeatOf(notes) {
2203
+ let end = 0;
2204
+ for (const note of notes) {
2205
+ end = Math.max(end, note.startBeat + note.durationBeat);
2206
+ }
2207
+ return end;
2208
+ }
2209
+ function onsetSpan(notes) {
2210
+ if (notes.length < 2) {
2211
+ return 0;
2212
+ }
2213
+ return (notes[notes.length - 1]?.startBeat ?? 0) - (notes[0]?.startBeat ?? 0);
2214
+ }
2215
+ function spellingOf(key) {
2216
+ if (key === void 0) {
2217
+ return "sharp";
2218
+ }
2219
+ const spelled = spelledKeyOf(key);
2220
+ return keySignatureFifths(spelled.tonic, spelled.key) < 0 ? "flat" : "sharp";
2221
+ }
2222
+ function intervalBetween(from, to, key) {
2223
+ const spelling = spellingOf(key);
2224
+ return spelledInterval(midiToNote(from, spelling), midiToNote(to, spelling));
2225
+ }
2226
+ function intervalName(interval) {
2227
+ return `${interval.descending && interval.semitones !== 0 ? "-" : ""}${interval.quality}${interval.number}`;
2228
+ }
2229
+ function ladderIndices(notes, key) {
2230
+ const root = pitchClassOf(key.rootPc);
2231
+ const offsets = scaleTonesInDegreeOrder(key).map((pc) => pitchClassOf(pc - root));
2232
+ if (offsets.length === 0) {
2233
+ return null;
2234
+ }
2235
+ const out = [];
2236
+ for (const note of notes) {
2237
+ const semitones = note.pitch - root;
2238
+ const octave = Math.floor(semitones / 12);
2239
+ const degree = offsets.indexOf(semitones - octave * 12);
2240
+ if (degree < 0) {
2241
+ return null;
2242
+ }
2243
+ out.push(octave * offsets.length + degree);
2244
+ }
2245
+ return out;
2246
+ }
2247
+ function ladderSteps(indices) {
2248
+ const out = [];
2249
+ for (let i = 1; i < indices.length; i += 1) {
2250
+ out.push((indices[i] ?? 0) - (indices[i - 1] ?? 0));
2251
+ }
2252
+ return out;
2253
+ }
2254
+ function windowSignature(notes) {
2255
+ const profile = rhythmProfile(onsetGaps(notes));
2256
+ if (profile === null) {
2257
+ return null;
2258
+ }
2259
+ return `${notes.length}|${intervalsOf(notes).join(",")}|${profile.join(",")}`;
2260
+ }
2261
+ function nonOverlapping(starts, length) {
2262
+ const picked = [];
2263
+ let lastEnd = Number.NEGATIVE_INFINITY;
2264
+ for (const start of starts) {
2265
+ if (start > lastEnd) {
2266
+ picked.push(start);
2267
+ lastEnd = start + length - 1;
2268
+ }
2269
+ }
2270
+ return picked;
2271
+ }
2272
+ function subsumes(outer, inner) {
2273
+ return inner.starts.every(
2274
+ (start) => outer.starts.some((from) => start >= from && start + inner.length <= from + outer.length)
2275
+ );
2276
+ }
2277
+ function listBeats(occurrences) {
2278
+ const shown = occurrences.slice(0, 5).map((occurrence) => roundTo(occurrence.startBeat, 3));
2279
+ return occurrences.length > shown.length ? `${shown.join(", ")}, and ${occurrences.length - shown.length} more` : shown.join(", ");
2280
+ }
2281
+ function motifFromGroup(group, notes) {
2282
+ const first = group.starts[0] ?? 0;
2283
+ const prime = notes.slice(first, first + group.length).map((note) => ({ ...note }));
2284
+ const primePitch = prime[0]?.pitch ?? 0;
2285
+ const primeSpan = onsetSpan(prime);
2286
+ const occurrences = group.starts.map((start) => {
2287
+ const statement = notes.slice(start, start + group.length);
2288
+ const span = onsetSpan(statement);
2289
+ return {
2290
+ noteIndex: start,
2291
+ startBeat: statement[0]?.startBeat ?? 0,
2292
+ endBeat: endBeatOf(statement),
2293
+ transpose: (statement[0]?.pitch ?? 0) - primePitch,
2294
+ timeRatio: primeSpan > EPS7 ? span / primeSpan : 1
2295
+ };
2296
+ });
2297
+ const shape = melodicContour(prime).shape;
2298
+ const transposed = occurrences.some((occurrence) => occurrence.transpose !== 0);
2299
+ const stretched = occurrences.some((occurrence) => Math.abs(occurrence.timeRatio - 1) > EPS7);
2300
+ const detail = [
2301
+ transposed ? "at more than one pitch level" : "at the same pitch level",
2302
+ stretched ? "and in more than one set of note values" : ""
2303
+ ].filter((part) => part !== "").join(" ");
2304
+ return {
2305
+ notes: prime,
2306
+ intervals: intervalsOf(prime),
2307
+ rhythm: rhythmProfile(onsetGaps(prime)) ?? [],
2308
+ occurrences,
2309
+ rationale: `${group.length}-note ${shape} cell stated ${occurrences.length} ${occurrences.length === 1 ? "time" : "times"} (beats ${listBeats(occurrences)}), ${detail}`
2310
+ };
2311
+ }
2312
+ function extractMotifs(notes, opts = {}) {
2313
+ const minNotes = assertInteger(opts.minNotes ?? MIN_CELL_NOTES, "motif minNotes", 2, 64);
2314
+ const maxNotes = assertInteger(opts.maxNotes ?? MAX_CELL_NOTES, "motif maxNotes", 2, 64);
2315
+ if (maxNotes < minNotes) {
2316
+ throw new InvalidInputError(
2317
+ `motif maxNotes must be at least minNotes ${minNotes}; received ${maxNotes}`
2318
+ );
2319
+ }
2320
+ const minOccurrences = assertInteger(
2321
+ opts.minOccurrences ?? MIN_OCCURRENCES,
2322
+ "motif minOccurrences",
2323
+ 2
2324
+ );
2325
+ const sounding = orderedNotes(notes, "melody notes", opts.budget);
2326
+ if (sounding.length < minNotes) {
2327
+ return [];
2328
+ }
2329
+ assertGenerationBudget(sounding.length * (maxNotes - minNotes + 1), "motif windows", opts.budget);
2330
+ const groups = /* @__PURE__ */ new Map();
2331
+ for (let length = minNotes; length <= maxNotes; length += 1) {
2332
+ for (let start = 0; start + length <= sounding.length; start += 1) {
2333
+ const signature = windowSignature(sounding.slice(start, start + length));
2334
+ if (signature === null) {
2335
+ continue;
2336
+ }
2337
+ const group = groups.get(signature);
2338
+ if (group === void 0) {
2339
+ groups.set(signature, { signature, length, starts: [start] });
2340
+ } else {
2341
+ group.starts.push(start);
2342
+ }
2343
+ }
2344
+ }
2345
+ const candidates = [...groups.values()].map((group) => ({ ...group, starts: nonOverlapping(group.starts, group.length) })).filter((group) => group.starts.length >= minOccurrences).sort(
2346
+ (a, b) => b.length - a.length || b.starts.length - a.starts.length || (a.starts[0] ?? 0) - (b.starts[0] ?? 0) || (a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0)
2347
+ );
2348
+ const kept = [];
2349
+ for (const candidate of candidates) {
2350
+ const covered = kept.some(
2351
+ (outer) => outer.length > candidate.length && outer.starts.length >= candidate.starts.length && subsumes(outer, candidate)
2352
+ );
2353
+ if (!covered) {
2354
+ kept.push(candidate);
2355
+ }
2356
+ }
2357
+ return kept.map((group) => motifFromGroup(group, sounding));
2358
+ }
2359
+ function motifFromNotes(notes) {
2360
+ const sounding = orderedNotes(notes, "motif notes").map((note) => ({ ...note }));
2361
+ return motifFromGroup(
2362
+ { signature: windowSignature(sounding) ?? "", length: sounding.length, starts: [0] },
2363
+ sounding
2364
+ );
2365
+ }
2366
+ function pitchRelation(a, b) {
2367
+ if (sameNumbers(a, b)) {
2368
+ return "exact";
2369
+ }
2370
+ if (sameNumbers(
2371
+ b,
2372
+ a.map((step) => -step)
2373
+ )) {
2374
+ return "inversion";
2375
+ }
2376
+ const reversed = [...a].reverse();
2377
+ if (sameNumbers(
2378
+ b,
2379
+ reversed.map((step) => -step)
2380
+ )) {
2381
+ return "retrograde";
2382
+ }
2383
+ if (sameNumbers(b, reversed)) {
2384
+ return "retrogradeInversion";
2385
+ }
2386
+ return null;
2387
+ }
2388
+ function stretchPhrase(ratio) {
2389
+ return `note values ${roundTo(ratio, 3)}x the model's`;
2390
+ }
2391
+ function relateMotifs(a, b, key) {
2392
+ const model = a.notes;
2393
+ const answer = b.notes;
2394
+ if (model.length === 0 || model.length !== answer.length) {
2395
+ return null;
2396
+ }
2397
+ const modelGaps = onsetGaps(model);
2398
+ const answerGaps = onsetGaps(answer);
2399
+ const modelProfile = rhythmProfile(modelGaps);
2400
+ const answerProfile = rhythmProfile(answerGaps);
2401
+ const retroProfile = rhythmProfile([...answerGaps].reverse());
2402
+ if (modelProfile === null || answerProfile === null) {
2403
+ return null;
2404
+ }
2405
+ const forward = sameNumbers(modelProfile, answerProfile);
2406
+ const backward = retroProfile !== null && sameNumbers(modelProfile, retroProfile);
2407
+ const modelSpan = onsetSpan(model);
2408
+ const timeRatio = modelSpan > EPS7 ? onsetSpan(answer) / modelSpan : 1;
2409
+ const semitones = (answer[0]?.pitch ?? 0) - (model[0]?.pitch ?? 0);
2410
+ const interval = intervalBetween(model[0]?.pitch ?? 0, answer[0]?.pitch ?? 0, key);
2411
+ const gap = (answer[0]?.startBeat ?? 0) - endBeatOf(model);
2412
+ const sequence = gap >= -SEQUENCE_GAP && gap <= SEQUENCE_GAP;
2413
+ const stretched = Math.abs(timeRatio - 1) > EPS7;
2414
+ const relation = pitchRelation(intervalsOf(model), intervalsOf(answer));
2415
+ const base = { sequence, semitones, interval, timeRatio };
2416
+ if (forward && relation === "exact") {
2417
+ if (semitones === 0) {
2418
+ if (!stretched) {
2419
+ return {
2420
+ ...base,
2421
+ kind: "repetition",
2422
+ rationale: "Exact repetition: the same pitches in the same rhythm"
2423
+ };
2424
+ }
2425
+ const augmented = timeRatio > 1;
2426
+ return {
2427
+ ...base,
2428
+ kind: augmented ? "augmentation" : "diminution",
2429
+ rationale: `${augmented ? "Augmentation" : "Diminution"}: the same pitches in ${stretchPhrase(timeRatio)}`
2430
+ };
2431
+ }
2432
+ const name = intervalName(interval);
2433
+ return {
2434
+ ...base,
2435
+ kind: "transposition",
2436
+ rationale: sequence ? `Real sequence: the cell restated ${name} away with every interval kept literally` + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "") : `Transposition by ${name}: every interval kept literally` + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
2437
+ };
2438
+ }
2439
+ if (forward && key !== void 0) {
2440
+ const modelLadder = ladderIndices(model, key);
2441
+ const answerLadder = ladderIndices(answer, key);
2442
+ if (modelLadder !== null && answerLadder !== null) {
2443
+ const degrees = (answerLadder[0] ?? 0) - (modelLadder[0] ?? 0);
2444
+ if (degrees !== 0 && sameNumbers(ladderSteps(modelLadder), ladderSteps(answerLadder))) {
2445
+ const direction = degrees > 0 ? "higher" : "lower";
2446
+ const count = Math.abs(degrees);
2447
+ return {
2448
+ ...base,
2449
+ kind: "tonalTransposition",
2450
+ degrees,
2451
+ rationale: `${sequence ? "Tonal sequence" : "Tonal transposition"}: the cell restated ${count} scale ${count === 1 ? "degree" : "degrees"} ${direction} inside the key, so the key resizes its intervals \u2014 a real one would have kept them literal`
2452
+ };
2453
+ }
2454
+ }
2455
+ }
2456
+ if (forward && relation === "inversion") {
2457
+ return {
2458
+ ...base,
2459
+ kind: "inversion",
2460
+ rationale: "Inversion: every interval turned upside down about the first note" + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
2461
+ };
2462
+ }
2463
+ if (backward && relation === "retrograde") {
2464
+ return {
2465
+ ...base,
2466
+ kind: "retrograde",
2467
+ rationale: "Retrograde: the cell read back to front, rhythm included"
2468
+ };
2469
+ }
2470
+ if (backward && relation === "retrogradeInversion") {
2471
+ return {
2472
+ ...base,
2473
+ kind: "retrogradeInversion",
2474
+ rationale: "Retrograde inversion: the cell read back to front and turned upside down"
2475
+ };
2476
+ }
2477
+ return null;
2478
+ }
2479
+ function gradedEditDistance(a, b, cost) {
2480
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
2481
+ for (let i = 1; i <= a.length; i += 1) {
2482
+ const row = new Array(b.length + 1);
2483
+ row[0] = i;
2484
+ for (let j = 1; j <= b.length; j += 1) {
2485
+ const substitution = (previous[j - 1] ?? 0) + cost(a[i - 1] ?? 0, b[j - 1] ?? 0);
2486
+ const deletion = (previous[j] ?? 0) + 1;
2487
+ const insertion = (row[j - 1] ?? 0) + 1;
2488
+ row[j] = Math.min(substitution, deletion, insertion);
2489
+ }
2490
+ previous = row;
2491
+ }
2492
+ return previous[b.length] ?? 0;
2493
+ }
2494
+ function distanceToSimilarity(distance, a, b) {
2495
+ const longest = Math.max(a, b);
2496
+ if (longest === 0) {
2497
+ return 1;
2498
+ }
2499
+ return Math.min(1, Math.max(0, 1 - distance / longest));
2500
+ }
2501
+ function intervalCost(x, y) {
2502
+ return Math.min(1, Math.abs(x - y) / INTERVAL_TOLERANCE);
2503
+ }
2504
+ function gapCost(x, y) {
2505
+ if (!(x > EPS7) || !(y > EPS7)) {
2506
+ return Math.abs(x - y) > EPS7 ? 1 : 0;
2507
+ }
2508
+ return Math.min(1, Math.abs(Math.log2(x / y)));
2509
+ }
2510
+ function compareMelodies(a, b) {
2511
+ const left = orderedNotes(phraseNotes(a), "melody a");
2512
+ const right = orderedNotes(phraseNotes(b), "melody b");
2513
+ const leftIntervals = intervalsOf(left);
2514
+ const rightIntervals = intervalsOf(right);
2515
+ const pitchSimilarity = distanceToSimilarity(
2516
+ gradedEditDistance(leftIntervals, rightIntervals, intervalCost),
2517
+ leftIntervals.length,
2518
+ rightIntervals.length
2519
+ );
2520
+ const leftGaps = rhythmProfile(onsetGaps(left)) ?? onsetGaps(left);
2521
+ const rightGaps = rhythmProfile(onsetGaps(right)) ?? onsetGaps(right);
2522
+ const rhythmSimilarity = distanceToSimilarity(
2523
+ gradedEditDistance(leftGaps, rightGaps, gapCost),
2524
+ leftGaps.length,
2525
+ rightGaps.length
2526
+ );
2527
+ const similarity = PITCH_WEIGHT * pitchSimilarity + RHYTHM_WEIGHT * rhythmSimilarity;
2528
+ return {
2529
+ similarity,
2530
+ pitchSimilarity,
2531
+ rhythmSimilarity,
2532
+ rationale: `Interval shapes ${Math.round(pitchSimilarity * 100)}% alike, rhythms ${Math.round(rhythmSimilarity * 100)}% alike, weighted ${PITCH_WEIGHT}/${RHYTHM_WEIGHT} into ${roundTo(similarity, 3)}`
2533
+ };
2534
+ }
2535
+ function melodicSimilarity(a, b) {
2536
+ return compareMelodies(a, b).similarity;
2537
+ }
2538
+ function extremes(notes) {
2539
+ let peak = 0;
2540
+ let trough = 0;
2541
+ for (let i = 1; i < notes.length; i += 1) {
2542
+ if ((notes[i]?.pitch ?? 0) > (notes[peak]?.pitch ?? 0)) {
2543
+ peak = i;
2544
+ }
2545
+ if ((notes[i]?.pitch ?? 0) < (notes[trough]?.pitch ?? 0)) {
2546
+ trough = i;
2547
+ }
2548
+ }
2549
+ return { peak, trough };
2550
+ }
2551
+ function melodicContour(notes) {
2552
+ const line = orderedNotes(phraseNotes(notes), "contour notes");
2553
+ const intervals = intervalsOf(line);
2554
+ const directions = intervals.map(
2555
+ (step) => step > 0 ? "up" : step < 0 ? "down" : "same"
2556
+ );
2557
+ const { peak, trough } = extremes(line);
2558
+ const range = (line[peak]?.pitch ?? 0) - (line[trough]?.pitch ?? 0);
2559
+ const motion = intervals.reduce((sum, step) => sum + Math.abs(step), 0);
2560
+ if (motion === 0) {
2561
+ return {
2562
+ directions,
2563
+ shape: "static",
2564
+ peakIndex: peak,
2565
+ troughIndex: trough,
2566
+ range: 0,
2567
+ rationale: line.length < 2 ? "Too short to have a direction, so the line is static" : "Every note at the same pitch, so the line is static"
2568
+ };
2569
+ }
2570
+ const first = line[0]?.pitch ?? 0;
2571
+ const last = line[line.length - 1]?.pitch ?? 0;
2572
+ const net = last - first;
2573
+ const directness = Math.abs(net) / motion;
2574
+ const share = Math.round(directness * 100);
2575
+ if (directness >= DIRECTIONAL_SHARE) {
2576
+ const shape = net > 0 ? "ascending" : "descending";
2577
+ return {
2578
+ directions,
2579
+ shape,
2580
+ peakIndex: peak,
2581
+ troughIndex: trough,
2582
+ range,
2583
+ rationale: `${share}% of the motion runs ${net > 0 ? "up" : "down"}, so the line is ${shape}`
2584
+ };
2585
+ }
2586
+ const peakPitch = line[peak]?.pitch ?? 0;
2587
+ const archMotion = peakPitch - first + (peakPitch - last);
2588
+ if (peak > 0 && peak < line.length - 1 && archMotion >= DIRECTIONAL_SHARE * motion) {
2589
+ return {
2590
+ directions,
2591
+ shape: "arch",
2592
+ peakIndex: peak,
2593
+ troughIndex: trough,
2594
+ range,
2595
+ rationale: `Rises to its peak at note ${peak + 1} of ${line.length} and falls back, so the line is an arch`
2596
+ };
2597
+ }
2598
+ return {
2599
+ directions,
2600
+ shape: "wave",
2601
+ peakIndex: peak,
2602
+ troughIndex: trough,
2603
+ range,
2604
+ rationale: `Changes direction without settling either way (${share}% of the motion nets out), so the line is a wave`
2605
+ };
2606
+ }
2607
+
2608
+ // src/analyze/form/phrase.ts
2609
+ var CADENCE_BOUNDARY_STRENGTH = {
2610
+ authentic: 0.9,
2611
+ modal: 0.75,
2612
+ plagal: 0.7,
2613
+ phrygian: 0.65,
2614
+ half: 0.6,
2615
+ deceptive: 0.45
2616
+ };
2617
+ var CADENCE_CLOSURE = {
2618
+ authentic: 0.85,
2619
+ modal: 0.6,
2620
+ plagal: 0.55,
2621
+ phrygian: 0.4,
2622
+ half: 0.35,
2623
+ deceptive: 0.3
2624
+ };
2625
+ var REST_STRENGTH = 0.6;
2626
+ var LONG_NOTE_STRENGTH = 0.4;
2627
+ var REPETITION_STRENGTH = 0.5;
2628
+ var REPETITION_THRESHOLD = 0.75;
2629
+ var HYPERMETER_STRENGTH = 0.25;
2630
+ var PIECE_END_STRENGTH = 0.5;
2631
+ var PHRASE_CUT_COST = 1.3;
2632
+ var CLOSING_SHARE = 0.65;
2633
+ var FINAL_PHRASE_BONUS = 0.15;
2634
+ var HYPERMETRIC_CLOSE_BONUS = 0.1;
2635
+ var EVADED_FACTOR = 0.6;
2636
+ var SIGNAL_ORDER = [
2637
+ "cadence",
2638
+ "rest",
2639
+ "repetition",
2640
+ "longNote",
2641
+ "pieceEnd",
2642
+ "hypermeter"
2643
+ ];
2644
+ function addSignal(boundaries, beat, signal, strength, cadence = null) {
2645
+ const key = Math.round(beat / EPS6);
2646
+ const existing = boundaries.get(key);
2647
+ const boundary = existing ?? { beat, strengths: /* @__PURE__ */ new Map(), cadence: null };
2648
+ boundary.strengths.set(signal, Math.max(boundary.strengths.get(signal) ?? 0, strength));
2649
+ if (cadence !== null && boundary.cadence === null) {
2650
+ boundary.cadence = cadence;
2651
+ }
2652
+ boundaries.set(key, boundary);
2653
+ }
2654
+ function readBoundary(boundary) {
2655
+ const signals = [...boundary.strengths.entries()].sort(
2656
+ (a, b) => b[1] - a[1] || SIGNAL_ORDER.indexOf(a[0]) - SIGNAL_ORDER.indexOf(b[0])
2657
+ );
2658
+ return {
2659
+ strength: combineEvidence(signals.map(([, strength]) => strength)),
2660
+ signals: signals.map(([signal]) => signal)
2661
+ };
2662
+ }
2663
+ function lengthFit(length, expected) {
2664
+ if (!(expected > 0)) {
2665
+ return 0;
2666
+ }
2667
+ return Math.max(-1, 1 - Math.abs(length - expected) / expected);
2668
+ }
2669
+ function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
2670
+ const value = new Array(beats.length).fill(Number.NEGATIVE_INFINITY);
2671
+ const cameFrom = new Array(beats.length).fill(-1);
2672
+ value[0] = 0;
2673
+ for (let j = 1; j < beats.length; j += 1) {
2674
+ const endBeat = beats[j] ?? 0;
2675
+ for (let i = 0; i < j; i += 1) {
2676
+ if (!Number.isFinite(value[i] ?? Number.NEGATIVE_INFINITY)) {
2677
+ continue;
2678
+ }
2679
+ const length = endBeat - (beats[i] ?? 0);
2680
+ if (length < minPhraseBeats - EPS6) {
2681
+ continue;
2682
+ }
2683
+ const gain = (value[i] ?? 0) + (strengths[j] ?? 0) + lengthFit(length, expected) - PHRASE_CUT_COST;
2684
+ if (gain > (value[j] ?? Number.NEGATIVE_INFINITY)) {
2685
+ value[j] = gain;
2686
+ cameFrom[j] = i;
2687
+ }
2688
+ }
2689
+ }
2690
+ const last = beats.length - 1;
2691
+ const path = [];
2692
+ if (last > 0 && Number.isFinite(value[last] ?? Number.NEGATIVE_INFINITY)) {
2693
+ for (let i = last; i > 0; i = cameFrom[i] ?? 0) {
2694
+ path.push(i);
2695
+ }
2696
+ path.reverse();
2697
+ } else if (last > 0) {
2698
+ path.push(last);
2699
+ }
2700
+ return path;
2701
+ }
2702
+ function arrivalEnd(timeline, atBeat) {
2703
+ for (const segment of timeline.segments) {
2704
+ if (Math.abs(segment.startBeat - atBeat) < EPS6) {
2705
+ return segment.endBeat;
2706
+ }
2707
+ }
2708
+ return atBeat;
2709
+ }
2710
+ function resolveKey(opts, notes, meters, totalBeats) {
2711
+ if (opts.key !== void 0) {
2712
+ return opts.key;
2713
+ }
2714
+ const regions = keyTimelineFromNotes(notes, {
2715
+ meters,
2716
+ totalBeats,
2717
+ budget: opts.budget
2718
+ });
2719
+ return keyLookup(regions, prevailingKeyOf(regions) ?? majorKey(0));
2720
+ }
2721
+ function restBoundaries(boundaries, notes, meters, restBeats) {
2722
+ const ordered = [...notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
2723
+ let covered = Number.NEGATIVE_INFINITY;
2724
+ for (const note of ordered) {
2725
+ const gap = note.startBeat - covered;
2726
+ if (Number.isFinite(covered) && gap >= restBeats - EPS6) {
2727
+ const bar = beatsPerBarAt(note.startBeat, meters);
2728
+ addSignal(
2729
+ boundaries,
2730
+ note.startBeat,
2731
+ "rest",
2732
+ REST_STRENGTH * clamp012(bar > 0 ? gap / bar : 1)
2733
+ );
2734
+ }
2735
+ covered = Math.max(covered, note.startBeat + note.durationBeat);
2736
+ }
2737
+ }
2738
+ function repetitionBoundaries(boundaries, notes, meters, spanStart, spanEnd, windowBeats, budget) {
2739
+ if (!(windowBeats > 0)) {
2740
+ return;
2741
+ }
2742
+ const slices = sliceBars(notes, meters, spanStart, spanEnd, budget);
2743
+ assertGenerationBudget(slices.length * notes.length, "form repetition comparisons", budget);
2744
+ const onsetsIn = (from, to) => notes.filter((note) => note.startBeat >= from - EPS6 && note.startBeat < to - EPS6);
2745
+ for (const slice of slices) {
2746
+ const beat = slice.startBeat;
2747
+ if (beat - windowBeats < spanStart - EPS6 || beat + windowBeats > spanEnd + EPS6) {
2748
+ continue;
2749
+ }
2750
+ const before = onsetsIn(beat - windowBeats, beat);
2751
+ const after = onsetsIn(beat, beat + windowBeats);
2752
+ if (before.length < 2 || after.length < 2) {
2753
+ continue;
2754
+ }
2755
+ const similarity = melodicSimilarity(before, after);
2756
+ if (similarity >= REPETITION_THRESHOLD) {
2757
+ addSignal(boundaries, beat, "repetition", REPETITION_STRENGTH * similarity);
2758
+ }
2759
+ }
2760
+ }
2761
+ function structuralWeightOf(cadence, confidence, aligned, isLast) {
2762
+ if (cadence === null || cadence.cadence.type === null) {
2763
+ return 0;
2764
+ }
2765
+ const result = cadence.cadence;
2766
+ let base = CADENCE_CLOSURE[result.type];
2767
+ if (result.type === "authentic") {
2768
+ if (result.strength === "perfect") {
2769
+ base = 1;
2770
+ } else if (result.strength === "imperfect") {
2771
+ base *= 0.8;
2772
+ }
2773
+ }
2774
+ if (result.evaded) {
2775
+ base *= EVADED_FACTOR;
2776
+ }
2777
+ let weight = base * confidence;
2778
+ if (isLast) {
2779
+ weight += FINAL_PHRASE_BONUS;
2780
+ }
2781
+ if (aligned || isLast) {
2782
+ weight += HYPERMETRIC_CLOSE_BONUS;
2783
+ }
2784
+ return clamp012(weight);
2785
+ }
2786
+ function describeCadence(cadence) {
2787
+ if (cadence === null || cadence.cadence.type === null) {
2788
+ return "no cadence";
2789
+ }
2790
+ const { type, strength } = cadence.cadence;
2791
+ const graded = strength === null ? type : `${strength} ${type}`;
2792
+ return `a ${graded} cadence at beat ${cadence.atBeat}`;
2793
+ }
2794
+ function phrasesFromTimeline(timeline, notes, opts = {}) {
2795
+ const meters = resolveMeters(opts, "phrase meters");
2796
+ assertNoteEvents(notes, "phrase notes", {
2797
+ allowNonPositiveDuration: true,
2798
+ budget: opts.budget
2799
+ });
2800
+ const sounding = notes.filter((note) => note.durationBeat > 0);
2801
+ const segments = timeline.segments;
2802
+ const firstSegment = segments[0];
2803
+ const lastSegment = segments[segments.length - 1];
2804
+ const spanStart = sounding.reduce(
2805
+ (first, n) => Math.min(first, n.startBeat),
2806
+ firstSegment?.startBeat ?? 0
2807
+ );
2808
+ const notesEnd = sounding.reduce(
2809
+ (end, n) => Math.max(end, n.startBeat + n.durationBeat),
2810
+ lastSegment?.endBeat ?? 0
2811
+ );
2812
+ const spanEnd = Math.max(spanStart, opts.totalBeats ?? notesEnd);
2813
+ assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "phrase totalBeats");
2814
+ const barBeats = beatsPerBarAt(Math.max(0, spanStart), meters);
2815
+ const restBeats = opts.restBeats ?? barBeats / 2;
2816
+ assertRange(restBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase restBeats");
2817
+ const longNoteBeats = opts.longNoteBeats ?? barBeats;
2818
+ assertRange(longNoteBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase longNoteBeats");
2819
+ const minPhraseBeats = opts.minPhraseBeats ?? barBeats;
2820
+ assertRange(minPhraseBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase minPhraseBeats");
2821
+ const key = resolveKey(opts, sounding, meters, spanEnd);
2822
+ const cadences = detectCadences(timeline, key);
2823
+ const grouping = opts.hypermeter ?? hypermeter(sounding, meters, {
2824
+ totalBeats: spanEnd,
2825
+ cadenceBeats: cadences.map((hit) => hit.atBeat),
2826
+ budget: opts.budget
2827
+ });
2828
+ const hyperBeats = Math.max(
2829
+ barBeats,
2830
+ barPositionToBeat({ bar: grouping.groupBars, beat: 0 }, meters) - barPositionToBeat({ bar: 0, beat: 0 }, meters)
2831
+ );
2832
+ const expected = opts.expectedPhraseBeats ?? hyperBeats;
2833
+ assertRange(expected, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase expectedPhraseBeats");
2834
+ const boundaries = /* @__PURE__ */ new Map();
2835
+ for (const hit of cadences) {
2836
+ const type = hit.cadence.type;
2837
+ if (type === null) {
2838
+ continue;
2839
+ }
2840
+ addSignal(
2841
+ boundaries,
2842
+ arrivalEnd(timeline, hit.atBeat),
2843
+ "cadence",
2844
+ CADENCE_BOUNDARY_STRENGTH[type],
2845
+ hit
2846
+ );
2847
+ }
2848
+ restBoundaries(boundaries, sounding, meters, restBeats);
2849
+ for (const note of sounding) {
2850
+ if (note.durationBeat >= longNoteBeats - EPS6) {
2851
+ addSignal(boundaries, note.startBeat + note.durationBeat, "longNote", LONG_NOTE_STRENGTH);
2852
+ }
2853
+ }
2854
+ repetitionBoundaries(boundaries, sounding, meters, spanStart, spanEnd, hyperBeats, opts.budget);
2855
+ for (const downbeat of grouping.downbeats) {
2856
+ addSignal(boundaries, downbeat, "hypermeter", HYPERMETER_STRENGTH * grouping.confidence);
2857
+ }
2858
+ addSignal(boundaries, spanEnd, "pieceEnd", PIECE_END_STRENGTH);
2859
+ const candidates = [...boundaries.values()].filter((boundary) => boundary.beat > spanStart + EPS6 && boundary.beat < spanEnd - EPS6).sort((a, b) => a.beat - b.beat);
2860
+ const endBoundary = boundaries.get(Math.round(spanEnd / EPS6)) ?? {
2861
+ beat: spanEnd,
2862
+ strengths: /* @__PURE__ */ new Map(),
2863
+ cadence: null
2864
+ };
2865
+ const stops = [
2866
+ { beat: spanStart, strengths: /* @__PURE__ */ new Map(), cadence: null },
2867
+ ...candidates,
2868
+ endBoundary
2869
+ ];
2870
+ assertGenerationBudget(stops.length * stops.length, "form phrase boundary pairs", opts.budget);
2871
+ const readings = stops.map(readBoundary);
2872
+ const path = choosePhrasePath(
2873
+ stops.map((stop) => stop.beat),
2874
+ readings.map((reading) => reading.strength),
2875
+ minPhraseBeats,
2876
+ expected
2877
+ );
2878
+ const downbeatKeys = new Set(grouping.downbeats.map((downbeat) => Math.round(downbeat / EPS6)));
2879
+ const phrases = [];
2880
+ let from = 0;
2881
+ for (let n = 0; n < path.length; n += 1) {
2882
+ const to = path[n] ?? stops.length - 1;
2883
+ const startBeat = stops[from]?.beat ?? spanStart;
2884
+ const stop = stops[to];
2885
+ const endBeat = stop?.beat ?? spanEnd;
2886
+ const reading = readings[to] ?? { strength: 0, signals: [] };
2887
+ const length = endBeat - startBeat;
2888
+ const confidence = clamp012(
2889
+ CLOSING_SHARE * reading.strength + (1 - CLOSING_SHARE) * Math.max(0, lengthFit(length, expected))
2890
+ );
2891
+ const isLast = n === path.length - 1;
2892
+ const cadence = stop?.cadence ?? null;
2893
+ const structuralWeight = structuralWeightOf(
2894
+ cadence,
2895
+ confidence,
2896
+ downbeatKeys.has(Math.round(endBeat / EPS6)),
2897
+ isLast
2898
+ );
2899
+ const bars = barBeats > 0 ? Math.round(length / barBeats * 100) / 100 : 0;
2900
+ phrases.push({
2901
+ startBeat,
2902
+ endBeat,
2903
+ cadence,
2904
+ signals: reading.signals,
2905
+ confidence,
2906
+ structuralWeight,
2907
+ rationale: `Phrase of ${bars} bar(s) closing on ${describeCadence(cadence)}; boundary from ${reading.signals.length === 0 ? "the end of the span" : reading.signals.join(", ")}`
2908
+ });
2909
+ from = to;
2910
+ }
2911
+ return phrases;
2912
+ }
2913
+ function structuralCadences(phrases) {
2914
+ const ranked = [];
2915
+ for (let index = 0; index < phrases.length; index += 1) {
2916
+ const phrase = phrases[index];
2917
+ if (phrase === void 0 || phrase.cadence === null || phrase.cadence.cadence.type === null) {
2918
+ continue;
2919
+ }
2920
+ const hit = phrase.cadence;
2921
+ ranked.push({
2922
+ phraseIndex: index,
2923
+ atBeat: hit.atBeat,
2924
+ endBeat: phrase.endBeat,
2925
+ cadence: hit.cadence,
2926
+ weight: phrase.structuralWeight,
2927
+ rationale: `Closes ${describeCadence(hit)} on a phrase of confidence ${Math.round(phrase.confidence * 100) / 100}`
2928
+ });
2929
+ }
2930
+ return ranked.sort((a, b) => b.weight - a.weight || a.atBeat - b.atBeat);
2931
+ }
2932
+
2933
+ // src/analyze/form/section.ts
2934
+ var MELODY_SHARE = 0.6;
2935
+ var HARMONY_SHARE = 0.4;
2936
+ var DEFAULT_THRESHOLD = 0.8;
2937
+ function labelAt(index) {
2938
+ let n = index;
2939
+ let label = "";
2940
+ do {
2941
+ label = String.fromCharCode(65 + n % 26) + label;
2942
+ n = Math.floor(n / 26) - 1;
2943
+ } while (n >= 0);
2944
+ return label;
2945
+ }
2946
+ function unitSimilarity(a, b) {
2947
+ const harmony = weightSimilarity(a.weights, b.weights);
2948
+ if (a.notes.length < 2 || b.notes.length < 2) {
2949
+ return a.notes.length === b.notes.length ? harmony : 0;
2950
+ }
2951
+ const melody = melodicSimilarity(a.notes, b.notes);
2952
+ return clamp012(MELODY_SHARE * melody + HARMONY_SHARE * harmony);
2953
+ }
2954
+ function sectionsFromNotes(notes, opts = {}) {
2955
+ const meters = resolveMeters(opts, "section meters");
2956
+ assertNoteEvents(notes, "section notes", {
2957
+ allowNonPositiveDuration: true,
2958
+ budget: opts.budget
2959
+ });
2960
+ const sounding = notes.filter((note) => note.durationBeat > 0);
2961
+ if (sounding.length === 0) {
2962
+ return [];
2963
+ }
2964
+ const firstOnset = firstSoundingBeat(sounding);
2965
+ const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
2966
+ const spanEnd = Math.max(firstOnset, opts.totalBeats ?? lastEnd);
2967
+ assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "section totalBeats");
2968
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
2969
+ assertRange(threshold, 0, 1, "section threshold");
2970
+ const unitBars = opts.unitBars ?? hypermeter(sounding, meters, { totalBeats: spanEnd, budget: opts.budget }).groupBars;
2971
+ assertPositiveInt(unitBars, "section unitBars");
2972
+ const firstBar = barIndexAt(firstOnset, meters);
2973
+ const lastBar = lastBarOf(meters, firstOnset, spanEnd);
2974
+ const startBar = Math.max(0, firstBar);
2975
+ const unitCount = Math.max(1, Math.ceil((lastBar - startBar + 1) / unitBars));
2976
+ assertGenerationBudget(unitCount * unitCount, "form section comparisons", opts.budget);
2977
+ assertGenerationBudget(unitCount * sounding.length, "form section unit notes", opts.budget);
2978
+ const units = [];
2979
+ for (let index = 0; index < unitCount; index += 1) {
2980
+ const barStart = startBar + index * unitBars;
2981
+ const startBeat = index === 0 ? firstOnset : barPositionToBeat({ bar: barStart, beat: 0 }, meters);
2982
+ const endBeat = index === unitCount - 1 ? spanEnd : barPositionToBeat({ bar: barStart + unitBars, beat: 0 }, meters);
2983
+ const unitNotes = sounding.filter((note) => note.startBeat >= startBeat - EPS6 && note.startBeat < endBeat - EPS6).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
2984
+ units.push({
2985
+ startBeat,
2986
+ endBeat,
2987
+ notes: unitNotes,
2988
+ weights: windowWeights(unitNotes, startBeat, endBeat, meters).weights
2989
+ });
2990
+ }
2991
+ const labels = [];
2992
+ const scores = [];
2993
+ let distinct = 0;
2994
+ for (let index = 0; index < units.length; index += 1) {
2995
+ const unit = units[index];
2996
+ if (unit === void 0) {
2997
+ continue;
2998
+ }
2999
+ let bestScore = -1;
3000
+ let bestIndex = -1;
3001
+ for (let earlier = 0; earlier < index; earlier += 1) {
3002
+ const other = units[earlier];
3003
+ if (other === void 0) {
3004
+ continue;
3005
+ }
3006
+ const score = unitSimilarity(unit, other);
3007
+ if (score > bestScore) {
3008
+ bestScore = score;
3009
+ bestIndex = earlier;
3010
+ }
3011
+ }
3012
+ if (bestIndex >= 0 && bestScore >= threshold) {
3013
+ labels.push(labels[bestIndex] ?? labelAt(distinct));
3014
+ scores.push(bestScore);
3015
+ } else {
3016
+ labels.push(labelAt(distinct));
3017
+ scores.push(1);
3018
+ distinct += 1;
3019
+ }
3020
+ }
3021
+ const sections = [];
3022
+ const firstByLabel = /* @__PURE__ */ new Map();
3023
+ for (let index = 0; index < units.length; ) {
3024
+ const label = labels[index] ?? labelAt(0);
3025
+ let end = index;
3026
+ let scoreSum = 0;
3027
+ while (end < units.length && labels[end] === label) {
3028
+ scoreSum += scores[end] ?? 1;
3029
+ end += 1;
3030
+ }
3031
+ const startBeat = units[index]?.startBeat ?? 0;
3032
+ const endBeat = units[end - 1]?.endBeat ?? spanEnd;
3033
+ const sectionIndex = sections.length;
3034
+ const first = firstByLabel.get(label);
3035
+ if (first === void 0) {
3036
+ firstByLabel.set(label, sectionIndex);
3037
+ }
3038
+ const similarity = first === void 0 ? 1 : clamp012(scoreSum / (end - index));
3039
+ sections.push({
3040
+ label,
3041
+ startBeat,
3042
+ endBeat,
3043
+ bars: lastBarOf(meters, startBeat, endBeat) - barIndexAt(startBeat, meters) + 1,
3044
+ firstOccurrence: first ?? sectionIndex,
3045
+ similarity,
3046
+ rationale: first === void 0 ? `Section ${label}: material first heard at beat ${startBeat}` : `Section ${label}: ${Math.round(similarity * 100)}% alike the ${label} at beat ${sections[first]?.startBeat ?? 0}`
3047
+ });
3048
+ index = end;
3049
+ }
3050
+ return sections;
3051
+ }
3052
+
3053
+ // src/analyze/reduction/index.ts
3054
+ var ADJACENCY_EPS = 1e-9;
3055
+ var TONIC_ARRIVING = /* @__PURE__ */ new Set(["authentic", "plagal", "modal", "deceptive"]);
3056
+ function signedStep(fromPc, toPc) {
3057
+ return (pitchClassOf(toPc) - pitchClassOf(fromPc) + 18) % 12 - 6;
3058
+ }
3059
+ function isStep2(distance) {
3060
+ const size = Math.abs(distance);
3061
+ return size === 1 || size === 2;
3062
+ }
3063
+ function touches(before, after) {
3064
+ return Math.abs(after.startBeat - before.endBeat) <= ADJACENCY_EPS;
3065
+ }
3066
+ function median(values) {
3067
+ if (values.length === 0) {
3068
+ return 0;
3069
+ }
3070
+ const sorted = [...values].sort((a, b) => a - b);
3071
+ const middle = sorted.length >> 1;
3072
+ if (sorted.length % 2 === 1) {
3073
+ return sorted[middle] ?? 0;
3074
+ }
3075
+ return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
3076
+ }
3077
+ function isTonicChord(chord, key) {
3078
+ return pitchClassOf(chord.rootPc - key.rootPc) === 0;
3079
+ }
3080
+ function isDominantChord(chord, key) {
3081
+ return pitchClassOf(chord.rootPc - key.rootPc) === 7 && chord.intervals.some((iv) => pitchClassOf(iv) === 4);
3082
+ }
3083
+ function functionalReason(segment, before, after, keyAt) {
3084
+ const key = keyAt(segment.startBeat);
3085
+ if (after !== void 0 && isCadentialSixFour(segment.chord, after.chord, key)) {
3086
+ return { kind: "cadentialSixFour" };
3087
+ }
3088
+ if (isTonicChord(segment.chord, key)) {
3089
+ return { kind: "tonic" };
3090
+ }
3091
+ if (isDominantChord(segment.chord, key)) {
3092
+ return { kind: "dominant" };
3093
+ }
3094
+ if (before !== void 0) {
3095
+ const arrival = detectCadence(before.chord, segment.chord, key).type;
3096
+ if (arrival !== null) {
3097
+ return { kind: "cadenceArrival", cadence: arrival };
3098
+ }
3099
+ }
3100
+ if (after !== void 0) {
3101
+ const agent = detectCadence(segment.chord, after.chord, keyAt(after.startBeat)).type;
3102
+ if (agent !== null && TONIC_ARRIVING.has(agent)) {
3103
+ return { kind: "cadenceAgent", cadence: agent };
3104
+ }
3105
+ }
3106
+ return null;
3107
+ }
3108
+ function figureReason(segment, before, after) {
3109
+ if (before === void 0 || after === void 0) {
3110
+ return null;
3111
+ }
3112
+ const incoming = signedStep(before.chord.rootPc, segment.chord.rootPc);
3113
+ const outgoing = signedStep(segment.chord.rootPc, after.chord.rootPc);
3114
+ if (!isStep2(incoming) || !isStep2(outgoing)) {
3115
+ return null;
3116
+ }
3117
+ if (incoming > 0 === outgoing > 0) {
3118
+ return { kind: "passing" };
3119
+ }
3120
+ return pitchClassOf(before.chord.rootPc) === pitchClassOf(after.chord.rootPc) ? { kind: "neighbor" } : null;
3121
+ }
3122
+ function describe2(reason) {
3123
+ switch (reason.kind) {
3124
+ case "tonic":
3125
+ return "Structural: the tonic of the key, which the progression is heard against";
3126
+ case "dominant":
3127
+ return "Structural: the dominant of the key, the chord its tonic is approached from";
3128
+ case "cadentialSixFour":
3129
+ return "Structural: a tonic six-four on the bass of the dominant that follows, which prolongs that dominant rather than framing the tonic";
3130
+ case "cadenceArrival":
3131
+ return `Structural: the arrival of a ${reason.cadence} cadence`;
3132
+ case "cadenceAgent":
3133
+ return `Structural: the chord that drives a ${reason.cadence} cadence`;
3134
+ case "salient":
3135
+ return "Structural: it holds the harmony at least as long as the median chord";
3136
+ case "first":
3137
+ return "Structural: the first chord of the progression frames it";
3138
+ case "last":
3139
+ return "Structural: the last chord of the progression frames it";
3140
+ case "unfigured":
3141
+ return "Structural: its root neither steps through to the next chord nor returns to the last, so it embellishes nothing";
3142
+ case "passing":
3143
+ return "Passing: its root steps in from the previous chord and on to the next in the same direction";
3144
+ case "neighbor":
3145
+ return "Neighbor: its root steps away from the surrounding harmony and returns to it";
3146
+ }
3147
+ }
3148
+ var BASIS_READINGS = {
3149
+ function: ({ segment, before, after, keyAt }) => functionalReason(segment, before, after, keyAt),
3150
+ duration: ({ segment, salienceFloor }) => segment.endBeat - segment.startBeat >= salienceFloor ? { kind: "salient" } : null
3151
+ };
3152
+ var REDUCTION_BASES = Object.keys(BASIS_READINGS);
3153
+ function levelOf(reason) {
3154
+ if (reason.kind === "passing") {
3155
+ return "passing";
3156
+ }
3157
+ return reason.kind === "neighbor" ? "neighbor" : "structural";
3158
+ }
3159
+ function reduceProgression(timeline, key, opts = {}) {
3160
+ const keyAt = typeof key === "function" ? key : () => key;
3161
+ const basis = assertOneOf(opts.basis ?? "function", REDUCTION_BASES, "reduction basis");
3162
+ const reading = BASIS_READINGS[basis];
3163
+ const segments = timeline.segments;
3164
+ const salienceFloor = basis === "duration" ? median(segments.map((segment) => segment.endBeat - segment.startBeat)) : 0;
3165
+ const result = [];
3166
+ for (let index = 0; index < segments.length; index += 1) {
3167
+ const segment = segments[index];
3168
+ if (segment === void 0) {
3169
+ continue;
3170
+ }
3171
+ const previous = segments[index - 1];
3172
+ const following = segments[index + 1];
3173
+ const before = previous !== void 0 && touches(previous, segment) ? previous : void 0;
3174
+ const after = following !== void 0 && touches(segment, following) ? following : void 0;
3175
+ let reason = reading({ segment, before, after, keyAt, salienceFloor });
3176
+ if (reason === null && index === 0) {
3177
+ reason = { kind: "first" };
3178
+ }
3179
+ if (reason === null && index === segments.length - 1) {
3180
+ reason = { kind: "last" };
3181
+ }
3182
+ reason ??= figureReason(segment, before, after) ?? { kind: "unfigured" };
3183
+ result.push({
3184
+ chord: segment.chord,
3185
+ startBeat: segment.startBeat,
3186
+ endBeat: segment.endBeat,
3187
+ level: levelOf(reason),
3188
+ rationale: describe2(reason)
3189
+ });
3190
+ }
3191
+ return result;
3192
+ }
3193
+
3194
+ export {
3195
+ keyTimelineFromNotes,
3196
+ detectModulations,
3197
+ keyLookup,
3198
+ prevailingKeyOf,
3199
+ chordTimelineFromChords,
3200
+ chordTimelineFromNotes,
3201
+ detectCadences,
3202
+ toVoiceNotes,
3203
+ analyzeVoice,
3204
+ analyzeArrangement,
3205
+ createArrangementSession,
3206
+ tensionCurve,
3207
+ tensionCurveFrom,
3208
+ hypermeter,
3209
+ extractMotifs,
3210
+ motifFromNotes,
3211
+ relateMotifs,
3212
+ compareMelodies,
3213
+ melodicSimilarity,
3214
+ melodicContour,
3215
+ phrasesFromTimeline,
3216
+ structuralCadences,
3217
+ sectionsFromNotes,
3218
+ reduceProgression
3219
+ };
3220
+ //# sourceMappingURL=chunk-GOZG57ER.js.map