@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.js CHANGED
@@ -390,12 +390,24 @@ function isLeadingToneResolution(prev, cur, key) {
390
390
  }
391
391
 
392
392
  // src/analysis/index.ts
393
+ var EPS = 1e-9;
393
394
  function pitchClass4(pitch) {
394
395
  return (Math.trunc(pitch) % 12 + 12) % 12;
395
396
  }
396
397
  function intervalAboveRoot(pitch, chord) {
397
398
  return ((pitchClass4(pitch) - pitchClass4(chord.rootPc)) % 12 + 12) % 12;
398
399
  }
400
+ function intervalAboveBass(pitch, others, chord) {
401
+ let bass = Number.POSITIVE_INFINITY;
402
+ for (const ov of others) {
403
+ bass = Math.min(bass, ov.pitch);
404
+ }
405
+ if (Number.isFinite(bass) && bass < pitch) {
406
+ return ((pitch - bass) % 12 + 12) % 12;
407
+ }
408
+ const ref = chord.bassPc ?? chord.rootPc;
409
+ return ((pitchClass4(pitch) - pitchClass4(ref)) % 12 + 12) % 12;
410
+ }
399
411
  function tensionDegree(ic) {
400
412
  if (ic === 1 || ic === 2) {
401
413
  return 9;
@@ -440,8 +452,10 @@ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
440
452
  if (!note) {
441
453
  continue;
442
454
  }
443
- const prev = i > 0 ? voice[i - 1] : void 0;
444
- const next = i + 1 < voice.length ? voice[i + 1] : void 0;
455
+ const prevNote = i > 0 ? voice[i - 1] : void 0;
456
+ const nextNote = i + 1 < voice.length ? voice[i + 1] : void 0;
457
+ const prev = prevNote !== void 0 && prevNote.startBeat < note.startBeat - EPS ? prevNote : void 0;
458
+ const next = nextNote !== void 0 && nextNote.startBeat > note.startBeat + EPS ? nextNote : void 0;
445
459
  const chord = chordAtBeat(note.startBeat);
446
460
  const labels = [];
447
461
  const member = isChordMember(note.pitch, chord);
@@ -460,11 +474,12 @@ function analyzeVoice(voice, chordAtBeat, key, otherVoicesAtBeat) {
460
474
  const prevChord = chordAtBeat(prev.startBeat);
461
475
  const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
462
476
  const resolves = isStep(next.pitch, note.pitch);
463
- const verticallyDissonant = otherVoicesAtBeat(note.startBeat).some(
477
+ const others = otherVoicesAtBeat(note.startBeat);
478
+ const verticallyDissonant = others.some(
464
479
  (ov) => createsVerticalDissonance(note.pitch, ov.pitch, true)
465
480
  );
466
481
  if (prepared && verticallyDissonant && resolves) {
467
- const ic = intervalAboveRoot(note.pitch, chord);
482
+ const ic = intervalAboveBass(note.pitch, others, chord);
468
483
  const sus = suspensionType(ic, next.pitch - note.pitch);
469
484
  sus.resolveTo = next.pitch;
470
485
  labels.push(sus);
@@ -638,11 +653,43 @@ function seventhQuality(base, halfDim) {
638
653
  return base === "maj" ? "dom7" : "min7";
639
654
  }
640
655
  var INVERSION_FIGURES = /* @__PURE__ */ new Set(["6", "64", "65", "43", "42", "2", "7"]);
641
- var EXTENSION_FIGURES = {
642
- "9": "dom9",
643
- "11": "11",
644
- "13": "13"
656
+ var ROMAN_STYLE = {
657
+ maj: { lower: false, suffix: "" },
658
+ min: { lower: true, suffix: "" },
659
+ dim: { lower: true, suffix: "o" },
660
+ aug: { lower: false, suffix: "+" },
661
+ maj7: { lower: false, suffix: "maj7" },
662
+ min7: { lower: true, suffix: "7" },
663
+ dom7: { lower: false, suffix: "7" },
664
+ dim7: { lower: true, suffix: "o7" },
665
+ m7b5: { lower: true, suffix: "\xF87" },
666
+ minMaj7: { lower: true, suffix: "maj7" },
667
+ aug7: { lower: false, suffix: "+7" },
668
+ augMaj7: { lower: false, suffix: "+maj7" },
669
+ majb5: { lower: false, suffix: "b5" },
670
+ "6": { lower: false, suffix: "add6" },
671
+ min6: { lower: true, suffix: "add6" },
672
+ "6/9": { lower: false, suffix: "69" },
673
+ sus2: { lower: false, suffix: "sus2" },
674
+ sus4: { lower: false, suffix: "sus4" },
675
+ add9: { lower: false, suffix: "add9" },
676
+ add11: { lower: false, suffix: "add11" },
677
+ maj9: { lower: false, suffix: "maj9" },
678
+ min9: { lower: true, suffix: "9" },
679
+ dom9: { lower: false, suffix: "9" },
680
+ "7b9": { lower: false, suffix: "7b9" },
681
+ "7#9": { lower: false, suffix: "7#9" },
682
+ "7#11": { lower: false, suffix: "7#11" },
683
+ "7b13": { lower: false, suffix: "7b13" },
684
+ "11": { lower: false, suffix: "11" },
685
+ "13": { lower: false, suffix: "13" },
686
+ "5": { lower: false, suffix: "5" }
645
687
  };
688
+ var SUFFIX_QUALITY = new Map(
689
+ Object.entries(ROMAN_STYLE).map(
690
+ ([quality, style]) => [`${style.lower ? "l" : "u"}:${style.suffix}`, quality]
691
+ )
692
+ );
646
693
  function parseInversion(figures) {
647
694
  if (figures === "65") {
648
695
  return { inversion: 1, seventh: true };
@@ -678,15 +725,15 @@ function parseSimpleRoman(text, key) {
678
725
  const isUpper = (match[2] ?? "")[0] === (match[2] ?? "")[0]?.toUpperCase();
679
726
  const suffix = match[3] ?? "";
680
727
  const rootPc = mod12(degreeRootPc(degreeNumber, key) + accidental);
728
+ const canonical = SUFFIX_QUALITY.get(`${isUpper ? "u" : "l"}:${suffix}`);
729
+ if (canonical !== void 0) {
730
+ return { rootPc, quality: canonical, inversion: 0 };
731
+ }
681
732
  const isDim = /o|°|dim/.test(suffix);
682
733
  const isAug = /\+|aug/.test(suffix);
683
734
  const isHalfDim = /ø/.test(suffix);
684
735
  const explicitMaj7 = /maj7|M7/.test(suffix);
685
736
  const figures = suffix.replace(/maj7|M7/g, "").replace(/[^0-9]/g, "");
686
- const extension = EXTENSION_FIGURES[figures];
687
- if (extension !== void 0) {
688
- return { rootPc, quality: extension, inversion: 0 };
689
- }
690
737
  if (figures !== "" && !INVERSION_FIGURES.has(figures)) {
691
738
  throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
692
739
  }
@@ -702,7 +749,7 @@ function parseSimpleRoman(text, key) {
702
749
  }
703
750
  let quality;
704
751
  if (explicitMaj7) {
705
- quality = base === "min" ? "minMaj7" : "maj7";
752
+ quality = base === "min" ? "minMaj7" : base === "aug" ? "augMaj7" : "maj7";
706
753
  } else if (hasSeventh) {
707
754
  quality = seventhQuality(base, isHalfDim);
708
755
  } else if (base === "dim") {
@@ -734,30 +781,7 @@ function romanToChord(text, key) {
734
781
  return chordFromParsed(parseSimpleRoman(trimmed, key));
735
782
  }
736
783
  function romanStyle(quality) {
737
- switch (quality) {
738
- case "min":
739
- return { lower: true, suffix: "" };
740
- case "dim":
741
- return { lower: true, suffix: "o" };
742
- case "aug":
743
- return { lower: false, suffix: "+" };
744
- case "maj7":
745
- return { lower: false, suffix: "maj7" };
746
- case "dom7":
747
- return { lower: false, suffix: "7" };
748
- case "min7":
749
- return { lower: true, suffix: "7" };
750
- case "m7b5":
751
- return { lower: true, suffix: "\xF87" };
752
- case "dim7":
753
- return { lower: true, suffix: "o7" };
754
- case "minMaj7":
755
- return { lower: true, suffix: "maj7" };
756
- case "maj":
757
- return { lower: false, suffix: "" };
758
- default:
759
- return { lower: false, suffix: quality };
760
- }
784
+ return ROMAN_STYLE[quality];
761
785
  }
762
786
  function romanSpelling(rootPc, key) {
763
787
  const tones = scaleTonesInDegreeOrder(key);
@@ -792,6 +816,9 @@ function baseMarker(quality) {
792
816
  if (quality === "aug" || quality === "aug7") {
793
817
  return "+";
794
818
  }
819
+ if (quality === "augMaj7") {
820
+ return "+maj7";
821
+ }
795
822
  if (quality === "maj7" || quality === "minMaj7") {
796
823
  return "maj7";
797
824
  }
@@ -799,6 +826,17 @@ function baseMarker(quality) {
799
826
  }
800
827
  var TRIAD_FIGURES = { 1: "6", 2: "64" };
801
828
  var SEVENTH_FIGURES = { 1: "65", 2: "43", 3: "42" };
829
+ var TRIAD_FIGURE_QUALITIES = /* @__PURE__ */ new Set(["maj", "min", "dim", "aug"]);
830
+ var SEVENTH_FIGURE_QUALITIES = /* @__PURE__ */ new Set([
831
+ "maj7",
832
+ "min7",
833
+ "dom7",
834
+ "dim7",
835
+ "m7b5",
836
+ "minMaj7",
837
+ "aug7",
838
+ "augMaj7"
839
+ ]);
802
840
  function chordToRoman(chord, key) {
803
841
  const { degreeNumber, accidental } = romanSpelling(chord.rootPc, key);
804
842
  const { lower, suffix } = romanStyle(chord.quality);
@@ -812,8 +850,7 @@ function chordToRoman(chord, key) {
812
850
  }
813
851
  }
814
852
  if (inversion > 0) {
815
- const isSeventh = chord.intervals.length >= 4;
816
- const figure = (isSeventh ? SEVENTH_FIGURES : TRIAD_FIGURES)[inversion];
853
+ const figure = SEVENTH_FIGURE_QUALITIES.has(chord.quality) ? SEVENTH_FIGURES[inversion] : TRIAD_FIGURE_QUALITIES.has(chord.quality) ? TRIAD_FIGURES[inversion] : void 0;
817
854
  if (figure !== void 0) {
818
855
  return `${accidental}${cased}${baseMarker(chord.quality)}${figure}`;
819
856
  }
@@ -912,13 +949,13 @@ function secondaryDominant(targetDegree, key) {
912
949
  }
913
950
 
914
951
  // src/meter/index.ts
915
- var EPS = 1e-9;
952
+ var EPS2 = 1e-9;
916
953
  function isMultiple(value, unit) {
917
954
  if (unit === 0) {
918
955
  return false;
919
956
  }
920
957
  const ratio = value / unit;
921
- return Math.abs(ratio - Math.round(ratio)) < EPS;
958
+ return Math.abs(ratio - Math.round(ratio)) < EPS2;
922
959
  }
923
960
  function parseTimeSignature(text) {
924
961
  const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text);
@@ -950,6 +987,29 @@ function pulseBeats(ts) {
950
987
  function pulsesPerBar(ts) {
951
988
  return beatsPerBar(ts) / pulseBeats(ts);
952
989
  }
990
+ function isValidGrouping(grouping, pulses) {
991
+ if (grouping.length === 0) {
992
+ return false;
993
+ }
994
+ let sum = 0;
995
+ for (const g of grouping) {
996
+ if (!Number.isInteger(g) || g <= 0) {
997
+ return false;
998
+ }
999
+ sum += g;
1000
+ }
1001
+ return sum === pulses;
1002
+ }
1003
+ function isGroupHead(grouping, pulseIndex) {
1004
+ let acc = 0;
1005
+ for (const g of grouping) {
1006
+ if (acc === pulseIndex) {
1007
+ return true;
1008
+ }
1009
+ acc += g;
1010
+ }
1011
+ return false;
1012
+ }
953
1013
  function beatToBarPosition(beatInQuarters, ts) {
954
1014
  const barLen = beatsPerBar(ts);
955
1015
  const bar = Math.floor(beatInQuarters / barLen);
@@ -969,6 +1029,15 @@ function metricWeight(beatInQuarters, ts) {
969
1029
  if (pulseIndex === 0) {
970
1030
  return 3;
971
1031
  }
1032
+ const grouping = ts.grouping;
1033
+ if (grouping !== void 0) {
1034
+ if (!isValidGrouping(grouping, pulses)) {
1035
+ throw new Error(
1036
+ `Invalid grouping [${grouping.join(", ")}] for ${formatTimeSignature(ts)}: entries must be positive integers summing to ${pulses}`
1037
+ );
1038
+ }
1039
+ return isGroupHead(grouping, pulseIndex) ? 2 : 1;
1040
+ }
972
1041
  if (pulses % 2 === 0 && pulseIndex === pulses / 2) {
973
1042
  return 2;
974
1043
  }
@@ -1129,7 +1198,7 @@ function evaluateInternal(q, collectSuggestions) {
1129
1198
  if (ov.prevPitch === void 0) {
1130
1199
  continue;
1131
1200
  }
1132
- if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch) || createsParallelOctave(prev, pitch, ov.prevPitch, ov.pitch)) {
1201
+ if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
1133
1202
  reasons |= 2048 /* ParallelPerfect */;
1134
1203
  raise(parallelSeverity);
1135
1204
  }
@@ -1261,7 +1330,7 @@ function detectChord(pitches) {
1261
1330
  quality,
1262
1331
  missingPcs,
1263
1332
  extraPcs,
1264
- exact: extraPcs.length === 0,
1333
+ exact: extraPcs.length === 0 && missingPcs.length === 0,
1265
1334
  inversion
1266
1335
  };
1267
1336
  if (inversion > 0) {
@@ -1293,38 +1362,49 @@ function detectChordBest(pitches) {
1293
1362
  }
1294
1363
  return makeChord(best.rootPc, best.quality, best.bassPc);
1295
1364
  }
1365
+ var MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MINOR_MASK];
1296
1366
  function detectKey(pitches) {
1297
1367
  const input = uniquePitchClasses(pitches);
1368
+ if (input.length === 0) {
1369
+ return [];
1370
+ }
1298
1371
  const counts = /* @__PURE__ */ new Map();
1299
1372
  for (const pc of pitches.map(pitchClass6)) {
1300
1373
  counts.set(pc, (counts.get(pc) ?? 0) + 1);
1301
1374
  }
1302
- const total = pitches.length || 1;
1375
+ const total = pitches.length;
1303
1376
  const results = [];
1304
1377
  for (let tonic = 0; tonic < 12; tonic += 1) {
1305
1378
  for (const mode of ["major", "minor"]) {
1306
1379
  const key = mode === "major" ? majorKey(tonic) : minorKey(tonic);
1380
+ const masks = mode === "major" ? [MAJOR_MASK] : MINOR_MASK_VARIANTS;
1307
1381
  let inScale = 0;
1308
- let weighted = 0;
1309
- for (const pc of input) {
1310
- const offset = (pc - tonic + 12) % 12;
1311
- if (key.modeMask12 >> offset & 1) {
1312
- inScale += 1;
1382
+ let weighted = Number.NEGATIVE_INFINITY;
1383
+ for (const mask of masks) {
1384
+ let variantInScale = 0;
1385
+ let variantWeighted = 0;
1386
+ for (const pc of input) {
1387
+ const offset = (pc - tonic + 12) % 12;
1388
+ if (mask >> offset & 1) {
1389
+ variantInScale += 1;
1390
+ }
1313
1391
  }
1314
- }
1315
- for (const [pc, count] of counts) {
1316
- const offset = (pc - tonic + 12) % 12;
1317
- if (key.modeMask12 >> offset & 1) {
1318
- weighted += count;
1392
+ for (const [pc, count] of counts) {
1393
+ const offset = (pc - tonic + 12) % 12;
1394
+ if (mask >> offset & 1) {
1395
+ variantWeighted += count;
1396
+ }
1319
1397
  }
1320
- if (pc === tonic) {
1321
- weighted += count * 0.5;
1398
+ if (variantWeighted > weighted) {
1399
+ weighted = variantWeighted;
1400
+ inScale = variantInScale;
1322
1401
  }
1323
1402
  }
1403
+ weighted += (counts.get(tonic) ?? 0) * 0.5;
1324
1404
  results.push({
1325
1405
  key,
1326
1406
  mode,
1327
- fit: input.length === 0 ? 0 : inScale / input.length,
1407
+ fit: inScale / input.length,
1328
1408
  score: weighted / total
1329
1409
  });
1330
1410
  }
@@ -1357,7 +1437,7 @@ function segmentLookup(segments) {
1357
1437
  return null;
1358
1438
  };
1359
1439
  }
1360
- var EPS2 = 1e-9;
1440
+ var EPS3 = 1e-9;
1361
1441
  var NOISE_THRESHOLD_RATIO = 0.2;
1362
1442
  var MAX_DETECTION_PCS = 6;
1363
1443
  var FALLBACK_PCS = 3;
@@ -1399,11 +1479,11 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1399
1479
  for (const note of notes) {
1400
1480
  const noteEnd = note.startBeat + note.durationBeat;
1401
1481
  const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
1402
- if (overlap <= EPS2) {
1482
+ if (overlap <= EPS3) {
1403
1483
  continue;
1404
1484
  }
1405
1485
  const velocityFactor = note.velocity !== void 0 ? note.velocity / 127 : 1;
1406
- const onsetInWindow = note.startBeat >= windowStart - EPS2 && note.startBeat < windowEnd - EPS2;
1486
+ const onsetInWindow = note.startBeat >= windowStart - EPS3 && note.startBeat < windowEnd - EPS3;
1407
1487
  const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, ts) / 3 : 1;
1408
1488
  const pc = pitchClass7(note.pitch);
1409
1489
  weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
@@ -1417,7 +1497,7 @@ function analyzeWindow(notes, windowStart, windowEnd, ts, key) {
1417
1497
  totalWeight += w;
1418
1498
  maxWeight = Math.max(maxWeight, w);
1419
1499
  }
1420
- if (totalWeight <= EPS2) {
1500
+ if (totalWeight <= EPS3) {
1421
1501
  return null;
1422
1502
  }
1423
1503
  const ranked = weights.map((weight, pc) => ({ pc, weight })).filter(({ weight }) => weight > 0).sort((a, b) => b.weight - a.weight || a.pc - b.pc);
@@ -1454,21 +1534,22 @@ function chordTimelineFromNotes(notes, opts = {}) {
1454
1534
  if (!(harmonicRhythm > 0)) {
1455
1535
  throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
1456
1536
  }
1457
- const lastNoteEnd = notes.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1537
+ const sounding = notes.filter((note) => note.durationBeat > 0);
1538
+ const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
1458
1539
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
1459
- const key = opts.key ?? detectKey(notes.map((n) => n.pitch))[0]?.key ?? majorKey(0);
1540
+ const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
1460
1541
  const segments = [];
1461
1542
  const segmentConfidence = [];
1462
- const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS2));
1543
+ const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS3));
1463
1544
  for (let i = 0; i < windowCount; i += 1) {
1464
1545
  const start = i * harmonicRhythm;
1465
1546
  const end = Math.min(start + harmonicRhythm, totalBeats);
1466
- const inferred = analyzeWindow(notes, start, end, ts, key);
1547
+ const inferred = analyzeWindow(sounding, start, end, ts, key);
1467
1548
  if (!inferred) {
1468
1549
  continue;
1469
1550
  }
1470
1551
  const last = segments[segments.length - 1];
1471
- if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS2) {
1552
+ if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS3) {
1472
1553
  const lastLength = last.endBeat - last.startBeat;
1473
1554
  const length = end - start;
1474
1555
  const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
@@ -1493,6 +1574,9 @@ function detectCadences(timeline, key) {
1493
1574
  if (!prev || !cur) {
1494
1575
  continue;
1495
1576
  }
1577
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS3) {
1578
+ continue;
1579
+ }
1496
1580
  const type = detectCadence(prev.chord, cur.chord, key);
1497
1581
  if (type !== null) {
1498
1582
  hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
@@ -1502,67 +1586,101 @@ function detectCadences(timeline, key) {
1502
1586
  }
1503
1587
 
1504
1588
  // src/arrange/index.ts
1505
- var EPS3 = 1e-9;
1589
+ var EPS4 = 1e-9;
1506
1590
  function pitchClass8(pitch) {
1507
1591
  return (Math.trunc(pitch) % 12 + 12) % 12;
1508
1592
  }
1509
1593
  function covers(note, beat) {
1510
- return beat >= note.startBeat - EPS3 && beat < note.endBeat - EPS3;
1594
+ return beat >= note.startBeat - EPS4 && beat < note.endBeat - EPS4;
1511
1595
  }
1512
1596
  function poolNotes(tracks) {
1513
1597
  const notes = [];
1514
1598
  for (const track of tracks) {
1515
1599
  for (const note of track.notes) {
1516
- notes.push(note);
1600
+ if (note.durationBeat > 0) {
1601
+ notes.push(note);
1602
+ }
1517
1603
  }
1518
1604
  }
1519
1605
  return notes;
1520
1606
  }
1607
+ function splitIntoSubVoices(ordered) {
1608
+ const lanes = [];
1609
+ for (const note of ordered) {
1610
+ const lane = lanes.find((candidate) => {
1611
+ const last = candidate[candidate.length - 1];
1612
+ return last !== void 0 && last.startBeat + last.durationBeat <= note.startBeat + EPS4;
1613
+ });
1614
+ if (lane) {
1615
+ lane.push(note);
1616
+ } else {
1617
+ lanes.push([note]);
1618
+ }
1619
+ }
1620
+ return lanes;
1621
+ }
1521
1622
  function prepareTracks(tracks) {
1522
1623
  let nextId = 0;
1523
1624
  return tracks.map((track, index) => {
1524
- const ordered = [...track.notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
1525
- const voice = ordered.map((note) => ({ ...note, id: nextId++ }));
1526
- const sounding = ordered.map((note, i) => {
1527
- const prev = i > 0 ? ordered[i - 1] : void 0;
1528
- return {
1529
- pitch: note.pitch,
1530
- prevPitch: prev?.pitch,
1531
- startBeat: note.startBeat,
1532
- endBeat: note.startBeat + note.durationBeat
1533
- };
1534
- });
1625
+ 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++ }));
1626
+ const voices = splitIntoSubVoices(ordered).map((voice) => ({
1627
+ voice,
1628
+ sounding: voice.map((note, i) => {
1629
+ const prev = i > 0 ? voice[i - 1] : void 0;
1630
+ return {
1631
+ pitch: note.pitch,
1632
+ prevPitch: prev?.pitch,
1633
+ startBeat: note.startBeat,
1634
+ endBeat: note.startBeat + note.durationBeat
1635
+ };
1636
+ })
1637
+ }));
1535
1638
  return {
1536
1639
  name: track.name ?? `track ${index + 1}`,
1537
1640
  role: track.role ?? "other",
1538
- voice,
1539
- sounding
1641
+ voices
1540
1642
  };
1541
1643
  });
1542
1644
  }
1543
- function otherVoicesSounding(prepared, excludeIndex, beat) {
1645
+ function otherVoicesSounding(prepared, excludeTrack, excludeVoice, beat) {
1544
1646
  const out = [];
1545
1647
  for (let t = 0; t < prepared.length; t += 1) {
1546
- if (t === excludeIndex) {
1547
- continue;
1548
- }
1549
1648
  const track = prepared[t];
1550
1649
  if (!track) {
1551
1650
  continue;
1552
1651
  }
1553
- for (const note of track.sounding) {
1554
- if (!covers(note, beat)) {
1652
+ for (let v = 0; v < track.voices.length; v += 1) {
1653
+ if (t === excludeTrack && v === excludeVoice) {
1654
+ continue;
1655
+ }
1656
+ const subVoice = track.voices[v];
1657
+ if (!subVoice) {
1555
1658
  continue;
1556
1659
  }
1557
- const snap = { pitch: note.pitch };
1558
- if (note.prevPitch !== void 0) {
1559
- snap.prevPitch = note.prevPitch;
1660
+ for (const note of subVoice.sounding) {
1661
+ if (!covers(note, beat)) {
1662
+ continue;
1663
+ }
1664
+ const snap = { pitch: note.pitch };
1665
+ if (note.prevPitch !== void 0) {
1666
+ snap.prevPitch = note.prevPitch;
1667
+ }
1668
+ out.push(snap);
1560
1669
  }
1561
- out.push(snap);
1562
1670
  }
1563
1671
  }
1564
1672
  return out;
1565
1673
  }
1674
+ function evaluationBeats(note, timeline) {
1675
+ const noteEnd = note.startBeat + note.durationBeat;
1676
+ const beats = [note.startBeat];
1677
+ for (const segment of timeline.segments) {
1678
+ if (segment.startBeat > note.startBeat + EPS4 && segment.startBeat < noteEnd - EPS4) {
1679
+ beats.push(segment.startBeat);
1680
+ }
1681
+ }
1682
+ return beats;
1683
+ }
1566
1684
  function analyzeArrangement(tracks, opts = {}) {
1567
1685
  const ts = opts.ts ?? parseTimeSignature("4/4");
1568
1686
  const profile = opts.profile ?? "pop";
@@ -1581,36 +1699,48 @@ function analyzeArrangement(tracks, opts = {}) {
1581
1699
  if (!track) {
1582
1700
  continue;
1583
1701
  }
1584
- const notes = analyzeVoice(
1585
- track.voice,
1586
- timeline.at,
1587
- key,
1588
- (beat) => otherVoicesSounding(prepared, t, beat)
1589
- );
1590
- trackAnalyses.push({ name: track.name, role: track.role, notes });
1591
- for (const note of track.voice) {
1592
- const result = evaluateSafety({
1593
- profile,
1594
- candidatePitch: note.pitch,
1595
- chord: timeline.at(note.startBeat),
1596
- key,
1597
- otherVoices: otherVoicesSounding(prepared, t, note.startBeat),
1598
- strongBeat: isStrongBeat(note.startBeat, ts)
1599
- });
1600
- if (result.safety !== 0 /* Safe */) {
1601
- const conflict = {
1602
- beat: note.startBeat,
1603
- trackName: track.name,
1604
- pitch: note.pitch,
1605
- safety: result.safety,
1606
- reasons: result.reasons
1607
- };
1608
- if (result.rationale !== void 0) {
1609
- conflict.rationale = result.rationale;
1702
+ const notes = [];
1703
+ for (let v = 0; v < track.voices.length; v += 1) {
1704
+ const subVoice = track.voices[v];
1705
+ if (!subVoice) {
1706
+ continue;
1707
+ }
1708
+ notes.push(
1709
+ ...analyzeVoice(
1710
+ subVoice.voice,
1711
+ timeline.at,
1712
+ key,
1713
+ (beat) => otherVoicesSounding(prepared, t, v, beat)
1714
+ )
1715
+ );
1716
+ for (const note of subVoice.voice) {
1717
+ for (const beat of evaluationBeats(note, timeline)) {
1718
+ const result = evaluateSafety({
1719
+ profile,
1720
+ candidatePitch: note.pitch,
1721
+ chord: timeline.at(beat),
1722
+ key,
1723
+ otherVoices: otherVoicesSounding(prepared, t, v, beat),
1724
+ strongBeat: isStrongBeat(beat, ts)
1725
+ });
1726
+ if (result.safety !== 0 /* Safe */) {
1727
+ const conflict = {
1728
+ beat,
1729
+ trackName: track.name,
1730
+ pitch: note.pitch,
1731
+ safety: result.safety,
1732
+ reasons: result.reasons
1733
+ };
1734
+ if (result.rationale !== void 0) {
1735
+ conflict.rationale = result.rationale;
1736
+ }
1737
+ conflicts.push(conflict);
1738
+ }
1610
1739
  }
1611
- conflicts.push(conflict);
1612
1740
  }
1613
1741
  }
1742
+ notes.sort((a, b) => a.noteId - b.noteId);
1743
+ trackAnalyses.push({ name: track.name, role: track.role, notes });
1614
1744
  }
1615
1745
  conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
1616
1746
  return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
@@ -1643,7 +1773,7 @@ function tensionCurve(tracks, opts = {}) {
1643
1773
  });
1644
1774
  const prepared = prepareTracks(tracks);
1645
1775
  const points = [];
1646
- const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS3));
1776
+ const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS4));
1647
1777
  for (let i = 0; i < sampleCount; i += 1) {
1648
1778
  const beat = i * step;
1649
1779
  points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
@@ -1657,9 +1787,11 @@ function allSounding(prepared, beat) {
1657
1787
  if (!track) {
1658
1788
  continue;
1659
1789
  }
1660
- for (const note of track.sounding) {
1661
- if (covers(note, beat)) {
1662
- out.push({ pitch: note.pitch, track: t });
1790
+ for (const subVoice of track.voices) {
1791
+ for (const note of subVoice.sounding) {
1792
+ if (covers(note, beat)) {
1793
+ out.push({ pitch: note.pitch, track: t });
1794
+ }
1663
1795
  }
1664
1796
  }
1665
1797
  }
@@ -1735,7 +1867,7 @@ var DEFAULT_OCTAVE = 2;
1735
1867
  var STRONG_VELOCITY = 100;
1736
1868
  var WEAK_VELOCITY = 80;
1737
1869
  var PICKUP_PROB = 0.35;
1738
- var EPS4 = 1e-9;
1870
+ var EPS5 = 1e-9;
1739
1871
  function pitchClass9(value) {
1740
1872
  return (Math.trunc(value) % 12 + 12) % 12;
1741
1873
  }
@@ -1766,15 +1898,16 @@ function chordTonePcs(chord) {
1766
1898
  return pcs;
1767
1899
  }
1768
1900
  function fifthPcOf(chord) {
1769
- const hasFifth = chord.intervals.some((i) => pitchClass9(i) === 7);
1770
- return hasFifth ? pitchClass9(chord.rootPc + 7) : pitchClass9(chord.rootPc);
1901
+ const fifths = new Set(chord.intervals.map((i) => pitchClass9(i)));
1902
+ const chosen = fifths.has(7) ? 7 : fifths.has(6) ? 6 : fifths.has(8) ? 8 : void 0;
1903
+ return chosen === void 0 ? pitchClass9(chord.rootPc) : pitchClass9(chord.rootPc + chosen);
1771
1904
  }
1772
1905
  function bassPcOf(chord) {
1773
1906
  return pitchClass9(chord.bassPc ?? chord.rootPc);
1774
1907
  }
1775
1908
  function beatPositions(start, end) {
1776
1909
  const positions = [];
1777
- for (let p = start; p < end - EPS4; p += 1) {
1910
+ for (let p = start; p < end - EPS5; p += 1) {
1778
1911
  positions.push(p);
1779
1912
  }
1780
1913
  if (positions.length === 0) {
@@ -1794,7 +1927,7 @@ function buildRoot(ctx, seg) {
1794
1927
  function buildRootFifth(ctx, seg) {
1795
1928
  ctx.prevMidi = emit(ctx, seg.startBeat, bassPcOf(seg.chord));
1796
1929
  const mid = (seg.startBeat + seg.endBeat) / 2;
1797
- if (mid > seg.startBeat + EPS4 && mid < seg.endBeat - EPS4) {
1930
+ if (mid > seg.startBeat + EPS5 && mid < seg.endBeat - EPS5) {
1798
1931
  ctx.prevMidi = emit(ctx, mid, fifthPcOf(seg.chord));
1799
1932
  }
1800
1933
  }
@@ -1809,7 +1942,7 @@ function buildPop(ctx, seg) {
1809
1942
  } else if (ctx.rng.prob(PICKUP_PROB)) {
1810
1943
  if (ctx.rng.prob(0.5)) {
1811
1944
  const base = placePc(rootPc, ctx.prevMidi, ctx.low);
1812
- emit(ctx, pos, rootPc, base - 12);
1945
+ emit(ctx, pos, rootPc, Math.max(ctx.low, base - 12));
1813
1946
  } else {
1814
1947
  ctx.prevMidi = emit(ctx, pos, fifthPcOf(seg.chord));
1815
1948
  }
@@ -1905,7 +2038,7 @@ function generateBassLine(opts) {
1905
2038
  }
1906
2039
  const nextStart = ctx.notes[i + 1]?.startBeat ?? lastEnd;
1907
2040
  const durationBeat = nextStart - note.startBeat;
1908
- if (durationBeat <= EPS4) {
2041
+ if (durationBeat <= EPS5) {
1909
2042
  continue;
1910
2043
  }
1911
2044
  out.push({
@@ -1941,18 +2074,22 @@ function scaleMatchesChord(chordPcs, scaleMask, scaleRootPc) {
1941
2074
  }
1942
2075
  return true;
1943
2076
  }
2077
+ var ALIASED_SCALE_NAMES = /* @__PURE__ */ new Set(["major", "naturalMinor"]);
2078
+ var HEPTATONIC_SIZE = 7;
1944
2079
  function chordScales(chord) {
1945
2080
  const chordPcs = chordPitchClasses(chord);
1946
2081
  const rootPc = pitchClass10(chord.rootPc);
1947
2082
  const ranked = [];
2083
+ const seenMasks = /* @__PURE__ */ new Set();
1948
2084
  for (const name of Object.keys(NAMED_SCALES)) {
1949
- if (name === "chromatic") {
2085
+ if (name === "chromatic" || ALIASED_SCALE_NAMES.has(name)) {
1950
2086
  continue;
1951
2087
  }
1952
2088
  const mask = NAMED_SCALES[name];
1953
- if (mask === void 0) {
2089
+ if (mask === void 0 || seenMasks.has(mask)) {
1954
2090
  continue;
1955
2091
  }
2092
+ seenMasks.add(mask);
1956
2093
  if (scaleMatchesChord(chordPcs, mask, rootPc)) {
1957
2094
  const size = popcount12(mask);
1958
2095
  ranked.push({ name, extra: size - chordPcs.length, size });
@@ -1964,7 +2101,11 @@ function chordScales(chord) {
1964
2101
  }
1965
2102
  return [];
1966
2103
  }
2104
+ const preferHeptatonic = chordPcs.length <= 3;
1967
2105
  ranked.sort((a, b) => {
2106
+ if (preferHeptatonic && a.size === HEPTATONIC_SIZE !== (b.size === HEPTATONIC_SIZE)) {
2107
+ return a.size === HEPTATONIC_SIZE ? -1 : 1;
2108
+ }
1968
2109
  if (a.extra !== b.extra) {
1969
2110
  return a.extra - b.extra;
1970
2111
  }
@@ -2116,7 +2257,7 @@ function scalesForChanges(chords) {
2116
2257
 
2117
2258
  // src/countermelody/index.ts
2118
2259
  var DEFAULT_TS2 = { numerator: 4, denominator: 4 };
2119
- var EPS5 = 1e-9;
2260
+ var EPS6 = 1e-9;
2120
2261
  var GRID_STEP = 0.5;
2121
2262
  var MIN_ONSET_GAP = 1;
2122
2263
  var REGISTER_OFFSET = 12;
@@ -2127,12 +2268,12 @@ var REINFORCE_PROB = 0.35;
2127
2268
  var DEFAULT_MELODY_VELOCITY = 96;
2128
2269
  var VELOCITY_DROP = 16;
2129
2270
  function isWholeBeat(beat) {
2130
- return Math.abs(beat - Math.round(beat)) < EPS5;
2271
+ return Math.abs(beat - Math.round(beat)) < EPS6;
2131
2272
  }
2132
2273
  function melodyNoteAt(melody, beat) {
2133
2274
  let found;
2134
2275
  for (const note of melody) {
2135
- if (note.startBeat - EPS5 <= beat && beat < note.startBeat + note.durationBeat - EPS5) {
2276
+ if (note.startBeat - EPS6 <= beat && beat < note.startBeat + note.durationBeat - EPS6) {
2136
2277
  if (!found || note.startBeat > found.startBeat) {
2137
2278
  found = note;
2138
2279
  }
@@ -2141,7 +2282,7 @@ function melodyNoteAt(melody, beat) {
2141
2282
  return found;
2142
2283
  }
2143
2284
  function melodyAttacksAt(melody, beat) {
2144
- return melody.some((note) => Math.abs(note.startBeat - beat) < EPS5);
2285
+ return melody.some((note) => Math.abs(note.startBeat - beat) < EPS6);
2145
2286
  }
2146
2287
  function followOnsets(melody) {
2147
2288
  return [...new Set(melody.map((note) => note.startBeat))].sort((a, b) => a - b);
@@ -2150,7 +2291,7 @@ function complementOnsets(melody, ts, spanStart, spanEnd, rng) {
2150
2291
  const onsets = [];
2151
2292
  const gridStart = Math.floor(spanStart / GRID_STEP) * GRID_STEP;
2152
2293
  let last = Number.NEGATIVE_INFINITY;
2153
- for (let beat = gridStart; beat < spanEnd - EPS5; beat += GRID_STEP) {
2294
+ for (let beat = gridStart; beat < spanEnd - EPS6; beat += GRID_STEP) {
2154
2295
  const attacked = melodyAttacksAt(melody, beat);
2155
2296
  const sounding = melodyNoteAt(melody, beat) !== void 0;
2156
2297
  let place = false;
@@ -2161,7 +2302,7 @@ function complementOnsets(melody, ts, spanStart, spanEnd, rng) {
2161
2302
  } else if (isStrongBeat(beat, ts)) {
2162
2303
  place = rng.prob(REINFORCE_PROB);
2163
2304
  }
2164
- if (place && beat - last >= MIN_ONSET_GAP - EPS5) {
2305
+ if (place && beat - last >= MIN_ONSET_GAP - EPS6) {
2165
2306
  onsets.push(beat);
2166
2307
  last = beat;
2167
2308
  }
@@ -2279,13 +2420,15 @@ function generateCounterMelody(opts) {
2279
2420
  if (register === "above" && candidate <= melPitch) {
2280
2421
  continue;
2281
2422
  }
2282
- if (prevPitch !== void 0 && melPrev !== void 0 && (createsParallelPerfect(prevPitch, candidate, melPrev, melPitch) || createsParallelOctave(prevPitch, candidate, melPrev, melPitch))) {
2423
+ if (prevPitch !== void 0 && melPrev !== void 0 && // Parallel octaves are the perfect-class-zero case of this predicate,
2424
+ // so a single check covers both parallel fifths and octaves.
2425
+ createsParallelPerfect(prevPitch, candidate, melPrev, melPitch)) {
2283
2426
  continue;
2284
2427
  }
2285
2428
  }
2286
2429
  const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
2287
- const tie = Math.abs(score - bestScore) <= EPS5 && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
2288
- if (score > bestScore + EPS5 || tie) {
2430
+ const tie = Math.abs(score - bestScore) <= EPS6 && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
2431
+ if (score > bestScore + EPS6 || tie) {
2289
2432
  bestScore = score;
2290
2433
  bestPitch = candidate;
2291
2434
  }
@@ -2338,7 +2481,11 @@ var STYLE_MAP = {
2338
2481
  bossa: { style: "latin", feel: "straight", ghostBoost: false, snareBeat3: false },
2339
2482
  trap: { style: "trap", feel: "straight", ghostBoost: false, snareBeat3: true },
2340
2483
  halftime: { style: "sparse", feel: "straight", ghostBoost: false, snareBeat3: true },
2341
- breakbeat: { style: "rock", feel: "straight", ghostBoost: false, snareBeat3: false }
2484
+ breakbeat: { style: "rock", feel: "straight", ghostBoost: false, snareBeat3: false },
2485
+ // Four-on-the-floor dance kick with idol-style auxiliary percussion.
2486
+ house: { style: "fourOnFloor", feel: "straight", ghostBoost: false, snareBeat3: false },
2487
+ // Synth-pop pulse with denser 16th hi-hats and busy kick syncopation.
2488
+ synthpop: { style: "synth", feel: "straight", ghostBoost: true, snareBeat3: false }
2342
2489
  };
2343
2490
  function mapStyle(style) {
2344
2491
  return STYLE_MAP[style];
@@ -2825,8 +2972,7 @@ function quantizeSwing(startBeat, swing, resolution) {
2825
2972
  return base + EIGHTH2 + DELTA_8 * clamped;
2826
2973
  }
2827
2974
  if (offset >= EIGHTH2 + HALF_16TH && offset < 3 * SIXTEENTH2 + HALF_16TH) {
2828
- const result = base + EIGHTH2 + DELTA_8 * clamped + SIXTEENTH2 + DELTA_16 * clamped;
2829
- return Math.min(result, base + 1 - 1 / 480);
2975
+ return base + 3 * SIXTEENTH2 + DELTA_16 * clamped;
2830
2976
  }
2831
2977
  return startBeat;
2832
2978
  }
@@ -3021,6 +3167,52 @@ function generateHiHatForBeat(ctx, sec) {
3021
3167
  }
3022
3168
  }
3023
3169
 
3170
+ // src/drums/euclid.ts
3171
+ function euclideanRhythm(pulses, steps, rotation = 0) {
3172
+ if (steps < 1) {
3173
+ return [];
3174
+ }
3175
+ const p = Math.max(0, Math.min(pulses, steps));
3176
+ const base = bjorklund(p, steps);
3177
+ const shift = (rotation % steps + steps) % steps;
3178
+ if (shift === 0) {
3179
+ return base;
3180
+ }
3181
+ return base.map((_, i) => base[(i - shift + steps) % steps] ?? false);
3182
+ }
3183
+ function hasHit(mask, step) {
3184
+ return (mask >> step & 1) === 1;
3185
+ }
3186
+ function patternToMask(pattern) {
3187
+ let mask = 0;
3188
+ for (let i = 0; i < pattern.length; i += 1) {
3189
+ if (pattern[i]) {
3190
+ mask |= 1 << i;
3191
+ }
3192
+ }
3193
+ return mask;
3194
+ }
3195
+ function bjorklund(pulses, steps) {
3196
+ if (pulses === 0) {
3197
+ return Array.from({ length: steps }, () => false);
3198
+ }
3199
+ if (pulses >= steps) {
3200
+ return Array.from({ length: steps }, () => true);
3201
+ }
3202
+ let head = Array.from({ length: pulses }, () => [true]);
3203
+ let remainder = Array.from({ length: steps - pulses }, () => [false]);
3204
+ while (remainder.length > 1) {
3205
+ const count = Math.min(head.length, remainder.length);
3206
+ const next = [];
3207
+ for (let i = 0; i < count; i += 1) {
3208
+ next.push([...head[i] ?? [], ...remainder[i] ?? []]);
3209
+ }
3210
+ remainder = head.length > count ? head.slice(count) : remainder.slice(count);
3211
+ head = next;
3212
+ }
3213
+ return [...head.flat(), ...remainder.flat()];
3214
+ }
3215
+
3024
3216
  // src/drums/fills.ts
3025
3217
  function getFillStartBeat(energy) {
3026
3218
  switch (energy) {
@@ -3037,6 +3229,9 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3037
3229
  if (style === "sparse") {
3038
3230
  return rng.range(0, 1) === 0 ? "simpleCrash" : "breakdownFill";
3039
3231
  }
3232
+ const toChorus = to === "chorus";
3233
+ const fromIntro = from === "intro";
3234
+ const highEnergy = style === "rock" || style === "fourOnFloor";
3040
3235
  if (nextEnergy === "low") {
3041
3236
  switch (rng.range(0, 2)) {
3042
3237
  case 0:
@@ -3047,25 +3242,10 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3047
3242
  return "halfTimeFill";
3048
3243
  }
3049
3244
  }
3050
- if (nextEnergy === "peak") {
3051
- switch (rng.range(0, 3)) {
3052
- case 0:
3053
- return "tomDescend";
3054
- case 1:
3055
- return "snareRoll";
3056
- case 2:
3057
- return "linearFill";
3058
- default:
3059
- return "flamsAndDrags";
3060
- }
3061
- }
3062
- const toChorus = to === "chorus";
3063
- const fromIntro = from === "intro";
3064
- const highEnergy = style === "rock" || style === "fourOnFloor";
3065
- const choice = rng.range(0, 7);
3066
3245
  if (toChorus) {
3246
+ const choice2 = rng.range(0, 7);
3067
3247
  if (highEnergy) {
3068
- switch (choice) {
3248
+ switch (choice2) {
3069
3249
  case 0:
3070
3250
  case 1:
3071
3251
  return "tomDescend";
@@ -3083,7 +3263,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3083
3263
  return "ghostToAccent";
3084
3264
  }
3085
3265
  }
3086
- switch (choice) {
3266
+ switch (choice2) {
3087
3267
  case 0:
3088
3268
  case 1:
3089
3269
  return "snareTomCombo";
@@ -3100,7 +3280,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3100
3280
  }
3101
3281
  }
3102
3282
  if (fromIntro) {
3103
- switch (choice) {
3283
+ switch (rng.range(0, 5)) {
3104
3284
  case 0:
3105
3285
  case 1:
3106
3286
  return "snareRoll";
@@ -3114,6 +3294,7 @@ function selectFillType(from, to, style, nextEnergy, rng) {
3114
3294
  return "halfTimeFill";
3115
3295
  }
3116
3296
  }
3297
+ const choice = rng.range(0, 7);
3117
3298
  if (highEnergy) {
3118
3299
  switch (choice) {
3119
3300
  case 0:
@@ -3296,6 +3477,10 @@ function generateFill(track, beatTick, beat, fillType, velocity) {
3296
3477
  if (beat === 2) {
3297
3478
  track.add(GM.SD, beatTick, 1, accentVel);
3298
3479
  track.add(GM.BD, beatTick, 1, fillVel);
3480
+ } else if (beat === 3) {
3481
+ track.add(GM.SD, beatTick, EIGHTH, accentVel);
3482
+ track.add(GM.SD, beatTick + E, S, fillVel * 0.6);
3483
+ track.add(GM.SD, beatTick + E + S, S, accentVel);
3299
3484
  }
3300
3485
  break;
3301
3486
  }
@@ -3338,6 +3523,19 @@ function isInPreChorusLift(section, bar, sectionBars, nextIsChorus) {
3338
3523
  }
3339
3524
  return bar >= sectionBars - 2;
3340
3525
  }
3526
+ function euclideanToKickPattern(mask) {
3527
+ const slot = (step) => hasHit(mask, step) || hasHit(mask, (step + 15) % 16);
3528
+ return {
3529
+ beat1: slot(0),
3530
+ beat1and: slot(2),
3531
+ beat2: slot(4),
3532
+ beat2and: slot(6),
3533
+ beat3: slot(8),
3534
+ beat3and: slot(10),
3535
+ beat4: slot(12),
3536
+ beat4and: slot(14)
3537
+ };
3538
+ }
3341
3539
  function getKickPattern(section, style, bar, rng) {
3342
3540
  const p = { ...EMPTY };
3343
3541
  if (section === "outro") {
@@ -3542,6 +3740,17 @@ function generateDrums(opts) {
3542
3740
  const energy = sectionEnergy(section);
3543
3741
  const fillStartBeat = getFillStartBeat(energy);
3544
3742
  const percMood = percMoodCategory(style);
3743
+ const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
3744
+ const nextEnergy = sectionEnergy(nextSection);
3745
+ const euclidKick = opts.euclideanKick ? euclideanToKickPattern(
3746
+ patternToMask(
3747
+ euclideanRhythm(
3748
+ opts.euclideanKick.pulses,
3749
+ opts.euclideanKick.steps ?? 16,
3750
+ opts.euclideanKick.rotation ?? 0
3751
+ )
3752
+ )
3753
+ ) : void 0;
3545
3754
  const reuseSectionKick = (section === "b" || section === "chorus") && style !== "sparse";
3546
3755
  let sectionKick;
3547
3756
  for (let bar = 0; bar < opts.bars; bar += 1) {
@@ -3557,7 +3766,9 @@ function generateDrums(opts) {
3557
3766
  barHasOpenHh = !track.hasCrashNear(barStart + openHhBeatIndex);
3558
3767
  }
3559
3768
  let kick;
3560
- if (reuseSectionKick) {
3769
+ if (euclidKick) {
3770
+ kick = euclidKick;
3771
+ } else if (reuseSectionKick) {
3561
3772
  sectionKick ??= getKickPattern(section, style, 0, rng);
3562
3773
  kick = sectionKick;
3563
3774
  } else {
@@ -3570,12 +3781,17 @@ function generateDrums(opts) {
3570
3781
  const velocity = calculateVelocity(section, beat);
3571
3782
  if (opts.fills && isLastBar && !inLift && beat >= fillStartBeat) {
3572
3783
  if (beat === fillStartBeat) {
3573
- currentFill = selectFillType(section, section, style, energy, rng);
3784
+ currentFill = selectFillType(section, nextSection, style, nextEnergy, rng);
3574
3785
  }
3575
3786
  if (playMainVoices) {
3787
+ const before = track.hits.length;
3576
3788
  generateFill(track, beatTick, beat, currentFill, velocity);
3789
+ if (track.hits.length > before) {
3790
+ continue;
3791
+ }
3792
+ } else {
3793
+ continue;
3577
3794
  }
3578
- continue;
3579
3795
  }
3580
3796
  const ctx = {
3581
3797
  track,
@@ -3697,18 +3913,23 @@ function extractGrooveTemplate(events, ts, subdivision) {
3697
3913
  for (let i = 0; i < slotsPerBar; i += 1) {
3698
3914
  const count = counts[i] ?? 0;
3699
3915
  if (count === 0) {
3700
- slots.push({ timingOffset: 0, velocity: 0 });
3916
+ slots.push({ timingOffset: 0, velocity: null });
3701
3917
  continue;
3702
3918
  }
3703
3919
  const velocityCount = velocityCounts[i] ?? 0;
3704
3920
  slots.push({
3705
3921
  timingOffset: (offsetSums[i] ?? 0) / count,
3706
- velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : 0
3922
+ velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : null
3707
3923
  });
3708
3924
  }
3709
- return { subdivision, slotsPerBar, slots };
3925
+ return { subdivision, slotsPerBar, slots, ts };
3710
3926
  }
3711
3927
  function applyGrooveTemplate(events, template, ts) {
3928
+ if (template.ts && (template.ts.numerator !== ts.numerator || template.ts.denominator !== ts.denominator)) {
3929
+ throw new Error(
3930
+ `Groove template meter ${formatTimeSignature(template.ts)} does not match apply-time meter ${formatTimeSignature(ts)}`
3931
+ );
3932
+ }
3712
3933
  const barBeats = beatsPerBar(ts);
3713
3934
  return events.map((event) => {
3714
3935
  const { quantizedBeat, slotIndex } = quantizeToGrid(
@@ -3719,7 +3940,7 @@ function applyGrooveTemplate(events, template, ts) {
3719
3940
  );
3720
3941
  const slot = template.slots[slotIndex];
3721
3942
  const startBeat = quantizedBeat + (slot?.timingOffset ?? 0);
3722
- const velocity = slot && slot.velocity > 0 ? Math.round(slot.velocity) : event.velocity;
3943
+ const velocity = slot && slot.velocity !== null && slot.velocity !== void 0 ? Math.round(slot.velocity) : event.velocity;
3723
3944
  return {
3724
3945
  pitch: event.pitch,
3725
3946
  startBeat,
@@ -3772,6 +3993,7 @@ var DEFAULT_METER = { numerator: 4, denominator: 4 };
3772
3993
  var COMFORT_LOW = 55;
3773
3994
  var COMFORT_HIGH = 79;
3774
3995
  var TESSITURA_WEIGHT = 1e-3;
3996
+ var TIE_BREAK_JITTER = 1e-6;
3775
3997
  function pitchClass11(pitch) {
3776
3998
  return (Math.trunc(pitch) % 12 + 12) % 12;
3777
3999
  }
@@ -3786,26 +4008,33 @@ function mulberry32(seed) {
3786
4008
  };
3787
4009
  }
3788
4010
  function inferKey(melody) {
3789
- let best = 0;
4011
+ let best = majorKey(0);
3790
4012
  let bestScore = Number.NEGATIVE_INFINITY;
4013
+ const candidates = [];
3791
4014
  for (let tonic = 0; tonic < 12; tonic += 1) {
3792
- const key = majorKey(tonic);
4015
+ candidates.push(majorKey(tonic), minorKey(tonic));
4016
+ }
4017
+ for (const key of candidates) {
4018
+ const tonic = pitchClass11(key.rootPc);
4019
+ const isMinor = key.modeMask12 === NATURAL_MINOR_MASK;
4020
+ const leadingTone = (tonic + 11) % 12;
3793
4021
  let score = 0;
3794
4022
  for (const n of melody) {
3795
4023
  const w = Math.max(0.25, n.durationBeat);
3796
- if (isScaleTone(n.pitch, key)) {
4024
+ const pc = pitchClass11(n.pitch);
4025
+ if (isScaleTone(n.pitch, key) || isMinor && pc === leadingTone) {
3797
4026
  score += w;
3798
4027
  }
3799
- if (pitchClass11(n.pitch) === tonic) {
4028
+ if (pc === tonic) {
3800
4029
  score += 0.5 * w;
3801
4030
  }
3802
4031
  }
3803
4032
  if (score > bestScore) {
3804
4033
  bestScore = score;
3805
- best = tonic;
4034
+ best = key;
3806
4035
  }
3807
4036
  }
3808
- return majorKey(best);
4037
+ return best;
3809
4038
  }
3810
4039
  function buildCandidates(key, reharmonize) {
3811
4040
  const tones = scaleTonesInDegreeOrder(key);
@@ -3957,7 +4186,7 @@ function harmonizeMelody(opts) {
3957
4186
  const candidates = buildCandidates(key, opts.reharmonize);
3958
4187
  const candAt = (i) => candidates[i] ?? FALLBACK;
3959
4188
  const rng = mulberry32(opts.seed ?? 0);
3960
- const jitter = candidates.map(() => rng() * 1e-6);
4189
+ const jitter = candidates.map(() => rng() * TIE_BREAK_JITTER);
3961
4190
  const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
3962
4191
  const hr = Math.max(0.25, opts.harmonicRhythm);
3963
4192
  const segCount = Math.max(1, Math.ceil(melodyEnd / hr));
@@ -4154,6 +4383,8 @@ function spelledInterval(a, b) {
4154
4383
  const reference = SIMPLE_REFERENCE[letterSteps + 1] ?? 0;
4155
4384
  if (reference - rawSemis > 6) {
4156
4385
  rawSemis += 12;
4386
+ } else if (rawSemis - reference > 6) {
4387
+ rawSemis -= 12;
4157
4388
  }
4158
4389
  semitones = rawSemis;
4159
4390
  }
@@ -4378,8 +4609,8 @@ function noteNames(notes) {
4378
4609
  }
4379
4610
 
4380
4611
  // src/symbol/index.ts
4381
- var ROOT_RE = /^[A-G](?:#{1,2}|b{1,2})?/;
4382
- var BASS_RE = /^[A-G](?:#{1,2}|b{1,2})?$/;
4612
+ var ROOT_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?/;
4613
+ var BASS_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?$/;
4383
4614
  var QUALITY_MAP = {
4384
4615
  "": "maj",
4385
4616
  m: "min",
@@ -4474,8 +4705,17 @@ var CANONICAL_SUFFIX = {
4474
4705
  function pitchClass12(value) {
4475
4706
  return (Math.trunc(value) % 12 + 12) % 12;
4476
4707
  }
4477
- function bassPitchClass(token) {
4478
- return noteToPitchClass(parseNote(token));
4708
+ function makeSpelledChord(root, quality, bass) {
4709
+ const chord = makeChord(
4710
+ noteToPitchClass(root),
4711
+ quality,
4712
+ bass === void 0 ? void 0 : noteToPitchClass(bass)
4713
+ );
4714
+ chord.rootSpelling = { letter: root.letter, alter: root.alter };
4715
+ if (bass !== void 0) {
4716
+ chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
4717
+ }
4718
+ return chord;
4479
4719
  }
4480
4720
  function parseChordSymbol(text) {
4481
4721
  const trimmed = text.trim();
@@ -4484,37 +4724,37 @@ function parseChordSymbol(text) {
4484
4724
  throw new Error(`Invalid chord symbol: ${text}`);
4485
4725
  }
4486
4726
  const rootToken = rootMatch[0];
4487
- const rootPc = noteToPitchClass(parseNote(rootToken));
4727
+ const rootNote = parseNote(rootToken);
4488
4728
  const rest = trimmed.slice(rootToken.length);
4489
- const slashIndex = rest.indexOf("/");
4490
- if (slashIndex === -1) {
4491
- const quality = QUALITY_MAP[rest];
4492
- if (quality === void 0) {
4493
- throw new Error(`Unrecognized chord quality: ${text}`);
4729
+ const slashIndex = rest.lastIndexOf("/");
4730
+ if (slashIndex !== -1) {
4731
+ const before = rest.slice(0, slashIndex);
4732
+ const after = rest.slice(slashIndex + 1);
4733
+ const beforeQuality = QUALITY_MAP[before];
4734
+ if (beforeQuality !== void 0 && BASS_RE.test(after)) {
4735
+ return makeSpelledChord(rootNote, beforeQuality, parseNote(after));
4494
4736
  }
4495
- return makeChord(rootPc, quality);
4496
4737
  }
4497
- const before = rest.slice(0, slashIndex);
4498
- const after = rest.slice(slashIndex + 1);
4499
- const beforeQuality = QUALITY_MAP[before];
4500
- if (beforeQuality !== void 0 && BASS_RE.test(after)) {
4501
- return makeChord(rootPc, beforeQuality, bassPitchClass(after));
4738
+ const quality = QUALITY_MAP[rest];
4739
+ if (quality === void 0) {
4740
+ throw new Error(`Unrecognized chord quality: ${text}`);
4502
4741
  }
4503
- const combinedQuality = QUALITY_MAP[rest];
4504
- if (combinedQuality !== void 0) {
4505
- return makeChord(rootPc, combinedQuality);
4742
+ return makeSpelledChord(rootNote, quality);
4743
+ }
4744
+ function pitchClassName(pc, hint, flats) {
4745
+ if (flats === void 0 && hint !== void 0 && noteToPitchClass(hint) === pc) {
4746
+ return formatNote(hint);
4506
4747
  }
4507
- throw new Error(`Unrecognized chord quality: ${text}`);
4748
+ const note = midiToNote(60 + pc, flats ? "flat" : "sharp");
4749
+ return formatNote({ letter: note.letter, alter: note.alter });
4508
4750
  }
4509
4751
  function formatChordSymbol(chord, opts) {
4510
- const spelling = opts?.flats ? "flat" : "sharp";
4511
- const rootNote = midiToNote(60 + pitchClass12(chord.rootPc), spelling);
4512
- const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
4752
+ const rootPc = pitchClass12(chord.rootPc);
4753
+ const rootName = pitchClassName(rootPc, chord.rootSpelling, opts?.flats);
4513
4754
  const suffix = CANONICAL_SUFFIX[chord.quality];
4514
4755
  let symbol = `${rootName}${suffix}`;
4515
- if (chord.bassPc !== void 0 && pitchClass12(chord.bassPc) !== pitchClass12(chord.rootPc)) {
4516
- const bassNote = midiToNote(60 + pitchClass12(chord.bassPc), spelling);
4517
- symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
4756
+ if (chord.bassPc !== void 0 && pitchClass12(chord.bassPc) !== rootPc) {
4757
+ symbol += `/${pitchClassName(pitchClass12(chord.bassPc), chord.bassSpelling, opts?.flats)}`;
4518
4758
  }
4519
4759
  return symbol;
4520
4760
  }
@@ -4541,6 +4781,7 @@ var DERIVED_SPAN = 19;
4541
4781
  var VIOLATION_PENALTY = 1e3;
4542
4782
  var MISSING_TONE_PENALTY = 500;
4543
4783
  var POOR_DOUBLING_PENALTY = 4;
4784
+ var HIDDEN_PERFECT_PENALTY = 6;
4544
4785
  var MAX_CANDIDATES = 4e3;
4545
4786
  function pitchClass13(value) {
4546
4787
  return (Math.trunc(value) % 12 + 12) % 12;
@@ -4578,7 +4819,12 @@ function pitchesForPc(pc, range) {
4578
4819
  result.push(midi);
4579
4820
  }
4580
4821
  }
4581
- return result;
4822
+ const center = (range.min + range.max) / 2;
4823
+ return result.sort((a, b) => {
4824
+ const da = Math.abs(a - center);
4825
+ const db = Math.abs(b - center);
4826
+ return da === db ? a - b : da - db;
4827
+ });
4582
4828
  }
4583
4829
  function enumerateVoicings(chord, ranges, maxSpacing) {
4584
4830
  const chordPcs = chordPitchClasses(chord);
@@ -4654,9 +4900,6 @@ function violationCount(prev, cur, maxSpacing) {
4654
4900
  if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
4655
4901
  count += 1;
4656
4902
  }
4657
- if (createsParallelOctave(prevUpper, curUpper, prevLower, curLower)) {
4658
- count += 1;
4659
- }
4660
4903
  if (createsVoiceCrossing(curUpper, curLower)) {
4661
4904
  count += 1;
4662
4905
  }
@@ -4685,6 +4928,15 @@ function voiceLeadingCost(from, to) {
4685
4928
  }
4686
4929
  total += Math.abs(b - a);
4687
4930
  }
4931
+ if (from.length >= 2) {
4932
+ const bassPrev = from[0];
4933
+ const bassCur = to[0];
4934
+ const sopPrev = from[from.length - 1];
4935
+ const sopCur = to[to.length - 1];
4936
+ if (bassPrev !== void 0 && bassCur !== void 0 && sopPrev !== void 0 && sopCur !== void 0 && createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)) {
4937
+ total += HIDDEN_PERFECT_PENALTY;
4938
+ }
4939
+ }
4688
4940
  return total;
4689
4941
  }
4690
4942
  function voiceChord(chord, opts) {
@@ -4823,7 +5075,10 @@ function voiceChordStyled(chord, opts) {
4823
5075
  return stack.sort((a, b) => a - b);
4824
5076
  }
4825
5077
  function nextVoicing(current, chord, opts) {
4826
- const ranges = opts?.ranges !== void 0 || opts?.voices !== void 0 ? resolveRanges(opts) : current.map((pitch) => ({ min: pitch - 12, max: pitch + 12 }));
5078
+ const ranges = opts?.ranges !== void 0 || opts?.voices !== void 0 ? resolveRanges(opts) : current.map((pitch) => {
5079
+ const centre = Math.min(127, Math.max(0, pitch));
5080
+ return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
5081
+ });
4827
5082
  const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
4828
5083
  const candidates = enumerateVoicings(chord, ranges, maxSpacing);
4829
5084
  let best;
@@ -4861,6 +5116,12 @@ function copyChord(data) {
4861
5116
  if (data.bassPc !== void 0) {
4862
5117
  copy.bassPc = data.bassPc;
4863
5118
  }
5119
+ if (data.rootSpelling !== void 0) {
5120
+ copy.rootSpelling = { letter: data.rootSpelling.letter, alter: data.rootSpelling.alter };
5121
+ }
5122
+ if (data.bassSpelling !== void 0) {
5123
+ copy.bassSpelling = { letter: data.bassSpelling.letter, alter: data.bassSpelling.alter };
5124
+ }
4864
5125
  return copy;
4865
5126
  }
4866
5127
  function spellPitchClassBare(pc, spelling) {
@@ -4946,12 +5207,16 @@ var Note = class _Note {
4946
5207
  *
4947
5208
  * When the note carries an octave the transposition happens in MIDI space and
4948
5209
  * the result is spelled with a sharp preference. An octave-less note stays
4949
- * octave-less: only its pitch class is moved.
5210
+ * octave-less: only its pitch class is moved. Transposing by zero is the
5211
+ * identity: the original spelling is preserved (no enharmonic respelling).
4950
5212
  *
4951
5213
  * @param semitones The signed semitone offset.
4952
5214
  * @returns The transposed note.
4953
5215
  */
4954
5216
  transpose(semitones) {
5217
+ if (semitones === 0) {
5218
+ return new _Note(this.#data);
5219
+ }
4955
5220
  if (this.#data.octave !== void 0) {
4956
5221
  return _Note.fromMidi(this.midi + semitones);
4957
5222
  }
@@ -4975,6 +5240,17 @@ var Note = class _Note {
4975
5240
  equals(other) {
4976
5241
  return this.#data.letter === other.#data.letter && this.#data.alter === other.#data.alter && this.#data.octave === other.#data.octave;
4977
5242
  }
5243
+ /**
5244
+ * The plain note data, for JSON serialization.
5245
+ *
5246
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5247
+ * `JSON.stringify(note)` from collapsing to `{}`.
5248
+ *
5249
+ * @returns A copy of the underlying plain note object.
5250
+ */
5251
+ toJSON() {
5252
+ return this.data;
5253
+ }
4978
5254
  };
4979
5255
  var Interval = class _Interval {
4980
5256
  #number;
@@ -5023,10 +5299,32 @@ var Interval = class _Interval {
5023
5299
  get name() {
5024
5300
  return `${this.#quality}${this.#number}`;
5025
5301
  }
5302
+ /**
5303
+ * The plain interval data, for JSON serialization.
5304
+ *
5305
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5306
+ * `JSON.stringify(interval)` from collapsing to `{}`.
5307
+ *
5308
+ * @returns The diatonic number, quality, and semitone span.
5309
+ */
5310
+ toJSON() {
5311
+ return { number: this.#number, quality: this.#quality, semitones: this.#semitones };
5312
+ }
5026
5313
  };
5027
5314
  function resolveTonic(root, spelling) {
5028
5315
  return typeof root === "string" ? Note.of(root) : new Note(spellPitchClassBare(root, spelling));
5029
5316
  }
5317
+ function accidentalLoad(tonic, scale) {
5318
+ return spellScale(tonic, scale).reduce((sum, note) => sum + Math.abs(note.alter), 0);
5319
+ }
5320
+ function bestTonicForScale(rootPc, scale) {
5321
+ const sharp = spellPitchClassBare(rootPc, "sharp");
5322
+ const flat = spellPitchClassBare(rootPc, "flat");
5323
+ if (sharp.letter === flat.letter && sharp.alter === flat.alter) {
5324
+ return new Note(sharp);
5325
+ }
5326
+ return accidentalLoad(flat, scale) <= accidentalLoad(sharp, scale) ? new Note(flat) : new Note(sharp);
5327
+ }
5030
5328
  var Key = class _Key {
5031
5329
  #scale;
5032
5330
  #tonic;
@@ -5043,24 +5341,34 @@ var Key = class _Key {
5043
5341
  /**
5044
5342
  * A major key.
5045
5343
  *
5046
- * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; numeric
5047
- * roots are spelled with a sharp preference.
5344
+ * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; a numeric
5345
+ * root is spelled with whichever accidental side yields the fewest
5346
+ * accidentals across the scale.
5048
5347
  * @returns The major key.
5049
5348
  */
5050
5349
  static major(root) {
5051
- const tonic = resolveTonic(root, "sharp");
5052
- return new _Key(majorKey(tonic.pitchClass), tonic);
5350
+ if (typeof root === "string") {
5351
+ const tonic = Note.of(root);
5352
+ return new _Key(majorKey(tonic.pitchClass), tonic);
5353
+ }
5354
+ const scale = majorKey(root);
5355
+ return new _Key(scale, bestTonicForScale(root, scale));
5053
5356
  }
5054
5357
  /**
5055
5358
  * A natural-minor key.
5056
5359
  *
5057
- * @param root Tonic as a note name or a pitch class; numeric roots are
5058
- * spelled with a flat preference.
5360
+ * @param root Tonic as a note name or a pitch class; a numeric root is
5361
+ * spelled with whichever accidental side yields the fewest accidentals
5362
+ * across the scale.
5059
5363
  * @returns The minor key.
5060
5364
  */
5061
5365
  static minor(root) {
5062
- const tonic = resolveTonic(root, "flat");
5063
- return new _Key(minorKey(tonic.pitchClass), tonic);
5366
+ if (typeof root === "string") {
5367
+ const tonic = Note.of(root);
5368
+ return new _Key(minorKey(tonic.pitchClass), tonic);
5369
+ }
5370
+ const scale = minorKey(root);
5371
+ return new _Key(scale, bestTonicForScale(root, scale));
5064
5372
  }
5065
5373
  /**
5066
5374
  * A key on a named scale (e.g. `'dorian'`, `'harmonicMinor'`).
@@ -5187,6 +5495,19 @@ var Key = class _Key {
5187
5495
  contains(x) {
5188
5496
  return isScaleTone(typeof x === "number" ? x : x.pitchClass, this.#scale);
5189
5497
  }
5498
+ /**
5499
+ * The plain key data, for JSON serialization.
5500
+ *
5501
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5502
+ * `JSON.stringify(key)` from collapsing to `{}`. The result pairs the
5503
+ * `KeyScale` with the spelled tonic, enough to reconstruct the key via
5504
+ * {@link Key.of}.
5505
+ *
5506
+ * @returns The key/scale and its spelled tonic.
5507
+ */
5508
+ toJSON() {
5509
+ return { scale: this.scale, tonic: this.#tonic.data };
5510
+ }
5190
5511
  };
5191
5512
  var Chord = class _Chord {
5192
5513
  #data;
@@ -5383,13 +5704,17 @@ var Chord = class _Chord {
5383
5704
  * @throws If no key is given and none is carried.
5384
5705
  */
5385
5706
  negativeHarmony(key) {
5386
- return new _Chord(negativeHarmonyMirror(this.#data, this.#resolveKey(key).scale), this.#key);
5707
+ const resolved = this.#resolveKey(key);
5708
+ return new _Chord(negativeHarmonyMirror(this.#data, resolved.scale), key ?? this.#key);
5387
5709
  }
5388
5710
  /**
5389
5711
  * The n-th inversion: a copy whose bass is the chord tone `n` steps above the
5390
5712
  * root in the interval template (`invert(1)` puts the third in the bass).
5391
5713
  * `n` wraps around the template length; negative values count backwards.
5392
5714
  *
5715
+ * `invert(0)` (and any `n` that wraps to it) is root position, so it carries
5716
+ * no slash bass and equals the original chord.
5717
+ *
5393
5718
  * @param n The inversion number.
5394
5719
  * @returns The inverted chord, keeping any key context.
5395
5720
  * @throws If the chord has no intervals.
@@ -5402,7 +5727,11 @@ var Chord = class _Chord {
5402
5727
  }
5403
5728
  const index = (n % length + length) % length;
5404
5729
  const data = copyChord(this.#data);
5405
- data.bassPc = mod125(this.#data.rootPc + (intervals[index] ?? 0));
5730
+ if (index === 0) {
5731
+ delete data.bassPc;
5732
+ } else {
5733
+ data.bassPc = mod125(this.#data.rootPc + (intervals[index] ?? 0));
5734
+ }
5406
5735
  return new _Chord(data, this.#key);
5407
5736
  }
5408
5737
  /**
@@ -5583,6 +5912,21 @@ var Progression = class _Progression {
5583
5912
  const cadence = from !== void 0 && to !== void 0 ? detectCadence(from.data, to.data, resolved.scale) : null;
5584
5913
  return { chords, cadence };
5585
5914
  }
5915
+ /**
5916
+ * The plain progression data, for JSON serialization.
5917
+ *
5918
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
5919
+ * `JSON.stringify(progression)` from collapsing to `{}`. The chords are
5920
+ * emitted as plain data and the carried key, when present, as its own data.
5921
+ *
5922
+ * @returns The chord data sequence and the carried key, if any.
5923
+ */
5924
+ toJSON() {
5925
+ return {
5926
+ chords: this.#chords.map((chord) => chord.data),
5927
+ key: this.#key?.toJSON()
5928
+ };
5929
+ }
5586
5930
  /** Resolve the key for an analysis method: explicit first, then carried. */
5587
5931
  #resolveKey(key) {
5588
5932
  const resolved = key ?? this.#key;
@@ -5660,7 +6004,6 @@ function mulberry322(seed) {
5660
6004
  }
5661
6005
  function contourOffsets(contour, count) {
5662
6006
  const offsets = [];
5663
- const half = Math.floor(count / 2);
5664
6007
  for (let i = 0; i < count; i += 1) {
5665
6008
  switch (contour) {
5666
6009
  case "ascending":
@@ -5670,7 +6013,7 @@ function contourOffsets(contour, count) {
5670
6013
  offsets.push(-i);
5671
6014
  break;
5672
6015
  case "arch":
5673
- offsets.push(i <= half ? i : count - 1 - i);
6016
+ offsets.push(Math.min(i, count - 1 - i));
5674
6017
  break;
5675
6018
  case "wave":
5676
6019
  offsets.push([0, 1, 0, -1][i % 4] ?? 0);
@@ -5686,11 +6029,15 @@ function generateMotif(opts) {
5686
6029
  const noteCount = Math.max(3, bars * 2);
5687
6030
  const beatsPerNote = totalBeats / noteCount;
5688
6031
  const rng = mulberry322(opts.seed ?? 0);
6032
+ const jitterProb = Math.min(1, Math.max(0, opts.jitter ?? 0));
5689
6033
  const tonic = pitchClass14(opts.key.rootPc) + 60;
5690
6034
  const offsets = contourOffsets(contour, noteCount);
5691
6035
  const notes = [];
5692
6036
  for (let i = 0; i < noteCount; i += 1) {
5693
- const jitter = rng() < 0.25 ? 1 : 0;
6037
+ let jitter = 0;
6038
+ if (jitterProb > 0 && rng() < jitterProb) {
6039
+ jitter = rng() < 0.5 ? 1 : -1;
6040
+ }
5694
6041
  let pitch = stepDiatonic(tonic, (offsets[i] ?? 0) + jitter, opts.key);
5695
6042
  const startBeat = i * beatsPerNote;
5696
6043
  if (opts.chord && startBeat % 4 === 0) {
@@ -6002,6 +6349,9 @@ function generateProgression(opts) {
6002
6349
  let preset;
6003
6350
  if (opts.presetId !== void 0) {
6004
6351
  preset = PRESETS.find((p) => p.id === opts.presetId);
6352
+ if (preset === void 0) {
6353
+ throw new Error(`Unknown progression preset: ${opts.presetId}`);
6354
+ }
6005
6355
  }
6006
6356
  if (preset === void 0) {
6007
6357
  const pool = PRESETS.filter((p) => p.styles.includes(opts.style));
@@ -6072,15 +6422,17 @@ function generateRhythm(ts, opts = {}) {
6072
6422
  const seed = opts.seed ?? 0;
6073
6423
  const bars = opts.bars ?? DEFAULT_BARS;
6074
6424
  const subdivision = opts.subdivision ?? DEFAULT_SUBDIVISION;
6075
- const density = opts.density ?? DEFAULT_DENSITY;
6076
- const spanBeats = beatsPerBar(ts) * bars;
6425
+ const density = Math.min(1, Math.max(0, opts.density ?? DEFAULT_DENSITY));
6426
+ const barBeats = beatsPerBar(ts);
6427
+ const spanBeats = barBeats * bars;
6077
6428
  const step = 1 / subdivision;
6078
6429
  const slotCount = Math.round(spanBeats * subdivision);
6430
+ const slotsPerBar = Math.round(barBeats * subdivision);
6079
6431
  const rng = createRng(seed);
6080
6432
  const positions = [];
6081
6433
  for (let i = 0; i < slotCount; i += 1) {
6082
6434
  const position = i * step;
6083
- if (i === 0) {
6435
+ if (i % slotsPerBar === 0) {
6084
6436
  positions.push(position);
6085
6437
  continue;
6086
6438
  }