@libraz/libcantus 1.3.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 (130) hide show
  1. package/NOTICE +63 -0
  2. package/README.md +3 -1
  3. package/README_ja.md +13 -11
  4. package/dist/analyze/index.cjs +7 -7
  5. package/dist/analyze/index.cjs.map +1 -1
  6. package/dist/analyze/index.d.cts +14 -12
  7. package/dist/analyze/index.d.ts +14 -12
  8. package/dist/analyze/index.js +6 -6
  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-YFA7X2F6.cjs → chunk-3MDOEFIQ.cjs} +411 -327
  14. package/dist/chunk-3MDOEFIQ.cjs.map +1 -0
  15. package/dist/{chunk-IPEF6NWB.js → chunk-5MWRBYAT.js} +278 -154
  16. package/dist/chunk-5MWRBYAT.js.map +1 -0
  17. package/dist/{chunk-B2PW6EHN.cjs → chunk-6TTBEON3.cjs} +1253 -889
  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-PWC4CRWT.cjs → chunk-E3QO3UET.cjs} +31 -23
  22. package/dist/chunk-E3QO3UET.cjs.map +1 -0
  23. package/dist/{chunk-GNVN4PPJ.js → chunk-FJFULJZE.js} +531 -213
  24. package/dist/chunk-FJFULJZE.js.map +1 -0
  25. package/dist/{chunk-CPMB7P75.js → chunk-FK3DGAIZ.js} +608 -244
  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-4R2LWV7E.cjs → chunk-HYSVSFNI.cjs} +664 -540
  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-IFZDWIKM.cjs → chunk-LQZQDBCD.cjs} +1048 -730
  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-MLBPXA4Z.js → chunk-UTE5A36R.js} +198 -114
  46. package/dist/chunk-UTE5A36R.js.map +1 -0
  47. package/dist/{chunk-ZBUROTJQ.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 +8 -8
  67. package/dist/generate/index.cjs.map +1 -1
  68. package/dist/generate/index.d.cts +28 -18
  69. package/dist/generate/index.d.ts +28 -18
  70. package/dist/generate/index.js +7 -7
  71. package/dist/{index-BMweUlm5.d.cts → index-5VJgKz_Q.d.cts} +6 -2
  72. package/dist/{index-BHIUyjhZ.d.ts → index-BLuDesVG.d.ts} +20 -13
  73. package/dist/{index-B9QLziDe.d.cts → index-BV6APVa6.d.cts} +20 -13
  74. package/dist/{index-CL4b4gUQ.d.ts → 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-CY-3NVfy.d.cts → index-DVInzCDS.d.cts} +53 -29
  80. package/dist/{index-9eoQs2C1.d.ts → index-Dg-SAfhz.d.ts} +6 -4
  81. package/dist/{index-zaZdmnU4.d.ts → index-jgeug9EA.d.ts} +53 -29
  82. package/dist/{index-cHvbYgJI.d.cts → index-pQihL4z7.d.cts} +6 -4
  83. package/dist/index.cjs +13 -13
  84. package/dist/index.cjs.map +1 -1
  85. package/dist/index.d.cts +11 -11
  86. package/dist/index.d.ts +11 -11
  87. package/dist/index.js +12 -12
  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 +63 -48
  93. package/dist/model/index.d.ts +63 -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/{profile-DLdlIpS9.d.ts → profile-B4WqcM4H.d.ts} +25 -17
  98. package/dist/{profile-CQMEqdH9.d.cts → profile-BJd1XkPB.d.cts} +25 -17
  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/{types-D-XgxGvp.d.cts → types-7mGFwvMm.d.ts} +45 -22
  105. package/dist/{types-Dh7C064C.d.ts → types-_ofpb2be.d.cts} +45 -22
  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-4R2LWV7E.cjs.map +0 -1
  110. package/dist/chunk-76ZBTRG2.cjs.map +0 -1
  111. package/dist/chunk-7KG3RTYS.js.map +0 -1
  112. package/dist/chunk-AGOVW7U6.js.map +0 -1
  113. package/dist/chunk-B2PW6EHN.cjs.map +0 -1
  114. package/dist/chunk-CGBADIO5.cjs.map +0 -1
  115. package/dist/chunk-CPMB7P75.js.map +0 -1
  116. package/dist/chunk-GNVN4PPJ.js.map +0 -1
  117. package/dist/chunk-IFZDWIKM.cjs.map +0 -1
  118. package/dist/chunk-IPEF6NWB.js.map +0 -1
  119. package/dist/chunk-M5RYMAFI.js.map +0 -1
  120. package/dist/chunk-MLBPXA4Z.js.map +0 -1
  121. package/dist/chunk-NB25P3EH.js.map +0 -1
  122. package/dist/chunk-NM7UAMC3.cjs.map +0 -1
  123. package/dist/chunk-PM4XJE2Y.cjs.map +0 -1
  124. package/dist/chunk-PUKJP2Z5.js.map +0 -1
  125. package/dist/chunk-PWC4CRWT.cjs.map +0 -1
  126. package/dist/chunk-RFIBF2RR.js.map +0 -1
  127. package/dist/chunk-S5BKZZGM.cjs.map +0 -1
  128. package/dist/chunk-UMWZ4ZET.js.map +0 -1
  129. package/dist/chunk-YFA7X2F6.cjs.map +0 -1
  130. package/dist/chunk-ZBUROTJQ.js.map +0 -1
@@ -6,6 +6,7 @@ import {
6
6
  analyzeVoice,
7
7
  assertChordTimeline,
8
8
  assertReferenceProfile,
9
+ assertSpansInOrder,
9
10
  attachPivots,
10
11
  barGridStart,
11
12
  barSpanOf,
@@ -17,6 +18,7 @@ import {
17
18
  contourSimilarity,
18
19
  densitySimilarity,
19
20
  detectCadences,
21
+ detectModulations,
20
22
  dirtySlotsFor,
21
23
  durationSimilarity,
22
24
  extractMotifs,
@@ -31,6 +33,7 @@ import {
31
33
  keyPlanSimilarity,
32
34
  keyTimelineFromNotes,
33
35
  lastBarOf,
36
+ latestSoundingEnd,
34
37
  melodicContour,
35
38
  melodicSimilarity,
36
39
  motifGraph,
@@ -43,16 +46,19 @@ import {
43
46
  progressionSimilarity,
44
47
  reduceProgression,
45
48
  registerSimilarity,
49
+ resolveSpanEnd,
50
+ rhythmOver,
46
51
  sameInstant,
47
52
  sectionSequenceSimilarity,
48
53
  segmentStartingAt,
49
54
  sliceBars,
55
+ statedKeyRegions,
50
56
  surfaceSimilarity,
51
57
  syncopationSimilarity,
52
58
  weightSimilarity,
53
59
  weightedMean,
54
60
  windowWeights
55
- } from "./chunk-GNVN4PPJ.js";
61
+ } from "./chunk-FJFULJZE.js";
56
62
  import {
57
63
  PROFILE_WEIGHTS,
58
64
  chordPitchClasses,
@@ -60,7 +66,7 @@ import {
60
66
  majorKey,
61
67
  resolveKey,
62
68
  scaleOf
63
- } from "./chunk-RFIBF2RR.js";
69
+ } from "./chunk-H7FVMBUC.js";
64
70
  import {
65
71
  BEAT_EPS,
66
72
  InvalidInputError,
@@ -82,7 +88,7 @@ import {
82
88
  isStrongBeat,
83
89
  pitchClassOf,
84
90
  resolveMeters
85
- } from "./chunk-AGOVW7U6.js";
91
+ } from "./chunk-YYVXRV3V.js";
86
92
 
87
93
  // src/analyze/arrange/internal.ts
88
94
  var MAX_LANE_LEAP = 24;
@@ -115,7 +121,7 @@ function assertTrackNotes(tracks, options) {
115
121
  const notes = tracks[index]?.notes;
116
122
  if (!Array.isArray(notes)) {
117
123
  throw new InvalidInputError(
118
- `tracks[${index}].notes must be an array; received ${typeof notes}`
124
+ `tracks[${index}].notes must be an array; received ${describeRejected(notes)}`
119
125
  );
120
126
  }
121
127
  assertNoteEvents(notes, `tracks[${index}].notes`, options);
@@ -153,6 +159,7 @@ function assertGivenKeys(keys, name) {
153
159
  }
154
160
  resolveKey(region.key);
155
161
  }
162
+ assertSpansInOrder(keys, name);
156
163
  }
157
164
  function harmonyTrackSet(harmonyTracks, tracks, name = "harmonyTracks") {
158
165
  if (harmonyTracks === void 0) {
@@ -338,7 +345,12 @@ function lastStartingAtOrBefore(sounding, beat) {
338
345
  function prepareTracks(tracks) {
339
346
  let nextId = 0;
340
347
  return tracks.map((track, index) => {
341
- 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
+ }));
342
354
  const voices = splitIntoSubVoices(ordered).map((voice) => {
343
355
  const sounding = voice.map((note, i) => {
344
356
  const prev = i > 0 ? voice[i - 1] : void 0;
@@ -379,15 +391,16 @@ function withGivenKeys(result, given) {
379
391
  return { ...result, keys: given, prevailingKey: prevailingKeyOf(given) ?? result.prevailingKey };
380
392
  }
381
393
  function callerTimeline(timeline, key, given, pooled, meters, budget) {
382
- const chartEnd = timeline.segments.reduce((end, segment) => Math.max(end, segment.endBeat), 0);
383
- const soundingEnd = pooled.reduce(
384
- (end, note) => Math.max(end, note.startBeat + note.durationBeat),
385
- 0
394
+ const chartEnd = timeline.segments.reduce(
395
+ (end, segment) => Math.max(end, segment.endBeat),
396
+ Number.NEGATIVE_INFINITY
386
397
  );
387
- 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;
388
401
  const { startBeat: musicStart } = gridForNotes(pooled, beatsPerBarAt(0, meters));
389
402
  const keys = given ?? attachPivots(
390
- 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 }),
391
404
  timeline.segments
392
405
  );
393
406
  return {
@@ -738,7 +751,7 @@ function sessionOf(tracks, opts, analysis, evidence) {
738
751
  }
739
752
  if (!Array.isArray(edit.notes)) {
740
753
  throw new InvalidInputError(
741
- `edits[${index}].notes must be an array; received ${typeof edit.notes}`
754
+ `edits[${index}].notes must be an array; received ${describeRejected(edit.notes)}`
742
755
  );
743
756
  }
744
757
  assertNoteEvents(edit.notes, `edits[${index}].notes`, {
@@ -773,6 +786,7 @@ var FUNCTION_WEIGHT = 0.5;
773
786
  var DISSONANCE_WEIGHT = 0.35;
774
787
  var SPAN_WEIGHT = 0.15;
775
788
  var SPAN_SATURATION = 24;
789
+ var MAX_DISTINCT_PITCHES = 128;
776
790
  function clamp012(value) {
777
791
  return Math.min(1, Math.max(0, value));
778
792
  }
@@ -804,19 +818,14 @@ function tensionCurve(tracks, opts = {}) {
804
818
  const harmonyTracks = harmonyTrackSet(opts.harmonyTracks, tracks);
805
819
  const pooled = poolNotes(tracks, harmonyTracks);
806
820
  const all = poolNotes(tracks);
807
- const totalBeats = all.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
821
+ const totalBeats = latestSoundingEnd(all);
808
822
  const { origin: sampleStart, startBeat: musicStart } = gridForNotes(all, step);
809
- const suppliedKeys = opts.timeline === void 0 ? void 0 : opts.keys ?? (opts.key !== void 0 ? [
810
- {
811
- // The one region a named key holds starts where the music does,
812
- // as the searched regions and the timeline's own key window do:
813
- // a key cannot be in force over beats nothing sounds in.
814
- startBeat: musicStart,
815
- endBeat: totalBeats,
816
- key: resolveKey(opts.key),
817
- confidence: 1
818
- }
819
- ] : 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 }));
820
829
  const { timeline, keys, prevailingKey } = opts.timeline === void 0 || suppliedKeys === void 0 ? chordTimelineFromNotes(pooled, {
821
830
  key: opts.key,
822
831
  meters,
@@ -836,7 +845,7 @@ function tensionCurve(tracks, opts = {}) {
836
845
  assertGenerationBudget(sampleCount, "tension samples", budget);
837
846
  const voiceCount = prepared.reduce((sum, track) => sum + track.voices.length, 0);
838
847
  assertGenerationBudget(
839
- sampleCount * Math.min(128, voiceCount) * voiceCount,
848
+ sampleCount * Math.min(MAX_DISTINCT_PITCHES, voiceCount) * voiceCount,
840
849
  "tension sample-voice comparisons",
841
850
  budget
842
851
  );
@@ -844,9 +853,8 @@ function tensionCurve(tracks, opts = {}) {
844
853
  const beat = i === 0 ? Math.max(sampleStart, musicStart) : sampleStart + i * step;
845
854
  points.push({
846
855
  beat,
847
- // The pitch classes alone: tension is scored from what sounds against
848
- // the tonic, and no reading below here asks how the key is written.
849
- 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)
850
858
  });
851
859
  }
852
860
  return points;
@@ -917,7 +925,7 @@ function sampleTension(prepared, timeline, key, meters, profile, beat) {
917
925
  others.pop();
918
926
  }
919
927
  const result = evaluateSafety(
920
- { profile, candidatePitch: pitch, chord, key, otherVoices: others, strongBeat },
928
+ { profile, candidatePitch: pitch, chord, key: key.scale, otherVoices: others, strongBeat },
921
929
  { suggestions: false }
922
930
  );
923
931
  if (held !== void 0 && tail !== void 0) {
@@ -943,19 +951,34 @@ function sampleTension(prepared, timeline, key, meters, profile, beat) {
943
951
 
944
952
  // src/analyze/form/hypermeter.ts
945
953
  var GROUP_CANDIDATES = [2, 3, 4, 6, 8];
946
- var GROUP_PRIOR = /* @__PURE__ */ new Map([
954
+ var GROUP_PRIOR_LIST = [
947
955
  [2, 0.9],
948
956
  [3, 0.75],
949
957
  [4, 1],
950
958
  [6, 0.7],
951
959
  [8, 0.85]
952
- ]);
960
+ ];
961
+ var GROUP_PRIOR = new Map(GROUP_PRIOR_LIST);
953
962
  var CONTRAST_SHARE = 0.65;
954
963
  var CADENCE_SHARE = 0.35;
955
964
  var GROUP_FINAL_FIT = 1;
956
965
  var ELIDED_FIT = 0.5;
957
966
  var MIN_GROUPS = 2;
958
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
+ }
959
982
  function accentContrast(novelty, slices, groupBars, phase) {
960
983
  let headSum = 0;
961
984
  let headCount = 0;
@@ -980,22 +1003,41 @@ function accentContrast(novelty, slices, groupBars, phase) {
980
1003
  }
981
1004
  return headSum / headCount - otherSum / otherCount;
982
1005
  }
983
- function cadenceFitOf(cadenceBars, slices, groupBars, phase) {
1006
+ function cadenceFitOf(cadences, slices, groupBars, phase) {
984
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
+ }
985
1024
  let ends = 0;
986
- let fit = 0;
1025
+ let closed = 0;
1026
+ let weighted = 0;
987
1027
  for (const slice of slices) {
988
1028
  if (slice.index < 0 || floorMod(slice.index - phase, groupBars) !== groupBars - 1) {
989
1029
  continue;
990
1030
  }
991
1031
  ends += 1;
992
- if (cadenceBars.has(slice.index)) {
993
- fit += GROUP_FINAL_FIT;
994
- } else if (slice.index < lastBar && cadenceBars.has(slice.index + 1)) {
995
- fit += ELIDED_FIT;
996
- }
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));
997
1039
  }
998
- return ends === 0 ? 0 : clamp01(fit / ends);
1040
+ return ends === 0 ? { closed: 0, weighted: 0 } : { closed: clamp01(closed / ends), weighted: clamp01(weighted / ends) };
999
1041
  }
1000
1042
  function undecidedReading(slices) {
1001
1043
  const fullBars = slices.filter((slice) => slice.index >= 0);
@@ -1009,6 +1051,17 @@ function undecidedReading(slices) {
1009
1051
  };
1010
1052
  }
1011
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) {
1012
1065
  const asked = assertOptions(opts, "opts");
1013
1066
  const meters = resolveMeters({ meters: meter }, "meter");
1014
1067
  assertNoteEvents(notes, "hypermeter notes", {
@@ -1017,10 +1070,16 @@ function hypermeter(notes, meter, opts = {}) {
1017
1070
  });
1018
1071
  const sounding = notes.filter((note) => note.durationBeat > 0);
1019
1072
  const firstOnset = firstSoundingBeat(sounding);
1020
- const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1021
- const spanEnd = asked.totalBeats ?? lastEnd;
1022
- 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
+ );
1023
1079
  const spanStart = barGridStart(firstOnset, meters);
1080
+ if (sounding.length === 0 || spanEnd <= spanStart + BEAT_EPS) {
1081
+ return undecidedReading([]);
1082
+ }
1024
1083
  const slices = sliceBars(sounding, meters, spanStart, spanEnd, asked.budget);
1025
1084
  const fullBars = slices.reduce((count, slice) => slice.index >= 0 ? count + 1 : count, 0);
1026
1085
  const candidates = GROUP_CANDIDATES.filter((groupBars) => fullBars >= groupBars * MIN_GROUPS);
@@ -1033,20 +1092,21 @@ function hypermeter(notes, meter, opts = {}) {
1033
1092
  asked.budget
1034
1093
  );
1035
1094
  const novelty = slices.map((_, index) => harmonicNovelty(slices, index));
1036
- const cadenceBars = new Set((asked.cadenceBeats ?? []).map((beat) => barIndexAt(beat, meters)));
1037
- const hasCadences = cadenceBars.size > 0;
1095
+ const cadenceBars = cadenceBarsOf(spansOf(asked), meters);
1096
+ const hasCadences = cadenceBars.length > 0;
1038
1097
  const readings = [];
1039
1098
  for (const groupBars of candidates) {
1040
1099
  const phases = Array.from({ length: groupBars }, (_, phase) => ({
1041
1100
  phase,
1042
1101
  contrast: accentContrast(novelty, slices, groupBars, phase),
1043
- cadenceFit: cadenceFitOf(cadenceBars, slices, groupBars, phase)
1102
+ fit: cadenceFitOf(cadenceBars, slices, groupBars, phase)
1044
1103
  }));
1045
- const bestFit = phases.reduce((best2, reading) => Math.max(best2, reading.cadenceFit), 0);
1046
- for (const { phase, contrast, cadenceFit } of phases) {
1047
- 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) {
1048
1107
  continue;
1049
1108
  }
1109
+ const cadenceFit = fit.closed;
1050
1110
  const evidence = clamp01(
1051
1111
  hasCadences ? (
1052
1112
  // Floored at zero rather than summed raw: group heads that restate
@@ -1118,6 +1178,7 @@ var PHRASE_CUT_COST = 1.3;
1118
1178
  var CLOSING_SHARE = 0.65;
1119
1179
  var FINAL_PHRASE_BONUS = 0.15;
1120
1180
  var HYPERMETRIC_CLOSE_BONUS = 0.1;
1181
+ var IMPERFECT_CLOSURE_FACTOR = 0.8;
1121
1182
  var EVADED_FACTOR = 0.6;
1122
1183
  var SIGNAL_ORDER = [
1123
1184
  "cadence",
@@ -1143,16 +1204,22 @@ function readBoundary(boundary) {
1143
1204
  );
1144
1205
  return {
1145
1206
  strength: combineEvidence(signals.map(([, strength]) => strength)),
1207
+ uncredited: combineEvidence(
1208
+ signals.filter(([signal]) => signal !== "cadence").map(([, strength]) => strength)
1209
+ ),
1146
1210
  signals: signals.map(([signal]) => signal)
1147
1211
  };
1148
1212
  }
1213
+ function closesPhrase(atBeat, startBeat, endBeat) {
1214
+ return atBeat >= startBeat - BEAT_EPS && atBeat < endBeat - BEAT_EPS;
1215
+ }
1149
1216
  function lengthFit(length, expected) {
1150
1217
  if (!(expected > 0)) {
1151
1218
  return 0;
1152
1219
  }
1153
1220
  return Math.max(-1, 1 - Math.abs(length - expected) / expected);
1154
1221
  }
1155
- function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1222
+ function choosePhrasePath(beats, strengths, minPhraseBeats, expected, held) {
1156
1223
  const value = new Array(beats.length).fill(Number.NEGATIVE_INFINITY);
1157
1224
  const cameFrom = new Array(beats.length).fill(-1);
1158
1225
  value[0] = 0;
@@ -1166,7 +1233,9 @@ function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1166
1233
  if (length < minPhraseBeats - BEAT_EPS) {
1167
1234
  continue;
1168
1235
  }
1169
- 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;
1170
1239
  if (gain > (value[j] ?? Number.NEGATIVE_INFINITY)) {
1171
1240
  value[j] = gain;
1172
1241
  cameFrom[j] = i;
@@ -1185,21 +1254,35 @@ function choosePhrasePath(beats, strengths, minPhraseBeats, expected) {
1185
1254
  }
1186
1255
  return path;
1187
1256
  }
1188
- function arrivalEnd(timeline, atBeat) {
1189
- return segmentStartingAt(timeline.segments, atBeat)?.endBeat ?? atBeat;
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 };
1190
1267
  }
1191
- function keyContextFor(opts, notes, meters, totalBeats) {
1192
- if (opts.key !== void 0) {
1193
- return opts.key;
1268
+ function keyContextFor(key, timeline, meters, totalBeats, budget) {
1269
+ if (key !== void 0) {
1270
+ return key;
1194
1271
  }
1195
- const regions = keyTimelineFromNotes(notes, {
1196
- meters,
1197
- totalBeats,
1198
- budget: opts.budget
1199
- });
1272
+ const regions = detectModulations(timeline.segments, { meters, totalBeats, budget });
1200
1273
  const keyAt = keyLookup(regions, prevailingKeyOf(regions) ?? resolveKey(majorKey(0)));
1201
1274
  return (beat) => scaleOf(keyAt(beat));
1202
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
+ }
1203
1286
  function restBoundaries(boundaries, notes, meters, restBeats) {
1204
1287
  const ordered = [...notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1205
1288
  let covered = Number.NEGATIVE_INFINITY;
@@ -1253,7 +1336,7 @@ function structuralWeightOf(cadence, confidence, aligned, isLast) {
1253
1336
  if (result.strength === "perfect") {
1254
1337
  base = 1;
1255
1338
  } else if (result.strength === "imperfect") {
1256
- base *= 0.8;
1339
+ base *= IMPERFECT_CLOSURE_FACTOR;
1257
1340
  }
1258
1341
  }
1259
1342
  if (result.evaded) {
@@ -1291,17 +1374,18 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1291
1374
  const segments = timeline.segments;
1292
1375
  const firstSegment = segments[0];
1293
1376
  const lastSegment = segments[segments.length - 1];
1294
- const firstOnset = sounding.reduce(
1295
- (first, n) => Math.min(first, n.startBeat),
1296
- firstSegment?.startBeat ?? 0
1377
+ const earliest = Math.min(
1378
+ firstSoundingBeat(sounding, Number.POSITIVE_INFINITY),
1379
+ firstSegment?.startBeat ?? Number.POSITIVE_INFINITY
1297
1380
  );
1381
+ const firstOnset = Number.isFinite(earliest) ? earliest : 0;
1298
1382
  const spanStart = barGridStart(firstOnset, meters);
1299
- const notesEnd = sounding.reduce(
1300
- (end, n) => Math.max(end, n.startBeat + n.durationBeat),
1301
- lastSegment?.endBeat ?? 0
1383
+ const latestEnd = Math.max(
1384
+ lastSegment?.endBeat ?? Number.NEGATIVE_INFINITY,
1385
+ latestSoundingEnd(sounding, Number.NEGATIVE_INFINITY)
1302
1386
  );
1303
- const spanEnd = Math.max(spanStart, opts.totalBeats ?? notesEnd);
1304
- 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");
1305
1389
  if (spanEnd <= spanStart + BEAT_EPS) {
1306
1390
  return [];
1307
1391
  }
@@ -1312,13 +1396,9 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1312
1396
  assertRange(longNoteBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase longNoteBeats");
1313
1397
  const minPhraseBeats = opts.minPhraseBeats ?? barBeats;
1314
1398
  assertRange(minPhraseBeats, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase minPhraseBeats");
1315
- const key = keyContextFor(opts, sounding, meters, spanEnd);
1316
- const cadences = detectCadences(timeline, key);
1317
- const grouping = opts.hypermeter ?? hypermeter(sounding, meters, {
1318
- totalBeats: spanEnd,
1319
- cadenceBeats: cadences.map((hit) => hit.atBeat),
1320
- budget: opts.budget
1321
- });
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);
1322
1402
  const firstBar = barIndexAt(Math.max(0, spanStart), meters);
1323
1403
  const hyperBeats = Math.max(
1324
1404
  barBeats,
@@ -1327,19 +1407,11 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1327
1407
  const expected = opts.expectedPhraseBeats ?? hyperBeats;
1328
1408
  assertRange(expected, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, "phrase expectedPhraseBeats");
1329
1409
  const boundaries = /* @__PURE__ */ new Map();
1330
- for (const hit of cadences) {
1331
- const type = hit.cadence.type;
1332
- if (type === null) {
1333
- continue;
1334
- }
1335
- const strength = CADENCE_BOUNDARY_STRENGTH[type];
1336
- const chordEnd = arrivalEnd(timeline, hit.atBeat);
1337
- addSignal(boundaries, chordEnd, "cadence", strength, hit);
1338
- const barEnd = barPositionToBeat({ bar: barIndexAt(hit.atBeat, meters) + 1, beat: 0 }, meters);
1339
- if (chordEnd > barEnd + BEAT_EPS) {
1340
- addSignal(boundaries, barEnd, "cadence", strength, hit);
1341
- }
1342
- }
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
+ });
1343
1415
  restBoundaries(boundaries, sounding, meters, restBeats);
1344
1416
  for (const note of sounding) {
1345
1417
  if (note.durationBeat >= longNoteBeats - BEAT_EPS) {
@@ -1370,7 +1442,11 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1370
1442
  stops.map((stop) => stop.beat),
1371
1443
  readings.map((reading) => reading.strength),
1372
1444
  minPhraseBeats,
1373
- expected
1445
+ expected,
1446
+ {
1447
+ arrivals: stops.map((stop) => stop.cadence?.atBeat ?? stop.beat),
1448
+ uncredited: readings.map((reading) => reading.uncredited)
1449
+ }
1374
1450
  );
1375
1451
  const downbeatKeys = new Set(
1376
1452
  grouping.downbeats.map((downbeat) => Math.round(downbeat / BEAT_EPS))
@@ -1382,14 +1458,15 @@ function phrasesFromTimeline(timeline, notes, opts = {}) {
1382
1458
  const startBeat = stops[from]?.beat ?? spanStart;
1383
1459
  const stop = stops[to];
1384
1460
  const endBeat = stop?.beat ?? spanEnd;
1385
- const reading = readings[to] ?? { strength: 0, signals: [] };
1461
+ const reading = readings[to] ?? { strength: 0, uncredited: 0, signals: [] };
1386
1462
  const length = endBeat - startBeat;
1387
- const confidence = clamp01(
1388
- CLOSING_SHARE * reading.strength + (1 - CLOSING_SHARE) * Math.max(0, lengthFit(length, expected))
1389
- );
1390
1463
  const isLast = n === path.length - 1;
1391
1464
  const closing = stop?.cadence ?? null;
1392
- 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
+ );
1393
1470
  const structuralWeight = structuralWeightOf(
1394
1471
  cadence,
1395
1472
  confidence,
@@ -1463,13 +1540,17 @@ function sectionsFromNotes(notes, opts = {}) {
1463
1540
  budget: opts.budget
1464
1541
  });
1465
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
+ );
1466
1551
  if (sounding.length === 0) {
1467
1552
  return [];
1468
1553
  }
1469
- const firstOnset = barGridStart(firstSoundingBeat(sounding), meters);
1470
- const lastEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1471
- const spanEnd = Math.max(firstOnset, opts.totalBeats ?? lastEnd);
1472
- assertRange(spanEnd, 0, Number.MAX_SAFE_INTEGER, "section totalBeats");
1473
1554
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
1474
1555
  assertRange(threshold, 0, 1, "section threshold");
1475
1556
  const unitBars = opts.unitBars ?? hypermeter(sounding, meters, { totalBeats: spanEnd, budget: opts.budget }).groupBars;
@@ -1485,8 +1566,9 @@ function sectionsFromNotes(notes, opts = {}) {
1485
1566
  const barStart = startBar + index * unitBars;
1486
1567
  const startBeat = index === 0 ? firstOnset : barPositionToBeat({ bar: barStart, beat: 0 }, meters);
1487
1568
  const endBeat = index === unitCount - 1 ? spanEnd : barPositionToBeat({ bar: barStart + unitBars, beat: 0 }, meters);
1569
+ const fromBeat = index === 0 ? Math.min(firstSounding, startBeat) : startBeat;
1488
1570
  const unitNotes = sounding.filter(
1489
- (note) => note.startBeat >= startBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS
1571
+ (note) => note.startBeat >= fromBeat - BEAT_EPS && note.startBeat < endBeat - BEAT_EPS
1490
1572
  ).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1491
1573
  units.push({
1492
1574
  startBeat,
@@ -1663,10 +1745,11 @@ function spanOf(sounding, timeline, meters, totalBeats) {
1663
1745
  Math.min(firstOnset, firstSegment?.startBeat ?? firstOnset),
1664
1746
  meters
1665
1747
  );
1666
- const notesEnd = sounding.reduce(
1667
- (end, note) => Math.max(end, note.startBeat + note.durationBeat),
1668
- lastSegment?.endBeat ?? 0
1748
+ const latestEnd = Math.max(
1749
+ lastSegment?.endBeat ?? Number.NEGATIVE_INFINITY,
1750
+ latestSoundingEnd(sounding, Number.NEGATIVE_INFINITY)
1669
1751
  );
1752
+ const notesEnd = Number.isFinite(latestEnd) ? latestEnd : 0;
1670
1753
  return { startBeat, endBeat: Math.max(startBeat, totalBeats ?? notesEnd) };
1671
1754
  }
1672
1755
  function sameKey(a, b) {
@@ -1710,14 +1793,7 @@ function readingsFor(notes, opts = {}) {
1710
1793
  budget
1711
1794
  }).filter((note) => note.durationBeat > 0);
1712
1795
  const span = spanOf(sounding, timeline, meters, asked.totalBeats);
1713
- keys = [
1714
- {
1715
- startBeat: span.startBeat,
1716
- endBeat: span.endBeat,
1717
- key: resolveKey(asked.key),
1718
- confidence: 1
1719
- }
1720
- ];
1796
+ keys = statedKeyRegions(resolveKey(asked.key), span.startBeat, span.endBeat);
1721
1797
  }
1722
1798
  return { timeline, keys, prevailingKey: prevailingKeyOf(keys) ?? resolveKey(majorKey(0)) };
1723
1799
  }
@@ -1732,7 +1808,9 @@ function referenceFromReadings(notes, readings, opts = {}) {
1732
1808
  if (asked.totalBeats !== void 0) {
1733
1809
  assertRange(asked.totalBeats, 0, Number.MAX_SAFE_INTEGER, "reference totalBeats");
1734
1810
  }
1735
- const line = orderedNotes(asked.melody ?? notes, "reference melody", budget);
1811
+ const line = orderedNotes(asked.melody ?? notes, "reference melody", budget).map(
1812
+ (note) => Object.is(note.pitch, -0) ? { ...note, pitch: 0 } : note
1813
+ );
1736
1814
  const { timeline, prevailingKey } = readings;
1737
1815
  const span = spanOf(sounding, timeline, meters, asked.totalBeats);
1738
1816
  const spanEnd = span.endBeat;
@@ -1848,7 +1926,7 @@ function referenceFromReadings(notes, readings, opts = {}) {
1848
1926
  shape: line.length < 2 ? null : melodicContour(line).shape,
1849
1927
  motifs: referenceMotifs,
1850
1928
  graph,
1851
- rhythm: analyzeRhythm(line, { meters, totalBeats: spanEnd, budget })
1929
+ rhythm: rhythmOver(line, { meters, totalBeats: spanEnd, budget }, span.startBeat)
1852
1930
  }
1853
1931
  };
1854
1932
  }
@@ -1858,12 +1936,18 @@ function analyzeReference(notes, opts) {
1858
1936
 
1859
1937
  export {
1860
1938
  trackRoleOf,
1939
+ arrangementProfile,
1940
+ assertTrackNotes,
1941
+ harmonyTrackSet,
1861
1942
  analyzePolyphony,
1862
1943
  analyzeArrangement,
1863
1944
  createArrangementSession,
1864
1945
  tensionCurve,
1865
1946
  tensionCurveFrom,
1866
1947
  hypermeter,
1948
+ hypermeterOver,
1949
+ boundaryCadences,
1950
+ keyContextFor,
1867
1951
  phrasesFromTimeline,
1868
1952
  structuralCadences,
1869
1953
  sectionsFromNotes,
@@ -1871,4 +1955,4 @@ export {
1871
1955
  referenceFromReadings,
1872
1956
  analyzeReference
1873
1957
  };
1874
- //# sourceMappingURL=chunk-MLBPXA4Z.js.map
1958
+ //# sourceMappingURL=chunk-UTE5A36R.js.map