@goplayerjuggler/abc-tools 1.0.18 → 1.0.20

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.18",
3
+ "version": "1.0.20",
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": {
@@ -4,6 +4,14 @@ const { parseAbc, getMeter, getUnitLength } = require("./parse/parser.js");
4
4
  const { getBarInfo } = require("./parse/getBarInfo.js");
5
5
  const { getHeaderValue } = require("./parse/header-parser.js");
6
6
 
7
+ const {
8
+ getKeySignatureAccidentals,
9
+ getBarAccidentals,
10
+ addAccidentalsForMergedBar,
11
+ removeRedundantAccidentals,
12
+ reconstructMusicFromTokens
13
+ } = require("./parse/accidental-helpers.js");
14
+
7
15
  // ============================================================================
8
16
  // ABC manipulation functions
9
17
  // ============================================================================
@@ -146,22 +154,25 @@ function hasAnacrucis(abc) {
146
154
  * - Converts variant ending markers from |1, |2 to [1, [2 format
147
155
  * - Respects section breaks (||, :|, etc.) and resets pairing after them
148
156
  * - Handles bars starting with variant endings by keeping the bar line after them
157
+ * - Adds accidentals when merging bars to restore key signature defaults
149
158
  *
150
159
  * When going from large to small meters (e.g., 4/2→4/4):
151
160
  * - Inserts bar lines at halfway points within each bar
152
161
  * - Preserves variant ending markers in [1, [2 format (does not convert back to |1, |2)
153
162
  * - Inserts bar lines before variant endings when they occur at the split point
163
+ * - Removes redundant accidentals in the second half of split bars
154
164
  *
155
165
  * This is nearly a true inverse operation - going there and back preserves musical content
156
166
  * but may change spacing around bar lines and normalises variant ending syntax to [1, [2 format.
157
- * Correctly handles anacrusis (pickup bars), multi-bar variant endings, partial bars, and preserves line breaks.
167
+ * Correctly handles anacrusis (pickup bars), multi-bar variant endings, partial bars, preserves line breaks,
168
+ * and manages accidentals correctly when bars are merged or split.
158
169
  *
159
170
  * @param {string} abc - ABC notation string
160
171
  * @param {Array<number>} smallMeter - The smaller meter signature [numerator, denominator]
161
172
  * @param {Array<number>} largeMeter - The larger meter signature [numerator, denominator]
162
173
  * @param {Array<number>} currentMeter - The current meter signature [numerator, denominator] of the abc tune - may be omitted. (If omitted, it gets fetched from `abc`)
163
174
  * @returns {string} ABC notation with toggled meter
164
- * @throws {Error} If the current meter doesnt match either smallMeter or largeMeter
175
+ * @throws {Error} If the current meter doesn't match either smallMeter or largeMeter
165
176
  */
166
177
  function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
167
178
  if (!currentMeter) currentMeter = getMeter(abc);
@@ -197,6 +208,11 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
197
208
 
198
209
  if (isSmall) {
199
210
  // Going from small to large: remove every other complete musical bar line
211
+
212
+ // Get initial key and build key map
213
+ const initialKey = getHeaderValue(abc, "K");
214
+ const keyAtBar = getKeyAtEachBar(barLines, initialKey);
215
+
200
216
  // Get bar info to understand musical structure
201
217
  getBarInfo(bars, barLines, meter, {
202
218
  barNumbers: true,
@@ -215,27 +231,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
215
231
  const barLineDecisions = new Map();
216
232
  const barLinesToConvert = new Map(); // variant markers to convert from |N to [N
217
233
 
218
- //Discarded
219
- // // Map bar numbers to their sequential index among complete bars
220
- // // This handles cases where bar numbers skip (due to anacrusis or partials)
221
- // const completeBarIndexByNumber = new Map();
222
- // let completeBarIndex = 0;
223
-
224
- // for (let i = 0; i < barLines.length; i++) {
225
- // const barLine = barLines[i];
226
- // if (
227
- // barLine.barNumber !== null
228
- // //&& !barLine.isSectionBreak
229
- // ) {
230
- // const isCompleteMusicBar =
231
- // !barLine.isPartial || barLine.completesMusicBar === true;
232
- // if (isCompleteMusicBar) {
233
- // completeBarIndexByNumber.set(barLine.barNumber, completeBarIndex);
234
- // completeBarIndex++;
235
- // }
236
- // }
237
- // }
238
-
239
234
  const hasAnacrucis = hasAnacrucisFromParsed(null, barLines);
240
235
 
241
236
  for (let i = 0; i < barLines.length; i++) {
@@ -256,7 +251,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
256
251
  // Section breaks are always kept
257
252
  if (barLine.isSectionBreak) {
258
253
  barLineDecisions.set(i, { action: "keep" });
259
- // Don't count section breaks - they're structural markers, not part of pairing
260
254
  continue;
261
255
  }
262
256
 
@@ -273,10 +267,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
273
267
  // This is a complete bar - use its barNumber to decide
274
268
  // Without anacrucis: Remove complete bars with even barNumber (0, 2, 4, ...), keep odd ones (1, 3, 5, ...)
275
269
  // With anacrucis: the other way round!
276
-
277
- //Discarded
278
- // const index = completeBarIndexByNumber.get(barLine.barNumber);
279
- // if (index !== undefined && index % 2 === 0) {
280
270
  const remove = hasAnacrucis
281
271
  ? barLine.barNumber % 2 !== 0
282
272
  : barLine.barNumber % 2 === 0;
@@ -305,25 +295,87 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
305
295
  }
306
296
  }
307
297
 
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
- // );
298
+ // === ACCIDENTAL HANDLING ===
299
+ // Build map of bars to replace: bar start position -> {originalEnd, replacementText}
300
+
301
+ const barReplacements = new Map();
302
+
303
+ for (let i = 0; i < barLines.length; i++) {
304
+ const decision = barLineDecisions.get(i);
305
+
306
+ if (decision && decision.action === "remove") {
307
+ // Find which bar comes after this bar line
308
+ const barLineEnd = barLines[i].sourceIndex + barLines[i].sourceLength;
309
+
310
+ let bar2Idx = -1;
311
+ for (let b = 0; b < bars.length; b++) {
312
+ if (bars[b].length > 0 && bars[b][0].sourceIndex >= barLineEnd) {
313
+ bar2Idx = b;
314
+ break;
315
+ }
316
+ }
317
+
318
+ // Find the bar that ends at or before this bar line
319
+ let bar1Idx = -1;
320
+ for (let b = bars.length - 1; b >= 0; b--) {
321
+ if (bars[b].length > 0) {
322
+ const barEnd =
323
+ bars[b][bars[b].length - 1].sourceIndex +
324
+ bars[b][bars[b].length - 1].sourceLength;
325
+ if (barEnd <= barLines[i].sourceIndex) {
326
+ bar1Idx = b;
327
+ break;
328
+ }
329
+ }
330
+ }
331
+
332
+ if (bar1Idx >= 0 && bar2Idx >= 0 && bar2Idx < bars.length) {
333
+ // Get current key
334
+ const currentKey = keyAtBar.get(bar1Idx) || initialKey;
335
+ const keyAccidentals = getKeySignatureAccidentals(
336
+ currentKey,
337
+ normaliseKey
338
+ );
339
+
340
+ // Get accidentals at end of bar 1
341
+ const firstBarAccidentals = getBarAccidentals(
342
+ bars[bar1Idx],
343
+ keyAccidentals
344
+ );
345
+
346
+ // Modify bar 2 tokens
347
+ const modifiedBar2Tokens = addAccidentalsForMergedBar(
348
+ bars[bar2Idx],
349
+ firstBarAccidentals,
350
+ keyAccidentals,
351
+ musicText
352
+ );
353
+
354
+ // Reconstruct bar 2 text
355
+ const bar2Start = bars[bar2Idx][0].sourceIndex;
356
+ const bar2End =
357
+ bars[bar2Idx][bars[bar2Idx].length - 1].sourceIndex +
358
+ bars[bar2Idx][bars[bar2Idx].length - 1].sourceLength;
359
+
360
+ const modifiedText = reconstructMusicFromTokens(
361
+ modifiedBar2Tokens,
362
+ musicText
363
+ );
364
+ // console.log(
365
+ // `Replacement text: "${modifiedText}" (length: ${modifiedText.length})`
366
+ // );
367
+ // console.log(`First char code: ${modifiedText.charCodeAt(0)}`);
368
+
369
+ // Store replacement: when we reach bar2Start, replace until bar2End with modifiedText
370
+ barReplacements.set(bar2Start, {
371
+ originalEnd: bar2End,
372
+ replacementText: modifiedText
373
+ });
374
+ }
375
+ }
376
+ }
377
+
378
+ // === END ACCIDENTAL HANDLING ===
327
379
 
328
380
  // Reconstruct music
329
381
  let newMusic = "";
@@ -361,6 +413,23 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
361
413
  skipLength++;
362
414
  }
363
415
  pos += skipLength;
416
+
417
+ // NOW check if the bar after this removed bar line needs replacement
418
+ if (barReplacements.has(pos)) {
419
+ // const replacement = barReplacements.get(pos);
420
+ // newMusic += replacement.replacementText;
421
+ // pos = replacement.originalEnd;
422
+
423
+ const replacement = barReplacements.get(pos);
424
+ // console.log(`REPLACING: adding "${replacement.replacementText}"`);
425
+ // console.log(
426
+ // `REPLACING: jumping from ${pos} to ${replacement.originalEnd}`
427
+ // );
428
+ newMusic += replacement.replacementText;
429
+ pos = replacement.originalEnd;
430
+ // console.log(`newMusic so far: "${newMusic}"`);
431
+ }
432
+
364
433
  continue;
365
434
  } else if (decision && decision.action === "keep") {
366
435
  // Keep this bar line
@@ -378,17 +447,134 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
378
447
  return `${newHeaders.join("\n")}\n${newMusic}`;
379
448
  } else {
380
449
  // Going from large to small: add bar lines at midpoints
450
+
451
+ // Get initial key and build key map
452
+ const initialKey = getHeaderValue(abc, "K");
453
+ const keyAtBar = getKeyAtEachBar(barLines, initialKey);
454
+
381
455
  const barInfo = getBarInfo(bars, barLines, meter, {
382
456
  divideBarsBy: 2
383
457
  });
384
458
 
385
459
  const { midpoints } = barInfo;
386
460
 
461
+ // === ACCIDENTAL HANDLING ===
462
+ // Build map of bar sections to replace
463
+
464
+ const barReplacements = new Map();
465
+
466
+ for (let i = 0; i < bars.length; i++) {
467
+ const bar = bars[i];
468
+ if (bar.length === 0) continue;
469
+
470
+ const barStart = bar[0].sourceIndex;
471
+ const barEnd =
472
+ bar[bar.length - 1].sourceIndex + bar[bar.length - 1].sourceLength;
473
+ const midpoint = midpoints.find((mp) => mp > barStart && mp < barEnd);
474
+
475
+ if (midpoint) {
476
+ // Get current key
477
+ const currentKey = keyAtBar.get(i) || initialKey;
478
+ const keyAccidentals = getKeySignatureAccidentals(
479
+ currentKey,
480
+ normaliseKey
481
+ );
482
+
483
+ // Split into first and second half
484
+ const firstHalfTokens = bar.filter((t) => t.sourceIndex < midpoint);
485
+ const secondHalfTokens = bar.filter((t) => t.sourceIndex >= midpoint);
486
+
487
+ if (secondHalfTokens.length > 0) {
488
+ // Get accidentals from first half
489
+ const firstHalfAccidentals = getBarAccidentals(
490
+ firstHalfTokens,
491
+ keyAccidentals
492
+ );
493
+
494
+ // Remove redundant accidentals from second half
495
+ const modifiedSecondHalf = removeRedundantAccidentals(
496
+ secondHalfTokens,
497
+ firstHalfAccidentals,
498
+ keyAccidentals
499
+ );
500
+
501
+ // Reconstruct
502
+ const secondHalfStart = secondHalfTokens[0].sourceIndex;
503
+ const secondHalfEnd =
504
+ secondHalfTokens[secondHalfTokens.length - 1].sourceIndex +
505
+ secondHalfTokens[secondHalfTokens.length - 1].sourceLength;
506
+
507
+ const modifiedText = reconstructMusicFromTokens(
508
+ modifiedSecondHalf,
509
+ musicText
510
+ );
511
+
512
+ // console.log(
513
+ // `Replacement text: "${modifiedText}" (length: ${modifiedText.length})`
514
+ // );
515
+ // console.log(`First char code: ${modifiedText.charCodeAt(0)}`);
516
+
517
+ barReplacements.set(secondHalfStart, {
518
+ originalEnd: secondHalfEnd,
519
+ replacementText: modifiedText
520
+ });
521
+ }
522
+ }
523
+ }
524
+
525
+ // Reconstruct music with replacements
526
+ let newMusic = "";
527
+ let pos = 0;
528
+
529
+ while (pos < musicText.length) {
530
+ // Check if we're at a bar section that needs replacement
531
+ if (barReplacements.has(pos)) {
532
+ // const replacement = barReplacements.get(pos);
533
+ // newMusic += replacement.replacementText;
534
+ // pos = replacement.originalEnd;
535
+ // continue;
536
+
537
+ const replacement = barReplacements.get(pos);
538
+ // console.log(`REPLACING: adding "${replacement.replacementText}"`);
539
+ // console.log(
540
+ // `REPLACING: jumping from ${pos} to ${replacement.originalEnd}`
541
+ // );
542
+ newMusic += replacement.replacementText;
543
+ pos = replacement.originalEnd;
544
+ // console.log(`newMusic so far: "${newMusic}"`);
545
+ }
546
+
547
+ // Regular character
548
+ newMusic += musicText[pos];
549
+ pos++;
550
+ }
551
+
552
+ // === END ACCIDENTAL HANDLING ===
553
+
387
554
  // Insert bar lines at calculated positions
388
555
  const insertionPoints = [...midpoints].sort((a, b) => b - a);
389
- let newMusic = musicText;
556
+
557
+ // Adjust positions based on replacements
558
+ const adjustedInsertionPoints = [];
390
559
 
391
560
  for (const pos of insertionPoints) {
561
+ let adjustedPos = pos;
562
+
563
+ // Calculate offset from replacements before this position
564
+ for (const [replStart, replInfo] of barReplacements.entries()) {
565
+ if (replStart < pos) {
566
+ const originalLength = replInfo.originalEnd - replStart;
567
+ const newLength = replInfo.replacementText.length;
568
+ adjustedPos += newLength - originalLength;
569
+ }
570
+ }
571
+
572
+ adjustedInsertionPoints.push(adjustedPos);
573
+ }
574
+
575
+ // Insert bar lines (in reverse order to maintain positions)
576
+ adjustedInsertionPoints.sort((a, b) => b - a);
577
+ for (const pos of adjustedInsertionPoints) {
392
578
  newMusic = newMusic.substring(0, pos) + "| " + newMusic.substring(pos);
393
579
  }
394
580
 
@@ -396,6 +582,32 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
396
582
  }
397
583
  }
398
584
 
585
+ /**
586
+ * Build a map of bar index to active key signature
587
+ * Tracks key changes from barLines[].newKey
588
+ *
589
+ * @param {Array} barLines - Bar line array from parseAbc
590
+ * @param {string} initialKey - Initial K: header value
591
+ * @returns {Map<number, string>} - Map from bar index to key signature string
592
+ */
593
+ function getKeyAtEachBar(barLines, initialKey) {
594
+ const keyMap = new Map();
595
+ let currentKey = initialKey;
596
+
597
+ // Bar index 0 is before the first bar line (or at it if there's an initial bar line)
598
+ keyMap.set(0, currentKey);
599
+
600
+ for (let i = 0; i < barLines.length; i++) {
601
+ if (barLines[i].newKey) {
602
+ currentKey = barLines[i].newKey;
603
+ }
604
+ // The key after this bar line applies to the next bar
605
+ keyMap.set(i + 1, currentKey);
606
+ }
607
+
608
+ return keyMap;
609
+ }
610
+
399
611
  /**
400
612
  * Toggle between M:4/4 and M:4/2 by surgically adding/removing bar lines
401
613
  * This is a true inverse operation - going there and back preserves the ABC exactly
@@ -408,6 +620,7 @@ function toggleMeter_4_4_to_4_2(abc, currentMeter) {
408
620
  const defaultCommentForReelConversion =
409
621
  "*abc-tools: convert to M:4/4 & L:1/16*";
410
622
  const defaultCommentForHornpipeConversion = "*abc-tools: convert to M:4/2*";
623
+ const defaultCommentForPolkaConversion = "*abc-tools: convert to M:4/4*";
411
624
  const defaultCommentForJigConversion = "*abc-tools: convert to M:12/8*";
412
625
  /**
413
626
  * Adjusts bar lengths and L, M fields - a
@@ -470,6 +683,20 @@ function convertStandardJig(jig, comment = defaultCommentForJigConversion) {
470
683
  return result;
471
684
  }
472
685
 
686
+ function convertStandardPolka(t, comment = defaultCommentForPolkaConversion) {
687
+ const meter = getMeter(t);
688
+ if (!Array.isArray(meter) || !meter || !meter[0] === 2 || !meter[1] === 4) {
689
+ throw new Error("invalid meter");
690
+ }
691
+
692
+ let result = //toggleMeter_4_4_to_4_2(reel, meter);
693
+ toggleMeterDoubling(t, [2, 4], [4, 4], meter);
694
+ if (comment) {
695
+ result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
696
+ }
697
+ return result;
698
+ }
699
+
473
700
  /**
474
701
  * Adjusts bar lengths and M field to alter a
475
702
  * hornpipe written in the normal way (M:6/8) so it’s
@@ -580,6 +807,19 @@ function convertToStandardJig(jig, comment = defaultCommentForJigConversion) {
580
807
  return result;
581
808
  }
582
809
 
810
+ function convertToStandardPolka(t, comment = defaultCommentForPolkaConversion) {
811
+ const meter = getMeter(t);
812
+ if (!Array.isArray(meter) || !meter || !meter[0] === 4 || !meter[1] === 4) {
813
+ throw new Error("invalid meter");
814
+ }
815
+
816
+ let result = toggleMeterDoubling(t, [2, 4], [4, 4], meter);
817
+ if (comment) {
818
+ result = result.replace(`\nN:${comment}`, "");
819
+ }
820
+ return result;
821
+ }
822
+
583
823
  function convertToStandardHornpipe(
584
824
  hornpipe,
585
825
  comment = defaultCommentForHornpipeConversion
@@ -821,21 +1061,19 @@ function canDoubleBarLength(abc) {
821
1061
  rhythm = getHeaderValue(abc, "R");
822
1062
  if (
823
1063
  !rhythm ||
824
- ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
1064
+ ["reel", "hornpipe", "jig", "polka"].indexOf(rhythm.toLowerCase()) < 0
825
1065
  ) {
826
1066
  return false;
827
1067
  }
828
1068
  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" &&
1069
+ (!abc.match(/\[M:/) && //inline meter marking
1070
+ !abc.match(/\[L:/) &&
1071
+ (((rhythm === "reel" || rhythm === "hornpipe") &&
836
1072
  l.equals(new Fraction(1, 8)) &&
837
- meter[0] === 6 &&
838
- meter[1] === 8))
1073
+ meter[0] === 4 &&
1074
+ meter[1] === 4) ||
1075
+ (rhythm === "jig" && meter[0] === 6 && meter[1] === 8))) ||
1076
+ (rhythm === "polka" && meter[0] === 2 && meter[1] === 4)
839
1077
  );
840
1078
  }
841
1079
  function canHalveBarLength(abc) {
@@ -844,17 +1082,11 @@ function canHalveBarLength(abc) {
844
1082
  rhythm = getHeaderValue(abc, "R");
845
1083
  if (
846
1084
  !rhythm ||
847
- ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
1085
+ ["reel", "hornpipe", "jig", "polka"].indexOf(rhythm.toLowerCase()) < 0
848
1086
  ) {
849
1087
  return false;
850
1088
  }
851
1089
 
852
- if (
853
- !rhythm ||
854
- ["reel", "hornpipe", "jig"].indexOf(rhythm.toLowerCase()) < 0
855
- ) {
856
- return false;
857
- }
858
1090
  return (
859
1091
  !abc.match(/\[M:/) && //inline meter marking
860
1092
  !abc.match(/\[L:/) &&
@@ -866,10 +1098,8 @@ function canHalveBarLength(abc) {
866
1098
  l.equals(new Fraction(1, 8)) &&
867
1099
  meter[0] === 4 &&
868
1100
  meter[1] === 2) ||
869
- (rhythm === "jig" &&
870
- l.equals(new Fraction(1, 8)) &&
871
- meter[0] === 12 &&
872
- meter[1] === 8))
1101
+ (rhythm === "jig" && meter[0] === 12 && meter[1] === 8) ||
1102
+ (rhythm === "polka" && meter[0] === 4 && meter[1] === 4))
873
1103
  );
874
1104
  }
875
1105
 
@@ -878,9 +1108,11 @@ module.exports = {
878
1108
  canHalveBarLength,
879
1109
  convertStandardJig,
880
1110
  convertStandardHornpipe,
1111
+ convertStandardPolka,
881
1112
  convertStandardReel,
882
1113
  convertToStandardJig,
883
1114
  convertToStandardHornpipe,
1115
+ convertToStandardPolka,
884
1116
  convertToStandardReel,
885
1117
  defaultCommentForReelConversion,
886
1118
  doubleBarLength,
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Accidental handling helpers for toggleMeterDoubling
3
+ * Uses existing parsed token structure from parseAbc
4
+ */
5
+
6
+ /**
7
+ * Extract accidentals implied by a key signature
8
+ * @param {string} keyHeader - The K: header value (e.g., "D dorian", "F# minor")
9
+ * @param {Function} normaliseKey - The normaliseKey function from manipulator.js
10
+ * @returns {Map<string, string>} - Map of note letter to accidental ('^', '_', or null for natural)
11
+ */
12
+ function getKeySignatureAccidentals(keyHeader, normaliseKey) {
13
+ // Parse key using existing normaliseKey
14
+ const parsed = normaliseKey(keyHeader);
15
+ const tonic = parsed[0];
16
+ const mode = parsed[1];
17
+
18
+ // Remove unicode accidentals from tonic to get base note
19
+ const baseNote = tonic.replace(/[♯♭]/g, "");
20
+ const tonicAccidental =
21
+ tonic.length > 1 ? (tonic.includes("♯") ? "^" : "_") : null;
22
+
23
+ // Semitone positions for each natural note
24
+ const noteValues = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
25
+
26
+ // Mode patterns (semitones from tonic)
27
+ const modePatterns = {
28
+ major: [0, 2, 4, 5, 7, 9, 11], // Ionian
29
+ minor: [0, 2, 3, 5, 7, 8, 10], // Aeolian
30
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
31
+ dorian: [0, 2, 3, 5, 7, 9, 10],
32
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
33
+ lydian: [0, 2, 4, 6, 7, 9, 11],
34
+ locrian: [0, 1, 3, 5, 6, 8, 10]
35
+ };
36
+
37
+ const pattern = modePatterns[mode] || modePatterns.major;
38
+ const notes = ["C", "D", "E", "F", "G", "A", "B"];
39
+
40
+ // Calculate tonic's actual semitone position
41
+ let tonicValue = noteValues[baseNote];
42
+ if (tonicAccidental === "^") tonicValue = (tonicValue + 1) % 12;
43
+ if (tonicAccidental === "_") tonicValue = (tonicValue + 11) % 12;
44
+
45
+ // Build the scale and determine accidentals
46
+ const accidentals = new Map();
47
+ for (let i = 0; i < 7; i++) {
48
+ const noteLetter = notes[(notes.indexOf(baseNote) + i) % 7];
49
+ const expectedValue = (tonicValue + pattern[i]) % 12;
50
+ const naturalValue = noteValues[noteLetter];
51
+
52
+ const diff = (expectedValue - naturalValue + 12) % 12;
53
+ if (diff === 1) {
54
+ accidentals.set(noteLetter, "^"); // Sharp
55
+ } else if (diff === 11) {
56
+ accidentals.set(noteLetter, "_"); // Flat
57
+ }
58
+ // diff === 0 means natural (no entry)
59
+ }
60
+
61
+ return accidentals;
62
+ }
63
+
64
+ /**
65
+ * Extract accidental and note information from a parsed note token
66
+ * Handles regular notes and chords in brackets
67
+ *
68
+ * @param {object} token - Parsed token object from parseAbc
69
+ * @returns {Array<object>} - Array of {noteLetter, octaveMarkers, accidental} objects
70
+ */
71
+ function extractNoteInfo(token) {
72
+ if (!token.pitch) return [];
73
+
74
+ const result = [];
75
+
76
+ // Handle chord in brackets [CEG]
77
+ if (token.isChord && token.chordNotes) {
78
+ // Process each note in the chord
79
+ for (const chordNote of token.chordNotes) {
80
+ const info = extractNoteInfo(chordNote);
81
+ result.push(...info);
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // Regular note - extract from the token string
87
+ // Format: [decorations][accidental]pitch[octave][duration][tie]
88
+ // We need to extract the accidental, pitch letter, and octave markers
89
+
90
+ const tokenStr = token.token;
91
+
92
+ // Match pattern: optional accidental (=, ^, _, ^^, __) followed by pitch followed by octave markers
93
+ const noteMatch = tokenStr.match(/(__|_|=|\^\^|\^)?([A-Ga-g])([',]*)/);
94
+
95
+ if (noteMatch) {
96
+ const accidental = noteMatch[1] || null;
97
+ const noteLetter = noteMatch[2];
98
+ const octaveMarkers = noteMatch[3] || "";
99
+
100
+ result.push({
101
+ noteLetter,
102
+ octaveMarkers,
103
+ accidental,
104
+ noteWithOctave: noteLetter + octaveMarkers
105
+ });
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Track accidentals in effect within a bar using parsed tokens
113
+ * @param {Array<object>} barTokens - Array of parsed tokens from a bar
114
+ * @param {Map<string, string>} keyAccidentals - Accidentals from key signature
115
+ * @returns {Map<string, string>} - Map of note (with octave) to its current accidental state
116
+ */
117
+ function getBarAccidentals(barTokens, keyAccidentals) {
118
+ const accidentals = new Map();
119
+
120
+ for (const token of barTokens) {
121
+ // Skip non-note tokens
122
+ if (
123
+ token.isSilence ||
124
+ token.isDummy ||
125
+ token.isInlineField ||
126
+ token.isChordSymbol ||
127
+ token.isTuple ||
128
+ token.isBrokenRhythm ||
129
+ token.isVariantEnding ||
130
+ token.isDecoration ||
131
+ token.isGraceNote
132
+ ) {
133
+ continue;
134
+ }
135
+
136
+ const noteInfos = extractNoteInfo(token);
137
+
138
+ for (const { noteLetter, accidental, noteWithOctave } of noteInfos) {
139
+ const baseNoteLetter = noteLetter.toUpperCase();
140
+
141
+ if (accidental) {
142
+ // Explicit accidental - record it
143
+ accidentals.set(noteWithOctave, accidental);
144
+ } else if (!accidentals.has(noteWithOctave)) {
145
+ // No explicit accidental, use key signature default
146
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
147
+ accidentals.set(noteWithOctave, keyAccidental);
148
+ }
149
+ // If accidental already set for this note in this bar, it carries over
150
+ }
151
+ }
152
+
153
+ return accidentals;
154
+ }
155
+
156
+ /**
157
+ * Add correct accidentals when merging bars
158
+ * Modifies the tokens in the second bar to add accidentals where needed
159
+ *
160
+ * @param {Array<object>} secondBarTokens - Array of parsed tokens from second bar
161
+ * @param {Map<string, string>} firstBarAccidentals - Accidentals in effect from first bar
162
+ * @param {Map<string, string>} keyAccidentals - Accidentals from key signature
163
+ * @param {string} musicText - Original music text for reconstruction
164
+ * @returns {Array<object>} - Modified tokens with accidentals added
165
+ */
166
+ function addAccidentalsForMergedBar(
167
+ secondBarTokens,
168
+ firstBarAccidentals,
169
+ keyAccidentals
170
+ ) {
171
+ const modifiedTokens = [];
172
+ const secondBarAccidentals = new Map();
173
+
174
+ for (const token of secondBarTokens) {
175
+ // Non-note tokens pass through unchanged
176
+ if (
177
+ token.isSilence ||
178
+ token.isDummy ||
179
+ token.isInlineField ||
180
+ token.isChordSymbol ||
181
+ token.isTuple ||
182
+ token.isBrokenRhythm ||
183
+ token.isVariantEnding ||
184
+ token.isDecoration ||
185
+ token.isGraceNote
186
+ ) {
187
+ modifiedTokens.push(token);
188
+ continue;
189
+ }
190
+
191
+ const noteInfos = extractNoteInfo(token);
192
+
193
+ // If no notes extracted, pass through
194
+ if (noteInfos.length === 0) {
195
+ modifiedTokens.push(token);
196
+ continue;
197
+ }
198
+
199
+ // Check if we need to modify this token
200
+ let needsModification = false;
201
+ const modificationsNeeded = [];
202
+
203
+ for (const { noteLetter, accidental, noteWithOctave } of noteInfos) {
204
+ const baseNoteLetter = noteLetter.toUpperCase();
205
+ const firstBarAccidental = firstBarAccidentals.get(noteWithOctave);
206
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
207
+
208
+ if (accidental) {
209
+ // Has explicit accidental
210
+ const currentAccidental = secondBarAccidentals.get(noteWithOctave);
211
+
212
+ if (currentAccidental !== undefined) {
213
+ // Already set in this bar (merged context)
214
+ secondBarAccidentals.set(noteWithOctave, accidental);
215
+ modificationsNeeded.push(null);
216
+ } else if (accidental === firstBarAccidental) {
217
+ // Redundant - same as what's in effect from first bar
218
+ needsModification = true;
219
+ secondBarAccidentals.set(noteWithOctave, accidental);
220
+ modificationsNeeded.push("remove");
221
+ } else {
222
+ // Different accidental, keep it
223
+ secondBarAccidentals.set(noteWithOctave, accidental);
224
+ modificationsNeeded.push(null);
225
+ }
226
+ } else {
227
+ // No explicit accidental
228
+ const currentAccidental = secondBarAccidentals.get(noteWithOctave);
229
+
230
+ if (currentAccidental !== undefined) {
231
+ // Already set in this bar, no modification
232
+ modificationsNeeded.push(null);
233
+ } else if (
234
+ firstBarAccidental !== undefined &&
235
+ firstBarAccidental !== keyAccidental
236
+ ) {
237
+ // Bar 1 had this note with a different accidental than key signature
238
+ // Need to add the key signature accidental to restore it
239
+ needsModification = true;
240
+ const neededAccidental = keyAccidental || "=";
241
+ secondBarAccidentals.set(noteWithOctave, neededAccidental);
242
+ modificationsNeeded.push(neededAccidental);
243
+ } else {
244
+ // No modification needed (bar 1 didn't have this note, or had same as key)
245
+ secondBarAccidentals.set(noteWithOctave, keyAccidental);
246
+ modificationsNeeded.push(null);
247
+ }
248
+ }
249
+ }
250
+
251
+ if (needsModification) {
252
+ // Reconstruct the token with added accidentals
253
+ const modifiedToken = { ...token };
254
+ let modifiedTokenStr = token.token;
255
+
256
+ // For simple single notes (not chords), we can modify directly
257
+ if (!token.isChord && modificationsNeeded[0]) {
258
+ // Find where the note letter starts (after decorations)
259
+ const noteMatch = modifiedTokenStr.match(
260
+ /(^[~.MPSTHUV!]*(?:![^!]+!)*\s*)?(__|_|=|\^\^|\^)?([A-Ga-g])/
261
+ );
262
+ if (noteMatch) {
263
+ const prefix = noteMatch[1] || "";
264
+ const existingAcc = noteMatch[2] || "";
265
+ const noteLetter = noteMatch[3];
266
+ const afterNote = modifiedTokenStr.substring(
267
+ prefix.length + existingAcc.length + noteLetter.length
268
+ );
269
+
270
+ if (modificationsNeeded[0] === "remove") {
271
+ // Remove the existing accidental
272
+ modifiedTokenStr = prefix + noteLetter + afterNote;
273
+ } else {
274
+ // Add or change the accidental
275
+ modifiedTokenStr =
276
+ prefix + modificationsNeeded[0] + noteLetter + afterNote;
277
+ }
278
+ }
279
+ }
280
+ // For chords, this is more complex - we'd need to modify the chord parsing
281
+ // For now, mark that it needs modification and handle in integration
282
+
283
+ modifiedToken.token = modifiedTokenStr;
284
+ modifiedToken.needsAccidentalModification = needsModification;
285
+ modifiedToken.accidentalModifications = modificationsNeeded;
286
+ modifiedTokens.push(modifiedToken);
287
+ } else {
288
+ modifiedTokens.push(token);
289
+ }
290
+ }
291
+
292
+ return modifiedTokens;
293
+ }
294
+
295
+ /**
296
+ * Remove redundant accidentals when splitting a bar
297
+ *
298
+ * @param {Array<object>} secondHalfTokens - Tokens from second half after split
299
+ * @param {Map<string, string>} firstHalfAccidentals - Accidentals from first half
300
+ * @param {Map<string, string>} keyAccidentals - Accidentals from key signature
301
+ * @returns {Array<object>} - Modified tokens with redundant accidentals removed
302
+ */
303
+ function removeRedundantAccidentals(
304
+ secondHalfTokens,
305
+ firstHalfAccidentals,
306
+ keyAccidentals
307
+ ) {
308
+ const modifiedTokens = [];
309
+ const secondHalfAccidentals = new Map();
310
+
311
+ for (const token of secondHalfTokens) {
312
+ // Non-note tokens pass through
313
+ if (
314
+ token.isSilence ||
315
+ token.isDummy ||
316
+ token.isInlineField ||
317
+ token.isChordSymbol ||
318
+ token.isTuple ||
319
+ token.isBrokenRhythm ||
320
+ token.isVariantEnding ||
321
+ token.isDecoration ||
322
+ token.isGraceNote
323
+ ) {
324
+ modifiedTokens.push(token);
325
+ continue;
326
+ }
327
+
328
+ const noteInfos = extractNoteInfo(token);
329
+
330
+ if (noteInfos.length === 0) {
331
+ modifiedTokens.push(token);
332
+ continue;
333
+ }
334
+
335
+ // Check if we need to remove accidentals
336
+ let needsModification = false;
337
+ const modificationsNeeded = [];
338
+
339
+ for (const { noteLetter, accidental, noteWithOctave } of noteInfos) {
340
+ const baseNoteLetter = noteLetter.toUpperCase();
341
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
342
+ const currentAccidental = secondHalfAccidentals.get(noteWithOctave);
343
+
344
+ // Normalize accidentals for comparison: treat '=' and null as equivalent (both natural)
345
+ const normalizedAccidental = accidental === "=" ? null : accidental;
346
+ const normalizedKeyAccidental =
347
+ keyAccidental === "=" ? null : keyAccidental;
348
+
349
+ if (currentAccidental !== undefined) {
350
+ // Already set in second half
351
+ modificationsNeeded.push(null);
352
+ } else if (
353
+ normalizedAccidental === normalizedKeyAccidental &&
354
+ accidental !== null
355
+ ) {
356
+ // Redundant - explicit accidental matches key signature
357
+ // (only remove explicit accidentals, not implicit ones)
358
+ needsModification = true;
359
+ secondHalfAccidentals.set(noteWithOctave, accidental);
360
+ modificationsNeeded.push("remove");
361
+ } else {
362
+ // Keep it
363
+ if (accidental) {
364
+ secondHalfAccidentals.set(noteWithOctave, accidental);
365
+ } else {
366
+ secondHalfAccidentals.set(noteWithOctave, keyAccidental);
367
+ }
368
+ modificationsNeeded.push(null);
369
+ }
370
+ }
371
+
372
+ if (needsModification) {
373
+ const modifiedToken = { ...token };
374
+ let modifiedTokenStr = token.token;
375
+
376
+ // For simple single notes
377
+ if (!token.isChord && modificationsNeeded[0] === "remove") {
378
+ // Remove the accidental
379
+ const noteMatch = modifiedTokenStr.match(
380
+ /(^[~.MPSTHUV!]*(?:![^!]+!)*\s*)?(__|_|=|\^\^|\^)?([A-Ga-g])/
381
+ );
382
+ if (noteMatch && noteMatch[2]) {
383
+ const prefix = noteMatch[1] || "";
384
+ const accToRemove = noteMatch[2];
385
+ const noteLetter = noteMatch[3];
386
+ const afterNote = modifiedTokenStr.substring(
387
+ prefix.length + accToRemove.length + noteLetter.length
388
+ );
389
+
390
+ modifiedTokenStr = prefix + noteLetter + afterNote;
391
+ }
392
+ }
393
+
394
+ modifiedToken.token = modifiedTokenStr;
395
+ modifiedToken.needsAccidentalModification = needsModification;
396
+ modifiedToken.accidentalModifications = modificationsNeeded;
397
+ modifiedTokens.push(modifiedToken);
398
+ } else {
399
+ modifiedTokens.push(token);
400
+ }
401
+ }
402
+
403
+ return modifiedTokens;
404
+ }
405
+
406
+ /**
407
+ * Reconstruct music text from tokens
408
+ * @param {Array<object>} tokens - Array of token objects
409
+ * @returns {string} - Reconstructed music text
410
+ */
411
+ function reconstructMusicFromTokens(tokens) {
412
+ if (tokens.length === 0) return "";
413
+
414
+ let result = "";
415
+
416
+ for (let i = 0; i < tokens.length; i++) {
417
+ const token = tokens[i];
418
+
419
+ // Add the token (possibly modified)
420
+ result += token.token;
421
+
422
+ // Add spacing after token (but not after the last token)
423
+ if (i < tokens.length - 1 && token.spacing && token.spacing.whitespace) {
424
+ result += token.spacing.whitespace;
425
+ }
426
+ }
427
+
428
+ return result;
429
+ }
430
+
431
+ module.exports = {
432
+ getKeySignatureAccidentals,
433
+ getBarAccidentals,
434
+ extractNoteInfo,
435
+ addAccidentalsForMergedBar,
436
+ removeRedundantAccidentals,
437
+ reconstructMusicFromTokens
438
+ };
@@ -1,50 +1,72 @@
1
1
  const { normaliseKey } = require("../manipulator");
2
2
 
3
3
  /**
4
- * Extracts data in the ABC header T R C M K S F D N fields
4
+ * Extracts data in the ABC _header_ T R C M K S F D N H fields
5
5
  * and returns it in a object with properties: title, rhythm, composer, meter, key,
6
- * source, url, recording, and comments.
6
+ * source, url, recording, comments, and hComments.
7
7
  * Minimal parsing, but a few features:
8
8
  * - only extracts the first T title; subsequent T entries are ignored
9
9
  * - the key is normalised, so C, Cmaj, C maj, C major will all map to key:"C major"
10
- * - the comments go in an array, with one array entry per N: line.
10
+ * - the comments (i.e. the N / notes) go in an array called `comments`, with one array entry per N: line
11
+ * - the history (H) lines are joined up with spaces into a single line that is returned as `hComments`
12
+ * - the field continuation `+:` is handled only for lines following an initial H (history)
13
+ * - if there’s more than one T (title), then titles after the first one are returned in an array `titles`
11
14
  * @param {*} abc
12
15
  * @returns {object} - The header info
13
16
  */
14
17
  function getMetadata(abc) {
15
18
  const lines = abc.split("\n"),
16
19
  metadata = {},
17
- comments = [];
20
+ comments = [],
21
+ hComments = [],
22
+ titles = [];
23
+
24
+ let currentHeader = "";
18
25
 
19
26
  for (const line of lines) {
20
27
  const trimmed = line.trim();
21
- if (trimmed.startsWith("T:") && !metadata.title) {
22
- metadata.title = trimmed.substring(2).trim();
28
+ const trimmed2 = trimmed.substring(2).trim().replace(/%.+/, "");
29
+ if (trimmed.startsWith("T:")) {
30
+ if (!metadata.title) metadata.title = trimmed2;
31
+ else titles.push(trimmed2);
23
32
  } else if (trimmed.startsWith("R:")) {
24
- metadata.rhythm = trimmed.substring(2).trim().toLowerCase();
33
+ metadata.rhythm = trimmed2.toLowerCase();
25
34
  } else if (trimmed.startsWith("C:")) {
26
- metadata.composer = trimmed.substring(2).trim();
35
+ metadata.composer = trimmed2;
27
36
  } else if (trimmed.startsWith("M:")) {
28
- metadata.meter = trimmed.substring(2).trim();
37
+ metadata.meter = trimmed2;
29
38
  } else if (trimmed.startsWith("K:")) {
30
- metadata.key = normaliseKey(trimmed.substring(2).trim()).join(" ");
39
+ metadata.key = normaliseKey(trimmed2).join(" ");
31
40
  // metadata.indexOfKey = i
32
41
  break;
33
42
  } else if (trimmed.startsWith("S:")) {
34
- metadata.source = trimmed.substring(2).trim();
43
+ metadata.source = trimmed2;
35
44
  } else if (trimmed.startsWith("O:")) {
36
- metadata.origin = trimmed.substring(2).trim();
45
+ metadata.origin = trimmed2;
37
46
  } else if (trimmed.startsWith("F:")) {
38
- metadata.url = trimmed.substring(2).trim();
47
+ metadata.url = trimmed2;
39
48
  } else if (trimmed.startsWith("D:")) {
40
- metadata.recording = trimmed.substring(2).trim();
49
+ metadata.recording = trimmed2;
41
50
  } else if (trimmed.startsWith("N:")) {
42
- comments.push(trimmed.substring(2).trim());
51
+ comments.push(trimmed2);
52
+ } else if (trimmed.startsWith("H:")) {
53
+ currentHeader = "H";
54
+ hComments.push(trimmed2);
55
+ } else if (trimmed.startsWith("+:")) {
56
+ switch (currentHeader) {
57
+ case "H":
58
+ hComments.push(trimmed2);
59
+ break;
60
+ }
43
61
  }
44
62
  }
45
63
  if (comments.length > 0) {
46
64
  metadata.comments = comments;
47
65
  }
66
+ if (hComments.length > 0) {
67
+ metadata.hComments = hComments.join(" ");
68
+ }
69
+ if (titles.length > 0) metadata.titles = titles;
48
70
 
49
71
  return metadata;
50
72
  }
@@ -38,8 +38,8 @@ const // captures not only |1 |2, but also :|1 :||1 :|2 :||2
38
38
  return String.raw`(?:${this.bangDecoration}\s*)*`;
39
39
  },
40
40
 
41
- // Accidental: :, ^, _ (natural, sharp, flat)
42
- accidental: String.raw`[:^_]?`,
41
+ // Accidental: :, ^, _ (natural, sharp, flat; and double flat/sharp; and even add rarer ones like natural+sharp)
42
+ accidental: String.raw`(_|\^|=|\^\^|__|==|=_|=\^)?`,
43
43
 
44
44
  // Note pitch: A-G (lower octave), a-g (middle octave), z/x (rest), y (dummy)
45
45
  // Or chord in brackets: [CEG], [DF#A]