@libraz/libcantus 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -65
- package/README_ja.md +28 -66
- package/dist/analyze/index.cjs +11 -7
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +79 -144
- package/dist/analyze/index.d.ts +79 -144
- package/dist/analyze/index.js +20 -16
- package/dist/chunk-35NBV2FT.cjs +145 -0
- package/dist/chunk-35NBV2FT.cjs.map +1 -0
- package/dist/{chunk-UADYAYAT.cjs → chunk-3R3QVKY5.cjs} +1353 -580
- package/dist/chunk-3R3QVKY5.cjs.map +1 -0
- package/dist/{chunk-WWURE4XT.cjs → chunk-76ZBTRG2.cjs} +624 -729
- package/dist/chunk-76ZBTRG2.cjs.map +1 -0
- package/dist/{chunk-FVSI36UV.js → chunk-7KG3RTYS.js} +197 -110
- package/dist/chunk-7KG3RTYS.js.map +1 -0
- package/dist/{chunk-VTI6FOLB.js → chunk-AGOVW7U6.js} +709 -133
- package/dist/chunk-AGOVW7U6.js.map +1 -0
- package/dist/{chunk-E3F4AHAH.cjs → chunk-CGBADIO5.cjs} +221 -134
- package/dist/chunk-CGBADIO5.cjs.map +1 -0
- package/dist/{chunk-6XH6QZYQ.cjs → chunk-DSMJRXCZ.cjs} +1455 -849
- package/dist/chunk-DSMJRXCZ.cjs.map +1 -0
- package/dist/{chunk-PKFNUHDY.js → chunk-FBH75DAY.js} +1218 -612
- package/dist/chunk-FBH75DAY.js.map +1 -0
- package/dist/chunk-H2KJHBW4.js +3329 -0
- package/dist/chunk-H2KJHBW4.js.map +1 -0
- package/dist/chunk-HPVSH7SF.cjs +2294 -0
- package/dist/chunk-HPVSH7SF.cjs.map +1 -0
- package/dist/{chunk-MXO72PLY.cjs → chunk-HWHGOVO6.cjs} +21 -19
- package/dist/chunk-HWHGOVO6.cjs.map +1 -0
- package/dist/chunk-HYJJ2V2I.js +2294 -0
- package/dist/chunk-HYJJ2V2I.js.map +1 -0
- package/dist/{chunk-AFM2ULJH.cjs → chunk-J7FHVZII.cjs} +1567 -1067
- package/dist/chunk-J7FHVZII.cjs.map +1 -0
- package/dist/chunk-K6OZXIR6.js +1 -0
- package/dist/{chunk-H3VORETI.js → chunk-M5RYMAFI.js} +487 -592
- package/dist/chunk-M5RYMAFI.js.map +1 -0
- package/dist/chunk-NB25P3EH.js +363 -0
- package/dist/chunk-NB25P3EH.js.map +1 -0
- package/dist/{chunk-TTCLJX6D.js → chunk-NFJ4P6ZZ.js} +10 -8
- package/dist/chunk-NFJ4P6ZZ.js.map +1 -0
- package/dist/chunk-NM7UAMC3.cjs +457 -0
- package/dist/chunk-NM7UAMC3.cjs.map +1 -0
- package/dist/chunk-PM4XJE2Y.cjs +363 -0
- package/dist/chunk-PM4XJE2Y.cjs.map +1 -0
- package/dist/chunk-PUKJP2Z5.js +145 -0
- package/dist/chunk-PUKJP2Z5.js.map +1 -0
- package/dist/{chunk-VOHZB45S.js → chunk-Q6LFSAKV.js} +1321 -821
- package/dist/chunk-Q6LFSAKV.js.map +1 -0
- package/dist/{chunk-INHVXP6Z.js → chunk-RFIBF2RR.js} +1203 -430
- package/dist/chunk-RFIBF2RR.js.map +1 -0
- package/dist/{chunk-Q2WGKJNN.cjs → chunk-S5BKZZGM.cjs} +699 -123
- package/dist/chunk-S5BKZZGM.cjs.map +1 -0
- package/dist/chunk-SGLEQVJH.cjs +3329 -0
- package/dist/chunk-SGLEQVJH.cjs.map +1 -0
- package/dist/chunk-UGLFDG7L.cjs +1 -0
- package/dist/chunk-UGLFDG7L.cjs.map +1 -0
- package/dist/{chunk-YH3BIKHH.js → chunk-UMWZ4ZET.js} +146 -49
- package/dist/chunk-UMWZ4ZET.js.map +1 -0
- package/dist/{index-szBv3496.d.ts → coerce-DP0vYu-x.d.ts} +286 -199
- package/dist/{index-C_8o--VE.d.cts → coerce-DRERzdve.d.cts} +286 -199
- package/dist/core/index.cjs +9 -5
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +122 -108
- package/dist/core/index.d.ts +122 -108
- package/dist/core/index.js +32 -28
- package/dist/generate/index.cjs +8 -8
- package/dist/generate/index.d.cts +168 -84
- package/dist/generate/index.d.ts +168 -84
- package/dist/generate/index.js +7 -7
- package/dist/{index-CGpRv5sl.d.cts → index-9eoQs2C1.d.ts} +34 -19
- package/dist/{index-CCNJRG-P.d.cts → index-B9QLziDe.d.cts} +293 -56
- package/dist/{index-Dzr13q3d.d.cts → index-BBWNZS0f.d.cts} +135 -58
- package/dist/{index-9wcMhG73.d.ts → index-BHIUyjhZ.d.ts} +293 -56
- package/dist/{index-BGFW-8P2.d.cts → index-BJgP97N1.d.ts} +196 -114
- package/dist/{index-CunYKLiu.d.ts → index-BL5N2Oyz.d.cts} +1 -1
- package/dist/{index-Cj49F892.d.ts → index-BMweUlm5.d.cts} +63 -23
- package/dist/{index-CgHqxdps.d.ts → index-BNSM-DSk.d.cts} +196 -114
- package/dist/{index-D57QRbVn.d.ts → index-BRMbPkHE.d.ts} +480 -254
- package/dist/{index-Chbst3Jx.d.ts → index-CJBBiAmU.d.ts} +135 -58
- package/dist/{index-BkGmlyI3.d.cts → index-CL4b4gUQ.d.ts} +63 -23
- package/dist/{index-B9MNZboQ.d.ts → index-Cy15oTvh.d.cts} +52 -22
- package/dist/{index-B9MNZboQ.d.cts → index-Cy15oTvh.d.ts} +52 -22
- package/dist/{index-BO6xE9YU.d.cts → index-DGCNKAUr.d.cts} +480 -254
- package/dist/{index-B4o55NDr.d.cts → index-Dw2DP7vm.d.ts} +1 -1
- package/dist/{index-G1uwGGWQ.d.ts → index-cHvbYgJI.d.cts} +34 -19
- package/dist/index.cjs +42 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -14
- package/dist/index.d.ts +14 -14
- package/dist/index.js +82 -54
- package/dist/{masks-DYbE485J.d.cts → masks-Cc7-MzzY.d.ts} +49 -2
- package/dist/{masks-DYbE485J.d.ts → masks-pADfTOiD.d.cts} +49 -2
- package/dist/model/index.cjs +19 -12
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +504 -182
- package/dist/model/index.d.ts +504 -182
- package/dist/model/index.js +18 -11
- package/dist/{positional-Cptlj-xs.d.ts → positional-DJgs6ltD.d.ts} +85 -8
- package/dist/{positional-DvH_sd8u.d.cts → positional-DMZer-FM.d.cts} +85 -8
- package/dist/theory/index.cjs +35 -6
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +257 -62
- package/dist/theory/index.d.ts +257 -62
- package/dist/theory/index.js +41 -12
- package/package.json +7 -5
- package/dist/chunk-2LCPM3EG.js +0 -3224
- package/dist/chunk-2LCPM3EG.js.map +0 -1
- package/dist/chunk-6XH6QZYQ.cjs.map +0 -1
- package/dist/chunk-6XPTBE7Y.cjs +0 -49
- package/dist/chunk-6XPTBE7Y.cjs.map +0 -1
- package/dist/chunk-AFM2ULJH.cjs.map +0 -1
- package/dist/chunk-B6XNKWZV.cjs +0 -3224
- package/dist/chunk-B6XNKWZV.cjs.map +0 -1
- package/dist/chunk-E3F4AHAH.cjs.map +0 -1
- package/dist/chunk-ELCTZUJJ.js +0 -379
- package/dist/chunk-ELCTZUJJ.js.map +0 -1
- package/dist/chunk-FVSI36UV.js.map +0 -1
- package/dist/chunk-H3VORETI.js.map +0 -1
- package/dist/chunk-H42UHPYW.js +0 -1577
- package/dist/chunk-H42UHPYW.js.map +0 -1
- package/dist/chunk-HFPS7JFD.js +0 -1
- package/dist/chunk-INHVXP6Z.js.map +0 -1
- package/dist/chunk-K4ZJUGF4.cjs +0 -379
- package/dist/chunk-K4ZJUGF4.cjs.map +0 -1
- package/dist/chunk-KNJEKLKV.js +0 -49
- package/dist/chunk-KNJEKLKV.js.map +0 -1
- package/dist/chunk-MXO72PLY.cjs.map +0 -1
- package/dist/chunk-NLEU4QSE.cjs +0 -360
- package/dist/chunk-NLEU4QSE.cjs.map +0 -1
- package/dist/chunk-OE6WK2Y2.cjs +0 -1577
- package/dist/chunk-OE6WK2Y2.cjs.map +0 -1
- package/dist/chunk-PKFNUHDY.js.map +0 -1
- package/dist/chunk-Q2WGKJNN.cjs.map +0 -1
- package/dist/chunk-RHIJ3S4I.cjs +0 -1
- package/dist/chunk-RHIJ3S4I.cjs.map +0 -1
- package/dist/chunk-TTCLJX6D.js.map +0 -1
- package/dist/chunk-UADYAYAT.cjs.map +0 -1
- package/dist/chunk-VOHZB45S.js.map +0 -1
- package/dist/chunk-VTI6FOLB.js.map +0 -1
- package/dist/chunk-WWURE4XT.cjs.map +0 -1
- package/dist/chunk-YH3BIKHH.js.map +0 -1
- /package/dist/{chunk-HFPS7JFD.js.map → chunk-K6OZXIR6.js.map} +0 -0
|
@@ -0,0 +1,2294 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HUMANIZE_ADJACENCY,
|
|
3
|
+
adjacent,
|
|
4
|
+
analyzeTimeline,
|
|
5
|
+
analyzeVoice,
|
|
6
|
+
assertChordTimeline,
|
|
7
|
+
attachPivots,
|
|
8
|
+
barGridStart,
|
|
9
|
+
chordTimelineFromNotes,
|
|
10
|
+
clamp01,
|
|
11
|
+
combineEvidence,
|
|
12
|
+
detectCadence,
|
|
13
|
+
detectCadences,
|
|
14
|
+
dirtySlotsFor,
|
|
15
|
+
firstSoundingBeat,
|
|
16
|
+
floorMod,
|
|
17
|
+
functionOf,
|
|
18
|
+
gridForNotes,
|
|
19
|
+
harmonicNovelty,
|
|
20
|
+
hasEnded,
|
|
21
|
+
isAppliedDominant,
|
|
22
|
+
isCadentialSixFour,
|
|
23
|
+
keyLookup,
|
|
24
|
+
keyScaleAt,
|
|
25
|
+
keyTimelineFromNotes,
|
|
26
|
+
lastBarOf,
|
|
27
|
+
prevailingKeyOf,
|
|
28
|
+
sameInstant,
|
|
29
|
+
segmentStartingAt,
|
|
30
|
+
sliceBars,
|
|
31
|
+
weightSimilarity,
|
|
32
|
+
windowWeights
|
|
33
|
+
} from "./chunk-H2KJHBW4.js";
|
|
34
|
+
import {
|
|
35
|
+
PROFILE_WEIGHTS,
|
|
36
|
+
chordPitchClasses,
|
|
37
|
+
evaluateSafety,
|
|
38
|
+
isDominantChordOf,
|
|
39
|
+
keySignatureFifths,
|
|
40
|
+
majorKey,
|
|
41
|
+
resolveKey,
|
|
42
|
+
scaleOf,
|
|
43
|
+
scaleTonesInDegreeOrder
|
|
44
|
+
} from "./chunk-RFIBF2RR.js";
|
|
45
|
+
import {
|
|
46
|
+
BEAT_EPS,
|
|
47
|
+
InvalidInputError,
|
|
48
|
+
assertArray,
|
|
49
|
+
assertFiniteNumber,
|
|
50
|
+
assertFunction,
|
|
51
|
+
assertGenerationBudget,
|
|
52
|
+
assertInteger,
|
|
53
|
+
assertNoteEvents,
|
|
54
|
+
assertOneOf,
|
|
55
|
+
assertOptions,
|
|
56
|
+
assertPositiveInt,
|
|
57
|
+
assertRange,
|
|
58
|
+
assertRecord,
|
|
59
|
+
barIndexAt,
|
|
60
|
+
barPositionToBeat,
|
|
61
|
+
beatsPerBarAt,
|
|
62
|
+
describeRejected,
|
|
63
|
+
isStrongBeat,
|
|
64
|
+
midiToNote,
|
|
65
|
+
pitchClassOf,
|
|
66
|
+
resolveMeters,
|
|
67
|
+
spelledInterval
|
|
68
|
+
} from "./chunk-AGOVW7U6.js";
|
|
69
|
+
|
|
70
|
+
// src/analyze/arrange/internal.ts
|
|
71
|
+
var MAX_LANE_LEAP = 24;
|
|
72
|
+
function covers(note, beat) {
|
|
73
|
+
return beat >= note.startBeat - BEAT_EPS && beat < note.endBeat - BEAT_EPS;
|
|
74
|
+
}
|
|
75
|
+
function isPercussion(role) {
|
|
76
|
+
return role === "drums";
|
|
77
|
+
}
|
|
78
|
+
var TRACK_ROLE_TABLE = {
|
|
79
|
+
melody: true,
|
|
80
|
+
harmony: true,
|
|
81
|
+
bass: true,
|
|
82
|
+
drums: true,
|
|
83
|
+
other: true
|
|
84
|
+
};
|
|
85
|
+
var TRACK_ROLES = Object.keys(TRACK_ROLE_TABLE);
|
|
86
|
+
function trackRoleOf(role, name) {
|
|
87
|
+
return role === void 0 ? "other" : assertOneOf(role, TRACK_ROLES, name);
|
|
88
|
+
}
|
|
89
|
+
var DEFAULT_PROFILE = "pop";
|
|
90
|
+
var SAFETY_PROFILES = Object.keys(PROFILE_WEIGHTS);
|
|
91
|
+
function arrangementProfile(profile) {
|
|
92
|
+
return assertOneOf(profile ?? DEFAULT_PROFILE, SAFETY_PROFILES, "arrangement profile");
|
|
93
|
+
}
|
|
94
|
+
function assertTrackNotes(tracks, options) {
|
|
95
|
+
assertArray(tracks, "arrangement tracks");
|
|
96
|
+
let noteCount = 0;
|
|
97
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
98
|
+
const notes = tracks[index]?.notes;
|
|
99
|
+
if (!Array.isArray(notes)) {
|
|
100
|
+
throw new InvalidInputError(
|
|
101
|
+
`tracks[${index}].notes must be an array; received ${typeof notes}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
assertNoteEvents(notes, `tracks[${index}].notes`, options);
|
|
105
|
+
noteCount += notes.length;
|
|
106
|
+
}
|
|
107
|
+
return noteCount;
|
|
108
|
+
}
|
|
109
|
+
function assertGivenTimeline(timeline, name) {
|
|
110
|
+
if (timeline !== void 0) {
|
|
111
|
+
assertChordTimeline(timeline, name);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function assertGivenKeys(keys, name) {
|
|
115
|
+
if (keys === void 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(keys)) {
|
|
119
|
+
throw new InvalidInputError(
|
|
120
|
+
`${name} must be an array of key regions; received ${describeRejected(keys)}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
124
|
+
const region = keys[index];
|
|
125
|
+
if (typeof region !== "object" || region === null) {
|
|
126
|
+
throw new InvalidInputError(
|
|
127
|
+
`${name}[${index}] must be a key region; received ${describeRejected(region)}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
assertFiniteNumber(region.startBeat, `${name}[${index}].startBeat`);
|
|
131
|
+
assertFiniteNumber(region.endBeat, `${name}[${index}].endBeat`);
|
|
132
|
+
if (region.key === void 0 || region.key === null || typeof region.key !== "string" && typeof region.key !== "object") {
|
|
133
|
+
throw new InvalidInputError(
|
|
134
|
+
`${name}[${index}].key must name a key; received ${describeRejected(region.key)}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
resolveKey(region.key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function harmonyTrackSet(harmonyTracks, tracks, name = "harmonyTracks") {
|
|
141
|
+
if (harmonyTracks === void 0) {
|
|
142
|
+
return void 0;
|
|
143
|
+
}
|
|
144
|
+
const only = /* @__PURE__ */ new Set();
|
|
145
|
+
for (let index = 0; index < harmonyTracks.length; index += 1) {
|
|
146
|
+
const value = harmonyTracks[index];
|
|
147
|
+
if (value === void 0 || !Number.isFinite(value)) {
|
|
148
|
+
throw new InvalidInputError(`${name}[${index}] must be a track index; received ${value}`);
|
|
149
|
+
}
|
|
150
|
+
const track = Math.trunc(value);
|
|
151
|
+
if (track < 0 || track >= tracks.length) {
|
|
152
|
+
throw new InvalidInputError(
|
|
153
|
+
`${name}[${index}] ${track} is outside the arrangement's ${tracks.length} tracks`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (isPercussion(tracks[track]?.role)) {
|
|
157
|
+
throw new InvalidInputError(
|
|
158
|
+
`${name}[${index}] ${track} names a percussion track, whose pitches state no harmony`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
only.add(track);
|
|
162
|
+
}
|
|
163
|
+
return only;
|
|
164
|
+
}
|
|
165
|
+
function poolNotes(tracks, only) {
|
|
166
|
+
const notes = [];
|
|
167
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
168
|
+
const track = tracks[index];
|
|
169
|
+
if (track === void 0 || isPercussion(track.role)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (only !== void 0 && !only.has(index)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const note of track.notes) {
|
|
176
|
+
if (note.durationBeat > 0) {
|
|
177
|
+
notes.push(note);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return notes;
|
|
182
|
+
}
|
|
183
|
+
function splitIntoSubVoices(ordered) {
|
|
184
|
+
const lanes = [];
|
|
185
|
+
const attachNearest = (note, excluded = /* @__PURE__ */ new Set()) => {
|
|
186
|
+
let best = -1;
|
|
187
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
188
|
+
let bestEnd = Number.NEGATIVE_INFINITY;
|
|
189
|
+
for (let lane = 0; lane < lanes.length; lane += 1) {
|
|
190
|
+
if (excluded.has(lane)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const last = lanes[lane]?.[(lanes[lane]?.length ?? 0) - 1];
|
|
194
|
+
if (last === void 0) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const end = last.startBeat + last.durationBeat;
|
|
198
|
+
if (!hasEnded(end, note.startBeat)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const distance = Math.abs(last.pitch - note.pitch);
|
|
202
|
+
if (distance > MAX_LANE_LEAP) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (distance < bestDistance || distance === bestDistance && end > bestEnd) {
|
|
206
|
+
best = lane;
|
|
207
|
+
bestDistance = distance;
|
|
208
|
+
bestEnd = end;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (best >= 0) {
|
|
212
|
+
lanes[best]?.push(note);
|
|
213
|
+
}
|
|
214
|
+
return best;
|
|
215
|
+
};
|
|
216
|
+
for (let start = 0; start < ordered.length; ) {
|
|
217
|
+
const onset = ordered[start]?.startBeat ?? 0;
|
|
218
|
+
let end = start + 1;
|
|
219
|
+
while (end < ordered.length && sameInstant(ordered[end]?.startBeat ?? 0, onset)) {
|
|
220
|
+
end += 1;
|
|
221
|
+
}
|
|
222
|
+
const block = ordered.slice(start, end);
|
|
223
|
+
if (block.length > 1) {
|
|
224
|
+
const freeLanes = lanes.map((lane, index) => ({ index, last: lane[lane.length - 1] })).filter(
|
|
225
|
+
(entry) => entry.last !== void 0 && hasEnded(entry.last.startBeat + entry.last.durationBeat, onset)
|
|
226
|
+
).sort((a, b) => a.last.pitch - b.last.pitch);
|
|
227
|
+
const assignment = pairWithFreeLanes(block, freeLanes);
|
|
228
|
+
const taken = new Set(
|
|
229
|
+
assignment.filter((lane) => lane !== void 0)
|
|
230
|
+
);
|
|
231
|
+
for (let index = 0; index < block.length; index += 1) {
|
|
232
|
+
const note = block[index];
|
|
233
|
+
if (note === void 0) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const lane = assignment[index];
|
|
237
|
+
if (lane !== void 0) {
|
|
238
|
+
lanes[lane]?.push(note);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const attached = attachNearest(note, taken);
|
|
242
|
+
if (attached < 0) {
|
|
243
|
+
lanes.push([note]);
|
|
244
|
+
} else {
|
|
245
|
+
taken.add(attached);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
const note = block[0];
|
|
250
|
+
if (note !== void 0 && attachNearest(note) < 0) {
|
|
251
|
+
lanes.push([note]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
start = end;
|
|
255
|
+
}
|
|
256
|
+
return lanes;
|
|
257
|
+
}
|
|
258
|
+
function pairWithFreeLanes(block, freeLanes) {
|
|
259
|
+
const noteCount = block.length;
|
|
260
|
+
const laneCount = freeLanes.length;
|
|
261
|
+
const assignment = new Array(noteCount).fill(void 0);
|
|
262
|
+
if (laneCount === 0) {
|
|
263
|
+
return assignment;
|
|
264
|
+
}
|
|
265
|
+
const width = laneCount + 1;
|
|
266
|
+
const cost = new Float64Array((noteCount + 1) * width);
|
|
267
|
+
const step = new Uint8Array((noteCount + 1) * width);
|
|
268
|
+
for (let i = 1; i <= noteCount; i += 1) {
|
|
269
|
+
cost[i * width] = i * MAX_LANE_LEAP;
|
|
270
|
+
step[i * width] = 2;
|
|
271
|
+
}
|
|
272
|
+
for (let i = 1; i <= noteCount; i += 1) {
|
|
273
|
+
for (let j = 1; j <= laneCount; j += 1) {
|
|
274
|
+
let best = cost[i * width + j - 1] ?? 0;
|
|
275
|
+
let choice = 0;
|
|
276
|
+
const unpaired = (cost[(i - 1) * width + j] ?? 0) + MAX_LANE_LEAP;
|
|
277
|
+
if (unpaired < best) {
|
|
278
|
+
best = unpaired;
|
|
279
|
+
choice = 2;
|
|
280
|
+
}
|
|
281
|
+
const note = block[i - 1];
|
|
282
|
+
const lane = freeLanes[j - 1];
|
|
283
|
+
if (note !== void 0 && lane !== void 0) {
|
|
284
|
+
const distance = Math.abs(lane.last.pitch - note.pitch);
|
|
285
|
+
const paired = (cost[(i - 1) * width + j - 1] ?? 0) + distance;
|
|
286
|
+
if (distance <= MAX_LANE_LEAP && paired < best) {
|
|
287
|
+
best = paired;
|
|
288
|
+
choice = 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
cost[i * width + j] = best;
|
|
292
|
+
step[i * width + j] = choice;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (let i = noteCount, j = laneCount; i > 0; ) {
|
|
296
|
+
if (j === 0 || step[i * width + j] === 2) {
|
|
297
|
+
i -= 1;
|
|
298
|
+
} else if (step[i * width + j] === 1) {
|
|
299
|
+
assignment[i - 1] = freeLanes[j - 1]?.index;
|
|
300
|
+
i -= 1;
|
|
301
|
+
j -= 1;
|
|
302
|
+
} else {
|
|
303
|
+
j -= 1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return assignment;
|
|
307
|
+
}
|
|
308
|
+
function lastStartingAtOrBefore(sounding, beat) {
|
|
309
|
+
let low = 0;
|
|
310
|
+
let high = sounding.length;
|
|
311
|
+
while (low < high) {
|
|
312
|
+
const middle = Math.floor((low + high) / 2);
|
|
313
|
+
if ((sounding[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat + BEAT_EPS) {
|
|
314
|
+
low = middle + 1;
|
|
315
|
+
} else {
|
|
316
|
+
high = middle;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return low - 1;
|
|
320
|
+
}
|
|
321
|
+
function prepareTracks(tracks) {
|
|
322
|
+
let nextId = 0;
|
|
323
|
+
return tracks.map((track, index) => {
|
|
324
|
+
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 }));
|
|
325
|
+
const voices = splitIntoSubVoices(ordered).map((voice) => {
|
|
326
|
+
const sounding = voice.map((note, i) => {
|
|
327
|
+
const prev = i > 0 ? voice[i - 1] : void 0;
|
|
328
|
+
const prevEnd = prev === void 0 ? void 0 : prev.startBeat + prev.durationBeat;
|
|
329
|
+
const contiguous = prevEnd !== void 0 && adjacent(prevEnd, note.startBeat);
|
|
330
|
+
return {
|
|
331
|
+
id: note.id,
|
|
332
|
+
originalIndex: note.originalIndex,
|
|
333
|
+
pitch: note.pitch,
|
|
334
|
+
prevPitch: contiguous ? prev?.pitch : void 0,
|
|
335
|
+
startBeat: note.startBeat,
|
|
336
|
+
endBeat: note.startBeat + note.durationBeat
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
voice,
|
|
341
|
+
sounding,
|
|
342
|
+
at: (beat) => {
|
|
343
|
+
const candidate = sounding[lastStartingAtOrBefore(sounding, beat)];
|
|
344
|
+
return candidate !== void 0 && covers(candidate, beat) ? candidate : void 0;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
return {
|
|
349
|
+
name: track.name ?? `track ${index + 1}`,
|
|
350
|
+
role: trackRoleOf(track.role, `tracks[${index}].role`),
|
|
351
|
+
trackIndex: index,
|
|
352
|
+
voices
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/analyze/arrange/tracks.ts
|
|
358
|
+
function withGivenKeys(result, given) {
|
|
359
|
+
if (given === void 0) {
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
return { ...result, keys: given, prevailingKey: prevailingKeyOf(given) ?? result.prevailingKey };
|
|
363
|
+
}
|
|
364
|
+
function callerTimeline(timeline, key, given, pooled, meters, budget) {
|
|
365
|
+
const chartEnd = timeline.segments.reduce((end, segment) => Math.max(end, segment.endBeat), 0);
|
|
366
|
+
const soundingEnd = pooled.reduce(
|
|
367
|
+
(end, note) => Math.max(end, note.startBeat + note.durationBeat),
|
|
368
|
+
0
|
|
369
|
+
);
|
|
370
|
+
const totalBeats = Math.max(chartEnd, soundingEnd);
|
|
371
|
+
const { startBeat: musicStart } = gridForNotes(pooled, beatsPerBarAt(0, meters));
|
|
372
|
+
const keys = given ?? attachPivots(
|
|
373
|
+
key !== void 0 ? [{ startBeat: musicStart, endBeat: totalBeats, key: resolveKey(key), confidence: 1 }] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }),
|
|
374
|
+
timeline.segments
|
|
375
|
+
);
|
|
376
|
+
return {
|
|
377
|
+
timeline,
|
|
378
|
+
keys,
|
|
379
|
+
prevailingKey: prevailingKeyOf(keys) ?? resolveKey(majorKey(0)),
|
|
380
|
+
segmentConfidence: timeline.segments.map(() => 0)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function soundingVoicesAt(prepared, beat) {
|
|
384
|
+
const out = [];
|
|
385
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
386
|
+
const track = prepared[t];
|
|
387
|
+
if (!track || isPercussion(track.role)) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
391
|
+
const note = track.voices[v]?.at(beat);
|
|
392
|
+
if (note === void 0) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const snap = { pitch: note.pitch };
|
|
396
|
+
const previous = sameInstant(note.startBeat, beat) ? note.prevPitch : note.pitch;
|
|
397
|
+
if (previous !== void 0) {
|
|
398
|
+
snap.prevPitch = previous;
|
|
399
|
+
}
|
|
400
|
+
out.push({ track: t, voice: v, trackIndex: track.trackIndex, note, snapshot: snap });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
function otherVoicesSounding(prepared, excludeTrack, excludeVoice, beat, cache) {
|
|
406
|
+
const sounding = cache.get(beat) ?? soundingVoicesAt(prepared, beat);
|
|
407
|
+
cache.set(beat, sounding);
|
|
408
|
+
return sounding.filter(({ track, voice }) => track !== excludeTrack || voice !== excludeVoice);
|
|
409
|
+
}
|
|
410
|
+
function conflictPartner(sounding, reasons) {
|
|
411
|
+
const partner = {
|
|
412
|
+
trackIndex: sounding.trackIndex,
|
|
413
|
+
noteId: sounding.note.id,
|
|
414
|
+
reasons
|
|
415
|
+
};
|
|
416
|
+
if (sounding.note.originalIndex !== void 0) {
|
|
417
|
+
partner.originalIndex = sounding.note.originalIndex;
|
|
418
|
+
}
|
|
419
|
+
return partner;
|
|
420
|
+
}
|
|
421
|
+
function snapshotsOf(voices) {
|
|
422
|
+
return voices.map(({ snapshot }) => snapshot);
|
|
423
|
+
}
|
|
424
|
+
function analyzePolyphony(notes, chordAtBeat, key) {
|
|
425
|
+
const passage = assertNoteEvents(notes, "notes", { allowNonPositiveDuration: true });
|
|
426
|
+
assertFunction(chordAtBeat, "chordAtBeat");
|
|
427
|
+
const prepared = prepareTracks([{ notes: [...passage] }]);
|
|
428
|
+
const track = prepared[0];
|
|
429
|
+
assertGenerationBudget(
|
|
430
|
+
passage.length * (track?.voices.length ?? 0),
|
|
431
|
+
"polyphony note-voice comparisons"
|
|
432
|
+
);
|
|
433
|
+
const soundingCache = /* @__PURE__ */ new Map();
|
|
434
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
435
|
+
for (let v = 0; v < (track?.voices.length ?? 0); v += 1) {
|
|
436
|
+
const subVoice = track?.voices[v];
|
|
437
|
+
if (subVoice === void 0) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const analyzed = analyzeVoice(
|
|
441
|
+
subVoice.voice,
|
|
442
|
+
chordAtBeat,
|
|
443
|
+
key,
|
|
444
|
+
(beat) => snapshotsOf(otherVoicesSounding(prepared, 0, v, beat, soundingCache))
|
|
445
|
+
);
|
|
446
|
+
for (const note of analyzed) {
|
|
447
|
+
if (note.originalIndex !== void 0) {
|
|
448
|
+
const { originalIndex, ...annotation } = note;
|
|
449
|
+
byIndex.set(originalIndex, { ...annotation, noteId: originalIndex });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return notes.map((note, index) => {
|
|
454
|
+
const analyzed = byIndex.get(index);
|
|
455
|
+
if (analyzed !== void 0) {
|
|
456
|
+
return analyzed;
|
|
457
|
+
}
|
|
458
|
+
const [only] = analyzeVoice([{ ...note, id: index }], chordAtBeat, key);
|
|
459
|
+
return only ?? {
|
|
460
|
+
noteId: index,
|
|
461
|
+
pitch: note.pitch,
|
|
462
|
+
startBeat: note.startBeat,
|
|
463
|
+
durationBeat: note.durationBeat,
|
|
464
|
+
labels: []
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function firstSegmentAfter(timeline, beat) {
|
|
469
|
+
let low = 0;
|
|
470
|
+
let high = timeline.segments.length;
|
|
471
|
+
while (low < high) {
|
|
472
|
+
const middle = Math.floor((low + high) / 2);
|
|
473
|
+
if ((timeline.segments[middle]?.startBeat ?? Number.POSITIVE_INFINITY) <= beat + BEAT_EPS) {
|
|
474
|
+
low = middle + 1;
|
|
475
|
+
} else {
|
|
476
|
+
high = middle;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return low;
|
|
480
|
+
}
|
|
481
|
+
function evaluationBeats(note, timeline) {
|
|
482
|
+
const noteEnd = note.startBeat + note.durationBeat;
|
|
483
|
+
const beats = [note.startBeat];
|
|
484
|
+
for (let i = firstSegmentAfter(timeline, note.startBeat); i < timeline.segments.length; i += 1) {
|
|
485
|
+
const start = timeline.segments[i]?.startBeat;
|
|
486
|
+
if (start === void 0 || start >= noteEnd - BEAT_EPS) {
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
beats.push(start);
|
|
490
|
+
}
|
|
491
|
+
return beats;
|
|
492
|
+
}
|
|
493
|
+
function analyzeArrangement(tracks, opts = {}) {
|
|
494
|
+
return analyzeArrangementWith(tracks, opts).analysis;
|
|
495
|
+
}
|
|
496
|
+
function analyzeArrangementWith(tracks, opts = {}, previous, dirty) {
|
|
497
|
+
const meters = resolveMeters(opts, "arrangement meters");
|
|
498
|
+
const budget = opts.budget;
|
|
499
|
+
const noteOptions = { allowNonPositiveDuration: true, budget };
|
|
500
|
+
if (opts.pickupBeats !== void 0) {
|
|
501
|
+
assertRange(opts.pickupBeats, 0, Number.MAX_SAFE_INTEGER, "arrangement pickupBeats");
|
|
502
|
+
noteOptions.minStartBeat = -opts.pickupBeats;
|
|
503
|
+
}
|
|
504
|
+
assertArray(tracks, "arrangement tracks");
|
|
505
|
+
assertGenerationBudget(tracks.length, "arrangement tracks", budget);
|
|
506
|
+
const noteCount = assertTrackNotes(tracks, noteOptions);
|
|
507
|
+
assertGenerationBudget(noteCount, "arrangement notes", budget);
|
|
508
|
+
const profile = arrangementProfile(opts.profile);
|
|
509
|
+
const minSeverity = opts.minSeverity === void 0 ? 1 /* Warning */ : assertInteger(
|
|
510
|
+
opts.minSeverity,
|
|
511
|
+
"arrangement minSeverity",
|
|
512
|
+
0 /* Safe */,
|
|
513
|
+
2 /* Dissonant */
|
|
514
|
+
);
|
|
515
|
+
assertGivenTimeline(opts.timeline, "arrangement timeline");
|
|
516
|
+
assertGivenKeys(opts.keys, "arrangement keys");
|
|
517
|
+
const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks);
|
|
518
|
+
const prepared = prepareTracks(tracks);
|
|
519
|
+
const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
|
|
520
|
+
assertGenerationBudget(noteCount * voiceCount, "arrangement note-voice comparisons", budget);
|
|
521
|
+
const pooled = poolNotes(tracks, harmonyTracks);
|
|
522
|
+
let evidence;
|
|
523
|
+
let inferred;
|
|
524
|
+
if (opts.timeline === void 0) {
|
|
525
|
+
const run = analyzeTimeline(
|
|
526
|
+
pooled,
|
|
527
|
+
{
|
|
528
|
+
key: opts.key,
|
|
529
|
+
meters,
|
|
530
|
+
pickupBeats: opts.pickupBeats,
|
|
531
|
+
harmonicRhythm: opts.harmonicRhythm,
|
|
532
|
+
budget
|
|
533
|
+
},
|
|
534
|
+
previous,
|
|
535
|
+
dirty
|
|
536
|
+
);
|
|
537
|
+
evidence = run.evidence;
|
|
538
|
+
inferred = withGivenKeys(run.result, opts.keys);
|
|
539
|
+
} else {
|
|
540
|
+
inferred = callerTimeline(opts.timeline, opts.key, opts.keys, pooled, meters, budget);
|
|
541
|
+
}
|
|
542
|
+
const { timeline, keys, prevailingKey, segmentConfidence } = inferred;
|
|
543
|
+
const keyAt = keyLookup(keys, prevailingKey);
|
|
544
|
+
const scaleAt = (beat) => scaleOf(keyAt(beat));
|
|
545
|
+
const cadences = detectCadences(timeline, scaleAt);
|
|
546
|
+
const soundingCache = /* @__PURE__ */ new Map();
|
|
547
|
+
const trackAnalyses = [];
|
|
548
|
+
const conflicts = [];
|
|
549
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
550
|
+
const track = prepared[t];
|
|
551
|
+
if (!track) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (isPercussion(track.role)) {
|
|
555
|
+
trackAnalyses.push({ name: track.name, role: track.role, notes: [] });
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const notes = [];
|
|
559
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
560
|
+
const subVoice = track.voices[v];
|
|
561
|
+
if (!subVoice) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const analyzed = analyzeVoice(
|
|
565
|
+
subVoice.voice,
|
|
566
|
+
timeline.at,
|
|
567
|
+
scaleAt,
|
|
568
|
+
(beat) => snapshotsOf(otherVoicesSounding(prepared, t, v, beat, soundingCache))
|
|
569
|
+
);
|
|
570
|
+
for (const note of analyzed) {
|
|
571
|
+
notes.push({ ...note, trackIndex: track.trackIndex });
|
|
572
|
+
}
|
|
573
|
+
const labelsById = new Map(analyzed.map((note) => [note.noteId, note.labels]));
|
|
574
|
+
for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
|
|
575
|
+
const note = subVoice.voice[noteIndex];
|
|
576
|
+
const preparedNote = subVoice.sounding[noteIndex];
|
|
577
|
+
if (note === void 0 || preparedNote === void 0) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
for (const beat of evaluationBeats(note, timeline)) {
|
|
581
|
+
const atOnset = sameInstant(beat, note.startBeat);
|
|
582
|
+
const others = otherVoicesSounding(prepared, t, v, beat, soundingCache);
|
|
583
|
+
const safetyQuery = {
|
|
584
|
+
profile,
|
|
585
|
+
candidatePitch: note.pitch,
|
|
586
|
+
prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
|
|
587
|
+
chord: timeline.at(beat),
|
|
588
|
+
key: scaleAt(beat),
|
|
589
|
+
otherVoices: snapshotsOf(others),
|
|
590
|
+
strongBeat: isStrongBeat(beat, meters)
|
|
591
|
+
};
|
|
592
|
+
let result = evaluateSafety(safetyQuery, { suggestions: false });
|
|
593
|
+
if (result.safety !== 0 /* Safe */ && result.safety >= minSeverity) {
|
|
594
|
+
result = evaluateSafety(safetyQuery, { partners: true });
|
|
595
|
+
const conflict = {
|
|
596
|
+
beat,
|
|
597
|
+
trackName: track.name,
|
|
598
|
+
trackIndex: track.trackIndex,
|
|
599
|
+
noteId: note.id,
|
|
600
|
+
pitch: note.pitch,
|
|
601
|
+
labels: labelsById.get(note.id) ?? [],
|
|
602
|
+
safety: result.safety,
|
|
603
|
+
reasons: result.reasons
|
|
604
|
+
};
|
|
605
|
+
if (note.originalIndex !== void 0) {
|
|
606
|
+
conflict.originalIndex = note.originalIndex;
|
|
607
|
+
}
|
|
608
|
+
if (result.rationale !== void 0) {
|
|
609
|
+
conflict.rationale = result.rationale;
|
|
610
|
+
}
|
|
611
|
+
if (result.resolveTo !== void 0) {
|
|
612
|
+
conflict.resolveTo = result.resolveTo;
|
|
613
|
+
}
|
|
614
|
+
if (result.suggestions !== void 0) {
|
|
615
|
+
conflict.suggestions = [...result.suggestions];
|
|
616
|
+
}
|
|
617
|
+
if (result.partners !== void 0) {
|
|
618
|
+
conflict.partners = result.partners.map(
|
|
619
|
+
({ index, reasons }) => conflictPartner(others[index], reasons)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
conflicts.push(conflict);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
notes.sort((a, b) => a.noteId - b.noteId);
|
|
628
|
+
trackAnalyses.push({ name: track.name, role: track.role, notes });
|
|
629
|
+
}
|
|
630
|
+
conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
|
|
631
|
+
const analysis = {
|
|
632
|
+
keys,
|
|
633
|
+
prevailingKey,
|
|
634
|
+
timeline,
|
|
635
|
+
segmentConfidence,
|
|
636
|
+
cadences,
|
|
637
|
+
tracks: trackAnalyses,
|
|
638
|
+
conflicts
|
|
639
|
+
};
|
|
640
|
+
return evidence === void 0 ? { analysis } : { analysis, evidence };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/analyze/arrange/session.ts
|
|
644
|
+
function noteId(note) {
|
|
645
|
+
return `${note.pitch}|${note.startBeat}|${note.durationBeat}|${note.velocity ?? ""}`;
|
|
646
|
+
}
|
|
647
|
+
function sameOrder(before, after) {
|
|
648
|
+
if (before.length !== after.length) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
for (let index = 0; index < before.length; index += 1) {
|
|
652
|
+
const previous = before[index];
|
|
653
|
+
const current = after[index];
|
|
654
|
+
if (previous === void 0 || current === void 0 || noteId(previous) !== noteId(current)) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
function editedSpan(before, after) {
|
|
661
|
+
const counts = /* @__PURE__ */ new Map();
|
|
662
|
+
const tally = (notes, sign) => {
|
|
663
|
+
for (const note of notes) {
|
|
664
|
+
const id = noteId(note);
|
|
665
|
+
const entry = counts.get(id);
|
|
666
|
+
if (entry === void 0) {
|
|
667
|
+
counts.set(id, {
|
|
668
|
+
count: sign,
|
|
669
|
+
startBeat: note.startBeat,
|
|
670
|
+
endBeat: note.startBeat + note.durationBeat
|
|
671
|
+
});
|
|
672
|
+
} else {
|
|
673
|
+
entry.count += sign;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
tally(before, 1);
|
|
678
|
+
tally(after, -1);
|
|
679
|
+
let startBeat = Number.POSITIVE_INFINITY;
|
|
680
|
+
let endBeat = Number.NEGATIVE_INFINITY;
|
|
681
|
+
for (const entry of counts.values()) {
|
|
682
|
+
if (entry.count === 0) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
startBeat = Math.min(startBeat, entry.startBeat);
|
|
686
|
+
endBeat = Math.max(endBeat, entry.endBeat, entry.startBeat);
|
|
687
|
+
}
|
|
688
|
+
return startBeat <= endBeat ? { startBeat, endBeat } : null;
|
|
689
|
+
}
|
|
690
|
+
function unionSpan(a, b) {
|
|
691
|
+
if (a === null) {
|
|
692
|
+
return b;
|
|
693
|
+
}
|
|
694
|
+
if (b === null) {
|
|
695
|
+
return a;
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
startBeat: Math.min(a.startBeat, b.startBeat),
|
|
699
|
+
endBeat: Math.max(a.endBeat, b.endBeat)
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function sessionOf(tracks, opts, analysis, evidence) {
|
|
703
|
+
return {
|
|
704
|
+
tracks,
|
|
705
|
+
analysis,
|
|
706
|
+
update(edits) {
|
|
707
|
+
assertGenerationBudget(edits.length, "arrangement edits", opts.budget);
|
|
708
|
+
const next = [...tracks];
|
|
709
|
+
let edited = null;
|
|
710
|
+
let reordered = false;
|
|
711
|
+
for (let index = 0; index < edits.length; index += 1) {
|
|
712
|
+
const edit = edits[index];
|
|
713
|
+
if (edit === void 0) {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const target = next[edit.trackIndex];
|
|
717
|
+
if (target === void 0) {
|
|
718
|
+
throw new InvalidInputError(
|
|
719
|
+
`edits[${index}].trackIndex ${edit.trackIndex} is outside the session's ${tracks.length} tracks`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (!Array.isArray(edit.notes)) {
|
|
723
|
+
throw new InvalidInputError(
|
|
724
|
+
`edits[${index}].notes must be an array; received ${typeof edit.notes}`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
assertNoteEvents(edit.notes, `edits[${index}].notes`, {
|
|
728
|
+
allowNonPositiveDuration: true,
|
|
729
|
+
budget: opts.budget
|
|
730
|
+
});
|
|
731
|
+
edited = unionSpan(edited, editedSpan(target.notes, edit.notes));
|
|
732
|
+
reordered = reordered || !sameOrder(target.notes, edit.notes);
|
|
733
|
+
next[edit.trackIndex] = { ...target, notes: edit.notes };
|
|
734
|
+
}
|
|
735
|
+
if (edited === null && !reordered) {
|
|
736
|
+
return sessionOf(next, opts, analysis, evidence);
|
|
737
|
+
}
|
|
738
|
+
const dirty = evidence === void 0 ? void 0 : edited === null ? { from: 0, to: 0 } : dirtySlotsFor(evidence, edited.startBeat, edited.endBeat);
|
|
739
|
+
const run = analyzeArrangementWith(next, opts, evidence, dirty);
|
|
740
|
+
return sessionOf(next, opts, run.analysis, run.evidence);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function createArrangementSession(tracks, opts = {}) {
|
|
745
|
+
const run = analyzeArrangementWith(tracks, opts);
|
|
746
|
+
return sessionOf([...tracks], opts, run.analysis, run.evidence);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/analyze/arrange/tension.ts
|
|
750
|
+
var FUNCTION_TENSION = {
|
|
751
|
+
tonic: 0,
|
|
752
|
+
subdominant: 0.5,
|
|
753
|
+
dominant: 1
|
|
754
|
+
};
|
|
755
|
+
var FUNCTION_WEIGHT = 0.5;
|
|
756
|
+
var DISSONANCE_WEIGHT = 0.35;
|
|
757
|
+
var SPAN_WEIGHT = 0.15;
|
|
758
|
+
var SPAN_SATURATION = 24;
|
|
759
|
+
function clamp012(value) {
|
|
760
|
+
return Math.min(1, Math.max(0, value));
|
|
761
|
+
}
|
|
762
|
+
function tensionCurve(tracks, opts = {}) {
|
|
763
|
+
const meters = resolveMeters(opts, "arrangement meters");
|
|
764
|
+
const budget = opts.budget;
|
|
765
|
+
const noteOptions = { allowNonPositiveDuration: true, budget };
|
|
766
|
+
if (opts.pickupBeats !== void 0) {
|
|
767
|
+
assertRange(opts.pickupBeats, 0, Number.MAX_SAFE_INTEGER, "arrangement pickupBeats");
|
|
768
|
+
noteOptions.minStartBeat = -opts.pickupBeats;
|
|
769
|
+
}
|
|
770
|
+
assertArray(tracks, "arrangement tracks");
|
|
771
|
+
assertGenerationBudget(tracks.length, "arrangement tracks", budget);
|
|
772
|
+
const noteCount = assertTrackNotes(tracks, noteOptions);
|
|
773
|
+
assertGenerationBudget(noteCount, "arrangement notes", budget);
|
|
774
|
+
const profile = arrangementProfile(opts.profile);
|
|
775
|
+
const step = opts.step ?? 1;
|
|
776
|
+
assertRange(step, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "tension sampling step");
|
|
777
|
+
if (opts.minSeverity !== void 0) {
|
|
778
|
+
assertInteger(
|
|
779
|
+
opts.minSeverity,
|
|
780
|
+
"arrangement minSeverity",
|
|
781
|
+
0 /* Safe */,
|
|
782
|
+
2 /* Dissonant */
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
assertGivenTimeline(opts.timeline, "arrangement timeline");
|
|
786
|
+
assertGivenKeys(opts.keys, "arrangement keys");
|
|
787
|
+
const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks);
|
|
788
|
+
const pooled = poolNotes(tracks, harmonyTracks);
|
|
789
|
+
const all = poolNotes(tracks);
|
|
790
|
+
const totalBeats = all.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
791
|
+
const { origin: sampleStart, startBeat: musicStart } = gridForNotes(all, step);
|
|
792
|
+
const suppliedKeys = opts.timeline === void 0 ? void 0 : opts.keys ?? (opts.key !== void 0 ? [
|
|
793
|
+
{
|
|
794
|
+
// The one region a named key holds starts where the music does,
|
|
795
|
+
// as the searched regions and the timeline's own key window do:
|
|
796
|
+
// a key cannot be in force over beats nothing sounds in.
|
|
797
|
+
startBeat: musicStart,
|
|
798
|
+
endBeat: totalBeats,
|
|
799
|
+
key: resolveKey(opts.key),
|
|
800
|
+
confidence: 1
|
|
801
|
+
}
|
|
802
|
+
] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }));
|
|
803
|
+
const { timeline, keys, prevailingKey } = opts.timeline === void 0 || suppliedKeys === void 0 ? chordTimelineFromNotes(pooled, {
|
|
804
|
+
key: opts.key,
|
|
805
|
+
meters,
|
|
806
|
+
pickupBeats: opts.pickupBeats,
|
|
807
|
+
harmonicRhythm: opts.harmonicRhythm,
|
|
808
|
+
budget
|
|
809
|
+
}) : {
|
|
810
|
+
timeline: opts.timeline,
|
|
811
|
+
keys: suppliedKeys,
|
|
812
|
+
prevailingKey: prevailingKeyOf(suppliedKeys) ?? resolveKey(majorKey(0))
|
|
813
|
+
};
|
|
814
|
+
const regions = opts.keys ?? keys;
|
|
815
|
+
const keyAt = keyLookup(regions, prevailingKeyOf(regions) ?? prevailingKey);
|
|
816
|
+
const prepared = prepareTracks(tracks);
|
|
817
|
+
const points = [];
|
|
818
|
+
const sampleCount = Math.max(0, Math.ceil((totalBeats - sampleStart) / step - BEAT_EPS));
|
|
819
|
+
assertGenerationBudget(sampleCount, "tension samples", budget);
|
|
820
|
+
const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
|
|
821
|
+
assertGenerationBudget(
|
|
822
|
+
sampleCount * Math.min(128, voiceCount) * voiceCount,
|
|
823
|
+
"tension sample-voice comparisons",
|
|
824
|
+
budget
|
|
825
|
+
);
|
|
826
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
827
|
+
const beat = i === 0 ? Math.max(sampleStart, musicStart) : sampleStart + i * step;
|
|
828
|
+
points.push({
|
|
829
|
+
beat,
|
|
830
|
+
// The pitch classes alone: tension is scored from what sounds against
|
|
831
|
+
// the tonic, and no reading below here asks how the key is written.
|
|
832
|
+
tension: sampleTension(prepared, timeline, scaleOf(keyAt(beat)), meters, profile, beat)
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
return points;
|
|
836
|
+
}
|
|
837
|
+
function tensionCurveFrom(tracks, analysis, opts = {}) {
|
|
838
|
+
const asked = assertRecord(opts, "opts");
|
|
839
|
+
const carried = carriedHarmony(assertRecord(analysis, "analysis"), asked);
|
|
840
|
+
return tensionCurve(tracks, { ...carried, ...asked });
|
|
841
|
+
}
|
|
842
|
+
function carriedHarmony(analysis, opts) {
|
|
843
|
+
if (opts.harmonyTracks !== void 0) {
|
|
844
|
+
return {};
|
|
845
|
+
}
|
|
846
|
+
if (opts.key !== void 0 || opts.keys !== void 0) {
|
|
847
|
+
return { timeline: analysis.timeline };
|
|
848
|
+
}
|
|
849
|
+
return { timeline: analysis.timeline, keys: analysis.keys };
|
|
850
|
+
}
|
|
851
|
+
function allSounding(prepared, beat) {
|
|
852
|
+
const out = [];
|
|
853
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
854
|
+
const track = prepared[t];
|
|
855
|
+
if (!track || isPercussion(track.role)) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
for (const subVoice of track.voices) {
|
|
859
|
+
const note = subVoice.at(beat);
|
|
860
|
+
if (note !== void 0) {
|
|
861
|
+
out.push({ pitch: note.pitch, track: t });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
function sampleTension(prepared, timeline, key, meters, profile, beat) {
|
|
868
|
+
const sounding = allSounding(prepared, beat);
|
|
869
|
+
if (sounding.length === 0) {
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
const chord = timeline.at(beat);
|
|
873
|
+
const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
|
|
874
|
+
const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
|
|
875
|
+
const strongBeat = isStrongBeat(beat, meters);
|
|
876
|
+
let nonChord = 0;
|
|
877
|
+
let dissonant = 0;
|
|
878
|
+
const pitches = /* @__PURE__ */ new Map();
|
|
879
|
+
for (const voice of sounding) {
|
|
880
|
+
pitches.set(voice.pitch, (pitches.get(voice.pitch) ?? 0) + 1);
|
|
881
|
+
}
|
|
882
|
+
const others = [];
|
|
883
|
+
const firstOf = /* @__PURE__ */ new Map();
|
|
884
|
+
for (const [pitch, count] of pitches) {
|
|
885
|
+
firstOf.set(pitch, others.length);
|
|
886
|
+
for (let i = 0; i < count; i += 1) {
|
|
887
|
+
others.push({ pitch });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
for (const [pitch, count] of pitches) {
|
|
891
|
+
if (chordPcs && !chordPcs.has(pitchClassOf(pitch))) {
|
|
892
|
+
nonChord += count;
|
|
893
|
+
}
|
|
894
|
+
const at = firstOf.get(pitch) ?? 0;
|
|
895
|
+
const last = others.length - 1;
|
|
896
|
+
const held = others[at];
|
|
897
|
+
const tail = others[last];
|
|
898
|
+
if (held !== void 0 && tail !== void 0) {
|
|
899
|
+
others[at] = tail;
|
|
900
|
+
others.pop();
|
|
901
|
+
}
|
|
902
|
+
const result = evaluateSafety(
|
|
903
|
+
{ profile, candidatePitch: pitch, chord, key, otherVoices: others, strongBeat },
|
|
904
|
+
{ suggestions: false }
|
|
905
|
+
);
|
|
906
|
+
if (held !== void 0 && tail !== void 0) {
|
|
907
|
+
others.push(tail);
|
|
908
|
+
others[at] = held;
|
|
909
|
+
}
|
|
910
|
+
if (result.safety === 2 /* Dissonant */) {
|
|
911
|
+
dissonant += count;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
|
|
915
|
+
let low = Number.POSITIVE_INFINITY;
|
|
916
|
+
let high = Number.NEGATIVE_INFINITY;
|
|
917
|
+
for (const voice of sounding) {
|
|
918
|
+
low = Math.min(low, voice.pitch);
|
|
919
|
+
high = Math.max(high, voice.pitch);
|
|
920
|
+
}
|
|
921
|
+
const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
|
|
922
|
+
return clamp012(
|
|
923
|
+
FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/analyze/form/hypermeter.ts
|
|
928
|
+
var GROUP_CANDIDATES = [2, 3, 4, 6, 8];
|
|
929
|
+
var GROUP_PRIOR = /* @__PURE__ */ new Map([
|
|
930
|
+
[2, 0.9],
|
|
931
|
+
[3, 0.75],
|
|
932
|
+
[4, 1],
|
|
933
|
+
[6, 0.7],
|
|
934
|
+
[8, 0.85]
|
|
935
|
+
]);
|
|
936
|
+
var CONTRAST_SHARE = 0.65;
|
|
937
|
+
var CADENCE_SHARE = 0.35;
|
|
938
|
+
var GROUP_FINAL_FIT = 1;
|
|
939
|
+
var ELIDED_FIT = 0.5;
|
|
940
|
+
var MIN_GROUPS = 2;
|
|
941
|
+
var MARGIN_FLOOR = 0.7;
|
|
942
|
+
function accentContrast(novelty, slices, groupBars, phase) {
|
|
943
|
+
let headSum = 0;
|
|
944
|
+
let headCount = 0;
|
|
945
|
+
let otherSum = 0;
|
|
946
|
+
let otherCount = 0;
|
|
947
|
+
for (let i = 0; i < slices.length; i += 1) {
|
|
948
|
+
const slice = slices[i];
|
|
949
|
+
if (slice === void 0) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const value = novelty[i] ?? 0;
|
|
953
|
+
if (floorMod(slice.index - phase, groupBars) === 0) {
|
|
954
|
+
headSum += value;
|
|
955
|
+
headCount += 1;
|
|
956
|
+
} else {
|
|
957
|
+
otherSum += value;
|
|
958
|
+
otherCount += 1;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (headCount === 0 || otherCount === 0) {
|
|
962
|
+
return 0;
|
|
963
|
+
}
|
|
964
|
+
return headSum / headCount - otherSum / otherCount;
|
|
965
|
+
}
|
|
966
|
+
function cadenceFitOf(cadenceBars, slices, groupBars, phase) {
|
|
967
|
+
const lastBar = slices[slices.length - 1]?.index ?? -1;
|
|
968
|
+
let ends = 0;
|
|
969
|
+
let fit = 0;
|
|
970
|
+
for (const slice of slices) {
|
|
971
|
+
if (slice.index < 0 || floorMod(slice.index - phase, groupBars) !== groupBars - 1) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
ends += 1;
|
|
975
|
+
if (cadenceBars.has(slice.index)) {
|
|
976
|
+
fit += GROUP_FINAL_FIT;
|
|
977
|
+
} else if (slice.index < lastBar && cadenceBars.has(slice.index + 1)) {
|
|
978
|
+
fit += ELIDED_FIT;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return ends === 0 ? 0 : clamp01(fit / ends);
|
|
982
|
+
}
|
|
983
|
+
function undecidedReading(slices) {
|
|
984
|
+
const fullBars = slices.filter((slice) => slice.index >= 0);
|
|
985
|
+
const groupBars = Math.max(1, fullBars.length);
|
|
986
|
+
const first = fullBars[0];
|
|
987
|
+
return {
|
|
988
|
+
groupBars,
|
|
989
|
+
downbeats: first === void 0 ? [] : [first.startBeat],
|
|
990
|
+
confidence: 0,
|
|
991
|
+
rationale: `Span of ${fullBars.length} bar(s) is too short to establish a grouping; read as one group`
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
function hypermeter(notes, meter, opts = {}) {
|
|
995
|
+
const asked = assertOptions(opts, "opts");
|
|
996
|
+
const meters = resolveMeters({ meters: meter }, "meter");
|
|
997
|
+
assertNoteEvents(notes, "hypermeter notes", {
|
|
998
|
+
allowNonPositiveDuration: true,
|
|
999
|
+
budget: asked.budget
|
|
1000
|
+
});
|
|
1001
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
1002
|
+
const firstOnset = firstSoundingBeat(sounding);
|
|
1003
|
+
const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
1004
|
+
const spanEnd = asked.totalBeats ?? lastEnd;
|
|
1005
|
+
assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "hypermeter totalBeats");
|
|
1006
|
+
const spanStart = barGridStart(firstOnset, meters);
|
|
1007
|
+
const slices = sliceBars(sounding, meters, spanStart, spanEnd, asked.budget);
|
|
1008
|
+
const fullBars = slices.reduce((count, slice) => slice.index >= 0 ? count + 1 : count, 0);
|
|
1009
|
+
const candidates = GROUP_CANDIDATES.filter((groupBars) => fullBars >= groupBars * MIN_GROUPS);
|
|
1010
|
+
if (candidates.length === 0) {
|
|
1011
|
+
return undecidedReading(slices);
|
|
1012
|
+
}
|
|
1013
|
+
assertGenerationBudget(
|
|
1014
|
+
candidates.reduce((sum, groupBars) => sum + groupBars * slices.length, 0),
|
|
1015
|
+
"hypermeter readings",
|
|
1016
|
+
asked.budget
|
|
1017
|
+
);
|
|
1018
|
+
const novelty = slices.map((_, index) => harmonicNovelty(slices, index));
|
|
1019
|
+
const cadenceBars = new Set((asked.cadenceBeats ?? []).map((beat) => barIndexAt(beat, meters)));
|
|
1020
|
+
const hasCadences = cadenceBars.size > 0;
|
|
1021
|
+
const readings = [];
|
|
1022
|
+
for (const groupBars of candidates) {
|
|
1023
|
+
const phases = Array.from({ length: groupBars }, (_, phase) => ({
|
|
1024
|
+
phase,
|
|
1025
|
+
contrast: accentContrast(novelty, slices, groupBars, phase),
|
|
1026
|
+
cadenceFit: cadenceFitOf(cadenceBars, slices, groupBars, phase)
|
|
1027
|
+
}));
|
|
1028
|
+
const bestFit = phases.reduce((best2, reading) => Math.max(best2, reading.cadenceFit), 0);
|
|
1029
|
+
for (const { phase, contrast, cadenceFit } of phases) {
|
|
1030
|
+
if (hasCadences && cadenceFit < bestFit - BEAT_EPS) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const evidence = clamp01(
|
|
1034
|
+
hasCadences ? (
|
|
1035
|
+
// Floored at zero rather than summed raw: group heads that restate
|
|
1036
|
+
// the harmony their group opened with — the commonest way a phrase
|
|
1037
|
+
// begins — change less than the bars between them, and a negative
|
|
1038
|
+
// contrast subtracted from the cadence term would count that
|
|
1039
|
+
// restatement as evidence against the very phase it confirms.
|
|
1040
|
+
CONTRAST_SHARE * clamp01(contrast) + CADENCE_SHARE * cadenceFit
|
|
1041
|
+
) : contrast
|
|
1042
|
+
);
|
|
1043
|
+
readings.push({
|
|
1044
|
+
groupBars,
|
|
1045
|
+
phase,
|
|
1046
|
+
contrast,
|
|
1047
|
+
cadenceFit,
|
|
1048
|
+
evidence,
|
|
1049
|
+
score: evidence * (GROUP_PRIOR.get(groupBars) ?? 0)
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
readings.sort(
|
|
1054
|
+
(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
|
|
1055
|
+
);
|
|
1056
|
+
const best = readings[0];
|
|
1057
|
+
if (best === void 0) {
|
|
1058
|
+
return undecidedReading(slices);
|
|
1059
|
+
}
|
|
1060
|
+
const runnerUp = readings.find((reading) => reading.groupBars !== best.groupBars);
|
|
1061
|
+
const margin = runnerUp === void 0 || best.score <= BEAT_EPS ? 1 : clamp01((best.score - runnerUp.score) / best.score);
|
|
1062
|
+
const downbeats = slices.filter((slice) => slice.index >= 0 && floorMod(slice.index - best.phase, best.groupBars) === 0).map((slice) => slice.startBeat);
|
|
1063
|
+
const cadencePart = hasCadences ? `${Math.round(best.cadenceFit * 100)}% of group ends closed by a cadence` : "no cadences given";
|
|
1064
|
+
return {
|
|
1065
|
+
groupBars: best.groupBars,
|
|
1066
|
+
downbeats,
|
|
1067
|
+
// Mostly the evidence, discounted when a rival grouping explains the
|
|
1068
|
+
// accents nearly as well. The discount is the smaller term because the
|
|
1069
|
+
// runner-up is nearly always a multiple or a divisor of the winner — four
|
|
1070
|
+
// bars nest inside eight — and a reading of the same accents at another
|
|
1071
|
+
// level is weaker counter-evidence than an unrelated rival would be.
|
|
1072
|
+
confidence: clamp01(best.evidence * (MARGIN_FLOOR + (1 - MARGIN_FLOOR) * margin)),
|
|
1073
|
+
rationale: `${best.groupBars}-bar groups from bar ${best.phase}: harmonic change ${Math.round(best.contrast * 100)}% stronger on group heads, ${cadencePart}`
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/analyze/melody/internal.ts
|
|
1078
|
+
var RHYTHM_GRID = 64;
|
|
1079
|
+
function phraseNotes(phrase) {
|
|
1080
|
+
if (Array.isArray(phrase)) {
|
|
1081
|
+
return phrase;
|
|
1082
|
+
}
|
|
1083
|
+
return assertRecord(phrase, "melodic phrase").notes;
|
|
1084
|
+
}
|
|
1085
|
+
function orderedNotes(notes, name, budget) {
|
|
1086
|
+
assertNoteEvents(notes, name, { allowNonPositiveDuration: true, budget });
|
|
1087
|
+
const sounding = notes.filter((note) => note.durationBeat > 0).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
|
|
1088
|
+
const line = [];
|
|
1089
|
+
for (const note of sounding) {
|
|
1090
|
+
const previous = line[line.length - 1];
|
|
1091
|
+
if (previous !== void 0 && Math.abs(note.startBeat - previous.startBeat) <= BEAT_EPS) {
|
|
1092
|
+
line[line.length - 1] = note;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
line.push(note);
|
|
1096
|
+
}
|
|
1097
|
+
return line;
|
|
1098
|
+
}
|
|
1099
|
+
function intervalsOf(notes) {
|
|
1100
|
+
const out = [];
|
|
1101
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
1102
|
+
out.push((notes[i]?.pitch ?? 0) - (notes[i - 1]?.pitch ?? 0));
|
|
1103
|
+
}
|
|
1104
|
+
return out;
|
|
1105
|
+
}
|
|
1106
|
+
function onsetGaps(notes) {
|
|
1107
|
+
const out = [];
|
|
1108
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
1109
|
+
out.push((notes[i]?.startBeat ?? 0) - (notes[i - 1]?.startBeat ?? 0));
|
|
1110
|
+
}
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
function retrogradeGaps(notes) {
|
|
1114
|
+
const out = [];
|
|
1115
|
+
for (let i = notes.length - 1; i >= 1; i -= 1) {
|
|
1116
|
+
const later = notes[i];
|
|
1117
|
+
const earlier = notes[i - 1];
|
|
1118
|
+
out.push(
|
|
1119
|
+
(later?.startBeat ?? 0) + (later?.durationBeat ?? 0) - ((earlier?.startBeat ?? 0) + (earlier?.durationBeat ?? 0))
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
function rhythmProfile(gaps) {
|
|
1125
|
+
const unit = gaps[0] ?? 0;
|
|
1126
|
+
if (!(unit > BEAT_EPS)) {
|
|
1127
|
+
return gaps.length === 0 ? [] : null;
|
|
1128
|
+
}
|
|
1129
|
+
return gaps.map((gap) => Math.round(gap / unit * RHYTHM_GRID) / RHYTHM_GRID);
|
|
1130
|
+
}
|
|
1131
|
+
function sameNumbers(a, b) {
|
|
1132
|
+
if (a.length !== b.length) {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
1136
|
+
if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > BEAT_EPS) {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
function roundTo(value, digits) {
|
|
1143
|
+
const scale = 10 ** digits;
|
|
1144
|
+
return Math.round(value * scale) / scale;
|
|
1145
|
+
}
|
|
1146
|
+
function endBeatOf(notes) {
|
|
1147
|
+
let end = 0;
|
|
1148
|
+
for (const note of notes) {
|
|
1149
|
+
end = Math.max(end, note.startBeat + note.durationBeat);
|
|
1150
|
+
}
|
|
1151
|
+
return end;
|
|
1152
|
+
}
|
|
1153
|
+
function onsetSpan(notes) {
|
|
1154
|
+
if (notes.length < 2) {
|
|
1155
|
+
return 0;
|
|
1156
|
+
}
|
|
1157
|
+
return (notes[notes.length - 1]?.startBeat ?? 0) - (notes[0]?.startBeat ?? 0);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// src/analyze/melody/contour.ts
|
|
1161
|
+
var DIRECTIONAL_SHARE = 0.75;
|
|
1162
|
+
function extremes(notes) {
|
|
1163
|
+
if (notes.length === 0) {
|
|
1164
|
+
return { peak: -1, trough: -1 };
|
|
1165
|
+
}
|
|
1166
|
+
let peak = 0;
|
|
1167
|
+
let trough = 0;
|
|
1168
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
1169
|
+
if ((notes[i]?.pitch ?? 0) > (notes[peak]?.pitch ?? 0)) {
|
|
1170
|
+
peak = i;
|
|
1171
|
+
}
|
|
1172
|
+
if ((notes[i]?.pitch ?? 0) < (notes[trough]?.pitch ?? 0)) {
|
|
1173
|
+
trough = i;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return { peak, trough };
|
|
1177
|
+
}
|
|
1178
|
+
function melodicContour(notes) {
|
|
1179
|
+
const line = orderedNotes(phraseNotes(notes), "contour notes");
|
|
1180
|
+
const intervals = intervalsOf(line);
|
|
1181
|
+
const directions = intervals.map(
|
|
1182
|
+
(step) => step > 0 ? "up" : step < 0 ? "down" : "same"
|
|
1183
|
+
);
|
|
1184
|
+
const { peak, trough } = extremes(line);
|
|
1185
|
+
const range = (line[peak]?.pitch ?? 0) - (line[trough]?.pitch ?? 0);
|
|
1186
|
+
const motion = intervals.reduce((sum, step) => sum + Math.abs(step), 0);
|
|
1187
|
+
if (motion === 0) {
|
|
1188
|
+
return {
|
|
1189
|
+
directions,
|
|
1190
|
+
shape: "static",
|
|
1191
|
+
peakIndex: peak,
|
|
1192
|
+
troughIndex: trough,
|
|
1193
|
+
range: 0,
|
|
1194
|
+
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"
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const first = line[0]?.pitch ?? 0;
|
|
1198
|
+
const last = line[line.length - 1]?.pitch ?? 0;
|
|
1199
|
+
const net = last - first;
|
|
1200
|
+
const directness = Math.abs(net) / motion;
|
|
1201
|
+
const share = Math.round(directness * 100);
|
|
1202
|
+
if (directness >= DIRECTIONAL_SHARE) {
|
|
1203
|
+
const shape = net > 0 ? "ascending" : "descending";
|
|
1204
|
+
return {
|
|
1205
|
+
directions,
|
|
1206
|
+
shape,
|
|
1207
|
+
peakIndex: peak,
|
|
1208
|
+
troughIndex: trough,
|
|
1209
|
+
range,
|
|
1210
|
+
rationale: `${share}% of the motion runs ${net > 0 ? "up" : "down"}, so the line is ${shape}`
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const peakPitch = line[peak]?.pitch ?? 0;
|
|
1214
|
+
const archMotion = peakPitch - first + (peakPitch - last);
|
|
1215
|
+
if (peak > 0 && peak < line.length - 1 && archMotion >= DIRECTIONAL_SHARE * motion) {
|
|
1216
|
+
return {
|
|
1217
|
+
directions,
|
|
1218
|
+
shape: "arch",
|
|
1219
|
+
peakIndex: peak,
|
|
1220
|
+
troughIndex: trough,
|
|
1221
|
+
range,
|
|
1222
|
+
rationale: `Rises to its peak at note ${peak + 1} of ${line.length} and falls back, so the line is an arch`
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
directions,
|
|
1227
|
+
shape: "wave",
|
|
1228
|
+
peakIndex: peak,
|
|
1229
|
+
troughIndex: trough,
|
|
1230
|
+
range,
|
|
1231
|
+
rationale: `Changes direction without settling either way (${share}% of the motion nets out), so the line is a wave`
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/analyze/melody/motifs.ts
|
|
1236
|
+
var MIN_CELL_NOTES = 3;
|
|
1237
|
+
var MAX_CELL_NOTES = 8;
|
|
1238
|
+
var MIN_OCCURRENCES = 2;
|
|
1239
|
+
function windowSignature(notes) {
|
|
1240
|
+
const profile = rhythmProfile(onsetGaps(notes));
|
|
1241
|
+
if (profile === null) {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
return `${notes.length}|${intervalsOf(notes).join(",")}|${profile.join(",")}`;
|
|
1245
|
+
}
|
|
1246
|
+
function nonOverlapping(starts, length) {
|
|
1247
|
+
const picked = [];
|
|
1248
|
+
let lastEnd = Number.NEGATIVE_INFINITY;
|
|
1249
|
+
for (const start of starts) {
|
|
1250
|
+
if (start > lastEnd) {
|
|
1251
|
+
picked.push(start);
|
|
1252
|
+
lastEnd = start + length - 1;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return picked;
|
|
1256
|
+
}
|
|
1257
|
+
function subsumes(outer, inner) {
|
|
1258
|
+
let at = 0;
|
|
1259
|
+
for (const start of inner.starts) {
|
|
1260
|
+
while (at + 1 < outer.starts.length && (outer.starts[at + 1] ?? 0) <= start) {
|
|
1261
|
+
at += 1;
|
|
1262
|
+
}
|
|
1263
|
+
const from = outer.starts[at];
|
|
1264
|
+
if (from === void 0 || start < from || start + inner.length > from + outer.length) {
|
|
1265
|
+
return false;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
function listBeats(occurrences) {
|
|
1271
|
+
const shown = occurrences.slice(0, 5).map((occurrence) => roundTo(occurrence.startBeat, 3));
|
|
1272
|
+
return occurrences.length > shown.length ? `${shown.join(", ")}, and ${occurrences.length - shown.length} more` : shown.join(", ");
|
|
1273
|
+
}
|
|
1274
|
+
function motifFromGroup(group, notes) {
|
|
1275
|
+
const first = group.starts[0] ?? 0;
|
|
1276
|
+
const prime = notes.slice(first, first + group.length).map((note) => ({ ...note }));
|
|
1277
|
+
const primePitch = prime[0]?.pitch ?? 0;
|
|
1278
|
+
const primeSpan = onsetSpan(prime);
|
|
1279
|
+
const occurrences = group.starts.map((start) => {
|
|
1280
|
+
const statement = notes.slice(start, start + group.length);
|
|
1281
|
+
const span = onsetSpan(statement);
|
|
1282
|
+
return {
|
|
1283
|
+
noteIndex: start,
|
|
1284
|
+
startBeat: statement[0]?.startBeat ?? 0,
|
|
1285
|
+
endBeat: endBeatOf(statement),
|
|
1286
|
+
transpose: (statement[0]?.pitch ?? 0) - primePitch,
|
|
1287
|
+
timeRatio: primeSpan > BEAT_EPS ? span / primeSpan : 1
|
|
1288
|
+
};
|
|
1289
|
+
});
|
|
1290
|
+
const shape = melodicContour(prime).shape;
|
|
1291
|
+
const transposed = occurrences.some((occurrence) => occurrence.transpose !== 0);
|
|
1292
|
+
const stretched = occurrences.some((occurrence) => Math.abs(occurrence.timeRatio - 1) > BEAT_EPS);
|
|
1293
|
+
const detail = [
|
|
1294
|
+
transposed ? "at more than one pitch level" : "at the same pitch level",
|
|
1295
|
+
stretched ? "and in more than one set of note values" : ""
|
|
1296
|
+
].filter((part) => part !== "").join(" ");
|
|
1297
|
+
return {
|
|
1298
|
+
notes: prime,
|
|
1299
|
+
intervals: intervalsOf(prime),
|
|
1300
|
+
rhythm: rhythmProfile(onsetGaps(prime)) ?? [],
|
|
1301
|
+
occurrences,
|
|
1302
|
+
rationale: `${group.length}-note ${shape} cell stated ${occurrences.length} ${occurrences.length === 1 ? "time" : "times"} (beats ${listBeats(occurrences)}), ${detail}`
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function extractMotifs(notes, opts = {}) {
|
|
1306
|
+
const asked = assertOptions(opts, "opts");
|
|
1307
|
+
const minNotes = assertInteger(asked.minNotes ?? MIN_CELL_NOTES, "motif minNotes", 2, 64);
|
|
1308
|
+
const maxNotes = assertInteger(asked.maxNotes ?? MAX_CELL_NOTES, "motif maxNotes", 2, 64);
|
|
1309
|
+
if (maxNotes < minNotes) {
|
|
1310
|
+
throw new InvalidInputError(
|
|
1311
|
+
`motif maxNotes must be at least minNotes ${minNotes}; received ${maxNotes}`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
const minOccurrences = assertInteger(
|
|
1315
|
+
asked.minOccurrences ?? MIN_OCCURRENCES,
|
|
1316
|
+
"motif minOccurrences",
|
|
1317
|
+
2
|
|
1318
|
+
);
|
|
1319
|
+
const sounding = orderedNotes(notes, "melody notes", asked.budget);
|
|
1320
|
+
if (sounding.length < minNotes) {
|
|
1321
|
+
return [];
|
|
1322
|
+
}
|
|
1323
|
+
assertGenerationBudget(
|
|
1324
|
+
sounding.length * (maxNotes - minNotes + 1),
|
|
1325
|
+
"motif windows",
|
|
1326
|
+
asked.budget
|
|
1327
|
+
);
|
|
1328
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1329
|
+
for (let length = minNotes; length <= maxNotes; length += 1) {
|
|
1330
|
+
for (let start = 0; start + length <= sounding.length; start += 1) {
|
|
1331
|
+
const signature = windowSignature(sounding.slice(start, start + length));
|
|
1332
|
+
if (signature === null) {
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
const group = groups.get(signature);
|
|
1336
|
+
if (group === void 0) {
|
|
1337
|
+
groups.set(signature, { signature, length, starts: [start] });
|
|
1338
|
+
} else {
|
|
1339
|
+
group.starts.push(start);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
const candidates = [...groups.values()].map((group) => ({ ...group, starts: nonOverlapping(group.starts, group.length) })).filter((group) => group.starts.length >= minOccurrences).sort(
|
|
1344
|
+
(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)
|
|
1345
|
+
);
|
|
1346
|
+
const keptByLength = /* @__PURE__ */ new Map();
|
|
1347
|
+
const kept = [];
|
|
1348
|
+
let comparisons = 0;
|
|
1349
|
+
for (const candidate of candidates) {
|
|
1350
|
+
const first = candidate.starts[0] ?? 0;
|
|
1351
|
+
let covered = false;
|
|
1352
|
+
for (let length = candidate.length + 1; length <= maxNotes && !covered; length += 1) {
|
|
1353
|
+
const byStart = keptByLength.get(length);
|
|
1354
|
+
if (byStart === void 0) {
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
for (let from = Math.max(0, first - (length - candidate.length)); from <= first; from += 1) {
|
|
1358
|
+
comparisons += 1;
|
|
1359
|
+
const outer = byStart.get(from);
|
|
1360
|
+
if (outer === void 0 || outer.starts.length < candidate.starts.length) {
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
comparisons += outer.starts.length + candidate.starts.length;
|
|
1364
|
+
if (subsumes(outer, candidate)) {
|
|
1365
|
+
covered = true;
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
assertGenerationBudget(comparisons, "motif subsumption comparisons", asked.budget);
|
|
1371
|
+
if (!covered) {
|
|
1372
|
+
kept.push(candidate);
|
|
1373
|
+
let byStart = keptByLength.get(candidate.length);
|
|
1374
|
+
if (byStart === void 0) {
|
|
1375
|
+
byStart = /* @__PURE__ */ new Map();
|
|
1376
|
+
keptByLength.set(candidate.length, byStart);
|
|
1377
|
+
}
|
|
1378
|
+
for (const start of candidate.starts) {
|
|
1379
|
+
byStart.set(start, candidate);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return kept.map((group) => motifFromGroup(group, sounding));
|
|
1384
|
+
}
|
|
1385
|
+
function motifFromNotes(notes) {
|
|
1386
|
+
const sounding = orderedNotes(notes, "motif notes").map((note) => ({ ...note }));
|
|
1387
|
+
return motifFromGroup(
|
|
1388
|
+
{ signature: windowSignature(sounding) ?? "", length: sounding.length, starts: [0] },
|
|
1389
|
+
sounding
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/analyze/melody/relation.ts
|
|
1394
|
+
function spellingOf(key) {
|
|
1395
|
+
if (key === void 0) {
|
|
1396
|
+
return "sharp";
|
|
1397
|
+
}
|
|
1398
|
+
return keySignatureFifths(key.tonic, key.scale) < 0 ? "flat" : "sharp";
|
|
1399
|
+
}
|
|
1400
|
+
function intervalBetween(from, to, key) {
|
|
1401
|
+
const spelling = spellingOf(key);
|
|
1402
|
+
return spelledInterval(midiToNote(from, spelling), midiToNote(to, spelling));
|
|
1403
|
+
}
|
|
1404
|
+
function intervalName(interval) {
|
|
1405
|
+
return `${interval.descending && interval.semitones !== 0 ? "-" : ""}${interval.quality}${interval.number}`;
|
|
1406
|
+
}
|
|
1407
|
+
function ladderIndices(notes, key) {
|
|
1408
|
+
const root = pitchClassOf(key.rootPc);
|
|
1409
|
+
const offsets = scaleTonesInDegreeOrder(key).map((pc) => pitchClassOf(pc - root));
|
|
1410
|
+
if (offsets.length === 0) {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
const out = [];
|
|
1414
|
+
for (const note of notes) {
|
|
1415
|
+
const semitones = note.pitch - root;
|
|
1416
|
+
const octave = Math.floor(semitones / 12);
|
|
1417
|
+
const degree = offsets.indexOf(semitones - octave * 12);
|
|
1418
|
+
if (degree < 0) {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
out.push(octave * offsets.length + degree);
|
|
1422
|
+
}
|
|
1423
|
+
return out;
|
|
1424
|
+
}
|
|
1425
|
+
function ladderSteps(indices) {
|
|
1426
|
+
const out = [];
|
|
1427
|
+
for (let i = 1; i < indices.length; i += 1) {
|
|
1428
|
+
out.push((indices[i] ?? 0) - (indices[i - 1] ?? 0));
|
|
1429
|
+
}
|
|
1430
|
+
return out;
|
|
1431
|
+
}
|
|
1432
|
+
function pitchShapes(a, b) {
|
|
1433
|
+
const shapes = [];
|
|
1434
|
+
if (sameNumbers(a, b)) {
|
|
1435
|
+
shapes.push("exact");
|
|
1436
|
+
}
|
|
1437
|
+
if (sameNumbers(
|
|
1438
|
+
b,
|
|
1439
|
+
a.map((step) => -step)
|
|
1440
|
+
)) {
|
|
1441
|
+
shapes.push("inversion");
|
|
1442
|
+
}
|
|
1443
|
+
const reversed = [...a].reverse();
|
|
1444
|
+
if (sameNumbers(
|
|
1445
|
+
b,
|
|
1446
|
+
reversed.map((step) => -step)
|
|
1447
|
+
)) {
|
|
1448
|
+
shapes.push("retrograde");
|
|
1449
|
+
}
|
|
1450
|
+
if (sameNumbers(b, reversed)) {
|
|
1451
|
+
shapes.push("retrogradeInversion");
|
|
1452
|
+
}
|
|
1453
|
+
return shapes;
|
|
1454
|
+
}
|
|
1455
|
+
function totalGap(gaps) {
|
|
1456
|
+
return gaps.reduce((sum, gap) => sum + gap, 0);
|
|
1457
|
+
}
|
|
1458
|
+
function stretchRatio(modelGaps, answerGaps) {
|
|
1459
|
+
const span = totalGap(modelGaps);
|
|
1460
|
+
return span > BEAT_EPS ? totalGap(answerGaps) / span : 1;
|
|
1461
|
+
}
|
|
1462
|
+
function stretchPhrase(ratio) {
|
|
1463
|
+
return `note values ${roundTo(ratio, 3)}x the model's`;
|
|
1464
|
+
}
|
|
1465
|
+
function relateMotifs(a, b, keyLike) {
|
|
1466
|
+
const key = keyLike === void 0 ? void 0 : resolveKey(keyLike);
|
|
1467
|
+
const model = assertArray(assertRecord(a, "a").notes, "a.notes");
|
|
1468
|
+
const answer = assertArray(assertRecord(b, "b").notes, "b.notes");
|
|
1469
|
+
if (model.length === 0 || model.length !== answer.length) {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
const modelGaps = onsetGaps(model);
|
|
1473
|
+
const answerGaps = onsetGaps(answer);
|
|
1474
|
+
const modelProfile = rhythmProfile(modelGaps);
|
|
1475
|
+
const answerProfile = rhythmProfile(answerGaps);
|
|
1476
|
+
const retroGaps = retrogradeGaps(model);
|
|
1477
|
+
const retroProfile = rhythmProfile(retroGaps);
|
|
1478
|
+
if (modelProfile === null || answerProfile === null) {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
const forward = sameNumbers(modelProfile, answerProfile);
|
|
1482
|
+
const backward = retroProfile !== null && sameNumbers(retroProfile, answerProfile);
|
|
1483
|
+
const semitones = (answer[0]?.pitch ?? 0) - (model[0]?.pitch ?? 0);
|
|
1484
|
+
const interval = intervalBetween(model[0]?.pitch ?? 0, answer[0]?.pitch ?? 0, key);
|
|
1485
|
+
const gap = (answer[0]?.startBeat ?? 0) - endBeatOf(model);
|
|
1486
|
+
const sequence = gap >= -HUMANIZE_ADJACENCY && gap <= HUMANIZE_ADJACENCY;
|
|
1487
|
+
const timeRatio = stretchRatio(modelGaps, answerGaps);
|
|
1488
|
+
const stretched = Math.abs(timeRatio - 1) > BEAT_EPS;
|
|
1489
|
+
const shapes = pitchShapes(intervalsOf(model), intervalsOf(answer));
|
|
1490
|
+
const base = { sequence, semitones, interval, timeRatio };
|
|
1491
|
+
const backwardBase = { ...base, timeRatio: stretchRatio(retroGaps, answerGaps) };
|
|
1492
|
+
if (forward && shapes.includes("exact")) {
|
|
1493
|
+
if (semitones === 0) {
|
|
1494
|
+
if (!stretched) {
|
|
1495
|
+
return {
|
|
1496
|
+
...base,
|
|
1497
|
+
kind: "repetition",
|
|
1498
|
+
rationale: "Exact repetition: the same pitches in the same rhythm"
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
const augmented = timeRatio > 1;
|
|
1502
|
+
return {
|
|
1503
|
+
...base,
|
|
1504
|
+
kind: augmented ? "augmentation" : "diminution",
|
|
1505
|
+
rationale: `${augmented ? "Augmentation" : "Diminution"}: the same pitches in ${stretchPhrase(timeRatio)}`
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
const name = intervalName(interval);
|
|
1509
|
+
return {
|
|
1510
|
+
...base,
|
|
1511
|
+
kind: "transposition",
|
|
1512
|
+
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)}` : "")
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
if (forward && key !== void 0) {
|
|
1516
|
+
const modelLadder = ladderIndices(model, key.scale);
|
|
1517
|
+
const answerLadder = ladderIndices(answer, key.scale);
|
|
1518
|
+
if (modelLadder !== null && answerLadder !== null) {
|
|
1519
|
+
const degrees = (answerLadder[0] ?? 0) - (modelLadder[0] ?? 0);
|
|
1520
|
+
if (degrees !== 0 && sameNumbers(ladderSteps(modelLadder), ladderSteps(answerLadder))) {
|
|
1521
|
+
const direction = degrees > 0 ? "higher" : "lower";
|
|
1522
|
+
const count = Math.abs(degrees);
|
|
1523
|
+
return {
|
|
1524
|
+
...base,
|
|
1525
|
+
kind: "tonalTransposition",
|
|
1526
|
+
degrees,
|
|
1527
|
+
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`
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
if (forward && shapes.includes("inversion")) {
|
|
1533
|
+
return {
|
|
1534
|
+
...base,
|
|
1535
|
+
kind: "inversion",
|
|
1536
|
+
rationale: "Inversion: every interval turned upside down about the first note" + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (backward && shapes.includes("retrograde")) {
|
|
1540
|
+
return {
|
|
1541
|
+
...backwardBase,
|
|
1542
|
+
kind: "retrograde",
|
|
1543
|
+
rationale: "Retrograde: the cell read back to front, rhythm included"
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
if (backward && shapes.includes("retrogradeInversion")) {
|
|
1547
|
+
return {
|
|
1548
|
+
...backwardBase,
|
|
1549
|
+
kind: "retrogradeInversion",
|
|
1550
|
+
rationale: "Retrograde inversion: the cell read back to front and turned upside down"
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
return null;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/analyze/melody/similarity.ts
|
|
1557
|
+
var INTERVAL_TOLERANCE = 6;
|
|
1558
|
+
var PITCH_WEIGHT = 0.75;
|
|
1559
|
+
var RHYTHM_WEIGHT = 0.25;
|
|
1560
|
+
function gradedEditDistance(a, b, cost) {
|
|
1561
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1562
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1563
|
+
const row = new Array(b.length + 1);
|
|
1564
|
+
row[0] = i;
|
|
1565
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1566
|
+
const substitution = (previous[j - 1] ?? 0) + cost(a[i - 1] ?? 0, b[j - 1] ?? 0);
|
|
1567
|
+
const deletion = (previous[j] ?? 0) + 1;
|
|
1568
|
+
const insertion = (row[j - 1] ?? 0) + 1;
|
|
1569
|
+
row[j] = Math.min(substitution, deletion, insertion);
|
|
1570
|
+
}
|
|
1571
|
+
previous = row;
|
|
1572
|
+
}
|
|
1573
|
+
return previous[b.length] ?? 0;
|
|
1574
|
+
}
|
|
1575
|
+
function distanceToSimilarity(distance, a, b) {
|
|
1576
|
+
const longest = Math.max(a, b);
|
|
1577
|
+
if (longest === 0) {
|
|
1578
|
+
return 1;
|
|
1579
|
+
}
|
|
1580
|
+
return Math.min(1, Math.max(0, 1 - distance / longest));
|
|
1581
|
+
}
|
|
1582
|
+
function intervalCost(x, y) {
|
|
1583
|
+
return Math.min(1, Math.abs(x - y) / INTERVAL_TOLERANCE);
|
|
1584
|
+
}
|
|
1585
|
+
function gapCost(x, y) {
|
|
1586
|
+
if (!(x > BEAT_EPS) || !(y > BEAT_EPS)) {
|
|
1587
|
+
return Math.abs(x - y) > BEAT_EPS ? 1 : 0;
|
|
1588
|
+
}
|
|
1589
|
+
return Math.min(1, Math.abs(Math.log2(x / y)));
|
|
1590
|
+
}
|
|
1591
|
+
function compareMelodies(a, b) {
|
|
1592
|
+
const left = orderedNotes(phraseNotes(a), "melody a");
|
|
1593
|
+
const right = orderedNotes(phraseNotes(b), "melody b");
|
|
1594
|
+
const leftIntervals = intervalsOf(left);
|
|
1595
|
+
const rightIntervals = intervalsOf(right);
|
|
1596
|
+
const pitchSimilarity = distanceToSimilarity(
|
|
1597
|
+
gradedEditDistance(leftIntervals, rightIntervals, intervalCost),
|
|
1598
|
+
leftIntervals.length,
|
|
1599
|
+
rightIntervals.length
|
|
1600
|
+
);
|
|
1601
|
+
const leftGaps = rhythmProfile(onsetGaps(left)) ?? onsetGaps(left);
|
|
1602
|
+
const rightGaps = rhythmProfile(onsetGaps(right)) ?? onsetGaps(right);
|
|
1603
|
+
const rhythmSimilarity = distanceToSimilarity(
|
|
1604
|
+
gradedEditDistance(leftGaps, rightGaps, gapCost),
|
|
1605
|
+
leftGaps.length,
|
|
1606
|
+
rightGaps.length
|
|
1607
|
+
);
|
|
1608
|
+
const similarity = PITCH_WEIGHT * pitchSimilarity + RHYTHM_WEIGHT * rhythmSimilarity;
|
|
1609
|
+
return {
|
|
1610
|
+
similarity,
|
|
1611
|
+
pitchSimilarity,
|
|
1612
|
+
rhythmSimilarity,
|
|
1613
|
+
rationale: `Interval shapes ${Math.round(pitchSimilarity * 100)}% alike, rhythms ${Math.round(rhythmSimilarity * 100)}% alike, weighted ${PITCH_WEIGHT}/${RHYTHM_WEIGHT} into ${roundTo(similarity, 3)}`
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
function melodicSimilarity(a, b) {
|
|
1617
|
+
return compareMelodies(a, b).similarity;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// src/analyze/form/phrase.ts
|
|
1621
|
+
var CADENCE_BOUNDARY_STRENGTH = {
|
|
1622
|
+
authentic: 0.9,
|
|
1623
|
+
modal: 0.75,
|
|
1624
|
+
plagal: 0.7,
|
|
1625
|
+
phrygian: 0.65,
|
|
1626
|
+
half: 0.6,
|
|
1627
|
+
deceptive: 0.45
|
|
1628
|
+
};
|
|
1629
|
+
var CADENCE_CLOSURE = {
|
|
1630
|
+
authentic: 0.85,
|
|
1631
|
+
modal: 0.6,
|
|
1632
|
+
plagal: 0.55,
|
|
1633
|
+
phrygian: 0.4,
|
|
1634
|
+
half: 0.35,
|
|
1635
|
+
deceptive: 0.3
|
|
1636
|
+
};
|
|
1637
|
+
var REST_STRENGTH = 0.6;
|
|
1638
|
+
var LONG_NOTE_STRENGTH = 0.4;
|
|
1639
|
+
var REPETITION_STRENGTH = 0.5;
|
|
1640
|
+
var REPETITION_THRESHOLD = 0.75;
|
|
1641
|
+
var HYPERMETER_STRENGTH = 0.25;
|
|
1642
|
+
var PIECE_END_STRENGTH = 0.5;
|
|
1643
|
+
var PHRASE_CUT_COST = 1.3;
|
|
1644
|
+
var CLOSING_SHARE = 0.65;
|
|
1645
|
+
var FINAL_PHRASE_BONUS = 0.15;
|
|
1646
|
+
var HYPERMETRIC_CLOSE_BONUS = 0.1;
|
|
1647
|
+
var EVADED_FACTOR = 0.6;
|
|
1648
|
+
var SIGNAL_ORDER = [
|
|
1649
|
+
"cadence",
|
|
1650
|
+
"rest",
|
|
1651
|
+
"repetition",
|
|
1652
|
+
"longNote",
|
|
1653
|
+
"pieceEnd",
|
|
1654
|
+
"hypermeter"
|
|
1655
|
+
];
|
|
1656
|
+
function addSignal(boundaries, beat, signal, strength, cadence = null) {
|
|
1657
|
+
const key = Math.round(beat / BEAT_EPS);
|
|
1658
|
+
const existing = boundaries.get(key);
|
|
1659
|
+
const boundary = existing ?? { beat, strengths: /* @__PURE__ */ new Map(), cadence: null };
|
|
1660
|
+
boundary.strengths.set(signal, Math.max(boundary.strengths.get(signal) ?? 0, strength));
|
|
1661
|
+
if (cadence !== null && boundary.cadence === null) {
|
|
1662
|
+
boundary.cadence = cadence;
|
|
1663
|
+
}
|
|
1664
|
+
boundaries.set(key, boundary);
|
|
1665
|
+
}
|
|
1666
|
+
function readBoundary(boundary) {
|
|
1667
|
+
const signals = [...boundary.strengths.entries()].sort(
|
|
1668
|
+
(a, b) => b[1] - a[1] || SIGNAL_ORDER.indexOf(a[0]) - SIGNAL_ORDER.indexOf(b[0])
|
|
1669
|
+
);
|
|
1670
|
+
return {
|
|
1671
|
+
strength: combineEvidence(signals.map(([, strength]) => strength)),
|
|
1672
|
+
signals: signals.map(([signal]) => signal)
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
function lengthFit(length, expected) {
|
|
1676
|
+
if (!(expected > 0)) {
|
|
1677
|
+
return 0;
|
|
1678
|
+
}
|
|
1679
|
+
return Math.max(-1, 1 - Math.abs(length - expected) / expected);
|
|
1680
|
+
}
|
|
1681
|
+
function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
|
|
1682
|
+
const value = new Array(beats.length).fill(Number.NEGATIVE_INFINITY);
|
|
1683
|
+
const cameFrom = new Array(beats.length).fill(-1);
|
|
1684
|
+
value[0] = 0;
|
|
1685
|
+
for (let j = 1; j < beats.length; j += 1) {
|
|
1686
|
+
const endBeat = beats[j] ?? 0;
|
|
1687
|
+
for (let i = 0; i < j; i += 1) {
|
|
1688
|
+
if (!Number.isFinite(value[i] ?? Number.NEGATIVE_INFINITY)) {
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
const length = endBeat - (beats[i] ?? 0);
|
|
1692
|
+
if (length < minPhraseBeats - BEAT_EPS) {
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
const gain = (value[i] ?? 0) + (strengths[j] ?? 0) + lengthFit(length, expected) - PHRASE_CUT_COST;
|
|
1696
|
+
if (gain > (value[j] ?? Number.NEGATIVE_INFINITY)) {
|
|
1697
|
+
value[j] = gain;
|
|
1698
|
+
cameFrom[j] = i;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const last = beats.length - 1;
|
|
1703
|
+
const path = [];
|
|
1704
|
+
if (last > 0 && Number.isFinite(value[last] ?? Number.NEGATIVE_INFINITY)) {
|
|
1705
|
+
for (let i = last; i > 0; i = cameFrom[i] ?? 0) {
|
|
1706
|
+
path.push(i);
|
|
1707
|
+
}
|
|
1708
|
+
path.reverse();
|
|
1709
|
+
} else if (last > 0) {
|
|
1710
|
+
path.push(last);
|
|
1711
|
+
}
|
|
1712
|
+
return path;
|
|
1713
|
+
}
|
|
1714
|
+
function arrivalEnd(timeline, atBeat) {
|
|
1715
|
+
return segmentStartingAt(timeline.segments, atBeat)?.endBeat ?? atBeat;
|
|
1716
|
+
}
|
|
1717
|
+
function barSpanOf(startBeat, endBeat, meters) {
|
|
1718
|
+
const firstBar = barIndexAt(startBeat, meters);
|
|
1719
|
+
const lastBar = barIndexAt(endBeat, meters);
|
|
1720
|
+
const partOf = (beat, bar) => {
|
|
1721
|
+
const barStart = barPositionToBeat({ bar, beat: 0 }, meters);
|
|
1722
|
+
const barLength = beatsPerBarAt(barStart, meters);
|
|
1723
|
+
return barLength > 0 ? (beat - barStart) / barLength : 0;
|
|
1724
|
+
};
|
|
1725
|
+
const bars = lastBar - firstBar + partOf(endBeat, lastBar) - partOf(startBeat, firstBar);
|
|
1726
|
+
return Math.round(bars * 100) / 100;
|
|
1727
|
+
}
|
|
1728
|
+
function keyContextFor(opts, notes, meters, totalBeats) {
|
|
1729
|
+
if (opts.key !== void 0) {
|
|
1730
|
+
return opts.key;
|
|
1731
|
+
}
|
|
1732
|
+
const regions = keyTimelineFromNotes(notes, {
|
|
1733
|
+
meters,
|
|
1734
|
+
totalBeats,
|
|
1735
|
+
budget: opts.budget
|
|
1736
|
+
});
|
|
1737
|
+
const keyAt = keyLookup(regions, prevailingKeyOf(regions) ?? resolveKey(majorKey(0)));
|
|
1738
|
+
return (beat) => scaleOf(keyAt(beat));
|
|
1739
|
+
}
|
|
1740
|
+
function restBoundaries(boundaries, notes, meters, restBeats) {
|
|
1741
|
+
const ordered = [...notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
|
|
1742
|
+
let covered = Number.NEGATIVE_INFINITY;
|
|
1743
|
+
for (const note of ordered) {
|
|
1744
|
+
const gap = note.startBeat - covered;
|
|
1745
|
+
if (Number.isFinite(covered) && gap >= restBeats - BEAT_EPS) {
|
|
1746
|
+
const bar = beatsPerBarAt(note.startBeat, meters);
|
|
1747
|
+
addSignal(
|
|
1748
|
+
boundaries,
|
|
1749
|
+
note.startBeat,
|
|
1750
|
+
"rest",
|
|
1751
|
+
REST_STRENGTH * clamp01(bar > 0 ? gap / bar : 1)
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
covered = Math.max(covered, note.startBeat + note.durationBeat);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
function repetitionBoundaries(boundaries, notes, meters, spanStart, spanEnd, windowBeats, budget) {
|
|
1758
|
+
if (!(windowBeats > 0)) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const slices = sliceBars(notes, meters, spanStart, spanEnd, budget);
|
|
1762
|
+
assertGenerationBudget(slices.length * notes.length, "form repetition comparisons", budget);
|
|
1763
|
+
const onsetsIn = (from, to) => notes.filter((note) => note.startBeat >= from - BEAT_EPS && note.startBeat < to - BEAT_EPS);
|
|
1764
|
+
let comparisons = 0;
|
|
1765
|
+
for (const slice of slices) {
|
|
1766
|
+
const beat = slice.startBeat;
|
|
1767
|
+
if (beat - windowBeats < spanStart - BEAT_EPS || beat + windowBeats > spanEnd + BEAT_EPS) {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
const before = onsetsIn(beat - windowBeats, beat);
|
|
1771
|
+
const after = onsetsIn(beat, beat + windowBeats);
|
|
1772
|
+
if (before.length < 2 || after.length < 2) {
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
comparisons += before.length * after.length;
|
|
1776
|
+
assertGenerationBudget(comparisons, "form repetition melody comparisons", budget);
|
|
1777
|
+
const similarity = melodicSimilarity(before, after);
|
|
1778
|
+
if (similarity >= REPETITION_THRESHOLD) {
|
|
1779
|
+
addSignal(boundaries, beat, "repetition", REPETITION_STRENGTH * similarity);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
function structuralWeightOf(cadence, confidence, aligned, isLast) {
|
|
1784
|
+
if (cadence === null || cadence.cadence.type === null) {
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
1787
|
+
const result = cadence.cadence;
|
|
1788
|
+
let base = CADENCE_CLOSURE[result.type];
|
|
1789
|
+
if (result.type === "authentic") {
|
|
1790
|
+
if (result.strength === "perfect") {
|
|
1791
|
+
base = 1;
|
|
1792
|
+
} else if (result.strength === "imperfect") {
|
|
1793
|
+
base *= 0.8;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (result.evaded) {
|
|
1797
|
+
base *= EVADED_FACTOR;
|
|
1798
|
+
}
|
|
1799
|
+
let weight = base * confidence;
|
|
1800
|
+
if (isLast) {
|
|
1801
|
+
weight += FINAL_PHRASE_BONUS;
|
|
1802
|
+
}
|
|
1803
|
+
if (aligned) {
|
|
1804
|
+
weight += HYPERMETRIC_CLOSE_BONUS;
|
|
1805
|
+
}
|
|
1806
|
+
return clamp01(weight);
|
|
1807
|
+
}
|
|
1808
|
+
function describeCadence(cadence) {
|
|
1809
|
+
if (cadence === null || cadence.cadence.type === null) {
|
|
1810
|
+
return "no cadence";
|
|
1811
|
+
}
|
|
1812
|
+
const { type, strength } = cadence.cadence;
|
|
1813
|
+
const graded = strength === null ? type : `${strength} ${type}`;
|
|
1814
|
+
return `a ${graded} cadence at beat ${cadence.atBeat}`;
|
|
1815
|
+
}
|
|
1816
|
+
function phrasesFromTimeline(timeline, notes, opts = {}) {
|
|
1817
|
+
assertChordTimeline(timeline);
|
|
1818
|
+
const meters = resolveMeters(opts, "phrase meters");
|
|
1819
|
+
assertNoteEvents(notes, "phrase notes", {
|
|
1820
|
+
allowNonPositiveDuration: true,
|
|
1821
|
+
budget: opts.budget
|
|
1822
|
+
});
|
|
1823
|
+
if (opts.hypermeter !== void 0) {
|
|
1824
|
+
assertRange(opts.hypermeter.confidence, 0, 1, "phrase hypermeter confidence");
|
|
1825
|
+
assertPositiveInt(opts.hypermeter.groupBars, "phrase hypermeter groupBars");
|
|
1826
|
+
}
|
|
1827
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
1828
|
+
const segments = timeline.segments;
|
|
1829
|
+
const firstSegment = segments[0];
|
|
1830
|
+
const lastSegment = segments[segments.length - 1];
|
|
1831
|
+
const firstOnset = sounding.reduce(
|
|
1832
|
+
(first, n) => Math.min(first, n.startBeat),
|
|
1833
|
+
firstSegment?.startBeat ?? 0
|
|
1834
|
+
);
|
|
1835
|
+
const spanStart = barGridStart(firstOnset, meters);
|
|
1836
|
+
const notesEnd = sounding.reduce(
|
|
1837
|
+
(end, n) => Math.max(end, n.startBeat + n.durationBeat),
|
|
1838
|
+
lastSegment?.endBeat ?? 0
|
|
1839
|
+
);
|
|
1840
|
+
const spanEnd = Math.max(spanStart, opts.totalBeats ?? notesEnd);
|
|
1841
|
+
assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "phrase totalBeats");
|
|
1842
|
+
if (spanEnd <= spanStart + BEAT_EPS) {
|
|
1843
|
+
return [];
|
|
1844
|
+
}
|
|
1845
|
+
const barBeats = beatsPerBarAt(Math.max(0, spanStart), meters);
|
|
1846
|
+
const restBeats = opts.restBeats ?? barBeats / 2;
|
|
1847
|
+
assertRange(restBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase restBeats");
|
|
1848
|
+
const longNoteBeats = opts.longNoteBeats ?? barBeats;
|
|
1849
|
+
assertRange(longNoteBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase longNoteBeats");
|
|
1850
|
+
const minPhraseBeats = opts.minPhraseBeats ?? barBeats;
|
|
1851
|
+
assertRange(minPhraseBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase minPhraseBeats");
|
|
1852
|
+
const key = keyContextFor(opts, sounding, meters, spanEnd);
|
|
1853
|
+
const cadences = detectCadences(timeline, key);
|
|
1854
|
+
const grouping = opts.hypermeter ?? hypermeter(sounding, meters, {
|
|
1855
|
+
totalBeats: spanEnd,
|
|
1856
|
+
cadenceBeats: cadences.map((hit) => hit.atBeat),
|
|
1857
|
+
budget: opts.budget
|
|
1858
|
+
});
|
|
1859
|
+
const firstBar = barIndexAt(Math.max(0, spanStart), meters);
|
|
1860
|
+
const hyperBeats = Math.max(
|
|
1861
|
+
barBeats,
|
|
1862
|
+
barPositionToBeat({ bar: firstBar + grouping.groupBars, beat: 0 }, meters) - barPositionToBeat({ bar: firstBar, beat: 0 }, meters)
|
|
1863
|
+
);
|
|
1864
|
+
const expected = opts.expectedPhraseBeats ?? hyperBeats;
|
|
1865
|
+
assertRange(expected, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase expectedPhraseBeats");
|
|
1866
|
+
const boundaries = /* @__PURE__ */ new Map();
|
|
1867
|
+
for (const hit of cadences) {
|
|
1868
|
+
const type = hit.cadence.type;
|
|
1869
|
+
if (type === null) {
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
const strength = CADENCE_BOUNDARY_STRENGTH[type];
|
|
1873
|
+
const chordEnd = arrivalEnd(timeline, hit.atBeat);
|
|
1874
|
+
addSignal(boundaries, chordEnd, "cadence", strength, hit);
|
|
1875
|
+
const barEnd = barPositionToBeat({ bar: barIndexAt(hit.atBeat, meters) + 1, beat: 0 }, meters);
|
|
1876
|
+
if (chordEnd > barEnd + BEAT_EPS) {
|
|
1877
|
+
addSignal(boundaries, barEnd, "cadence", strength, hit);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
restBoundaries(boundaries, sounding, meters, restBeats);
|
|
1881
|
+
for (const note of sounding) {
|
|
1882
|
+
if (note.durationBeat >= longNoteBeats - BEAT_EPS) {
|
|
1883
|
+
addSignal(boundaries, note.startBeat + note.durationBeat, "longNote", LONG_NOTE_STRENGTH);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
repetitionBoundaries(boundaries, sounding, meters, spanStart, spanEnd, hyperBeats, opts.budget);
|
|
1887
|
+
for (const downbeat of grouping.downbeats) {
|
|
1888
|
+
addSignal(boundaries, downbeat, "hypermeter", HYPERMETER_STRENGTH * grouping.confidence);
|
|
1889
|
+
}
|
|
1890
|
+
addSignal(boundaries, spanEnd, "pieceEnd", PIECE_END_STRENGTH);
|
|
1891
|
+
const candidates = [...boundaries.values()].filter(
|
|
1892
|
+
(boundary) => boundary.beat > spanStart + BEAT_EPS && boundary.beat < spanEnd - BEAT_EPS
|
|
1893
|
+
).filter((boundary) => readBoundary(boundary).strength > 0).sort((a, b) => a.beat - b.beat);
|
|
1894
|
+
const endBoundary = boundaries.get(Math.round(spanEnd / BEAT_EPS)) ?? {
|
|
1895
|
+
beat: spanEnd,
|
|
1896
|
+
strengths: /* @__PURE__ */ new Map(),
|
|
1897
|
+
cadence: null
|
|
1898
|
+
};
|
|
1899
|
+
const stops = [
|
|
1900
|
+
{ beat: spanStart, strengths: /* @__PURE__ */ new Map(), cadence: null },
|
|
1901
|
+
...candidates,
|
|
1902
|
+
endBoundary
|
|
1903
|
+
];
|
|
1904
|
+
assertGenerationBudget(stops.length * stops.length, "form phrase boundary pairs", opts.budget);
|
|
1905
|
+
const readings = stops.map(readBoundary);
|
|
1906
|
+
const path = choosePhrasePath(
|
|
1907
|
+
stops.map((stop) => stop.beat),
|
|
1908
|
+
readings.map((reading) => reading.strength),
|
|
1909
|
+
minPhraseBeats,
|
|
1910
|
+
expected
|
|
1911
|
+
);
|
|
1912
|
+
const downbeatKeys = new Set(
|
|
1913
|
+
grouping.downbeats.map((downbeat) => Math.round(downbeat / BEAT_EPS))
|
|
1914
|
+
);
|
|
1915
|
+
const phrases = [];
|
|
1916
|
+
let from = 0;
|
|
1917
|
+
for (let n = 0; n < path.length; n += 1) {
|
|
1918
|
+
const to = path[n] ?? stops.length - 1;
|
|
1919
|
+
const startBeat = stops[from]?.beat ?? spanStart;
|
|
1920
|
+
const stop = stops[to];
|
|
1921
|
+
const endBeat = stop?.beat ?? spanEnd;
|
|
1922
|
+
const reading = readings[to] ?? { strength: 0, signals: [] };
|
|
1923
|
+
const length = endBeat - startBeat;
|
|
1924
|
+
const confidence = clamp01(
|
|
1925
|
+
CLOSING_SHARE * reading.strength + (1 - CLOSING_SHARE) * Math.max(0, lengthFit(length, expected))
|
|
1926
|
+
);
|
|
1927
|
+
const isLast = n === path.length - 1;
|
|
1928
|
+
const closing = stop?.cadence ?? null;
|
|
1929
|
+
const cadence = closing !== null && closing.atBeat >= startBeat - BEAT_EPS && closing.atBeat < endBeat - BEAT_EPS ? closing : null;
|
|
1930
|
+
const structuralWeight = structuralWeightOf(
|
|
1931
|
+
cadence,
|
|
1932
|
+
confidence,
|
|
1933
|
+
downbeatKeys.has(Math.round(endBeat / BEAT_EPS)),
|
|
1934
|
+
isLast
|
|
1935
|
+
);
|
|
1936
|
+
const signals = cadence === null ? reading.signals.filter((signal) => signal !== "cadence") : reading.signals;
|
|
1937
|
+
const bars = barSpanOf(startBeat, endBeat, meters);
|
|
1938
|
+
phrases.push({
|
|
1939
|
+
startBeat,
|
|
1940
|
+
endBeat,
|
|
1941
|
+
cadence,
|
|
1942
|
+
signals,
|
|
1943
|
+
confidence,
|
|
1944
|
+
structuralWeight,
|
|
1945
|
+
rationale: `Phrase of ${bars} bar(s) closing on ${describeCadence(cadence)}; boundary from ${signals.length === 0 ? "the end of the span" : signals.join(", ")}`
|
|
1946
|
+
});
|
|
1947
|
+
from = to;
|
|
1948
|
+
}
|
|
1949
|
+
return phrases;
|
|
1950
|
+
}
|
|
1951
|
+
function structuralCadences(phrases) {
|
|
1952
|
+
assertArray(phrases, "phrases");
|
|
1953
|
+
const ranked = [];
|
|
1954
|
+
for (let index = 0; index < phrases.length; index += 1) {
|
|
1955
|
+
const phrase = assertRecord(phrases[index], `phrases[${index}]`);
|
|
1956
|
+
if (phrase.cadence === null || phrase.cadence === void 0 || phrase.cadence.cadence.type === null) {
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
const hit = phrase.cadence;
|
|
1960
|
+
ranked.push({
|
|
1961
|
+
phraseIndex: index,
|
|
1962
|
+
atBeat: hit.atBeat,
|
|
1963
|
+
endBeat: phrase.endBeat,
|
|
1964
|
+
cadence: hit.cadence,
|
|
1965
|
+
weight: phrase.structuralWeight,
|
|
1966
|
+
rationale: `Closes ${describeCadence(hit)} on a phrase of confidence ${Math.round(phrase.confidence * 100) / 100}`
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
return ranked.sort((a, b) => b.weight - a.weight || a.atBeat - b.atBeat);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// src/analyze/form/section.ts
|
|
1973
|
+
var MELODY_SHARE = 0.6;
|
|
1974
|
+
var HARMONY_SHARE = 0.4;
|
|
1975
|
+
var DEFAULT_THRESHOLD = 0.8;
|
|
1976
|
+
function labelAt(index) {
|
|
1977
|
+
let n = index;
|
|
1978
|
+
let label = "";
|
|
1979
|
+
do {
|
|
1980
|
+
label = String.fromCharCode(65 + n % 26) + label;
|
|
1981
|
+
n = Math.floor(n / 26) - 1;
|
|
1982
|
+
} while (n >= 0);
|
|
1983
|
+
return label;
|
|
1984
|
+
}
|
|
1985
|
+
function isSilent(unit) {
|
|
1986
|
+
return unit.notes.length === 0;
|
|
1987
|
+
}
|
|
1988
|
+
function unitSimilarity(a, b) {
|
|
1989
|
+
const harmony = weightSimilarity(a.weights, b.weights);
|
|
1990
|
+
if (a.notes.length < 2 || b.notes.length < 2) {
|
|
1991
|
+
return a.notes.length === b.notes.length ? harmony : 0;
|
|
1992
|
+
}
|
|
1993
|
+
const melody = melodicSimilarity(a.notes, b.notes);
|
|
1994
|
+
return clamp01(MELODY_SHARE * melody + HARMONY_SHARE * harmony);
|
|
1995
|
+
}
|
|
1996
|
+
function sectionsFromNotes(notes, opts = {}) {
|
|
1997
|
+
const meters = resolveMeters(opts, "section meters");
|
|
1998
|
+
assertNoteEvents(notes, "section notes", {
|
|
1999
|
+
allowNonPositiveDuration: true,
|
|
2000
|
+
budget: opts.budget
|
|
2001
|
+
});
|
|
2002
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
2003
|
+
if (sounding.length === 0) {
|
|
2004
|
+
return [];
|
|
2005
|
+
}
|
|
2006
|
+
const firstOnset = barGridStart(firstSoundingBeat(sounding), meters);
|
|
2007
|
+
const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
2008
|
+
const spanEnd = Math.max(firstOnset, opts.totalBeats ?? lastEnd);
|
|
2009
|
+
assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "section totalBeats");
|
|
2010
|
+
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
2011
|
+
assertRange(threshold, 0, 1, "section threshold");
|
|
2012
|
+
const unitBars = opts.unitBars ?? hypermeter(sounding, meters, { totalBeats: spanEnd, budget: opts.budget }).groupBars;
|
|
2013
|
+
assertPositiveInt(unitBars, "section unitBars");
|
|
2014
|
+
const firstBar = barIndexAt(firstOnset, meters);
|
|
2015
|
+
const lastBar = lastBarOf(meters, firstOnset, spanEnd);
|
|
2016
|
+
const startBar = Math.max(0, firstBar);
|
|
2017
|
+
const unitCount = Math.max(1, Math.ceil((lastBar - startBar + 1) / unitBars));
|
|
2018
|
+
assertGenerationBudget(unitCount * unitCount, "form section comparisons", opts.budget);
|
|
2019
|
+
assertGenerationBudget(unitCount * sounding.length, "form section unit notes", opts.budget);
|
|
2020
|
+
const units = [];
|
|
2021
|
+
for (let index = 0; index < unitCount; index += 1) {
|
|
2022
|
+
const barStart = startBar + index * unitBars;
|
|
2023
|
+
const startBeat = index === 0 ? firstOnset : barPositionToBeat({ bar: barStart, beat: 0 }, meters);
|
|
2024
|
+
const endBeat = index === unitCount - 1 ? spanEnd : barPositionToBeat({ bar: barStart + unitBars, beat: 0 }, meters);
|
|
2025
|
+
const unitNotes = sounding.filter(
|
|
2026
|
+
(note) => note.startBeat >= startBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS
|
|
2027
|
+
).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
|
|
2028
|
+
units.push({
|
|
2029
|
+
startBeat,
|
|
2030
|
+
endBeat,
|
|
2031
|
+
notes: unitNotes,
|
|
2032
|
+
weights: windowWeights(unitNotes, startBeat, endBeat, meters).weights
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
let comparisons = 0;
|
|
2036
|
+
let earlierNotes = 0;
|
|
2037
|
+
for (const unit of units) {
|
|
2038
|
+
comparisons += unit.notes.length * earlierNotes;
|
|
2039
|
+
earlierNotes += unit.notes.length;
|
|
2040
|
+
}
|
|
2041
|
+
assertGenerationBudget(comparisons, "form section melody comparisons", opts.budget);
|
|
2042
|
+
const labels = [];
|
|
2043
|
+
const scores = [];
|
|
2044
|
+
let distinct = 0;
|
|
2045
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
2046
|
+
const unit = units[index];
|
|
2047
|
+
if (unit === void 0) {
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
if (isSilent(unit)) {
|
|
2051
|
+
labels.push(null);
|
|
2052
|
+
scores.push(scores[index - 1] ?? 1);
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
let bestScore = -1;
|
|
2056
|
+
let bestIndex = -1;
|
|
2057
|
+
for (let earlier = 0; earlier < index; earlier += 1) {
|
|
2058
|
+
const other = units[earlier];
|
|
2059
|
+
if (other === void 0 || isSilent(other)) {
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
const score = unitSimilarity(unit, other);
|
|
2063
|
+
if (score > bestScore) {
|
|
2064
|
+
bestScore = score;
|
|
2065
|
+
bestIndex = earlier;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
if (bestIndex >= 0 && bestScore >= threshold) {
|
|
2069
|
+
labels.push(labels[bestIndex] ?? labelAt(distinct));
|
|
2070
|
+
scores.push(bestScore);
|
|
2071
|
+
} else {
|
|
2072
|
+
labels.push(labelAt(distinct));
|
|
2073
|
+
scores.push(1);
|
|
2074
|
+
distinct += 1;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const sections = [];
|
|
2078
|
+
const firstByLabel = /* @__PURE__ */ new Map();
|
|
2079
|
+
for (let index = 0; index < units.length; ) {
|
|
2080
|
+
const label = labels[index] ?? null;
|
|
2081
|
+
if (label === null) {
|
|
2082
|
+
index += 1;
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
let end = index;
|
|
2086
|
+
let scoreSum = 0;
|
|
2087
|
+
let sounded = 0;
|
|
2088
|
+
while (end < units.length && labels[end] === label) {
|
|
2089
|
+
scoreSum += scores[end] ?? 1;
|
|
2090
|
+
sounded += 1;
|
|
2091
|
+
end += 1;
|
|
2092
|
+
}
|
|
2093
|
+
while (end < units.length && labels[end] === null) {
|
|
2094
|
+
end += 1;
|
|
2095
|
+
}
|
|
2096
|
+
const startBeat = units[index]?.startBeat ?? 0;
|
|
2097
|
+
const endBeat = units[end - 1]?.endBeat ?? spanEnd;
|
|
2098
|
+
const sectionFirstBar = startBar + index * unitBars;
|
|
2099
|
+
const sectionLastBar = Math.max(
|
|
2100
|
+
sectionFirstBar,
|
|
2101
|
+
Math.min(lastBar, startBar + end * unitBars - 1)
|
|
2102
|
+
);
|
|
2103
|
+
const sectionIndex = sections.length;
|
|
2104
|
+
const first = firstByLabel.get(label);
|
|
2105
|
+
if (first === void 0) {
|
|
2106
|
+
firstByLabel.set(label, sectionIndex);
|
|
2107
|
+
}
|
|
2108
|
+
const similarity = first === void 0 ? 1 : clamp01(scoreSum / Math.max(1, sounded));
|
|
2109
|
+
sections.push({
|
|
2110
|
+
label,
|
|
2111
|
+
startBeat,
|
|
2112
|
+
endBeat,
|
|
2113
|
+
bars: sectionLastBar - sectionFirstBar + 1,
|
|
2114
|
+
firstOccurrence: first ?? sectionIndex,
|
|
2115
|
+
similarity,
|
|
2116
|
+
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}`
|
|
2117
|
+
});
|
|
2118
|
+
index = end;
|
|
2119
|
+
}
|
|
2120
|
+
return sections;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/analyze/reduction/index.ts
|
|
2124
|
+
var TONIC_ARRIVING = /* @__PURE__ */ new Set(["authentic", "plagal", "modal", "deceptive"]);
|
|
2125
|
+
function signedStep(fromPc, toPc) {
|
|
2126
|
+
return (pitchClassOf(toPc) - pitchClassOf(fromPc) + 18) % 12 - 6;
|
|
2127
|
+
}
|
|
2128
|
+
function isStep(distance) {
|
|
2129
|
+
const size = Math.abs(distance);
|
|
2130
|
+
return size === 1 || size === 2;
|
|
2131
|
+
}
|
|
2132
|
+
function touches(before, after) {
|
|
2133
|
+
return Math.abs(after.startBeat - before.endBeat) <= BEAT_EPS;
|
|
2134
|
+
}
|
|
2135
|
+
function salienceFloors(durations) {
|
|
2136
|
+
const order = durations.map((_, index) => index).sort((a, b) => {
|
|
2137
|
+
const left = durations[a] ?? 0;
|
|
2138
|
+
const right = durations[b] ?? 0;
|
|
2139
|
+
return left - right || a - b;
|
|
2140
|
+
});
|
|
2141
|
+
const rankOf = new Array(durations.length).fill(0);
|
|
2142
|
+
for (let rank = 0; rank < order.length; rank += 1) {
|
|
2143
|
+
rankOf[order[rank] ?? 0] = rank;
|
|
2144
|
+
}
|
|
2145
|
+
const floors = [];
|
|
2146
|
+
const remaining = durations.length - 1;
|
|
2147
|
+
for (let index = 0; index < durations.length; index += 1) {
|
|
2148
|
+
if (remaining <= 0) {
|
|
2149
|
+
floors.push(0);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
const skipped = rankOf[index] ?? 0;
|
|
2153
|
+
const at = (position) => durations[order[position < skipped ? position : position + 1] ?? 0] ?? 0;
|
|
2154
|
+
const middle = remaining >> 1;
|
|
2155
|
+
floors.push(remaining % 2 === 1 ? at(middle) : (at(middle - 1) + at(middle)) / 2);
|
|
2156
|
+
}
|
|
2157
|
+
return floors;
|
|
2158
|
+
}
|
|
2159
|
+
function isTonicChord(chord, key) {
|
|
2160
|
+
return pitchClassOf(chord.rootPc - key.rootPc) === 0 && !isAppliedDominant(chord, key);
|
|
2161
|
+
}
|
|
2162
|
+
function functionalReason(segment, before, after, keyAt) {
|
|
2163
|
+
const key = keyAt(segment.startBeat);
|
|
2164
|
+
if (after !== void 0 && isCadentialSixFour(segment.chord, after.chord, key)) {
|
|
2165
|
+
return { kind: "cadentialSixFour" };
|
|
2166
|
+
}
|
|
2167
|
+
if (isTonicChord(segment.chord, key)) {
|
|
2168
|
+
return { kind: "tonic" };
|
|
2169
|
+
}
|
|
2170
|
+
if (isDominantChordOf(segment.chord, key)) {
|
|
2171
|
+
return { kind: "dominant" };
|
|
2172
|
+
}
|
|
2173
|
+
if (before !== void 0) {
|
|
2174
|
+
const arrival = detectCadence(before.chord, segment.chord, key).type;
|
|
2175
|
+
if (arrival !== null) {
|
|
2176
|
+
return { kind: "cadenceArrival", cadence: arrival };
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (after !== void 0) {
|
|
2180
|
+
const agent = detectCadence(segment.chord, after.chord, keyAt(after.startBeat)).type;
|
|
2181
|
+
if (agent !== null && TONIC_ARRIVING.has(agent)) {
|
|
2182
|
+
return { kind: "cadenceAgent", cadence: agent };
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
function figureReason(segment, before, after) {
|
|
2188
|
+
if (before === void 0 || after === void 0) {
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
const incoming = signedStep(before.chord.rootPc, segment.chord.rootPc);
|
|
2192
|
+
const outgoing = signedStep(segment.chord.rootPc, after.chord.rootPc);
|
|
2193
|
+
if (!isStep(incoming) || !isStep(outgoing)) {
|
|
2194
|
+
return null;
|
|
2195
|
+
}
|
|
2196
|
+
if (incoming > 0 === outgoing > 0) {
|
|
2197
|
+
return { kind: "passing" };
|
|
2198
|
+
}
|
|
2199
|
+
return pitchClassOf(before.chord.rootPc) === pitchClassOf(after.chord.rootPc) ? { kind: "neighbor" } : null;
|
|
2200
|
+
}
|
|
2201
|
+
function describe(reason) {
|
|
2202
|
+
switch (reason.kind) {
|
|
2203
|
+
case "tonic":
|
|
2204
|
+
return "Structural: the tonic of the key, which the progression is heard against";
|
|
2205
|
+
case "dominant":
|
|
2206
|
+
return "Structural: the dominant of the key, the chord its tonic is approached from";
|
|
2207
|
+
case "cadentialSixFour":
|
|
2208
|
+
return "Structural: a tonic six-four on the bass of the dominant that follows, which prolongs that dominant rather than framing the tonic";
|
|
2209
|
+
case "cadenceArrival":
|
|
2210
|
+
return `Structural: the arrival of a ${reason.cadence} cadence`;
|
|
2211
|
+
case "cadenceAgent":
|
|
2212
|
+
return `Structural: the chord that drives a ${reason.cadence} cadence`;
|
|
2213
|
+
case "salient":
|
|
2214
|
+
return "Structural: it holds the harmony longer than the chords around it";
|
|
2215
|
+
case "first":
|
|
2216
|
+
return "Structural: the first chord of the progression frames it";
|
|
2217
|
+
case "last":
|
|
2218
|
+
return "Structural: the last chord of the progression frames it";
|
|
2219
|
+
case "unfigured":
|
|
2220
|
+
return "Structural: its root neither steps through to the next chord nor returns to the last, so it embellishes nothing";
|
|
2221
|
+
case "passing":
|
|
2222
|
+
return "Passing: its root steps in from the previous chord and on to the next in the same direction";
|
|
2223
|
+
case "neighbor":
|
|
2224
|
+
return "Neighbor: its root steps away from the surrounding harmony and returns to it";
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
var BASIS_READINGS = {
|
|
2228
|
+
function: ({ segment, before, after, keyAt }) => functionalReason(segment, before, after, keyAt),
|
|
2229
|
+
duration: ({ segment, salienceFloor }) => segment.endBeat - segment.startBeat > salienceFloor ? { kind: "salient" } : null
|
|
2230
|
+
};
|
|
2231
|
+
var REDUCTION_BASES = Object.keys(BASIS_READINGS);
|
|
2232
|
+
function levelOf(reason) {
|
|
2233
|
+
if (reason.kind === "passing") {
|
|
2234
|
+
return "passing";
|
|
2235
|
+
}
|
|
2236
|
+
return reason.kind === "neighbor" ? "neighbor" : "structural";
|
|
2237
|
+
}
|
|
2238
|
+
function reduceProgression(timeline, key, opts = {}) {
|
|
2239
|
+
assertChordTimeline(timeline);
|
|
2240
|
+
const asked = assertOptions(opts, "opts");
|
|
2241
|
+
const keyAt = keyScaleAt(key);
|
|
2242
|
+
const basis = assertOneOf(asked.basis ?? "function", REDUCTION_BASES, "reduction basis");
|
|
2243
|
+
const reading = BASIS_READINGS[basis];
|
|
2244
|
+
const segments = timeline.segments;
|
|
2245
|
+
const floors = basis === "duration" ? salienceFloors(segments.map((segment) => segment.endBeat - segment.startBeat)) : [];
|
|
2246
|
+
const result = [];
|
|
2247
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
2248
|
+
const segment = segments[index];
|
|
2249
|
+
if (segment === void 0) {
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
const previous = segments[index - 1];
|
|
2253
|
+
const following = segments[index + 1];
|
|
2254
|
+
const before = previous !== void 0 && touches(previous, segment) ? previous : void 0;
|
|
2255
|
+
const after = following !== void 0 && touches(segment, following) ? following : void 0;
|
|
2256
|
+
let reason = reading({ segment, before, after, keyAt, salienceFloor: floors[index] ?? 0 });
|
|
2257
|
+
if (reason === null && index === 0) {
|
|
2258
|
+
reason = { kind: "first" };
|
|
2259
|
+
}
|
|
2260
|
+
if (reason === null && index === segments.length - 1) {
|
|
2261
|
+
reason = { kind: "last" };
|
|
2262
|
+
}
|
|
2263
|
+
reason ??= figureReason(segment, before, after) ?? { kind: "unfigured" };
|
|
2264
|
+
result.push({
|
|
2265
|
+
chord: segment.chord,
|
|
2266
|
+
startBeat: segment.startBeat,
|
|
2267
|
+
endBeat: segment.endBeat,
|
|
2268
|
+
level: levelOf(reason),
|
|
2269
|
+
rationale: describe(reason)
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
return result;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
export {
|
|
2276
|
+
trackRoleOf,
|
|
2277
|
+
analyzePolyphony,
|
|
2278
|
+
analyzeArrangement,
|
|
2279
|
+
createArrangementSession,
|
|
2280
|
+
tensionCurve,
|
|
2281
|
+
tensionCurveFrom,
|
|
2282
|
+
hypermeter,
|
|
2283
|
+
melodicContour,
|
|
2284
|
+
extractMotifs,
|
|
2285
|
+
motifFromNotes,
|
|
2286
|
+
relateMotifs,
|
|
2287
|
+
compareMelodies,
|
|
2288
|
+
melodicSimilarity,
|
|
2289
|
+
phrasesFromTimeline,
|
|
2290
|
+
structuralCadences,
|
|
2291
|
+
sectionsFromNotes,
|
|
2292
|
+
reduceProgression
|
|
2293
|
+
};
|
|
2294
|
+
//# sourceMappingURL=chunk-HYJJ2V2I.js.map
|