@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.
Files changed (131) hide show
  1. package/NOTICE +63 -0
  2. package/README.md +7 -1
  3. package/README_ja.md +17 -11
  4. package/dist/analyze/index.cjs +31 -7
  5. package/dist/analyze/index.cjs.map +1 -1
  6. package/dist/analyze/index.d.cts +161 -13
  7. package/dist/analyze/index.d.ts +161 -13
  8. package/dist/analyze/index.js +37 -13
  9. package/dist/{chunk-NB25P3EH.js → chunk-2G5MCGYU.js} +33 -11
  10. package/dist/chunk-2G5MCGYU.js.map +1 -0
  11. package/dist/{chunk-NM7UAMC3.cjs → chunk-3FDBMIPR.cjs} +64 -64
  12. package/dist/chunk-3FDBMIPR.cjs.map +1 -0
  13. package/dist/{chunk-HPVSH7SF.cjs → chunk-3MDOEFIQ.cjs} +618 -954
  14. package/dist/chunk-3MDOEFIQ.cjs.map +1 -0
  15. package/dist/{chunk-FBH75DAY.js → chunk-5MWRBYAT.js} +560 -159
  16. package/dist/chunk-5MWRBYAT.js.map +1 -0
  17. package/dist/{chunk-J7FHVZII.cjs → chunk-6TTBEON3.cjs} +3074 -740
  18. package/dist/chunk-6TTBEON3.cjs.map +1 -0
  19. package/dist/{chunk-UMWZ4ZET.js → chunk-7NQ6AL3H.js} +4 -4
  20. package/dist/chunk-7NQ6AL3H.js.map +1 -0
  21. package/dist/{chunk-HWHGOVO6.cjs → chunk-E3QO3UET.cjs} +31 -23
  22. package/dist/chunk-E3QO3UET.cjs.map +1 -0
  23. package/dist/{chunk-H2KJHBW4.js → chunk-FJFULJZE.js} +2375 -191
  24. package/dist/chunk-FJFULJZE.js.map +1 -0
  25. package/dist/{chunk-Q6LFSAKV.js → chunk-FK3DGAIZ.js} +2657 -323
  26. package/dist/chunk-FK3DGAIZ.js.map +1 -0
  27. package/dist/chunk-GEDTJHAC.cjs.map +1 -1
  28. package/dist/{chunk-RFIBF2RR.js → chunk-H7FVMBUC.js} +138 -65
  29. package/dist/chunk-H7FVMBUC.js.map +1 -0
  30. package/dist/{chunk-DSMJRXCZ.cjs → chunk-HYSVSFNI.cjs} +931 -530
  31. package/dist/chunk-HYSVSFNI.cjs.map +1 -0
  32. package/dist/{chunk-35NBV2FT.cjs → chunk-KMYGRFZ6.cjs} +47 -24
  33. package/dist/chunk-KMYGRFZ6.cjs.map +1 -0
  34. package/dist/chunk-LQZQDBCD.cjs +5513 -0
  35. package/dist/chunk-LQZQDBCD.cjs.map +1 -0
  36. package/dist/{chunk-PM4XJE2Y.cjs → chunk-P3CPXAJ5.cjs} +76 -54
  37. package/dist/chunk-P3CPXAJ5.cjs.map +1 -0
  38. package/dist/{chunk-S5BKZZGM.cjs → chunk-S3QELJUE.cjs} +72 -56
  39. package/dist/chunk-S3QELJUE.cjs.map +1 -0
  40. package/dist/{chunk-PUKJP2Z5.js → chunk-TXFWSU5D.js} +31 -8
  41. package/dist/chunk-TXFWSU5D.js.map +1 -0
  42. package/dist/{chunk-76ZBTRG2.cjs → chunk-U4EQTDGN.cjs} +258 -218
  43. package/dist/chunk-U4EQTDGN.cjs.map +1 -0
  44. package/dist/chunk-UGLFDG7L.cjs.map +1 -1
  45. package/dist/{chunk-HYJJ2V2I.js → chunk-UTE5A36R.js} +483 -819
  46. package/dist/chunk-UTE5A36R.js.map +1 -0
  47. package/dist/{chunk-NFJ4P6ZZ.js → chunk-UZKQISRF.js} +17 -9
  48. package/dist/chunk-UZKQISRF.js.map +1 -0
  49. package/dist/{chunk-7KG3RTYS.js → chunk-VL5NXVCN.js} +281 -155
  50. package/dist/chunk-VL5NXVCN.js.map +1 -0
  51. package/dist/{chunk-CGBADIO5.cjs → chunk-WOMCXN2W.cjs} +328 -202
  52. package/dist/chunk-WOMCXN2W.cjs.map +1 -0
  53. package/dist/{chunk-3R3QVKY5.cjs → chunk-YDGMWH43.cjs} +397 -324
  54. package/dist/chunk-YDGMWH43.cjs.map +1 -0
  55. package/dist/{chunk-AGOVW7U6.js → chunk-YYVXRV3V.js} +72 -56
  56. package/dist/chunk-YYVXRV3V.js.map +1 -0
  57. package/dist/{chunk-M5RYMAFI.js → chunk-ZLNSH4UL.js} +71 -31
  58. package/dist/chunk-ZLNSH4UL.js.map +1 -0
  59. package/dist/{coerce-DRERzdve.d.cts → coerce-CUhHc3Tu.d.ts} +16 -8
  60. package/dist/{coerce-DP0vYu-x.d.ts → coerce-Ytt2Eiq9.d.cts} +16 -8
  61. package/dist/core/index.cjs +5 -5
  62. package/dist/core/index.cjs.map +1 -1
  63. package/dist/core/index.d.cts +9 -8
  64. package/dist/core/index.d.ts +9 -8
  65. package/dist/core/index.js +4 -4
  66. package/dist/generate/index.cjs +20 -8
  67. package/dist/generate/index.cjs.map +1 -1
  68. package/dist/generate/index.d.cts +105 -17
  69. package/dist/generate/index.d.ts +105 -17
  70. package/dist/generate/index.js +19 -7
  71. package/dist/{index-CL4b4gUQ.d.ts → index-5VJgKz_Q.d.cts} +6 -2
  72. package/dist/{index-B9QLziDe.d.cts → index-BLuDesVG.d.ts} +20 -13
  73. package/dist/{index-BHIUyjhZ.d.ts → index-BV6APVa6.d.cts} +20 -13
  74. package/dist/{index-BMweUlm5.d.cts → index-Bf0Sv8a9.d.ts} +6 -2
  75. package/dist/{index-Dw2DP7vm.d.ts → index-BkT21EMv.d.cts} +3 -2
  76. package/dist/{index-BL5N2Oyz.d.cts → index-C7KCaTRz.d.ts} +3 -2
  77. package/dist/{index-Cy15oTvh.d.ts → index-C9MYbuKS.d.cts} +7 -3
  78. package/dist/{index-Cy15oTvh.d.cts → index-C9MYbuKS.d.ts} +7 -3
  79. package/dist/{index-BNSM-DSk.d.cts → index-DVInzCDS.d.cts} +533 -28
  80. package/dist/{index-9eoQs2C1.d.ts → index-Dg-SAfhz.d.ts} +6 -4
  81. package/dist/{index-BJgP97N1.d.ts → index-jgeug9EA.d.ts} +533 -28
  82. package/dist/{index-cHvbYgJI.d.cts → index-pQihL4z7.d.cts} +6 -4
  83. package/dist/index.cjs +49 -13
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +13 -13
  86. package/dist/index.d.ts +13 -13
  87. package/dist/index.js +55 -19
  88. package/dist/{masks-Cc7-MzzY.d.ts → masks-BnzXnBj0.d.cts} +3 -2
  89. package/dist/{masks-pADfTOiD.d.cts → masks-tycmkrgs.d.ts} +3 -2
  90. package/dist/model/index.cjs +12 -12
  91. package/dist/model/index.cjs.map +1 -1
  92. package/dist/model/index.d.cts +259 -48
  93. package/dist/model/index.d.ts +259 -48
  94. package/dist/model/index.js +11 -11
  95. package/dist/{positional-DJgs6ltD.d.ts → positional-CscpxtlE.d.cts} +3 -2
  96. package/dist/{positional-DMZer-FM.d.cts → positional-DLnfUgqG.d.ts} +3 -2
  97. package/dist/{index-BRMbPkHE.d.ts → profile-B4WqcM4H.d.ts} +85 -610
  98. package/dist/{index-DGCNKAUr.d.cts → profile-BJd1XkPB.d.cts} +85 -610
  99. package/dist/theory/index.cjs +5 -5
  100. package/dist/theory/index.cjs.map +1 -1
  101. package/dist/theory/index.d.cts +18 -14
  102. package/dist/theory/index.d.ts +18 -14
  103. package/dist/theory/index.js +4 -4
  104. package/dist/{index-CJBBiAmU.d.ts → types-7mGFwvMm.d.ts} +1210 -31
  105. package/dist/{index-BBWNZS0f.d.cts → types-_ofpb2be.d.cts} +1210 -31
  106. package/package.json +13 -13
  107. package/dist/chunk-35NBV2FT.cjs.map +0 -1
  108. package/dist/chunk-3R3QVKY5.cjs.map +0 -1
  109. package/dist/chunk-76ZBTRG2.cjs.map +0 -1
  110. package/dist/chunk-7KG3RTYS.js.map +0 -1
  111. package/dist/chunk-AGOVW7U6.js.map +0 -1
  112. package/dist/chunk-CGBADIO5.cjs.map +0 -1
  113. package/dist/chunk-DSMJRXCZ.cjs.map +0 -1
  114. package/dist/chunk-FBH75DAY.js.map +0 -1
  115. package/dist/chunk-H2KJHBW4.js.map +0 -1
  116. package/dist/chunk-HPVSH7SF.cjs.map +0 -1
  117. package/dist/chunk-HWHGOVO6.cjs.map +0 -1
  118. package/dist/chunk-HYJJ2V2I.js.map +0 -1
  119. package/dist/chunk-J7FHVZII.cjs.map +0 -1
  120. package/dist/chunk-M5RYMAFI.js.map +0 -1
  121. package/dist/chunk-NB25P3EH.js.map +0 -1
  122. package/dist/chunk-NFJ4P6ZZ.js.map +0 -1
  123. package/dist/chunk-NM7UAMC3.cjs.map +0 -1
  124. package/dist/chunk-PM4XJE2Y.cjs.map +0 -1
  125. package/dist/chunk-PUKJP2Z5.js.map +0 -1
  126. package/dist/chunk-Q6LFSAKV.js.map +0 -1
  127. package/dist/chunk-RFIBF2RR.js.map +0 -1
  128. package/dist/chunk-S5BKZZGM.cjs.map +0 -1
  129. package/dist/chunk-SGLEQVJH.cjs +0 -3329
  130. package/dist/chunk-SGLEQVJH.cjs.map +0 -1
  131. package/dist/chunk-UMWZ4ZET.js.map +0 -1
