@goplayerjuggler/abc-tools 1.0.16 → 1.0.18

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.16",
3
+ "version": "1.0.18",
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": {
@@ -44,13 +44,13 @@
44
44
  "devDependencies": {
45
45
  "@eslint/eslintrc": "^3.3.1",
46
46
  "@eslint/js": "^9.37.0",
47
- "baseline-browser-mapping": "^2.9.19",
48
47
  "clipboardy": "^5.0.1",
49
48
  "eslint": "^9.37.0",
50
49
  "eslint-plugin-jest": "^29.0.1",
51
50
  "globals": "^16.4.0",
52
51
  "jest": "^30.2.0",
53
- "jsdoc": "^4.0.5"
52
+ "jsdoc": "^4.0.5",
53
+ "prettier": "^3.8.1"
54
54
  },
55
55
  "jest": {
56
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
 
@@ -178,6 +179,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
178
179
 
179
180
  const parsed = parseAbc(abc);
180
181
  const { headerLines, barLines, musicText, bars, meter } = parsed;
182
+
181
183
  // throw if there's a change of meter or unit length in the tune
182
184
  if (barLines.find((bl) => bl.newMeter || bl.newUnitLength)) {
183
185
  throw new Error("change of meter or unit length not handled");
@@ -198,7 +200,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
198
200
  // Get bar info to understand musical structure
199
201
  getBarInfo(bars, barLines, meter, {
200
202
  barNumbers: true,
201
- isPartial: true,
203
+ isPartial: true
202
204
  });
203
205
 
204
206
  // Build a map of which bars start with variant endings
@@ -268,13 +270,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
268
270
  continue;
269
271
  }
270
272
 
271
- // If the current bar starts with a variant, keep its bar line
272
- const currentBarVariant = barStartsWithVariant.get(i);
273
- if (currentBarVariant) {
274
- barLineDecisions.set(i, { action: "keep" });
275
- continue;
276
- }
277
-
278
273
  // This is a complete bar - use its barNumber to decide
279
274
  // Without anacrucis: Remove complete bars with even barNumber (0, 2, 4, ...), keep odd ones (1, 3, 5, ...)
280
275
  // With anacrucis: the other way round!
@@ -286,12 +281,21 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
286
281
  ? barLine.barNumber % 2 !== 0
287
282
  : barLine.barNumber % 2 === 0;
288
283
  if (remove) {
284
+ // Check if current bar starts with variant
285
+ if (barStartsWithVariant.has(i)) {
286
+ const variantToken = barStartsWithVariant.get(i);
287
+ barLinesToConvert.set(variantToken.sourceIndex, {
288
+ oldLength: variantToken.sourceLength,
289
+ oldText: variantToken.token
290
+ });
291
+ }
292
+ // Also check if next bar starts with variant
289
293
  const nextBarIdx = i + 1;
290
294
  if (nextBarIdx < bars.length && barStartsWithVariant.has(nextBarIdx)) {
291
295
  const variantToken = barStartsWithVariant.get(nextBarIdx);
292
296
  barLinesToConvert.set(variantToken.sourceIndex, {
293
297
  oldLength: variantToken.sourceLength,
294
- oldText: variantToken.token,
298
+ oldText: variantToken.token
295
299
  });
296
300
  }
297
301
  barLineDecisions.set(i, { action: "remove" });
@@ -301,6 +305,26 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
301
305
  }
302
306
  }
303
307
 
308
+ // --- Debugging - may be helpful ----
309
+ // console.log(
310
+ // "Bar line decisions:",
311
+ // Array.from(barLineDecisions.entries()).map(([i, d]) => ({
312
+ // index: i,
313
+ // barLine: barLines[i]?.text,
314
+ // sourceIndex: barLines[i]?.sourceIndex,
315
+ // action: d.action
316
+ // }))
317
+ // );
318
+
319
+ // console.log(
320
+ // "Bar lines:",
321
+ // barLines.map((bl) => ({ text: bl.text, sourceIndex: bl.sourceIndex }))
322
+ // );
323
+ // console.log(
324
+ // "Bars starting with variants:",
325
+ // Array.from(barStartsWithVariant.entries())
326
+ // );
327
+
304
328
  // Reconstruct music
