@libraz/libcantus 1.2.0 → 1.4.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/NOTICE +63 -0
- package/README.md +7 -1
- package/README_ja.md +17 -11
- package/dist/analyze/index.cjs +31 -7
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +161 -13
- package/dist/analyze/index.d.ts +161 -13
- package/dist/analyze/index.js +37 -13
- package/dist/{chunk-NB25P3EH.js → chunk-2G5MCGYU.js} +33 -11
- package/dist/chunk-2G5MCGYU.js.map +1 -0
- package/dist/{chunk-NM7UAMC3.cjs → chunk-3FDBMIPR.cjs} +64 -64
- package/dist/chunk-3FDBMIPR.cjs.map +1 -0
- package/dist/{chunk-HPVSH7SF.cjs → chunk-3MDOEFIQ.cjs} +618 -954
- package/dist/chunk-3MDOEFIQ.cjs.map +1 -0
- package/dist/{chunk-FBH75DAY.js → chunk-5MWRBYAT.js} +560 -159
- package/dist/chunk-5MWRBYAT.js.map +1 -0
- package/dist/{chunk-J7FHVZII.cjs → chunk-6TTBEON3.cjs} +3074 -740
- package/dist/chunk-6TTBEON3.cjs.map +1 -0
- package/dist/{chunk-UMWZ4ZET.js → chunk-7NQ6AL3H.js} +4 -4
- package/dist/chunk-7NQ6AL3H.js.map +1 -0
- package/dist/{chunk-HWHGOVO6.cjs → chunk-E3QO3UET.cjs} +31 -23
- package/dist/chunk-E3QO3UET.cjs.map +1 -0
- package/dist/{chunk-H2KJHBW4.js → chunk-FJFULJZE.js} +2375 -191
- package/dist/chunk-FJFULJZE.js.map +1 -0
- package/dist/{chunk-Q6LFSAKV.js → chunk-FK3DGAIZ.js} +2657 -323
- package/dist/chunk-FK3DGAIZ.js.map +1 -0
- package/dist/chunk-GEDTJHAC.cjs.map +1 -1
- package/dist/{chunk-RFIBF2RR.js → chunk-H7FVMBUC.js} +138 -65
- package/dist/chunk-H7FVMBUC.js.map +1 -0
- package/dist/{chunk-DSMJRXCZ.cjs → chunk-HYSVSFNI.cjs} +931 -530
- package/dist/chunk-HYSVSFNI.cjs.map +1 -0
- package/dist/{chunk-35NBV2FT.cjs → chunk-KMYGRFZ6.cjs} +47 -24
- package/dist/chunk-KMYGRFZ6.cjs.map +1 -0
- package/dist/chunk-LQZQDBCD.cjs +5513 -0
- package/dist/chunk-LQZQDBCD.cjs.map +1 -0
- package/dist/{chunk-PM4XJE2Y.cjs → chunk-P3CPXAJ5.cjs} +76 -54
- package/dist/chunk-P3CPXAJ5.cjs.map +1 -0
- package/dist/{chunk-S5BKZZGM.cjs → chunk-S3QELJUE.cjs} +72 -56
- package/dist/chunk-S3QELJUE.cjs.map +1 -0
- package/dist/{chunk-PUKJP2Z5.js → chunk-TXFWSU5D.js} +31 -8
- package/dist/chunk-TXFWSU5D.js.map +1 -0
- package/dist/{chunk-76ZBTRG2.cjs → chunk-U4EQTDGN.cjs} +258 -218
- package/dist/chunk-U4EQTDGN.cjs.map +1 -0
- package/dist/chunk-UGLFDG7L.cjs.map +1 -1
- package/dist/{chunk-HYJJ2V2I.js → chunk-UTE5A36R.js} +483 -819
- package/dist/chunk-UTE5A36R.js.map +1 -0
- package/dist/{chunk-NFJ4P6ZZ.js → chunk-UZKQISRF.js} +17 -9
- package/dist/chunk-UZKQISRF.js.map +1 -0
- package/dist/{chunk-7KG3RTYS.js → chunk-VL5NXVCN.js} +281 -155
- package/dist/chunk-VL5NXVCN.js.map +1 -0
- package/dist/{chunk-CGBADIO5.cjs → chunk-WOMCXN2W.cjs} +328 -202
- package/dist/chunk-WOMCXN2W.cjs.map +1 -0
- package/dist/{chunk-3R3QVKY5.cjs → chunk-YDGMWH43.cjs} +397 -324
- package/dist/chunk-YDGMWH43.cjs.map +1 -0
- package/dist/{chunk-AGOVW7U6.js → chunk-YYVXRV3V.js} +72 -56
- package/dist/chunk-YYVXRV3V.js.map +1 -0
- package/dist/{chunk-M5RYMAFI.js → chunk-ZLNSH4UL.js} +71 -31
- package/dist/chunk-ZLNSH4UL.js.map +1 -0
- package/dist/{coerce-DRERzdve.d.cts → coerce-CUhHc3Tu.d.ts} +16 -8
- package/dist/{coerce-DP0vYu-x.d.ts → coerce-Ytt2Eiq9.d.cts} +16 -8
- package/dist/core/index.cjs +5 -5
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +9 -8
- package/dist/core/index.d.ts +9 -8
- package/dist/core/index.js +4 -4
- package/dist/generate/index.cjs +20 -8
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +105 -17
- package/dist/generate/index.d.ts +105 -17
- package/dist/generate/index.js +19 -7
- package/dist/{index-CL4b4gUQ.d.ts → index-5VJgKz_Q.d.cts} +6 -2
- package/dist/{index-B9QLziDe.d.cts → index-BLuDesVG.d.ts} +20 -13
- package/dist/{index-BHIUyjhZ.d.ts → index-BV6APVa6.d.cts} +20 -13
- package/dist/{index-BMweUlm5.d.cts → index-Bf0Sv8a9.d.ts} +6 -2
- package/dist/{index-Dw2DP7vm.d.ts → index-BkT21EMv.d.cts} +3 -2
- package/dist/{index-BL5N2Oyz.d.cts → index-C7KCaTRz.d.ts} +3 -2
- package/dist/{index-Cy15oTvh.d.ts → index-C9MYbuKS.d.cts} +7 -3
- package/dist/{index-Cy15oTvh.d.cts → index-C9MYbuKS.d.ts} +7 -3
- package/dist/{index-BNSM-DSk.d.cts → index-DVInzCDS.d.cts} +533 -28
- package/dist/{index-9eoQs2C1.d.ts → index-Dg-SAfhz.d.ts} +6 -4
- package/dist/{index-BJgP97N1.d.ts → index-jgeug9EA.d.ts} +533 -28
- package/dist/{index-cHvbYgJI.d.cts → index-pQihL4z7.d.cts} +6 -4
- package/dist/index.cjs +49 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -13
- package/dist/index.d.ts +13 -13
- package/dist/index.js +55 -19
- package/dist/{masks-Cc7-MzzY.d.ts → masks-BnzXnBj0.d.cts} +3 -2
- package/dist/{masks-pADfTOiD.d.cts → masks-tycmkrgs.d.ts} +3 -2
- package/dist/model/index.cjs +12 -12
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +259 -48
- package/dist/model/index.d.ts +259 -48
- package/dist/model/index.js +11 -11
- package/dist/{positional-DJgs6ltD.d.ts → positional-CscpxtlE.d.cts} +3 -2
- package/dist/{positional-DMZer-FM.d.cts → positional-DLnfUgqG.d.ts} +3 -2
- package/dist/{index-BRMbPkHE.d.ts → profile-B4WqcM4H.d.ts} +85 -610
- package/dist/{index-DGCNKAUr.d.cts → profile-BJd1XkPB.d.cts} +85 -610
- package/dist/theory/index.cjs +5 -5
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +18 -14
- package/dist/theory/index.d.ts +18 -14
- package/dist/theory/index.js +4 -4
- package/dist/{index-CJBBiAmU.d.ts → types-7mGFwvMm.d.ts} +1210 -31
- package/dist/{index-BBWNZS0f.d.cts → types-_ofpb2be.d.cts} +1210 -31
- package/package.json +13 -13
- package/dist/chunk-35NBV2FT.cjs.map +0 -1
- package/dist/chunk-3R3QVKY5.cjs.map +0 -1
- package/dist/chunk-76ZBTRG2.cjs.map +0 -1
- package/dist/chunk-7KG3RTYS.js.map +0 -1
- package/dist/chunk-AGOVW7U6.js.map +0 -1
- package/dist/chunk-CGBADIO5.cjs.map +0 -1
- package/dist/chunk-DSMJRXCZ.cjs.map +0 -1
- package/dist/chunk-FBH75DAY.js.map +0 -1
- package/dist/chunk-H2KJHBW4.js.map +0 -1
- package/dist/chunk-HPVSH7SF.cjs.map +0 -1
- package/dist/chunk-HWHGOVO6.cjs.map +0 -1
- package/dist/chunk-HYJJ2V2I.js.map +0 -1
- package/dist/chunk-J7FHVZII.cjs.map +0 -1
- package/dist/chunk-M5RYMAFI.js.map +0 -1
- package/dist/chunk-NB25P3EH.js.map +0 -1
- package/dist/chunk-NFJ4P6ZZ.js.map +0 -1
- package/dist/chunk-NM7UAMC3.cjs.map +0 -1
- package/dist/chunk-PM4XJE2Y.cjs.map +0 -1
- package/dist/chunk-PUKJP2Z5.js.map +0 -1
- package/dist/chunk-Q6LFSAKV.js.map +0 -1
- package/dist/chunk-RFIBF2RR.js.map +0 -1
- package/dist/chunk-S5BKZZGM.cjs.map +0 -1
- package/dist/chunk-SGLEQVJH.cjs +0 -3329
- package/dist/chunk-SGLEQVJH.cjs.map +0 -1
- package/dist/chunk-UMWZ4ZET.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
sortedNoteEvents
|
|
3
|
-
|
|
2
|
+
sortedNoteEvents,
|
|
3
|
+
soundingIndices
|
|
4
|
+
} from "./chunk-TXFWSU5D.js";
|
|
4
5
|
import {
|
|
5
6
|
DORIAN_MASK,
|
|
6
7
|
HARMONIC_MINOR_MASK,
|
|
@@ -12,7 +13,8 @@ import {
|
|
|
12
13
|
MIXOLYDIAN_MASK,
|
|
13
14
|
NATURAL_MINOR_MASK,
|
|
14
15
|
PHRYGIAN_MASK,
|
|
15
|
-
|
|
16
|
+
QUALITY_INTERVALS,
|
|
17
|
+
appliedChordTarget,
|
|
16
18
|
chordFromSpan,
|
|
17
19
|
chordPitchClasses,
|
|
18
20
|
chordQualities,
|
|
@@ -20,7 +22,6 @@ import {
|
|
|
20
22
|
chordToneSpellings,
|
|
21
23
|
createsVerticalDissonance,
|
|
22
24
|
diatonicTriad,
|
|
23
|
-
hasDominantSonority,
|
|
24
25
|
hasMajorThird,
|
|
25
26
|
heptatonicFrameOf,
|
|
26
27
|
intervalAboveRoot,
|
|
@@ -33,8 +34,11 @@ import {
|
|
|
33
34
|
isNeapolitanChordOf,
|
|
34
35
|
isScaleTone,
|
|
35
36
|
keyRelationBetween,
|
|
37
|
+
keySignatureFifths,
|
|
38
|
+
keySpelling,
|
|
36
39
|
majorKey,
|
|
37
40
|
makeChord,
|
|
41
|
+
makeSpelledChord,
|
|
38
42
|
minorKey,
|
|
39
43
|
resolveKey,
|
|
40
44
|
scaleOf,
|
|
@@ -44,10 +48,11 @@ import {
|
|
|
44
48
|
spellChord,
|
|
45
49
|
spellPitchClass,
|
|
46
50
|
spelledKeyOf,
|
|
51
|
+
spellingAbove,
|
|
47
52
|
toChordData,
|
|
48
53
|
toKeyScale,
|
|
49
54
|
tonicizableDegrees
|
|
50
|
-
} from "./chunk-
|
|
55
|
+
} from "./chunk-H7FVMBUC.js";
|
|
51
56
|
import {
|
|
52
57
|
BEAT_EPS,
|
|
53
58
|
InvalidInputError,
|
|
@@ -55,29 +60,43 @@ import {
|
|
|
55
60
|
allocateChoiceTable,
|
|
56
61
|
assertArray,
|
|
57
62
|
assertFiniteNumber,
|
|
63
|
+
assertFlag,
|
|
58
64
|
assertFunction,
|
|
59
65
|
assertGenerationBudget,
|
|
60
66
|
assertInteger,
|
|
67
|
+
assertMeterMap,
|
|
61
68
|
assertMidiPitch,
|
|
62
69
|
assertNoteEvents,
|
|
63
70
|
assertOneOf,
|
|
64
71
|
assertOptions,
|
|
72
|
+
assertPositiveInt,
|
|
65
73
|
assertRange,
|
|
66
74
|
assertRecord,
|
|
75
|
+
assertTimeSignature,
|
|
67
76
|
barIndexAt,
|
|
68
77
|
barPositionToBeat,
|
|
78
|
+
barStartBeat,
|
|
79
|
+
beatsPerBar,
|
|
69
80
|
beatsPerBarAt,
|
|
81
|
+
describeRejected,
|
|
70
82
|
diatonicLetterOf,
|
|
71
83
|
formatNote,
|
|
84
|
+
isCompound,
|
|
85
|
+
isConsonantInterval,
|
|
86
|
+
meterAt,
|
|
72
87
|
metricGridUnit,
|
|
73
88
|
metricWeight,
|
|
89
|
+
midiToNote,
|
|
90
|
+
naturalPitchClassOf,
|
|
74
91
|
parseInterval,
|
|
75
92
|
pitchClassOf,
|
|
93
|
+
pulseBeats,
|
|
76
94
|
resolveMeters,
|
|
95
|
+
spelledInterval,
|
|
77
96
|
toNoteData,
|
|
78
97
|
transposeByInterval,
|
|
79
98
|
transposeNote
|
|
80
|
-
} from "./chunk-
|
|
99
|
+
} from "./chunk-YYVXRV3V.js";
|
|
81
100
|
|
|
82
101
|
// src/analyze/detect/modes.ts
|
|
83
102
|
var MODAL_CANDIDATES = Object.freeze([
|
|
@@ -191,6 +210,30 @@ function windowWeights(notes, windowStart, windowEnd, meter) {
|
|
|
191
210
|
return { weights, totalWeight, maxWeight, lowestPitch };
|
|
192
211
|
}
|
|
193
212
|
|
|
213
|
+
// src/analyze/detect/internal.ts
|
|
214
|
+
var MINOR_VARIANTS = [
|
|
215
|
+
{ variant: "natural", mask: NATURAL_MINOR_MASK, scaleName: "naturalMinor" },
|
|
216
|
+
{ variant: "harmonic", mask: HARMONIC_MINOR_MASK, scaleName: "harmonicMinor" },
|
|
217
|
+
{ variant: "melodic", mask: MELODIC_MINOR_MASK, scaleName: "melodicMinor" }
|
|
218
|
+
];
|
|
219
|
+
var MAJOR_VARIANTS = [
|
|
220
|
+
{ variant: "major", mask: MAJOR_MASK, scaleName: "major" }
|
|
221
|
+
];
|
|
222
|
+
var SCALE_NAME_ORDER = [
|
|
223
|
+
"major",
|
|
224
|
+
"naturalMinor",
|
|
225
|
+
"harmonicMinor",
|
|
226
|
+
"melodicMinor",
|
|
227
|
+
"dorian",
|
|
228
|
+
"phrygian",
|
|
229
|
+
"lydian",
|
|
230
|
+
"mixolydian",
|
|
231
|
+
"locrian"
|
|
232
|
+
];
|
|
233
|
+
var EXPLAINED_RIVALS = 3;
|
|
234
|
+
var SCORE_DIGITS = 2;
|
|
235
|
+
var MAX_SCORE_DIGITS = 6;
|
|
236
|
+
|
|
194
237
|
// src/analyze/detect/profiles.ts
|
|
195
238
|
var KRUMHANSL_KESSLER_PROFILE = {
|
|
196
239
|
major: [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88],
|
|
@@ -289,6 +332,11 @@ function profileScore(dist, vector, tonic) {
|
|
|
289
332
|
}
|
|
290
333
|
|
|
291
334
|
// src/analyze/detect/index.ts
|
|
335
|
+
var CHORD_INPUT_KINDS = [
|
|
336
|
+
"auto",
|
|
337
|
+
"midi",
|
|
338
|
+
"pitchClass"
|
|
339
|
+
];
|
|
292
340
|
function uniquePitchClasses(pitches) {
|
|
293
341
|
return [...new Set(pitches.map(pitchClassOf))].sort((a, b) => a - b);
|
|
294
342
|
}
|
|
@@ -302,12 +350,13 @@ function assertPitches(pitches, name, budget = void 0) {
|
|
|
302
350
|
function detectChord(pitches, opts = {}) {
|
|
303
351
|
const asked = assertOptions(opts, "opts");
|
|
304
352
|
assertPitches(pitches, "chord detection pitches");
|
|
353
|
+
const askedKind = assertOneOf(asked.input ?? "auto", CHORD_INPUT_KINDS, "input");
|
|
305
354
|
if (asked.bassPc !== void 0) assertFiniteNumber(asked.bassPc, "bassPc");
|
|
306
355
|
const input = uniquePitchClasses(pitches);
|
|
307
356
|
if (input.length === 0) {
|
|
308
357
|
return [];
|
|
309
358
|
}
|
|
310
|
-
const inputKind =
|
|
359
|
+
const inputKind = askedKind === "auto" ? pitches.every((pitch) => Number.isInteger(pitch) && pitch >= 0 && pitch <= 11) ? "pitchClass" : "midi" : askedKind;
|
|
311
360
|
const bassPc = asked.bassPc !== void 0 ? pitchClassOf(asked.bassPc) : inputKind === "midi" ? pitchClassOf(pitches.reduce((lowest, pitch) => Math.min(lowest, pitch), Infinity)) : void 0;
|
|
312
361
|
const inputSet = new Set(input);
|
|
313
362
|
const matches = [];
|
|
@@ -326,6 +375,9 @@ function detectChord(pitches, opts = {}) {
|
|
|
326
375
|
if (missingPcs.length > 1 || missingPcs.length === 1 && missingPcs[0] !== fifthPc) {
|
|
327
376
|
continue;
|
|
328
377
|
}
|
|
378
|
+
if (tones.length - missingPcs.length < 2) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
329
381
|
const extraPcs = input.filter((pc) => !toneSet.has(pc));
|
|
330
382
|
const bassIndex = bassPc === void 0 ? -1 : chord.intervals.findIndex((iv) => pitchClassOf(rootPc + iv) === bassPc);
|
|
331
383
|
const inversion = bassPc === void 0 || bassIndex < 0 ? null : bassIndex;
|
|
@@ -374,31 +426,9 @@ function detectChordBest(pitches, opts = {}) {
|
|
|
374
426
|
}
|
|
375
427
|
return makeChord(best.rootPc, best.quality, best.bassPc);
|
|
376
428
|
}
|
|
377
|
-
var MINOR_VARIANTS = [
|
|
378
|
-
{ variant: "natural", mask: NATURAL_MINOR_MASK, scaleName: "naturalMinor" },
|
|
379
|
-
{ variant: "harmonic", mask: HARMONIC_MINOR_MASK, scaleName: "harmonicMinor" },
|
|
380
|
-
{ variant: "melodic", mask: MELODIC_MINOR_MASK, scaleName: "melodicMinor" }
|
|
381
|
-
];
|
|
382
|
-
var MAJOR_VARIANTS = [
|
|
383
|
-
{ variant: "major", mask: MAJOR_MASK, scaleName: "major" }
|
|
384
|
-
];
|
|
385
|
-
var SCALE_NAME_ORDER = [
|
|
386
|
-
"major",
|
|
387
|
-
"naturalMinor",
|
|
388
|
-
"harmonicMinor",
|
|
389
|
-
"melodicMinor",
|
|
390
|
-
"dorian",
|
|
391
|
-
"phrygian",
|
|
392
|
-
"lydian",
|
|
393
|
-
"mixolydian",
|
|
394
|
-
"locrian"
|
|
395
|
-
];
|
|
396
429
|
function scaleNameRank(name) {
|
|
397
430
|
return SCALE_NAME_ORDER.indexOf(name);
|
|
398
431
|
}
|
|
399
|
-
var EXPLAINED_RIVALS = 3;
|
|
400
|
-
var SCORE_DIGITS = 2;
|
|
401
|
-
var MAX_SCORE_DIGITS = 6;
|
|
402
432
|
function quoteScores(rival, winner) {
|
|
403
433
|
let digits = SCORE_DIGITS;
|
|
404
434
|
while (digits < MAX_SCORE_DIGITS && rival.toFixed(digits) === winner.toFixed(digits)) {
|
|
@@ -616,13 +646,14 @@ var CHORD_SHAPE = {
|
|
|
616
646
|
french: { rootAboveBass: 6, quality: "7b5", intervals: [0, 4, 6, 10] },
|
|
617
647
|
german: { rootAboveBass: 0, quality: "dom7", intervals: [0, 4, 7, 10] }
|
|
618
648
|
};
|
|
619
|
-
var
|
|
649
|
+
var KIND_SYMBOLS = [
|
|
620
650
|
["It6", "italian"],
|
|
621
651
|
["Fr6", "french"],
|
|
622
652
|
["Ger6", "german"],
|
|
623
653
|
// The German sixth's figures over its bass are 6/5; both spellings are in use.
|
|
624
654
|
["Ger65", "german"]
|
|
625
|
-
]
|
|
655
|
+
];
|
|
656
|
+
var KIND_BY_SYMBOL = new Map(KIND_SYMBOLS);
|
|
626
657
|
var SYMBOL_BY_KIND = {
|
|
627
658
|
italian: "It6",
|
|
628
659
|
french: "Fr6",
|
|
@@ -647,14 +678,16 @@ function augmentedSixthOn(kind, keyLike) {
|
|
|
647
678
|
const shape = CHORD_SHAPE[checked];
|
|
648
679
|
const { scale: key, tonic } = resolveKey(keyLike);
|
|
649
680
|
const bassPc = bassPcOf(key);
|
|
650
|
-
const chord = makeChord(pitchClassOf(bassPc + shape.rootAboveBass), shape.quality, bassPc);
|
|
651
|
-
chord.intervals = [...shape.intervals];
|
|
652
681
|
const bass = transposeByInterval(tonic, BASS_INTERVAL);
|
|
653
682
|
const tones = spelledInChordOrder(checked, tonic);
|
|
654
683
|
const [root = bass] = tones;
|
|
655
|
-
chord
|
|
656
|
-
|
|
657
|
-
|
|
684
|
+
const chord = makeSpelledChord(pitchClassOf(bassPc + shape.rootAboveBass), shape.quality, {
|
|
685
|
+
bassPc,
|
|
686
|
+
rootSpelling: bareSpelling(root),
|
|
687
|
+
bassSpelling: bareSpelling(bass),
|
|
688
|
+
toneSpellings: tones.map(bareSpelling)
|
|
689
|
+
});
|
|
690
|
+
chord.intervals = [...shape.intervals];
|
|
658
691
|
return chord;
|
|
659
692
|
}
|
|
660
693
|
function kindOfPitchClasses(pcs, bass) {
|
|
@@ -779,6 +812,14 @@ function outerPitches(voicing) {
|
|
|
779
812
|
function bassPcOf2(chord, voicedBass) {
|
|
780
813
|
return pitchClassOf(voicedBass ?? chord.bassPc ?? chord.rootPc);
|
|
781
814
|
}
|
|
815
|
+
var CADENCE_TYPES = Object.freeze([
|
|
816
|
+
"authentic",
|
|
817
|
+
"plagal",
|
|
818
|
+
"half",
|
|
819
|
+
"deceptive",
|
|
820
|
+
"phrygian",
|
|
821
|
+
"modal"
|
|
822
|
+
]);
|
|
782
823
|
function cadenceType(from, to, key, fromBassPc, toBassPc) {
|
|
783
824
|
const tonic = pitchClassOf(key.rootPc);
|
|
784
825
|
const fromOffset = pitchClassOf(from.rootPc - tonic);
|
|
@@ -988,14 +1029,6 @@ function borrowedSource(chord, key) {
|
|
|
988
1029
|
function isTonicizableRoot(rootPc, key) {
|
|
989
1030
|
return tonicizableDegrees(key).some((degree) => degree.rootPc === pitchClassOf(rootPc));
|
|
990
1031
|
}
|
|
991
|
-
function appliedTarget(chord, key) {
|
|
992
|
-
const dominant = hasDominantSonority(chord);
|
|
993
|
-
const leadingTone = LEADING_TONE_QUALITIES.has(chord.quality);
|
|
994
|
-
if (!dominant && !leadingTone) {
|
|
995
|
-
return null;
|
|
996
|
-
}
|
|
997
|
-
return appliedDominantTarget(chord.rootPc + (dominant ? 5 : 1), key);
|
|
998
|
-
}
|
|
999
1032
|
function isAppliedDominant(chord, key) {
|
|
1000
1033
|
if (isDiatonicChord(chord, key)) {
|
|
1001
1034
|
return false;
|
|
@@ -1007,7 +1040,7 @@ function isAppliedDominant(chord, key) {
|
|
|
1007
1040
|
return pointsAtTonicizableDegree(chord, key);
|
|
1008
1041
|
}
|
|
1009
1042
|
function pointsAtTonicizableDegree(chord, key) {
|
|
1010
|
-
if (
|
|
1043
|
+
if (appliedChordTarget(chord, key) !== null) {
|
|
1011
1044
|
return true;
|
|
1012
1045
|
}
|
|
1013
1046
|
return soundsDominantSeventh(chord) && isTonicizableRoot(pitchClassOf(chord.rootPc - 1), key);
|
|
@@ -1050,7 +1083,8 @@ function seventhQuality(base, halfDim) {
|
|
|
1050
1083
|
}
|
|
1051
1084
|
return base === "maj" ? "dom7" : "min7";
|
|
1052
1085
|
}
|
|
1053
|
-
var
|
|
1086
|
+
var INVERSION_FIGURE_LIST = ["6", "64", "65", "43", "42", "2", "7"];
|
|
1087
|
+
var INVERSION_FIGURES = new Set(INVERSION_FIGURE_LIST);
|
|
1054
1088
|
var ROMAN_STYLE = {
|
|
1055
1089
|
maj: { lower: false, suffix: "" },
|
|
1056
1090
|
min: { lower: true, suffix: "" },
|
|
@@ -1122,7 +1156,12 @@ function parseInversion(figures) {
|
|
|
1122
1156
|
}
|
|
1123
1157
|
return { inversion: 0, seventh: false };
|
|
1124
1158
|
}
|
|
1125
|
-
function
|
|
1159
|
+
function numeralRootSpelling(tonic, degreeNumber, rootPc) {
|
|
1160
|
+
const spelling = spellingAbove(tonic, degreeNumber - 1, rootPc);
|
|
1161
|
+
return Math.abs(spelling.alter) <= 2 ? spelling : void 0;
|
|
1162
|
+
}
|
|
1163
|
+
function parseSimpleRoman(text, resolved) {
|
|
1164
|
+
const key = resolved.scale;
|
|
1126
1165
|
const match = /^([b#]?)([iIvV]+)(.*)$/.exec(text.trim());
|
|
1127
1166
|
if (!match) {
|
|
1128
1167
|
throw new InvalidInputError(`Invalid Roman numeral: ${text}`);
|
|
@@ -1146,8 +1185,17 @@ function parseSimpleRoman(text, key) {
|
|
|
1146
1185
|
);
|
|
1147
1186
|
const rootForQuality = (quality2) => accidental === 0 && degreeNumber === 7 && loweredDegrees(frame).has(7) && LEADING_TONE_QUALITIES.has(quality2) ? pitchClassOf(diatonicRootPc + 1) : diatonicRootPc;
|
|
1148
1187
|
const canonical = SUFFIX_QUALITY.get(`${isUpper ? "u" : "l"}:${suffix}`);
|
|
1188
|
+
const parsed = (quality2, inversion2) => {
|
|
1189
|
+
const rootPc = rootForQuality(quality2);
|
|
1190
|
+
return {
|
|
1191
|
+
rootPc,
|
|
1192
|
+
rootSpelling: numeralRootSpelling(resolved.tonic, degreeNumber, rootPc),
|
|
1193
|
+
quality: quality2,
|
|
1194
|
+
inversion: inversion2
|
|
1195
|
+
};
|
|
1196
|
+
};
|
|
1149
1197
|
if (canonical !== void 0) {
|
|
1150
|
-
return
|
|
1198
|
+
return parsed(canonical, 0);
|
|
1151
1199
|
}
|
|
1152
1200
|
const parsedSuffix = /^(o|°|dim|ø|\+|aug)?(maj7|M7)?(65|64|43|42|7|6|2)?$/.exec(suffix);
|
|
1153
1201
|
if (!parsedSuffix) {
|
|
@@ -1186,20 +1234,23 @@ function parseSimpleRoman(text, key) {
|
|
|
1186
1234
|
} else {
|
|
1187
1235
|
quality = base;
|
|
1188
1236
|
}
|
|
1189
|
-
return
|
|
1237
|
+
return parsed(quality, inversion);
|
|
1190
1238
|
}
|
|
1191
1239
|
var NEAPOLITAN_SIXTH = "N6";
|
|
1192
1240
|
function chromaticSymbolChord(text, key) {
|
|
1193
1241
|
if (text === NEAPOLITAN_SIXTH) {
|
|
1194
1242
|
const rootPc = pitchClassOf(key.scale.rootPc + 1);
|
|
1195
|
-
return
|
|
1243
|
+
return makeSpelledChord(rootPc, "maj", {
|
|
1244
|
+
bassPc: pitchClassOf(rootPc + 4),
|
|
1245
|
+
rootSpelling: numeralRootSpelling(key.tonic, 2, rootPc)
|
|
1246
|
+
});
|
|
1196
1247
|
}
|
|
1197
1248
|
return augmentedSixthFromSymbol(text, key);
|
|
1198
1249
|
}
|
|
1199
|
-
function tonicizedKey(targetRoot, key) {
|
|
1250
|
+
function tonicizedKey(targetRoot, key, written) {
|
|
1200
1251
|
return resolveKey({
|
|
1201
1252
|
scale: majorKey(targetRoot),
|
|
1202
|
-
tonic: spellPitchClass(targetRoot, key.tonic, key.scale)
|
|
1253
|
+
tonic: written ?? spellPitchClass(targetRoot, key.tonic, key.scale)
|
|
1203
1254
|
});
|
|
1204
1255
|
}
|
|
1205
1256
|
function augmentedSixthTarget(chord, key) {
|
|
@@ -1217,30 +1268,34 @@ function augmentedSixthTarget(chord, key) {
|
|
|
1217
1268
|
return null;
|
|
1218
1269
|
}
|
|
1219
1270
|
function chordFromParsed(parsed) {
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1271
|
+
const bassInterval = parsed.inversion > 0 ? QUALITY_INTERVALS[parsed.quality][parsed.inversion] : void 0;
|
|
1272
|
+
return makeSpelledChord(parsed.rootPc, parsed.quality, {
|
|
1273
|
+
bassPc: bassInterval === void 0 ? void 0 : pitchClassOf(parsed.rootPc + bassInterval),
|
|
1274
|
+
rootSpelling: parsed.rootSpelling
|
|
1275
|
+
});
|
|
1225
1276
|
}
|
|
1226
1277
|
function romanToChord(text, key) {
|
|
1227
1278
|
const resolved = resolveKey(key);
|
|
1228
|
-
|
|
1279
|
+
if (typeof text !== "string") {
|
|
1280
|
+
throw new InvalidInputError(
|
|
1281
|
+
`Roman numeral must be a string; received ${describeRejected(text)}`
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1229
1284
|
const trimmed = text.trim().replace(/(\d)\/(?=\d)/g, "$1");
|
|
1230
1285
|
const slash = trimmed.indexOf("/");
|
|
1231
1286
|
if (slash >= 0) {
|
|
1232
1287
|
const applied = trimmed.slice(0, slash);
|
|
1233
1288
|
const target = trimmed.slice(slash + 1);
|
|
1234
|
-
const
|
|
1235
|
-
const localKey = tonicizedKey(
|
|
1236
|
-
return chromaticSymbolChord(applied, localKey) ?? chordFromParsed(parseSimpleRoman(applied, localKey
|
|
1289
|
+
const targetNumeral = parseSimpleRoman(target, resolved);
|
|
1290
|
+
const localKey = tonicizedKey(targetNumeral.rootPc, resolved, targetNumeral.rootSpelling);
|
|
1291
|
+
return chromaticSymbolChord(applied, localKey) ?? chordFromParsed(parseSimpleRoman(applied, localKey));
|
|
1237
1292
|
}
|
|
1238
|
-
return chromaticSymbolChord(trimmed, resolved) ?? chordFromParsed(parseSimpleRoman(trimmed,
|
|
1293
|
+
return chromaticSymbolChord(trimmed, resolved) ?? chordFromParsed(parseSimpleRoman(trimmed, resolved));
|
|
1239
1294
|
}
|
|
1240
1295
|
function romanStyle(quality) {
|
|
1241
1296
|
return ROMAN_STYLE[quality];
|
|
1242
1297
|
}
|
|
1243
|
-
function romanSpelling(rootPc, key) {
|
|
1298
|
+
function romanSpelling(rootPc, key, quality) {
|
|
1244
1299
|
const tones = scaleTonesInDegreeOrder(romanReference(key));
|
|
1245
1300
|
const diatonic = tones.indexOf(pitchClassOf(rootPc));
|
|
1246
1301
|
if (diatonic >= 0) {
|
|
@@ -1252,7 +1307,7 @@ function romanSpelling(rootPc, key) {
|
|
|
1252
1307
|
return { degreeNumber: i + 1, accidental: "#" };
|
|
1253
1308
|
}
|
|
1254
1309
|
}
|
|
1255
|
-
for (let i = 0; i < tones.length; i += 1) {
|
|
1310
|
+
for (let i = 0; i < tones.length && !LEADING_TONE_QUALITIES.has(quality); i += 1) {
|
|
1256
1311
|
const degreeNumber2 = i + 1;
|
|
1257
1312
|
if (degreeNumber2 === 1 || degreeNumber2 === 5) {
|
|
1258
1313
|
continue;
|
|
@@ -1298,8 +1353,9 @@ function baseMarker(quality) {
|
|
|
1298
1353
|
}
|
|
1299
1354
|
var TRIAD_FIGURES = { 1: "6", 2: "64" };
|
|
1300
1355
|
var SEVENTH_FIGURES = { 1: "65", 2: "43", 3: "42" };
|
|
1301
|
-
var
|
|
1302
|
-
var
|
|
1356
|
+
var TRIAD_FIGURE_QUALITY_NAMES = ["maj", "min", "dim", "aug"];
|
|
1357
|
+
var TRIAD_FIGURE_QUALITIES = new Set(TRIAD_FIGURE_QUALITY_NAMES);
|
|
1358
|
+
var SEVENTH_FIGURE_QUALITY_NAMES = [
|
|
1303
1359
|
"maj7",
|
|
1304
1360
|
"min7",
|
|
1305
1361
|
"dom7",
|
|
@@ -1308,7 +1364,8 @@ var SEVENTH_FIGURE_QUALITIES = /* @__PURE__ */ new Set([
|
|
|
1308
1364
|
"minMaj7",
|
|
1309
1365
|
"aug7",
|
|
1310
1366
|
"augMaj7"
|
|
1311
|
-
]
|
|
1367
|
+
];
|
|
1368
|
+
var SEVENTH_FIGURE_QUALITIES = new Set(SEVENTH_FIGURE_QUALITY_NAMES);
|
|
1312
1369
|
function numeralFor(degreeNumber, lower) {
|
|
1313
1370
|
const glyph = ROMAN[degreeNumber - 1];
|
|
1314
1371
|
if (glyph === void 0) {
|
|
@@ -1319,6 +1376,47 @@ function numeralFor(degreeNumber, lower) {
|
|
|
1319
1376
|
function chordToRoman(chord, key, opts = {}) {
|
|
1320
1377
|
return renderRoman(toChordData(chord), key, assertOptions(opts, "opts")).roman;
|
|
1321
1378
|
}
|
|
1379
|
+
function readNumeral(chord, resolved, applied) {
|
|
1380
|
+
const key = resolved.scale;
|
|
1381
|
+
const written = writtenRoot(chord);
|
|
1382
|
+
if (applied) {
|
|
1383
|
+
const target = augmentedSixthTarget(chord, resolved) ?? (isAppliedDominant(chord, key) ? appliedChordTarget(chord, key) : null);
|
|
1384
|
+
if (target !== null && leadsByLetter(chord, written, target, resolved)) {
|
|
1385
|
+
return { kind: "applied", target };
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
kind: "root",
|
|
1390
|
+
...writtenSpelling(chord.rootPc, written, resolved) ?? romanSpelling(chord.rootPc, key, chord.quality)
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
function writtenRoot(chord) {
|
|
1394
|
+
const hint = chord.rootSpelling;
|
|
1395
|
+
if (typeof hint !== "object" || hint === null || !Number.isInteger(hint.letter) || hint.letter < 0 || hint.letter > 6 || !Number.isInteger(hint.alter) || pitchClassOf(naturalPitchClassOf(hint.letter) + hint.alter) !== pitchClassOf(chord.rootPc)) {
|
|
1396
|
+
return void 0;
|
|
1397
|
+
}
|
|
1398
|
+
return hint;
|
|
1399
|
+
}
|
|
1400
|
+
function writtenSpelling(rootPc, written, resolved) {
|
|
1401
|
+
const tones = scaleTonesInDegreeOrder(romanReference(resolved.scale));
|
|
1402
|
+
if (written === void 0 || tones.includes(pitchClassOf(rootPc))) {
|
|
1403
|
+
return void 0;
|
|
1404
|
+
}
|
|
1405
|
+
const degreeNumber = diatonicLetterOf(written.letter - resolved.tonic.letter) + 1;
|
|
1406
|
+
const degreePc = tones[degreeNumber - 1];
|
|
1407
|
+
if (degreePc === void 0) {
|
|
1408
|
+
return void 0;
|
|
1409
|
+
}
|
|
1410
|
+
const offset = pitchClassOf(rootPc - degreePc + 6) - 6;
|
|
1411
|
+
return Math.abs(offset) > 1 ? void 0 : { degreeNumber, accidental: offset < 0 ? "b" : offset > 0 ? "#" : "" };
|
|
1412
|
+
}
|
|
1413
|
+
function leadsByLetter(chord, written, target, resolved) {
|
|
1414
|
+
if (written === void 0 || !LEADING_TONE_QUALITIES.has(chord.quality)) {
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
const targetLetter = spellPitchClass(target.rootPc, resolved.tonic, resolved.scale).letter;
|
|
1418
|
+
return diatonicLetterOf(targetLetter - written.letter) === 1;
|
|
1419
|
+
}
|
|
1322
1420
|
function renderRoman(chord, keyLike, opts) {
|
|
1323
1421
|
const resolved = resolveKey(keyLike);
|
|
1324
1422
|
const key = resolved.scale;
|
|
@@ -1326,18 +1424,16 @@ function renderRoman(chord, keyLike, opts) {
|
|
|
1326
1424
|
if (augmented !== null) {
|
|
1327
1425
|
return { roman: augmented, derivation: { kind: "augmentedSixth" } };
|
|
1328
1426
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1427
|
+
const reading = readNumeral(chord, resolved, opts.applied === true);
|
|
1428
|
+
if (reading.kind === "applied") {
|
|
1429
|
+
const local = renderRoman(chord, tonicizedKey(reading.target.rootPc, resolved), {}).roman;
|
|
1430
|
+
const numeral = numeralFor(reading.target.degreeNumber, reading.target.lower);
|
|
1431
|
+
return {
|
|
1432
|
+
roman: `${local}/${numeral}`,
|
|
1433
|
+
derivation: { kind: "applied", target: numeral }
|
|
1434
|
+
};
|
|
1339
1435
|
}
|
|
1340
|
-
const { degreeNumber, accidental: spelledAccidental } =
|
|
1436
|
+
const { degreeNumber, accidental: spelledAccidental } = reading;
|
|
1341
1437
|
const { lower, suffix } = romanStyle(chord.quality);
|
|
1342
1438
|
const cased = numeralFor(degreeNumber, lower);
|
|
1343
1439
|
const accidental = seventhDegreeAccidental(spelledAccidental, degreeNumber, chord.quality, key);
|
|
@@ -1384,7 +1480,15 @@ function renderRoman(chord, keyLike, opts) {
|
|
|
1384
1480
|
}
|
|
1385
1481
|
};
|
|
1386
1482
|
}
|
|
1387
|
-
var DEGREE_NAMES = [
|
|
1483
|
+
var DEGREE_NAMES = [
|
|
1484
|
+
"first",
|
|
1485
|
+
"second",
|
|
1486
|
+
"third",
|
|
1487
|
+
"fourth",
|
|
1488
|
+
"fifth",
|
|
1489
|
+
"sixth",
|
|
1490
|
+
"seventh"
|
|
1491
|
+
];
|
|
1388
1492
|
function describeDegree(degreeNumber, accidental, literal) {
|
|
1389
1493
|
const name = DEGREE_NAMES[degreeNumber - 1] ?? `${degreeNumber}th`;
|
|
1390
1494
|
if (accidental !== literal) {
|
|
@@ -1466,6 +1570,7 @@ function capitalize(text) {
|
|
|
1466
1570
|
}
|
|
1467
1571
|
|
|
1468
1572
|
// src/analyze/functional/function.ts
|
|
1573
|
+
var HARMONIC_FUNCTIONS = Object.freeze(["tonic", "subdominant", "dominant"]);
|
|
1469
1574
|
var FUNCTION_BY_OFFSET = [
|
|
1470
1575
|
"tonic",
|
|
1471
1576
|
// 0 I
|
|
@@ -1522,18 +1627,19 @@ function isMinorKey(key) {
|
|
|
1522
1627
|
return isMinorScale(toKeyScale(key));
|
|
1523
1628
|
}
|
|
1524
1629
|
function functionOf(chord, key) {
|
|
1525
|
-
return functionWithReason(toChordData(chord),
|
|
1630
|
+
return functionWithReason(toChordData(chord), resolveKey(key)).function;
|
|
1526
1631
|
}
|
|
1527
1632
|
function degreeFunction(chord, key) {
|
|
1528
1633
|
const functions = isMinorScale(key) ? MINOR_FUNCTION_BY_OFFSET : FUNCTION_BY_OFFSET;
|
|
1529
1634
|
return functions[pitchClassOf(chord.rootPc - key.rootPc)] ?? "tonic";
|
|
1530
1635
|
}
|
|
1531
|
-
function functionWithReason(chord,
|
|
1636
|
+
function functionWithReason(chord, resolved) {
|
|
1637
|
+
const key = resolved.scale;
|
|
1532
1638
|
const offset = pitchClassOf(chord.rootPc - key.rootPc);
|
|
1533
1639
|
if (isNeapolitanChordOf(chord, key)) {
|
|
1534
1640
|
return { function: "subdominant", reason: "neapolitan" };
|
|
1535
1641
|
}
|
|
1536
|
-
if (augmentedSixthKindOf(chord,
|
|
1642
|
+
if (augmentedSixthKindOf(chord, resolved) !== null) {
|
|
1537
1643
|
return { function: "subdominant", reason: "augmentedSixth" };
|
|
1538
1644
|
}
|
|
1539
1645
|
if (isAppliedDominant(chord, key)) {
|
|
@@ -1559,7 +1665,8 @@ function describeChord(fn, reason, roman, source) {
|
|
|
1559
1665
|
const head = reason === "degree" ? `${capitalize(fn)}: ${roman} takes the ${fn} function of its degree in the key` : `${capitalize(fn)}: ${roman} is ${FUNCTION_REASON_PHRASE[reason]}`;
|
|
1560
1666
|
return source === null || reason === "neapolitan" ? head : `${head}, ${BORROWED_SOURCE_PHRASE[source]}`;
|
|
1561
1667
|
}
|
|
1562
|
-
function functionAlternatives(chord,
|
|
1668
|
+
function functionAlternatives(chord, resolved, reason, accepted) {
|
|
1669
|
+
const key = resolved.scale;
|
|
1563
1670
|
const out = [];
|
|
1564
1671
|
if (reason === "degree") {
|
|
1565
1672
|
if (pointsAtTonicizableDegree(chord, key) && isDiatonicChord(chord, key)) {
|
|
@@ -1577,7 +1684,7 @@ function functionAlternatives(chord, key, reason, accepted) {
|
|
|
1577
1684
|
});
|
|
1578
1685
|
}
|
|
1579
1686
|
}
|
|
1580
|
-
if (augmentedSixthKindOf(chord,
|
|
1687
|
+
if (augmentedSixthKindOf(chord, resolved) === "german") {
|
|
1581
1688
|
out.push({
|
|
1582
1689
|
label: "tritone substitute",
|
|
1583
1690
|
reason: "The German sixth sounds a dominant seventh, but the lowered submediant under it fixes the reading as an augmented sixth"
|
|
@@ -1594,10 +1701,10 @@ function parallelKey(key) {
|
|
|
1594
1701
|
function analyzeChord(chord, key, opts = {}) {
|
|
1595
1702
|
const asked = assertOptions(opts, "opts");
|
|
1596
1703
|
const data = toChordData(chord);
|
|
1597
|
-
const
|
|
1598
|
-
const source = borrowedSourceOf(data, scale);
|
|
1599
|
-
const { function: fn, reason } = functionWithReason(data,
|
|
1600
|
-
const roman = renderRoman(data,
|
|
1704
|
+
const resolved = resolveKey(key);
|
|
1705
|
+
const source = borrowedSourceOf(data, resolved.scale);
|
|
1706
|
+
const { function: fn, reason } = functionWithReason(data, resolved);
|
|
1707
|
+
const roman = renderRoman(data, resolved, asked).roman;
|
|
1601
1708
|
return {
|
|
1602
1709
|
function: fn,
|
|
1603
1710
|
borrowed: source !== null,
|
|
@@ -1605,43 +1712,41 @@ function analyzeChord(chord, key, opts = {}) {
|
|
|
1605
1712
|
roman,
|
|
1606
1713
|
rationale: describeChord(fn, reason, roman, source),
|
|
1607
1714
|
alternatives: asked.alternatives === true ? [
|
|
1608
|
-
...functionAlternatives(data,
|
|
1609
|
-
...romanAlternatives(data,
|
|
1715
|
+
...functionAlternatives(data, resolved, reason, fn),
|
|
1716
|
+
...romanAlternatives(data, resolved, asked)
|
|
1610
1717
|
] : []
|
|
1611
1718
|
};
|
|
1612
1719
|
}
|
|
1613
1720
|
function secondaryDominant(targetDegree, key) {
|
|
1614
|
-
const
|
|
1721
|
+
const resolved = resolveKey(key);
|
|
1722
|
+
const scale = resolved.scale;
|
|
1615
1723
|
const degreeCount = scaleTonesInDegreeOrder(scale).length;
|
|
1616
1724
|
assertInteger(targetDegree, "targetDegree", 1, degreeCount);
|
|
1617
1725
|
const targetRoot = degreeRootPc(targetDegree, scale);
|
|
1618
|
-
|
|
1726
|
+
const rootSpelling = transposeByInterval(keySpelling(targetRoot, resolved), "P5");
|
|
1727
|
+
return makeSpelledChord(pitchClassOf(targetRoot + 7), "dom7", { rootSpelling });
|
|
1619
1728
|
}
|
|
1620
1729
|
function secondaryDominantOf(target) {
|
|
1621
1730
|
const data = toChordData(target);
|
|
1622
1731
|
assertFiniteNumber(data.rootPc, "target chord rootPc");
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
const moved = transposeNote(data.rootSpelling, 7);
|
|
1626
|
-
dominant.rootSpelling = { letter: moved.letter, alter: moved.alter };
|
|
1627
|
-
}
|
|
1628
|
-
return dominant;
|
|
1732
|
+
const rootSpelling = data.rootSpelling === void 0 ? void 0 : transposeNote(data.rootSpelling, 7);
|
|
1733
|
+
return makeSpelledChord(pitchClassOf(data.rootPc + 7), "dom7", { rootSpelling });
|
|
1629
1734
|
}
|
|
1630
1735
|
|
|
1631
1736
|
// src/analyze/functional/pivot.ts
|
|
1632
1737
|
var HEPTATONIC_DEGREES = 7;
|
|
1633
1738
|
function pivotChords(from, to) {
|
|
1634
|
-
return pivotsBetween(
|
|
1739
|
+
return pivotsBetween(resolveKey(from), resolveKey(to));
|
|
1635
1740
|
}
|
|
1636
1741
|
function pivotsBetween(from, to) {
|
|
1637
|
-
if (scaleTonesInDegreeOrder(from).length !== HEPTATONIC_DEGREES) {
|
|
1742
|
+
if (scaleTonesInDegreeOrder(from.scale).length !== HEPTATONIC_DEGREES) {
|
|
1638
1743
|
return [];
|
|
1639
1744
|
}
|
|
1640
1745
|
const pivots = [];
|
|
1641
1746
|
const seen = /* @__PURE__ */ new Set();
|
|
1642
1747
|
for (let degree = 1; degree <= HEPTATONIC_DEGREES; degree += 1) {
|
|
1643
1748
|
const chord = stackedTriad(degree, from);
|
|
1644
|
-
if (chord === null || !isDiatonicChord(chord, to)) {
|
|
1749
|
+
if (chord === null || !isDiatonicChord(chord, to.scale)) {
|
|
1645
1750
|
continue;
|
|
1646
1751
|
}
|
|
1647
1752
|
const identity = `${chord.rootPc}:${chord.quality}`;
|
|
@@ -1669,6 +1774,16 @@ function stackedTriad(degree, key) {
|
|
|
1669
1774
|
}
|
|
1670
1775
|
|
|
1671
1776
|
// src/analyze/form/internal.ts
|
|
1777
|
+
function barSpanOf(startBeat, endBeat, meter) {
|
|
1778
|
+
const firstBar = barIndexAt(startBeat, meter);
|
|
1779
|
+
const lastBar = barIndexAt(endBeat, meter);
|
|
1780
|
+
const partOf = (beat, bar) => {
|
|
1781
|
+
const barStart = barPositionToBeat({ bar, beat: 0 }, meter);
|
|
1782
|
+
const barLength = beatsPerBarAt(barStart, meter);
|
|
1783
|
+
return barLength > 0 ? (beat - barStart) / barLength : 0;
|
|
1784
|
+
};
|
|
1785
|
+
return lastBar - firstBar + partOf(endBeat, lastBar) - partOf(startBeat, firstBar);
|
|
1786
|
+
}
|
|
1672
1787
|
function clamp01(value) {
|
|
1673
1788
|
return Math.min(1, Math.max(0, value));
|
|
1674
1789
|
}
|
|
@@ -1682,6 +1797,14 @@ function firstSoundingBeat(notes, fallback = 0) {
|
|
|
1682
1797
|
}
|
|
1683
1798
|
return Number.isFinite(first) ? first : fallback;
|
|
1684
1799
|
}
|
|
1800
|
+
function resolveSpanEnd(totalBeats, startBeat, soundingEnd, name) {
|
|
1801
|
+
const given = totalBeats ?? null;
|
|
1802
|
+
if (given === null) {
|
|
1803
|
+
return Math.max(startBeat, soundingEnd);
|
|
1804
|
+
}
|
|
1805
|
+
assertRange(given, Math.min(0, startBeat), Number.MAX_SAFE_INTEGER, name);
|
|
1806
|
+
return Math.max(startBeat, given);
|
|
1807
|
+
}
|
|
1685
1808
|
function lastBarOf(meter, spanStart, spanEnd) {
|
|
1686
1809
|
const firstBar = barIndexAt(spanStart, meter);
|
|
1687
1810
|
const bar = barIndexAt(Math.max(spanStart, spanEnd), meter);
|
|
@@ -1763,6 +1886,16 @@ function gridOriginOf(firstOnset, slotBeats) {
|
|
|
1763
1886
|
function gridForNotes(notes, slotBeats) {
|
|
1764
1887
|
return gridOriginOf(firstSoundingBeat(notes), slotBeats);
|
|
1765
1888
|
}
|
|
1889
|
+
function latestSoundingEnd(notes, fallback = 0) {
|
|
1890
|
+
if (notes.length === 0) {
|
|
1891
|
+
return fallback;
|
|
1892
|
+
}
|
|
1893
|
+
let end = Number.NEGATIVE_INFINITY;
|
|
1894
|
+
for (const note of notes) {
|
|
1895
|
+
end = Math.max(end, note.startBeat + note.durationBeat);
|
|
1896
|
+
}
|
|
1897
|
+
return end;
|
|
1898
|
+
}
|
|
1766
1899
|
function barGridStart(firstOnset, meter) {
|
|
1767
1900
|
return gridOriginOf(firstOnset, beatsPerBarAt(firstOnset, meter)).startBeat;
|
|
1768
1901
|
}
|
|
@@ -2053,9 +2186,13 @@ function keyTimelineFromNotes(notes, opts = {}) {
|
|
|
2053
2186
|
const budget = opts.budget;
|
|
2054
2187
|
assertNoteEvents(notes, "key timeline notes", { allowNonPositiveDuration: true, budget });
|
|
2055
2188
|
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
|
|
2189
|
+
const totalBeats = opts.totalBeats ?? latestSoundingEnd(sounding);
|
|
2190
|
+
assertRange(
|
|
2191
|
+
totalBeats,
|
|
2192
|
+
Math.min(0, firstSoundingBeat(sounding)),
|
|
2193
|
+
Number.MAX_SAFE_INTEGER,
|
|
2194
|
+
"key timeline totalBeats"
|
|
2195
|
+
);
|
|
2059
2196
|
const slotBeats = Math.min(minKeyBeats, expectedKeyBeats);
|
|
2060
2197
|
const { origin, startBeat: musicStart } = gridForNotes(sounding, slotBeats);
|
|
2061
2198
|
const slotCount = Math.max(0, Math.ceil((totalBeats - origin) / slotBeats - BEAT_EPS));
|
|
@@ -2191,9 +2328,10 @@ function detectModulations(chords, opts = {}) {
|
|
|
2191
2328
|
opts.budget
|
|
2192
2329
|
);
|
|
2193
2330
|
const sorted = [...chords].filter((segment) => segment.endBeat > segment.startBeat).sort((a, b) => a.startBeat - b.startBeat);
|
|
2194
|
-
const lastChordEnd = sorted.reduce((end, segment) => Math.max(end, segment.endBeat),
|
|
2331
|
+
const lastChordEnd = sorted.length === 0 ? 0 : sorted.reduce((end, segment) => Math.max(end, segment.endBeat), Number.NEGATIVE_INFINITY);
|
|
2195
2332
|
const totalBeats = opts.totalBeats ?? lastChordEnd;
|
|
2196
|
-
|
|
2333
|
+
const lowest = totalBeats < 0 ? Math.min(0, sorted[0]?.startBeat ?? 0) : 0;
|
|
2334
|
+
assertRange(totalBeats, lowest, Number.MAX_SAFE_INTEGER, "modulation totalBeats");
|
|
2197
2335
|
const ordered = sorted.map((segment) => ({ ...segment, endBeat: Math.min(segment.endBeat, totalBeats) })).filter((segment) => segment.endBeat > segment.startBeat);
|
|
2198
2336
|
if (ordered.length === 0) {
|
|
2199
2337
|
return [];
|
|
@@ -2390,7 +2528,7 @@ function prevailingKeyOf(regions) {
|
|
|
2390
2528
|
// src/analyze/voice/index.ts
|
|
2391
2529
|
function keyScaleAt(key) {
|
|
2392
2530
|
if (typeof key === "function") {
|
|
2393
|
-
return key;
|
|
2531
|
+
return (beat) => toKeyScale(key(beat));
|
|
2394
2532
|
}
|
|
2395
2533
|
const scale = toKeyScale(key);
|
|
2396
2534
|
return () => scale;
|
|
@@ -2419,7 +2557,8 @@ var TENSION_DEGREE_BY_INTERVAL = {
|
|
|
2419
2557
|
8: 13,
|
|
2420
2558
|
9: 13
|
|
2421
2559
|
};
|
|
2422
|
-
var
|
|
2560
|
+
var NATURAL_TENSION_INTERVAL_LIST = [2, 5, 9];
|
|
2561
|
+
var NATURAL_TENSION_INTERVALS = new Set(NATURAL_TENSION_INTERVAL_LIST);
|
|
2423
2562
|
function tensionDegree(ic) {
|
|
2424
2563
|
return TENSION_DEGREE_BY_INTERVAL[ic];
|
|
2425
2564
|
}
|
|
@@ -2434,11 +2573,22 @@ function isStep(a, b) {
|
|
|
2434
2573
|
return d === 1 || d === 2;
|
|
2435
2574
|
}
|
|
2436
2575
|
var LEAP_SEMITONES = 3;
|
|
2437
|
-
function suspensionType(ic, delta) {
|
|
2576
|
+
function suspensionType(ic, delta, resolvedIc) {
|
|
2577
|
+
const figure = suspendedFigure(ic, delta);
|
|
2578
|
+
return figure !== null && SUSPENSION_RESOLUTIONS[figure].includes(resolvedIc) ? figure : null;
|
|
2579
|
+
}
|
|
2580
|
+
var SUSPENSION_RESOLUTIONS = {
|
|
2581
|
+
"sus4-3": [3, 4],
|
|
2582
|
+
"sus6-5": [7],
|
|
2583
|
+
"sus7-6": [8, 9],
|
|
2584
|
+
"sus9-8": [0],
|
|
2585
|
+
"sus2-3": [3, 4]
|
|
2586
|
+
};
|
|
2587
|
+
function suspendedFigure(ic, delta) {
|
|
2438
2588
|
if (delta > 0) {
|
|
2439
2589
|
return ic === 1 || ic === 2 ? "sus2-3" : null;
|
|
2440
2590
|
}
|
|
2441
|
-
if (ic === 2) {
|
|
2591
|
+
if (ic === 1 || ic === 2) {
|
|
2442
2592
|
return "sus9-8";
|
|
2443
2593
|
}
|
|
2444
2594
|
if (ic === 10 || ic === 11) {
|
|
@@ -2452,6 +2602,18 @@ function suspensionType(ic, delta) {
|
|
|
2452
2602
|
}
|
|
2453
2603
|
return null;
|
|
2454
2604
|
}
|
|
2605
|
+
function clashesWithChord(pitch, chord) {
|
|
2606
|
+
if (!isConsonantInterval(intervalAboveBass(pitch, [], chord), true)) {
|
|
2607
|
+
return true;
|
|
2608
|
+
}
|
|
2609
|
+
const tones = chord.intervals.map((interval) => chord.rootPc + interval);
|
|
2610
|
+
if (chord.bassPc !== void 0) {
|
|
2611
|
+
tones.push(chord.bassPc);
|
|
2612
|
+
}
|
|
2613
|
+
return tones.some(
|
|
2614
|
+
(tone) => !isConsonantInterval(((pitchClassOf(pitch) - tone) % 12 + 12) % 12, false)
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2455
2617
|
var STEP_RESOLUTION = 2;
|
|
2456
2618
|
var NEAREST_CHORD_TONE_REACH = 12;
|
|
2457
2619
|
function nearestChordTone(pitch, chord, maxDistance) {
|
|
@@ -2471,17 +2633,20 @@ function analyzeVoice(voice, chordAtBeat2, key, otherVoicesAtBeat = () => []) {
|
|
|
2471
2633
|
assertFunction(otherVoicesAtBeat, "otherVoicesAtBeat");
|
|
2472
2634
|
const keyAt = keyScaleAt(key);
|
|
2473
2635
|
const result = [];
|
|
2636
|
+
const sounding = soundingIndices(voice);
|
|
2637
|
+
const line = new Map(sounding.map((index, position) => [index, position]));
|
|
2474
2638
|
for (let i = 0; i < voice.length; i += 1) {
|
|
2475
2639
|
const note = voice[i];
|
|
2476
2640
|
if (!note) {
|
|
2477
2641
|
continue;
|
|
2478
2642
|
}
|
|
2479
|
-
const
|
|
2480
|
-
const
|
|
2481
|
-
const
|
|
2482
|
-
const
|
|
2483
|
-
const
|
|
2484
|
-
const
|
|
2643
|
+
const position = line.get(i);
|
|
2644
|
+
const prevIndex = position === void 0 ? void 0 : sounding[position - 1];
|
|
2645
|
+
const nextIndex = position === void 0 ? void 0 : sounding[position + 1];
|
|
2646
|
+
const prevNote = prevIndex === void 0 ? void 0 : voice[prevIndex];
|
|
2647
|
+
const nextNote = nextIndex === void 0 ? void 0 : voice[nextIndex];
|
|
2648
|
+
const prev = prevNote !== void 0 && !sameInstant(prevNote.startBeat, note.startBeat) && adjacent(prevNote.startBeat + prevNote.durationBeat, note.startBeat) ? prevNote : void 0;
|
|
2649
|
+
const next = nextNote !== void 0 && !sameInstant(nextNote.startBeat, note.startBeat) && adjacent(note.startBeat + note.durationBeat, nextNote.startBeat) ? nextNote : void 0;
|
|
2485
2650
|
const chord = chordAtBeat2(note.startBeat);
|
|
2486
2651
|
const labels = [];
|
|
2487
2652
|
const member = isChordMember(note.pitch, chord);
|
|
@@ -2508,12 +2673,11 @@ function analyzeVoice(voice, chordAtBeat2, key, otherVoicesAtBeat = () => []) {
|
|
|
2508
2673
|
const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
|
|
2509
2674
|
const resolves = isStep(next.pitch, note.pitch);
|
|
2510
2675
|
const others = otherVoicesAtBeat(note.startBeat);
|
|
2511
|
-
const verticallyDissonant = others.some(
|
|
2512
|
-
(ov) => createsVerticalDissonance(note.pitch, ov.pitch, true)
|
|
2513
|
-
);
|
|
2676
|
+
const verticallyDissonant = others.length > 0 ? others.some((ov) => createsVerticalDissonance(note.pitch, ov.pitch, true)) : clashesWithChord(note.pitch, chord);
|
|
2514
2677
|
if (prepared && verticallyDissonant && resolves) {
|
|
2515
2678
|
const ic = intervalAboveBass(note.pitch, others, chord);
|
|
2516
|
-
const
|
|
2679
|
+
const resolvedIc = intervalAboveBass(next.pitch, others, chord);
|
|
2680
|
+
const type = suspensionType(ic, next.pitch - note.pitch, resolvedIc);
|
|
2517
2681
|
if (type !== null) {
|
|
2518
2682
|
labels.push({ kind: "suspension", type, resolveTo: next.pitch });
|
|
2519
2683
|
handled = true;
|
|
@@ -2643,8 +2807,34 @@ function assertChordTimeline(timeline, name = "timeline") {
|
|
|
2643
2807
|
assertFiniteNumber(segment.startBeat, `${name}.segments[${index}].startBeat`);
|
|
2644
2808
|
assertFiniteNumber(segment.endBeat, `${name}.segments[${index}].endBeat`);
|
|
2645
2809
|
}
|
|
2810
|
+
assertSpansInOrder(segments, `${name}.segments`);
|
|
2646
2811
|
return read;
|
|
2647
2812
|
}
|
|
2813
|
+
function assertSpansInOrder(spans, name) {
|
|
2814
|
+
for (let index = 0; index < spans.length; index += 1) {
|
|
2815
|
+
const span = spans[index];
|
|
2816
|
+
if (span === void 0) {
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
2819
|
+
if (!(span.endBeat > span.startBeat)) {
|
|
2820
|
+
throw new InvalidInputError(
|
|
2821
|
+
`${name}[${index}] must end after it starts; received [${span.startBeat}, ${span.endBeat})`
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
const previous = spans[index - 1];
|
|
2825
|
+
if (previous !== void 0 && span.startBeat < previous.endBeat) {
|
|
2826
|
+
throw new InvalidInputError(
|
|
2827
|
+
`${name}[${index}] must not start before ${name}[${index - 1}] ends; received ${span.startBeat} before ${previous.endBeat}`
|
|
2828
|
+
);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
function statedKeyRegions(key, startBeat, endBeat) {
|
|
2833
|
+
if (!(endBeat > startBeat)) {
|
|
2834
|
+
return [];
|
|
2835
|
+
}
|
|
2836
|
+
return [{ startBeat, endBeat, key, confidence: 1 }];
|
|
2837
|
+
}
|
|
2648
2838
|
function chordTimelineFromChords(chords, totalBeats) {
|
|
2649
2839
|
assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, "timeline totalBeats");
|
|
2650
2840
|
assertArray(chords, "chords");
|
|
@@ -2709,6 +2899,7 @@ var EXTRA_TONE_PENALTY = 0.6;
|
|
|
2709
2899
|
var OMITTED_TONE_PENALTY = 0.15;
|
|
2710
2900
|
var BASS_ROOT_STANDING = 0.95;
|
|
2711
2901
|
var OCTAVE = 12;
|
|
2902
|
+
var CHORD_SEGMENTATIONS = ["dynamic", "grid"];
|
|
2712
2903
|
function scoreMatch(match, weights, maxWeight, key, pedalPc) {
|
|
2713
2904
|
const rootWeight = maxWeight > 0 ? (weights[match.rootPc] ?? 0) / maxWeight : 0;
|
|
2714
2905
|
const rootStanding = match.inversion === 0 ? Math.max(rootWeight, BASS_ROOT_STANDING) : rootWeight;
|
|
@@ -2725,7 +2916,10 @@ function scoreMatch(match, weights, maxWeight, key, pedalPc) {
|
|
|
2725
2916
|
score -= OMITTED_TONE_PENALTY * account.missing;
|
|
2726
2917
|
return score;
|
|
2727
2918
|
}
|
|
2728
|
-
var
|
|
2919
|
+
var SLASH_UPPER_QUALITY_LIST = ["maj", "min"];
|
|
2920
|
+
var SLASH_UPPER_QUALITIES = new Set(
|
|
2921
|
+
SLASH_UPPER_QUALITY_LIST
|
|
2922
|
+
);
|
|
2729
2923
|
function isSlashReading(match, pedalPc) {
|
|
2730
2924
|
return pedalPc !== null && match.inversion === null && match.missingPcs.length === 0 && match.extraPcs.length === 1 && match.extraPcs[0] === pedalPc && SLASH_UPPER_QUALITIES.has(match.quality);
|
|
2731
2925
|
}
|
|
@@ -2757,6 +2951,30 @@ function bassSoundsOnlyBelow(notes, windowStart, windowEnd, bassPc) {
|
|
|
2757
2951
|
}
|
|
2758
2952
|
return highestBass <= upperFloor;
|
|
2759
2953
|
}
|
|
2954
|
+
function windowBass(notes, windowStart, windowEnd, meters, harmonicRhythm) {
|
|
2955
|
+
const counted = [];
|
|
2956
|
+
let firstSounding = Number.POSITIVE_INFINITY;
|
|
2957
|
+
for (const note of notes) {
|
|
2958
|
+
const overlap = Math.min(note.startBeat + note.durationBeat, windowEnd) - Math.max(note.startBeat, windowStart);
|
|
2959
|
+
if (overlap <= BEAT_EPS) {
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
const onsetInWindow = note.startBeat >= windowStart - BEAT_EPS && note.startBeat < windowEnd - BEAT_EPS;
|
|
2963
|
+
if (noteWeight(note, overlap, onsetInWindow ? meters : void 0) <= BEAT_EPS) {
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
counted.push({ note, overlap });
|
|
2967
|
+
firstSounding = Math.min(firstSounding, Math.max(note.startBeat, windowStart));
|
|
2968
|
+
}
|
|
2969
|
+
const half = (windowEnd - windowStart) / 2;
|
|
2970
|
+
let bass = Number.POSITIVE_INFINITY;
|
|
2971
|
+
for (const { note, overlap } of counted) {
|
|
2972
|
+
if (note.startBeat <= firstSounding + BEAT_EPS || overlap > half || overlap >= harmonicRhythm - BEAT_EPS) {
|
|
2973
|
+
bass = Math.min(bass, note.pitch);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
return bass;
|
|
2977
|
+
}
|
|
2760
2978
|
function chordConfidence(chord, weights, totalWeight, exact) {
|
|
2761
2979
|
const tones = new Set(chordPitchClasses(chord));
|
|
2762
2980
|
let chordWeight = 0;
|
|
@@ -2769,23 +2987,20 @@ function chordConfidence(chord, weights, totalWeight, exact) {
|
|
|
2769
2987
|
const confidence = Math.min(1, Math.max(0, raw));
|
|
2770
2988
|
return exact ? confidence : confidence * INEXACT_CONFIDENCE_FACTOR;
|
|
2771
2989
|
}
|
|
2772
|
-
function analyzeWindow(notes, windowStart, windowEnd, meters, key) {
|
|
2773
|
-
const { weights, totalWeight, maxWeight
|
|
2774
|
-
notes,
|
|
2775
|
-
windowStart,
|
|
2776
|
-
windowEnd,
|
|
2777
|
-
meters
|
|
2778
|
-
);
|
|
2990
|
+
function analyzeWindow(notes, windowStart, windowEnd, meters, key, harmonicRhythm) {
|
|
2991
|
+
const { weights, totalWeight, maxWeight } = windowWeights(notes, windowStart, windowEnd, meters);
|
|
2779
2992
|
if (totalWeight <= BEAT_EPS) {
|
|
2780
2993
|
return null;
|
|
2781
2994
|
}
|
|
2782
2995
|
const ranked = weights.map((weight, pc) => ({ pc, weight })).filter(({ weight }) => weight > 0).sort((a, b) => b.weight - a.weight || a.pc - b.pc);
|
|
2783
|
-
const
|
|
2784
|
-
const
|
|
2785
|
-
const
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2996
|
+
const bassPc = pitchClassOf(windowBass(notes, windowStart, windowEnd, meters, harmonicRhythm));
|
|
2997
|
+
const sounding = ranked.slice(0, MAX_DETECTION_PCS).map(({ pc }) => pc);
|
|
2998
|
+
const significant = ranked.filter(({ weight }) => weight >= maxWeight * NOISE_THRESHOLD_RATIO).slice(0, MAX_DETECTION_PCS).map(({ pc }) => pc);
|
|
2999
|
+
let detection = detectWindow(significant, bassPc, notes, windowStart, windowEnd);
|
|
3000
|
+
if (detection.matches.length === 0 && significant.length < sounding.length) {
|
|
3001
|
+
detection = detectWindow(sounding, bassPc, notes, windowStart, windowEnd);
|
|
3002
|
+
}
|
|
3003
|
+
const { selected, hasBass, pedalPc, matches } = detection;
|
|
2789
3004
|
let bestMatch = null;
|
|
2790
3005
|
let bestScore = Number.NEGATIVE_INFINITY;
|
|
2791
3006
|
for (const match of matches) {
|
|
@@ -2802,10 +3017,26 @@ function analyzeWindow(notes, windowStart, windowEnd, meters, key) {
|
|
|
2802
3017
|
makeChord(bestMatch.rootPc, bestMatch.quality),
|
|
2803
3018
|
pedalPc
|
|
2804
3019
|
);
|
|
3020
|
+
const explained = account.exact || isSlashReading(bestMatch, pedalPc);
|
|
2805
3021
|
const reading = {
|
|
2806
3022
|
chord: shared(chord),
|
|
2807
|
-
confidence: chordConfidence(chord, weights, totalWeight,
|
|
3023
|
+
confidence: chordConfidence(chord, weights, totalWeight, explained)
|
|
2808
3024
|
};
|
|
3025
|
+
if (bestMatch.quality === SYMMETRIC_QUALITY) {
|
|
3026
|
+
reading.rootings = chordPitchClasses(makeChord(bestMatch.rootPc, bestMatch.quality)).map(
|
|
3027
|
+
(rootPc) => {
|
|
3028
|
+
const rooted = makeChord(
|
|
3029
|
+
rootPc,
|
|
3030
|
+
bestMatch.quality,
|
|
3031
|
+
hasBass && bassPc !== rootPc ? bassPc : void 0
|
|
3032
|
+
);
|
|
3033
|
+
return {
|
|
3034
|
+
chord: shared(rooted),
|
|
3035
|
+
confidence: chordConfidence(rooted, weights, totalWeight, explained)
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
2809
3040
|
const augmented = hasBass ? augmentedSixthFromPitchClasses(selected, bassPc, key) : null;
|
|
2810
3041
|
if (augmented !== null) {
|
|
2811
3042
|
reading.augmented = {
|
|
@@ -2817,6 +3048,13 @@ function analyzeWindow(notes, windowStart, windowEnd, meters, key) {
|
|
|
2817
3048
|
}
|
|
2818
3049
|
return null;
|
|
2819
3050
|
}
|
|
3051
|
+
function detectWindow(selected, bassPc, notes, windowStart, windowEnd) {
|
|
3052
|
+
const hasBass = selected.includes(bassPc);
|
|
3053
|
+
const detectionPitches = hasBass ? selected.map((pc) => pc === bassPc ? pc : pc + 12) : selected;
|
|
3054
|
+
const pedalPc = hasBass && bassSoundsOnlyBelow(notes, windowStart, windowEnd, bassPc) ? bassPc : null;
|
|
3055
|
+
const matches = detectChord(detectionPitches, { input: hasBass ? "midi" : "pitchClass" });
|
|
3056
|
+
return { selected, hasBass, pedalPc, matches };
|
|
3057
|
+
}
|
|
2820
3058
|
function shared(chord) {
|
|
2821
3059
|
Object.freeze(chord.intervals);
|
|
2822
3060
|
return Object.freeze(chord);
|
|
@@ -2843,6 +3081,29 @@ function resolvesOntoDominant(readings, index, key) {
|
|
|
2843
3081
|
}
|
|
2844
3082
|
return true;
|
|
2845
3083
|
}
|
|
3084
|
+
var SYMMETRIC_QUALITY = "dim7";
|
|
3085
|
+
function settledDiminishedRoot(readings, index, key) {
|
|
3086
|
+
const reading = readings[index];
|
|
3087
|
+
const rootings = reading?.rootings;
|
|
3088
|
+
if (!reading || rootings === void 0) {
|
|
3089
|
+
return null;
|
|
3090
|
+
}
|
|
3091
|
+
const roots = rootings.map((rooting) => rooting.chord.rootPc);
|
|
3092
|
+
const tones = chordPitchClasses(reading.chord).join();
|
|
3093
|
+
for (let i = index + 1; i < readings.length; i += 1) {
|
|
3094
|
+
const next = readings[i];
|
|
3095
|
+
if (!next || chordPitchClasses(next.chord).join() === tones) {
|
|
3096
|
+
continue;
|
|
3097
|
+
}
|
|
3098
|
+
const leading = pitchClassOf(next.chord.rootPc - 1);
|
|
3099
|
+
if (roots.includes(leading)) {
|
|
3100
|
+
return leading;
|
|
3101
|
+
}
|
|
3102
|
+
break;
|
|
3103
|
+
}
|
|
3104
|
+
const leadingTone = pitchClassOf(key.rootPc - 1);
|
|
3105
|
+
return roots.includes(leadingTone) ? leadingTone : null;
|
|
3106
|
+
}
|
|
2846
3107
|
function sameChord(a, b) {
|
|
2847
3108
|
return a.rootPc === b.rootPc && a.quality === b.quality && a.bassPc === b.bassPc;
|
|
2848
3109
|
}
|
|
@@ -3091,7 +3352,11 @@ function analyzeTimeline(notes, opts = {}, previous, dirty) {
|
|
|
3091
3352
|
const meters = resolveMeters(opts, "timeline meters");
|
|
3092
3353
|
const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBarAt(0, meters);
|
|
3093
3354
|
assertRange(harmonicRhythm, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "harmonic rhythm");
|
|
3094
|
-
const segmentation =
|
|
3355
|
+
const segmentation = assertOneOf(
|
|
3356
|
+
opts.segmentation ?? "dynamic",
|
|
3357
|
+
CHORD_SEGMENTATIONS,
|
|
3358
|
+
"timeline segmentation"
|
|
3359
|
+
);
|
|
3095
3360
|
const gridUnit = metricGridUnit(meters);
|
|
3096
3361
|
const minChordBeats = opts.minChordBeats ?? gridUnit;
|
|
3097
3362
|
assertRange(minChordBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "minChordBeats");
|
|
@@ -3103,9 +3368,13 @@ function analyzeTimeline(notes, opts = {}, previous, dirty) {
|
|
|
3103
3368
|
}
|
|
3104
3369
|
assertNoteEvents(notes, "timeline notes", noteOptions);
|
|
3105
3370
|
const sounding = sortedNoteEvents(notes.filter((note) => note.durationBeat > 0));
|
|
3106
|
-
const
|
|
3107
|
-
|
|
3108
|
-
|
|
3371
|
+
const totalBeats = opts.totalBeats ?? latestSoundingEnd(sounding);
|
|
3372
|
+
assertRange(
|
|
3373
|
+
totalBeats,
|
|
3374
|
+
Math.min(0, firstSoundingBeat(sounding)),
|
|
3375
|
+
Number.MAX_SAFE_INTEGER,
|
|
3376
|
+
"timeline totalBeats"
|
|
3377
|
+
);
|
|
3109
3378
|
const slotBeats = segmentation === "grid" ? harmonicRhythm : slotBeatsWithin(gridUnit, minChordBeats, harmonicRhythm);
|
|
3110
3379
|
const { origin, startBeat: musicStart } = gridForNotes(sounding, slotBeats);
|
|
3111
3380
|
const grid = { origin, slotBeats };
|
|
@@ -3116,14 +3385,7 @@ function analyzeTimeline(notes, opts = {}, previous, dirty) {
|
|
|
3116
3385
|
"timeline windows",
|
|
3117
3386
|
budget
|
|
3118
3387
|
);
|
|
3119
|
-
const keys = opts.key !== void 0 ?
|
|
3120
|
-
{
|
|
3121
|
-
startBeat: musicStart,
|
|
3122
|
-
endBeat: totalBeats,
|
|
3123
|
-
key: resolveKey(opts.key),
|
|
3124
|
-
confidence: 1
|
|
3125
|
-
}
|
|
3126
|
-
] : keyTimelineFromNotes(sounding, { meters, totalBeats, budget });
|
|
3388
|
+
const keys = opts.key !== void 0 ? statedKeyRegions(resolveKey(opts.key), musicStart, totalBeats) : keyTimelineFromNotes(sounding, { meters, totalBeats, budget });
|
|
3127
3389
|
const prevailingKey = prevailingKeyOf(keys) ?? resolveKey(majorKey(0));
|
|
3128
3390
|
const keyAt = keyLookup(keys, prevailingKey);
|
|
3129
3391
|
const scaleAt = (beat) => scaleOf(keyAt(beat));
|
|
@@ -3179,12 +3441,27 @@ function analyzeTimeline(notes, opts = {}, previous, dirty) {
|
|
|
3179
3441
|
const spanKeyWhole = keyAt(start);
|
|
3180
3442
|
const id = windowId(start, end, spanKey);
|
|
3181
3443
|
const cached = carried !== void 0 && spanIsClean(grid, span, dirtyFrom, dirtyTo) ? carried.windows.get(id) : void 0;
|
|
3182
|
-
const inferred = cached !== void 0 ? cached : analyzeWindow(
|
|
3444
|
+
const inferred = cached !== void 0 ? cached : analyzeWindow(
|
|
3445
|
+
notesOfSpan(slotNotes, span, grid),
|
|
3446
|
+
start,
|
|
3447
|
+
end,
|
|
3448
|
+
meters,
|
|
3449
|
+
spanKeyWhole,
|
|
3450
|
+
harmonicRhythm
|
|
3451
|
+
);
|
|
3183
3452
|
windows.set(id, inferred);
|
|
3184
3453
|
readings.push(inferred);
|
|
3185
3454
|
readingKeys.push(spanKey);
|
|
3186
3455
|
readingBounds.push({ start, end });
|
|
3187
3456
|
}
|
|
3457
|
+
for (let i = readings.length - 1; i >= 0; i -= 1) {
|
|
3458
|
+
const spanKey = readingKeys[i];
|
|
3459
|
+
const root = spanKey === void 0 ? null : settledDiminishedRoot(readings, i, spanKey);
|
|
3460
|
+
const rooting = readings[i]?.rootings?.find((option) => option.chord.rootPc === root);
|
|
3461
|
+
if (rooting !== void 0 && root !== readings[i]?.chord.rootPc) {
|
|
3462
|
+
readings[i] = rooting;
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3188
3465
|
for (let i = 0; i < readings.length; i += 1) {
|
|
3189
3466
|
const offered = readings[i]?.augmented;
|
|
3190
3467
|
const spanKey = readingKeys[i];
|
|
@@ -3267,63 +3544,1970 @@ function detectCadences(timeline, key) {
|
|
|
3267
3544
|
return hits;
|
|
3268
3545
|
}
|
|
3269
3546
|
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3547
|
+
// src/analyze/melody/internal.ts
|
|
3548
|
+
var RHYTHM_GRID = 64;
|
|
3549
|
+
function phraseNotes(phrase) {
|
|
3550
|
+
if (Array.isArray(phrase)) {
|
|
3551
|
+
return phrase;
|
|
3552
|
+
}
|
|
3553
|
+
return assertRecord(phrase, "melodic phrase").notes;
|
|
3554
|
+
}
|
|
3555
|
+
function orderedNotes(notes, name, budget) {
|
|
3556
|
+
assertNoteEvents(notes, name, { allowNonPositiveDuration: true, budget });
|
|
3557
|
+
const sounding = notes.filter((note) => note.durationBeat > 0).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
|
|
3558
|
+
const line = [];
|
|
3559
|
+
for (const note of sounding) {
|
|
3560
|
+
const previous = line[line.length - 1];
|
|
3561
|
+
if (previous !== void 0 && Math.abs(note.startBeat - previous.startBeat) <= BEAT_EPS) {
|
|
3562
|
+
line[line.length - 1] = note;
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
line.push(note);
|
|
3566
|
+
}
|
|
3567
|
+
return line;
|
|
3568
|
+
}
|
|
3569
|
+
function intervalsOf(notes) {
|
|
3570
|
+
const out = [];
|
|
3571
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
3572
|
+
out.push((notes[i]?.pitch ?? 0) - (notes[i - 1]?.pitch ?? 0));
|
|
3573
|
+
}
|
|
3574
|
+
return out;
|
|
3575
|
+
}
|
|
3576
|
+
function onsetGaps(notes) {
|
|
3577
|
+
const out = [];
|
|
3578
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
3579
|
+
out.push((notes[i]?.startBeat ?? 0) - (notes[i - 1]?.startBeat ?? 0));
|
|
3580
|
+
}
|
|
3581
|
+
return out;
|
|
3582
|
+
}
|
|
3583
|
+
function retrogradeGaps(notes) {
|
|
3584
|
+
const out = [];
|
|
3585
|
+
for (let i = notes.length - 1; i >= 1; i -= 1) {
|
|
3586
|
+
const later = notes[i];
|
|
3587
|
+
const earlier = notes[i - 1];
|
|
3588
|
+
out.push(
|
|
3589
|
+
(later?.startBeat ?? 0) + (later?.durationBeat ?? 0) - ((earlier?.startBeat ?? 0) + (earlier?.durationBeat ?? 0))
|
|
3590
|
+
);
|
|
3591
|
+
}
|
|
3592
|
+
return out;
|
|
3593
|
+
}
|
|
3594
|
+
function rhythmProfile(gaps) {
|
|
3595
|
+
const unit = gaps[0] ?? 0;
|
|
3596
|
+
if (!(unit > BEAT_EPS)) {
|
|
3597
|
+
return gaps.length === 0 ? [] : null;
|
|
3598
|
+
}
|
|
3599
|
+
return gaps.map((gap) => Math.round(gap / unit * RHYTHM_GRID) / RHYTHM_GRID);
|
|
3600
|
+
}
|
|
3601
|
+
function sameNumbers(a, b) {
|
|
3602
|
+
if (a.length !== b.length) {
|
|
3603
|
+
return false;
|
|
3604
|
+
}
|
|
3605
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
3606
|
+
if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > BEAT_EPS) {
|
|
3607
|
+
return false;
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
return true;
|
|
3611
|
+
}
|
|
3612
|
+
function roundTo(value, digits) {
|
|
3613
|
+
const scale = 10 ** digits;
|
|
3614
|
+
return Math.round(value * scale) / scale;
|
|
3615
|
+
}
|
|
3616
|
+
function endBeatOf(notes) {
|
|
3617
|
+
return latestSoundingEnd(notes);
|
|
3618
|
+
}
|
|
3619
|
+
function onsetSpan(notes) {
|
|
3620
|
+
if (notes.length < 2) {
|
|
3621
|
+
return 0;
|
|
3622
|
+
}
|
|
3623
|
+
return (notes[notes.length - 1]?.startBeat ?? 0) - (notes[0]?.startBeat ?? 0);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// src/analyze/melody/contour.ts
|
|
3627
|
+
var DIRECTIONAL_SHARE = 0.75;
|
|
3628
|
+
var MELODIC_CONTOUR_SHAPES = Object.freeze([
|
|
3629
|
+
"arch",
|
|
3630
|
+
"ascending",
|
|
3631
|
+
"descending",
|
|
3632
|
+
"wave",
|
|
3633
|
+
"static"
|
|
3634
|
+
]);
|
|
3635
|
+
function extremes(notes) {
|
|
3636
|
+
if (notes.length === 0) {
|
|
3637
|
+
return { peak: -1, trough: -1 };
|
|
3638
|
+
}
|
|
3639
|
+
let peak = 0;
|
|
3640
|
+
let trough = 0;
|
|
3641
|
+
for (let i = 1; i < notes.length; i += 1) {
|
|
3642
|
+
if ((notes[i]?.pitch ?? 0) > (notes[peak]?.pitch ?? 0)) {
|
|
3643
|
+
peak = i;
|
|
3644
|
+
}
|
|
3645
|
+
if ((notes[i]?.pitch ?? 0) < (notes[trough]?.pitch ?? 0)) {
|
|
3646
|
+
trough = i;
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
return { peak, trough };
|
|
3650
|
+
}
|
|
3651
|
+
function melodicContour(notes) {
|
|
3652
|
+
const line = orderedNotes(phraseNotes(notes), "contour notes");
|
|
3653
|
+
const intervals = intervalsOf(line);
|
|
3654
|
+
const directions = intervals.map(
|
|
3655
|
+
(step) => step > 0 ? "up" : step < 0 ? "down" : "same"
|
|
3656
|
+
);
|
|
3657
|
+
const { peak, trough } = extremes(line);
|
|
3658
|
+
const range = (line[peak]?.pitch ?? 0) - (line[trough]?.pitch ?? 0);
|
|
3659
|
+
const motion = intervals.reduce((sum, step) => sum + Math.abs(step), 0);
|
|
3660
|
+
if (motion === 0) {
|
|
3661
|
+
return {
|
|
3662
|
+
directions,
|
|
3663
|
+
shape: "static",
|
|
3664
|
+
peakIndex: peak,
|
|
3665
|
+
troughIndex: trough,
|
|
3666
|
+
range: 0,
|
|
3667
|
+
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"
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
const first = line[0]?.pitch ?? 0;
|
|
3671
|
+
const last = line[line.length - 1]?.pitch ?? 0;
|
|
3672
|
+
const net = last - first;
|
|
3673
|
+
const directness = Math.abs(net) / motion;
|
|
3674
|
+
const share = Math.round(directness * 100);
|
|
3675
|
+
if (directness >= DIRECTIONAL_SHARE) {
|
|
3676
|
+
const shape = net > 0 ? "ascending" : "descending";
|
|
3677
|
+
return {
|
|
3678
|
+
directions,
|
|
3679
|
+
shape,
|
|
3680
|
+
peakIndex: peak,
|
|
3681
|
+
troughIndex: trough,
|
|
3682
|
+
range,
|
|
3683
|
+
rationale: `The net ${net > 0 ? "rise" : "fall"} is ${share}% of the motion, so the line is ${shape}`
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
const peakPitch = line[peak]?.pitch ?? 0;
|
|
3687
|
+
const archMotion = peakPitch - first + (peakPitch - last);
|
|
3688
|
+
if (peak > 0 && peak < line.length - 1 && archMotion >= DIRECTIONAL_SHARE * motion) {
|
|
3689
|
+
return {
|
|
3690
|
+
directions,
|
|
3691
|
+
shape: "arch",
|
|
3692
|
+
peakIndex: peak,
|
|
3693
|
+
troughIndex: trough,
|
|
3694
|
+
range,
|
|
3695
|
+
rationale: `Rises to its peak at note ${peak + 1} of ${line.length} and falls back, so the line is an arch`
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
return {
|
|
3699
|
+
directions,
|
|
3700
|
+
shape: "wave",
|
|
3701
|
+
peakIndex: peak,
|
|
3702
|
+
troughIndex: trough,
|
|
3703
|
+
range,
|
|
3704
|
+
rationale: `Changes direction without settling either way (the net movement is ${share}% of the motion), so the line is a wave`
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
// src/analyze/melody/motifs.ts
|
|
3709
|
+
var MIN_CELL_NOTES = 3;
|
|
3710
|
+
var MAX_CELL_NOTES = 8;
|
|
3711
|
+
var MIN_OCCURRENCES = 2;
|
|
3712
|
+
function windowSignature(notes) {
|
|
3713
|
+
const profile = rhythmProfile(onsetGaps(notes));
|
|
3714
|
+
if (profile === null) {
|
|
3715
|
+
return null;
|
|
3716
|
+
}
|
|
3717
|
+
return `${notes.length}|${intervalsOf(notes).join(",")}|${profile.join(",")}`;
|
|
3718
|
+
}
|
|
3719
|
+
function nonOverlapping(starts, length) {
|
|
3720
|
+
const picked = [];
|
|
3721
|
+
let lastEnd = Number.NEGATIVE_INFINITY;
|
|
3722
|
+
for (const start of starts) {
|
|
3723
|
+
if (start > lastEnd) {
|
|
3724
|
+
picked.push(start);
|
|
3725
|
+
lastEnd = start + length - 1;
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
return picked;
|
|
3729
|
+
}
|
|
3730
|
+
function subsumes(outer, inner) {
|
|
3731
|
+
let at = 0;
|
|
3732
|
+
for (const start of inner.starts) {
|
|
3733
|
+
while (at + 1 < outer.starts.length && (outer.starts[at + 1] ?? 0) <= start) {
|
|
3734
|
+
at += 1;
|
|
3735
|
+
}
|
|
3736
|
+
const from = outer.starts[at];
|
|
3737
|
+
if (from === void 0 || start < from || start + inner.length > from + outer.length) {
|
|
3738
|
+
return false;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
return true;
|
|
3742
|
+
}
|
|
3743
|
+
function listBeats(occurrences) {
|
|
3744
|
+
const shown = occurrences.slice(0, 5).map((occurrence) => roundTo(occurrence.startBeat, 3));
|
|
3745
|
+
return occurrences.length > shown.length ? `${shown.join(", ")}, and ${occurrences.length - shown.length} more` : shown.join(", ");
|
|
3746
|
+
}
|
|
3747
|
+
function motifFromGroup(group, notes) {
|
|
3748
|
+
const first = group.starts[0] ?? 0;
|
|
3749
|
+
const prime = notes.slice(first, first + group.length).map((note) => ({ ...note }));
|
|
3750
|
+
const primePitch = prime[0]?.pitch ?? 0;
|
|
3751
|
+
const primeSpan = onsetSpan(prime);
|
|
3752
|
+
const occurrences = group.starts.map((start) => {
|
|
3753
|
+
const statement = notes.slice(start, start + group.length);
|
|
3754
|
+
const span = onsetSpan(statement);
|
|
3755
|
+
return {
|
|
3756
|
+
noteIndex: start,
|
|
3757
|
+
startBeat: statement[0]?.startBeat ?? 0,
|
|
3758
|
+
endBeat: endBeatOf(statement),
|
|
3759
|
+
transpose: (statement[0]?.pitch ?? 0) - primePitch,
|
|
3760
|
+
timeRatio: primeSpan > BEAT_EPS ? span / primeSpan : 1
|
|
3761
|
+
};
|
|
3762
|
+
});
|
|
3763
|
+
const shape = melodicContour(prime).shape;
|
|
3764
|
+
const transposed = occurrences.some((occurrence) => occurrence.transpose !== 0);
|
|
3765
|
+
const stretched = occurrences.some((occurrence) => Math.abs(occurrence.timeRatio - 1) > BEAT_EPS);
|
|
3766
|
+
const detail = [
|
|
3767
|
+
transposed ? "at more than one pitch level" : "at the same pitch level",
|
|
3768
|
+
stretched ? "and in more than one set of note values" : ""
|
|
3769
|
+
].filter((part) => part !== "").join(" ");
|
|
3770
|
+
return {
|
|
3771
|
+
notes: prime,
|
|
3772
|
+
intervals: intervalsOf(prime),
|
|
3773
|
+
rhythm: rhythmProfile(onsetGaps(prime)) ?? [],
|
|
3774
|
+
occurrences,
|
|
3775
|
+
rationale: `${group.length}-note ${shape} cell stated ${occurrences.length} ${occurrences.length === 1 ? "time" : "times"} (beats ${listBeats(occurrences)}), ${detail}`
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
function extractMotifs(notes, opts = {}) {
|
|
3779
|
+
const asked = assertOptions(opts, "opts");
|
|
3780
|
+
const minNotes = assertInteger(asked.minNotes ?? MIN_CELL_NOTES, "motif minNotes", 2, 64);
|
|
3781
|
+
const maxNotes = assertInteger(asked.maxNotes ?? MAX_CELL_NOTES, "motif maxNotes", 2, 64);
|
|
3782
|
+
if (maxNotes < minNotes) {
|
|
3783
|
+
throw new InvalidInputError(
|
|
3784
|
+
`motif maxNotes must be at least minNotes ${minNotes}; received ${maxNotes}`
|
|
3785
|
+
);
|
|
3786
|
+
}
|
|
3787
|
+
const minOccurrences = assertInteger(
|
|
3788
|
+
asked.minOccurrences ?? MIN_OCCURRENCES,
|
|
3789
|
+
"motif minOccurrences",
|
|
3790
|
+
2
|
|
3791
|
+
);
|
|
3792
|
+
const sounding = orderedNotes(notes, "melody notes", asked.budget);
|
|
3793
|
+
if (sounding.length < minNotes) {
|
|
3794
|
+
return [];
|
|
3795
|
+
}
|
|
3796
|
+
assertGenerationBudget(
|
|
3797
|
+
sounding.length * (maxNotes - minNotes + 1),
|
|
3798
|
+
"motif windows",
|
|
3799
|
+
asked.budget
|
|
3800
|
+
);
|
|
3801
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3802
|
+
for (let length = minNotes; length <= maxNotes; length += 1) {
|
|
3803
|
+
for (let start = 0; start + length <= sounding.length; start += 1) {
|
|
3804
|
+
const signature = windowSignature(sounding.slice(start, start + length));
|
|
3805
|
+
if (signature === null) {
|
|
3806
|
+
continue;
|
|
3807
|
+
}
|
|
3808
|
+
const group = groups.get(signature);
|
|
3809
|
+
if (group === void 0) {
|
|
3810
|
+
groups.set(signature, { signature, length, starts: [start] });
|
|
3811
|
+
} else {
|
|
3812
|
+
group.starts.push(start);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
const candidates = [...groups.values()].map((group) => ({ ...group, starts: nonOverlapping(group.starts, group.length) })).filter((group) => group.starts.length >= minOccurrences).sort(
|
|
3817
|
+
(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)
|
|
3818
|
+
);
|
|
3819
|
+
const keptByLength = /* @__PURE__ */ new Map();
|
|
3820
|
+
const kept = [];
|
|
3821
|
+
let comparisons = 0;
|
|
3822
|
+
for (const candidate of candidates) {
|
|
3823
|
+
const first = candidate.starts[0] ?? 0;
|
|
3824
|
+
let covered = false;
|
|
3825
|
+
for (let length = candidate.length + 1; length <= maxNotes && !covered; length += 1) {
|
|
3826
|
+
const byStart = keptByLength.get(length);
|
|
3827
|
+
if (byStart === void 0) {
|
|
3828
|
+
continue;
|
|
3829
|
+
}
|
|
3830
|
+
for (let from = Math.max(0, first - (length - candidate.length)); from <= first; from += 1) {
|
|
3831
|
+
comparisons += 1;
|
|
3832
|
+
const outer = byStart.get(from);
|
|
3833
|
+
if (outer === void 0 || outer.starts.length < candidate.starts.length) {
|
|
3834
|
+
continue;
|
|
3835
|
+
}
|
|
3836
|
+
comparisons += outer.starts.length + candidate.starts.length;
|
|
3837
|
+
if (subsumes(outer, candidate)) {
|
|
3838
|
+
covered = true;
|
|
3839
|
+
break;
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
assertGenerationBudget(comparisons, "motif subsumption comparisons", asked.budget);
|
|
3844
|
+
if (!covered) {
|
|
3845
|
+
kept.push(candidate);
|
|
3846
|
+
let byStart = keptByLength.get(candidate.length);
|
|
3847
|
+
if (byStart === void 0) {
|
|
3848
|
+
byStart = /* @__PURE__ */ new Map();
|
|
3849
|
+
keptByLength.set(candidate.length, byStart);
|
|
3850
|
+
}
|
|
3851
|
+
for (const start of candidate.starts) {
|
|
3852
|
+
byStart.set(start, candidate);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
return kept.map((group) => motifFromGroup(group, sounding));
|
|
3857
|
+
}
|
|
3858
|
+
function motifFromNotes(notes) {
|
|
3859
|
+
const sounding = orderedNotes(notes, "motif notes").map((note) => ({ ...note }));
|
|
3860
|
+
return motifFromGroup(
|
|
3861
|
+
{ signature: windowSignature(sounding) ?? "", length: sounding.length, starts: [0] },
|
|
3862
|
+
sounding
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
// src/analyze/melody/relation.ts
|
|
3867
|
+
var MOTIF_RELATION_KINDS = Object.freeze([
|
|
3868
|
+
"repetition",
|
|
3869
|
+
"transposition",
|
|
3870
|
+
"tonalTransposition",
|
|
3871
|
+
"inversion",
|
|
3872
|
+
"retrograde",
|
|
3873
|
+
"retrogradeInversion",
|
|
3874
|
+
"augmentation",
|
|
3875
|
+
"diminution"
|
|
3876
|
+
]);
|
|
3877
|
+
function spellingOf(key) {
|
|
3878
|
+
if (key === void 0) {
|
|
3879
|
+
return "sharp";
|
|
3880
|
+
}
|
|
3881
|
+
return keySignatureFifths(key.tonic, key.scale) < 0 ? "flat" : "sharp";
|
|
3882
|
+
}
|
|
3883
|
+
function intervalBetween(from, to, key) {
|
|
3884
|
+
const start = midiToNote(from, spellingOf(key));
|
|
3885
|
+
return spelledInterval(start, transposeNote(start, to - from));
|
|
3886
|
+
}
|
|
3887
|
+
function intervalName(interval) {
|
|
3888
|
+
return `${interval.descending && interval.semitones !== 0 ? "-" : ""}${interval.quality}${interval.number}`;
|
|
3889
|
+
}
|
|
3890
|
+
function ladderIndices(notes, key) {
|
|
3891
|
+
const root = pitchClassOf(key.rootPc);
|
|
3892
|
+
const offsets = scaleTonesInDegreeOrder(key).map((pc) => pitchClassOf(pc - root));
|
|
3893
|
+
if (offsets.length === 0) {
|
|
3894
|
+
return null;
|
|
3895
|
+
}
|
|
3896
|
+
const out = [];
|
|
3897
|
+
for (const note of notes) {
|
|
3898
|
+
const semitones = note.pitch - root;
|
|
3899
|
+
const octave = Math.floor(semitones / 12);
|
|
3900
|
+
const degree = offsets.indexOf(semitones - octave * 12);
|
|
3901
|
+
if (degree < 0) {
|
|
3902
|
+
return null;
|
|
3903
|
+
}
|
|
3904
|
+
out.push(octave * offsets.length + degree);
|
|
3905
|
+
}
|
|
3906
|
+
return out;
|
|
3907
|
+
}
|
|
3908
|
+
function ladderSteps(indices) {
|
|
3909
|
+
const out = [];
|
|
3910
|
+
for (let i = 1; i < indices.length; i += 1) {
|
|
3911
|
+
out.push((indices[i] ?? 0) - (indices[i - 1] ?? 0));
|
|
3912
|
+
}
|
|
3913
|
+
return out;
|
|
3914
|
+
}
|
|
3915
|
+
function pitchShapes(a, b) {
|
|
3916
|
+
const shapes = [];
|
|
3917
|
+
if (sameNumbers(a, b)) {
|
|
3918
|
+
shapes.push("exact");
|
|
3919
|
+
}
|
|
3920
|
+
if (sameNumbers(
|
|
3921
|
+
b,
|
|
3922
|
+
a.map((step) => -step)
|
|
3923
|
+
)) {
|
|
3924
|
+
shapes.push("inversion");
|
|
3925
|
+
}
|
|
3926
|
+
const reversed = [...a].reverse();
|
|
3927
|
+
if (sameNumbers(
|
|
3928
|
+
b,
|
|
3929
|
+
reversed.map((step) => -step)
|
|
3930
|
+
)) {
|
|
3931
|
+
shapes.push("retrograde");
|
|
3932
|
+
}
|
|
3933
|
+
if (sameNumbers(b, reversed)) {
|
|
3934
|
+
shapes.push("retrogradeInversion");
|
|
3935
|
+
}
|
|
3936
|
+
return shapes;
|
|
3937
|
+
}
|
|
3938
|
+
function totalGap(gaps) {
|
|
3939
|
+
return gaps.reduce((sum, gap) => sum + gap, 0);
|
|
3940
|
+
}
|
|
3941
|
+
function stretchRatio(modelGaps, answerGaps) {
|
|
3942
|
+
const span = totalGap(modelGaps);
|
|
3943
|
+
return span > BEAT_EPS ? totalGap(answerGaps) / span : 1;
|
|
3944
|
+
}
|
|
3945
|
+
function stretchPhrase(ratio2) {
|
|
3946
|
+
return `note values ${roundTo(ratio2, 3)}x the model's`;
|
|
3947
|
+
}
|
|
3948
|
+
function relateMotifs(a, b, keyLike) {
|
|
3949
|
+
const key = keyLike === void 0 ? void 0 : resolveKey(keyLike);
|
|
3950
|
+
const model = assertArray(assertRecord(a, "a").notes, "a.notes");
|
|
3951
|
+
const answer = assertArray(assertRecord(b, "b").notes, "b.notes");
|
|
3952
|
+
if (model.length === 0 || model.length !== answer.length) {
|
|
3953
|
+
return null;
|
|
3954
|
+
}
|
|
3955
|
+
const modelGaps = onsetGaps(model);
|
|
3956
|
+
const answerGaps = onsetGaps(answer);
|
|
3957
|
+
const modelProfile = rhythmProfile(modelGaps);
|
|
3958
|
+
const answerProfile = rhythmProfile(answerGaps);
|
|
3959
|
+
const retroGaps = retrogradeGaps(model);
|
|
3960
|
+
const retroProfile = rhythmProfile(retroGaps);
|
|
3961
|
+
if (modelProfile === null || answerProfile === null) {
|
|
3962
|
+
return null;
|
|
3963
|
+
}
|
|
3964
|
+
const forward = sameNumbers(modelProfile, answerProfile);
|
|
3965
|
+
const backward = retroProfile !== null && sameNumbers(retroProfile, answerProfile);
|
|
3966
|
+
const semitones = (answer[0]?.pitch ?? 0) - (model[0]?.pitch ?? 0);
|
|
3967
|
+
const interval = intervalBetween(model[0]?.pitch ?? 0, answer[0]?.pitch ?? 0, key);
|
|
3968
|
+
const gap = (answer[0]?.startBeat ?? 0) - endBeatOf(model);
|
|
3969
|
+
const sequence = gap >= -HUMANIZE_ADJACENCY && gap <= HUMANIZE_ADJACENCY;
|
|
3970
|
+
const timeRatio = stretchRatio(modelGaps, answerGaps);
|
|
3971
|
+
const stretched = Math.abs(timeRatio - 1) > BEAT_EPS;
|
|
3972
|
+
const shapes = pitchShapes(intervalsOf(model), intervalsOf(answer));
|
|
3973
|
+
const base = { sequence, semitones, interval, timeRatio };
|
|
3974
|
+
const backwardBase = { ...base, timeRatio: stretchRatio(retroGaps, answerGaps) };
|
|
3975
|
+
if (forward && shapes.includes("exact")) {
|
|
3976
|
+
if (semitones === 0) {
|
|
3977
|
+
if (!stretched) {
|
|
3978
|
+
return {
|
|
3979
|
+
...base,
|
|
3980
|
+
kind: "repetition",
|
|
3981
|
+
rationale: "Exact repetition: the same pitches in the same rhythm"
|
|
3982
|
+
};
|
|
3983
|
+
}
|
|
3984
|
+
const augmented = timeRatio > 1;
|
|
3985
|
+
return {
|
|
3986
|
+
...base,
|
|
3987
|
+
kind: augmented ? "augmentation" : "diminution",
|
|
3988
|
+
rationale: `${augmented ? "Augmentation" : "Diminution"}: the same pitches in ${stretchPhrase(timeRatio)}`
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
const name = intervalName(interval);
|
|
3992
|
+
return {
|
|
3993
|
+
...base,
|
|
3994
|
+
kind: "transposition",
|
|
3995
|
+
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)}` : "")
|
|
3996
|
+
};
|
|
3997
|
+
}
|
|
3998
|
+
if (forward && key !== void 0) {
|
|
3999
|
+
const modelLadder = ladderIndices(model, key.scale);
|
|
4000
|
+
const answerLadder = ladderIndices(answer, key.scale);
|
|
4001
|
+
if (modelLadder !== null && answerLadder !== null) {
|
|
4002
|
+
const degrees = (answerLadder[0] ?? 0) - (modelLadder[0] ?? 0);
|
|
4003
|
+
if (degrees !== 0 && sameNumbers(ladderSteps(modelLadder), ladderSteps(answerLadder))) {
|
|
4004
|
+
const direction = degrees > 0 ? "higher" : "lower";
|
|
4005
|
+
const count = Math.abs(degrees);
|
|
4006
|
+
return {
|
|
4007
|
+
...base,
|
|
4008
|
+
kind: "tonalTransposition",
|
|
4009
|
+
degrees,
|
|
4010
|
+
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`
|
|
4011
|
+
};
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
const literalRetrograde = backward && shapes.includes("retrograde") && answer[0]?.pitch === model[model.length - 1]?.pitch;
|
|
4016
|
+
if (forward && shapes.includes("inversion") && !(literalRetrograde && semitones !== 0)) {
|
|
4017
|
+
return {
|
|
4018
|
+
...base,
|
|
4019
|
+
kind: "inversion",
|
|
4020
|
+
rationale: `Inversion: every interval turned upside down${semitones === 0 ? " about the first note" : ""}` + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
|
|
4021
|
+
};
|
|
4022
|
+
}
|
|
4023
|
+
if (backward && shapes.includes("retrograde")) {
|
|
4024
|
+
return {
|
|
4025
|
+
...backwardBase,
|
|
4026
|
+
kind: "retrograde",
|
|
4027
|
+
rationale: "Retrograde: the cell read back to front, rhythm included"
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
if (backward && shapes.includes("retrogradeInversion")) {
|
|
4031
|
+
return {
|
|
4032
|
+
...backwardBase,
|
|
4033
|
+
kind: "retrogradeInversion",
|
|
4034
|
+
rationale: "Retrograde inversion: the cell read back to front and turned upside down"
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
return null;
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
// src/analyze/sequence.ts
|
|
4041
|
+
function gradedEditDistance(a, b, cost, gap = 1) {
|
|
4042
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i * gap);
|
|
4043
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
4044
|
+
const row = new Array(b.length + 1);
|
|
4045
|
+
row[0] = i * gap;
|
|
4046
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
4047
|
+
const ai = a[i - 1];
|
|
4048
|
+
const bj = b[j - 1];
|
|
4049
|
+
const substitution = (previous[j - 1] ?? 0) + cost(ai, bj);
|
|
4050
|
+
const deletion = (previous[j] ?? 0) + gap;
|
|
4051
|
+
const insertion = (row[j - 1] ?? 0) + gap;
|
|
4052
|
+
row[j] = Math.min(substitution, deletion, insertion);
|
|
4053
|
+
}
|
|
4054
|
+
previous = row;
|
|
4055
|
+
}
|
|
4056
|
+
return previous[b.length] ?? 0;
|
|
4057
|
+
}
|
|
4058
|
+
function distanceToSimilarity(distance, a, b) {
|
|
4059
|
+
const longest = Math.max(a, b);
|
|
4060
|
+
if (longest === 0) {
|
|
4061
|
+
return 1;
|
|
4062
|
+
}
|
|
4063
|
+
return Math.min(1, Math.max(0, 1 - distance / longest));
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// src/analyze/melody/similarity.ts
|
|
4067
|
+
var INTERVAL_TOLERANCE = 6;
|
|
4068
|
+
var PITCH_WEIGHT = 0.75;
|
|
4069
|
+
var RHYTHM_WEIGHT = 0.25;
|
|
4070
|
+
function intervalCost(x, y) {
|
|
4071
|
+
return Math.min(1, Math.abs(x - y) / INTERVAL_TOLERANCE);
|
|
4072
|
+
}
|
|
4073
|
+
function gapCost(x, y) {
|
|
4074
|
+
if (!(x > BEAT_EPS) || !(y > BEAT_EPS)) {
|
|
4075
|
+
return Math.abs(x - y) > BEAT_EPS ? 1 : 0;
|
|
4076
|
+
}
|
|
4077
|
+
return Math.min(1, Math.abs(Math.log2(x / y)));
|
|
4078
|
+
}
|
|
4079
|
+
function compareMelodies(a, b) {
|
|
4080
|
+
const left = orderedNotes(phraseNotes(a), "melody a");
|
|
4081
|
+
const right = orderedNotes(phraseNotes(b), "melody b");
|
|
4082
|
+
const leftIntervals = intervalsOf(left);
|
|
4083
|
+
const rightIntervals = intervalsOf(right);
|
|
4084
|
+
assertGenerationBudget(leftIntervals.length * rightIntervals.length, "melody alignment");
|
|
4085
|
+
const pitchSimilarity = distanceToSimilarity(
|
|
4086
|
+
gradedEditDistance(leftIntervals, rightIntervals, intervalCost),
|
|
4087
|
+
leftIntervals.length,
|
|
4088
|
+
rightIntervals.length
|
|
4089
|
+
);
|
|
4090
|
+
const leftGaps = rhythmProfile(onsetGaps(left)) ?? onsetGaps(left);
|
|
4091
|
+
const rightGaps = rhythmProfile(onsetGaps(right)) ?? onsetGaps(right);
|
|
4092
|
+
const rhythmSimilarity = distanceToSimilarity(
|
|
4093
|
+
gradedEditDistance(leftGaps, rightGaps, gapCost),
|
|
4094
|
+
leftGaps.length,
|
|
4095
|
+
rightGaps.length
|
|
4096
|
+
);
|
|
4097
|
+
const similarity = PITCH_WEIGHT * pitchSimilarity + RHYTHM_WEIGHT * rhythmSimilarity;
|
|
4098
|
+
return {
|
|
4099
|
+
similarity,
|
|
4100
|
+
pitchSimilarity,
|
|
4101
|
+
rhythmSimilarity,
|
|
4102
|
+
rationale: `Interval shapes ${Math.round(pitchSimilarity * 100)}% alike, rhythms ${Math.round(rhythmSimilarity * 100)}% alike, weighted ${PITCH_WEIGHT}/${RHYTHM_WEIGHT} into ${roundTo(similarity, 3)}`
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
function melodicSimilarity(a, b) {
|
|
4106
|
+
return compareMelodies(a, b).similarity;
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// src/analyze/melody/graph.ts
|
|
4110
|
+
var DEFAULT_VARIATION_THRESHOLD = 0.75;
|
|
4111
|
+
function summarize(relation) {
|
|
4112
|
+
const { kind, sequence, semitones, degrees, timeRatio } = relation;
|
|
4113
|
+
return { kind, sequence, semitones, degrees, timeRatio };
|
|
4114
|
+
}
|
|
4115
|
+
function betterParent(candidate, current) {
|
|
4116
|
+
const candidateNamed = candidate.relation !== null;
|
|
4117
|
+
const currentNamed = current.relation !== null;
|
|
4118
|
+
if (candidateNamed !== currentNamed) {
|
|
4119
|
+
return candidateNamed;
|
|
4120
|
+
}
|
|
4121
|
+
if (candidate.similarity !== current.similarity) {
|
|
4122
|
+
return candidate.similarity > current.similarity;
|
|
4123
|
+
}
|
|
4124
|
+
if (candidate.startBeat !== current.startBeat) {
|
|
4125
|
+
return candidate.startBeat > current.startBeat;
|
|
4126
|
+
}
|
|
4127
|
+
return candidate.index < current.index;
|
|
4128
|
+
}
|
|
4129
|
+
function motifGraph(line, motifs, opts = {}) {
|
|
4130
|
+
const asked = assertOptions(opts, "opts");
|
|
4131
|
+
const variationThreshold = asked.variationThreshold ?? DEFAULT_VARIATION_THRESHOLD;
|
|
4132
|
+
assertRange(variationThreshold, 0, 1, "motif graph variationThreshold");
|
|
4133
|
+
const ordered = orderedNotes(line, "line", asked.budget);
|
|
4134
|
+
const motifList = assertArray(motifs, "motifs");
|
|
4135
|
+
const infos = [];
|
|
4136
|
+
for (let mi = 0; mi < motifList.length; mi += 1) {
|
|
4137
|
+
const motif = assertRecord(motifList[mi], `motifs[${mi}]`);
|
|
4138
|
+
const notes = assertArray(motif.notes, `motifs[${mi}].notes`);
|
|
4139
|
+
const occurrences = assertArray(
|
|
4140
|
+
motif.occurrences,
|
|
4141
|
+
`motifs[${mi}].occurrences`
|
|
4142
|
+
);
|
|
4143
|
+
for (let oi = 0; oi < occurrences.length; oi += 1) {
|
|
4144
|
+
const occurrence = assertRecord(
|
|
4145
|
+
occurrences[oi],
|
|
4146
|
+
`motifs[${mi}].occurrences[${oi}]`
|
|
4147
|
+
);
|
|
4148
|
+
const { noteIndex } = occurrence;
|
|
4149
|
+
const onLine = Number.isInteger(noteIndex) && noteIndex >= 0 && noteIndex + notes.length <= ordered.length && Math.abs((ordered[noteIndex]?.startBeat ?? Number.NaN) - occurrence.startBeat) <= BEAT_EPS;
|
|
4150
|
+
if (!onLine) {
|
|
4151
|
+
throw new InvalidInputError(
|
|
4152
|
+
`motif occurrence motifs[${mi}].occurrences[${oi}] does not lie on the line`
|
|
4153
|
+
);
|
|
4154
|
+
}
|
|
4155
|
+
infos.push({
|
|
4156
|
+
node: {
|
|
4157
|
+
motif: mi,
|
|
4158
|
+
occurrence: oi,
|
|
4159
|
+
startBeat: occurrence.startBeat,
|
|
4160
|
+
endBeat: occurrence.endBeat
|
|
4161
|
+
},
|
|
4162
|
+
data: motifFromNotes(ordered.slice(noteIndex, noteIndex + notes.length))
|
|
4163
|
+
});
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
infos.sort((a, b) => a.node.startBeat - b.node.startBeat || a.node.motif - b.node.motif);
|
|
4167
|
+
const nodeCount = infos.length;
|
|
4168
|
+
assertGenerationBudget(nodeCount * nodeCount, "motif graph comparisons", asked.budget);
|
|
4169
|
+
const edges = [];
|
|
4170
|
+
for (let to = 1; to < nodeCount; to += 1) {
|
|
4171
|
+
const toInfo = infos[to];
|
|
4172
|
+
let best = null;
|
|
4173
|
+
for (let from = 0; from < to; from += 1) {
|
|
4174
|
+
const fromInfo = infos[from];
|
|
4175
|
+
const candidate = {
|
|
4176
|
+
index: from,
|
|
4177
|
+
startBeat: fromInfo.node.startBeat,
|
|
4178
|
+
relation: relateMotifs(fromInfo.data, toInfo.data, asked.key),
|
|
4179
|
+
similarity: melodicSimilarity(fromInfo.data, toInfo.data)
|
|
4180
|
+
};
|
|
4181
|
+
if (best === null || betterParent(candidate, best)) {
|
|
4182
|
+
best = candidate;
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
if (best !== null && (best.relation !== null || best.similarity >= variationThreshold)) {
|
|
4186
|
+
edges.push({
|
|
4187
|
+
from: best.index,
|
|
4188
|
+
to,
|
|
4189
|
+
relation: best.relation === null ? null : summarize(best.relation),
|
|
4190
|
+
similarity: best.similarity
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
return { nodes: infos.map((info) => info.node), edges };
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
// src/analyze/reduction/index.ts
|
|
4198
|
+
var REDUCTION_LEVELS = Object.freeze(["structural", "passing", "neighbor"]);
|
|
4199
|
+
var TONIC_ARRIVING_CADENCES = [
|
|
4200
|
+
"authentic",
|
|
4201
|
+
"plagal",
|
|
4202
|
+
"modal",
|
|
4203
|
+
"deceptive"
|
|
4204
|
+
];
|
|
4205
|
+
var TONIC_ARRIVING = new Set(TONIC_ARRIVING_CADENCES);
|
|
4206
|
+
function signedStep(fromPc, toPc) {
|
|
4207
|
+
return (pitchClassOf(toPc) - pitchClassOf(fromPc) + 18) % 12 - 6;
|
|
4208
|
+
}
|
|
4209
|
+
function isStep2(distance) {
|
|
4210
|
+
const size = Math.abs(distance);
|
|
4211
|
+
return size === 1 || size === 2;
|
|
4212
|
+
}
|
|
4213
|
+
function touches(before, after) {
|
|
4214
|
+
return Math.abs(after.startBeat - before.endBeat) <= BEAT_EPS;
|
|
4215
|
+
}
|
|
4216
|
+
function salienceFloors(durations) {
|
|
4217
|
+
const order = durations.map((_, index) => index).sort((a, b) => {
|
|
4218
|
+
const left = durations[a] ?? 0;
|
|
4219
|
+
const right = durations[b] ?? 0;
|
|
4220
|
+
return left - right || a - b;
|
|
4221
|
+
});
|
|
4222
|
+
const rankOf = new Array(durations.length).fill(0);
|
|
4223
|
+
for (let rank = 0; rank < order.length; rank += 1) {
|
|
4224
|
+
rankOf[order[rank] ?? 0] = rank;
|
|
4225
|
+
}
|
|
4226
|
+
const floors = [];
|
|
4227
|
+
const remaining = durations.length - 1;
|
|
4228
|
+
for (let index = 0; index < durations.length; index += 1) {
|
|
4229
|
+
if (remaining <= 0) {
|
|
4230
|
+
floors.push(0);
|
|
4231
|
+
continue;
|
|
4232
|
+
}
|
|
4233
|
+
const skipped = rankOf[index] ?? 0;
|
|
4234
|
+
const at = (position) => durations[order[position < skipped ? position : position + 1] ?? 0] ?? 0;
|
|
4235
|
+
const middle = remaining >> 1;
|
|
4236
|
+
floors.push(remaining % 2 === 1 ? at(middle) : (at(middle - 1) + at(middle)) / 2);
|
|
4237
|
+
}
|
|
4238
|
+
return floors;
|
|
4239
|
+
}
|
|
4240
|
+
function isTonicChord(chord, key) {
|
|
4241
|
+
return pitchClassOf(chord.rootPc - key.rootPc) === 0 && !isAppliedDominant(chord, key);
|
|
4242
|
+
}
|
|
4243
|
+
function functionalReason(segment, before, after, keyAt) {
|
|
4244
|
+
const key = keyAt(segment.startBeat);
|
|
4245
|
+
if (after !== void 0 && isCadentialSixFour(segment.chord, after.chord, key)) {
|
|
4246
|
+
return { kind: "cadentialSixFour" };
|
|
4247
|
+
}
|
|
4248
|
+
if (isTonicChord(segment.chord, key)) {
|
|
4249
|
+
return { kind: "tonic" };
|
|
4250
|
+
}
|
|
4251
|
+
if (isDominantChordOf(segment.chord, key)) {
|
|
4252
|
+
return { kind: "dominant" };
|
|
4253
|
+
}
|
|
4254
|
+
if (before !== void 0) {
|
|
4255
|
+
const arrival = detectCadence(before.chord, segment.chord, key).type;
|
|
4256
|
+
if (arrival !== null) {
|
|
4257
|
+
return { kind: "cadenceArrival", cadence: arrival };
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
if (after !== void 0) {
|
|
4261
|
+
const agent = detectCadence(segment.chord, after.chord, keyAt(after.startBeat)).type;
|
|
4262
|
+
if (agent !== null && TONIC_ARRIVING.has(agent)) {
|
|
4263
|
+
return { kind: "cadenceAgent", cadence: agent };
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
return null;
|
|
4267
|
+
}
|
|
4268
|
+
function figureReason(segment, before, after) {
|
|
4269
|
+
if (before === void 0 || after === void 0) {
|
|
4270
|
+
return null;
|
|
4271
|
+
}
|
|
4272
|
+
const incoming = signedStep(before.chord.rootPc, segment.chord.rootPc);
|
|
4273
|
+
const outgoing = signedStep(segment.chord.rootPc, after.chord.rootPc);
|
|
4274
|
+
if (!isStep2(incoming) || !isStep2(outgoing)) {
|
|
4275
|
+
return null;
|
|
4276
|
+
}
|
|
4277
|
+
if (incoming > 0 === outgoing > 0) {
|
|
4278
|
+
return { kind: "passing" };
|
|
4279
|
+
}
|
|
4280
|
+
return pitchClassOf(before.chord.rootPc) === pitchClassOf(after.chord.rootPc) ? { kind: "neighbor" } : null;
|
|
4281
|
+
}
|
|
4282
|
+
function describe2(reason) {
|
|
4283
|
+
switch (reason.kind) {
|
|
4284
|
+
case "tonic":
|
|
4285
|
+
return "Structural: the tonic of the key, which the progression is heard against";
|
|
4286
|
+
case "dominant":
|
|
4287
|
+
return "Structural: the dominant of the key, the chord its tonic is approached from";
|
|
4288
|
+
case "cadentialSixFour":
|
|
4289
|
+
return "Structural: a tonic six-four on the bass of the dominant that follows, which prolongs that dominant rather than framing the tonic";
|
|
4290
|
+
case "cadenceArrival":
|
|
4291
|
+
return `Structural: the arrival of a ${reason.cadence} cadence`;
|
|
4292
|
+
case "cadenceAgent":
|
|
4293
|
+
return `Structural: the chord that drives a ${reason.cadence} cadence`;
|
|
4294
|
+
case "salient":
|
|
4295
|
+
return "Structural: it holds the harmony longer than the chords around it";
|
|
4296
|
+
case "first":
|
|
4297
|
+
return "Structural: the first chord of the progression frames it";
|
|
4298
|
+
case "last":
|
|
4299
|
+
return "Structural: the last chord of the progression frames it";
|
|
4300
|
+
case "unfigured":
|
|
4301
|
+
return "Structural: its root neither steps through to the next chord nor returns to the last, so it embellishes nothing";
|
|
4302
|
+
case "passing":
|
|
4303
|
+
return "Passing: its root steps in from the previous chord and on to the next in the same direction";
|
|
4304
|
+
case "neighbor":
|
|
4305
|
+
return "Neighbor: its root steps away from the surrounding harmony and returns to it";
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
var BASIS_READINGS = {
|
|
4309
|
+
function: ({ segment, before, after, keyAt }) => functionalReason(segment, before, after, keyAt),
|
|
4310
|
+
duration: ({ segment, salienceFloor }) => segment.endBeat - segment.startBeat > salienceFloor ? { kind: "salient" } : null
|
|
4311
|
+
};
|
|
4312
|
+
var REDUCTION_BASES = Object.keys(BASIS_READINGS);
|
|
4313
|
+
function levelOf(reason) {
|
|
4314
|
+
if (reason.kind === "passing") {
|
|
4315
|
+
return "passing";
|
|
4316
|
+
}
|
|
4317
|
+
return reason.kind === "neighbor" ? "neighbor" : "structural";
|
|
4318
|
+
}
|
|
4319
|
+
function reduceProgression(timeline, key, opts = {}) {
|
|
4320
|
+
assertChordTimeline(timeline);
|
|
4321
|
+
const asked = assertOptions(opts, "opts");
|
|
4322
|
+
const keyAt = keyScaleAt(key);
|
|
4323
|
+
const basis = assertOneOf(asked.basis ?? "function", REDUCTION_BASES, "reduction basis");
|
|
4324
|
+
const reading = BASIS_READINGS[basis];
|
|
4325
|
+
const segments = timeline.segments;
|
|
4326
|
+
const floors = basis === "duration" ? salienceFloors(segments.map((segment) => segment.endBeat - segment.startBeat)) : [];
|
|
4327
|
+
const result = [];
|
|
4328
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
4329
|
+
const segment = segments[index];
|
|
4330
|
+
if (segment === void 0) {
|
|
4331
|
+
continue;
|
|
4332
|
+
}
|
|
4333
|
+
const previous = segments[index - 1];
|
|
4334
|
+
const following = segments[index + 1];
|
|
4335
|
+
const before = previous !== void 0 && touches(previous, segment) ? previous : void 0;
|
|
4336
|
+
const after = following !== void 0 && touches(segment, following) ? following : void 0;
|
|
4337
|
+
let reason = reading({ segment, before, after, keyAt, salienceFloor: floors[index] ?? 0 });
|
|
4338
|
+
if (reason === null && index === 0) {
|
|
4339
|
+
reason = { kind: "first" };
|
|
4340
|
+
}
|
|
4341
|
+
if (reason === null && index === segments.length - 1) {
|
|
4342
|
+
reason = { kind: "last" };
|
|
4343
|
+
}
|
|
4344
|
+
reason ??= figureReason(segment, before, after) ?? { kind: "unfigured" };
|
|
4345
|
+
result.push({
|
|
4346
|
+
chord: segment.chord,
|
|
4347
|
+
startBeat: segment.startBeat,
|
|
4348
|
+
endBeat: segment.endBeat,
|
|
4349
|
+
level: levelOf(reason),
|
|
4350
|
+
rationale: describe2(reason)
|
|
4351
|
+
});
|
|
4352
|
+
}
|
|
4353
|
+
return result;
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
// src/analyze/reference/types.ts
|
|
4357
|
+
var REFERENCE_PROFILE_VERSION = 1;
|
|
4358
|
+
var PHRASE_OUTLINE_POINTS = 8;
|
|
4359
|
+
var MIDI_MAX = 127;
|
|
4360
|
+
|
|
4361
|
+
// src/analyze/rhythm/internal.ts
|
|
4362
|
+
function isMultipleOf(value, unit) {
|
|
4363
|
+
if (unit <= 0) {
|
|
4364
|
+
return false;
|
|
4365
|
+
}
|
|
4366
|
+
const ratio2 = value / unit;
|
|
4367
|
+
return Math.abs(ratio2 - Math.round(ratio2)) < BEAT_EPS;
|
|
4368
|
+
}
|
|
4369
|
+
function rhythmLevel(beatInQuarters, meters) {
|
|
4370
|
+
const ts = meterAt(beatInQuarters, meters);
|
|
4371
|
+
const offset = beatInQuarters - barStartBeat(beatInQuarters, meters);
|
|
4372
|
+
const pulse = pulseBeats(ts);
|
|
4373
|
+
if (isMultipleOf(offset, pulse)) {
|
|
4374
|
+
return metricWeight(beatInQuarters, meters) + 2;
|
|
4375
|
+
}
|
|
4376
|
+
const subUnit = pulse / (isCompound(ts) ? 3 : 2);
|
|
4377
|
+
return isMultipleOf(offset, subUnit) ? 1 : 0;
|
|
4378
|
+
}
|
|
4379
|
+
var IOI_MIN_EXPONENT = -4;
|
|
4380
|
+
var IOI_MAX_EXPONENT = 4;
|
|
4381
|
+
var IOI_EXPONENT_STEP = 0.5;
|
|
4382
|
+
function ioiBinIndex(ioi) {
|
|
4383
|
+
const exponent = Math.min(
|
|
4384
|
+
IOI_MAX_EXPONENT,
|
|
4385
|
+
Math.max(IOI_MIN_EXPONENT, Math.round(2 * Math.log2(ioi)) / 2)
|
|
4386
|
+
);
|
|
4387
|
+
return Math.round((exponent - IOI_MIN_EXPONENT) / IOI_EXPONENT_STEP);
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
// src/analyze/rhythm/index.ts
|
|
4391
|
+
var RHYTHM_SLOTS_PER_BEAT = 12;
|
|
4392
|
+
var SLOT_WIDTH = 1 / RHYTHM_SLOTS_PER_BEAT;
|
|
4393
|
+
var RHYTHM_OFF_GRID_TOLERANCE = 1 / 32;
|
|
4394
|
+
var RHYTHM_IOI_BINS = Object.freeze(
|
|
4395
|
+
Array.from({ length: 17 }, (_, i) => 2 ** (-4 + i * 0.5))
|
|
4396
|
+
);
|
|
4397
|
+
var BUDGET_SLOTS_PER_BAR = 48;
|
|
4398
|
+
var RHYTHM_LEVEL_COUNT = 6;
|
|
4399
|
+
function emptyAnalysis() {
|
|
4400
|
+
return {
|
|
4401
|
+
startBeat: 0,
|
|
4402
|
+
endBeat: 0,
|
|
4403
|
+
bars: 0,
|
|
4404
|
+
onsets: 0,
|
|
4405
|
+
onsetDensity: 0,
|
|
4406
|
+
barOnsets: [],
|
|
4407
|
+
onsetLevels: new Array(RHYTHM_LEVEL_COUNT).fill(0),
|
|
4408
|
+
barPositions: [],
|
|
4409
|
+
interOnsetShares: new Array(RHYTHM_IOI_BINS.length).fill(0),
|
|
4410
|
+
restRatio: 0,
|
|
4411
|
+
syncopation: 0,
|
|
4412
|
+
offGridRatio: 0,
|
|
4413
|
+
rationale: "Nothing sounds, so there is no rhythm to place on a grid"
|
|
4414
|
+
};
|
|
4415
|
+
}
|
|
4416
|
+
function foldOnsets(sounding) {
|
|
4417
|
+
const sorted = [...sounding].sort((a, b) => a.startBeat - b.startBeat);
|
|
4418
|
+
const onsets = [];
|
|
4419
|
+
for (const note of sorted) {
|
|
4420
|
+
const noteEnd = note.startBeat + note.durationBeat;
|
|
4421
|
+
const current = onsets[onsets.length - 1];
|
|
4422
|
+
if (current !== void 0 && note.startBeat - current.beat <= HUMANIZE_ADJACENCY) {
|
|
4423
|
+
current.end = Math.max(current.end, noteEnd);
|
|
4424
|
+
} else {
|
|
4425
|
+
onsets.push({ beat: note.startBeat, end: noteEnd });
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
return onsets;
|
|
4429
|
+
}
|
|
4430
|
+
function snapOnset(onset, meters) {
|
|
4431
|
+
const ts = meterAt(onset.beat, meters);
|
|
4432
|
+
const barStart = barStartBeat(onset.beat, meters);
|
|
4433
|
+
const offset = onset.beat - barStart;
|
|
4434
|
+
const slot = Math.round(offset / SLOT_WIDTH);
|
|
4435
|
+
const snapped = barStart + slot * SLOT_WIDTH;
|
|
4436
|
+
const offGrid = Math.abs(onset.beat - snapped) > RHYTHM_OFF_GRID_TOLERANCE;
|
|
4437
|
+
return { ...onset, ts, barStart, slot, snapped, level: rhythmLevel(snapped, meters), offGrid };
|
|
4438
|
+
}
|
|
4439
|
+
function tsKey(ts) {
|
|
4440
|
+
return ts.grouping === void 0 ? `${ts.numerator}/${ts.denominator}` : `${ts.numerator}/${ts.denominator}:${ts.grouping.join("+")}`;
|
|
4441
|
+
}
|
|
4442
|
+
function pulseKey(bar, index) {
|
|
4443
|
+
return `${bar}/${index}`;
|
|
4444
|
+
}
|
|
4445
|
+
function otherDivisionPulses(snapped, meters) {
|
|
4446
|
+
const pulses = /* @__PURE__ */ new Set();
|
|
4447
|
+
for (const onset of snapped) {
|
|
4448
|
+
const pulse = pulseBeats(onset.ts);
|
|
4449
|
+
const offset = onset.snapped - onset.barStart;
|
|
4450
|
+
const index = Math.floor(offset / pulse + BEAT_EPS);
|
|
4451
|
+
const within = offset - index * pulse;
|
|
4452
|
+
const [used, other] = isCompound(onset.ts) ? [3, 2] : [2, 3];
|
|
4453
|
+
if (isMultipleOf(within, pulse / other) && !isMultipleOf(within, pulse / used)) {
|
|
4454
|
+
pulses.add(pulseKey(barIndexAt(onset.beat, meters), index));
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
return pulses;
|
|
4458
|
+
}
|
|
4459
|
+
function barGridPoints(bar, meters, otherPulses) {
|
|
4460
|
+
const barStart = barPositionToBeat({ bar, beat: 0 }, meters);
|
|
4461
|
+
const ts = meterAt(barStart, meters);
|
|
4462
|
+
const barLength = beatsPerBarAt(barStart, meters);
|
|
4463
|
+
const pulse = pulseBeats(ts);
|
|
4464
|
+
const [used, other] = isCompound(ts) ? [3, 2] : [2, 3];
|
|
4465
|
+
const subUnit = pulse / used;
|
|
4466
|
+
const steps = Math.max(1, Math.round(barLength / subUnit));
|
|
4467
|
+
const points = [];
|
|
4468
|
+
for (let k = 0; k < steps; k += 1) {
|
|
4469
|
+
const index = Math.floor(k * subUnit / pulse + BEAT_EPS);
|
|
4470
|
+
if (k % used === 0 || !otherPulses.has(pulseKey(bar, index))) {
|
|
4471
|
+
const beat = barStart + k * subUnit;
|
|
4472
|
+
points.push({ beat, level: rhythmLevel(beat, meters) });
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
for (let index = 0; index * pulse < steps * subUnit - BEAT_EPS; index += 1) {
|
|
4476
|
+
if (otherPulses.has(pulseKey(bar, index))) {
|
|
4477
|
+
for (let m = 1; m < other; m += 1) {
|
|
4478
|
+
points.push({ beat: barStart + index * pulse + m * pulse / other, level: 1 });
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
return points.sort((a, b) => a.beat - b.beat);
|
|
4483
|
+
}
|
|
4484
|
+
function restRatioOf(sounding, windowStart, windowEnd) {
|
|
4485
|
+
const span = windowEnd - windowStart;
|
|
4486
|
+
if (span <= 0) {
|
|
4487
|
+
return 0;
|
|
4488
|
+
}
|
|
4489
|
+
const intervals = sounding.map((note) => [
|
|
4490
|
+
Math.max(windowStart, note.startBeat),
|
|
4491
|
+
Math.min(windowEnd, note.startBeat + note.durationBeat)
|
|
4492
|
+
]).filter(([from, to]) => to > from).sort((a, b) => a[0] - b[0]);
|
|
4493
|
+
let covered = 0;
|
|
4494
|
+
let curStart = 0;
|
|
4495
|
+
let curEnd = Number.NEGATIVE_INFINITY;
|
|
4496
|
+
for (const [from, to] of intervals) {
|
|
4497
|
+
if (from - curEnd > HUMANIZE_ADJACENCY) {
|
|
4498
|
+
if (curEnd > Number.NEGATIVE_INFINITY) {
|
|
4499
|
+
covered += curEnd - curStart;
|
|
4500
|
+
}
|
|
4501
|
+
curStart = from;
|
|
4502
|
+
curEnd = to;
|
|
4503
|
+
} else {
|
|
4504
|
+
curEnd = Math.max(curEnd, to);
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
if (curEnd > Number.NEGATIVE_INFINITY) {
|
|
4508
|
+
covered += curEnd - curStart;
|
|
4509
|
+
}
|
|
4510
|
+
return 1 - covered / span;
|
|
4511
|
+
}
|
|
4512
|
+
function syncopationOf(snapped, startBeat, endBeat, meters) {
|
|
4513
|
+
if (snapped.length === 0) {
|
|
4514
|
+
return 0;
|
|
4515
|
+
}
|
|
4516
|
+
const firstBar = barIndexAt(startBeat, meters);
|
|
4517
|
+
const lastBar = barIndexAt(endBeat, meters);
|
|
4518
|
+
const otherPulses = otherDivisionPulses(snapped, meters);
|
|
4519
|
+
const points = [];
|
|
4520
|
+
for (let bar = firstBar; bar <= lastBar; bar += 1) {
|
|
4521
|
+
for (const point of barGridPoints(bar, meters, otherPulses)) {
|
|
4522
|
+
if (point.beat > startBeat + BEAT_EPS && point.beat <= endBeat + BEAT_EPS) {
|
|
4523
|
+
points.push(point);
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
const normalizer = points.reduce((sum, p) => sum + p.level, 0);
|
|
4528
|
+
if (normalizer <= 0) {
|
|
4529
|
+
return 0;
|
|
4530
|
+
}
|
|
4531
|
+
let numerator = 0;
|
|
4532
|
+
let ptr = 0;
|
|
4533
|
+
for (let i = 0; i < snapped.length; i += 1) {
|
|
4534
|
+
const onset = snapped[i];
|
|
4535
|
+
if (onset === void 0) {
|
|
4536
|
+
continue;
|
|
4537
|
+
}
|
|
4538
|
+
const next = snapped[i + 1];
|
|
4539
|
+
const windowEnd = next !== void 0 ? next.snapped : onset.end;
|
|
4540
|
+
while (ptr < points.length && (points[ptr]?.beat ?? Number.POSITIVE_INFINITY) <= onset.snapped + BEAT_EPS) {
|
|
4541
|
+
ptr += 1;
|
|
4542
|
+
}
|
|
4543
|
+
const here = ptr > 0 ? points[ptr - 1] : void 0;
|
|
4544
|
+
const onsetLevel = here !== void 0 && Math.abs(here.beat - onset.snapped) < BEAT_EPS ? Math.max(onset.level, here.level) : onset.level;
|
|
4545
|
+
let scan = ptr;
|
|
4546
|
+
let maxLevel = 0;
|
|
4547
|
+
while (scan < points.length && (points[scan]?.beat ?? Number.POSITIVE_INFINITY) < windowEnd - BEAT_EPS) {
|
|
4548
|
+
maxLevel = Math.max(maxLevel, points[scan]?.level ?? 0);
|
|
4549
|
+
scan += 1;
|
|
4550
|
+
}
|
|
4551
|
+
if (maxLevel > onsetLevel) {
|
|
4552
|
+
numerator += maxLevel - onsetLevel;
|
|
4553
|
+
}
|
|
4554
|
+
ptr = scan;
|
|
4555
|
+
}
|
|
4556
|
+
return numerator / normalizer;
|
|
4557
|
+
}
|
|
4558
|
+
function analyzeRhythm(notes, opts = {}) {
|
|
4559
|
+
return rhythmOver(notes, opts, void 0);
|
|
4560
|
+
}
|
|
4561
|
+
function rhythmOver(notes, opts, windowStart) {
|
|
4562
|
+
const meters = resolveMeters(opts, "rhythm meters");
|
|
4563
|
+
assertNoteEvents(notes, "rhythm notes", { allowNonPositiveDuration: true, budget: opts.budget });
|
|
4564
|
+
const sounding = notes.filter((note) => note.durationBeat > 0);
|
|
4565
|
+
const startBeat = windowStart ?? barGridStart(firstSoundingBeat(sounding), meters);
|
|
4566
|
+
const endBeat = resolveSpanEnd(
|
|
4567
|
+
opts.totalBeats,
|
|
4568
|
+
startBeat,
|
|
4569
|
+
latestSoundingEnd(sounding),
|
|
4570
|
+
"rhythm totalBeats"
|
|
4571
|
+
);
|
|
4572
|
+
if (sounding.length === 0) {
|
|
4573
|
+
return emptyAnalysis();
|
|
4574
|
+
}
|
|
4575
|
+
const bars = barSpanOf(startBeat, endBeat, meters);
|
|
4576
|
+
assertGenerationBudget(
|
|
4577
|
+
Math.ceil(bars) * BUDGET_SLOTS_PER_BAR,
|
|
4578
|
+
"rhythm analysis slots",
|
|
4579
|
+
opts.budget
|
|
4580
|
+
);
|
|
4581
|
+
const onsets = foldOnsets(sounding);
|
|
4582
|
+
const snapped = onsets.map((onset) => snapOnset(onset, meters));
|
|
4583
|
+
const onsetDensity = bars > 0 ? onsets.length / bars : 0;
|
|
4584
|
+
const firstBar = barIndexAt(startBeat, meters);
|
|
4585
|
+
const barOnsets = new Array(lastBarOf(meters, startBeat, endBeat) - firstBar + 1).fill(0);
|
|
4586
|
+
for (const onset of onsets) {
|
|
4587
|
+
const index = barIndexAt(onset.beat, meters) - firstBar;
|
|
4588
|
+
if (index >= 0 && index < barOnsets.length) {
|
|
4589
|
+
barOnsets[index] = (barOnsets[index] ?? 0) + 1;
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
const onsetLevels = new Array(RHYTHM_LEVEL_COUNT).fill(0);
|
|
4593
|
+
let offGridCount = 0;
|
|
4594
|
+
for (const onset of snapped) {
|
|
4595
|
+
onsetLevels[onset.level] = (onsetLevels[onset.level] ?? 0) + 1;
|
|
4596
|
+
if (onset.offGrid) {
|
|
4597
|
+
offGridCount += 1;
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
for (let i = 0; i < onsetLevels.length; i += 1) {
|
|
4601
|
+
onsetLevels[i] = (onsetLevels[i] ?? 0) / onsets.length;
|
|
4602
|
+
}
|
|
4603
|
+
const profiles = [];
|
|
4604
|
+
const profileIndex = /* @__PURE__ */ new Map();
|
|
4605
|
+
for (const onset of snapped) {
|
|
4606
|
+
const key = tsKey(onset.ts);
|
|
4607
|
+
let index = profileIndex.get(key);
|
|
4608
|
+
if (index === void 0) {
|
|
4609
|
+
index = profiles.length;
|
|
4610
|
+
profileIndex.set(key, index);
|
|
4611
|
+
const slotsPerBar = Math.ceil(beatsPerBar(onset.ts) * RHYTHM_SLOTS_PER_BEAT);
|
|
4612
|
+
profiles.push({
|
|
4613
|
+
ts: onset.ts,
|
|
4614
|
+
slotsPerBar,
|
|
4615
|
+
shares: new Array(slotsPerBar).fill(0),
|
|
4616
|
+
onsets: 0
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
const profile = profiles[index];
|
|
4620
|
+
if (profile !== void 0) {
|
|
4621
|
+
const slotIndex = (onset.slot % profile.slotsPerBar + profile.slotsPerBar) % profile.slotsPerBar;
|
|
4622
|
+
profile.shares[slotIndex] = (profile.shares[slotIndex] ?? 0) + 1;
|
|
4623
|
+
profile.onsets += 1;
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
for (const profile of profiles) {
|
|
4627
|
+
for (let i = 0; i < profile.shares.length; i += 1) {
|
|
4628
|
+
profile.shares[i] = (profile.shares[i] ?? 0) / profile.onsets;
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
const interOnsetShares = new Array(RHYTHM_IOI_BINS.length).fill(0);
|
|
4632
|
+
for (let i = 0; i < onsets.length; i += 1) {
|
|
4633
|
+
const onset = onsets[i];
|
|
4634
|
+
if (onset === void 0) {
|
|
4635
|
+
continue;
|
|
4636
|
+
}
|
|
4637
|
+
const next = onsets[i + 1];
|
|
4638
|
+
const ioi = next !== void 0 ? next.beat - onset.beat : onset.end - onset.beat;
|
|
4639
|
+
const bin = ioiBinIndex(ioi);
|
|
4640
|
+
interOnsetShares[bin] = (interOnsetShares[bin] ?? 0) + 1;
|
|
4641
|
+
}
|
|
4642
|
+
for (let i = 0; i < interOnsetShares.length; i += 1) {
|
|
4643
|
+
interOnsetShares[i] = (interOnsetShares[i] ?? 0) / onsets.length;
|
|
4644
|
+
}
|
|
4645
|
+
const restRatio = restRatioOf(sounding, startBeat, endBeat);
|
|
4646
|
+
const syncopation = syncopationOf(snapped, startBeat, endBeat, meters);
|
|
4647
|
+
const offGridRatio = offGridCount / onsets.length;
|
|
4648
|
+
return {
|
|
4649
|
+
startBeat,
|
|
4650
|
+
endBeat,
|
|
4651
|
+
bars,
|
|
4652
|
+
onsets: onsets.length,
|
|
4653
|
+
onsetDensity,
|
|
4654
|
+
barOnsets,
|
|
4655
|
+
onsetLevels,
|
|
4656
|
+
barPositions: profiles,
|
|
4657
|
+
interOnsetShares,
|
|
4658
|
+
restRatio,
|
|
4659
|
+
syncopation,
|
|
4660
|
+
offGridRatio,
|
|
4661
|
+
rationale: `${onsets.length} onsets over ${bars.toFixed(2)} bars (${onsetDensity.toFixed(2)} per bar), ${Math.round(restRatio * 100)}% rest, syncopation ${syncopation.toFixed(2)}`
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
// src/analyze/reference/validate.ts
|
|
4666
|
+
var MASS_TOLERANCE = 1e-9;
|
|
4667
|
+
function assertString(value, name) {
|
|
4668
|
+
if (typeof value !== "string") {
|
|
4669
|
+
throw new InvalidInputError(`${name} must be a string; received ${describeRejected(value)}`);
|
|
4670
|
+
}
|
|
4671
|
+
return value;
|
|
4672
|
+
}
|
|
4673
|
+
function assertSpanOrder(startBeat, endBeat, name) {
|
|
4674
|
+
if (endBeat < startBeat) {
|
|
4675
|
+
throw new InvalidInputError(
|
|
4676
|
+
`${name}.endBeat must be at least ${name}.startBeat; received ${endBeat} < ${startBeat}`
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
function assertNullable(value, name, validate) {
|
|
4681
|
+
if (value === void 0) {
|
|
4682
|
+
throw new InvalidInputError(`${name} must be present; received undefined`);
|
|
4683
|
+
}
|
|
4684
|
+
return value === null ? null : validate(value, name);
|
|
4685
|
+
}
|
|
4686
|
+
function assertMassDistribution(value, length, name, allowAllZero) {
|
|
4687
|
+
const shares = assertArray(value, name);
|
|
4688
|
+
if (shares.length !== length) {
|
|
4689
|
+
throw new InvalidInputError(`${name} must have length ${length}; received ${shares.length}`);
|
|
4690
|
+
}
|
|
4691
|
+
let sum = 0;
|
|
4692
|
+
for (let index = 0; index < shares.length; index += 1) {
|
|
4693
|
+
const share = assertFiniteNumber(shares[index], `${name}[${index}]`);
|
|
4694
|
+
if (share < 0) {
|
|
4695
|
+
throw new InvalidInputError(`${name}[${index}] must be at least 0; received ${share}`);
|
|
4696
|
+
}
|
|
4697
|
+
sum += share;
|
|
4698
|
+
}
|
|
4699
|
+
if (allowAllZero && sum === 0) {
|
|
4700
|
+
return shares;
|
|
4701
|
+
}
|
|
4702
|
+
if (Math.abs(sum - 1) > MASS_TOLERANCE) {
|
|
4703
|
+
throw new InvalidInputError(`${name} must sum to 1; received ${sum}`);
|
|
4704
|
+
}
|
|
4705
|
+
return shares;
|
|
4706
|
+
}
|
|
4707
|
+
function assertBarPositionProfile(value, name) {
|
|
4708
|
+
const read = assertRecord(value, name);
|
|
4709
|
+
assertTimeSignature(read.ts, `${name}.ts`);
|
|
4710
|
+
const slotsPerBar = assertPositiveInt(read.slotsPerBar, `${name}.slotsPerBar`);
|
|
4711
|
+
const onsets = assertInteger(read.onsets, `${name}.onsets`, 0);
|
|
4712
|
+
assertMassDistribution(read.shares, slotsPerBar, `${name}.shares`, onsets === 0);
|
|
4713
|
+
return read;
|
|
4714
|
+
}
|
|
4715
|
+
function assertRhythmAnalysis(value, name) {
|
|
4716
|
+
const read = assertRecord(value, name);
|
|
4717
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4718
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4719
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4720
|
+
assertRange(read.bars, 0, Number.MAX_SAFE_INTEGER, `${name}.bars`);
|
|
4721
|
+
const onsets = assertInteger(read.onsets, `${name}.onsets`, 0);
|
|
4722
|
+
assertRange(read.onsetDensity, 0, Number.MAX_SAFE_INTEGER, `${name}.onsetDensity`);
|
|
4723
|
+
const barOnsets = assertArray(read.barOnsets, `${name}.barOnsets`);
|
|
4724
|
+
for (let index = 0; index < barOnsets.length; index += 1) {
|
|
4725
|
+
assertRange(
|
|
4726
|
+
barOnsets[index],
|
|
4727
|
+
0,
|
|
4728
|
+
Number.MAX_SAFE_INTEGER,
|
|
4729
|
+
`${name}.barOnsets[${index}]`
|
|
4730
|
+
);
|
|
4731
|
+
}
|
|
4732
|
+
assertMassDistribution(read.onsetLevels, RHYTHM_LEVEL_COUNT, `${name}.onsetLevels`, onsets === 0);
|
|
4733
|
+
const barPositions = assertArray(read.barPositions, `${name}.barPositions`);
|
|
4734
|
+
for (let index = 0; index < barPositions.length; index += 1) {
|
|
4735
|
+
assertBarPositionProfile(barPositions[index], `${name}.barPositions[${index}]`);
|
|
4736
|
+
}
|
|
4737
|
+
assertMassDistribution(
|
|
4738
|
+
read.interOnsetShares,
|
|
4739
|
+
RHYTHM_IOI_BINS.length,
|
|
4740
|
+
`${name}.interOnsetShares`,
|
|
4741
|
+
onsets === 0
|
|
4742
|
+
);
|
|
4743
|
+
assertRange(read.restRatio, 0, 1, `${name}.restRatio`);
|
|
4744
|
+
assertRange(read.syncopation, 0, 1, `${name}.syncopation`);
|
|
4745
|
+
assertRange(read.offGridRatio, 0, 1, `${name}.offGridRatio`);
|
|
4746
|
+
assertString(read.rationale, `${name}.rationale`);
|
|
4747
|
+
return read;
|
|
4748
|
+
}
|
|
4749
|
+
function assertFormSection(value, name, sectionCount) {
|
|
4750
|
+
const read = assertRecord(value, name);
|
|
4751
|
+
assertString(read.label, `${name}.label`);
|
|
4752
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4753
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4754
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4755
|
+
assertRange(read.bars, 0, Number.MAX_SAFE_INTEGER, `${name}.bars`);
|
|
4756
|
+
assertInteger(read.firstOccurrence, `${name}.firstOccurrence`, 0, Math.max(sectionCount - 1, 0));
|
|
4757
|
+
assertRange(read.similarity, 0, 1, `${name}.similarity`);
|
|
4758
|
+
assertString(read.rationale, `${name}.rationale`);
|
|
4759
|
+
return read;
|
|
4760
|
+
}
|
|
4761
|
+
function assertReferenceCadence(value, name) {
|
|
4762
|
+
const read = assertRecord(value, name);
|
|
4763
|
+
assertFiniteNumber(read.atBeat, `${name}.atBeat`);
|
|
4764
|
+
assertOneOf(read.type, CADENCE_TYPES, `${name}.type`);
|
|
4765
|
+
assertRange(read.weight, 0, 1, `${name}.weight`);
|
|
4766
|
+
return read;
|
|
4767
|
+
}
|
|
4768
|
+
function assertReferencePhraseMelody(value, name) {
|
|
4769
|
+
const read = assertRecord(value, name);
|
|
4770
|
+
assertOneOf(read.shape, MELODIC_CONTOUR_SHAPES, `${name}.shape`);
|
|
4771
|
+
assertMidiPitch(read.low, `${name}.low`);
|
|
4772
|
+
assertMidiPitch(read.high, `${name}.high`);
|
|
4773
|
+
assertRange(read.mean, 0, MIDI_MAX, `${name}.mean`);
|
|
4774
|
+
assertRange(read.peakPitch, 0, MIDI_MAX, `${name}.peakPitch`);
|
|
4775
|
+
assertRange(read.peakPosition, 0, 1, `${name}.peakPosition`);
|
|
4776
|
+
const outline = assertArray(read.outline, `${name}.outline`);
|
|
4777
|
+
if (outline.length !== PHRASE_OUTLINE_POINTS) {
|
|
4778
|
+
throw new InvalidInputError(
|
|
4779
|
+
`${name}.outline must have length ${PHRASE_OUTLINE_POINTS}; received ${outline.length}`
|
|
4780
|
+
);
|
|
4781
|
+
}
|
|
4782
|
+
for (let index = 0; index < outline.length; index += 1) {
|
|
4783
|
+
assertFiniteNumber(outline[index], `${name}.outline[${index}]`);
|
|
4784
|
+
}
|
|
4785
|
+
assertRange(read.onsetDensity, 0, Number.MAX_SAFE_INTEGER, `${name}.onsetDensity`);
|
|
4786
|
+
assertRange(read.restRatio, 0, 1, `${name}.restRatio`);
|
|
4787
|
+
return read;
|
|
4788
|
+
}
|
|
4789
|
+
function assertReferencePhrase(value, name, span, sectionCount, nodeCount) {
|
|
4790
|
+
const read = assertRecord(value, name);
|
|
4791
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4792
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4793
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4794
|
+
assertRange(startBeat, span.startBeat, span.endBeat, `${name}.startBeat`);
|
|
4795
|
+
assertRange(endBeat, span.startBeat, span.endBeat, `${name}.endBeat`);
|
|
4796
|
+
assertRange(read.bars, 0, Number.MAX_SAFE_INTEGER, `${name}.bars`);
|
|
4797
|
+
assertNullable(
|
|
4798
|
+
read.section,
|
|
4799
|
+
`${name}.section`,
|
|
4800
|
+
(v, n) => assertInteger(v, n, 0, Math.max(sectionCount - 1, 0))
|
|
4801
|
+
);
|
|
4802
|
+
assertRange(read.confidence, 0, 1, `${name}.confidence`);
|
|
4803
|
+
assertNullable(read.cadence, `${name}.cadence`, assertReferenceCadence);
|
|
4804
|
+
assertNullable(read.melody, `${name}.melody`, assertReferencePhraseMelody);
|
|
4805
|
+
const motifNodes = assertArray(read.motifNodes, `${name}.motifNodes`);
|
|
4806
|
+
for (let index = 0; index < motifNodes.length; index += 1) {
|
|
4807
|
+
assertInteger(
|
|
4808
|
+
motifNodes[index],
|
|
4809
|
+
`${name}.motifNodes[${index}]`,
|
|
4810
|
+
0,
|
|
4811
|
+
Math.max(nodeCount - 1, 0)
|
|
4812
|
+
);
|
|
4813
|
+
}
|
|
4814
|
+
return read;
|
|
4815
|
+
}
|
|
4816
|
+
function assertReferenceForm(value, name, span, nodeCount) {
|
|
4817
|
+
const read = assertRecord(value, name);
|
|
4818
|
+
const hypermeter = assertRecord(
|
|
4819
|
+
read.hypermeter,
|
|
4820
|
+
`${name}.hypermeter`
|
|
4821
|
+
);
|
|
4822
|
+
assertRange(hypermeter.groupBars, 0, Number.MAX_SAFE_INTEGER, `${name}.hypermeter.groupBars`);
|
|
4823
|
+
assertRange(hypermeter.confidence, 0, 1, `${name}.hypermeter.confidence`);
|
|
4824
|
+
const sections = assertArray(read.sections, `${name}.sections`);
|
|
4825
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
4826
|
+
assertFormSection(sections[index], `${name}.sections[${index}]`, sections.length);
|
|
4827
|
+
}
|
|
4828
|
+
const phrases = assertArray(read.phrases, `${name}.phrases`);
|
|
4829
|
+
for (let index = 0; index < phrases.length; index += 1) {
|
|
4830
|
+
assertReferencePhrase(
|
|
4831
|
+
phrases[index],
|
|
4832
|
+
`${name}.phrases[${index}]`,
|
|
4833
|
+
span,
|
|
4834
|
+
sections.length,
|
|
4835
|
+
nodeCount
|
|
4836
|
+
);
|
|
4837
|
+
}
|
|
4838
|
+
return read;
|
|
4839
|
+
}
|
|
4840
|
+
function assertResolvedKeyField(value, name) {
|
|
4841
|
+
const read = assertRecord(value, name);
|
|
4842
|
+
try {
|
|
4843
|
+
resolveKey(read);
|
|
4844
|
+
} catch (error) {
|
|
4845
|
+
if (error instanceof InvalidInputError) {
|
|
4846
|
+
throw new InvalidInputError(`${name} must be a valid key: ${error.message}`);
|
|
4847
|
+
}
|
|
4848
|
+
throw error;
|
|
4849
|
+
}
|
|
4850
|
+
return read;
|
|
4851
|
+
}
|
|
4852
|
+
function assertReferenceKeyRegion(value, name) {
|
|
4853
|
+
const read = assertRecord(value, name);
|
|
4854
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4855
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4856
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4857
|
+
assertResolvedKeyField(read.key, `${name}.key`);
|
|
4858
|
+
assertRange(read.confidence, 0, 1, `${name}.confidence`);
|
|
4859
|
+
return read;
|
|
4860
|
+
}
|
|
4861
|
+
function assertReferenceChord(value, name, keys) {
|
|
4862
|
+
const read = assertRecord(value, name);
|
|
4863
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4864
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4865
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4866
|
+
const keyIndex = assertInteger(read.key, `${name}.key`, 0, Math.max(keys.length - 1, 0));
|
|
4867
|
+
const roman = assertString(read.roman, `${name}.roman`);
|
|
4868
|
+
try {
|
|
4869
|
+
romanToChord(roman, keys[keyIndex]);
|
|
4870
|
+
} catch (error) {
|
|
4871
|
+
if (error instanceof InvalidInputError) {
|
|
4872
|
+
throw new InvalidInputError(
|
|
4873
|
+
`${name}.roman must be readable by romanToChord; received ${describeRejected(roman)}: ${error.message}`
|
|
4874
|
+
);
|
|
4875
|
+
}
|
|
4876
|
+
throw error;
|
|
4877
|
+
}
|
|
4878
|
+
assertOneOf(read.function, HARMONIC_FUNCTIONS, `${name}.function`);
|
|
4879
|
+
assertOneOf(read.level, REDUCTION_LEVELS, `${name}.level`);
|
|
4880
|
+
return read;
|
|
4881
|
+
}
|
|
4882
|
+
function assertReferenceHarmony(value, name) {
|
|
4883
|
+
const read = assertRecord(value, name);
|
|
4884
|
+
const keys = assertArray(read.keys, `${name}.keys`);
|
|
4885
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
4886
|
+
assertReferenceKeyRegion(keys[index], `${name}.keys[${index}]`);
|
|
4887
|
+
}
|
|
4888
|
+
const resolvedKeys = keys.map((region) => region.key);
|
|
4889
|
+
const chords = assertArray(read.chords, `${name}.chords`);
|
|
4890
|
+
for (let index = 0; index < chords.length; index += 1) {
|
|
4891
|
+
assertReferenceChord(chords[index], `${name}.chords[${index}]`, resolvedKeys);
|
|
4892
|
+
}
|
|
4893
|
+
assertRhythmAnalysis(read.rhythm, `${name}.rhythm`);
|
|
4894
|
+
return read;
|
|
4895
|
+
}
|
|
4896
|
+
function assertMotifRelationSummary(value, name) {
|
|
4897
|
+
const read = assertRecord(value, name);
|
|
4898
|
+
assertOneOf(read.kind, MOTIF_RELATION_KINDS, `${name}.kind`);
|
|
4899
|
+
assertFlag(read.sequence, `${name}.sequence`);
|
|
4900
|
+
assertFiniteNumber(read.semitones, `${name}.semitones`);
|
|
4901
|
+
if (read.degrees !== void 0) {
|
|
4902
|
+
assertFiniteNumber(read.degrees, `${name}.degrees`);
|
|
4903
|
+
}
|
|
4904
|
+
const timeRatio = assertFiniteNumber(read.timeRatio, `${name}.timeRatio`);
|
|
4905
|
+
if (timeRatio <= 0) {
|
|
4906
|
+
throw new InvalidInputError(`${name}.timeRatio must be positive; received ${timeRatio}`);
|
|
4907
|
+
}
|
|
4908
|
+
return read;
|
|
4909
|
+
}
|
|
4910
|
+
function assertMotifGraphNode(value, name, occurrenceCounts) {
|
|
4911
|
+
const read = assertRecord(value, name);
|
|
4912
|
+
const motif = assertInteger(
|
|
4913
|
+
read.motif,
|
|
4914
|
+
`${name}.motif`,
|
|
4915
|
+
0,
|
|
4916
|
+
Math.max(occurrenceCounts.length - 1, 0)
|
|
4917
|
+
);
|
|
4918
|
+
assertInteger(
|
|
4919
|
+
read.occurrence,
|
|
4920
|
+
`${name}.occurrence`,
|
|
4921
|
+
0,
|
|
4922
|
+
Math.max((occurrenceCounts[motif] ?? 1) - 1, 0)
|
|
4923
|
+
);
|
|
4924
|
+
const startBeat = assertFiniteNumber(read.startBeat, `${name}.startBeat`);
|
|
4925
|
+
const endBeat = assertFiniteNumber(read.endBeat, `${name}.endBeat`);
|
|
4926
|
+
assertSpanOrder(startBeat, endBeat, name);
|
|
4927
|
+
return read;
|
|
4928
|
+
}
|
|
4929
|
+
function assertMotifGraphEdge(value, name, nodeCount) {
|
|
4930
|
+
const read = assertRecord(value, name);
|
|
4931
|
+
const from = assertInteger(read.from, `${name}.from`, 0, Math.max(nodeCount - 1, 0));
|
|
4932
|
+
const to = assertInteger(read.to, `${name}.to`, 0, Math.max(nodeCount - 1, 0));
|
|
4933
|
+
if (from >= to) {
|
|
4934
|
+
throw new InvalidInputError(
|
|
4935
|
+
`${name}.from must be less than ${name}.to; received from=${from}, to=${to}`
|
|
4936
|
+
);
|
|
4937
|
+
}
|
|
4938
|
+
assertNullable(read.relation, `${name}.relation`, assertMotifRelationSummary);
|
|
4939
|
+
assertRange(read.similarity, 0, 1, `${name}.similarity`);
|
|
4940
|
+
return read;
|
|
4941
|
+
}
|
|
4942
|
+
function assertMotifGraph(value, name, occurrenceCounts) {
|
|
4943
|
+
const read = assertRecord(value, name);
|
|
4944
|
+
const nodes = assertArray(read.nodes, `${name}.nodes`);
|
|
4945
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
4946
|
+
assertMotifGraphNode(nodes[index], `${name}.nodes[${index}]`, occurrenceCounts);
|
|
4947
|
+
}
|
|
4948
|
+
const edges = assertArray(read.edges, `${name}.edges`);
|
|
4949
|
+
const seenTo = /* @__PURE__ */ new Set();
|
|
4950
|
+
for (let index = 0; index < edges.length; index += 1) {
|
|
4951
|
+
const edge = assertMotifGraphEdge(edges[index], `${name}.edges[${index}]`, nodes.length);
|
|
4952
|
+
if (seenTo.has(edge.to)) {
|
|
4953
|
+
throw new InvalidInputError(`${name}.edges: node ${edge.to} has more than one incoming edge`);
|
|
4954
|
+
}
|
|
4955
|
+
seenTo.add(edge.to);
|
|
4956
|
+
}
|
|
4957
|
+
return read;
|
|
4958
|
+
}
|
|
4959
|
+
function assertReferenceMotif(value, name) {
|
|
4960
|
+
const read = assertRecord(value, name);
|
|
4961
|
+
const intervals = assertArray(read.intervals, `${name}.intervals`);
|
|
4962
|
+
let pitch = 0;
|
|
4963
|
+
let low = 0;
|
|
4964
|
+
let high = 0;
|
|
4965
|
+
for (let index = 0; index < intervals.length; index += 1) {
|
|
4966
|
+
const interval = assertFiniteNumber(intervals[index], `${name}.intervals[${index}]`);
|
|
4967
|
+
if (!Number.isInteger(interval)) {
|
|
4968
|
+
throw new InvalidInputError(
|
|
4969
|
+
`${name}.intervals[${index}] must be a whole number of semitones; received ${interval}`
|
|
4970
|
+
);
|
|
4971
|
+
}
|
|
4972
|
+
pitch += interval;
|
|
4973
|
+
low = Math.min(low, pitch);
|
|
4974
|
+
high = Math.max(high, pitch);
|
|
4975
|
+
}
|
|
4976
|
+
if (high - low > MIDI_MAX) {
|
|
4977
|
+
throw new InvalidInputError(
|
|
4978
|
+
`${name}.intervals must span at most ${MIDI_MAX} semitones; received ${high - low}`
|
|
4979
|
+
);
|
|
4980
|
+
}
|
|
4981
|
+
const rhythm = assertArray(read.rhythm, `${name}.rhythm`);
|
|
4982
|
+
if (rhythm.length !== intervals.length) {
|
|
4983
|
+
throw new InvalidInputError(
|
|
4984
|
+
`${name}.rhythm must hold one ratio per interval (${intervals.length}); received ${rhythm.length}`
|
|
4985
|
+
);
|
|
4986
|
+
}
|
|
4987
|
+
for (let index = 0; index < rhythm.length; index += 1) {
|
|
4988
|
+
const ratio2 = assertFiniteNumber(rhythm[index], `${name}.rhythm[${index}]`);
|
|
4989
|
+
if (!(ratio2 > 0)) {
|
|
4990
|
+
throw new InvalidInputError(`${name}.rhythm[${index}] must be positive; received ${ratio2}`);
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
const unitBeats = assertRange(read.unitBeats, 0, Number.MAX_SAFE_INTEGER, `${name}.unitBeats`);
|
|
4994
|
+
if (unitBeats > 0 !== rhythm.length > 0) {
|
|
4995
|
+
throw new InvalidInputError(
|
|
4996
|
+
`${name}.unitBeats must be positive exactly when rhythm is non-empty; received ${unitBeats} with ${rhythm.length} ratio(s)`
|
|
4997
|
+
);
|
|
4998
|
+
}
|
|
4999
|
+
assertRange(read.spanBeats, 0, Number.MAX_SAFE_INTEGER, `${name}.spanBeats`);
|
|
5000
|
+
assertPositiveInt(read.occurrences, `${name}.occurrences`);
|
|
5001
|
+
return read;
|
|
5002
|
+
}
|
|
5003
|
+
function assertReferenceMelody(value, name) {
|
|
5004
|
+
const read = assertRecord(value, name);
|
|
5005
|
+
assertNullable(read.register, `${name}.register`, (v, n) => {
|
|
5006
|
+
const register = assertRecord(v, n);
|
|
5007
|
+
assertMidiPitch(register.low, `${n}.low`);
|
|
5008
|
+
assertMidiPitch(register.high, `${n}.high`);
|
|
5009
|
+
assertRange(register.mean, 0, MIDI_MAX, `${n}.mean`);
|
|
5010
|
+
return register;
|
|
5011
|
+
});
|
|
5012
|
+
assertNullable(read.shape, `${name}.shape`, (v, n) => assertOneOf(v, MELODIC_CONTOUR_SHAPES, n));
|
|
5013
|
+
const motifs = assertArray(read.motifs, `${name}.motifs`);
|
|
5014
|
+
const occurrenceCounts = [];
|
|
5015
|
+
for (let index = 0; index < motifs.length; index += 1) {
|
|
5016
|
+
assertReferenceMotif(motifs[index], `${name}.motifs[${index}]`);
|
|
5017
|
+
occurrenceCounts.push(motifs[index].occurrences);
|
|
5018
|
+
}
|
|
5019
|
+
assertMotifGraph(read.graph, `${name}.graph`, occurrenceCounts);
|
|
5020
|
+
assertRhythmAnalysis(read.rhythm, `${name}.rhythm`);
|
|
5021
|
+
return read;
|
|
5022
|
+
}
|
|
5023
|
+
function assertReferenceProfile(value, name = "reference profile") {
|
|
5024
|
+
const read = assertRecord(value, name);
|
|
5025
|
+
if (read.profileVersion !== REFERENCE_PROFILE_VERSION) {
|
|
5026
|
+
throw new InvalidInputError(
|
|
5027
|
+
`${name}.profileVersion must be ${REFERENCE_PROFILE_VERSION}; received ${describeRejected(read.profileVersion)}`
|
|
5028
|
+
);
|
|
5029
|
+
}
|
|
5030
|
+
const span = assertRecord(
|
|
5031
|
+
read.span,
|
|
5032
|
+
`${name}.span`
|
|
5033
|
+
);
|
|
5034
|
+
const startBeat = assertFiniteNumber(span.startBeat, `${name}.span.startBeat`);
|
|
5035
|
+
const endBeat = assertFiniteNumber(span.endBeat, `${name}.span.endBeat`);
|
|
5036
|
+
assertSpanOrder(startBeat, endBeat, `${name}.span`);
|
|
5037
|
+
assertRange(span.bars, 0, Number.MAX_SAFE_INTEGER, `${name}.span.bars`);
|
|
5038
|
+
assertMeterMap(read.meters, `${name}.meters`);
|
|
5039
|
+
const melody = assertReferenceMelody(read.melody, `${name}.melody`);
|
|
5040
|
+
assertReferenceForm(read.form, `${name}.form`, { startBeat, endBeat }, melody.graph.nodes.length);
|
|
5041
|
+
assertReferenceHarmony(read.harmony, `${name}.harmony`);
|
|
5042
|
+
return read;
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
// src/analyze/reference/measures.ts
|
|
5046
|
+
var PROGRESSION_GAP = 0.75;
|
|
5047
|
+
var SAME_DEGREE_COST = 0.25;
|
|
5048
|
+
var SAME_FUNCTION_COST = 0.5;
|
|
5049
|
+
var HALF_PHRYGIAN_COST = 0.5;
|
|
5050
|
+
var MODE_CHANGE_COST = 0.5;
|
|
5051
|
+
var SHAPE_MISMATCH_COST = 0.25;
|
|
5052
|
+
var REGISTER_SPAN = 24;
|
|
5053
|
+
var PEAK_POSITION_WEIGHT = 0.5;
|
|
5054
|
+
var FAMILY_SIZE_BINS = 5;
|
|
5055
|
+
var DEPTH_BINS = 4;
|
|
5056
|
+
var NAMED_CHORDS = ["Ger", "It", "Fr", "N"];
|
|
5057
|
+
var PITCH_PRESERVING_KINDS = [
|
|
5058
|
+
"repetition",
|
|
5059
|
+
"augmentation",
|
|
5060
|
+
"diminution"
|
|
5061
|
+
];
|
|
5062
|
+
var PITCH_PRESERVING = new Set(PITCH_PRESERVING_KINDS);
|
|
5063
|
+
var CELL_START_PITCH = 60;
|
|
5064
|
+
function whenBoth(hasA, hasB, measure) {
|
|
5065
|
+
if (!hasA && !hasB) {
|
|
5066
|
+
return null;
|
|
5067
|
+
}
|
|
5068
|
+
if (!hasA || !hasB) {
|
|
5069
|
+
return 0;
|
|
5070
|
+
}
|
|
5071
|
+
return measure();
|
|
5072
|
+
}
|
|
5073
|
+
function aligned(a, b, cost, gap = 1) {
|
|
5074
|
+
return distanceToSimilarity(gradedEditDistance(a, b, cost, gap), a.length, b.length);
|
|
5075
|
+
}
|
|
5076
|
+
function alignedSimilarity(a, b, cost, gap = 1) {
|
|
5077
|
+
return whenBoth(a.length > 0, b.length > 0, () => aligned(a, b, cost, gap));
|
|
5078
|
+
}
|
|
5079
|
+
function massOf(values) {
|
|
5080
|
+
let total = 0;
|
|
5081
|
+
for (const value of values) {
|
|
5082
|
+
total += value;
|
|
5083
|
+
}
|
|
5084
|
+
return total;
|
|
5085
|
+
}
|
|
5086
|
+
function intersection(a, b) {
|
|
5087
|
+
const massA = massOf(a);
|
|
5088
|
+
const massB = massOf(b);
|
|
5089
|
+
let shared2 = 0;
|
|
5090
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
5091
|
+
shared2 += Math.min((a[i] ?? 0) / massA, (b[i] ?? 0) / massB);
|
|
5092
|
+
}
|
|
5093
|
+
return Math.min(1, shared2);
|
|
5094
|
+
}
|
|
5095
|
+
function distributionSimilarity(a, b) {
|
|
5096
|
+
return whenBoth(massOf(a) > 0, massOf(b) > 0, () => intersection(a, b));
|
|
5097
|
+
}
|
|
5098
|
+
function ratio(a, b) {
|
|
5099
|
+
const larger = Math.max(a, b);
|
|
5100
|
+
return larger === 0 ? 1 : Math.min(a, b) / larger;
|
|
5101
|
+
}
|
|
5102
|
+
function sectionSequenceSimilarity(a, b) {
|
|
5103
|
+
return alignedSimilarity(a, b, (x, y) => x === y ? 0 : 1);
|
|
5104
|
+
}
|
|
5105
|
+
function phraseLengthSimilarity(a, b) {
|
|
5106
|
+
return alignedSimilarity(a, b, (x, y) => {
|
|
5107
|
+
const longer = Math.max(x, y);
|
|
5108
|
+
return longer === 0 ? 0 : Math.min(Math.abs(x - y) / longer, 1);
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
5111
|
+
function simpleDegreeToken(roman) {
|
|
5112
|
+
for (const name of NAMED_CHORDS) {
|
|
5113
|
+
if (roman.startsWith(name)) {
|
|
5114
|
+
return name;
|
|
5115
|
+
}
|
|
5116
|
+
}
|
|
5117
|
+
const match = roman.match(/^([b#]*)([IV]+)/i);
|
|
5118
|
+
return match ? `${match[1]}${match[2].toUpperCase()}` : roman;
|
|
5119
|
+
}
|
|
5120
|
+
function romanDegreeToken(roman) {
|
|
5121
|
+
const slash = roman.indexOf("/");
|
|
5122
|
+
if (slash < 0) {
|
|
5123
|
+
return simpleDegreeToken(roman);
|
|
5124
|
+
}
|
|
5125
|
+
return `${simpleDegreeToken(roman.slice(0, slash))}/${simpleDegreeToken(roman.slice(slash + 1))}`;
|
|
5126
|
+
}
|
|
5127
|
+
function chordCost(x, y) {
|
|
5128
|
+
if (x.roman === y.roman) {
|
|
5129
|
+
return 0;
|
|
5130
|
+
}
|
|
5131
|
+
if (romanDegreeToken(x.roman) === romanDegreeToken(y.roman)) {
|
|
5132
|
+
return SAME_DEGREE_COST;
|
|
5133
|
+
}
|
|
5134
|
+
return x.function === y.function ? SAME_FUNCTION_COST : 1;
|
|
5135
|
+
}
|
|
5136
|
+
function progressionSimilarity(a, b, budget) {
|
|
5137
|
+
assertGenerationBudget(a.length * b.length, "reference progression alignment", budget);
|
|
5138
|
+
return alignedSimilarity(a, b, chordCost, PROGRESSION_GAP);
|
|
5139
|
+
}
|
|
5140
|
+
function functionMass(chords) {
|
|
5141
|
+
const mass = HARMONIC_FUNCTIONS.map(() => 0);
|
|
5142
|
+
for (const chord of chords) {
|
|
5143
|
+
const bin = HARMONIC_FUNCTIONS.indexOf(chord.function);
|
|
5144
|
+
mass[bin] = (mass[bin] ?? 0) + Math.max(0, chord.endBeat - chord.startBeat);
|
|
5145
|
+
}
|
|
5146
|
+
return mass;
|
|
5147
|
+
}
|
|
5148
|
+
function functionSimilarity(a, b) {
|
|
5149
|
+
return distributionSimilarity(functionMass(a), functionMass(b));
|
|
5150
|
+
}
|
|
5151
|
+
function cadenceSimilarity(a, b) {
|
|
5152
|
+
return alignedSimilarity(a, b, (x, y) => {
|
|
5153
|
+
if (x === y) {
|
|
5154
|
+
return 0;
|
|
5155
|
+
}
|
|
5156
|
+
const halfAndPhrygian = x === "half" && y === "phrygian" || x === "phrygian" && y === "half";
|
|
5157
|
+
return halfAndPhrygian ? HALF_PHRYGIAN_COST : 1;
|
|
5158
|
+
});
|
|
5159
|
+
}
|
|
5160
|
+
function keyTokens(keys) {
|
|
5161
|
+
const home = keys[0]?.scale.rootPc ?? 0;
|
|
5162
|
+
return keys.map((key) => ({
|
|
5163
|
+
distance: ((key.scale.rootPc - home) % 12 + 12) % 12,
|
|
5164
|
+
mask: key.scale.modeMask12
|
|
5165
|
+
}));
|
|
5166
|
+
}
|
|
5167
|
+
function keyPlanSimilarity(a, b) {
|
|
5168
|
+
return alignedSimilarity(keyTokens(a), keyTokens(b), (x, y) => {
|
|
5169
|
+
if (x.distance !== y.distance) {
|
|
5170
|
+
return 1;
|
|
5171
|
+
}
|
|
5172
|
+
return x.mask === y.mask ? 0 : MODE_CHANGE_COST;
|
|
5173
|
+
});
|
|
5174
|
+
}
|
|
5175
|
+
function onsetSimilarity(a, b) {
|
|
5176
|
+
return whenBoth(a.onsets > 0, b.onsets > 0, () => {
|
|
5177
|
+
let weighted = 0;
|
|
5178
|
+
let weight = 0;
|
|
5179
|
+
for (const left of a.barPositions) {
|
|
5180
|
+
for (const right of b.barPositions) {
|
|
5181
|
+
const shared2 = left.ts.numerator === right.ts.numerator && left.ts.denominator === right.ts.denominator && left.onsets > 0 && right.onsets > 0;
|
|
5182
|
+
if (shared2) {
|
|
5183
|
+
const w = left.onsets + right.onsets;
|
|
5184
|
+
weighted += w * intersection(left.shares, right.shares);
|
|
5185
|
+
weight += w;
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
return weight > 0 ? weighted / weight : intersection(a.onsetLevels, b.onsetLevels);
|
|
5190
|
+
});
|
|
5191
|
+
}
|
|
5192
|
+
function densitySimilarity(a, b) {
|
|
5193
|
+
return whenBoth(a.onsets > 0, b.onsets > 0, () => ratio(a.onsetDensity, b.onsetDensity));
|
|
5194
|
+
}
|
|
5195
|
+
function durationSimilarity(a, b) {
|
|
5196
|
+
return whenBoth(
|
|
5197
|
+
a.onsets > 0,
|
|
5198
|
+
b.onsets > 0,
|
|
5199
|
+
() => intersection(a.interOnsetShares, b.interOnsetShares)
|
|
5200
|
+
);
|
|
5201
|
+
}
|
|
5202
|
+
function syncopationSimilarity(a, b) {
|
|
5203
|
+
return whenBoth(a.onsets > 0, b.onsets > 0, () => 1 - Math.abs(a.syncopation - b.syncopation));
|
|
5204
|
+
}
|
|
5205
|
+
function cellNotes(motif) {
|
|
5206
|
+
const notes = [];
|
|
5207
|
+
let low = 0;
|
|
5208
|
+
let high = 0;
|
|
5209
|
+
let offset = 0;
|
|
5210
|
+
for (const interval of motif.intervals) {
|
|
5211
|
+
offset += interval;
|
|
5212
|
+
low = Math.min(low, offset);
|
|
5213
|
+
high = Math.max(high, offset);
|
|
5214
|
+
}
|
|
5215
|
+
let pitch = Math.min(MIDI_MAX - high, Math.max(-low, CELL_START_PITCH));
|
|
5216
|
+
let beat = 0;
|
|
5217
|
+
for (let i = 0; i <= motif.intervals.length; i += 1) {
|
|
5218
|
+
const step = motif.rhythm[i] ?? 1;
|
|
5219
|
+
notes.push({ pitch, startBeat: beat, durationBeat: i < motif.intervals.length ? step : 1 });
|
|
5220
|
+
pitch += motif.intervals[i] ?? 0;
|
|
5221
|
+
beat += step;
|
|
5222
|
+
}
|
|
5223
|
+
return notes;
|
|
5224
|
+
}
|
|
5225
|
+
function bestMatchMean(from, to) {
|
|
5226
|
+
let weighted = 0;
|
|
5227
|
+
let weight = 0;
|
|
5228
|
+
for (const motif of from) {
|
|
5229
|
+
let best = 0;
|
|
5230
|
+
for (const other of to) {
|
|
5231
|
+
best = Math.max(best, compareMelodies(motif.cell, other.cell).pitchSimilarity);
|
|
5232
|
+
}
|
|
5233
|
+
weighted += motif.occurrences * best;
|
|
5234
|
+
weight += motif.occurrences;
|
|
5235
|
+
}
|
|
5236
|
+
return weighted / weight;
|
|
5237
|
+
}
|
|
5238
|
+
function surfaceSimilarity(a, b) {
|
|
5239
|
+
return whenBoth(a.length > 0, b.length > 0, () => {
|
|
5240
|
+
const left = a.map((motif) => ({ cell: cellNotes(motif), occurrences: motif.occurrences }));
|
|
5241
|
+
const right = b.map((motif) => ({ cell: cellNotes(motif), occurrences: motif.occurrences }));
|
|
5242
|
+
return (bestMatchMean(left, right) + bestMatchMean(right, left)) / 2;
|
|
5243
|
+
});
|
|
5244
|
+
}
|
|
5245
|
+
function coverageOf(graph, spanBeats) {
|
|
5246
|
+
if (!(spanBeats > 0) || graph.nodes.length === 0) {
|
|
5247
|
+
return 0;
|
|
5248
|
+
}
|
|
5249
|
+
const spans = graph.nodes.map((node) => [node.startBeat, node.endBeat]).sort((x, y) => x[0] - y[0] || x[1] - y[1]);
|
|
5250
|
+
let covered = 0;
|
|
5251
|
+
let [runStart, runEnd] = spans[0];
|
|
5252
|
+
for (const [start, end] of spans) {
|
|
5253
|
+
if (start > runEnd) {
|
|
5254
|
+
covered += runEnd - runStart;
|
|
5255
|
+
runStart = start;
|
|
5256
|
+
runEnd = end;
|
|
5257
|
+
} else {
|
|
5258
|
+
runEnd = Math.max(runEnd, end);
|
|
5259
|
+
}
|
|
5260
|
+
}
|
|
5261
|
+
covered += runEnd - runStart;
|
|
5262
|
+
return Math.min(1, covered / spanBeats);
|
|
5263
|
+
}
|
|
5264
|
+
function bump(histogram, bin) {
|
|
5265
|
+
histogram[bin] = (histogram[bin] ?? 0) + 1;
|
|
5266
|
+
}
|
|
5267
|
+
function summarise({ graph, spanBeats }) {
|
|
5268
|
+
const kinds = new Array(MOTIF_RELATION_KINDS.length + 1).fill(0);
|
|
5269
|
+
const parentEdge = /* @__PURE__ */ new Map();
|
|
5270
|
+
for (const edge of graph.edges) {
|
|
5271
|
+
const kind = edge.relation?.kind;
|
|
5272
|
+
bump(
|
|
5273
|
+
kinds,
|
|
5274
|
+
kind === void 0 ? MOTIF_RELATION_KINDS.length : MOTIF_RELATION_KINDS.indexOf(kind)
|
|
5275
|
+
);
|
|
5276
|
+
parentEdge.set(edge.to, edge);
|
|
5277
|
+
}
|
|
5278
|
+
const depth = new Array(graph.nodes.length).fill(0);
|
|
5279
|
+
const root = graph.nodes.map((_, i) => i);
|
|
5280
|
+
for (let node = 0; node < graph.nodes.length; node += 1) {
|
|
5281
|
+
const edge = parentEdge.get(node);
|
|
5282
|
+
if (edge !== void 0) {
|
|
5283
|
+
const kind = edge.relation?.kind;
|
|
5284
|
+
const keepsPitch = kind !== void 0 && PITCH_PRESERVING.has(kind);
|
|
5285
|
+
depth[node] = (depth[edge.from] ?? 0) + (keepsPitch ? 0 : 1);
|
|
5286
|
+
root[node] = root[edge.from] ?? edge.from;
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
const familySize = /* @__PURE__ */ new Map();
|
|
5290
|
+
for (const r of root) {
|
|
5291
|
+
familySize.set(r, (familySize.get(r) ?? 0) + 1);
|
|
5292
|
+
}
|
|
5293
|
+
const families = new Array(FAMILY_SIZE_BINS).fill(0);
|
|
5294
|
+
for (const size of familySize.values()) {
|
|
5295
|
+
bump(families, Math.min(size, FAMILY_SIZE_BINS) - 1);
|
|
5296
|
+
}
|
|
5297
|
+
const depths = new Array(DEPTH_BINS).fill(0);
|
|
5298
|
+
for (const d of depth) {
|
|
5299
|
+
bump(depths, Math.min(d, DEPTH_BINS - 1));
|
|
5300
|
+
}
|
|
5301
|
+
return { kinds, families, depths, coverage: coverageOf(graph, spanBeats) };
|
|
5302
|
+
}
|
|
5303
|
+
function motifStructureSimilarity(a, b) {
|
|
5304
|
+
const left = summarise(a);
|
|
5305
|
+
const right = summarise(b);
|
|
5306
|
+
const terms = [
|
|
5307
|
+
distributionSimilarity(left.kinds, right.kinds),
|
|
5308
|
+
distributionSimilarity(left.families, right.families),
|
|
5309
|
+
distributionSimilarity(left.depths, right.depths),
|
|
5310
|
+
whenBoth(
|
|
5311
|
+
left.coverage > 0,
|
|
5312
|
+
right.coverage > 0,
|
|
5313
|
+
() => 1 - Math.abs(left.coverage - right.coverage)
|
|
5314
|
+
)
|
|
5315
|
+
].filter((term) => term !== null);
|
|
5316
|
+
return terms.length === 0 ? null : massOf(terms) / terms.length;
|
|
5317
|
+
}
|
|
5318
|
+
function nullableCost(cost) {
|
|
5319
|
+
return (x, y) => {
|
|
5320
|
+
if (x === null || y === null) {
|
|
5321
|
+
return x === y ? 0 : 1;
|
|
5322
|
+
}
|
|
5323
|
+
return cost(x, y);
|
|
5324
|
+
};
|
|
5325
|
+
}
|
|
5326
|
+
function phraseSimilarity(a, b, cost) {
|
|
5327
|
+
const hasA = a.some((phrase) => phrase !== null);
|
|
5328
|
+
const hasB = b.some((phrase) => phrase !== null);
|
|
5329
|
+
return whenBoth(hasA, hasB, () => aligned(a, b, nullableCost(cost)));
|
|
5330
|
+
}
|
|
5331
|
+
function normalisedOutline(phrase) {
|
|
5332
|
+
const mean = massOf(phrase.outline) / (phrase.outline.length || 1);
|
|
5333
|
+
const range = phrase.high - phrase.low || 1;
|
|
5334
|
+
return phrase.outline.map((point) => (point - mean) / range);
|
|
5335
|
+
}
|
|
5336
|
+
function contourCost(x, y) {
|
|
5337
|
+
const left = normalisedOutline(x);
|
|
5338
|
+
const right = normalisedOutline(y);
|
|
5339
|
+
let difference = 0;
|
|
5340
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
5341
|
+
difference += Math.abs((left[i] ?? 0) - (right[i] ?? 0));
|
|
5342
|
+
}
|
|
5343
|
+
const outlineCost = Math.min(1, difference / (left.length || 1));
|
|
5344
|
+
return Math.min(1, outlineCost + (x.shape === y.shape ? 0 : SHAPE_MISMATCH_COST));
|
|
5345
|
+
}
|
|
5346
|
+
function contourSimilarity(a, b) {
|
|
5347
|
+
return phraseSimilarity(a, b, contourCost);
|
|
5348
|
+
}
|
|
5349
|
+
function registerSimilarity(a, b) {
|
|
5350
|
+
const meanA = a.mean ?? 0;
|
|
5351
|
+
const meanB = b.mean ?? 0;
|
|
5352
|
+
return phraseSimilarity(a.phrases, b.phrases, (x, y) => {
|
|
5353
|
+
const offset = Math.abs(x.mean - meanA - (y.mean - meanB));
|
|
5354
|
+
const range = Math.abs(x.high - x.low - (y.high - y.low));
|
|
5355
|
+
const peak = Math.abs(x.peakPosition - y.peakPosition);
|
|
5356
|
+
return Math.min(1, (offset + range) / REGISTER_SPAN + PEAK_POSITION_WEIGHT * peak);
|
|
5357
|
+
});
|
|
5358
|
+
}
|
|
5359
|
+
|
|
5360
|
+
// src/analyze/reference/internal.ts
|
|
5361
|
+
function weightedMean(notes) {
|
|
5362
|
+
let weighted = 0;
|
|
5363
|
+
let total = 0;
|
|
5364
|
+
for (const note of notes) {
|
|
5365
|
+
weighted += note.pitch * note.durationBeat;
|
|
5366
|
+
total += note.durationBeat;
|
|
5367
|
+
}
|
|
5368
|
+
return total > 0 ? weighted / total : 0;
|
|
5369
|
+
}
|
|
5370
|
+
function phraseMelody(line, startBeat, endBeat, lineMean, meters, budget) {
|
|
5371
|
+
const inside = line.filter((note) => note.startBeat >= startBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS).map((note) => ({
|
|
5372
|
+
...note,
|
|
5373
|
+
durationBeat: Math.min(note.startBeat + note.durationBeat, endBeat) - note.startBeat
|
|
5374
|
+
}));
|
|
5375
|
+
const first = inside[0];
|
|
5376
|
+
if (first === void 0 || inside.length < 2) {
|
|
5377
|
+
return null;
|
|
5378
|
+
}
|
|
5379
|
+
let low = first.pitch;
|
|
5380
|
+
let peak = first;
|
|
5381
|
+
for (const note of inside) {
|
|
5382
|
+
low = Math.min(low, note.pitch);
|
|
5383
|
+
if (note.pitch > peak.pitch) {
|
|
5384
|
+
peak = note;
|
|
5385
|
+
}
|
|
5386
|
+
}
|
|
5387
|
+
const length = endBeat - startBeat;
|
|
5388
|
+
const outline = [];
|
|
5389
|
+
for (let k = 0; k < PHRASE_OUTLINE_POINTS; k += 1) {
|
|
5390
|
+
const at = startBeat + (k + 0.5) * length / PHRASE_OUTLINE_POINTS;
|
|
5391
|
+
let held = first;
|
|
5392
|
+
for (const note of inside) {
|
|
5393
|
+
if (note.startBeat > at + BEAT_EPS) {
|
|
5394
|
+
break;
|
|
5395
|
+
}
|
|
5396
|
+
held = note;
|
|
5397
|
+
}
|
|
5398
|
+
outline.push(held.pitch - lineMean);
|
|
5399
|
+
}
|
|
5400
|
+
const rhythm = rhythmOver(inside, { meters, totalBeats: endBeat, budget }, startBeat);
|
|
5401
|
+
return {
|
|
5402
|
+
shape: melodicContour(inside).shape,
|
|
5403
|
+
low,
|
|
5404
|
+
high: peak.pitch,
|
|
5405
|
+
mean: weightedMean(inside),
|
|
5406
|
+
peakPitch: peak.pitch,
|
|
5407
|
+
peakPosition: Math.min(1, Math.max(0, (peak.startBeat - startBeat) / length)),
|
|
5408
|
+
outline,
|
|
5409
|
+
onsetDensity: rhythm.onsetDensity,
|
|
5410
|
+
restRatio: rhythm.restRatio
|
|
5411
|
+
};
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
export {
|
|
5415
|
+
HUMANIZE_ADJACENCY,
|
|
5416
|
+
sameInstant,
|
|
5417
|
+
adjacent,
|
|
5418
|
+
hasEnded,
|
|
5419
|
+
windowWeights,
|
|
5420
|
+
MODAL_SCALE_NAMES,
|
|
5421
|
+
detectChord,
|
|
5422
|
+
detectChordBest,
|
|
5423
|
+
detectKey,
|
|
5424
|
+
detectKeyFromNotes,
|
|
5425
|
+
detectKeyBest,
|
|
5426
|
+
barSpanOf,
|
|
5427
|
+
clamp01,
|
|
5428
|
+
floorMod,
|
|
5429
|
+
firstSoundingBeat,
|
|
5430
|
+
resolveSpanEnd,
|
|
5431
|
+
lastBarOf,
|
|
5432
|
+
sliceBars,
|
|
5433
|
+
weightSimilarity,
|
|
5434
|
+
harmonicNovelty,
|
|
5435
|
+
combineEvidence,
|
|
5436
|
+
augmentedSixthChord,
|
|
5437
|
+
augmentedSixthKind,
|
|
5438
|
+
augmentedSixthFromPitchClasses,
|
|
5439
|
+
spellAugmentedSixth,
|
|
5440
|
+
CADENCE_TYPES,
|
|
5441
|
+
detectCadence,
|
|
5442
|
+
isBorrowedChord,
|
|
5443
|
+
borrowedSource,
|
|
5444
|
+
romanToChord,
|
|
5445
|
+
chordToRoman,
|
|
5446
|
+
explainRoman,
|
|
5447
|
+
HARMONIC_FUNCTIONS,
|
|
5448
|
+
isMinorKey,
|
|
5449
|
+
functionOf,
|
|
5450
|
+
isDiatonic,
|
|
5451
|
+
parallelKey,
|
|
5452
|
+
analyzeChord,
|
|
5453
|
+
secondaryDominant,
|
|
5454
|
+
secondaryDominantOf,
|
|
5455
|
+
pivotChords,
|
|
3310
5456
|
gridForNotes,
|
|
5457
|
+
latestSoundingEnd,
|
|
3311
5458
|
barGridStart,
|
|
3312
5459
|
keyTimelineFromNotes,
|
|
3313
5460
|
detectModulations,
|
|
3314
5461
|
attachPivots,
|
|
3315
5462
|
keyLookup,
|
|
3316
5463
|
prevailingKeyOf,
|
|
3317
|
-
keyScaleAt,
|
|
3318
5464
|
toVoiceNotes,
|
|
3319
5465
|
analyzeVoice,
|
|
3320
5466
|
assertChordTimeline,
|
|
5467
|
+
assertSpansInOrder,
|
|
5468
|
+
statedKeyRegions,
|
|
3321
5469
|
chordTimelineFromChords,
|
|
3322
5470
|
chordAtBeat,
|
|
3323
5471
|
segmentStartingAt,
|
|
3324
5472
|
chordTimelineFromNotes,
|
|
3325
5473
|
dirtySlotsFor,
|
|
3326
5474
|
analyzeTimeline,
|
|
3327
|
-
detectCadences
|
|
5475
|
+
detectCadences,
|
|
5476
|
+
orderedNotes,
|
|
5477
|
+
MELODIC_CONTOUR_SHAPES,
|
|
5478
|
+
melodicContour,
|
|
5479
|
+
extractMotifs,
|
|
5480
|
+
motifFromNotes,
|
|
5481
|
+
MOTIF_RELATION_KINDS,
|
|
5482
|
+
relateMotifs,
|
|
5483
|
+
compareMelodies,
|
|
5484
|
+
melodicSimilarity,
|
|
5485
|
+
DEFAULT_VARIATION_THRESHOLD,
|
|
5486
|
+
motifGraph,
|
|
5487
|
+
REDUCTION_LEVELS,
|
|
5488
|
+
reduceProgression,
|
|
5489
|
+
REFERENCE_PROFILE_VERSION,
|
|
5490
|
+
sectionSequenceSimilarity,
|
|
5491
|
+
phraseLengthSimilarity,
|
|
5492
|
+
progressionSimilarity,
|
|
5493
|
+
functionSimilarity,
|
|
5494
|
+
cadenceSimilarity,
|
|
5495
|
+
keyPlanSimilarity,
|
|
5496
|
+
onsetSimilarity,
|
|
5497
|
+
densitySimilarity,
|
|
5498
|
+
durationSimilarity,
|
|
5499
|
+
syncopationSimilarity,
|
|
5500
|
+
surfaceSimilarity,
|
|
5501
|
+
motifStructureSimilarity,
|
|
5502
|
+
contourSimilarity,
|
|
5503
|
+
registerSimilarity,
|
|
5504
|
+
rhythmLevel,
|
|
5505
|
+
ioiBinIndex,
|
|
5506
|
+
RHYTHM_IOI_BINS,
|
|
5507
|
+
analyzeRhythm,
|
|
5508
|
+
rhythmOver,
|
|
5509
|
+
assertReferenceProfile,
|
|
5510
|
+
weightedMean,
|
|
5511
|
+
phraseMelody
|
|
3328
5512
|
};
|
|
3329
|
-
//# sourceMappingURL=chunk-
|
|
5513
|
+
//# sourceMappingURL=chunk-FJFULJZE.js.map
|