@@ -1,47 +1,72 @@
1
1
  import {
2
- HUMANIZE_ADJACENCY,
2
+ REFERENCE_PROFILE_VERSION,
3
3
  adjacent,
4
+ analyzeRhythm,
4
5
  analyzeTimeline,
5
6
  analyzeVoice,
6
7
  assertChordTimeline,
8
+ assertReferenceProfile,
9
+ assertSpansInOrder,
7
10
  attachPivots,
8
11
  barGridStart,
12
+ barSpanOf,
13
+ cadenceSimilarity,
9
14
  chordTimelineFromNotes,
15
+ chordToRoman,
10
16
  clamp01,
11
17
  combineEvidence,
12
- detectCadence,
18
+ contourSimilarity,
19
+ densitySimilarity,
13
20
  detectCadences,
21
+ detectModulations,
14
22
  dirtySlotsFor,
23
+ durationSimilarity,
24
+ extractMotifs,
15
25
  firstSoundingBeat,
16
26
  floorMod,
17
27
  functionOf,
28
+ functionSimilarity,
18
29
  gridForNotes,
19
30
  harmonicNovelty,
20
31
  hasEnded,
21
- isAppliedDominant,
22
- isCadentialSixFour,
23
32
  keyLookup,
24
- keyScaleAt,
33
+ keyPlanSimilarity,
25
34
  keyTimelineFromNotes,
26
35
  lastBarOf,
36
+ latestSoundingEnd,
37
+ melodicContour,
38
+ melodicSimilarity,
39
+ motifGraph,
40
+ motifStructureSimilarity,
41
+ onsetSimilarity,
42
+ orderedNotes,
43
+ phraseLengthSimilarity,
44
+ phraseMelody,
27
45
  prevailingKeyOf,
46
+ progressionSimilarity,
47
+ reduceProgression,
48
+ registerSimilarity,
49
+ resolveSpanEnd,
50
+ rhythmOver,
28
51
  sameInstant,
52
+ sectionSequenceSimilarity,
29
53
  segmentStartingAt,
30
54
  sliceBars,
55
+ statedKeyRegions,
56
+ surfaceSimilarity,
57
+ syncopationSimilarity,
31
58
  weightSimilarity,
59
+ weightedMean,
32
60
  windowWeights
33
- } from "./chunk-H2KJHBW4.js";
61
+ } from "./chunk-FJFULJZE.js";
34
62
  import {
35
63
  PROFILE_WEIGHTS,
36
64
  chordPitchClasses,
37
65
  evaluateSafety,
38
- isDominantChordOf,
39
- keySignatureFifths,
40
66
  majorKey,
41
67
  resolveKey,
42
- scaleOf,
43
- scaleTonesInDegreeOrder
44
- } from "./chunk-RFIBF2RR.js";
68
+ scaleOf
69
+ } from "./chunk-H7FVMBUC.js";
45
70
  import {
46
71
  BEAT_EPS,
47
72
  InvalidInputError,
@@ -61,11 +86,9 @@ import {
61
86
  beatsPerBarAt,
62
87
  describeRejected,
63
88
  isStrongBeat,
64
- midiToNote,
65
89
  pitchClassOf,
66
- resolveMeters,
67
- spelledInterval
68
- } from "./chunk-AGOVW7U6.js";
90
+ resolveMeters
91
+ } from "./chunk-YYVXRV3V.js";
69
92
 
70
93
  // src/analyze/arrange/internal.ts
71
94
  var MAX_LANE_LEAP = 24;
@@ -98,7 +121,7 @@ function assertTrackNotes(tracks, options) {
98
121
  const notes = tracks[index]?.notes;
99
122
  if (!Array.isArray(notes)) {
100
123
  throw new InvalidInputError(
101
- `tracks[${index}].notes must be an array; received ${typeof notes}`
124
+ `tracks[${index}].notes must be an array; received ${describeRejected(notes)}`
102
125
  );
103
126
  }
104
127
  assertNoteEvents(notes, `tracks[${index}].notes`, options);
@@ -136,6 +159,7 @@ function assertGivenKeys(keys, name) {
136
159
  }
137
160
  resolveKey(region.key);
138
161
  }
162
+ assertSpansInOrder(keys, name);
139
163
  }
