@goplayerjuggler/abc-tools 1.0.11 → 1.0.12

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.11",
3
+ "version": "1.0.12",
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": {
package/src/incipit.js CHANGED
@@ -308,7 +308,7 @@ function getIncipit(data) {
308
308
 
309
309
  function getIncipitForContourGeneration(
310
310
  abc,
311
- { numBars = new Fraction(3, 2) } = {}
311
+ { numBars = new Fraction(2, 1) } = {}
312
312
  ) {
313
313
  return getIncipit({
314
314
  abc,
package/src/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  const parser = require("./parse/parser.js");
7
+ const miscParser = require("./parse/misc-parser.js");
7
8
  const manipulator = require("./manipulator.js");
8
9
  const sort = require("./sort/contour-sort.js");
9
10
  const contourToSvg = require("./sort/contour-svg.js");
@@ -12,10 +13,12 @@ const displayContour = require("./sort/display-contour.js");
12
13
  const incipit = require("./incipit.js");
13
14
  const javascriptify = require("./javascriptify.js");
14
15
  const getContour = require("./sort/get-contour.js");
16
+ const math = require("./math.js");
15
17
 
16
18
  module.exports = {
17
19
  // Parser functions
18
20
  ...parser,
21
+ ...miscParser,
19
22
 
20
23
  // Manipulator functions
21
24
  ...manipulator,
@@ -29,6 +32,7 @@ module.exports = {
29
32
  // Incipit functions
30
33
  ...incipit,
31
34
 
32
- //
35
+ // other
33
36
  javascriptify,
37
+ ...math,
34
38
  };
@@ -156,7 +156,7 @@ function hasAnacrucis(abc) {
156
156
  * @param {Array<number>} smallMeter - The smaller meter signature [numerator, denominator]
157
157
  * @param {Array<number>} largeMeter - The larger meter signature [numerator, denominator]
158
158
  * @returns {string} ABC notation with toggled meter
159
- * @throws {Error} If the current meter doesn't match either smallMeter or largeMeter
159
+ * @throws {Error} If the current meter doesnt match either smallMeter or largeMeter
160
160
  */
161
161
  function toggleMeterDoubling(abc, smallMeter, largeMeter) {
162
162
  const currentMeter = getMeter(abc);
@@ -307,7 +307,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
307
307
  decision.action === "remove" &&
308
308
  !decision.variantToken
309
309
  ) {
310
- // Remove bar line and ensure there's a space
310
+ // Remove bar line and ensure theres a space
311
311
  // Check if we already added a space (last char in newMusic)
312
312
  const needsSpace =
313
313
  newMusic.length === 0 || newMusic[newMusic.length - 1] !== " ";
@@ -393,7 +393,7 @@ function getFirstBars(
393
393
  countAnacrucisInTotal = false,
394
394
  headersToStrip
395
395
  ) {
396
- // Convert numBars to Fraction if it's a number
396
+ // Convert numBars to Fraction if its a number
397
397
  const numBarsFraction =
398
398
  typeof numBars === "number" ? new Fraction(numBars) : numBars;
399
399
 
@@ -423,7 +423,7 @@ function getFirstBars(
423
423
 
424
424
  const enrichedBarLines = barInfo.barLines;
425
425
 
426
- // Detect if there's an anacrusis
426
+ // Detect if theres an anacrusis
427
427
  const firstNumberedBarLine = enrichedBarLines.find(
428
428
  (bl) => bl.barNumber !== null
429
429
  );
@@ -439,10 +439,10 @@ function getFirstBars(
439
439
  const expectedBarDuration = new Fraction(meter[0], meter[1]);
440
440
  const targetDuration = expectedBarDuration.multiply(numBarsFraction);
441
441
 
442
- // Determine starting position
442
+ // Determine starting position and how much duration we need to accumulate
443
443
  let startPos = 0;
444
- let targetBarNumber = 0;
445
- let durationToAccumulate = targetDuration.clone();
444
+ let remainingDurationNeeded = targetDuration.clone();
445
+ let countingFromBarNumber = 0;
446
446
 
447
447
  if (hasPickup) {
448
448
  if (withAnacrucis) {
@@ -456,13 +456,13 @@ function getFirstBars(
456
456
  if (anacrusisBarLine && anacrusisBarLine.cumulativeDuration) {
457
457
  const anacrusisDuration =
458
458
  anacrusisBarLine.cumulativeDuration.sinceLastBarLine;
459
- durationToAccumulate =
460
- durationToAccumulate.subtract(anacrusisDuration);
459
+ remainingDurationNeeded =
460
+ remainingDurationNeeded.subtract(anacrusisDuration);
461
461
  }
462
- targetBarNumber = 1; // Start counting from first complete bar
462
+ countingFromBarNumber = 1; // Start counting from first complete bar
463
463
  } else {
464
- // Don't count anacrusis - we want full numBars after it
465
- targetBarNumber = 1;
464
+ // Dont count anacrusis - we want full numBars after it
465
+ countingFromBarNumber = 1;
466
466
  }
467
467
  } else {
468
468
  // Skip anacrusis - start after its bar line
@@ -473,42 +473,43 @@ function getFirstBars(
473
473
  const anacrusisBarLine = enrichedBarLines[anacrusisBarLineIdx];
474
474
  startPos = anacrusisBarLine.sourceIndex + anacrusisBarLine.sourceLength;
475
475
  }
476
- targetBarNumber = 1;
476
+ countingFromBarNumber = 1;
477
477
  }
478
478
  } else {
479
479
  // No anacrusis
480
480
  startPos = 0;
481
- targetBarNumber = 0;
481
+ countingFromBarNumber = 0;
482
482
  }
483
483
 
484
- // Find the target bar number we need to reach
485
- const isWholeBars = numBarsFraction.den === 1;
486
- let finalTargetBarNumber;
484
+ // Determine which bar numbers we need based on remaining duration
485
+ const wholeBarsInRemaining = Math.floor(
486
+ remainingDurationNeeded.divide(expectedBarDuration).toNumber()
487
+ );
488
+ const fractionalPart = remainingDurationNeeded.subtract(
489
+ expectedBarDuration.multiply(new Fraction(wholeBarsInRemaining, 1))
490
+ );
487
491
 
488
- if (isWholeBars) {
489
- // For whole bars, we want bars numbered targetBarNumber through targetBarNumber + numBars - 1
490
- finalTargetBarNumber = targetBarNumber + numBarsFraction.num - 1;
491
- } else {
492
- // For fractional bars, we need to go into the next bar
493
- finalTargetBarNumber =
494
- targetBarNumber + Math.floor(numBarsFraction.toNumber());
495
- }
492
+ const needsFractionalBar = fractionalPart.compare(new Fraction(0, 1)) > 0;
493
+ const finalTargetBarNumber =
494
+ countingFromBarNumber +
495
+ wholeBarsInRemaining +
496
+ (needsFractionalBar ? 0 : -1);
496
497
 
497
498
  // Find end position
498
499
  let endPos = startPos;
499
500
  let foundEnd = false;
500
501
 
501
- // First, find all bar lines with our target bar numbers
502
- const targetBarLines = enrichedBarLines.filter(
502
+ // Find all bar lines with our target bar numbers
503
+ const relevantBarLines = enrichedBarLines.filter(
503
504
  (bl) =>
504
505
  bl.barNumber !== null &&
505
- bl.barNumber >= targetBarNumber &&
506
+ bl.barNumber >= countingFromBarNumber &&
506
507
  bl.barNumber <= finalTargetBarNumber
507
508
  );
508
509
 
509
- if (isWholeBars) {
510
+ if (!needsFractionalBar) {
510
511
  // For whole bars, find the bar line at finalTargetBarNumber
511
- const finalBarLine = targetBarLines.find(
512
+ const finalBarLine = relevantBarLines.find(
512
513
  (bl) => bl.barNumber === finalTargetBarNumber
513
514
  );
514
515
  if (finalBarLine) {
@@ -516,58 +517,60 @@ function getFirstBars(
516
517
  foundEnd = true;
517
518
  }
518
519
  } else {
519
- // For fractional bars, we need to do duration counting within the final bar
520
- const fractionalPart = numBarsFraction.subtract(
521
- new Fraction(Math.floor(numBarsFraction.toNumber()), 1)
522
- );
523
- const fractionalDuration = expectedBarDuration.multiply(fractionalPart);
520
+ // For fractional bars, we need to do duration counting within bars with finalTargetBarNumber
521
+ // Note: there may be multiple segments with the same barNumber (partial bars)
524
522
 
525
- // Find all bars with finalTargetBarNumber
526
- const finalBarIndex = bars.findIndex((bar, idx) => {
527
- const correspondingBarLineIdx = enrichedBarLines.findIndex(
528
- (bl) => bl.sourceIndex > (bar[0]?.sourceIndex || 0)
529
- );
530
- if (correspondingBarLineIdx >= 0) {
531
- return (
532
- enrichedBarLines[correspondingBarLineIdx].barNumber ===
533
- finalTargetBarNumber
534
- );
535
- }
536
- return false;
537
- });
523
+ let accumulated = new Fraction(0, 1);
538
524
 
539
- if (finalBarIndex >= 0) {
540
- const finalBar = bars[finalBarIndex];
541
- let accumulated = new Fraction(0, 1);
525
+ // Iterate through bars and accumulate duration for bars with finalTargetBarNumber
526
+ for (let i = 0; i < bars.length; i++) {
527
+ const bar = bars[i];
528
+ if (bar.length === 0) continue;
542
529
 
543
- for (const token of finalBar) {
544
- if (!token.duration) {
545
- continue;
546
- }
530
+ // Find the bar line that follows this bar to get its barNumber
531
+ const barLineIdx = enrichedBarLines.findIndex(
532
+ (bl) => bl.sourceIndex >= bar[bar.length - 1].sourceIndex
533
+ );
547
534
 
548
- accumulated = accumulated.add(token.duration);
535
+ if (barLineIdx < 0) continue;
549
536
 
550
- if (accumulated.compare(fractionalDuration) >= 0) {
551
- // Found the position
552
- endPos = token.sourceIndex + token.sourceLength;
537
+ const barLine = enrichedBarLines[barLineIdx];
553
538
 
554
- // Skip trailing space if present
555
- if (
556
- token.spacing &&
557
- token.spacing.whitespace &&
558
- endPos < musicText.length &&
559
- musicText[endPos] === " "
560
- ) {
561
- endPos++;
539
+ if (barLine.barNumber === finalTargetBarNumber) {
540
+ // This bar segment is part of our target bar number
541
+ for (const token of bar) {
542
+ if (!token.duration) {
543
+ continue;
562
544
  }
563
- foundEnd = true;
564
- break;
545
+
546
+ const newAccumulated = accumulated.add(token.duration);
547
+
548
+ if (newAccumulated.compare(fractionalPart) >= 0) {
549
+ // Found the position - include this token
550
+ endPos = token.sourceIndex + token.sourceLength;
551
+
552
+ // Skip trailing space if present
553
+ if (
554
+ token.spacing &&
555
+ token.spacing.whitespace &&
556
+ endPos < musicText.length &&
557
+ musicText[endPos] === " "
558
+ ) {
559
+ endPos++;
560
+ }
561
+ foundEnd = true;
562
+ break;
563
+ }
564
+
565
+ accumulated = newAccumulated;
565
566
  }
567
+
568
+ if (foundEnd) break;
566
569
  }
567
570
  }
568
571
  }
569
572
 
570
- // Fallback: if we didn't find an end, use the last available position
573
+ // Fallback: if we didnt find an end, use the last available position
571
574
  if (!foundEnd || endPos === startPos) {
572
575
  endPos = musicText.length;
573
576
  }
@@ -24,6 +24,10 @@ const { Fraction } = require("../math.js");
24
24
  * All bars at the same position across different variant endings share the same barNumber
25
25
  * but have different variantId values (0 for first ending, 1 for second, etc.).
26
26
  *
27
+ * Meter and unit length changes: When a barline has newMeter or newUnitLength fields (from inline
28
+ * field changes), the new meter/length is applied to the following bar. The fullBarDuration is
29
+ * recalculated accordingly. Partial bar accumulation continues under the new meter.
30
+ *
27
31
  * IMPORTANT: The music segment associated with a bar line is the segment PRECEDING the bar line.
28
32
  * Example: In `A4|B4||c2d2|]`, the segment for `|` is `A4`, for `||` is `B4`, and for `|]` is `c2d2`.
29
33
  *
@@ -47,7 +51,12 @@ const { Fraction } = require("../math.js");
47
51
  * - After [1D2: isPartial: true, completesMusicBar: undefined (only 2 beats)
48
52
  * - After [2DF: isPartial: true, completesMusicBar: undefined (only 2 beats)
49
53
  *
50
- * Not handled: Change of meter (inline M: fields).
54
+ * Example 4: `C4|[M:3/4]D3|E3|` (M:4/4 3/4, L:1/4)
55
+ * - After C4: barNumber: 0 (complete 4/4 bar)
56
+ * - After D3: barNumber: 1 (complete 3/4 bar under new meter)
57
+ * - After E3: barNumber: 2 (complete 3/4 bar)
58
+ *
59
+ * Not handled: Change of meter or unit length mid-bar (inline M: or L: fields within a bar).
51
60
  *
52
61
  * @param {Array<Array<Object>>} bars - Array of bar arrays from parseAbc
53
62
  * @param {Array<Object>} barLines - Array of barLine objects from parseAbc
@@ -72,13 +81,10 @@ function getBarInfo(bars, barLines, meter, options = {}) {
72
81
  if (divideBarsBy !== null && divideBarsBy !== 2) {
73
82
  throw new Error("divideBarsBy currently only supports value 2");
74
83
  }
75
- if (!barLines || barLines.length < bars.length) {
76
- throw new Error(
77
- "currently not handling bars without a bar line at the end"
78
- );
79
- }
80
84
 
81
- const fullBarDuration = new Fraction(meter[0], meter[1]);
85
+ // Track current meter (can change via inline fields)
86
+ let currentMeter = [...meter];
87
+ let fullBarDuration = new Fraction(currentMeter[0], currentMeter[1]);
82
88
  const midpoints = [];
83
89
 
84
90
  let currentBarNumber = 0;
@@ -112,6 +118,19 @@ function getBarInfo(bars, barLines, meter, options = {}) {
112
118
  const bar = bars[barIdx];
113
119
  const barLineIdx = barIdx + barLineOffset;
114
120
 
121
+ // Check if the previous barline had a meter or unit length change
122
+ if (barLineIdx > 0) {
123
+ const prevBarLine = barLines[barLineIdx - 1];
124
+
125
+ if (prevBarLine.newMeter) {
126
+ currentMeter = [...prevBarLine.newMeter];
127
+ fullBarDuration = new Fraction(currentMeter[0], currentMeter[1]);
128
+ }
129
+
130
+ // Note: newUnitLength doesn't directly affect fullBarDuration
131
+ // (token durations are already calculated in the correct units)
132
+ }
133
+
115
134
  // Calculate duration of this bar segment
116
135
  // Process tokens and handle variant endings during traversal
117
136
  let barDuration = new Fraction(0, 1);
@@ -130,15 +149,16 @@ function getBarInfo(bars, barLines, meter, options = {}) {
130
149
  if (!inVariantGroup) {
131
150
  // Starting a new variant group - store state including duration accumulated up to this point
132
151
  // Add current barDuration to get the state before this variant token
133
- const durationBeforeVariant = durationSinceLastComplete.add(
134
- barDuration.subtract(token.duration || new Fraction(0, 1))
135
- );
152
+ const durationBeforeVariant =
153
+ durationSinceLastComplete.add(barDuration);
136
154
 
137
155
  inVariantGroup = true;
138
156
  variantBranchPoint = {
139
157
  barNumber: currentBarNumber,
140
158
  durationSinceLastComplete: durationBeforeVariant.clone(),
141
159
  lastCompleteBarLineIdx: lastCompleteBarLineIdx,
160
+ meter: [...currentMeter],
161
+ fullBarDuration: fullBarDuration.clone(),
142
162
  };
143
163
  variantCounter = 0;
144
164
  currentVariantId = variantCounter;
@@ -151,6 +171,8 @@ function getBarInfo(bars, barLines, meter, options = {}) {
151
171
  durationSinceLastComplete =
152
172
  variantBranchPoint.durationSinceLastComplete.clone();
153
173
  lastCompleteBarLineIdx = variantBranchPoint.lastCompleteBarLineIdx;
174
+ currentMeter = [...variantBranchPoint.meter];
175
+ fullBarDuration = variantBranchPoint.fullBarDuration.clone();
154
176
  // Discard barDuration accumulated so far (it's from the wrong variant path)
155
177
  barDuration = token.duration
156
178
  ? token.duration.clone()
@@ -291,9 +313,25 @@ function getBarInfo(bars, barLines, meter, options = {}) {
291
313
 
292
314
  // Calculate midpoints if requested
293
315
  if (divideBarsBy === 2) {
294
- const halfBarDuration = fullBarDuration.divide(new Fraction(2, 1));
316
+ // Track meter for midpoint calculation
317
+ let midpointMeter = [...meter];
318
+ let halfBarDuration = new Fraction(
319
+ midpointMeter[0],
320
+ midpointMeter[1]
321
+ ).divide(new Fraction(2, 1));
295
322
 
296
323
  for (let barIdx = 0; barIdx < bars.length; barIdx++) {
324
+ const barLineIdx = barIdx + barLineOffset;
325
+
326
+ // Update meter if previous barline had a change
327
+ if (barLineIdx > 0 && barLines[barLineIdx - 1].newMeter) {
328
+ midpointMeter = [...barLines[barLineIdx - 1].newMeter];
329
+ halfBarDuration = new Fraction(
330
+ midpointMeter[0],
331
+ midpointMeter[1]
332
+ ).divide(new Fraction(2, 1));
333
+ }
334
+
297
335
  const bar = bars[barIdx];
298
336
  let accumulated = new Fraction(0, 1);
299
337
 
@@ -28,10 +28,10 @@ function getTonalBase(abc) {
28
28
  }
29
29
 
30
30
  /**
31
- * Extract key signature from ABC header
31
+ * Extract full key signature from ABC header
32
32
  *
33
33
  * @param {string} abc - ABC notation string
34
- * @returns {string} - Tonic note (e.g., 'C', 'D', 'G')
34
+ * @returns {string} - Key (e.g., 'C dorian', 'D mixo', 'G')
35
35
  * @throws {Error} - If no key signature found
36
36
  */
37
37
  function getKey(abc) {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Analyze whitespace and back quotes after a token
3
+ * Returns object describing the spacing/beaming context
4
+ * Back quotes (`) are ignored for beaming but preserved for reconstruction
5
+ *
6
+ * @param {string} segment - The music segment to analyze
7
+ * @param {number} tokenEndPos - Position where the token ends
8
+ * @returns {object} - Spacing analysis object
9
+ *
10
+ * Return object structure:
11
+ * {
12
+ * whitespace: string, // Actual whitespace characters (back quotes removed)
13
+ * backquotes: number, // Number of ` characters for reconstruction
14
+ * beamBreak: boolean, // True if beam should break (multiple spaces/newline)
15
+ * lineBreak: boolean // True if there was a newline after this token
16
+ * }
17
+ */
18
+ function analyzeSpacing(segment, tokenEndPos) {
19
+ if (tokenEndPos >= segment.length) {
20
+ return {
21
+ whitespace: "",
22
+ backquotes: 0,
23
+ beamBreak: false,
24
+ lineBreak: false,
25
+ };
26
+ }
27
+
28
+ const remaining = segment.substring(tokenEndPos);
29
+
30
+ // Match whitespace and/or back quotes
31
+ const spacingMatch = remaining.match(/^([\s`]+)/);
32
+
33
+ if (!spacingMatch) {
34
+ return {
35
+ whitespace: "",
36
+ backquotes: 0,
37
+ beamBreak: false,
38
+ lineBreak: false,
39
+ };
40
+ }
41
+
42
+ const fullSpacing = spacingMatch[1];
43
+
44
+ // Count back quotes
45
+ const backquotes = (fullSpacing.match(/`/g) || []).length;
46
+
47
+ // Extract just whitespace (no back quotes)
48
+ const whitespace = fullSpacing.replace(/`/g, "");
49
+
50
+ return {
51
+ whitespace,
52
+ backquotes,
53
+ beamBreak: whitespace.length > 1 || whitespace.includes("\n"), // Multiple spaces or newline breaks beam
54
+ lineBreak: whitespace.includes("\n"),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Parse tuplet notation from a token
60
+ *
61
+ * @param {string} token - Tuplet token (e.g., '(3', '(3:2', '(3:2:4')
62
+ * @param {boolean} isCompoundTimeSignature - Whether current time signature is compound (affects default q value)
63
+ * @returns {object|null} - { isTuple: true, p, q, r } or null if not a valid tuplet
64
+ */
65
+ function parseTuplet(token, isCompoundTimeSignature) {
66
+ const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
67
+ if (tupleMatch) {
68
+ const pqr = {
69
+ p: parseInt(tupleMatch[1]),
70
+ q: tupleMatch[2],
71
+ r: tupleMatch[3],
72
+ };
73
+ const { p } = pqr;
74
+ let { q, r } = pqr;
75
+ if (q) {
76
+ q = parseInt(q);
77
+ } else {
78
+ switch (p) {
79
+ case 2:
80
+ q = 3;
81
+ break;
82
+ case 3:
83
+ q = 2;
84
+ break;
85
+ case 4:
86
+ q = 3;
87
+ break;
88
+ case 5:
89
+ case 7:
90
+ case 9:
91
+ q = isCompoundTimeSignature ? 3 : 2;
92
+ break;
93
+ case 6:
94
+ q = 2;
95
+ break;
96
+ case 8:
97
+ q = 3;
98
+ break;
99
+ }
100
+ }
101
+ if (r) {
102
+ r = parseInt(r);
103
+ } else {
104
+ r = p;
105
+ }
106
+ return {
107
+ isTuple: true,
108
+ p,
109
+ q,
110
+ r,
111
+ };
112
+ }
113
+ return null;
114
+ }
115
+ module.exports = {
116
+ analyzeSpacing,
117
+ parseTuplet,
118
+ };