@libraz/libcantus 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -557,12 +557,24 @@ function isLeadingToneResolution(prev, cur, key) {
557
557
  }
558
558
 
559
559
  // src/analysis/index.ts
560
+ var EPS = 1e-9;
560
561
  function pitchClass4(pitch) {
561
562
  return (Math.trunc(pitch) % 12 + 12) % 12;
562
563
  }
563
564
  function intervalAboveRoot(pitch, chord) {
564
565
  return ((pitchClass4(pitch) - pitchClass4(chord.rootPc)) % 12 + 12) % 12;
565
566
  }
567
+ function intervalAboveBass(pitch, others, chord) {
568
+ let bass = Number.POSITIVE_INFINITY;
569
+ for (const ov of others) {
570
+ bass = Math.min(bass, ov.pitch);
571
+ }
572
+ if (Number.isFinite(bass) && bass < pitch) {
573
+ return ((pitch - bass) % 12 + 12) % 12;
574
+ }
575
+ const ref = chord.bassPc ?? chord.rootPc;
576
+ return ((pitchClass4(pitch) - pitchClass4(ref)) % 12 + 12) % 12;
577
+ }
566
578
  function tensionDegree(ic) {
567
579
  if (ic === 1 || ic === 2) {
568
580
  return 9;
@@ -607,8 +619,10 @@ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
607
619
  if (!note) {
608
620
  continue;
609
621
  }
610
- const prev = i > 0 ? voice[i - 1] : void 0;
611
- const next = i + 1 < voice.length ? voice[i + 1] : void 0;
622
+ const prevNote = i > 0 ? voice[i - 1] : void 0;
623
+ const nextNote = i + 1 < voice.length ? voice[i + 1] : void 0;
624
+ const prev = prevNote !== void 0 && prevNote.startBeat < note.startBeat - EPS ? prevNote : void 0;
625
+ const next = nextNote !== void 0 && nextNote.startBeat > note.startBeat + EPS ? nextNote : void 0;
612
626
  const chord = chordAtBeat(note.startBeat);
613
627
  const labels = [];
614
628
  const member = isChordMember(note.pitch, chord);
@@ -627,11 +641,12 @@ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
627
641
  const prevChord = chordAtBeat(prev.startBeat);
628
642
  const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
629
643
  const resolves = isStep(next.pitch, note.pitch);
630
- const verticallyDissonant = otherVoicesAtBeat(note.startBeat).some(
644
+ const others = otherVoicesAtBeat(note.startBeat);
645
+ const verticallyDissonant = others.some(
631
646
  (ov) => createsVerticalDissonance(note.pitch, ov.pitch, true)
632
647
  );
633
648
  if (prepared && verticallyDissonant && resolves) {
634
- const ic = intervalAboveRoot(note.pitch, chord);
649
+ const ic = intervalAboveBass(note.pitch, others, chord);
635
650
  const sus = suspensionType(ic, next.pitch - note.pitch);
636
651
  sus.resolveTo = next.pitch;
637
652
  labels.push(sus);
@@ -805,11 +820,43 @@ function seventhQuality(base, halfDim) {
805
820
  return base === "maj" ? "dom7" : "min7";
806
821
  }
807
822
  var INVERSION_FIGURES = /* @__PURE__ */ new Set(["6", "64", "65", "43", "42", "2", "7"]);
808
- var EXTENSION_FIGURES = {
809
- "9": "dom9",
810
- "11": "11",
811
- "13": "13"
823
+ var ROMAN_STYLE = {
824
+ maj: { lower: false, suffix: "" },
825
+ min: { lower: true, suffix: "" },
826
+ dim: { lower: true, suffix: "o" },
827
+ aug: { lower: false, suffix: "+" },
828
+ maj7: { lower: false, suffix: "maj7" },
829
+ min7: { lower: true, suffix: "7" },
830
+ dom7: { lower: false, suffix: "7" },
831
+ dim7: { lower: true, suffix: "o7" },
832
+ m7b5: { lower: true, suffix: "\xF87" },
833
+ minMaj7: { lower: true, suffix: "maj7" },
834
+ aug7: { lower: false, suffix: "+7" },
835
+ augMaj7: { lower: false, suffix: "+maj7" },
836
+ majb5: { lower: false, suffix: "b5" },
837
+ "6": { lower: false, suffix: "add6" },
838
+ min6: { lower: true, suffix: "add6" },
839
+ "6/9": { lower: false, suffix: "69" },
840
+ sus2: { lower: false, suffix: "sus2" },
841
+ sus4: { lower: false, suffix: "sus4" },
842
+ add9: { lower: false, suffix: "add9" },
843
+ add11: { lower: false, suffix: "add11" },
844
+ maj9: { lower: false, suffix: "maj9" },
845
+ min9: { lower: true, suffix: "9" },
846
+ dom9: { lower: false, suffix: "9" },
847
+ "7b9": { lower: false, suffix: "7b9" },
848
+ "7#9": { lower: false, suffix: "7#9" },
849
+ "7#11": { lower: false, suffix: "7#11" },
850
+ "7b13": { lower: false, suffix: "7b13" },
851
+ "11": { lower: false, suffix: "11" },
852
+ "13": { lower: false, suffix: "13" },
853
+ "5": { lower: false, suffix: "5" }
812
854
  };
855
+ var SUFFIX_QUALITY = new Map(
856
+ Object.entries(ROMAN_STYLE).map(
857
+ ([quality, style]) => [`${style.lower ? "l" : "u"}:${style.suffix}`, quality]
858
+ )
859
+ );
813
860
  function parseInversion(figures) {
814
861
  if (figures === "65") {
815
862
  return { inversion: 1, seventh: true };
@@ -845,15 +892,15 @@ function parseSimpleRoman(text, key) {
845
892
  const isUpper = (match[2] ?? "")[0] === (match[2] ?? "")[0]?.toUpperCase();
846
893
  const suffix = match[3] ?? "";
847
894
  const rootPc = mod12(degreeRootPc(degreeNumber, key) + accidental);
895
+ const canonical = SUFFIX_QUALITY.get(`${isUpper ? "u" : "l"}:${suffix}`);
896
+ if (canonical !== void 0) {
897
+ return { rootPc, quality: canonical, inversion: 0 };
898
+ }
848
899
  const isDim = /o|°|dim/.test(suffix);
849
900
  const isAug = /\+|aug/.test(suffix);
850
901
  const isHalfDim = /ø/.test(suffix);
851
902
  const explicitMaj7 = /maj7|M7/.test(suffix);
852
903
  const figures = suffix.replace(/maj7|M7/g, "").replace(/[^0-9]/g, "");
853
- const extension = EXTENSION_FIGURES[figures];
854
- if (extension !== void 0) {
855
- return { rootPc, quality: extension, inversion: 0 };
856
- }
857
904
  if (figures !== "" && !INVERSION_FIGURES.has(figures)) {
858
905
  throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
859
906
  }
@@ -869,7 +916,7 @@ function parseSimpleRoman(text, key) {
869
916
  }
870
917
  let quality;
871
918
  if (explicitMaj7) {
872
- quality = base === "min" ? "minMaj7" : "maj7";
919
+ quality = base === "min" ? "minMaj7" : base === "aug" ? "augMaj7" : "maj7";
873
920
  } else if (hasSeventh) {
874
921
  quality = seventhQuality(base, isHalfDim);
875
922
  } else if (base === "dim") {
@@ -901,30 +948,7 @@ function romanToChord(text, key) {
901
948
  return chordFromParsed(parseSimpleRoman(trimmed, key));
902
949
  }
903
950
  function romanStyle(quality) {
904
- switch (quality) {
905
- case "min":
906
- return { lower: true, suffix: "" };
907
- case "dim":
908
- return { lower: true, suffix: "o" };
909
- case "aug":
910
- return { lower: false, suffix: "+" };
911
- case "maj7":
912
- return { lower: false, suffix: "maj7" };
913
- case "dom7":
914
- return { lower: false, suffix: "7" };
915
- case "min7":
916
- return { lower: true, suffix: "7" };
917
- case "m7b5":
918
- return { lower: true, suffix: "\xF87" };
919
- case "dim7":
920
- return { lower: true, suffix: "o7" };
921
- case "minMaj7":
922
- return { lower: true, suffix: "maj7" };
923
- case "maj":
924
- return { lower: false, suffix: "" };
925
- default:
926
- return { lower: false, suffix: quality };
927
- }
951
+ return ROMAN_STYLE[quality];
928
952
  }
929
953
  function romanSpelling(rootPc, key) {
930
954
  const tones = scaleTonesInDegreeOrder(key);
@@ -959,6 +983,9 @@ function baseMarker(quality) {
959
983
  if (quality === "aug" || quality === "aug7") {
960
984
  return "+";
961
985
  }
986
+ if (quality === "augMaj7") {
987
+ return "+maj7";
988
+ }
962
989
  if (quality === "maj7" || quality === "minMaj7") {
963
990
  return "maj7";
964
991
  }
@@ -966,6 +993,17 @@ function baseMarker(quality) {
966
993
  }
967
994
  var TRIAD_FIGURES = { 1: "6", 2: "64" };
968
995
  var SEVENTH_FIGURES = { 1: "65", 2: "43", 3: "42" };
996
+ var TRIAD_FIGURE_QUALITIES = /* @__PURE__ */ new Set(["maj", "min", "dim", "aug"]);
997
+ var SEVENTH_FIGURE_QUALITIES = /* @__PURE__ */ new Set([
998
+ "maj7",
999
+ "min7",
1000
+ "dom7",
1001
+ "dim7",
1002
+ "m7b5",
1003
+ "minMaj7",
1004
+ "aug7",
1005
+ "augMaj7"
1006
+ ]);
969
1007
  function chordToRoman(chord, key) {
970
1008
  const { degreeNumber, accidental } = romanSpelling(chord.rootPc, key);
971
1009
  const { lower, suffix } = romanStyle(chord.quality);
@@ -979,8 +1017,7 @@ function chordToRoman(chord, key) {
979
1017
  }
980
1018
  }
981
1019
  if (inversion > 0) {
982
- const isSeventh = chord.intervals.length >= 4;
983
- const figure = (isSeventh ? SEVENTH_FIGURES : TRIAD_FIGURES)[inversion];
1020
+ const figure = SEVENTH_FIGURE_QUALITIES.has(chord.quality) ? SEVENTH_FIGURES[inversion] : TRIAD_FIGURE_QUALITIES.has(chord.quality) ? TRIAD_FIGURES[inversion] : void 0;
984
1021
  if (figure !== void 0) {
985
1022
  return `${accidental}${cased}${baseMarker(chord.quality)}${figure}`;
986
1023
  }
@@ -1079,13 +1116,13 @@ function secondaryDominant(targetDegree, key) {
1079
1116
  }
1080
1117
 
1081
1118
  // src/meter/index.ts
1082
- var EPS = 1e-9;
1119
+ var EPS2 = 1e-9;
1083
1120
  function isMultiple(value, unit) {
1084
1121
  if (unit === 0) {
1085
1122
  return false;
1086
1123
  }
1087
1124
  const ratio = value / unit;
1088
- return Math.abs(ratio - Math.round(ratio)) < EPS;
1125
+ return Math.abs(ratio - Math.round(ratio)) < EPS2;
1089
1126
  }
1090
1127
  function parseTimeSignature(text) {
1091
1128
  const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text);
@@ -1117,6 +1154,29 @@ function pulseBeats(ts) {
1117
1154
  function pulsesPerBar(ts) {
1118
1155
  return beatsPerBar(ts) / pulseBeats(ts);
1119
1156
  }
1157
+ function isValidGrouping(grouping, pulses) {
1158
+ if (grouping.length === 0) {
1159
+ return false;
1160
+ }
1161
+ let sum = 0;
1162
+ for (const g of grouping) {
1163
+ if (!Number.isInteger(g) || g <= 0) {
1164
+ return false;
1165
+ }
1166
+ sum += g;
1167
+ }
1168
+ return sum === pulses;
1169
+ }
1170
+ function isGroupHead(grouping, pulseIndex) {
1171
+ let acc = 0;
1172
+ for (const g of grouping) {
1173
+ if (acc === pulseIndex) {
1174
+ return true;
1175
+ }
1176
+ acc += g;
1177
+ }
1178
+ return false;
1179
+ }
1120
1180
  function beatToBarPosition(beatInQuarters, ts) {
1121
1181
  const barLen = beatsPerBar(ts);
1122
1182
  const bar = Math.floor(beatInQuarters / barLen);
@@ -1136,6 +1196,15 @@ function metricWeight(beatInQuarters, ts) {
1136
1196
  if (pulseIndex === 0) {
1137
1197
  return 3;
1138
1198
  }
1199
+ const grouping = ts.grouping;
1200
+ if (grouping !== void 0) {
1201
+ if (!isValidGrouping(grouping, pulses)) {
1202
+ throw new Error(
1203
+ `Invalid grouping [${grouping.join(", ")}] for ${formatTimeSignature(ts)}: entries must be positive integers summing to ${pulses}`
1204
+ );
1205
+ }
1206
+ return isGroupHead(grouping, pulseIndex) ? 2 : 1;
1207
+ }
1139
1208
  if (pulses % 2 === 0 && pulseIndex === pulses / 2) {
1140
1209
  return 2;
1141
1210
  }
@@ -1296,7 +1365,7 @@ function evaluateInternal(q, collectSuggestions) {
1296
1365
  if (ov.prevPitch === void 0) {
1297
1366
  continue;
1298
1367
  }
1299
- if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch) || createsParallelOctave(prev, pitch, ov.prevPitch, ov.pitch)) {
1368
+ if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
1300
1369
  reasons |= 2048 /* ParallelPerfect */;
1301
1370
  raise(parallelSeverity);
1302
1371
  }
@@ -1428,7 +1497,7 @@ function detectChord(pitches) {
1428
1497
  quality,
1429
1498
  missingPcs,
1430
1499
  extraPcs,
1431
- exact: extraPcs.length === 0,
1500
+ exact: extraPcs.length === 0 && missingPcs.length === 0,
1432
1501
  inversion
1433
1502
  };
1434
1503
  if (inversion > 0) {
@@ -1460,38 +1529,49 @@ function detectChordBest(pitches) {
1460
1529
  }
1461
1530
  return makeChord(best.rootPc, best.quality, best.bassPc);
1462
1531
  }
1532
+ var MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MINOR_MASK];
1463
1533
  function detectKey(pitches) {
1464
1534
  const input = uniquePitchClasses(pitches);
1535
+ if (input.length === 0) {
1536
+ return [];
1537
+ }
1465
1538
  const counts = /* @__PURE__ */ new Map();
1466
1539
  for (const pc of pitches.map(pitchClass6)) {
1467
1540
  counts.set(pc, (counts.get(pc) ?? 0) + 1);
1468
1541
  }
1469
- const total = pitches.length || 1;
1542
+ const total = pitches.length;
1470
1543
  const results = [];
1471
1544
  for (let tonic = 0; tonic < 12; tonic += 1) {
1472
1545
  for (const mode of ["major", "minor"]) {
1473
1546
  const key = mode === "major" ? majorKey(tonic) : minorKey(tonic);
1547
+ const masks = mode === "major" ? [MAJOR_MASK] : MINOR_MASK_VARIANTS;
1474
1548
  let inScale = 0;
1475
- let weighted = 0;
1476
- for (const pc of input) {
1477
- const offset = (pc - tonic + 12) % 12;
1478
- if (key.modeMask12 >> offset & 1) {
1479
- inScale += 1;
1549
+ let weighted = Number.NEGATIVE_INFINITY;
1550
+ for (const mask of masks) {
1551
+ let variantInScale = 0;
1552
+ let variantWeighted = 0;
1553
+ for (const pc of input) {
1554
+ const offset = (pc - tonic + 12) % 12;
1555
+ if (mask >> offset & 1) {
1556
+ variantInScale += 1;
1557
+ }
1480
1558
  }
1481
- }
1482
- for (const [pc, count] of counts) {
1483
- const offset = (pc - tonic + 12) % 12;
1484
- if (key.modeMask12 >> offset & 1) {
1485
- weighted += count;
1559
+ for (const [pc, count] of counts) {
1560
+ const offset = (pc - tonic + 12) % 12;
1561
+ if (mask >> offset & 1) {
1562
+ variantWeighted += count;
1563
+ }
1486
1564
  }
1487
- if (pc === tonic) {
1488
- weighted += count * 0.5;
1565
+ if (variantWeighted > weighted) {
1566
+ weighted = variantWeighted;
1567
+ inScale = variantInScale;
1489
1568
  }
1490
1569
  }
1570
+ weighted += (counts.get(tonic) ?? 0) * 0.5;
1491
1571
  results.push({
1492
1572
  key,
1493
1573
  mode,
1494
- fit: input.length === 0 ? 0 : inScale / input.length,
1574
+ fit: inScale / input.length,
1495
1575
  score: weighted / total
1496
1576
  });
1497
1577
  }
@@ -1524,7 +1604,7 @@ function segmentLookup(segments) {
1524
1604
  return null;
1525
1605
  };
1526
1606
  }
1527
- var EPS2 = 1e-9;
1607
+ var EPS3 = 1e-9;
1528
1608
  var NOISE_THRESHOLD_RATIO = 0.2;
1529
1609
  var MAX_DETECTION_PCS = 6;
1530
1610
  var FALLBACK_PCS = 3;
@@ -1566,11 +1646,11 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1566
1646
  for (const note of notes) {
1567
1647
  const noteEnd = note.startBeat + note.durationBeat;
1568
1648
  const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
1569
- if (overlap <= EPS2) {
1649
+ if (overlap <= EPS3) {
1570
1650
  continue;
1571
1651
  }
1572
1652
  const velocityFactor = note.velocity !== void 0 ? note.velocity / 127 : 1;
1573
- const onsetInWindow = note.startBeat >= windowStart - EPS2 && note.startBeat < windowEnd - EPS2;
1653
+ const onsetInWindow = note.startBeat >= windowStart - EPS3 && note.startBeat < windowEnd - EPS3;
1574
1654
  const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, ts) / 3 : 1;
1575
1655
  const pc = pitchClass7(note.pitch);
1576
1656
  weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
@@ -1584,7 +1664,7 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1584
1664
  totalWeight += w;
1585
1665
  maxWeight = Math.max(maxWeight, w);
1586
1666
  }
1587
- if (totalWeight <= EPS2) {
1667
+ if (totalWeight <= EPS3) {
1588
1668
  return null;
1589
1669
  }
1590
1670
  const ranked = weights.map((weight, pc) => ({ pc, weight })).filter(({ weight }) => weight > 0).sort((a, b) => b.weight - a.weight || a.pc - b.pc);
@@ -1621,21 +1701,22 @@ function chordTimelineFromNotes(notes, opts = {}) {
1621
1701
  if (!(harmonicRhythm > 0)) {
1622
1702
  throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
1623
1703
  }
1624
- const lastNoteEnd = notes.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1704
+ const sounding = notes.filter((note) => note.durationBeat > 0);
1705
+ const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1625
1706
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
1626
- const key = opts.key ?? detectKey(notes.map((n) => n.pitch))[0]?.key ?? majorKey(0);
1707
+ const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
1627
1708
  const segments = [];
1628
1709
  const segmentConfidence = [];
1629
- const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS2));
1710
+ const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS3));
1630
1711
  for (let i = 0; i < windowCount; i += 1) {
1631
1712
  const start = i * harmonicRhythm;
1632
1713
  const end = Math.min(start + harmonicRhythm, totalBeats);
1633
- const inferred = analyzeWindow(notes, start, end, ts, key);
1714
+ const inferred = analyzeWindow(sounding, start, end, ts, key);
1634
1715
  if (!inferred) {
1635
1716
  continue;
1636
1717
  }
1637
1718
  const last = segments[segments.length - 1];
1638
- if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS2) {
1719
+ if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS3) {
1639
1720
  const lastLength = last.endBeat - last.startBeat;
1640
1721
  const length = end - start;
1641
1722
  const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
@@ -1660,6 +1741,9 @@ function detectCadences(timeline, key) {
1660
1741
  if (!prev || !cur) {
1661
1742
  continue;
1662
1743
  }
1744
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS3) {
1745
+ continue;
1746
+ }
1663
1747
  const type = detectCadence(prev.chord, cur.chord, key);
1664
1748
  if (type !== null) {
1665
1749
  hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
@@ -1669,67 +1753,101 @@ function detectCadences(timeline, key) {
1669
1753
  }
1670
1754
 
1671
1755
  // src/arrange/index.ts
1672
- var EPS3 = 1e-9;
1756
+ var EPS4 = 1e-9;
1673
1757
  function pitchClass8(pitch) {
1674
1758
  return (Math.trunc(pitch) % 12 + 12) % 12;
1675
1759
  }
1676
1760
  function covers(note, beat) {
1677
- return beat >= note.startBeat - EPS3 && beat < note.endBeat - EPS3;
1761
+ return beat >= note.startBeat - EPS4 && beat < note.endBeat - EPS4;
1678
1762
  }
1679
1763
  function poolNotes(tracks) {
1680
1764
  const notes = [];
1681
1765
  for (const track of tracks) {
1682
1766
  for (const note of track.notes) {
1683
- notes.push(note);
1767
+ if (note.durationBeat > 0) {
1768
+ notes.push(note);
1769
+ }
1684
1770
  }
1685
1771
  }
1686
1772
  return notes;
1687
1773
  }
1774
+ function splitIntoSubVoices(ordered) {
1775
+ const lanes = [];
1776
+ for (const note of ordered) {
1777
+ const lane = lanes.find((candidate) => {
1778
+ const last = candidate[candidate.length - 1];
1779
+ return last !== void 0 && last.startBeat + last.durationBeat <= note.startBeat + EPS4;
1780
+ });
1781
+ if (lane) {
1782
+ lane.push(note);
1783
+ } else {
1784
+ lanes.push([note]);
1785
+ }
1786
+ }
1787
+ return lanes;
1788
+ }
1688
1789
  function prepareTracks(tracks) {
1689
1790
  let nextId = 0;
1690
1791
  return tracks.map((track, index) => {
1691
- const ordered = [...track.notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1692
- const voice = ordered.map((note) => ({ ...note, id: nextId++ }));
1693
- const sounding = ordered.map((note, i) => {
1694
- const prev = i > 0 ? ordered[i - 1] : void 0;
1695
- return {
1696
- pitch: note.pitch,
1697
- prevPitch: prev?.pitch,
1698
- startBeat: note.startBeat,
1699
- endBeat: note.startBeat + note.durationBeat
1700
- };
1701
- });
1792
+ const ordered = track.notes.filter((note) => note.durationBeat > 0).sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch).map((note) => ({ ...note, id: nextId++ }));
1793
+ const voices = splitIntoSubVoices(ordered).map((voice) => ({
1794
+ voice,
1795
+ sounding: voice.map((note, i) => {
1796
+ const prev = i > 0 ? voice[i - 1] : void 0;
1797
+ return {
1798
+ pitch: note.pitch,
1799
+ prevPitch: prev?.pitch,
1800
+ startBeat: note.startBeat,
1801
+ endBeat: note.startBeat + note.durationBeat
1802
+ };
1803
+ })
1804
+ }));
1702
1805
  return {
1703
1806
  name: track.name ?? `track ${index + 1}`,
1704
1807
  role: track.role ?? "other",
1705
- voice,
1706
- sounding
1808
+ voices
1707
1809
  };
1708
1810
  });
1709
1811
  }
1710
- function otherVoicesSounding(prepared, excludeIndex, beat) {
1812
+ function otherVoicesSounding(prepared, excludeTrack, excludeVoice, beat) {
1711
1813
  const out = [];
1712
1814
  for (let t = 0; t < prepared.length; t += 1) {
1713
- if (t === excludeIndex) {
1714
- continue;
1715
- }
1716
1815
  const track = prepared[t];
1717
1816
  if (!track) {
1718
1817
  continue;
1719
1818
  }
1720
- for (const note of track.sounding) {
1721
- if (!covers(note, beat)) {
1819
+ for (let v = 0; v < track.voices.length; v += 1) {
1820
+ if (t === excludeTrack && v === excludeVoice) {
1821
+ continue;
1822
+ }
1823
+ const subVoice = track.voices[v];
1824
+ if (!subVoice) {
1722
1825
  continue;
1723
1826
  }
1724
- const snap = { pitch: note.pitch };
1725
- if (note.prevPitch !== void 0) {
1726
- snap.prevPitch = note.prevPitch;
1827
+ for (const note of subVoice.sounding) {
1828
+ if (!covers(note, beat)) {
1829
+ continue;
1830
+ }
1831
+ const snap = { pitch: note.pitch };
1832
+ if (note.prevPitch !== void 0) {
1833
+ snap.prevPitch = note.prevPitch;
1834
+ }
1835
+ out.push(snap);
1727
1836
  }
1728
- out.push(snap);
1729
1837
  }
1730
1838
  }
1731
1839
  return out;
1732
1840
  }
1841
+ function evaluationBeats(note, timeline) {
1842
+ const noteEnd = note.startBeat + note.durationBeat;
1843
+ const beats = [note.startBeat];
1844
+ for (const segment of timeline.segments) {
1845
+ if (segment.startBeat > note.startBeat + EPS4 && segment.startBeat < noteEnd - EPS4) {
1846
+ beats.push(segment.startBeat);
1847
+ }
1848
+ }
1849
+ return beats;
1850
+ }
1733
1851
  function analyzeArrangement(tracks, opts = {}) {
1734
1852
  const ts = opts.ts ?? parseTimeSignature("4/4");
1735
1853
  const profile = opts.profile ?? "pop";
@@ -1748,36 +1866,48 @@ function analyzeArrangement(tracks, opts = {}) {
1748
1866
  if (!track) {
1749
1867
  continue;
1750
1868
  }
1751
- const notes = analyzeVoice(
1752
- track.voice,
1753
- timeline.at,
1754
- key,
1755
- (beat) => otherVoicesSounding(prepared, t, beat)
1756
- );
1757
- trackAnalyses.push({ name: track.name, role: track.role, notes });
1758
- for (const note of track.voice) {
1759
- const result = evaluateSafety({
1760
- profile,
1761
- candidatePitch: note.pitch,
1762
- chord: timeline.at(note.startBeat),
1763
- key,
1764
- otherVoices: otherVoicesSounding(prepared, t, note.startBeat),
1765
- strongBeat: isStrongBeat(note.startBeat, ts)
1766
- });
1767
- if (result.safety !== 0 /* Safe */) {
1768
- const conflict = {
1769
- beat: note.startBeat,
1770
- trackName: track.name,
1771
- pitch: note.pitch,
1772
- safety: result.safety,
1773
- reasons: result.reasons
1774
- };
1775
- if (result.rationale !== void 0) {
1776
- conflict.rationale = result.rationale;
1869
+ const notes = [];
1870
+ for (let v = 0; v < track.voices.length; v += 1) {
1871
+ const subVoice = track.voices[v];
1872
+ if (!subVoice) {
1873
+ continue;
1874
+ }
1875
+ notes.push(
1876
+ ...analyzeVoice(
1877
+ subVoice.voice,
1878
+ timeline.at,
1879
+ key,
1880
+ (beat) => otherVoicesSounding(prepared, t, v, beat)
1881
+ )
1882
+ );
1883
+ for (const note of subVoice.voice) {
1884
+ for (const beat of evaluationBeats(note, timeline)) {
1885
+ const result = evaluateSafety({
1886
+ profile,
1887
+ candidatePitch: note.pitch,
1888
+ chord: timeline.at(beat),
1889
+ key,
1890
+ otherVoices: otherVoicesSounding(prepared, t, v, beat),
1891
+ strongBeat: isStrongBeat(beat, ts)
1892
+ });
1893
+ if (result.safety !== 0 /* Safe */) {
1894
+ const conflict = {
1895
+ beat,
1896
+ trackName: track.name,
1897
+ pitch: note.pitch,
1898
+ safety: result.safety,
1899
+ reasons: result.reasons
1900
+ };
1901
+ if (result.rationale !== void 0) {
1902
+ conflict.rationale = result.rationale;
1903
+ }
1904
+ conflicts.push(conflict);
1905
+ }
1777
1906
  }
1778
- conflicts.push(conflict);
1779
1907
  }
1780
1908
  }
1909
+ notes.sort((a, b) => a.noteId - b.noteId);
1910
+ trackAnalyses.push({ name: track.name, role: track.role, notes });
1781
1911
  }
1782
1912
  conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
1783
1913
  return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
@@ -1810,7 +1940,7 @@ function tensionCurve(tracks, opts = {}) {
1810
1940
  });
1811
1941
  const prepared = prepareTracks(tracks);
1812
1942
  const points = [];
1813
- const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS3));
1943
+ const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS4));
1814
1944
  for (let i = 0; i < sampleCount; i += 1) {
1815
1945
  const beat = i * step;
1816
1946
  points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
@@ -1824,9 +1954,11 @@ function allSounding(prepared, beat) {
1824
1954
  if (!track) {
1825
1955
  continue;
1826
1956
  }
1827
- for (const note of track.sounding) {
1828
- if (covers(note, beat)) {
1829
- out.push({ pitch: note.pitch, track: t });
1957
+ for (const subVoice of track.voices) {
1958
+ for (const note of subVoice.sounding) {
1959
+ if (covers(note, beat)) {
1960
+ out.push({ pitch: note.pitch, track: t });
1961
+ }
1830
1962
  }
1831
1963
  }
1832
1964
  }
@@ -1902,7 +2034,7 @@ var DEFAULT_OCTAVE = 2;
1902
2034
  var STRONG_VELOCITY = 100;
1903
2035
  var WEAK_VELOCITY = 80;
1904
2036
  var PICKUP_PROB = 0.35;
1905
- var EPS4 = 1e-9;
2037
+ var EPS5 = 1e-9;
1906
2038
  function pitchClass9(value) {
1907
2039
  return (Math.trunc(value) % 12 + 12) % 12;
1908
2040
  }
@@ -1933,15 +2065,16 @@ function chordTonePcs(chord) {
1933
2065
  return pcs;
1934
2066
  }
1935
2067
  function fifthPcOf(chord) {
1936
- const hasFifth = chord.intervals.some((i) => pitchClass9(i) === 7);
1937
- return hasFifth ? pitchClass9(chord.rootPc + 7) : pitchClass9(chord.rootPc);
2068
+ const fifths = new Set(chord.intervals.map((i) => pitchClass9(i)));
2069
+ const chosen = fifths.has(7) ? 7 : fifths.has(6) ? 6 : fifths.has(8) ? 8 : void 0;
2070
+ return chosen === void 0 ? pitchClass9(chord.rootPc) : pitchClass9(chord.rootPc + chosen);
1938
2071
  }
1939
2072
  function bassPcOf(chord) {
1940
2073
  return pitchClass9(chord.bassPc ?? chord.rootPc);
1941
2074
  }
1942
2075
  function beatPositions(start, end) {
1943
2076
  const positions = [];
1944
- for (let p = start; p < end - EPS4; p += 1) {
2077
+ for (let p = start; p < end - EPS5; p += 1) {
1945
2078
  positions.push(p);
1946
2079
  }
1947
2080
  if (positions.length === 0) {
@@ -1961,7 +2094,7 @@ function buildRoot(ctx, seg) {
1961
2094
  function buildRootFifth(ctx, seg) {
1962
2095
  ctx.prevMidi = emit(ctx, seg.startBeat, bassPcOf(seg.chord));
1963
2096
  const mid = (seg.startBeat + seg.endBeat) / 2;
1964
- if (mid > seg.startBeat + EPS4 && mid < seg.endBeat - EPS4) {
2097
+ if (mid > seg.startBeat + EPS5 && mid < seg.endBeat - EPS5) {
1965
2098
  ctx.prevMidi = emit(ctx, mid, fifthPcOf(seg.chord));
1966
2099
  }
1967
2100
  }
@@ -1976,7 +2109,7 @@ function buildPop(ctx, seg) {
1976
2109
  } else if (ctx.rng.prob(PICKUP_PROB)) {
1977
2110
  if (ctx.rng.prob(0.5)) {
1978
2111
  const base = placePc(rootPc, ctx.prevMidi, ctx.low);
1979
- emit(ctx, pos, rootPc, base - 12);
2112
+ emit(ctx, pos, rootPc, Math.max(ctx.low, base - 12));
1980
2113
  } else {
1981
2114
  ctx.prevMidi = emit(ctx, pos, fifthPcOf(seg.chord));
1982
2115
  }
@@ -2072,7 +2205,7 @@ function generateBassLine(opts) {
2072
2205
  }
2073
2206
  const nextStart = ctx.notes[i + 1]?.startBeat ?? lastEnd;
2074
2207
  const durationBeat = nextStart - note.startBeat;
2075
- if (durationBeat <= EPS4) {
2208
+ if (durationBeat <= EPS5) {
2076
2209
  continue;
2077
2210
  }
2078
2211
  out.push({
@@ -2108,18 +2241,22 @@ function scaleMatchesChord(chordPcs, scaleMask, scaleRootPc) {
2108
2241
  }
2109
2242
  return true;
2110
2243
  }
2244
+ var ALIASED_SCALE_NAMES = /* @__PURE__ */ new Set(["major", "naturalMinor"]);
2245
+ var HEPTATONIC_SIZE = 7;
2111
2246
  function chordScales(chord) {
2112
2247
  const chordPcs = chordPitchClasses(chord);
2113
2248
  const rootPc = pitchClass10(chord.rootPc);
2114
2249
  const ranked = [];
2250
+ const seenMasks = /* @__PURE__ */ new Set();
2115
2251
  for (const name of Object.keys(NAMED_SCALES)) {
2116
- if (name === "chromatic") {
2252
+ if (name === "chromatic" || ALIASED_SCALE_NAMES.has(name)) {
2117
2253
  continue;
2118
2254
  }
2119
2255
  const mask = NAMED_SCALES[name];
2120
- if (mask === void 0) {
2256
+ if (mask === void 0 || seenMasks.has(mask)) {
2121
2257
  continue;
2122
2258
  }
2259
+ seenMasks.add(mask);
2123
2260
  if (scaleMatchesChord(chordPcs, mask, rootPc)) {
2124
2261
  const size = popcount12(mask);
2125
2262
  ranked.push({ name, extra: size - chordPcs.length, size });
@@ -2131,7 +2268,11 @@ function chordScales(chord) {
2131
2268
  }
2132
2269
  return [];
2133
2270
  }
2271
+ const preferHeptatonic = chordPcs.length <= 3;
2134
2272
  ranked.sort((a, b) => {
2273
+ if (preferHeptatonic && a.size === HEPTATONIC_SIZE !== (b.size === HEPTATONIC_SIZE)) {
2274
+ return a.size === HEPTATONIC_SIZE ? -1 : 1;
2275
+ }
2135
2276
  if (a.extra !== b.extra) {
2136
2277
  return a.extra - b.extra;
2137
2278
  }
@@ -2283,7 +2424,7 @@ function scalesForChanges(chords) {
2283
2424
 
2284
2425
  // src/countermelody/index.ts
2285
2426
  var DEFAULT_TS2 = { numerator: 4, denominator: 4 };
2286
- var EPS5 = 1e-9;
2427
+ var EPS6 = 1e-9;
2287
2428
  var GRID_STEP = 0.5;
2288
2429
  var MIN_ONSET_GAP = 1;
2289
2430
  var REGISTER_OFFSET = 12;
@@ -2294,12 +2435,12 @@ var REINFORCE_PROB = 0.35;
2294
2435
  var DEFAULT_MELODY_VELOCITY = 96;
2295
2436
  var VELOCITY_DROP = 16;
2296
2437
  function isWholeBeat(beat) {
2297
- return Math.abs(beat - Math.round(beat)) < EPS5;
2438
+ return Math.abs(beat - Math.round(beat)) < EPS6;
2298
2439
  }
2299
2440
  function melodyNoteAt(melody, beat) {
2300
2441
  let found;
2301
2442
  for (const note of melody) {
2302
- if (note.startBeat - EPS5 <= beat && beat < note.startBeat + note.durationBeat - EPS5) {
2443
+ if (note.startBeat - EPS6 <= beat && beat < note.startBeat + note.durationBeat - EPS6) {
2303
2444
  if (!found || note.startBeat > found.startBeat) {
2304
2445
  found = note;
2305
2446
  }
@@ -2308,7 +2449,7 @@ function melodyNoteAt(melody, beat) {
2308
2449
  return found;
2309
2450
  }
2310
2451
  function melodyAttacksAt(melody, beat) {
2311
- return melody.some((note) => Math.abs(note.startBeat - beat) < EPS5);
2452
+ return melody.some((note) => Math.abs(note.startBeat - beat) < EPS6);
2312
2453
  }
2313
2454
  function followOnsets(melody) {
2314
2455
  return [...new Set(melody.map((note) => note.startBeat))].sort((a, b) => a - b);
@@ -2317,7 +2458,7 @@ function complementOnsets(melody, ts, spanStart, spanEnd, rng) {
2317
2458
  const onsets = [];
2318
2459
  const gridStart = Math.floor(spanStart / GRID_STEP) * GRID_STEP;
2319
2460
  let last = Number.NEGATIVE_INFINITY;
2320
- for (let beat = gridStart; beat < spanEnd - EPS5; beat += GRID_STEP) {
2461
+ for (let beat = gridStart; beat < spanEnd - EPS6; beat += GRID_STEP) {
2321
2462
  const attacked = melodyAttacksAt(melody, beat);
2322
2463
  const sounding = melodyNoteAt(melody, beat) !== void 0;
2323
2464
  let place = false;
@@ -2328,7 +2469,7 @@ function complementOnsets(melody, ts, spanStart, spanEnd, rng) {
2328
2469
  } else if (isStrongBeat(beat, ts)) {
2329
2470
  place = rng.prob(REINFORCE_PROB);
2330
2471
  }
2331
- if (place && beat - last >= MIN_ONSET_GAP - EPS5) {
2472
+ if (place && beat - last >= MIN_ONSET_GAP - EPS6) {
2332
2473
  onsets.push(beat);
2333
2474
  last = beat;
2334
2475
  }
@@ -2446,13 +2587,15 @@ function generateCounterMelody(opts) {
2446
2587
  if (register === "above" && candidate <= melPitch) {
2447
2588
  continue;
2448
2589
  }
2449
- if (prevPitch !== void 0 && melPrev !== void 0 && (createsParallelPerfect(prevPitch, candidate, melPrev, melPitch) || createsParallelOctave(prevPitch, candidate, melPrev, melPitch))) {
2590
+ if (prevPitch !== void 0 && melPrev !== void 0 && // Parallel octaves are the perfect-class-zero case of this predicate,
2591
+ // so a single check covers both parallel fifths and octaves.
2592
+ createsParallelPerfect(prevPitch, candidate, melPrev, melPitch)) {
2450
2593
  continue;
2451
2594
  }
2452
2595
  }
2453
2596
  const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
2454
- const tie = Math.abs(score - bestScore) <= EPS5 && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
2455
- if (score > bestScore + EPS5 || tie) {
2597
+ const tie = Math.abs(score - bestScore) <= EPS6 && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
2598
+ if (score > bestScore + EPS6 || tie) {
2456
2599
  bestScore = score;
2457
2600
  bestPitch = candidate;
2458
2601
  }
@@ -2505,7 +2648,11 @@ var STYLE_MAP = {
2505
2648
  bossa: { style: "latin", feel: "straight", ghostBoost: false, snareBeat3: false },
2506
2649
  trap: { style: "trap", feel: "straight", ghostBoost: false, snareBeat3: true },
2507
2650
  halftime: { style: "sparse", feel: "straight", ghostBoost: false, snareBeat3: true },
2508
- breakbeat: { style: "rock", feel: "straight", ghostBoost: false, snareBeat3: false }
2651
+ breakbeat: { style: "rock", feel: "straight", ghostBoost: false, snareBeat3: false },
2652
+ // Four-on-the-floor dance kick with idol-style auxiliary percussion.
2653
+ house: { style: "fourOnFloor", feel: "straight", ghostBoost: false, snareBeat3: false },
2654
+ // Synth-pop pulse with denser 16th hi-hats and busy kick syncopation.
2655
+ synthpop: { style: "synth", feel: "straight", ghostBoost: true, snareBeat3: false }
2509
2656
  };
2510
2657
  function mapStyle(style) {
2511
2658
  return STYLE_MAP[style];
@@ -2992,8 +3139,7 @@ function quantizeSwing(startBeat, swing, resolution) {
2992
3139
  return base + EIGHTH2 + DELTA_8 * clamped;
2993
3140
  }
2994
3141
  if (offset >= EIGHTH2 + HALF_16TH && offset < 3 * SIXTEENTH2 + HALF_16TH) {
2995
- const result = base + EIGHTH2 + DELTA_8 * clamped + SIXTEENTH2 + DELTA_16 * clamped;
2996
- return Math.min(result, base + 1 - 1 / 480);
3142
+ return base + 3 * SIXTEENTH2 + DELTA_16 * clamped;
2997
3143
  }
2998
3144
  return startBeat;
2999
3145
  }
@@ -3188,6 +3334,52 @@ function generateHiHatForBeat(ctx, sec) {
3188
3334
  }
3189
3335
  }
3190
3336
 
3337
+ // src/drums/euclid.ts
3338
+ function euclideanRhythm(pulses, steps, rotation = 0) {
3339
+ if (steps < 1) {
3340
+ return [];
3341
+ }
3342
+ const p = Math.max(0, Math.min(pulses, steps));
3343
+ const base = bjorklund(p, steps);
3344
+ const shift = (rotation % steps + steps) % steps;
3345
+ if (shift === 0) {
3346
+ return base;
3347
+ }
3348
+ return base.map((_, i) => base[(i - shift + steps) % steps] ?? false);
3349
+ }
3350
+ function hasHit(mask, step) {
3351
+ return (mask >> step & 1) === 1;
3352
+ }
3353
+ function patternToMask(pattern) {
3354
+ let mask = 0;
3355
+ for (let i = 0; i < pattern.length; i += 1) {
3356
+ if (pattern[i]) {
3357
+ mask |= 1 << i;
3358
+ }
3359
+ }
3360
+ return mask;
3361
+ }
3362
+ function bjorklund(pulses, steps) {
3363
+ if (pulses === 0) {
3364
+ return Array.from({ length: steps }, () => false);
3365
+ }
3366
+ if (pulses >= steps) {
3367
+ return Array.from({ length: steps }, () => true);
3368
+ }
3369
+ let head = Array.from({ length: pulses }, () => [true]);
3370
+ let remainder = Array.from({ length: steps - pulses }, () => [false]);
3371
+ while (remainder.length > 1) {
3372
+ const count = Math.min(head.length, remainder.length);
3373
+ const next = [];
3374
+ for (let i = 0; i < count; i += 1) {
3375
+ next.push([...head[i] ?? [], ...remainder[i] ?? []]);
3376
+ }
3377
+ remainder = head.length > count ? head.slice(count) : remainder.slice(count);
3378
+ head = next;
3379
+ }
3380
+ return [...head.flat(), ...remainder.flat()];
3381
+ }
3382
+
3191
3383
  // src/drums/fills.ts
3192
3384
  function getFillStartBeat(energy) {
3193
3385
  switch (energy) {
@@ -3204,6 +3396,9 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3204
3396
  if (style === "sparse") {
3205
3397
  return rng.range(0, 1) === 0 ? "simpleCrash" : "breakdownFill";
3206
3398
  }
3399
+ const toChorus = to === "chorus";
3400
+ const fromIntro = from === "intro";
3401
+ const highEnergy = style === "rock" || style === "fourOnFloor";
3207
3402
  if (nextEnergy === "low") {
3208
3403
  switch (rng.range(0, 2)) {
3209
3404
  case 0:
@@ -3214,25 +3409,10 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3214
3409
  return "halfTimeFill";
3215
3410
  }
3216
3411
  }
3217
- if (nextEnergy === "peak") {
3218
- switch (rng.range(0, 3)) {
3219
- case 0:
3220
- return "tomDescend";
3221
- case 1:
3222
- return "snareRoll";
3223
- case 2:
3224
- return "linearFill";
3225
- default:
3226
- return "flamsAndDrags";
3227
- }
3228
- }
3229
- const toChorus = to === "chorus";
3230
- const fromIntro = from === "intro";
3231
- const highEnergy = style === "rock" || style === "fourOnFloor";
3232
- const choice = rng.range(0, 7);
3233
3412
  if (toChorus) {
3413
+ const choice2 = rng.range(0, 7);
3234
3414
  if (highEnergy) {
3235
- switch (choice) {
3415
+ switch (choice2) {
3236
3416
  case 0:
3237
3417
  case 1:
3238
3418
  return "tomDescend";
@@ -3250,7 +3430,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3250
3430
  return "ghostToAccent";
3251
3431
  }
3252
3432
  }
3253
- switch (choice) {
3433
+ switch (choice2) {
3254
3434
  case 0:
3255
3435
  case 1:
3256
3436
  return "snareTomCombo";
@@ -3267,7 +3447,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3267
3447
  }
3268
3448
  }
3269
3449
  if (fromIntro) {
3270
- switch (choice) {
3450
+ switch (rng.range(0, 5)) {
3271
3451
  case 0:
3272
3452
  case 1:
3273
3453
  return "snareRoll";
@@ -3281,6 +3461,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3281
3461
  return "halfTimeFill";
3282
3462
  }
3283
3463
  }
3464
+ const choice = rng.range(0, 7);
3284
3465
  if (highEnergy) {
3285
3466
  switch (choice) {
3286
3467
  case 0:
@@ -3463,6 +3644,10 @@ function generateFill(track, beatTick, beat, fillType, velocity) {
3463
3644
  if (beat === 2) {
3464
3645
  track.add(GM.SD, beatTick, 1, accentVel);
3465
3646
  track.add(GM.BD, beatTick, 1, fillVel);
3647
+ } else if (beat === 3) {
3648
+ track.add(GM.SD, beatTick, EIGHTH, accentVel);
3649
+ track.add(GM.SD, beatTick + E, S, fillVel * 0.6);
3650
+ track.add(GM.SD, beatTick + E + S, S, accentVel);
3466
3651
  }
3467
3652
  break;
3468
3653
  }
@@ -3505,6 +3690,19 @@ function isInPreChorusLift(section, bar, sectionBars, nextIsChorus) {
3505
3690
  }
3506
3691
  return bar >= sectionBars - 2;
3507
3692
  }
3693
+ function euclideanToKickPattern(mask) {
3694
+ const slot = (step) => hasHit(mask, step) || hasHit(mask, (step + 15) % 16);
3695
+ return {
3696
+ beat1: slot(0),
3697
+ beat1and: slot(2),
3698
+ beat2: slot(4),
3699
+ beat2and: slot(6),
3700
+ beat3: slot(8),
3701
+ beat3and: slot(10),
3702
+ beat4: slot(12),
3703
+ beat4and: slot(14)
3704
+ };
3705
+ }
3508
3706
  function getKickPattern(section, style, bar, rng) {
3509
3707
  const p = { ...EMPTY };
3510
3708
  if (section === "outro") {
@@ -3709,6 +3907,17 @@ function generateDrums(opts) {
3709
3907
  const energy = sectionEnergy(section);
3710
3908
  const fillStartBeat = getFillStartBeat(energy);
3711
3909
  const percMood = percMoodCategory(style);
3910
+ const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
3911
+ const nextEnergy = sectionEnergy(nextSection);
3912
+ const euclidKick = opts.euclideanKick ? euclideanToKickPattern(
3913
+ patternToMask(
3914
+ euclideanRhythm(
3915
+ opts.euclideanKick.pulses,
3916
+ opts.euclideanKick.steps ?? 16,
3917
+ opts.euclideanKick.rotation ?? 0
3918
+ )
3919
+ )
3920
+ ) : void 0;
3712
3921
  const reuseSectionKick = (section === "b" || section === "chorus") && style !== "sparse";
3713
3922
  let sectionKick;
3714
3923
  for (let bar = 0; bar < opts.bars; bar += 1) {
@@ -3724,7 +3933,9 @@ function generateDrums(opts) {
3724
3933
  barHasOpenHh = !track.hasCrashNear(barStart + openHhBeatIndex);
3725
3934
  }
3726
3935
  let kick;
3727
- if (reuseSectionKick) {
3936
+ if (euclidKick) {
3937
+ kick = euclidKick;
3938
+ } else if (reuseSectionKick) {
3728
3939
  sectionKick ??= getKickPattern(section, style, 0, rng);
3729
3940
  kick = sectionKick;
3730
3941
  } else {
@@ -3737,12 +3948,17 @@ function generateDrums(opts) {
3737
3948
  const velocity = calculateVelocity(section, beat);
3738
3949
  if (opts.fills && isLastBar && !inLift && beat >= fillStartBeat) {
3739
3950
  if (beat === fillStartBeat) {
3740
- currentFill = selectFillType(section, section, style, energy, rng);
3951
+ currentFill = selectFillType(section, nextSection, style, nextEnergy, rng);
3741
3952
  }
3742
3953
  if (playMainVoices) {
3954
+ const before = track.hits.length;
3743
3955
  generateFill(track, beatTick, beat, currentFill, velocity);
3956
+ if (track.hits.length > before) {
3957
+ continue;
3958
+ }
3959
+ } else {
3960
+ continue;
3744
3961
  }
3745
- continue;
3746
3962
  }
3747
3963
  const ctx = {
3748
3964
  track,
@@ -3864,18 +4080,23 @@ function extractGrooveTemplate(events, ts, subdivision) {
3864
4080
  for (let i = 0; i < slotsPerBar; i += 1) {
3865
4081
  const count = counts[i] ?? 0;
3866
4082
  if (count === 0) {
3867
- slots.push({ timingOffset: 0, velocity: 0 });
4083
+ slots.push({ timingOffset: 0, velocity: null });
3868
4084
  continue;
3869
4085
  }
3870
4086
  const velocityCount = velocityCounts[i] ?? 0;
3871
4087
  slots.push({
3872
4088
  timingOffset: (offsetSums[i] ?? 0) / count,
3873
- velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : 0
4089
+ velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : null
3874
4090
  });
3875
4091
  }
3876
- return { subdivision, slotsPerBar, slots };
4092
+ return { subdivision, slotsPerBar, slots, ts };
3877
4093
  }
3878
4094
  function applyGrooveTemplate(events, template, ts) {
4095
+ if (template.ts && (template.ts.numerator !== ts.numerator || template.ts.denominator !== ts.denominator)) {
4096
+ throw new Error(
4097
+ `Groove template meter ${formatTimeSignature(template.ts)} does not match apply-time meter ${formatTimeSignature(ts)}`
4098
+ );
4099
+ }
3879
4100
  const barBeats = beatsPerBar(ts);
3880
4101
  return events.map((event) => {
3881
4102
  const { quantizedBeat, slotIndex } = quantizeToGrid(
@@ -3886,7 +4107,7 @@ function applyGrooveTemplate(events, template, ts) {
3886
4107
  );
3887
4108
  const slot = template.slots[slotIndex];
3888
4109
  const startBeat = quantizedBeat + (slot?.timingOffset ?? 0);
3889
- const velocity = slot && slot.velocity > 0 ? Math.round(slot.velocity) : event.velocity;
4110
+ const velocity = slot && slot.velocity !== null && slot.velocity !== void 0 ? Math.round(slot.velocity) : event.velocity;
3890
4111
  return {
3891
4112
  pitch: event.pitch,
3892
4113
  startBeat,
@@ -3939,6 +4160,7 @@ var DEFAULT_METER = { numerator: 4, denominator: 4 };
3939
4160
  var COMFORT_LOW = 55;
3940
4161
  var COMFORT_HIGH = 79;
3941
4162
  var TESSITURA_WEIGHT = 1e-3;
4163
+ var TIE_BREAK_JITTER = 1e-6;
3942
4164
  function pitchClass11(pitch) {
3943
4165
  return (Math.trunc(pitch) % 12 + 12) % 12;
3944
4166
  }
@@ -3953,26 +4175,33 @@ function mulberry32(seed) {
3953
4175
  };
3954
4176
  }
3955
4177
  function inferKey(melody) {
3956
- let best = 0;
4178
+ let best = majorKey(0);
3957
4179
  let bestScore = Number.NEGATIVE_INFINITY;
4180
+ const candidates = [];
3958
4181
  for (let tonic = 0; tonic < 12; tonic += 1) {
3959
- const key = majorKey(tonic);
4182
+ candidates.push(majorKey(tonic), minorKey(tonic));
4183
+ }
4184
+ for (const key of candidates) {
4185
+ const tonic = pitchClass11(key.rootPc);
4186
+ const isMinor = key.modeMask12 === NATURAL_MINOR_MASK;
4187
+ const leadingTone = (tonic + 11) % 12;
3960
4188
  let score = 0;
3961
4189
  for (const n of melody) {
3962
4190
  const w = Math.max(0.25, n.durationBeat);
3963
- if (isScaleTone(n.pitch, key)) {
4191
+ const pc = pitchClass11(n.pitch);
4192
+ if (isScaleTone(n.pitch, key) || isMinor && pc === leadingTone) {
3964
4193
  score += w;
3965
4194
  }
3966
- if (pitchClass11(n.pitch) === tonic) {
4195
+ if (pc === tonic) {
3967
4196
  score += 0.5 * w;
3968
4197
  }
3969
4198
  }
3970
4199
  if (score > bestScore) {
3971
4200
  bestScore = score;
3972
- best = tonic;
4201
+ best = key;
3973
4202
  }
3974
4203
  }
3975
- return majorKey(best);
4204
+ return best;
3976
4205
  }
3977
4206
  function buildCandidates(key, reharmonize) {
3978
4207
  const tones = scaleTonesInDegreeOrder(key);
@@ -4124,7 +4353,7 @@ function harmonizeMelody(opts) {
4124
4353
  const candidates = buildCandidates(key, opts.reharmonize);
4125
4354
  const candAt = (i) => candidates[i] ?? FALLBACK;
4126
4355
  const rng = mulberry32(opts.seed ?? 0);
4127
- const jitter = candidates.map(() => rng() * 1e-6);
4356
+ const jitter = candidates.map(() => rng() * TIE_BREAK_JITTER);
4128
4357
  const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
4129
4358
  const hr = Math.max(0.25, opts.harmonicRhythm);
4130
4359
  const segCount = Math.max(1, Math.ceil(melodyEnd / hr));
@@ -4321,6 +4550,8 @@ function spelledInterval(a, b) {
4321
4550
  const reference = SIMPLE_REFERENCE[letterSteps + 1] ?? 0;
4322
4551
  if (reference - rawSemis > 6) {
4323
4552
  rawSemis += 12;
4553
+ } else if (rawSemis - reference > 6) {
4554
+ rawSemis -= 12;
4324
4555
  }
4325
4556
  semitones = rawSemis;
4326
4557
  }
@@ -4545,8 +4776,8 @@ function noteNames(notes) {
4545
4776
  }
4546
4777
 
4547
4778
  // src/symbol/index.ts
4548
- var ROOT_RE = /^[A-G](?:#{1,2}|b{1,2})?/;
4549
- var BASS_RE = /^[A-G](?:#{1,2}|b{1,2})?$/;
4779
+ var ROOT_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?/;
4780
+ var BASS_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?$/;
4550
4781
  var QUALITY_MAP = {
4551
4782
  "": "maj",
4552
4783
  m: "min",
@@ -4641,8 +4872,17 @@ var CANONICAL_SUFFIX = {
4641
4872
  function pitchClass12(value) {
4642
4873
  return (Math.trunc(value) % 12 + 12) % 12;
4643
4874
  }
4644
- function bassPitchClass(token) {
4645
- return noteToPitchClass(parseNote(token));
4875
+ function makeSpelledChord(root, quality, bass) {
4876
+ const chord = makeChord(
4877
+ noteToPitchClass(root),
4878
+ quality,
4879
+ bass === void 0 ? void 0 : noteToPitchClass(bass)
4880
+ );
4881
+ chord.rootSpelling = { letter: root.letter, alter: root.alter };
4882
+ if (bass !== void 0) {
4883
+ chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
4884
+ }
4885
+ return chord;
4646
4886
  }
4647
4887
  function parseChordSymbol(text) {
4648
4888
  const trimmed = text.trim();
@@ -4651,37 +4891,37 @@ function parseChordSymbol(text) {
4651
4891
  throw new Error(`Invalid chord symbol: ${text}`);
4652
4892
  }
4653
4893
  const rootToken = rootMatch[0];
4654
- const rootPc = noteToPitchClass(parseNote(rootToken));
4894
+ const rootNote = parseNote(rootToken);
4655
4895
  const rest = trimmed.slice(rootToken.length);
4656
- const slashIndex = rest.indexOf("/");
4657
- if (slashIndex === -1) {
4658
- const quality = QUALITY_MAP[rest];
4659
- if (quality === void 0) {
4660
- throw new Error(`Unrecognized chord quality: ${text}`);
4896
+ const slashIndex = rest.lastIndexOf("/");
4897
+ if (slashIndex !== -1) {
4898
+ const before = rest.slice(0, slashIndex);
4899
+ const after = rest.slice(slashIndex + 1);
4900
+ const beforeQuality = QUALITY_MAP[before];
4901
+ if (beforeQuality !== void 0 && BASS_RE.test(after)) {
4902
+ return makeSpelledChord(rootNote, beforeQuality, parseNote(after));
4661
4903
  }
4662
- return makeChord(rootPc, quality);
4663
4904
  }
4664
- const before = rest.slice(0, slashIndex);
4665
- const after = rest.slice(slashIndex + 1);
4666
- const beforeQuality = QUALITY_MAP[before];
4667
- if (beforeQuality !== void 0 && BASS_RE.test(after)) {
4668
- return makeChord(rootPc, beforeQuality, bassPitchClass(after));
4905
+ const quality = QUALITY_MAP[rest];
4906
+ if (quality === void 0) {
4907
+ throw new Error(`Unrecognized chord quality: ${text}`);
4669
4908
  }
4670
- const combinedQuality = QUALITY_MAP[rest];
4671
- if (combinedQuality !== void 0) {
4672
- return makeChord(rootPc, combinedQuality);
4909
+ return makeSpelledChord(rootNote, quality);
4910
+ }
4911
+ function pitchClassName(pc, hint, flats) {
4912
+ if (flats === void 0 && hint !== void 0 && noteToPitchClass(hint) === pc) {
4913
+ return formatNote(hint);
4673
4914
  }
4674
- throw new Error(`Unrecognized chord quality: ${text}`);
4915
+ const note = midiToNote(60 + pc, flats ? "flat" : "sharp");
4916
+ return formatNote({ letter: note.letter, alter: note.alter });
4675
4917
  }
4676
4918
  function formatChordSymbol(chord, opts) {
4677
- const spelling = opts?.flats ? "flat" : "sharp";
4678
- const rootNote = midiToNote(60 + pitchClass12(chord.rootPc), spelling);
4679
- const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
4919
+ const rootPc = pitchClass12(chord.rootPc);
4920
+ const rootName = pitchClassName(rootPc, chord.rootSpelling, opts?.flats);
4680
4921
  const suffix = CANONICAL_SUFFIX[chord.quality];
4681
4922
  let symbol = `${rootName}${suffix}`;
4682
- if (chord.bassPc !== void 0 && pitchClass12(chord.bassPc) !== pitchClass12(chord.rootPc)) {
4683
- const bassNote = midiToNote(60 + pitchClass12(chord.bassPc), spelling);
4684
- symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
4923
+ if (chord.bassPc !== void 0 && pitchClass12(chord.bassPc) !== rootPc) {
4924
+ symbol += `/${pitchClassName(pitchClass12(chord.bassPc), chord.bassSpelling, opts?.flats)}`;
4685
4925
  }
4686
4926
  return symbol;
4687
4927
  }
@@ -4708,6 +4948,7 @@ var DERIVED_SPAN = 19;
4708
4948
  var VIOLATION_PENALTY = 1e3;
4709
4949
  var MISSING_TONE_PENALTY = 500;
4710
4950
  var POOR_DOUBLING_PENALTY = 4;
4951
+ var HIDDEN_PERFECT_PENALTY = 6;
4711
4952
  var MAX_CANDIDATES = 4e3;
4712
4953
  function pitchClass13(value) {
4713
4954
  return (Math.trunc(value) % 12 + 12) % 12;
@@ -4745,7 +4986,12 @@ function pitchesForPc(pc, range) {
4745
4986
  result.push(midi);
4746
4987
  }
4747
4988
  }
4748
- return result;
4989
+ const center = (range.min + range.max) / 2;
4990
+ return result.sort((a, b) => {
4991
+ const da = Math.abs(a - center);
4992
+ const db = Math.abs(b - center);
4993
+ return da === db ? a - b : da - db;
4994
+ });
4749
4995
  }
4750
4996
  function enumerateVoicings(chord, ranges, maxSpacing) {
4751
4997
  const chordPcs = chordPitchClasses(chord);
@@ -4821,9 +5067,6 @@ function violationCount(prev, cur, maxSpacing) {
4821
5067
  if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
4822
5068
  count += 1;
4823
5069
  }
4824
- if (createsParallelOctave(prevUpper, curUpper, prevLower, curLower)) {
4825
- count += 1;
4826
- }
4827
5070
  if (createsVoiceCrossing(curUpper, curLower)) {
4828
5071
  count += 1;
4829
5072
  }
@@ -4852,6 +5095,15 @@ function voiceLeadingCost(from, to) {
4852
5095
  }
4853
5096
  total += Math.abs(b - a);
4854
5097
  }
5098
+ if (from.length >= 2) {
5099
+ const bassPrev = from[0];
5100
+ const bassCur = to[0];
5101
+ const sopPrev = from[from.length - 1];
5102
+ const sopCur = to[to.length - 1];
5103
+ if (bassPrev !== void 0 && bassCur !== void 0 && sopPrev !== void 0 && sopCur !== void 0 && createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)) {
5104
+ total += HIDDEN_PERFECT_PENALTY;
5105
+ }
5106
+ }
4855
5107
  return total;
4856
5108
  }
4857
5109
  function voiceChord(chord, opts) {
@@ -4990,7 +5242,10 @@ function voiceChordStyled(chord, opts) {
4990
5242
  return stack.sort((a, b) => a - b);
4991
5243
  }
4992
5244
  function nextVoicing(current, chord, opts) {
4993
- const ranges = opts?.ranges !== void 0 || opts?.voices !== void 0 ? resolveRanges(opts) : current.map((pitch) => ({ min: pitch - 12, max: pitch + 12 }));
5245
+ const ranges = opts?.ranges !== void 0 || opts?.voices !== void 0 ? resolveRanges(opts) : current.map((pitch) => {
5246
+ const centre = Math.min(127, Math.max(0, pitch));
5247
+ return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
5248
+ });
4994
5249
  const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
4995
5250
  const candidates = enumerateVoicings(chord, ranges, maxSpacing);
4996
5251
  let best;
@@ -5028,6 +5283,12 @@ function copyChord(data) {
5028
5283
  if (data.bassPc !== void 0) {
5029
5284
  copy.bassPc = data.bassPc;
5030
5285
  }
5286
+ if (data.rootSpelling !== void 0) {
5287
+ copy.rootSpelling = { letter: data.rootSpelling.letter, alter: data.rootSpelling.alter };
5288
+ }
5289
+ if (data.bassSpelling !== void 0) {
5290
+ copy.bassSpelling = { letter: data.bassSpelling.letter, alter: data.bassSpelling.alter };
5291
+ }
5031
5292
  return copy;
5032
5293
  }
5033
5294
  function spellPitchClassBare(pc, spelling) {
@@ -5113,12 +5374,16 @@ var Note = class _Note {
5113
5374
  *
5114
5375
  * When the note carries an octave the transposition happens in MIDI space and
5115
5376
  * the result is spelled with a sharp preference. An octave-less note stays
5116
- * octave-less: only its pitch class is moved.
5377
+ * octave-less: only its pitch class is moved. Transposing by zero is the
5378
+ * identity: the original spelling is preserved (no enharmonic respelling).
5117
5379
  *
5118
5380
  * @param semitones The signed semitone offset.
5119
5381
  * @returns The transposed note.
5120
5382
  */
5121
5383
  transpose(semitones) {
5384
+ if (semitones === 0) {
5385
+ return new _Note(this.#data);
5386
+ }
5122
5387
  if (this.#data.octave !== void 0) {
5123
5388
  return _Note.fromMidi(this.midi + semitones);
5124
5389
  }
@@ -5142,6 +5407,17 @@ var Note = class _Note {
5142
5407
  equals(other) {
5143
5408
  return this.#data.letter === other.#data.letter && this.#data.alter === other.#data.alter && this.#data.octave === other.#data.octave;
5144
5409
  }
5410
+ /**
5411
+ * The plain note data, for JSON serialization.
5412
+ *
5413
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5414
+ * `JSON.stringify(note)` from collapsing to `{}`.
5415
+ *
5416
+ * @returns A copy of the underlying plain note object.
5417
+ */
5418
+ toJSON() {
5419
+ return this.data;
5420
+ }
5145
5421
  };
5146
5422
  var Interval = class _Interval {
5147
5423
  #number;
@@ -5190,10 +5466,32 @@ var Interval = class _Interval {
5190
5466
  get name() {
5191
5467
  return `${this.#quality}${this.#number}`;
5192
5468
  }
5469
+ /**
5470
+ * The plain interval data, for JSON serialization.
5471
+ *
5472
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5473
+ * `JSON.stringify(interval)` from collapsing to `{}`.
5474
+ *
5475
+ * @returns The diatonic number, quality, and semitone span.
5476
+ */
5477
+ toJSON() {
5478
+ return { number: this.#number, quality: this.#quality, semitones: this.#semitones };
5479
+ }
5193
5480
  };
5194
5481
  function resolveTonic(root, spelling) {
5195
5482
  return typeof root === "string" ? Note.of(root) : new Note(spellPitchClassBare(root, spelling));
5196
5483
  }
5484
+ function accidentalLoad(tonic, scale) {
5485
+ return spellScale(tonic, scale).reduce((sum, note) => sum + Math.abs(note.alter), 0);
5486
+ }
5487
+ function bestTonicForScale(rootPc, scale) {
5488
+ const sharp = spellPitchClassBare(rootPc, "sharp");
5489
+ const flat = spellPitchClassBare(rootPc, "flat");
5490
+ if (sharp.letter === flat.letter && sharp.alter === flat.alter) {
5491
+ return new Note(sharp);
5492
+ }
5493
+ return accidentalLoad(flat, scale) <= accidentalLoad(sharp, scale) ? new Note(flat) : new Note(sharp);
5494
+ }
5197
5495
  var Key = class _Key {
5198
5496
  #scale;
5199
5497
  #tonic;
@@ -5210,24 +5508,34 @@ var Key = class _Key {
5210
5508
  /**
5211
5509
  * A major key.
5212
5510
  *
5213
- * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; numeric
5214
- * roots are spelled with a sharp preference.
5511
+ * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; a numeric
5512
+ * root is spelled with whichever accidental side yields the fewest
5513
+ * accidentals across the scale.
5215
5514
  * @returns The major key.
5216
5515
  */
5217
5516
  static major(root) {
5218
- const tonic = resolveTonic(root, "sharp");
5219
- return new _Key(majorKey(tonic.pitchClass), tonic);
5517
+ if (typeof root === "string") {
5518
+ const tonic = Note.of(root);
5519
+ return new _Key(majorKey(tonic.pitchClass), tonic);
5520
+ }
5521
+ const scale = majorKey(root);
5522
+ return new _Key(scale, bestTonicForScale(root, scale));
5220
5523
  }
5221
5524
  /**
5222
5525
  * A natural-minor key.
5223
5526
  *
5224
- * @param root Tonic as a note name or a pitch class; numeric roots are
5225
- * spelled with a flat preference.
5527
+ * @param root Tonic as a note name or a pitch class; a numeric root is
5528
+ * spelled with whichever accidental side yields the fewest accidentals
5529
+ * across the scale.
5226
5530
  * @returns The minor key.
5227
5531
  */
5228
5532
  static minor(root) {
5229
- const tonic = resolveTonic(root, "flat");
5230
- return new _Key(minorKey(tonic.pitchClass), tonic);
5533
+ if (typeof root === "string") {
5534
+ const tonic = Note.of(root);
5535
+ return new _Key(minorKey(tonic.pitchClass), tonic);
5536
+ }
5537
+ const scale = minorKey(root);
5538
+ return new _Key(scale, bestTonicForScale(root, scale));
5231
5539
  }
5232
5540
  /**
5233
5541
  * A key on a named scale (e.g. `'dorian'`, `'harmonicMinor'`).
@@ -5354,6 +5662,19 @@ var Key = class _Key {
5354
5662
  contains(x) {
5355
5663
  return isScaleTone(typeof x === "number" ? x : x.pitchClass, this.#scale);
5356
5664
  }
5665
+ /**
5666
+ * The plain key data, for JSON serialization.
5667
+ *
5668
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5669
+ * `JSON.stringify(key)` from collapsing to `{}`. The result pairs the
5670
+ * `KeyScale` with the spelled tonic, enough to reconstruct the key via
5671
+ * {@link Key.of}.
5672
+ *
5673
+ * @returns The key/scale and its spelled tonic.
5674
+ */
5675
+ toJSON() {
5676
+ return { scale: this.scale, tonic: this.#tonic.data };
5677
+ }
5357
5678
  };
5358
5679
  var Chord = class _Chord {
5359
5680
  #data;
@@ -5550,13 +5871,17 @@ var Chord = class _Chord {
5550
5871
  * @throws If no key is given and none is carried.
5551
5872
  */
5552
5873
  negativeHarmony(key) {
5553
- return new _Chord(negativeHarmonyMirror(this.#data, this.#resolveKey(key).scale), this.#key);
5874
+ const resolved = this.#resolveKey(key);
5875
+ return new _Chord(negativeHarmonyMirror(this.#data, resolved.scale), key ?? this.#key);
5554
5876
  }
5555
5877
  /**
5556
5878
  * The n-th inversion: a copy whose bass is the chord tone `n` steps above the
5557
5879
  * root in the interval template (`invert(1)` puts the third in the bass).
5558
5880
  * `n` wraps around the template length; negative values count backwards.
5559
5881
  *
5882
+ * `invert(0)` (and any `n` that wraps to it) is root position, so it carries
5883
+ * no slash bass and equals the original chord.
5884
+ *
5560
5885
  * @param n The inversion number.
5561
5886
  * @returns The inverted chord, keeping any key context.
5562
5887
  * @throws If the chord has no intervals.
@@ -5569,7 +5894,11 @@ var Chord = class _Chord {
5569
5894
  }
5570
5895
  const index = (n % length + length) % length;
5571
5896
  const data = copyChord(this.#data);
5572
- data.bassPc = mod125(this.#data.rootPc + (intervals[index] ?? 0));
5897
+ if (index === 0) {
5898
+ delete data.bassPc;
5899
+ } else {
5900
+ data.bassPc = mod125(this.#data.rootPc + (intervals[index] ?? 0));
5901
+ }
5573
5902
  return new _Chord(data, this.#key);
5574
5903
  }
5575
5904
  /**
@@ -5750,6 +6079,21 @@ var Progression = class _Progression {
5750
6079
  const cadence = from !== void 0 && to !== void 0 ? detectCadence(from.data, to.data, resolved.scale) : null;
5751
6080
  return { chords, cadence };
5752
6081
  }
6082
+ /**
6083
+ * The plain progression data, for JSON serialization.
6084
+ *
6085
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
6086
+ * `JSON.stringify(progression)` from collapsing to `{}`. The chords are
6087
+ * emitted as plain data and the carried key, when present, as its own data.
6088
+ *
6089
+ * @returns The chord data sequence and the carried key, if any.
6090
+ */
6091
+ toJSON() {
6092
+ return {
6093
+ chords: this.#chords.map((chord) => chord.data),
6094
+ key: this.#key?.toJSON()
6095
+ };
6096
+ }
5753
6097
  /** Resolve the key for an analysis method: explicit first, then carried. */
5754
6098
  #resolveKey(key) {
5755
6099
  const resolved = key ?? this.#key;
@@ -5827,7 +6171,6 @@ function mulberry322(seed) {
5827
6171
  }
5828
6172
  function contourOffsets(contour, count) {
5829
6173
  const offsets = [];
5830
- const half = Math.floor(count / 2);
5831
6174
  for (let i = 0; i < count; i += 1) {
5832
6175
  switch (contour) {
5833
6176
  case "ascending":
@@ -5837,7 +6180,7 @@ function contourOffsets(contour, count) {
5837
6180
  offsets.push(-i);
5838
6181
  break;
5839
6182
  case "arch":
5840
- offsets.push(i <= half ? i : count - 1 - i);
6183
+ offsets.push(Math.min(i, count - 1 - i));
5841
6184
  break;
5842
6185
  case "wave":
5843
6186
  offsets.push([0, 1, 0, -1][i % 4] ?? 0);
@@ -5853,11 +6196,15 @@ function generateMotif(opts) {
5853
6196
  const noteCount = Math.max(3, bars * 2);
5854
6197
  const beatsPerNote = totalBeats / noteCount;
5855
6198
  const rng = mulberry322(opts.seed ?? 0);
6199
+ const jitterProb = Math.min(1, Math.max(0, opts.jitter ?? 0));
5856
6200
  const tonic = pitchClass14(opts.key.rootPc) + 60;
5857
6201
  const offsets = contourOffsets(contour, noteCount);
5858
6202
  const notes = [];
5859
6203
  for (let i = 0; i < noteCount; i += 1) {
5860
- const jitter = rng() < 0.25 ? 1 : 0;
6204
+ let jitter = 0;
6205
+ if (jitterProb > 0 && rng() < jitterProb) {
6206
+ jitter = rng() < 0.5 ? 1 : -1;
6207
+ }
5861
6208
  let pitch = stepDiatonic(tonic, (offsets[i] ?? 0) + jitter, opts.key);
5862
6209
  const startBeat = i * beatsPerNote;
5863
6210
  if (opts.chord && startBeat % 4 === 0) {
@@ -6169,6 +6516,9 @@ function generateProgression(opts) {
6169
6516
  let preset;
6170
6517
  if (opts.presetId !== void 0) {
6171
6518
  preset = PRESETS.find((p) => p.id === opts.presetId);
6519
+ if (preset === void 0) {
6520
+ throw new Error(`Unknown progression preset: ${opts.presetId}`);
6521
+ }
6172
6522
  }
6173
6523
  if (preset === void 0) {
6174
6524
  const pool = PRESETS.filter((p) => p.styles.includes(opts.style));
@@ -6239,15 +6589,17 @@ function generateRhythm(ts, opts = {}) {
6239
6589
  const seed = opts.seed ?? 0;
6240
6590
  const bars = opts.bars ?? DEFAULT_BARS;
6241
6591
  const subdivision = opts.subdivision ?? DEFAULT_SUBDIVISION;
6242
- const density = opts.density ?? DEFAULT_DENSITY;
6243
- const spanBeats = beatsPerBar(ts) * bars;
6592
+ const density = Math.min(1, Math.max(0, opts.density ?? DEFAULT_DENSITY));
6593
+ const barBeats = beatsPerBar(ts);
6594
+ const spanBeats = barBeats * bars;
6244
6595
  const step = 1 / subdivision;
6245
6596
  const slotCount = Math.round(spanBeats * subdivision);
6597
+ const slotsPerBar = Math.round(barBeats * subdivision);
6246
6598
  const rng = createRng(seed);
6247
6599
  const positions = [];
6248
6600
  for (let i = 0; i < slotCount; i += 1) {
6249
6601
  const position = i * step;
6250
- if (i === 0) {
6602
+ if (i % slotsPerBar === 0) {
6251
6603
  positions.push(position);
6252
6604
  continue;
6253
6605
  }