140
164
  function harmonyTrackSet(harmonyTracks, tracks, name = "harmonyTracks") {
141
165
  if (harmonyTracks === void 0) {
@@ -321,7 +345,12 @@ function lastStartingAtOrBefore(sounding, beat) {
321
345
  function prepareTracks(tracks) {
322
346
  let nextId = 0;
323
347
  return tracks.map((track, index) => {
324
- const ordered = track.notes.map((note, originalIndex) => ({ note, originalIndex })).filter(({ note }) => note.durationBeat > 0).sort((a, b) => a.note.startBeat - b.note.startBeat || a.note.pitch - b.note.pitch).map(({ note, originalIndex }) => ({ ...note, id: nextId++, originalIndex }));
348
+ const ordered = track.notes.map((note, originalIndex) => ({ note, originalIndex })).filter(({ note }) => note.durationBeat > 0).sort((a, b) => a.note.startBeat - b.note.startBeat || a.note.pitch - b.note.pitch).map(({ note, originalIndex }) => ({
349
+ ...note,
350
+ pitch: note.pitch === 0 ? 0 : note.pitch,
351
+ id: nextId++,
352
+ originalIndex
353
+ }));
325
354
  const voices = splitIntoSubVoices(ordered).map((voice) => {
326
355
  const sounding = voice.map((note, i) => {
327
356
  const prev = i > 0 ? voice[i - 1] : void 0;
@@ -362,15 +391,16 @@ function withGivenKeys(result, given) {
362
391
  return { ...result, keys: given, prevailingKey: prevailingKeyOf(given) ?? result.prevailingKey };
363
392
  }
364
393
  function callerTimeline(timeline, key, given, pooled, meters, budget) {
365
- const chartEnd = timeline.segments.reduce((end, segment) => Math.max(end, segment.endBeat), 0);
366
- const soundingEnd = pooled.reduce(
367
- (end, note) => Math.max(end, note.startBeat + note.durationBeat),
368
- 0
394
+ const chartEnd = timeline.segments.reduce(
395
+ (end, segment) => Math.max(end, segment.endBeat),
396
+ Number.NEGATIVE_INFINITY
369
397
  );
370
- const totalBeats = Math.max(chartEnd, soundingEnd);
398
+ const soundingEnd = latestSoundingEnd(pooled, Number.NEGATIVE_INFINITY);
399
+ const spanEnd = Math.max(chartEnd, soundingEnd);
400
+ const totalBeats = Number.isFinite(spanEnd) ? spanEnd : 0;
371
401
  const { startBeat: musicStart } = gridForNotes(pooled, beatsPerBarAt(0, meters));
372
402
  const keys = given ?? attachPivots(
373
- key !== void 0 ? [{ startBeat: musicStart, endBeat: totalBeats, key: resolveKey(key), confidence: 1 }] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }),
403
+ key !== void 0 ? statedKeyRegions(resolveKey(key), musicStart, totalBeats) : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }),
374
404
  timeline.segments
375
405
  );
376
406
  return {
@@ -721,7 +751,7 @@ function sessionOf(tracks, opts, analysis, evidence) {
721
751
  }
722
752
  if (!Array.isArray(edit.notes)) {
723
753
  throw new InvalidInputError(
724
- `edits[${index}].notes must be an array; received ${typeof edit.notes}`
754
+ `edits[${index}].notes must be an array; received ${describeRejected(edit.notes)}`
725
755
  );
726
756
  }
727
757
  assertNoteEvents(edit.notes, `edits[${index}].notes`, {
@@ -756,6 +786,7 @@ var FUNCTION_WEIGHT = 0.5;
756
786
  var DISSONANCE_WEIGHT = 0.35;
757
787
  var SPAN_WEIGHT = 0.15;
758
788
  var SPAN_SATURATION = 24;
789
+ var MAX_DISTINCT_PITCHES = 128;
759
790
  function clamp012(value) {
760
791
  return Math.min(1, Math.max(0, value));
761
792
  }
@@ -787,19 +818,14 @@ function tensionCurve(tracks, opts = {}) {
787
818
  const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks);
788
819
  const pooled = poolNotes(tracks, harmonyTracks);
789
820
  const all = poolNotes(tracks);
790
- const totalBeats = all.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
821
+ const totalBeats = latestSoundingEnd(all);
791
822
  const { origin: sampleStart, startBeat: musicStart } = gridForNotes(all, step);
792
- const suppliedKeys = opts.timeline === void 0 ? void 0 : opts.keys ?? (opts.key !== void 0 ? [
793
- {
794
- // The one region a named key holds starts where the music does,
795
- // as the searched regions and the timeline's own key window do:
796
- // a key cannot be in force over beats nothing sounds in.
797
- startBeat: musicStart,
798
- endBeat: totalBeats,
799
- key: resolveKey(opts.key),
800
- confidence: 1
801
- }
802
- ] : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }));
823
+ const suppliedKeys = opts.timeline === void 0 ? void 0 : opts.keys ?? (opts.key !== void 0 ? (
824
+ // The one region a named key holds starts where the music does, as
825
+ // the searched regions and the timeline's own key window do: a key
826
+ // cannot be in force over beats nothing sounds in.
827
+ statedKeyRegions(resolveKey(opts.key), musicStart, totalBeats)
828
+ ) : keyTimelineFromNotes(pooled, { meters, totalBeats, budget }));
803
829
  const { timeline, keys, prevailingKey } = opts.timeline === void 0 || suppliedKeys === void 0 ? chordTimelineFromNotes(pooled, {
804
830
  key: opts.key,
805
831
  meters,
@@ -819,7 +845,7 @@ function tensionCurve(tracks, opts = {}) {
819
845
  assertGenerationBudget(sampleCount, "tension samples", budget);
820
846
  const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
821
847
  assertGenerationBudget(
822
- sampleCount * Math.min(128, voiceCount) * voiceCount,
848
+ sampleCount * Math.min(MAX_DISTINCT_PITCHES, voiceCount) * voiceCount,
823
849
  "tension sample-voice comparisons",
824
850
  budget
825
851
  );
@@ -827,9 +853,8 @@ function tensionCurve(tracks, opts = {}) {
827
853
  const beat = i === 0 ? Math.max(sampleStart, musicStart) : sampleStart + i * step;
828
854
  points.push({
829
855
  beat,
830
- // The pitch classes alone: tension is scored from what sounds against
831
- // the tonic, and no reading below here asks how the key is written.
832
- tension: sampleTension(prepared, timeline, scaleOf(keyAt(beat)), meters, profile, beat)
856
+ // The key whole: an augmented sixth is told from a dominant by its letters.
857
+ tension: sampleTension(prepared, timeline, keyAt(beat), meters, profile, beat)
833
858
  });
834
859
  }
835
860
  return points;
@@ -900,7 +925,7 @@ function sampleTension(prepared, timeline, key, meters, profile, beat) {
900
925
  others.pop();
901
926
  }
902
927
  const result = evaluateSafety(
903
- { profile, candidatePitch: pitch, chord, key, otherVoices: others, strongBeat },
928
+ { profile, candidatePitch: pitch, chord, key: key.scale, otherVoices: others, strongBeat },
904
929
  { suggestions: false }
905
930
  );
906
931
  if (held !== void 0 && tail !== void 0) {
@@ -926,19 +951,34 @@ function sampleTension(prepared, timeline, key, meters, profile, beat) {
926
951
 
927
952
  // src/analyze/form/hypermeter.ts
928
953
  var GROUP_CANDIDATES = [2, 3, 4, 6, 8];
929
- var GROUP_PRIOR = /* @__PURE__ */ new Map([
954
+ var GROUP_PRIOR_LIST = [
930
955
  [2, 0.9],
931
956
  [3, 0.75],
932
957
  [4, 1],
933
958
  [6, 0.7],
934
959
  [8, 0.85]
935
- ]);
960
+ ];
961
+ var GROUP_PRIOR = new Map(GROUP_PRIOR_LIST);
936
962
  var CONTRAST_SHARE = 0.65;
937
963
  var CADENCE_SHARE = 0.35;
938
964
  var GROUP_FINAL_FIT = 1;
939
965
  var ELIDED_FIT = 0.5;
940
966
  var MIN_GROUPS = 2;
941
967
  var MARGIN_FLOOR = 0.7;
968
+ function cadenceSpans(arrivals, segments) {
969
+ return arrivals.map(({ atBeat, strength }) => ({
970
+ atBeat,
971
+ endBeat: segmentStartingAt(segments, atBeat)?.endBeat ?? atBeat,
972
+ strength
973
+ }));
974
+ }
975
+ function cadenceBarsOf(spans, meter) {
976
+ return spans.map((span) => ({
977
+ arrival: barIndexAt(span.atBeat, meter),
978
+ last: lastBarOf(meter, span.atBeat, span.endBeat),
979
+ strength: span.strength
980
+ }));
981
+ }
942
982
  function accentContrast(novelty, slices, groupBars, phase) {
943
983
  let headSum = 0;
944
984
  let headCount = 0;
@@ -963,22 +1003,41 @@ function accentContrast(novelty, slices, groupBars, phase) {
963
1003
  }
964
1004
  return headSum / headCount - otherSum / otherCount;
965
1005
  }
966
- function cadenceFitOf(cadenceBars, slices, groupBars, phase) {
1006
+ function cadenceFitOf(cadences, slices, groupBars, phase) {
967
1007
  const lastBar = slices[slices.length - 1]?.index ?? -1;
1008
+ const closing = /* @__PURE__ */ new Map();
1009
+ const eliding = /* @__PURE__ */ new Map();
1010
+ const credit = (bars, bar, strength) => {
1011
+ bars.set(bar, Math.max(bars.get(bar) ?? 0, strength));
1012
+ };
1013
+ for (const cadence of cadences) {
1014
+ const end = cadence.arrival + floorMod(groupBars - 1 + phase - cadence.arrival, groupBars);
1015
+ if (end <= cadence.last) {
1016
+ credit(closing, end, cadence.strength);
1017
+ } else if (floorMod(cadence.arrival - phase, groupBars) === 0 && // Elision needs a next group to arrive into. Past the last bar the span
1018
+ // holds there is none, so a cadence beat beyond the music is not evidence
1019
+ // about how the music that was read is grouped.
1020
+ cadence.arrival <= lastBar) {
1021
+ credit(eliding, cadence.arrival - 1, cadence.strength);
1022
+ }
1023
+ }
968
1024
  let ends = 0;
969
- let fit = 0;
1025
+ let closed = 0;
1026
+ let weighted = 0;
970
1027
  for (const slice of slices) {
971
1028
  if (slice.index < 0 || floorMod(slice.index - phase, groupBars) !== groupBars - 1) {
972
1029
  continue;
973
1030
  }
974
1031
  ends += 1;
975
- if (cadenceBars.has(slice.index)) {
976
- fit += GROUP_FINAL_FIT;
977
- } else if (slice.index < lastBar && cadenceBars.has(slice.index + 1)) {
978
- fit += ELIDED_FIT;
979
- }
1032
+ const final = closing.get(slice.index);
1033
+ const elided = eliding.get(slice.index);
1034
+ closed += Math.max(
1035
+ final === void 0 ? 0 : GROUP_FINAL_FIT,
1036
+ elided === void 0 ? 0 : ELIDED_FIT
1037
+ );
1038
+ weighted += Math.max(GROUP_FINAL_FIT * (final ?? 0), ELIDED_FIT * (elided ?? 0));
980
1039
  }
981
- return ends === 0 ? 0 : clamp01(fit / ends);
1040
+ return ends === 0 ? { closed: 0, weighted: 0 } : { closed: clamp01(closed / ends), weighted: clamp01(weighted / ends) };
982
1041
  }
983
1042
  function undecidedReading(slices) {
984
1043
  const fullBars = slices.filter((slice) => slice.index >= 0);
@@ -992,6 +1051,17 @@ function undecidedReading(slices) {
992
1051
  };
993
1052
  }
994
1053
  function hypermeter(notes, meter, opts = {}) {
1054
+ return hypermeterOver(
1055
+ notes,
1056
+ meter,
1057
+ opts,
1058
+ (asked) => cadenceSpans(
1059
+ (asked.cadenceBeats ?? []).map((atBeat) => ({ atBeat, strength: 1 })),
1060
+ []
1061
+ )
1062
+ );
1063
+ }
1064
+ function hypermeterOver(notes, meter, opts, spansOf) {
995
1065
  const asked = assertOptions(opts, "opts");
996
1066
  const meters = resolveMeters({ meters: meter }, "meter");
997
1067
  assertNoteEvents(notes, "hypermeter notes", {
@@ -1000,10 +1070,16 @@ function hypermeter(notes, meter, opts = {}) {
1000
1070
  });
1001
1071
  const sounding = notes.filter((note) => note.durationBeat > 0);
1002
1072
  const firstOnset = firstSoundingBeat(sounding);
1003
- const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1004
- const spanEnd = asked.totalBeats ?? lastEnd;
1005
- assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "hypermeter totalBeats");
1073
+ const spanEnd = resolveSpanEnd(
1074
+ asked.totalBeats,
1075
+ firstOnset,
1076
+ latestSoundingEnd(sounding),
1077
+ "hypermeter totalBeats"
1078
+ );
1006
1079
  const spanStart = barGridStart(firstOnset, meters);
1080
+ if (sounding.length === 0 || spanEnd <= spanStart + BEAT_EPS) {
1081
+ return undecidedReading([]);
1082
+ }
1007
1083
  const slices = sliceBars(sounding, meters, spanStart, spanEnd, asked.budget);
1008
1084
  const fullBars = slices.reduce((count, slice) => slice.index >= 0 ? count + 1 : count, 0);
1009
1085
  const candidates = GROUP_CANDIDATES.filter((groupBars) => fullBars >= groupBars * MIN_GROUPS);
@@ -1016,20 +1092,21 @@ function hypermeter(notes, meter, opts = {}) {
1016
1092
  asked.budget
1017
1093
  );
1018
1094
  const novelty = slices.map((_, index) => harmonicNovelty(slices, index));
1019
- const cadenceBars = new Set((asked.cadenceBeats ?? []).map((beat) => barIndexAt(beat, meters)));
1020
- const hasCadences = cadenceBars.size > 0;
1095
+ const cadenceBars = cadenceBarsOf(spansOf(asked), meters);
1096
+ const hasCadences = cadenceBars.length > 0;
1021
1097
  const readings = [];
1022
1098
  for (const groupBars of candidates) {
1023
1099
  const phases = Array.from({ length: groupBars }, (_, phase) => ({
1024
1100
  phase,
1025
1101
  contrast: accentContrast(novelty, slices, groupBars, phase),
1026
- cadenceFit: cadenceFitOf(cadenceBars, slices, groupBars, phase)
1102
+ fit: cadenceFitOf(cadenceBars, slices, groupBars, phase)
1027
1103
  }));
1028
- const bestFit = phases.reduce((best2, reading) => Math.max(best2, reading.cadenceFit), 0);
1029
- for (const { phase, contrast, cadenceFit } of phases) {
1030
- if (hasCadences && cadenceFit < bestFit - BEAT_EPS) {
1104
+ const bestFit = phases.reduce((best2, reading) => Math.max(best2, reading.fit.weighted), 0);
1105
+ for (const { phase, contrast, fit } of phases) {
1106
+ if (hasCadences && fit.weighted < bestFit - BEAT_EPS) {
1031
1107
  continue;
1032
1108
  }
1109
+ const cadenceFit = fit.closed;
1033
1110
  const evidence = clamp01(
1034
1111
  hasCadences ? (
1035
1112
  // Floored at zero rather than summed raw: group heads that restate
@@ -1074,549 +1151,6 @@ function hypermeter(notes, meter, opts = {}) {
1074
1151
  };
1075
1152
  }
1076
1153
 
1077
- // src/analyze/melody/internal.ts
1078
- var RHYTHM_GRID = 64;
1079
- function phraseNotes(phrase) {
1080
- if (Array.isArray(phrase)) {
1081
- return phrase;
1082
- }
1083
- return assertRecord(phrase, "melodic phrase").notes;
1084
- }
1085
- function orderedNotes(notes, name, budget) {
1086
- assertNoteEvents(notes, name, { allowNonPositiveDuration: true, budget });
1087
- const sounding = notes.filter((note) => note.durationBeat > 0).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1088
- const line = [];
1089
- for (const note of sounding) {
1090
- const previous = line[line.length - 1];
1091
- if (previous !== void 0 && Math.abs(note.startBeat - previous.startBeat) <= BEAT_EPS) {
1092
- line[line.length - 1] = note;
1093
- continue;
1094
- }
1095
- line.push(note);
1096
- }
1097
- return line;
1098
- }
1099
- function intervalsOf(notes) {
1100
- const out = [];
1101
- for (let i = 1; i < notes.length; i += 1) {
1102
- out.push((notes[i]?.pitch ?? 0) - (notes[i - 1]?.pitch ?? 0));
1103
- }
1104
- return out;
1105
- }
1106
- function onsetGaps(notes) {
1107
- const out = [];
1108
- for (let i = 1; i < notes.length; i += 1) {
1109
- out.push((notes[i]?.startBeat ?? 0) - (notes[i - 1]?.startBeat ?? 0));
1110
- }
1111
- return out;
1112
- }
1113
- function retrogradeGaps(notes) {
1114
- const out = [];
1115
- for (let i = notes.length - 1; i >= 1; i -= 1) {
1116
- const later = notes[i];
1117
- const earlier = notes[i - 1];
1118
- out.push(
1119
- (later?.startBeat ?? 0) + (later?.durationBeat ?? 0) - ((earlier?.startBeat ?? 0) + (earlier?.durationBeat ?? 0))
1120
- );
1121
- }
1122
- return out;
1123
- }
1124
- function rhythmProfile(gaps) {
1125
- const unit = gaps[0] ?? 0;
1126
- if (!(unit > BEAT_EPS)) {
1127
- return gaps.length === 0 ? [] : null;
1128
- }
1129
- return gaps.map((gap) => Math.round(gap / unit * RHYTHM_GRID) / RHYTHM_GRID);
1130
- }
1131
- function sameNumbers(a, b) {
1132
- if (a.length !== b.length) {
1133
- return false;
1134
- }
1135
- for (let i = 0; i < a.length; i += 1) {
1136
- if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > BEAT_EPS) {
1137
- return false;
1138
- }
1139
- }
1140
- return true;
1141
- }
1142
- function roundTo(value, digits) {
1143
- const scale = 10 ** digits;
1144
- return Math.round(value * scale) / scale;
1145
- }
1146
- function endBeatOf(notes) {
1147
- let end = 0;
1148
- for (const note of notes) {
1149
- end = Math.max(end, note.startBeat + note.durationBeat);
1150
- }
1151
- return end;
1152
- }
1153
- function onsetSpan(notes) {
1154
- if (notes.length < 2) {
1155
- return 0;
1156
- }
1157
- return (notes[notes.length - 1]?.startBeat ?? 0) - (notes[0]?.startBeat ?? 0);
1158
- }
1159
-
1160
- // src/analyze/melody/contour.ts
1161
- var DIRECTIONAL_SHARE = 0.75;
1162
- function extremes(notes) {
1163
- if (notes.length === 0) {
1164
- return { peak: -1, trough: -1 };
1165
- }
1166
- let peak = 0;
1167
- let trough = 0;
1168
- for (let i = 1; i < notes.length; i += 1) {
1169
- if ((notes[i]?.pitch ?? 0) > (notes[peak]?.pitch ?? 0)) {
1170
- peak = i;
1171
- }
1172
- if ((notes[i]?.pitch ?? 0) < (notes[trough]?.pitch ?? 0)) {
1173
- trough = i;
1174
- }
1175
- }
1176
- return { peak, trough };
1177
- }
1178
- function melodicContour(notes) {
1179
- const line = orderedNotes(phraseNotes(notes), "contour notes");
1180
- const intervals = intervalsOf(line);
1181
- const directions = intervals.map(
1182
- (step) => step > 0 ? "up" : step < 0 ? "down" : "same"
1183
- );
1184
- const { peak, trough } = extremes(line);
1185
- const range = (line[peak]?.pitch ?? 0) - (line[trough]?.pitch ?? 0);
1186
- const motion = intervals.reduce((sum, step) => sum + Math.abs(step), 0);
1187
- if (motion === 0) {
1188
- return {
1189
- directions,
1190
- shape: "static",
1191
- peakIndex: peak,
1192
- troughIndex: trough,
1193
- range: 0,
1194
- rationale: line.length < 2 ? "Too short to have a direction, so the line is static" : "Every note at the same pitch, so the line is static"
1195
- };
1196
- }
1197
- const first = line[0]?.pitch ?? 0;
1198
- const last = line[line.length - 1]?.pitch ?? 0;
1199
- const net = last - first;
1200
- const directness = Math.abs(net) / motion;
1201
- const share = Math.round(directness * 100);
1202
- if (directness >= DIRECTIONAL_SHARE) {
1203
- const shape = net > 0 ? "ascending" : "descending";
1204
- return {
1205
- directions,
1206
- shape,
1207
- peakIndex: peak,
1208
- troughIndex: trough,
1209
- range,
1210
- rationale: `${share}% of the motion runs ${net > 0 ? "up" : "down"}, so the line is ${shape}`
1211
- };
1212
- }
1213
- const peakPitch = line[peak]?.pitch ?? 0;
1214
- const archMotion = peakPitch - first + (peakPitch - last);
1215
- if (peak > 0 && peak < line.length - 1 && archMotion >= DIRECTIONAL_SHARE * motion) {
1216
- return {
1217
- directions,
1218
- shape: "arch",
1219
- peakIndex: peak,
1220
- troughIndex: trough,
1221
- range,
1222
- rationale: `Rises to its peak at note ${peak + 1} of ${line.length} and falls back, so the line is an arch`
1223
- };
1224
- }
1225
- return {
1226
- directions,
1227
- shape: "wave",
1228
- peakIndex: peak,
1229
- troughIndex: trough,
1230
- range,
1231
- rationale: `Changes direction without settling either way (${share}% of the motion nets out), so the line is a wave`
1232
- };
1233
- }
1234
-
1235
- // src/analyze/melody/motifs.ts
1236
- var MIN_CELL_NOTES = 3;
1237
- var MAX_CELL_NOTES = 8;
1238
- var MIN_OCCURRENCES = 2;
1239
- function windowSignature(notes) {
1240
- const profile = rhythmProfile(onsetGaps(notes));
1241
- if (profile === null) {
1242
- return null;
1243
- }
1244
- return `${notes.length}|${intervalsOf(notes).join(",")}|${profile.join(",")}`;
1245
- }
1246
- function nonOverlapping(starts, length) {
1247
- const picked = [];
1248
- let lastEnd = Number.NEGATIVE_INFINITY;
1249
- for (const start of starts) {
1250
- if (start > lastEnd) {
1251
- picked.push(start);
1252
- lastEnd = start + length - 1;
1253
- }
1254
- }
1255
- return picked;
1256
- }
1257
- function subsumes(outer, inner) {
1258
- let at = 0;
1259
- for (const start of inner.starts) {
1260
- while (at + 1 < outer.starts.length && (outer.starts[at + 1] ?? 0) <= start) {
1261
- at += 1;
1262
- }
1263
- const from = outer.starts[at];
1264
- if (from === void 0 || start < from || start + inner.length > from + outer.length) {
1265
- return false;
1266
- }
1267
- }
1268
- return true;
1269
- }
1270
- function listBeats(occurrences) {
1271
- const shown = occurrences.slice(0, 5).map((occurrence) => roundTo(occurrence.startBeat, 3));
1272
- return occurrences.length > shown.length ? `${shown.join(", ")}, and ${occurrences.length - shown.length} more` : shown.join(", ");
1273
- }
1274
- function motifFromGroup(group, notes) {
1275
- const first = group.starts[0] ?? 0;
1276
- const prime = notes.slice(first, first + group.length).map((note) => ({ ...note }));
1277
- const primePitch = prime[0]?.pitch ?? 0;
1278
- const primeSpan = onsetSpan(prime);
1279
- const occurrences = group.starts.map((start) => {
1280
- const statement = notes.slice(start, start + group.length);
1281
- const span = onsetSpan(statement);
1282
- return {
1283
- noteIndex: start,
1284
- startBeat: statement[0]?.startBeat ?? 0,
1285
- endBeat: endBeatOf(statement),
1286
- transpose: (statement[0]?.pitch ?? 0) - primePitch,
1287
- timeRatio: primeSpan > BEAT_EPS ? span / primeSpan : 1
1288
- };
1289
- });
1290
- const shape = melodicContour(prime).shape;
1291
- const transposed = occurrences.some((occurrence) => occurrence.transpose !== 0);
1292
- const stretched = occurrences.some((occurrence) => Math.abs(occurrence.timeRatio - 1) > BEAT_EPS);
1293
- const detail = [
1294
- transposed ? "at more than one pitch level" : "at the same pitch level",
1295
- stretched ? "and in more than one set of note values" : ""
1296
- ].filter((part) => part !== "").join(" ");
1297
- return {
1298
- notes: prime,
1299
- intervals: intervalsOf(prime),
1300
- rhythm: rhythmProfile(onsetGaps(prime)) ?? [],
1301
- occurrences,
1302
- rationale: `${group.length}-note ${shape} cell stated ${occurrences.length} ${occurrences.length === 1 ? "time" : "times"} (beats ${listBeats(occurrences)}), ${detail}`
1303
- };
1304
- }
1305
- function extractMotifs(notes, opts = {}) {
1306
- const asked = assertOptions(opts, "opts");
1307
- const minNotes = assertInteger(asked.minNotes ?? MIN_CELL_NOTES, "motif minNotes", 2, 64);
1308
- const maxNotes = assertInteger(asked.maxNotes ?? MAX_CELL_NOTES, "motif maxNotes", 2, 64);
1309
- if (maxNotes < minNotes) {
1310
- throw new InvalidInputError(
1311
- `motif maxNotes must be at least minNotes ${minNotes}; received ${maxNotes}`
1312
- );
1313
- }
1314
- const minOccurrences = assertInteger(
1315
- asked.minOccurrences ?? MIN_OCCURRENCES,
1316
- "motif minOccurrences",
1317
- 2
1318
- );
1319
- const sounding = orderedNotes(notes, "melody notes", asked.budget);
1320
- if (sounding.length < minNotes) {
1321
- return [];
1322
- }
1323
- assertGenerationBudget(
1324
- sounding.length * (maxNotes - minNotes + 1),
1325
- "motif windows",
1326
- asked.budget
1327
- );
1328
- const groups = /* @__PURE__ */ new Map();
1329
- for (let length = minNotes; length <= maxNotes; length += 1) {
1330
- for (let start = 0; start + length <= sounding.length; start += 1) {
1331
- const signature = windowSignature(sounding.slice(start, start + length));
1332
- if (signature === null) {
1333
- continue;
1334
- }
1335
- const group = groups.get(signature);
1336
- if (group === void 0) {
1337
- groups.set(signature, { signature, length, starts: [start] });
1338
- } else {
1339
- group.starts.push(start);
1340
- }
1341
- }
1342
- }
1343
- const candidates = [...groups.values()].map((group) => ({ ...group, starts: nonOverlapping(group.starts, group.length) })).filter((group) => group.starts.length >= minOccurrences).sort(
1344
- (a, b) => b.length - a.length || b.starts.length - a.starts.length || (a.starts[0] ?? 0) - (b.starts[0] ?? 0) || (a.signature < b.signature ? -1 : a.signature > b.signature ? 1 : 0)
1345
- );
1346
- const keptByLength = /* @__PURE__ */ new Map();
1347
- const kept = [];
1348
- let comparisons = 0;
1349
- for (const candidate of candidates) {
1350
- const first = candidate.starts[0] ?? 0;
1351
- let covered = false;
1352
- for (let length = candidate.length + 1; length <= maxNotes && !covered; length += 1) {
1353
- const byStart = keptByLength.get(length);
1354
- if (byStart === void 0) {
1355
- continue;
1356
- }
1357
- for (let from = Math.max(0, first - (length - candidate.length)); from <= first; from += 1) {
1358
- comparisons += 1;
1359
- const outer = byStart.get(from);
1360
- if (outer === void 0 || outer.starts.length < candidate.starts.length) {
1361
- continue;
1362
- }
1363
- comparisons += outer.starts.length + candidate.starts.length;
1364
- if (subsumes(outer, candidate)) {
1365
- covered = true;
1366
- break;
1367
- }
1368
- }
1369
- }
1370
- assertGenerationBudget(comparisons, "motif subsumption comparisons", asked.budget);
1371
- if (!covered) {
1372
- kept.push(candidate);
1373
- let byStart = keptByLength.get(candidate.length);
1374
- if (byStart === void 0) {
1375
- byStart = /* @__PURE__ */ new Map();
1376
- keptByLength.set(candidate.length, byStart);
1377
- }
1378
- for (const start of candidate.starts) {
1379
- byStart.set(start, candidate);
1380
- }
1381
- }
1382
- }
1383
- return kept.map((group) => motifFromGroup(group, sounding));
1384
- }
1385
- function motifFromNotes(notes) {
1386
- const sounding = orderedNotes(notes, "motif notes").map((note) => ({ ...note }));
1387
- return motifFromGroup(
1388
- { signature: windowSignature(sounding) ?? "", length: sounding.length, starts: [0] },
1389
- sounding
1390
- );
1391
- }
1392
-
1393
- // src/analyze/melody/relation.ts
1394
- function spellingOf(key) {
1395
- if (key === void 0) {
1396
- return "sharp";
1397
- }
1398
- return keySignatureFifths(key.tonic, key.scale) < 0 ? "flat" : "sharp";
1399
- }
1400
- function intervalBetween(from, to, key) {
1401
- const spelling = spellingOf(key);
1402
- return spelledInterval(midiToNote(from, spelling), midiToNote(to, spelling));
1403
- }
1404
- function intervalName(interval) {
1405
- return `${interval.descending && interval.semitones !== 0 ? "-" : ""}${interval.quality}${interval.number}`;
1406
- }
1407
- function ladderIndices(notes, key) {
1408
- const root = pitchClassOf(key.rootPc);
1409
- const offsets = scaleTonesInDegreeOrder(key).map((pc) => pitchClassOf(pc - root));
1410
- if (offsets.length === 0) {
1411
- return null;
1412
- }
1413
- const out = [];
1414
- for (const note of notes) {
1415
- const semitones = note.pitch - root;
1416
- const octave = Math.floor(semitones / 12);
1417
- const degree = offsets.indexOf(semitones - octave * 12);
1418
- if (degree < 0) {
1419
- return null;
1420
- }
1421
- out.push(octave * offsets.length + degree);
1422
- }
1423
- return out;
1424
- }
1425
- function ladderSteps(indices) {
1426
- const out = [];
1427
- for (let i = 1; i < indices.length; i += 1) {
1428
- out.push((indices[i] ?? 0) - (indices[i - 1] ?? 0));
1429
- }
1430
- return out;
1431
- }
1432
- function pitchShapes(a, b) {
1433
- const shapes = [];
1434
- if (sameNumbers(a, b)) {
1435
- shapes.push("exact");
1436
- }
1437
- if (sameNumbers(
1438
- b,
1439
- a.map((step) => -step)
1440
- )) {
1441
- shapes.push("inversion");
1442
- }
1443
- const reversed = [...a].reverse();
1444
- if (sameNumbers(
1445
- b,
1446
- reversed.map((step) => -step)
1447
- )) {
1448
- shapes.push("retrograde");
1449
- }
1450
- if (sameNumbers(b, reversed)) {
1451
- shapes.push("retrogradeInversion");
1452
- }
1453
- return shapes;
1454
- }
1455
- function totalGap(gaps) {
1456
- return gaps.reduce((sum, gap) => sum + gap, 0);
1457
- }
1458
- function stretchRatio(modelGaps, answerGaps) {
1459
- const span = totalGap(modelGaps);
1460
- return span > BEAT_EPS ? totalGap(answerGaps) / span : 1;
1461
- }
1462
- function stretchPhrase(ratio) {
1463
- return `note values ${roundTo(ratio, 3)}x the model's`;
1464
- }
1465
- function relateMotifs(a, b, keyLike) {
1466
- const key = keyLike === void 0 ? void 0 : resolveKey(keyLike);
1467
- const model = assertArray(assertRecord(a, "a").notes, "a.notes");
1468
- const answer = assertArray(assertRecord(b, "b").notes, "b.notes");
1469
- if (model.length === 0 || model.length !== answer.length) {
1470
- return null;
1471
- }
1472
- const modelGaps = onsetGaps(model);
1473
- const answerGaps = onsetGaps(answer);
1474
- const modelProfile = rhythmProfile(modelGaps);
1475
- const answerProfile = rhythmProfile(answerGaps);
1476
- const retroGaps = retrogradeGaps(model);
1477
- const retroProfile = rhythmProfile(retroGaps);
1478
- if (modelProfile === null || answerProfile === null) {
1479
- return null;
1480
- }
1481
- const forward = sameNumbers(modelProfile, answerProfile);
1482
- const backward = retroProfile !== null && sameNumbers(retroProfile, answerProfile);
1483
- const semitones = (answer[0]?.pitch ?? 0) - (model[0]?.pitch ?? 0);
1484
- const interval = intervalBetween(model[0]?.pitch ?? 0, answer[0]?.pitch ?? 0, key);
1485
- const gap = (answer[0]?.startBeat ?? 0) - endBeatOf(model);
1486
- const sequence = gap >= -HUMANIZE_ADJACENCY && gap <= HUMANIZE_ADJACENCY;
1487
- const timeRatio = stretchRatio(modelGaps, answerGaps);
1488
- const stretched = Math.abs(timeRatio - 1) > BEAT_EPS;
1489
- const shapes = pitchShapes(intervalsOf(model), intervalsOf(answer));
1490
- const base = { sequence, semitones, interval, timeRatio };
1491
- const backwardBase = { ...base, timeRatio: stretchRatio(retroGaps, answerGaps) };
1492
- if (forward && shapes.includes("exact")) {
1493
- if (semitones === 0) {
1494
- if (!stretched) {
1495
- return {
1496
- ...base,
1497
- kind: "repetition",
1498
- rationale: "Exact repetition: the same pitches in the same rhythm"
1499
- };
1500
- }
1501
- const augmented = timeRatio > 1;
1502
- return {
1503
- ...base,
1504
- kind: augmented ? "augmentation" : "diminution",
1505
- rationale: `${augmented ? "Augmentation" : "Diminution"}: the same pitches in ${stretchPhrase(timeRatio)}`
1506
- };
1507
- }
1508
- const name = intervalName(interval);
1509
- return {
1510
- ...base,
1511
- kind: "transposition",
1512
- rationale: sequence ? `Real sequence: the cell restated ${name} away with every interval kept literally` + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "") : `Transposition by ${name}: every interval kept literally` + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
1513
- };
1514
- }
1515
- if (forward && key !== void 0) {
1516
- const modelLadder = ladderIndices(model, key.scale);
1517
- const answerLadder = ladderIndices(answer, key.scale);
1518
- if (modelLadder !== null && answerLadder !== null) {
1519
- const degrees = (answerLadder[0] ?? 0) - (modelLadder[0] ?? 0);
1520
- if (degrees !== 0 && sameNumbers(ladderSteps(modelLadder), ladderSteps(answerLadder))) {
1521
- const direction = degrees > 0 ? "higher" : "lower";
1522
- const count = Math.abs(degrees);
1523
- return {
1524
- ...base,
1525
- kind: "tonalTransposition",
1526
- degrees,
1527
- rationale: `${sequence ? "Tonal sequence" : "Tonal transposition"}: the cell restated ${count} scale ${count === 1 ? "degree" : "degrees"} ${direction} inside the key, so the key resizes its intervals \u2014 a real one would have kept them literal`
1528
- };
1529
- }
1530
- }
1531
- }
1532
- if (forward && shapes.includes("inversion")) {
1533
- return {
1534
- ...base,
1535
- kind: "inversion",
1536
- rationale: "Inversion: every interval turned upside down about the first note" + (stretched ? `, in ${stretchPhrase(timeRatio)}` : "")
1537
- };
1538
- }
1539
- if (backward && shapes.includes("retrograde")) {
1540
- return {
1541
- ...backwardBase,
1542
- kind: "retrograde",
1543
- rationale: "Retrograde: the cell read back to front, rhythm included"
1544
- };
1545
- }
1546
- if (backward && shapes.includes("retrogradeInversion")) {
1547
- return {
1548
- ...backwardBase,
1549
- kind: "retrogradeInversion",
1550
- rationale: "Retrograde inversion: the cell read back to front and turned upside down"
1551
- };
1552
- }
1553
- return null;
1554
- }
1555
-
1556
- // src/analyze/melody/similarity.ts
1557
- var INTERVAL_TOLERANCE = 6;
1558
- var PITCH_WEIGHT = 0.75;
1559
- var RHYTHM_WEIGHT = 0.25;
1560
- function gradedEditDistance(a, b, cost) {
1561
- let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
1562
- for (let i = 1; i <= a.length; i += 1) {
1563
- const row = new Array(b.length + 1);
1564
- row[0] = i;
1565
- for (let j = 1; j <= b.length; j += 1) {
1566
- const substitution = (previous[j - 1] ?? 0) + cost(a[i - 1] ?? 0, b[j - 1] ?? 0);
1567
- const deletion = (previous[j] ?? 0) + 1;
1568
- const insertion = (row[j - 1] ?? 0) + 1;
1569
- row[j] = Math.min(substitution, deletion, insertion);
1570
- }
1571
- previous = row;
1572
- }
1573
- return previous[b.length] ?? 0;
1574
- }
1575
- function distanceToSimilarity(distance, a, b) {
1576
- const longest = Math.max(a, b);
1577
- if (longest === 0) {
1578
- return 1;
1579
- }
1580
- return Math.min(1, Math.max(0, 1 - distance / longest));
1581
- }
1582
- function intervalCost(x, y) {
1583
- return Math.min(1, Math.abs(x - y) / INTERVAL_TOLERANCE);
1584
- }
1585
- function gapCost(x, y) {
1586
- if (!(x > BEAT_EPS) || !(y > BEAT_EPS)) {
1587
- return Math.abs(x - y) > BEAT_EPS ? 1 : 0;
1588
- }
1589
- return Math.min(1, Math.abs(Math.log2(x / y)));
1590
- }
1591
- function compareMelodies(a, b) {
1592
- const left = orderedNotes(phraseNotes(a), "melody a");
1593
- const right = orderedNotes(phraseNotes(b), "melody b");
1594
- const leftIntervals = intervalsOf(left);
1595
- const rightIntervals = intervalsOf(right);
1596
- const pitchSimilarity = distanceToSimilarity(
1597
- gradedEditDistance(leftIntervals, rightIntervals, intervalCost),
1598
- leftIntervals.length,
1599
- rightIntervals.length
1600
- );
1601
- const leftGaps = rhythmProfile(onsetGaps(left)) ?? onsetGaps(left);
1602
- const rightGaps = rhythmProfile(onsetGaps(right)) ?? onsetGaps(right);
1603
- const rhythmSimilarity = distanceToSimilarity(
1604
- gradedEditDistance(leftGaps, rightGaps, gapCost),
1605
- leftGaps.length,
1606
- rightGaps.length
1607
- );
1608
- const similarity = PITCH_WEIGHT * pitchSimilarity + RHYTHM_WEIGHT * rhythmSimilarity;
1609
- return {
1610
- similarity,
1611
- pitchSimilarity,
1612
- rhythmSimilarity,
1613
- rationale: `Interval shapes ${Math.round(pitchSimilarity * 100)}% alike, rhythms ${Math.round(rhythmSimilarity * 100)}% alike, weighted ${PITCH_WEIGHT}/${RHYTHM_WEIGHT} into ${roundTo(similarity, 3)}`
1614
- };
1615
- }
1616
- function melodicSimilarity(a, b) {
1617
- return compareMelodies(a, b).similarity;
1618
- }
1619
-
1620
1154
  // src/analyze/form/phrase.ts
1621
1155
  var CADENCE_BOUNDARY_STRENGTH = {
1622
1156
  authentic: 0.9,
@@ -1644,6 +1178,7 @@ var PHRASE_CUT_COST = 1.3;
1644
1178
  var CLOSING_SHARE = 0.65;
1645
1179
  var FINAL_PHRASE_BONUS = 0.15;
1646
1180
  var HYPERMETRIC_CLOSE_BONUS = 0.1;
1181
+ var IMPERFECT_CLOSURE_FACTOR = 0.8;
1647
1182
  var EVADED_FACTOR = 0.6;
1648
1183
  var SIGNAL_ORDER = [
1649
1184
  "cadence",
@@ -1669,16 +1204,22 @@ function readBoundary(boundary) {
1669
1204
  );
1670
1205
  return {
1671
1206
  strength: combineEvidence(signals.map(([, strength]) => strength)),
1207
+ uncredited: combineEvidence(
1208
+ signals.filter(([signal]) => signal !== "cadence").map(([, strength]) => strength)
1209
+ ),
1672
1210
  signals: signals.map(([signal]) => signal)
1673
1211
  };
1674
1212
  }
1213
+ function closesPhrase(atBeat, startBeat, endBeat) {
1214
+ return atBeat >= startBeat - BEAT_EPS && atBeat < endBeat - BEAT_EPS;
1215
+ }
1675
1216
  function lengthFit(length, expected) {
1676
1217
  if (!(expected > 0)) {
1677
1218
  return 0;
1678
1219
  }
1679
1220
  return Math.max(-1, 1 - Math.abs(length - expected) / expected);
1680
1221
  }
1681
- function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1222
+ function choosePhrasePath(beats, strengths, minPhraseBeats, expected, held) {
1682
1223
  const value = new Array(beats.length).fill(Number.NEGATIVE_INFINITY);
1683
1224
  const cameFrom = new Array(beats.length).fill(-1);
1684
1225
  value[0] = 0;
@@ -1692,7 +1233,9 @@ function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1692
1233
  if (length < minPhraseBeats - BEAT_EPS) {
1693
1234
  continue;
1694
1235
  }
1695
- const gain = (value[i] ?? 0) + (strengths[j] ?? 0) + lengthFit(length, expected) - PHRASE_CUT_COST;
1236
+ const credited = held === void 0 || closesPhrase(held.arrivals[j] ?? endBeat, beats[i] ?? 0, endBeat);
1237
+ const strength = credited ? strengths[j] ?? 0 : held.uncredited[j] ?? 0;
1238
+ const gain = (value[i] ?? 0) + strength + lengthFit(length, expected) - PHRASE_CUT_COST;
1696
1239
  if (gain > (value[j] ?? Number.NEGATIVE_INFINITY)) {
1697
1240
  value[j] = gain;
1698
1241
  cameFrom[j] = i;
@@ -1711,32 +1254,35 @@ function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1711
1254
  }
1712
1255
  return path;
1713
1256
  }
1714
- function arrivalEnd(timeline, atBeat) {
1715
- return segmentStartingAt(timeline.segments, atBeat)?.endBeat ?? atBeat;
1716
- }
1717
- function barSpanOf(startBeat, endBeat, meters) {
1718
- const firstBar = barIndexAt(startBeat, meters);
1719
- const lastBar = barIndexAt(endBeat, meters);
1720
- const partOf = (beat, bar) => {
1721
- const barStart = barPositionToBeat({ bar, beat: 0 }, meters);
1722
- const barLength = beatsPerBarAt(barStart, meters);
1723
- return barLength > 0 ? (beat - barStart) / barLength : 0;
1724
- };
1725
- const bars = lastBar - firstBar + partOf(endBeat, lastBar) - partOf(startBeat, firstBar);
1726
- return Math.round(bars * 100) / 100;
1257
+ function boundaryCadences(timeline, key) {
1258
+ const hits = detectCadences(timeline, key).filter((hit) => hit.cadence.type !== null);
1259
+ const spans = cadenceSpans(
1260
+ hits.map((hit) => ({
1261
+ atBeat: hit.atBeat,
1262
+ strength: CADENCE_BOUNDARY_STRENGTH[hit.cadence.type]
1263
+ })),
1264
+ timeline.segments
1265
+ );
1266
+ return { hits, spans };
1727
1267
  }
1728
- function keyContextFor(opts, notes, meters, totalBeats) {
1729
- if (opts.key !== void 0) {
1730
- return opts.key;
1268
+ function keyContextFor(key, timeline, meters, totalBeats, budget) {
1269
+ if (key !== void 0) {
1270
+ return key;
1731
1271
  }
1732
- const regions = keyTimelineFromNotes(notes, {
1733
- meters,
1734
- totalBeats,
1735
- budget: opts.budget
1736
- });
1272
+ const regions = detectModulations(timeline.segments, { meters, totalBeats, budget });
1737
1273
  const keyAt = keyLookup(regions, prevailingKeyOf(regions) ?? resolveKey(majorKey(0)));
1738
1274
  return (beat) => scaleOf(keyAt(beat));
1739
1275
  }
1276
+ function heldCadenceBoundaries(boundaries, hit, span, meters, spanEnd) {
1277
+ const firstLine = barIndexAt(span.atBeat, meters) + 1;
1278
+ const lastLine = barIndexAt(Math.min(span.endBeat, spanEnd), meters);
1279
+ for (let bar = firstLine; bar <= lastLine; bar += 1) {
1280
+ const line = barPositionToBeat({ bar, beat: 0 }, meters);
1281
+ if (line < span.endBeat - BEAT_EPS) {
1282
+ addSignal(boundaries, line, "cadence", span.strength, hit);
1283
+ }
1284
+ }
1285
+ }
1740
1286
  function restBoundaries(boundaries, notes, meters, restBeats) {
1741
1287
  const ordered = [...notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1742
1288
  let covered = Number.NEGATIVE_INFINITY;
@@ -1790,7 +1336,7 @@ function structuralWeightOf(cadence, confidence, aligned, isLast) {
1790
1336
  if (result.strength === "perfect") {
1791
1337
  base = 1;
1792
1338
  } else if (result.strength === "imperfect") {
1793
- base *= 0.8;
1339
+ base *= IMPERFECT_CLOSURE_FACTOR;
1794
1340
  }
1795
1341
  }
1796
1342
  if (result.evaded) {
@@ -1828,17 +1374,18 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1828
1374
  const segments = timeline.segments;
1829
1375
  const firstSegment = segments[0];
1830
1376
  const lastSegment = segments[segments.length - 1];
1831
- const firstOnset = sounding.reduce(
1832
- (first, n) => Math.min(first, n.startBeat),
1833
- firstSegment?.startBeat ?? 0
1377
+ const earliest = Math.min(
1378
+ firstSoundingBeat(sounding, Number.POSITIVE_INFINITY),
1379
+ firstSegment?.startBeat ?? Number.POSITIVE_INFINITY
1834
1380
  );
1381
+ const firstOnset = Number.isFinite(earliest) ? earliest : 0;
1835
1382
  const spanStart = barGridStart(firstOnset, meters);
1836
- const notesEnd = sounding.reduce(
1837
- (end, n) => Math.max(end, n.startBeat + n.durationBeat),
1838
- lastSegment?.endBeat ?? 0
1383
+ const latestEnd = Math.max(
1384
+ lastSegment?.endBeat ?? Number.NEGATIVE_INFINITY,
1385
+ latestSoundingEnd(sounding, Number.NEGATIVE_INFINITY)
1839
1386
  );
1840
- const spanEnd = Math.max(spanStart, opts.totalBeats ?? notesEnd);
1841
- assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "phrase totalBeats");
1387
+ const notesEnd = Number.isFinite(latestEnd) ? latestEnd : 0;
1388
+ const spanEnd = resolveSpanEnd(opts.totalBeats, spanStart, notesEnd, "phrase totalBeats");
1842
1389
  if (spanEnd <= spanStart + BEAT_EPS) {
1843
1390
  return [];
1844
1391
  }
@@ -1849,13 +1396,9 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1849
1396
  assertRange(longNoteBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase longNoteBeats");
1850
1397
  const minPhraseBeats = opts.minPhraseBeats ?? barBeats;
1851
1398
  assertRange(minPhraseBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase minPhraseBeats");
1852
- const key = keyContextFor(opts, sounding, meters, spanEnd);
1853
- const cadences = detectCadences(timeline, key);
1854
- const grouping = opts.hypermeter ?? hypermeter(sounding, meters, {
1855
- totalBeats: spanEnd,
1856
- cadenceBeats: cadences.map((hit) => hit.atBeat),
1857
- budget: opts.budget
1858
- });
1399
+ const key = keyContextFor(opts.key, timeline, meters, spanEnd, opts.budget);
1400
+ const { hits: cadences, spans } = boundaryCadences(timeline, key);
1401
+ const grouping = opts.hypermeter ?? hypermeterOver(sounding, meters, { totalBeats: spanEnd, budget: opts.budget }, () => spans);
1859
1402
  const firstBar = barIndexAt(Math.max(0, spanStart), meters);
1860
1403
  const hyperBeats = Math.max(
1861
1404
  barBeats,
@@ -1864,19 +1407,11 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1864
1407
  const expected = opts.expectedPhraseBeats ?? hyperBeats;
1865
1408
  assertRange(expected, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase expectedPhraseBeats");
1866
1409
  const boundaries = /* @__PURE__ */ new Map();
1867
- for (const hit of cadences) {
1868
- const type = hit.cadence.type;
1869
- if (type === null) {
1870
- continue;
1871
- }
1872
- const strength = CADENCE_BOUNDARY_STRENGTH[type];
1873
- const chordEnd = arrivalEnd(timeline, hit.atBeat);
1874
- addSignal(boundaries, chordEnd, "cadence", strength, hit);
1875
- const barEnd = barPositionToBeat({ bar: barIndexAt(hit.atBeat, meters) + 1, beat: 0 }, meters);
1876
- if (chordEnd > barEnd + BEAT_EPS) {
1877
- addSignal(boundaries, barEnd, "cadence", strength, hit);
1878
- }
1879
- }
1410
+ cadences.forEach((hit, index) => {
1411
+ const span = spans[index];
1412
+ addSignal(boundaries, span.endBeat, "cadence", span.strength, hit);
1413
+ heldCadenceBoundaries(boundaries, hit, span, meters, spanEnd);
1414
+ });
1880
1415
  restBoundaries(boundaries, sounding, meters, restBeats);
1881
1416
  for (const note of sounding) {
1882
1417
  if (note.durationBeat >= longNoteBeats - BEAT_EPS) {
@@ -1907,7 +1442,11 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1907
1442
  stops.map((stop) => stop.beat),
1908
1443
  readings.map((reading) => reading.strength),
1909
1444
  minPhraseBeats,
1910
- expected
1445
+ expected,
1446
+ {
1447
+ arrivals: stops.map((stop) => stop.cadence?.atBeat ?? stop.beat),
1448
+ uncredited: readings.map((reading) => reading.uncredited)
1449
+ }
1911
1450
  );
1912
1451
  const downbeatKeys = new Set(
1913
1452
  grouping.downbeats.map((downbeat) => Math.round(downbeat / BEAT_EPS))
@@ -1919,14 +1458,15 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1919
1458
  const startBeat = stops[from]?.beat ?? spanStart;
1920
1459
  const stop = stops[to];
1921
1460
  const endBeat = stop?.beat ?? spanEnd;
1922
- const reading = readings[to] ?? { strength: 0, signals: [] };
1461
+ const reading = readings[to] ?? { strength: 0, uncredited: 0, signals: [] };
1923
1462
  const length = endBeat - startBeat;
1924
- const confidence = clamp01(
1925
- CLOSING_SHARE * reading.strength + (1 - CLOSING_SHARE) * Math.max(0, lengthFit(length, expected))
1926
- );
1927
1463
  const isLast = n === path.length - 1;
1928
1464
  const closing = stop?.cadence ?? null;
1929
- const cadence = closing !== null && closing.atBeat >= startBeat - BEAT_EPS && closing.atBeat < endBeat - BEAT_EPS ? closing : null;
1465
+ const cadence = closing !== null && closesPhrase(closing.atBeat, startBeat, endBeat) ? closing : null;
1466
+ const closingStrength = cadence === null ? reading.uncredited : reading.strength;
1467
+ const confidence = clamp01(
1468
+ CLOSING_SHARE * closingStrength + (1 - CLOSING_SHARE) * Math.max(0, lengthFit(length, expected))
1469
+ );
1930
1470
  const structuralWeight = structuralWeightOf(
1931
1471
  cadence,
1932
1472
  confidence,
@@ -1934,7 +1474,7 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1934
1474
  isLast
1935
1475
  );
1936
1476
  const signals = cadence === null ? reading.signals.filter((signal) => signal !== "cadence") : reading.signals;
1937
- const bars = barSpanOf(startBeat, endBeat, meters);
1477
+ const bars = Math.round(barSpanOf(startBeat, endBeat, meters) * 100) / 100;
1938
1478
  phrases.push({
1939
1479
  startBeat,
1940
1480
  endBeat,
@@ -2000,13 +1540,17 @@ function sectionsFromNotes(notes, opts = {}) {
2000
1540
  budget: opts.budget
2001
1541
  });
2002
1542
  const sounding = notes.filter((note) => note.durationBeat > 0);
1543
+ const firstSounding = firstSoundingBeat(sounding);
1544
+ const firstOnset = barGridStart(firstSounding, meters);
1545
+ const spanEnd = resolveSpanEnd(
1546
+ opts.totalBeats,
1547
+ firstOnset,
1548
+ latestSoundingEnd(sounding),
1549
+ "section totalBeats"
1550
+ );
2003
1551
  if (sounding.length === 0) {
2004
1552
  return [];
2005
1553
  }
2006
- const firstOnset = barGridStart(firstSoundingBeat(sounding), meters);
2007
- const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
2008
- const spanEnd = Math.max(firstOnset, opts.totalBeats ?? lastEnd);
2009
- assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "section totalBeats");
2010
1554
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
2011
1555
  assertRange(threshold, 0, 1, "section threshold");
2012
1556
  const unitBars = opts.unitBars ?? hypermeter(sounding, meters, { totalBeats: spanEnd, budget: opts.budget }).groupBars;
@@ -2022,8 +1566,9 @@ function sectionsFromNotes(notes, opts = {}) {
2022
1566
  const barStart = startBar + index * unitBars;
2023
1567
  const startBeat = index === 0 ? firstOnset : barPositionToBeat({ bar: barStart, beat: 0 }, meters);
2024
1568
  const endBeat = index === unitCount - 1 ? spanEnd : barPositionToBeat({ bar: barStart + unitBars, beat: 0 }, meters);
1569
+ const fromBeat = index === 0 ? Math.min(firstSounding, startBeat) : startBeat;
2025
1570
  const unitNotes = sounding.filter(
2026
- (note) => note.startBeat >= startBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS
1571
+ (note) => note.startBeat >= fromBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS
2027
1572
  ).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
2028
1573
  units.push({
2029
1574
  startBeat,
@@ -2120,175 +1665,294 @@ function sectionsFromNotes(notes, opts = {}) {
2120
1665
  return sections;
2121
1666
  }
2122
1667
 
2123
- // src/analyze/reduction/index.ts
2124
- var TONIC_ARRIVING = /* @__PURE__ */ new Set(["authentic", "plagal", "modal", "deceptive"]);
2125
- function signedStep(fromPc, toPc) {
2126
- return (pitchClassOf(toPc) - pitchClassOf(fromPc) + 18) % 12 - 6;
2127
- }
2128
- function isStep(distance) {
2129
- const size = Math.abs(distance);
2130
- return size === 1 || size === 2;
2131
- }
2132
- function touches(before, after) {
2133
- return Math.abs(after.startBeat - before.endBeat) <= BEAT_EPS;
2134
- }
2135
- function salienceFloors(durations) {
2136
- const order = durations.map((_, index) => index).sort((a, b) => {
2137
- const left = durations[a] ?? 0;
2138
- const right = durations[b] ?? 0;
2139
- return left - right || a - b;
2140
- });
2141
- const rankOf = new Array(durations.length).fill(0);
2142
- for (let rank = 0; rank < order.length; rank += 1) {
2143
- rankOf[order[rank] ?? 0] = rank;
2144
- }
2145
- const floors = [];
2146
- const remaining = durations.length - 1;
2147
- for (let index = 0; index < durations.length; index += 1) {
2148
- if (remaining <= 0) {
2149
- floors.push(0);
2150
- continue;
1668
+ // src/analyze/reference/compare.ts
1669
+ var RATIONALE_FIELDS = 2;
1670
+ function rationaleOf(comparison) {
1671
+ const measured = [];
1672
+ for (const group of [comparison.form, comparison.harmony, comparison.melody, comparison.rhythm]) {
1673
+ for (const [name, value] of Object.entries(group)) {
1674
+ if (value !== null) {
1675
+ measured.push({ name, value, order: measured.length });
1676
+ }
2151
1677
  }
2152
- const skipped = rankOf[index] ?? 0;
2153
- const at = (position) => durations[order[position < skipped ? position : position + 1] ?? 0] ?? 0;
2154
- const middle = remaining >> 1;
2155
- floors.push(remaining % 2 === 1 ? at(middle) : (at(middle - 1) + at(middle)) / 2);
2156
- }
2157
- return floors;
2158
- }
2159
- function isTonicChord(chord, key) {
2160
- return pitchClassOf(chord.rootPc - key.rootPc) === 0 && !isAppliedDominant(chord, key);
2161
- }
2162
- function functionalReason(segment, before, after, keyAt) {
2163
- const key = keyAt(segment.startBeat);
2164
- if (after !== void 0 && isCadentialSixFour(segment.chord, after.chord, key)) {
2165
- return { kind: "cadentialSixFour" };
2166
- }
2167
- if (isTonicChord(segment.chord, key)) {
2168
- return { kind: "tonic" };
2169
- }
2170
- if (isDominantChordOf(segment.chord, key)) {
2171
- return { kind: "dominant" };
2172
- }
2173
- if (before !== void 0) {
2174
- const arrival = detectCadence(before.chord, segment.chord, key).type;
2175
- if (arrival !== null) {
2176
- return { kind: "cadenceArrival", cadence: arrival };
2177
- }
2178
- }
2179
- if (after !== void 0) {
2180
- const agent = detectCadence(segment.chord, after.chord, keyAt(after.startBeat)).type;
2181
- if (agent !== null && TONIC_ARRIVING.has(agent)) {
2182
- return { kind: "cadenceAgent", cadence: agent };
2183
- }
2184
- }
2185
- return null;
2186
- }
2187
- function figureReason(segment, before, after) {
2188
- if (before === void 0 || after === void 0) {
2189
- return null;
2190
- }
2191
- const incoming = signedStep(before.chord.rootPc, segment.chord.rootPc);
2192
- const outgoing = signedStep(segment.chord.rootPc, after.chord.rootPc);
2193
- if (!isStep(incoming) || !isStep(outgoing)) {
2194
- return null;
2195
- }
2196
- if (incoming > 0 === outgoing > 0) {
2197
- return { kind: "passing" };
2198
- }
2199
- return pitchClassOf(before.chord.rootPc) === pitchClassOf(after.chord.rootPc) ? { kind: "neighbor" } : null;
2200
- }
2201
- function describe(reason) {
2202
- switch (reason.kind) {
2203
- case "tonic":
2204
- return "Structural: the tonic of the key, which the progression is heard against";
2205
- case "dominant":
2206
- return "Structural: the dominant of the key, the chord its tonic is approached from";
2207
- case "cadentialSixFour":
2208
- return "Structural: a tonic six-four on the bass of the dominant that follows, which prolongs that dominant rather than framing the tonic";
2209
- case "cadenceArrival":
2210
- return `Structural: the arrival of a ${reason.cadence} cadence`;
2211
- case "cadenceAgent":
2212
- return `Structural: the chord that drives a ${reason.cadence} cadence`;
2213
- case "salient":
2214
- return "Structural: it holds the harmony longer than the chords around it";
2215
- case "first":
2216
- return "Structural: the first chord of the progression frames it";
2217
- case "last":
2218
- return "Structural: the last chord of the progression frames it";
2219
- case "unfigured":
2220
- return "Structural: its root neither steps through to the next chord nor returns to the last, so it embellishes nothing";
2221
- case "passing":
2222
- return "Passing: its root steps in from the previous chord and on to the next in the same direction";
2223
- case "neighbor":
2224
- return "Neighbor: its root steps away from the surrounding harmony and returns to it";
2225
- }
2226
- }
2227
- var BASIS_READINGS = {
2228
- function: ({ segment, before, after, keyAt }) => functionalReason(segment, before, after, keyAt),
2229
- duration: ({ segment, salienceFloor }) => segment.endBeat - segment.startBeat > salienceFloor ? { kind: "salient" } : null
2230
- };
2231
- var REDUCTION_BASES = Object.keys(BASIS_READINGS);
2232
- function levelOf(reason) {
2233
- if (reason.kind === "passing") {
2234
- return "passing";
2235
1678
  }
2236
- return reason.kind === "neighbor" ? "neighbor" : "structural";
1679
+ if (measured.length === 0) {
1680
+ return "Nothing to compare: neither profile has material for any field";
1681
+ }
1682
+ const describe = (entries) => entries.map((entry) => `${entry.name} ${Math.round(entry.value * 100)}%`).join(", ");
1683
+ const highest = [...measured].sort((x, y) => y.value - x.value || x.order - y.order);
1684
+ const lowest = [...measured].sort((x, y) => x.value - y.value || x.order - y.order);
1685
+ return `Most alike: ${describe(highest.slice(0, RATIONALE_FIELDS))}; least alike: ${describe(lowest.slice(0, RATIONALE_FIELDS))}`;
1686
+ }
1687
+ function compareReferences(a, b, opts) {
1688
+ assertReferenceProfile(a, "reference profile a");
1689
+ assertReferenceProfile(b, "reference profile b");
1690
+ const { budget } = assertOptions(opts, "compare options");
1691
+ const structural = (profile) => profile.harmony.chords.filter((chord) => chord.level === "structural");
1692
+ const cadences = (profile) => profile.form.phrases.map((phrase) => phrase.cadence?.type ?? null);
1693
+ const phraseMelodies = (profile) => profile.form.phrases.map((phrase) => phrase.melody);
1694
+ const comparison = {
1695
+ form: {
1696
+ sectionSequenceSimilarity: sectionSequenceSimilarity(
1697
+ a.form.sections.map((section) => section.label),
1698
+ b.form.sections.map((section) => section.label)
1699
+ ),
1700
+ phraseLengthSimilarity: phraseLengthSimilarity(
1701
+ a.form.phrases.map((phrase) => phrase.bars),
1702
+ b.form.phrases.map((phrase) => phrase.bars)
1703
+ )
1704
+ },
1705
+ harmony: {
1706
+ progressionSimilarity: progressionSimilarity(structural(a), structural(b), budget),
1707
+ functionSimilarity: functionSimilarity(a.harmony.chords, b.harmony.chords),
1708
+ cadenceSimilarity: cadenceSimilarity(cadences(a), cadences(b)),
1709
+ keyPlanSimilarity: keyPlanSimilarity(
1710
+ a.harmony.keys.map((region) => region.key),
1711
+ b.harmony.keys.map((region) => region.key)
1712
+ ),
1713
+ harmonicRhythmOnsetSimilarity: onsetSimilarity(a.harmony.rhythm, b.harmony.rhythm),
1714
+ harmonicRhythmDensitySimilarity: densitySimilarity(a.harmony.rhythm, b.harmony.rhythm)
1715
+ },
1716
+ melody: {
1717
+ surfaceSimilarity: surfaceSimilarity(a.melody.motifs, b.melody.motifs),
1718
+ motifStructureSimilarity: motifStructureSimilarity(
1719
+ { graph: a.melody.graph, spanBeats: a.span.endBeat - a.span.startBeat },
1720
+ { graph: b.melody.graph, spanBeats: b.span.endBeat - b.span.startBeat }
1721
+ ),
1722
+ contourSimilarity: contourSimilarity(phraseMelodies(a), phraseMelodies(b)),
1723
+ registerSimilarity: registerSimilarity(
1724
+ { phrases: phraseMelodies(a), mean: a.melody.register?.mean ?? null },
1725
+ { phrases: phraseMelodies(b), mean: b.melody.register?.mean ?? null }
1726
+ )
1727
+ },
1728
+ rhythm: {
1729
+ onsetSimilarity: onsetSimilarity(a.melody.rhythm, b.melody.rhythm),
1730
+ densitySimilarity: densitySimilarity(a.melody.rhythm, b.melody.rhythm),
1731
+ durationSimilarity: durationSimilarity(a.melody.rhythm, b.melody.rhythm),
1732
+ syncopationSimilarity: syncopationSimilarity(a.melody.rhythm, b.melody.rhythm)
1733
+ }
1734
+ };
1735
+ return { ...comparison, rationale: rationaleOf(comparison) };
2237
1736
  }
2238
- function reduceProgression(timeline, key, opts = {}) {
2239
- assertChordTimeline(timeline);
2240
- const asked = assertOptions(opts, "opts");
2241
- const keyAt = keyScaleAt(key);
2242
- const basis = assertOneOf(asked.basis ?? "function", REDUCTION_BASES, "reduction basis");
2243
- const reading = BASIS_READINGS[basis];
1737
+
1738
+ // src/analyze/reference/profile.ts
1739
+ function spanOf(sounding, timeline, meters, totalBeats) {
2244
1740
  const segments = timeline.segments;
2245
- const floors = basis === "duration" ? salienceFloors(segments.map((segment) => segment.endBeat - segment.startBeat)) : [];
2246
- const result = [];
2247
- for (let index = 0; index < segments.length; index += 1) {
2248
- const segment = segments[index];
2249
- if (segment === void 0) {
2250
- continue;
2251
- }
2252
- const previous = segments[index - 1];
2253
- const following = segments[index + 1];
2254
- const before = previous !== void 0 && touches(previous, segment) ? previous : void 0;
2255
- const after = following !== void 0 && touches(segment, following) ? following : void 0;
2256
- let reason = reading({ segment, before, after, keyAt, salienceFloor: floors[index] ?? 0 });
2257
- if (reason === null && index === 0) {
2258
- reason = { kind: "first" };
2259
- }
2260
- if (reason === null && index === segments.length - 1) {
2261
- reason = { kind: "last" };
2262
- }
2263
- reason ??= figureReason(segment, before, after) ?? { kind: "unfigured" };
2264
- result.push({
2265
- chord: segment.chord,
2266
- startBeat: segment.startBeat,
2267
- endBeat: segment.endBeat,
2268
- level: levelOf(reason),
2269
- rationale: describe(reason)
1741
+ const firstSegment = segments[0];
1742
+ const lastSegment = segments[segments.length - 1];
1743
+ const firstOnset = firstSoundingBeat(sounding, firstSegment?.startBeat ?? 0);
1744
+ const startBeat = barGridStart(
1745
+ Math.min(firstOnset, firstSegment?.startBeat ?? firstOnset),
1746
+ meters
1747
+ );
1748
+ const latestEnd = Math.max(
1749
+ lastSegment?.endBeat ?? Number.NEGATIVE_INFINITY,
1750
+ latestSoundingEnd(sounding, Number.NEGATIVE_INFINITY)
1751
+ );
1752
+ const notesEnd = Number.isFinite(latestEnd) ? latestEnd : 0;
1753
+ return { startBeat, endBeat: Math.max(startBeat, totalBeats ?? notesEnd) };
1754
+ }
1755
+ function sameKey(a, b) {
1756
+ return pitchClassOf(a.scale.rootPc) === pitchClassOf(b.scale.rootPc) && a.scale.modeMask12 === b.scale.modeMask12;
1757
+ }
1758
+ function keyIndexAt(regions, beat, prevailing) {
1759
+ const holding = regions.findIndex((region) => region.startBeat <= beat && beat < region.endBeat);
1760
+ if (holding >= 0) {
1761
+ return holding;
1762
+ }
1763
+ return Math.max(
1764
+ 0,
1765
+ regions.findIndex((region) => sameKey(region.key, prevailing))
1766
+ );
1767
+ }
1768
+ function sectionAt(sections, beat) {
1769
+ const index = sections.findIndex(
1770
+ (section) => beat >= section.startBeat - BEAT_EPS && beat < section.endBeat - BEAT_EPS
1771
+ );
1772
+ return index < 0 ? null : index;
1773
+ }
1774
+ function readingsFor(notes, opts = {}) {
1775
+ const asked = assertOptions(opts, "reference options");
1776
+ const meters = resolveMeters(asked, "reference meters");
1777
+ const budget = asked.budget;
1778
+ if (asked.timeline === void 0) {
1779
+ return chordTimelineFromNotes(notes, {
1780
+ meters,
1781
+ key: asked.key,
1782
+ totalBeats: asked.totalBeats,
1783
+ budget
2270
1784
  });
2271
1785
  }
2272
- return result;
1786
+ const timeline = assertChordTimeline(asked.timeline, "reference timeline");
1787
+ let keys;
1788
+ if (asked.key === void 0) {
1789
+ keys = keyTimelineFromNotes(notes, { meters, totalBeats: asked.totalBeats, budget });
1790
+ } else {
1791
+ const sounding = assertNoteEvents(notes, "reference notes", {
1792
+ allowNonPositiveDuration: true,
1793
+ budget
1794
+ }).filter((note) => note.durationBeat > 0);
1795
+ const span = spanOf(sounding, timeline, meters, asked.totalBeats);
1796
+ keys = statedKeyRegions(resolveKey(asked.key), span.startBeat, span.endBeat);
1797
+ }
1798
+ return { timeline, keys, prevailingKey: prevailingKeyOf(keys) ?? resolveKey(majorKey(0)) };
1799
+ }
1800
+ function referenceFromReadings(notes, readings, opts = {}) {
1801
+ const asked = assertOptions(opts, "reference options");
1802
+ const meters = resolveMeters(asked, "reference meters");
1803
+ const budget = asked.budget;
1804
+ const sounding = assertNoteEvents(notes, "reference notes", {
1805
+ allowNonPositiveDuration: true,
1806
+ budget
1807
+ }).filter((note) => note.durationBeat > 0);
1808
+ if (asked.totalBeats !== void 0) {
1809
+ assertRange(asked.totalBeats, 0, Number.MAX_SAFE_INTEGER, "reference totalBeats");
1810
+ }
1811
+ const line = orderedNotes(asked.melody ?? notes, "reference melody", budget).map(
1812
+ (note) => Object.is(note.pitch, -0) ? { ...note, pitch: 0 } : note
1813
+ );
1814
+ const { timeline, prevailingKey } = readings;
1815
+ const span = spanOf(sounding, timeline, meters, asked.totalBeats);
1816
+ const spanEnd = span.endBeat;
1817
+ const keyAt = keyLookup(readings.keys, prevailingKey);
1818
+ const scaleAt = (beat) => scaleOf(keyAt(beat));
1819
+ const cadenceBeats = detectCadences(timeline, scaleAt).map((hit) => hit.atBeat);
1820
+ const grouping = hypermeter(sounding, meters, { cadenceBeats, totalBeats: spanEnd, budget });
1821
+ const phrases = phrasesFromTimeline(timeline, notes, {
1822
+ meters,
1823
+ key: scaleAt,
1824
+ hypermeter: grouping,
1825
+ totalBeats: spanEnd,
1826
+ budget
1827
+ });
1828
+ const cadences = /* @__PURE__ */ new Map();
1829
+ for (const closing of structuralCadences(phrases)) {
1830
+ const type = closing.cadence.type;
1831
+ if (type !== null) {
1832
+ cadences.set(closing.phraseIndex, {
1833
+ atBeat: closing.atBeat,
1834
+ type,
1835
+ weight: closing.weight
1836
+ });
1837
+ }
1838
+ }
1839
+ const sections = sectionsFromNotes(notes, {
1840
+ meters,
1841
+ unitBars: grouping.groupBars,
1842
+ totalBeats: spanEnd,
1843
+ budget
1844
+ });
1845
+ const keys = readings.keys.length === 0 && timeline.segments.length > 0 ? [
1846
+ {
1847
+ startBeat: span.startBeat,
1848
+ endBeat: spanEnd,
1849
+ key: resolveKey(prevailingKey),
1850
+ confidence: 0
1851
+ }
1852
+ ] : readings.keys.map((region) => ({
1853
+ startBeat: region.startBeat,
1854
+ endBeat: region.endBeat,
1855
+ key: resolveKey(region.key),
1856
+ confidence: region.confidence
1857
+ }));
1858
+ const chords = reduceProgression(timeline, scaleAt).map((reduced) => {
1859
+ const index = keyIndexAt(keys, (reduced.startBeat + reduced.endBeat) / 2, prevailingKey);
1860
+ const key = keys[index].key;
1861
+ return {
1862
+ startBeat: reduced.startBeat,
1863
+ endBeat: reduced.endBeat,
1864
+ key: index,
1865
+ roman: chordToRoman(reduced.chord, key),
1866
+ function: functionOf(reduced.chord, key),
1867
+ level: reduced.level
1868
+ };
1869
+ });
1870
+ const chordOnsets = timeline.segments.map((segment) => ({
1871
+ pitch: 60,
1872
+ startBeat: segment.startBeat,
1873
+ durationBeat: segment.endBeat - segment.startBeat
1874
+ }));
1875
+ const harmonyRhythm = analyzeRhythm(chordOnsets, { meters, totalBeats: spanEnd, budget });
1876
+ const motifs = extractMotifs(line, { budget });
1877
+ const graph = motifGraph(line, motifs, { key: prevailingKey, budget });
1878
+ let register = null;
1879
+ if (line.length > 0) {
1880
+ const pitches = line.map((note) => note.pitch);
1881
+ register = {
1882
+ low: Math.min(...pitches),
1883
+ high: Math.max(...pitches),
1884
+ mean: weightedMean(line)
1885
+ };
1886
+ }
1887
+ const lineMean = register?.mean ?? 0;
1888
+ const referenceMotifs = motifs.map((motif) => {
1889
+ const first = motif.occurrences[0];
1890
+ return {
1891
+ intervals: motif.intervals,
1892
+ rhythm: motif.rhythm,
1893
+ unitBeats: motif.notes.length < 2 ? 0 : motif.notes[1].startBeat - motif.notes[0].startBeat,
1894
+ spanBeats: first === void 0 ? 0 : first.endBeat - first.startBeat,
1895
+ occurrences: motif.occurrences.length
1896
+ };
1897
+ });
1898
+ const referencePhrases = phrases.map((phrase, index) => ({
1899
+ startBeat: phrase.startBeat,
1900
+ endBeat: phrase.endBeat,
1901
+ bars: barSpanOf(phrase.startBeat, phrase.endBeat, meters),
1902
+ section: sectionAt(sections, phrase.startBeat),
1903
+ confidence: phrase.confidence,
1904
+ cadence: cadences.get(index) ?? null,
1905
+ melody: phraseMelody(line, phrase.startBeat, phrase.endBeat, lineMean, meters, budget),
1906
+ motifNodes: graph.nodes.flatMap(
1907
+ (node, nodeIndex) => node.startBeat >= phrase.startBeat - BEAT_EPS && node.startBeat < phrase.endBeat - BEAT_EPS ? [nodeIndex] : []
1908
+ )
1909
+ }));
1910
+ return {
1911
+ profileVersion: REFERENCE_PROFILE_VERSION,
1912
+ span: {
1913
+ startBeat: span.startBeat,
1914
+ endBeat: spanEnd,
1915
+ bars: barSpanOf(span.startBeat, spanEnd, meters)
1916
+ },
1917
+ meters,
1918
+ form: {
1919
+ hypermeter: { groupBars: grouping.groupBars, confidence: grouping.confidence },
1920
+ sections,
1921
+ phrases: referencePhrases
1922
+ },
1923
+ harmony: { keys, chords, rhythm: harmonyRhythm },
1924
+ melody: {
1925
+ register,
1926
+ shape: line.length < 2 ? null : melodicContour(line).shape,
1927
+ motifs: referenceMotifs,
1928
+ graph,
1929
+ rhythm: rhythmOver(line, { meters, totalBeats: spanEnd, budget }, span.startBeat)
1930
+ }
1931
+ };
1932
+ }
1933
+ function analyzeReference(notes, opts) {
1934
+ return referenceFromReadings(notes, readingsFor(notes, opts), opts);
2273
1935
  }
2274
1936
 
2275
1937
  export {
2276
1938
  trackRoleOf,
1939
+ arrangementProfile,
1940
+ assertTrackNotes,
1941
+ harmonyTrackSet,
2277
1942
  analyzePolyphony,
2278
1943
  analyzeArrangement,
2279
1944
  createArrangementSession,
2280
1945
  tensionCurve,
2281
1946
  tensionCurveFrom,
2282
1947
  hypermeter,
2283
- melodicContour,
2284
- extractMotifs,
2285
- motifFromNotes,
2286
- relateMotifs,
2287
- compareMelodies,
2288
- melodicSimilarity,
1948
+ hypermeterOver,
1949
+ boundaryCadences,
1950
+ keyContextFor,
2289
1951
  phrasesFromTimeline,
2290
1952
  structuralCadences,
2291
1953
  sectionsFromNotes,
2292
- reduceProgression
1954
+ compareReferences,
1955
+ referenceFromReadings,
1956
+ analyzeReference
2293
1957
  };
2294
- //# sourceMappingURL=chunk-HYJJ2V2I.js.map
1958
+ //# sourceMappingURL=chunk-UTE5A36R.js.map