@goplayerjuggler/abc-tools 1.0.15 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "sorting algorithm and implementation for ABC tunes; plus other tools for parsing and manipulating ABC tunes",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -32,7 +32,7 @@
32
32
  "license": "GPL-3.0-or-later",
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/goplayerjuggler/abc-tools.git"
35
+ "url": "git+https://github.com/goplayerjuggler/abc-tools.git"
36
36
  },
37
37
  "bugs": {
38
38
  "url": "https://github.com/goplayerjuggler/abc-tools/issues"
@@ -44,11 +44,13 @@
44
44
  "devDependencies": {
45
45
  "@eslint/eslintrc": "^3.3.1",
46
46
  "@eslint/js": "^9.37.0",
47
+ "clipboardy": "^5.0.1",
47
48
  "eslint": "^9.37.0",
48
49
  "eslint-plugin-jest": "^29.0.1",
49
50
  "globals": "^16.4.0",
50
51
  "jest": "^30.2.0",
51
- "jsdoc": "^4.0.5"
52
+ "jsdoc": "^4.0.5",
53
+ "prettier": "^3.8.1"
52
54
  },
53
55
  "jest": {
54
56
  "testEnvironment": "node",
package/src/incipit.js CHANGED
@@ -278,7 +278,7 @@ function sanitise(theTune) {
278
278
  function getIncipit(data) {
279
279
  let {
280
280
  abc,
281
- numBars, //, part=null
281
+ numBars //, part=null
282
282
  } = typeof data === "string" ? { abc: data } : data;
283
283
 
284
284
  const { withAnacrucis = true } =
@@ -313,7 +313,7 @@ function getIncipitForContourGeneration(
313
313
  return getIncipit({
314
314
  abc,
315
315
  withAnacrucis: false,
316
- numBars,
316
+ numBars
317
317
  });
318
318
  }
319
319
 
@@ -322,7 +322,7 @@ function getContourFromFullAbc(
322
322
  {
323
323
  withSvg = true,
324
324
  withSwingTransform = false,
325
- numBars = new Fraction(3, 2),
325
+ numBars = new Fraction(3, 2)
326
326
  } = {}
327
327
  ) {
328
328
  if (Array.isArray(abc)) {
@@ -331,12 +331,12 @@ function getContourFromFullAbc(
331
331
  }
332
332
  return getContour(getIncipitForContourGeneration(abc, { numBars }), {
333
333
  withSvg,
334
- withSwingTransform,
334
+ withSwingTransform
335
335
  });
336
336
  }
337
337
 
338
338
  module.exports = {
339
339
  getIncipit,
340
340
  getIncipitForContourGeneration,
341
- getContourFromFullAbc,
341
+ getContourFromFullAbc
342
342
  };
package/src/index.js CHANGED
@@ -34,8 +34,7 @@ module.exports = {
34
34
  ...contourToSvg,
35
35
  ...getContour,
36
36
 
37
-
38
37
  // other
39
38
  javascriptify,
40
- ...math,
39
+ ...math
41
40
  };
@@ -12,8 +12,12 @@ function javascriptify(value, indent = 0) {
12
12
  const nextIndentStr = " ".repeat(indent + 1);
13
13
 
14
14
  // Handle null and undefined
15
- if (value === null) {return "null";}
16
- if (value === undefined) {return "undefined";}
15
+ if (value === null) {
16
+ return "null";
17
+ }
18
+ if (value === undefined) {
19
+ return "undefined";
20
+ }
17
21
 
18
22
  // Handle primitives
19
23
  if (typeof value === "number") {
@@ -39,7 +43,9 @@ function javascriptify(value, indent = 0) {
39
43
 
40
44
  // Handle arrays
41
45
  if (Array.isArray(value)) {
42
- if (value.length === 0) {return "[]";}
46
+ if (value.length === 0) {
47
+ return "[]";
48
+ }
43
49
 
44
50
  const items = value
45
51
  .map((item) => nextIndentStr + javascriptify(item, indent + 1))
@@ -54,15 +60,23 @@ function javascriptify(value, indent = 0) {
54
60
  const keys = Object.keys(value).filter((k) => {
55
61
  const val = value[k];
56
62
  // Omit all falsey values (including false, but keep 0)
57
- if (val === 0) {return true;}
63
+ if (val === 0) {
64
+ return true;
65
+ }
58
66
  //if (val === null || val === undefined || val === "") return false;
59
- if (!val) {return false;}
67
+ if (!val) {
68
+ return false;
69
+ }
60
70
  // Omit empty arrays
61
- if (Array.isArray(val) && val.length === 0) {return false;}
71
+ if (Array.isArray(val) && val.length === 0) {
72
+ return false;
73
+ }
62
74
  return true;
63
75
  });
64
76
 
65
- if (keys.length === 0) {return "{}";}
77
+ if (keys.length === 0) {
78
+ return "{}";
79
+ }
66
80
 
67
81
  const properties = keys
68
82
  .map((key) => {
@@ -2,6 +2,7 @@ const { Fraction } = require("./math.js");
2
2
  const { parseAbc, getMeter, getUnitLength } = require("./parse/parser.js");
3
3
 
4
4
  const { getBarInfo } = require("./parse/getBarInfo.js");
5
+ const { getHeaderValue } = require("./parse/header-parser.js");
5
6
 
6
7
  // ============================================================================
7
8
  // ABC manipulation functions
@@ -56,7 +57,7 @@ function normaliseKey(keyHeader) {
56
57
  lyd: "lydian",
57
58
  lydian: "lydian",
58
59
  loc: "locrian",
59
- locrian: "locrian",
60
+ locrian: "locrian"
60
61
  };
61
62
  const mode = Object.keys(modeMap).find((m) => key.includes(m)) || "major";
62
63
 
@@ -104,7 +105,7 @@ function hasAnacrucisFromParsed(parsed, barLines) {
104
105
  getBarInfo(bars, barLines, meter, {
105
106
  barNumbers: true,
106
107
  isPartial: true,
107
- cumulativeDuration: false,
108
+ cumulativeDuration: false
108
109
  });
109
110
  }
110
111
 
@@ -198,7 +199,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
198
199
  // Get bar info to understand musical structure
199
200
  getBarInfo(bars, barLines, meter, {
200
201
  barNumbers: true,
201
- isPartial: true,
202
+ isPartial: true
202
203
  });
203
204
 
204
205
  // Build a map of which bars start with variant endings
@@ -291,7 +292,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
291
292
  const variantToken = barStartsWithVariant.get(nextBarIdx);
292
293
  barLinesToConvert.set(variantToken.sourceIndex, {
293
294
  oldLength: variantToken.sourceLength,
294
- oldText: variantToken.token,
295
+ oldText: variantToken.token
295
296
  });
296
297
  }
297
298
  barLineDecisions.set(i, { action: "remove" });
@@ -355,7 +356,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
355
356
  } else {
356
357
  // Going from large to small: add bar lines at midpoints
357
358
  const barInfo = getBarInfo(bars, barLines, meter, {
358
- divideBarsBy: 2,
359
+ divideBarsBy: 2
359
360
  });
360
361
 
361
362
  const { midpoints } = barInfo;
@@ -383,10 +384,13 @@ function toggleMeter_4_4_to_4_2(abc, currentMeter) {
383
384
 
384
385
  const defaultCommentForReelConversion =
385
386
  "*abc-tools: convert reel to M:4/4 & L:1/16*";
387
+ const defaultCommentForHornpipeConversion =
388
+ "*abc-tools: convert hornpipe to M:4/2*";
389
+ const defaultCommentForJigConversion = "*abc-tools: convert jig to M:12/8*";
386
390
  /**
387
- * Adjusts bar lengths and L field to convert a
388
- * reel written in the normal way (M:4/4 L:1/8) to the same reel
389
- * written with M:4/4 L:1/16.
391
+ * Adjusts bar lengths and L, M fields - a
392
+ * reel written in the normal way (M:4/4 L:1/8) is written
393
+ * written with M:4/4 and L:1/16 (or M:4/2 and L:1/8, if withSemiquavers is unflagged)
390
394
  * Bars are twice as long, and the quick notes are semiquavers
391
395
  * rather than quavers.
392
396
  * @param {string} reel
@@ -420,6 +424,76 @@ function convertStandardReel(
420
424
  return result;
421
425
  }
422
426
 
427
+ /**
428
+ * Adjusts bar lengths and M field to alter a
429
+ * jig written in the normal way (M:6/8) so it’s
430
+ * written with M:12/8.
431
+ * Bars are twice as long.
432
+ * @param {string} jig
433
+ * @param {string} comment - when non falsey, the comment will be injected as an N: header
434
+
435
+ * @returns
436
+ */
437
+ function convertStandardJig(jig, comment = defaultCommentForJigConversion) {
438
+ const meter = getMeter(jig);
439
+ if (!Array.isArray(meter) || !meter || !meter[0] === 6 || !meter[1] === 8) {
440
+ throw new Error("invalid meter");
441
+ }
442
+
443
+ let result = //toggleMeter_4_4_to_4_2(reel, meter);
444
+ toggleMeterDoubling(jig, [6, 8], [12, 8], meter);
445
+ if (comment) {
446
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
447
+ }
448
+ return result;
449
+ }
450
+
451
+ /**
452
+ * Adjusts bar lengths and M field to alter a
453
+ * hornpipe written in the normal way (M:6/8) so it’s
454
+ * written with M:12/8.
455
+ * Bars are twice as long.
456
+ * @param {string} hornpipe
457
+ * @param {string} comment - when non falsey, the comment will be injected as an N: header
458
+
459
+ * @returns
460
+ */
461
+ function convertStandardHornpipe(
462
+ hornpipe,
463
+ comment = defaultCommentForHornpipeConversion
464
+ ) {
465
+ const meter = getMeter(hornpipe);
466
+ if (!Array.isArray(meter) || !meter || !meter[0] === 4 || !meter[1] === 4) {
467
+ throw new Error("invalid meter");
468
+ }
469
+
470
+ let result = toggleMeter_4_4_to_4_2(hornpipe, meter);
471
+
472
+ if (comment) {
473
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
474
+ }
475
+ return result;
476
+ }
477
+ function doubleBarLength(abc, comment = null) {
478
+ const meter = getMeter(abc);
479
+ if (!Array.isArray(meter) || !meter) {
480
+ throw new Error("invalid meter");
481
+ }
482
+ // const newMeter = [meter[0], meter[1]];
483
+ // if ([16, 8, 4, 2].indexOf(meter[1]) >= 0) newMeter[1] /= 2;
484
+ // else {
485
+ // newMeter[0] *= 2;
486
+ // }
487
+ const newMeter = [meter[0] * 2, meter[1]];
488
+
489
+ let result = //toggleMeter_4_4_to_4_2(reel, meter);
490
+ toggleMeterDoubling(abc, meter, newMeter, meter);
491
+ if (comment) {
492
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
493
+ }
494
+ return result;
495
+ }
496
+
423
497
  /**
424
498
  * Adjusts bar lengths and L field to convert a
425
499
  * reel written in the abnormal way (M:4/4 L:1/16) to the same reel
@@ -437,7 +511,9 @@ function convertToStandardReel(
437
511
  withSemiquavers = true
438
512
  ) {
439
513
  if (withSemiquavers) {
440
- reel = reel.replace("M:4/4", "M:4/2").reel("L:1/16", "L:1/8");
514
+ reel = reel
515
+ .replace(/\nM:\s*4\/4/, "\nM:4/2")
516
+ .replace(/\nL:\s*1\/16/, "\nL:1/8");
441
517
  }
442
518
 
443
519
  const unitLength = getUnitLength(reel);
@@ -456,6 +532,51 @@ function convertToStandardReel(
456
532
  }
457
533
  return result;
458
534
  }
535
+ /**
536
+ * Adjusts bar lengths to rewrite a
537
+ * jig written in the abnormal way (M:12/8) to M:6/8, the normal or standard way.
538
+ * Bars are half as long. Inverse operation to convertStandardJig
539
+ * @param {string} jig
540
+ * @param {string} comment - when non falsey, the comment (as an N:) will removed from the header
541
+ * @param {bool} withSemiquavers - when unflagged, the original jig was written in M:4/2 L:1/8
542
+ * @returns
543
+ */
544
+ function convertToStandardJig(jig, comment = defaultCommentForJigConversion) {
545
+ const unitLength = getUnitLength(jig);
546
+ if (unitLength.den !== 8) {
547
+ throw new Error("invalid L header");
548
+ }
549
+ const meter = getMeter(jig);
550
+ if (!Array.isArray(meter) || !meter || !meter[0] === 12 || !meter[1] === 8) {
551
+ throw new Error("invalid meter");
552
+ }
553
+
554
+ let result = toggleMeter_6_8_to_12_8(jig); // toggleMeter_4_4_to_4_2(jig, meter);
555
+ if (comment) {
556
+ result = result.replace(`\nN:${comment}`, "");
557
+ }
558
+ return result;
559
+ }
560
+
561
+ function convertToStandardHornpipe(
562
+ hornpipe,
563
+ comment = defaultCommentForHornpipeConversion
564
+ ) {
565
+ const unitLength = getUnitLength(hornpipe);
566
+ if (unitLength.den !== 8) {
567
+ throw new Error("invalid L header");
568
+ }
569
+ const meter = getMeter(hornpipe);
570
+ if (!Array.isArray(meter) || !meter || !meter[0] === 4 || !meter[1] === 2) {
571
+ throw new Error("invalid meter");
572
+ }
573
+
574
+ let result = toggleMeter_4_4_to_4_2(hornpipe); // toggleMeter_4_4_to_4_2(jig, meter);
575
+ if (comment) {
576
+ result = result.replace(`\nN:${comment}`, "");
577
+ }
578
+ return result;
579
+ }
459
580
 
460
581
  /**
461
582
  * Toggle between M:6/8 and M:12/8 by surgically adding/removing bar lines
@@ -508,7 +629,7 @@ function getFirstBars(
508
629
  barNumbers: true,
509
630
  isPartial: true,
510
631
  cumulativeDuration: true,
511
- stopAfterBarNumber,
632
+ stopAfterBarNumber
512
633
  });
513
634
 
514
635
  const enrichedBarLines = barInfo.barLines;
@@ -672,15 +793,80 @@ function getFirstBars(
672
793
  )}`;
673
794
  }
674
795
 
796
+ function canDoubleBarLength(abc) {
797
+ const meter = getMeter(abc),
798
+ l = getUnitLength(abc),
799
+ rhythm = getHeaderValue(abc, "R");
800
+ if (
801
+ !rhythm ||
802
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
803
+ ) {
804
+ return false;
805
+ }
806
+ return (
807
+ !abc.match(/\[M:/) && //inline meter marking
808
+ !abc.match(/\[L:/) &&
809
+ (((rhythm === "reel" || rhythm === "hornpipe") &&
810
+ l.equals(new Fraction(1, 8)) &&
811
+ meter[0] === 4 &&
812
+ meter[1] === 4) ||
813
+ (rhythm === "jig" &&
814
+ l.equals(new Fraction(1, 8)) &&
815
+ meter[0] === 6 &&
816
+ meter[1] === 8))
817
+ );
818
+ }
819
+ function canHalveBarLength(abc) {
820
+ const meter = getMeter(abc),
821
+ l = getUnitLength(abc),
822
+ rhythm = getHeaderValue(abc, "R");
823
+ if (
824
+ !rhythm ||
825
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
826
+ ) {
827
+ return false;
828
+ }
829
+
830
+ if (
831
+ !rhythm ||
832
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
833
+ ) {
834
+ return false;
835
+ }
836
+ return (
837
+ !abc.match(/\[M:/) && //inline meter marking
838
+ !abc.match(/\[L:/) &&
839
+ ((rhythm === "reel" &&
840
+ l.equals(new Fraction(1, 16)) &&
841
+ meter[0] === 4 &&
842
+ meter[1] === 4) ||
843
+ ((rhythm === "reel" || rhythm === "hornpipe") &&
844
+ l.equals(new Fraction(1, 8)) &&
845
+ meter[0] === 4 &&
846
+ meter[1] === 2) ||
847
+ (rhythm === "jig" &&
848
+ l.equals(new Fraction(1, 8)) &&
849
+ meter[0] === 12 &&
850
+ meter[1] === 8))
851
+ );
852
+ }
853
+
675
854
  module.exports = {
855
+ canDoubleBarLength,
856
+ canHalveBarLength,
857
+ convertStandardJig,
858
+ convertStandardHornpipe,
676
859
  convertStandardReel,
860
+ convertToStandardJig,
861
+ convertToStandardHornpipe,
677
862
  convertToStandardReel,
678
863
  defaultCommentForReelConversion,
864
+ doubleBarLength,
679
865
  filterHeaders,
680
866
  getFirstBars,
681
867
  hasAnacrucis,
682
868
  normaliseKey,
683
869
  toggleMeter_4_4_to_4_2,
684
870
  toggleMeter_6_8_to_12_8,
685
- toggleMeterDoubling,
871
+ toggleMeterDoubling
686
872
  };
package/src/math.js CHANGED
@@ -105,6 +105,6 @@ if (typeof module !== "undefined" && module.exports) {
105
105
  module.exports = {
106
106
  // lcm,
107
107
  // gcd,
108
- Fraction,
108
+ Fraction
109
109
  };
110
110
  }
@@ -40,7 +40,7 @@ function parseBarLine(barLineStr) {
40
40
  const trimmed = barLineStr.trim();
41
41
  const result = {
42
42
  text: barLineStr,
43
- trimmed,
43
+ trimmed
44
44
  };
45
45
  // Start repeat
46
46
  if (trimmed.match(/:$/)) {
@@ -60,5 +60,5 @@ function parseBarLine(barLineStr) {
60
60
  }
61
61
 
62
62
  module.exports = {
63
- parseBarLine,
63
+ parseBarLine
64
64
  };
@@ -21,7 +21,7 @@ function processSkippedBarLines(barLines, skippedBarLineIndexes) {
21
21
  // Copy any properties from the preceding barLine not already in the skipped barLine
22
22
  barLines[skippedIndex] = {
23
23
  ...barLines[skippedIndex - 1],
24
- ...barLines[skippedIndex],
24
+ ...barLines[skippedIndex]
25
25
  };
26
26
  }
27
27
  }
@@ -101,7 +101,7 @@ function getBarInfo(bars, barLines, meter, options = {}) {
101
101
  isPartial = true,
102
102
  cumulativeDuration = true,
103
103
  divideBarsBy = null,
104
- stopAfterBarNumber = null,
104
+ stopAfterBarNumber = null
105
105
  } = options;
106
106
 
107
107
  if (divideBarsBy !== null && divideBarsBy !== 2) {
@@ -205,7 +205,7 @@ function getBarInfo(bars, barLines, meter, options = {}) {
205
205
  durationSinceLastComplete: durationBeforeVariant.clone(),
206
206
  lastCompleteBarLineIdx: lastCompleteBarLineIdx,
207
207
  meter: [...currentMeter],
208
- fullBarDuration: fullBarDuration.clone(),
208
+ fullBarDuration: fullBarDuration.clone()
209
209
  };
210
210
  variantCounter = 0;
211
211
  currentVariantId = variantCounter;
@@ -287,7 +287,7 @@ function getBarInfo(bars, barLines, meter, options = {}) {
287
287
  processSkippedBarLines(barLines, skippedBarLineIndexes);
288
288
  return {
289
289
  barLines: barLines.slice(0, barLineIdx + 1),
290
- midpoints,
290
+ midpoints
291
291
  };
292
292
  }
293
293
  }
@@ -313,7 +313,7 @@ function getBarInfo(bars, barLines, meter, options = {}) {
313
313
  if (cumulativeDuration) {
314
314
  barLine.cumulativeDuration = {
315
315
  sinceLastBarLine: barDuration.clone(),
316
- sinceLastComplete: durationSinceLastComplete.clone(),
316
+ sinceLastComplete: durationSinceLastComplete.clone()
317
317
  };
318
318
  }
319
319
 
@@ -425,10 +425,10 @@ function getBarInfo(bars, barLines, meter, options = {}) {
425
425
  processSkippedBarLines(barLines, skippedBarLineIndexes);
426
426
  return {
427
427
  barLines,
428
- midpoints,
428
+ midpoints
429
429
  };
430
430
  }
431
431
 
432
432
  module.exports = {
433
- getBarInfo,
433
+ getBarInfo
434
434
  };
@@ -23,7 +23,7 @@ function getMetadata(abc) {
23
23
  } else if (trimmed.startsWith("R:")) {
24
24
  metadata.rhythm = trimmed.substring(2).trim().toLowerCase();
25
25
  } else if (trimmed.startsWith("C:")) {
26
- metadata.composer = trimmed.substring(2).trim().toLowerCase();
26
+ metadata.composer = trimmed.substring(2).trim();
27
27
  } else if (trimmed.startsWith("M:")) {
28
28
  metadata.meter = trimmed.substring(2).trim();
29
29
  } else if (trimmed.startsWith("K:")) {
@@ -32,6 +32,8 @@ function getMetadata(abc) {
32
32
  break;
33
33
  } else if (trimmed.startsWith("S:")) {
34
34
  metadata.source = trimmed.substring(2).trim();
35
+ } else if (trimmed.startsWith("O:")) {
36
+ metadata.origin = trimmed.substring(2).trim();
35
37
  } else if (trimmed.startsWith("F:")) {
36
38
  metadata.url = trimmed.substring(2).trim();
37
39
  } else if (trimmed.startsWith("D:")) {
@@ -77,7 +77,13 @@ function getUnitLength(abc) {
77
77
  * @returns {[string]} - array of titles
78
78
  */
79
79
  function getTitles(abc) {
80
- return [...abc.matchAll(/^(?:T:\s*.(.+)\n)/gm)];
80
+ return [...abc.matchAll(/^(?:T:\s*(.+)\n)/gm)];
81
+ }
82
+
83
+ function getHeaderValue(abc, header) {
84
+ const r = new RegExp(String.raw`(?:${header}:\s*(.+)\n)`, "m"),
85
+ m = abc.match(r);
86
+ return m ? m[1]?.trim() : null;
81
87
  }
82
88
 
83
89
  /**
@@ -138,7 +144,7 @@ function getMusicLines(abc) {
138
144
  originalLine: line,
139
145
  content: trimmed,
140
146
  comment,
141
- hasContinuation,
147
+ hasContinuation
142
148
  });
143
149
 
144
150
  // Track position where newline would be (unless continuation)
@@ -155,15 +161,16 @@ function getMusicLines(abc) {
155
161
  lineMetadata,
156
162
  newlinePositions,
157
163
  headerLines,
158
- headerEndIndex,
164
+ headerEndIndex
159
165
  };
160
166
  }
161
167
 
162
168
  module.exports = {
163
- getTonalBase,
169
+ getHeaderValue,
170
+ getKey,
164
171
  getMeter,
165
- getUnitLength,
166
172
  getMusicLines,
167
173
  getTitles,
168
- getKey,
174
+ getTonalBase,
175
+ getUnitLength
169
176
  };
@@ -21,7 +21,7 @@ function analyzeSpacing(segment, tokenEndPos) {
21
21
  whitespace: "",
22
22
  backquotes: 0,
23
23
  beamBreak: false,
24
- lineBreak: false,
24
+ lineBreak: false
25
25
  };
26
26
  }
27
27
 
@@ -35,7 +35,7 @@ function analyzeSpacing(segment, tokenEndPos) {
35
35
  whitespace: "",
36
36
  backquotes: 0,
37
37
  beamBreak: false,
38
- lineBreak: false,
38
+ lineBreak: false
39
39
  };
40
40
  }
41
41
 
@@ -51,7 +51,7 @@ function analyzeSpacing(segment, tokenEndPos) {
51
51
  whitespace,
52
52
  backquotes,
53
53
  beamBreak: whitespace.length > 1 || whitespace.includes("\n"), // Multiple spaces or newline breaks beam
54
- lineBreak: whitespace.includes("\n"),
54
+ lineBreak: whitespace.includes("\n")
55
55
  };
56
56
  }
57
57
 
@@ -68,7 +68,7 @@ function parseTuplet(token, isCompoundTimeSignature) {
68
68
  const pqr = {
69
69
  p: parseInt(tupleMatch[1]),
70
70
  q: tupleMatch[2],
71
- r: tupleMatch[3],
71
+ r: tupleMatch[3]
72
72
  };
73
73
  const { p } = pqr;
74
74
  let { q, r } = pqr;
@@ -107,12 +107,12 @@ function parseTuplet(token, isCompoundTimeSignature) {
107
107
  isTuple: true,
108
108
  p,
109
109
  q,
110
- r,
110
+ r
111
111
  };
112
112
  }
113
113
  return null;
114
114
  }
115
115
  module.exports = {
116
116
  analyzeSpacing,
117
- parseTuplet,
117
+ parseTuplet
118
118
  };
@@ -38,7 +38,7 @@ function parseDecorations(noteStr) {
38
38
  T: "trill",
39
39
  H: "fermata",
40
40
  u: "upbow",
41
- v: "downbow",
41
+ v: "downbow"
42
42
  };
43
43
 
44
44
  for (const [symbol, name] of Object.entries(symbolDecorations)) {
@@ -82,7 +82,7 @@ function parseAnnotation(noteStr) {
82
82
  if (annotationMatch) {
83
83
  return {
84
84
  position: annotationMatch[1],
85
- text: annotationMatch[2],
85
+ text: annotationMatch[2]
86
86
  };
87
87
  }
88
88
  return null;
@@ -190,7 +190,7 @@ function parseChord(chordStr, unitLength) {
190
190
  }
191
191
  return {
192
192
  isChord: true,
193
- notes,
193
+ notes
194
194
  };
195
195
  }
196
196
 
@@ -256,7 +256,7 @@ function parseGraceNotes(graceStr) {
256
256
  isGraceNote: true,
257
257
  duration: new Fraction(0, 1),
258
258
  isChord: true,
259
- chordNotes: chord.notes,
259
+ chordNotes: chord.notes
260
260
  });
261
261
  }
262
262
  } else {
@@ -266,7 +266,7 @@ function parseGraceNotes(graceStr) {
266
266
  graceNotes.push({
267
267
  ...pitchData,
268
268
  isGraceNote: true,
269
- duration: new Fraction(0, 1),
269
+ duration: new Fraction(0, 1)
270
270
  });
271
271
  }
272
272
  }
@@ -298,7 +298,7 @@ function parseBrokenRhythm(token) {
298
298
  return {
299
299
  isBrokenRhythm: true,
300
300
  direction: symbol[0],
301
- dots: symbol.length,
301
+ dots: symbol.length
302
302
  };
303
303
  }
304
304
  return null;
@@ -375,7 +375,7 @@ function parseNote(noteStr, unitLength, currentTuple) {
375
375
  if (cleanStr.match(/^y$/)) {
376
376
  return {
377
377
  isDummy: true,
378
- duration: new Fraction(0, 1, decorations, annotation),
378
+ duration: new Fraction(0, 1, decorations, annotation)
379
379
  };
380
380
  }
381
381
 
@@ -385,7 +385,7 @@ function parseNote(noteStr, unitLength, currentTuple) {
385
385
  const duration = getDuration({
386
386
  unitLength,
387
387
  noteString: cleanStr,
388
- currentTuple,
388
+ currentTuple
389
389
  });
390
390
  const result = { isSilence: true, duration, text: silenceMatch[0] };
391
391
  if (decorations) {
@@ -427,7 +427,7 @@ function parseNote(noteStr, unitLength, currentTuple) {
427
427
  const duration = getDuration({
428
428
  unitLength,
429
429
  noteString: cleanStr,
430
- currentTuple,
430
+ currentTuple
431
431
  });
432
432
  topNote.duration = duration;
433
433
  // Apply duration to all notes in chord
@@ -442,7 +442,7 @@ function parseNote(noteStr, unitLength, currentTuple) {
442
442
  chordSymbol: chordSymbol || chord.chordSymbol,
443
443
  decorations: decorations || chord.decorations,
444
444
  isChord: true,
445
- tied,
445
+ tied
446
446
  };
447
447
  }
448
448
  }
@@ -453,7 +453,7 @@ function parseNote(noteStr, unitLength, currentTuple) {
453
453
  const duration = getDuration({
454
454
  unitLength,
455
455
  noteString: cleanStr,
456
- currentTuple,
456
+ currentTuple
457
457
  });
458
458
 
459
459
  const result = { pitch, octave, duration, tied };
@@ -480,5 +480,5 @@ module.exports = {
480
480
  parseNote,
481
481
  parseBrokenRhythm,
482
482
  applyBrokenRhythm,
483
- parseGraceNotes,
483
+ parseGraceNotes
484
484
  };
@@ -4,19 +4,19 @@ const {
4
4
  getMeter,
5
5
  getKey,
6
6
  getUnitLength,
7
- getMusicLines,
7
+ getMusicLines
8
8
  } = require("./header-parser.js");
9
9
  const {
10
10
  parseNote,
11
11
  parseBrokenRhythm,
12
12
  applyBrokenRhythm,
13
- parseGraceNotes,
13
+ parseGraceNotes
14
14
  } = require("./note-parser.js");
15
15
  const { parseBarLine } = require("./barline-parser.js");
16
16
  const {
17
17
  getTokenRegex,
18
18
  parseInlineField,
19
- repeat_1Or2,
19
+ repeat_1Or2
20
20
  } = require("./token-utils.js");
21
21
  const { analyzeSpacing, parseTuplet } = require("./misc-parser.js");
22
22
 
@@ -338,7 +338,7 @@ function parseAbc(abc, options = {}) {
338
338
  lineMetadata,
339
339
  headerLines,
340
340
  headerEndIndex,
341
- newlinePositions,
341
+ newlinePositions
342
342
  } = getMusicLines(abc);
343
343
 
344
344
  // Create a Set of newline positions for O(1) lookup
@@ -413,8 +413,8 @@ function parseAbc(abc, options = {}) {
413
413
  whitespace: "",
414
414
  backquotes: 0,
415
415
  beamBreak: false,
416
- lineBreak: false,
417
- },
416
+ lineBreak: false
417
+ }
418
418
  });
419
419
  });
420
420
  // Grace notes don’t update previousRealNote
@@ -432,7 +432,7 @@ function parseAbc(abc, options = {}) {
432
432
  token: fullToken,
433
433
  sourceIndex: tokenStartPos,
434
434
  sourceLength: fullToken.length,
435
- spacing,
435
+ spacing
436
436
  };
437
437
  currentBar.push(inlineFieldObj);
438
438
 
@@ -482,7 +482,7 @@ function parseAbc(abc, options = {}) {
482
482
 
483
483
  // Add the broken rhythm marker to the bar
484
484
  currentBar.push({
485
- ...brokenRhythm,
485
+ ...brokenRhythm
486
486
  });
487
487
 
488
488
  // Add the next note to the bar
@@ -491,7 +491,7 @@ function parseAbc(abc, options = {}) {
491
491
  token: nextToken,
492
492
  sourceIndex: nextTokenStartPos,
493
493
  sourceLength: nextToken.length,
494
- spacing: nextSpacing,
494
+ spacing: nextSpacing
495
495
  };
496
496
  currentBar.push(nextNoteObj);
497
497
  previousRealNote = null; //can’t have successive broken rhythms
@@ -518,7 +518,7 @@ function parseAbc(abc, options = {}) {
518
518
  ...tuple,
519
519
  token: fullToken,
520
520
  sourceIndex: tokenStartPos,
521
- sourceLength: fullToken.length,
521
+ sourceLength: fullToken.length
522
522
  });
523
523
  previousRealNote = null; // Tuplet markers break note sequences
524
524
  continue;
@@ -533,7 +533,7 @@ function parseAbc(abc, options = {}) {
533
533
  token: fullToken,
534
534
  sourceIndex: tokenStartPos,
535
535
  sourceLength: fullToken.length,
536
- spacing,
536
+ spacing
537
537
  });
538
538
  // Chord symbols don’t break note sequences
539
539
  continue;
@@ -547,7 +547,7 @@ function parseAbc(abc, options = {}) {
547
547
  token: fullToken,
548
548
  sourceIndex: tokenStartPos,
549
549
  sourceLength: fullToken.length,
550
- spacing,
550
+ spacing
551
551
  });
552
552
  // Decorations don’t break note sequences
553
553
  continue;
@@ -562,7 +562,7 @@ function parseAbc(abc, options = {}) {
562
562
  token: fullToken,
563
563
  sourceIndex: tokenStartPos,
564
564
  sourceLength: fullToken.length,
565
- spacing,
565
+ spacing
566
566
  };
567
567
  currentBar.push(noteObj);
568
568
  // Only track as previous note if it has non-zero duration (for broken rhythms)
@@ -581,7 +581,7 @@ function parseAbc(abc, options = {}) {
581
581
  token: fullToken,
582
582
  sourceIndex: tokenStartPos,
583
583
  sourceLength: fullToken.length,
584
- spacing,
584
+ spacing
585
585
  };
586
586
  firstOrSecondRepeat = !!fullToken.match(new RegExp(repeat_1Or2));
587
587
  if (firstOrSecondRepeat) {
@@ -606,8 +606,8 @@ function parseAbc(abc, options = {}) {
606
606
  ? { barLineText: musicText, barLinePos: musicText.length }
607
607
  : {
608
608
  barLineText: fullToken,
609
- barLinePos: tokenStartPos,
610
- };
609
+ barLinePos: tokenStartPos
610
+ };
611
611
 
612
612
  //if (lastBarPos > 0) lastBarPos--; //the last character in a barline expression may be needed to match variant endings - eg `|1`
613
613
 
@@ -625,7 +625,7 @@ function parseAbc(abc, options = {}) {
625
625
  sourceIndex: barLinePos,
626
626
  sourceLength: barLineText.length,
627
627
  //barNumber: barCount,
628
- hasLineBreak: hasLineBreakAfterBar,
628
+ hasLineBreak: hasLineBreakAfterBar
629
629
  });
630
630
 
631
631
  // Update the last token in current bar to mark lineBreak if bar line has one
@@ -681,7 +681,7 @@ function parseAbc(abc, options = {}) {
681
681
  lineMetadata,
682
682
  headerLines,
683
683
  headerEndIndex,
684
- musicText,
684
+ musicText
685
685
  };
686
686
  }
687
687
  }
@@ -768,5 +768,5 @@ module.exports = {
768
768
  getUnitLength,
769
769
  getMusicLines,
770
770
  analyzeSpacing,
771
- parseBarLine,
771
+ parseBarLine
772
772
  };
@@ -84,7 +84,7 @@ const // captures not only |1 |2, but also :|1 :||1 :|2 :||2
84
84
  *
85
85
  * Strategy: Match `[` only when NOT followed by inline field or digit; match `|` and `]` freely
86
86
  */
87
- barLine: String.raw`:*\.*(?:[|\]]|\[(?![KLMP]:|[0-9]))*(?:\||::+)(?:[|\]:]|\[(?![KLMP]:|[0-9]))* *`,
87
+ barLine: String.raw`:*\.*(?:[|\]]|\[(?![KLMP]:|[0-9]))*(?:\||::+)(?:[|\]:]|\[(?![KLMP]:|[0-9]))* *`
88
88
  };
89
89
 
90
90
  /**
@@ -133,7 +133,7 @@ const getTokenRegex = (options = {}) => {
133
133
  s.broken,
134
134
  s.variantEnding,
135
135
  s.repeat_1Or2,
136
- s.barLine,
136
+ s.barLine
137
137
  ].join("|");
138
138
 
139
139
  return new RegExp(fullPattern, "g");
@@ -157,7 +157,7 @@ function parseInlineField(token) {
157
157
  if (fieldMatch) {
158
158
  return {
159
159
  field: fieldMatch[1],
160
- value: fieldMatch[2].trim(),
160
+ value: fieldMatch[2].trim()
161
161
  };
162
162
  }
163
163
  return null;
@@ -165,5 +165,5 @@ function parseInlineField(token) {
165
165
  module.exports = {
166
166
  repeat_1Or2,
167
167
  getTokenRegex,
168
- parseInlineField,
168
+ parseInlineField
169
169
  };
@@ -1,3 +1,4 @@
1
+ const { getIncipitForContourGeneration } = require("../incipit.js");
1
2
  const { Fraction } = require("../math.js");
2
3
 
3
4
  const { decodeChar, encodeToChar, silenceChar } = require("./encode.js");
@@ -161,10 +162,105 @@ function compare(objA, objB) {
161
162
  return posA >= keyA.length ? -1 : 1;
162
163
  }
163
164
 
165
+ function canBeCompared(tune1, tune2) {
166
+ if (!tune1.contour || !tune2.contour) return false;
167
+
168
+ // but not hop jigs with different meters
169
+ if (
170
+ tune1.rhythm?.indexOf("hop jig") >= 0 &&
171
+ tune2.rhythm?.indexOf("hop jig") >= 0 &&
172
+ tune1.meter !== tune2.meter
173
+ )
174
+ return false;
175
+
176
+ return true;
177
+ }
178
+
179
+ function getAbcForContour_default(tune) {
180
+ return getIncipitForContourGeneration(
181
+ tune.incipit
182
+ ? tune.incipit
183
+ : Array.isArray(tune.abc)
184
+ ? tune.abc[0]
185
+ : tune.abc
186
+ );
187
+ }
188
+
164
189
  /**
165
190
  * Sort an array of objects containing ABC notation
191
+ * - based on a contour property
192
+ * - when the contour is missing, attempts to generate it
193
+ * - adds info to each object like the contour, the type of rhythm
194
+ * @param {Array<Object>} arr - array of tune objects to sort
195
+ * @param {Object} [options] - options for the sorting
196
+ * @param {function(Object):string} [options.getAbc] - function returning an abc fragment to be used to calculate the contour.
166
197
  */
167
- function sortArray(arr) {
198
+ function sort(arr, options = {}) {
199
+ const {
200
+ comparable = [
201
+ ["jig", "slide", "single jig", "double jig"],
202
+ ["reel", "single reel", "reel (single)", "strathspey", "double reel"],
203
+ ["hornpipe", "barndance", "fling"]
204
+ ],
205
+ applySwingTransform = ["hornpipe", "barndance", "fling", "mazurka"],
206
+ getAbc: getAbcForContour = getAbcForContour_default,
207
+ getContourOptions = {
208
+ withSvg: true
209
+ }
210
+ } = options;
211
+
212
+ const comparableMap = {};
213
+ //set up comparableMap s.t. all entries in each list of index i go under i_<first entry of i>
214
+ //that way we show the jigs with similar rhythms followed by reels etc.
215
+ for (let i = 0; i < comparable.length; i++) {
216
+ const list = comparable[i];
217
+ //map subsequent entries to the first entry
218
+ for (let j = 0; j < list.length; j++) {
219
+ comparableMap[list[j]] = `${i}_${list[0]}`;
220
+ }
221
+ }
222
+
223
+ for (const tune of arr) {
224
+ if (!tune.contour) {
225
+ try {
226
+ const withSwingTransform =
227
+ applySwingTransform.indexOf(tune.rhythm) >= 0;
228
+ const shortAbc = getAbcForContour(tune);
229
+
230
+ if (shortAbc) {
231
+ getContourOptions.withSwingTransform = withSwingTransform;
232
+ tune.contour = getContour(shortAbc, getContourOptions);
233
+ }
234
+ } catch (error) {
235
+ console.log(error);
236
+ }
237
+ }
238
+ if (!tune.baseRhythm)
239
+ tune.baseRhythm = comparableMap[tune.rhythm] ?? tune.rhythm;
240
+ }
241
+ arr.sort((a, b) => {
242
+ const comparison =
243
+ a.baseRhythm !== b.baseRhythm
244
+ ? a.baseRhythm < b.baseRhythm
245
+ ? -1
246
+ : 1
247
+ : canBeCompared(a, b)
248
+ ? compare(a.contour, b.contour)
249
+ : a.contour && !b.contour
250
+ ? -1
251
+ : b.contour && !a.contour
252
+ ? 1
253
+ : a.name !== b.name
254
+ ? a.name < b.name
255
+ ? -1
256
+ : 1
257
+ : 0;
258
+ return comparison;
259
+ });
260
+ arr.forEach((t) => delete t.baseRhythm);
261
+ }
262
+
263
+ function simpleSort(arr) {
168
264
  for (const item of arr) {
169
265
  if (!item.contour && item.abc) {
170
266
  try {
@@ -198,6 +294,7 @@ function sortArray(arr) {
198
294
 
199
295
  module.exports = {
200
296
  compare,
201
- sortArray,
202
- decodeChar,
297
+ sort,
298
+ simpleSort,
299
+ decodeChar
203
300
  };
@@ -61,7 +61,7 @@ const contourToSvg_defaultConfig = {
61
61
  yAxisWidth: 0.5,
62
62
  yAxisTickLength: 4,
63
63
  yAxisTonicTickLength: 6,
64
- yAxisTickWidth: 0.5,
64
+ yAxisTickWidth: 0.5
65
65
  };
66
66
 
67
67
  /**
@@ -150,7 +150,7 @@ function contourToSvg(contour, svgConfig = {}) {
150
150
  width: segmentWidth,
151
151
  position: decoded.position,
152
152
  isHeld: decoded.isHeld,
153
- isSilence: decoded.isSilence,
153
+ isSilence: decoded.isSilence
154
154
  });
155
155
 
156
156
  xPosition += segmentWidth;
@@ -332,5 +332,5 @@ function contourToSvg(contour, svgConfig = {}) {
332
332
 
333
333
  module.exports = {
334
334
  contourToSvg,
335
- contourToSvg_defaultConfig,
335
+ contourToSvg_defaultConfig
336
336
  };
@@ -63,5 +63,5 @@ function sortKeyToString(sortKey, showHeld = false) {
63
63
 
64
64
  module.exports = {
65
65
  positionToString,
66
- sortKeyToString,
66
+ sortKeyToString
67
67
  };
@@ -52,9 +52,15 @@ function decodeChar(char) {
52
52
  return { position, isHeld, isSilence: false };
53
53
  }
54
54
 
55
+ function shiftChar(char, nbOctaves) {
56
+ const { position, isHeld } = decodeChar(char);
57
+ return encodeToChar(position + nbOctaves * OCTAVE_SHIFT, isHeld);
58
+ }
59
+
55
60
  module.exports = {
56
61
  calculateModalPosition,
57
62
  encodeToChar,
58
63
  decodeChar,
59
- silenceChar,
64
+ shiftChar,
65
+ silenceChar
60
66
  };
@@ -4,13 +4,14 @@ const {
4
4
  calculateModalPosition,
5
5
  encodeToChar,
6
6
  silenceChar,
7
+ shiftChar
7
8
  } = require("./encode.js");
8
9
 
9
10
  const {
10
11
  getTonalBase,
11
12
  getUnitLength,
12
13
  parseAbc,
13
- getMeter,
14
+ getMeter
14
15
  } = require("../parse/parser.js");
15
16
 
16
17
  const { contourToSvg } = require("./contour-svg.js");
@@ -28,11 +29,12 @@ const { contourToSvg } = require("./contour-svg.js");
28
29
  function getContour(
29
30
  abc,
30
31
  {
32
+ contourShift = null,
31
33
  withSvg = false,
32
34
  withSwingTransform = false,
33
- maxNbBars = new Fraction(3, 2),
35
+ maxNbBars = new Fraction(3, 1),
34
36
  maxNbUnitLengths = 12,
35
- svgConfig = {},
37
+ svgConfig = {}
36
38
  } = {}
37
39
  ) {
38
40
  const tonalBase = getTonalBase(abc);
@@ -52,10 +54,10 @@ function getContour(
52
54
  const maxDuration = maxNbBars.multiply(meterFraction);
53
55
 
54
56
  const {
55
- bars,
57
+ bars
56
58
  } = //todo: could add as an argument; default null
57
59
  parseAbc(abc, {
58
- maxBars: Math.ceil(maxNbBars.toNumber()),
60
+ maxBars: Math.ceil(maxNbBars.toNumber())
59
61
  });
60
62
 
61
63
  let cumulatedDuration = new Fraction(0, 1);
@@ -65,6 +67,7 @@ function getContour(
65
67
  let index = 0;
66
68
  // get the parsed notes - notes are tokens with a duration
67
69
  const notes = [];
70
+ let totalPosition = 0;
68
71
  let tied = false,
69
72
  previousPosition = null;
70
73
  for (let i = 0; i < bars.length; i++) {
@@ -90,6 +93,10 @@ function getContour(
90
93
  ? { encoded: silenceChar, encodedHeld: silenceChar, position: 0 }
91
94
  : getEncodedFromNote(note, tonalBase, tied, previousPosition);
92
95
 
96
+ if (!isSilence) {
97
+ totalPosition += position;
98
+ }
99
+
93
100
  if (note.tied) {
94
101
  tied = true;
95
102
  previousPosition = position;
@@ -140,8 +147,27 @@ function getContour(
140
147
  }
141
148
  });
142
149
 
150
+ // next loops shift the base octave so as to mninimise the average absolute distance
151
+ if (contourShift !== 0) {
152
+ if (Number.isInteger(contourShift))
153
+ sortKey.forEach((c, i) => (sortKey[i] = shiftChar(c, contourShift)));
154
+ else {
155
+ let avg = totalPosition / notes.length;
156
+ if (avg > 0)
157
+ while (avg >= 7) {
158
+ sortKey.forEach((c, i) => (sortKey[i] = shiftChar(c, -1)));
159
+ avg -= 7;
160
+ }
161
+ else
162
+ while (avg < -0.5) {
163
+ sortKey.forEach((c, i) => (sortKey[i] = shiftChar(c, 1)));
164
+ avg += 7;
165
+ }
166
+ }
167
+ }
168
+
143
169
  const result = {
144
- sortKey: sortKey.join(""),
170
+ sortKey: sortKey.join("")
145
171
  //debugPositions: debugPositions.join(","),
146
172
  };
147
173
  if (durations.length > 0) {
@@ -304,7 +330,7 @@ function pushShortNote(
304
330
  const relativeDuration = duration.divide(unitLength),
305
331
  d = {
306
332
  i: index,
307
- d: relativeDuration.den,
333
+ d: relativeDuration.den
308
334
  };
309
335
  if (relativeDuration.num !== 1) {
310
336
  d.n = relativeDuration.num;
@@ -324,10 +350,10 @@ function getEncodedFromNote(note, tonalBase, tied, previousPosition) {
324
350
  return {
325
351
  encoded: tied && position === previousPosition ? encodedHeld : encoded,
326
352
  encodedHeld,
327
- position,
353
+ position
328
354
  };
329
355
  }
330
356
 
331
357
  module.exports = {
332
- getContour,
358
+ getContour
333
359
  };