305
329
  let newMusic = "";
306
330
  let pos = 0;
@@ -355,7 +379,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
355
379
  } else {
356
380
  // Going from large to small: add bar lines at midpoints
357
381
  const barInfo = getBarInfo(bars, barLines, meter, {
358
- divideBarsBy: 2,
382
+ divideBarsBy: 2
359
383
  });
360
384
 
361
385
  const { midpoints } = barInfo;
@@ -382,11 +406,13 @@ function toggleMeter_4_4_to_4_2(abc, currentMeter) {
382
406
  }
383
407
 
384
408
  const defaultCommentForReelConversion =
385
- "*abc-tools: convert reel to M:4/4 & L:1/16*";
409
+ "*abc-tools: convert to M:4/4 & L:1/16*";
410
+ const defaultCommentForHornpipeConversion = "*abc-tools: convert to M:4/2*";
411
+ const defaultCommentForJigConversion = "*abc-tools: convert to M:12/8*";
386
412
  /**
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.
413
+ * Adjusts bar lengths and L, M fields - a
414
+ * reel written in the normal way (M:4/4 L:1/8) is written
415
+ * written with M:4/4 and L:1/16 (or M:4/2 and L:1/8, if withSemiquavers is unflagged)
390
416
  * Bars are twice as long, and the quick notes are semiquavers
391
417
  * rather than quavers.
392
418
  * @param {string} reel
@@ -420,6 +446,56 @@ function convertStandardReel(
420
446
  return result;
421
447
  }
422
448
 
449
+ /**
450
+ * Adjusts bar lengths and M field to alter a
451
+ * jig written in the normal way (M:6/8) so it’s
452
+ * written with M:12/8.
453
+ * Bars are twice as long.
454
+ * @param {string} jig
455
+ * @param {string} comment - when non falsey, the comment will be injected as an N: header
456
+
457
+ * @returns
458
+ */
459
+ function convertStandardJig(jig, comment = defaultCommentForJigConversion) {
460
+ const meter = getMeter(jig);
461
+ if (!Array.isArray(meter) || !meter || !meter[0] === 6 || !meter[1] === 8) {
462
+ throw new Error("invalid meter");
463
+ }
464
+
465
+ let result = //toggleMeter_4_4_to_4_2(reel, meter);
466
+ toggleMeterDoubling(jig, [6, 8], [12, 8], meter);
467
+ if (comment) {
468
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
469
+ }
470
+ return result;
471
+ }
472
+
473
+ /**
474
+ * Adjusts bar lengths and M field to alter a
475
+ * hornpipe written in the normal way (M:6/8) so it’s
476
+ * written with M:12/8.
477
+ * Bars are twice as long.
478
+ * @param {string} hornpipe
479
+ * @param {string} comment - when non falsey, the comment will be injected as an N: header
480
+
481
+ * @returns
482
+ */
483
+ function convertStandardHornpipe(
484
+ hornpipe,
485
+ comment = defaultCommentForHornpipeConversion
486
+ ) {
487
+ const meter = getMeter(hornpipe);
488
+ if (!Array.isArray(meter) || !meter || !meter[0] === 4 || !meter[1] === 4) {
489
+ throw new Error("invalid meter");
490
+ }
491
+
492
+ let result = toggleMeter_4_4_to_4_2(hornpipe, meter);
493
+
494
+ if (comment) {
495
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
496
+ }
497
+ return result;
498
+ }
423
499
  function doubleBarLength(abc, comment = null) {
424
500
  const meter = getMeter(abc);
425
501
  if (!Array.isArray(meter) || !meter) {
@@ -457,7 +533,9 @@ function convertToStandardReel(
457
533
  withSemiquavers = true
458
534
  ) {
459
535
  if (withSemiquavers) {
460
- reel = reel.replace("M:4/4", "M:4/2").reel("L:1/16", "L:1/8");
536
+ reel = reel
537
+ .replace(/\nM:\s*4\/4/, "\nM:4/2")
538
+ .replace(/\nL:\s*1\/16/, "\nL:1/8");
461
539
  }
462
540
 
463
541
  const unitLength = getUnitLength(reel);
@@ -476,6 +554,51 @@ function convertToStandardReel(
476
554
  }
477
555
  return result;
478
556
  }
557
+ /**
558
+ * Adjusts bar lengths to rewrite a
559
+ * jig written in the abnormal way (M:12/8) to M:6/8, the normal or standard way.
560
+ * Bars are half as long. Inverse operation to convertStandardJig
561
+ * @param {string} jig
562
+ * @param {string} comment - when non falsey, the comment (as an N:) will removed from the header
563
+ * @param {bool} withSemiquavers - when unflagged, the original jig was written in M:4/2 L:1/8
564
+ * @returns
565
+ */
566
+ function convertToStandardJig(jig, comment = defaultCommentForJigConversion) {
567
+ const unitLength = getUnitLength(jig);
568
+ if (unitLength.den !== 8) {
569
+ throw new Error("invalid L header");
570
+ }
571
+ const meter = getMeter(jig);
572
+ if (!Array.isArray(meter) || !meter || !meter[0] === 12 || !meter[1] === 8) {
573
+ throw new Error("invalid meter");
574
+ }
575
+
576
+ let result = toggleMeter_6_8_to_12_8(jig); // toggleMeter_4_4_to_4_2(jig, meter);
577
+ if (comment) {
578
+ result = result.replace(`\nN:${comment}`, "");
579
+ }
580
+ return result;
581
+ }
582
+
583
+ function convertToStandardHornpipe(
584
+ hornpipe,
585
+ comment = defaultCommentForHornpipeConversion
586
+ ) {
587
+ const unitLength = getUnitLength(hornpipe);
588
+ if (unitLength.den !== 8) {
589
+ throw new Error("invalid L header");
590
+ }
591
+ const meter = getMeter(hornpipe);
592
+ if (!Array.isArray(meter) || !meter || !meter[0] === 4 || !meter[1] === 2) {
593
+ throw new Error("invalid meter");
594
+ }
595
+
596
+ let result = toggleMeter_4_4_to_4_2(hornpipe); // toggleMeter_4_4_to_4_2(jig, meter);
597
+ if (comment) {
598
+ result = result.replace(`\nN:${comment}`, "");
599
+ }
600
+ return result;
601
+ }
479
602
 
480
603
  /**
481
604
  * Toggle between M:6/8 and M:12/8 by surgically adding/removing bar lines
@@ -528,7 +651,7 @@ function getFirstBars(
528
651
  barNumbers: true,
529
652
  isPartial: true,
530
653
  cumulativeDuration: true,
531
- stopAfterBarNumber,
654
+ stopAfterBarNumber
532
655
  });
533
656
 
534
657
  const enrichedBarLines = barInfo.barLines;
@@ -692,8 +815,72 @@ function getFirstBars(
692
815
  )}`;
693
816
  }
694
817
 
818
+ function canDoubleBarLength(abc) {
819
+ const meter = getMeter(abc),
820
+ l = getUnitLength(abc),
821
+ rhythm = getHeaderValue(abc, "R");
822
+ if (
823
+ !rhythm ||
824
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
825
+ ) {
826
+ return false;
827
+ }
828
+ return (
829
+ !abc.match(/\[M:/) && //inline meter marking
830
+ !abc.match(/\[L:/) &&
831
+ (((rhythm === "reel" || rhythm === "hornpipe") &&
832
+ l.equals(new Fraction(1, 8)) &&
833
+ meter[0] === 4 &&
834
+ meter[1] === 4) ||
835
+ (rhythm === "jig" &&
836
+ l.equals(new Fraction(1, 8)) &&
837
+ meter[0] === 6 &&
838
+ meter[1] === 8))
839
+ );
840
+ }
841
+ function canHalveBarLength(abc) {
842
+ const meter = getMeter(abc),
843
+ l = getUnitLength(abc),
844
+ rhythm = getHeaderValue(abc, "R");
845
+ if (
846
+ !rhythm ||
847
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
848
+ ) {
849
+ return false;
850
+ }
851
+
852
+ if (
853
+ !rhythm ||
854
+ ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
855
+ ) {
856
+ return false;
857
+ }
858
+ return (
859
+ !abc.match(/\[M:/) && //inline meter marking
860
+ !abc.match(/\[L:/) &&
861
+ ((rhythm === "reel" &&
862
+ l.equals(new Fraction(1, 16)) &&
863
+ meter[0] === 4 &&
864
+ meter[1] === 4) ||
865
+ ((rhythm === "reel" || rhythm === "hornpipe") &&
866
+ l.equals(new Fraction(1, 8)) &&
867
+ meter[0] === 4 &&
868
+ meter[1] === 2) ||
869
+ (rhythm === "jig" &&
870
+ l.equals(new Fraction(1, 8)) &&
871
+ meter[0] === 12 &&
872
+ meter[1] === 8))
873
+ );
874
+ }
875
+
695
876
  module.exports = {
877
+ canDoubleBarLength,
878
+ canHalveBarLength,
879
+ convertStandardJig,
880
+ convertStandardHornpipe,
696
881
  convertStandardReel,
882
+ convertToStandardJig,
883
+ convertToStandardHornpipe,
697
884
  convertToStandardReel,
698
885
  defaultCommentForReelConversion,
699
886
  doubleBarLength,
@@ -703,5 +890,5 @@ module.exports = {
703
890
  normaliseKey,
704
891
  toggleMeter_4_4_to_4_2,
705
892
  toggleMeter_6_8_to_12_8,
706
- toggleMeterDoubling,
893
+ toggleMeterDoubling
707
894
  };
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
  };
@@ -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
  };
@@ -181,8 +181,8 @@ function getAbcForContour_default(tune) {
181
181
  tune.incipit
182
182
  ? tune.incipit
183
183
  : Array.isArray(tune.abc)
184
- ? tune.abc[0]
185
- : tune.abc
184
+ ? tune.abc[0]
185
+ : tune.abc
186
186
  );
187
187
  }
188
188
 
@@ -200,13 +200,13 @@ function sort(arr, options = {}) {
200
200
  comparable = [
201
201
  ["jig", "slide", "single jig", "double jig"],
202
202
  ["reel", "single reel", "reel (single)", "strathspey", "double reel"],
203
- ["hornpipe", "barndance", "fling"],
203
+ ["hornpipe", "barndance", "fling"]
204
204
  ],
205
205
  applySwingTransform = ["hornpipe", "barndance", "fling", "mazurka"],
206
206
  getAbc: getAbcForContour = getAbcForContour_default,
207
207
  getContourOptions = {
208
- withSvg: true,
209
- },
208
+ withSvg: true
209
+ }
210
210
  } = options;
211
211
 
212
212
  const comparableMap = {};
@@ -245,16 +245,16 @@ function sort(arr, options = {}) {
245
245
  ? -1
246
246
  : 1
247
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;
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
258
  return comparison;
259
259
  });
260
260
  arr.forEach((t) => delete t.baseRhythm);
@@ -296,5 +296,5 @@ module.exports = {
296
296
  compare,
297
297
  sort,
298
298
  simpleSort,
299
- decodeChar,
299
+ decodeChar
300
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 < -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
  };