@goplayerjuggler/abc-tools 1.0.17 → 1.0.19

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.17",
3
+ "version": "1.0.19",
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);
@@ -179,6 +190,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
179
190
 
180
191
  const parsed = parseAbc(abc);
181
192
  const { headerLines, barLines, musicText, bars, meter } = parsed;
193
+
182
194
  // throw if there's a change of meter or unit length in the tune
183
195
  if (barLines.find((bl) => bl.newMeter || bl.newUnitLength)) {
184
196
  throw new Error("change of meter or unit length not handled");
@@ -196,6 +208,11 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
196
208
 
197
209
  if (isSmall) {
198
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
+
199
216
  // Get bar info to understand musical structure
200
217
  getBarInfo(bars, barLines, meter, {
201
218
  barNumbers: true,
@@ -214,27 +231,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
214
231
  const barLineDecisions = new Map();
215
232
  const barLinesToConvert = new Map(); // variant markers to convert from |N to [N
216
233
 
217
- //Discarded
218
- // // Map bar numbers to their sequential index among complete bars
219
- // // This handles cases where bar numbers skip (due to anacrusis or partials)
220
- // const completeBarIndexByNumber = new Map();
221
- // let completeBarIndex = 0;
222
-
223
- // for (let i = 0; i < barLines.length; i++) {
224
- // const barLine = barLines[i];
225
- // if (
226
- // barLine.barNumber !== null
227
- // //&& !barLine.isSectionBreak
228
- // ) {
229
- // const isCompleteMusicBar =
230
- // !barLine.isPartial || barLine.completesMusicBar === true;
231
- // if (isCompleteMusicBar) {
232
- // completeBarIndexByNumber.set(barLine.barNumber, completeBarIndex);
233
- // completeBarIndex++;
234
- // }
235
- // }
236
- // }
237
-
238
234
  const hasAnacrucis = hasAnacrucisFromParsed(null, barLines);
239
235
 
240
236
  for (let i = 0; i < barLines.length; i++) {
@@ -255,7 +251,6 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
255
251
  // Section breaks are always kept
256
252
  if (barLine.isSectionBreak) {
257
253
  barLineDecisions.set(i, { action: "keep" });
258
- // Don't count section breaks - they're structural markers, not part of pairing
259
254
  continue;
260
255
  }
261
256
 
@@ -269,24 +264,22 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
269
264
  continue;
270
265
  }
271
266
 
272
- // If the current bar starts with a variant, keep its bar line
273
- const currentBarVariant = barStartsWithVariant.get(i);
274
- if (currentBarVariant) {
275
- barLineDecisions.set(i, { action: "keep" });
276
- continue;
277
- }
278
-
279
267
  // This is a complete bar - use its barNumber to decide
280
268
  // Without anacrucis: Remove complete bars with even barNumber (0, 2, 4, ...), keep odd ones (1, 3, 5, ...)
281
269
  // With anacrucis: the other way round!
282
-
283
- //Discarded
284
- // const index = completeBarIndexByNumber.get(barLine.barNumber);
285
- // if (index !== undefined && index % 2 === 0) {
286
270
  const remove = hasAnacrucis
287
271
  ? barLine.barNumber % 2 !== 0
288
272
  : barLine.barNumber % 2 === 0;
289
273
  if (remove) {
274
+ // Check if current bar starts with variant
275
+ if (barStartsWithVariant.has(i)) {
276
+ const variantToken = barStartsWithVariant.get(i);
277
+ barLinesToConvert.set(variantToken.sourceIndex, {
278
+ oldLength: variantToken.sourceLength,
279
+ oldText: variantToken.token
280
+ });
281
+ }
282
+ // Also check if next bar starts with variant
290
283
  const nextBarIdx = i + 1;
291
284
  if (nextBarIdx < bars.length && barStartsWithVariant.has(nextBarIdx)) {
292
285
  const variantToken = barStartsWithVariant.get(nextBarIdx);
@@ -302,6 +295,88 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
302
295
  }
303
296
  }
304
297
 
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 ===
379
+
305
380
  // Reconstruct music
306
381
  let newMusic = "";
307
382
  let pos = 0;
@@ -338,6 +413,23 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
338
413
  skipLength++;
339
414
  }
340
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
+
341
433
  continue;
342
434
  } else if (decision && decision.action === "keep") {
343
435
  // Keep this bar line
@@ -355,17 +447,134 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
355
447
  return `${newHeaders.join("\n")}\n${newMusic}`;
356
448
  } else {
357
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
+
358
455
  const barInfo = getBarInfo(bars, barLines, meter, {
359
456
  divideBarsBy: 2
360
457
  });
361
458
 
362
459
  const { midpoints } = barInfo;
363
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
+
364
554
  // Insert bar lines at calculated positions
365
555
  const insertionPoints = [...midpoints].sort((a, b) => b - a);
366
- let newMusic = musicText;
556
+
557
+ // Adjust positions based on replacements
558
+ const adjustedInsertionPoints = [];
367
559
 
368
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) {
369
578
  newMusic = newMusic.substring(0, pos) + "| " + newMusic.substring(pos);
370
579
  }
371
580
 
@@ -373,6 +582,32 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
373
582
  }
374
583
  }
375
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
+
376
611
  /**
377
612
  * Toggle between M:4/4 and M:4/2 by surgically adding/removing bar lines
378
613
  * This is a true inverse operation - going there and back preserves the ABC exactly
@@ -383,10 +618,9 @@ function toggleMeter_4_4_to_4_2(abc, currentMeter) {
383
618
  }
384
619
 
385
620
  const defaultCommentForReelConversion =
386
- "*abc-tools: convert reel to M:4/4 & L:1/16*";
387
- const defaultCommentForHornpipeConversion =
388
- "*abc-tools: convert hornpipe to M:4/2*";
389
- const defaultCommentForJigConversion = "*abc-tools: convert jig to M:12/8*";
621
+ "*abc-tools: convert to M:4/4 & L:1/16*";
622
+ const defaultCommentForHornpipeConversion = "*abc-tools: convert to M:4/2*";
623
+ const defaultCommentForJigConversion = "*abc-tools: convert to M:12/8*";
390
624
  /**
391
625
  * Adjusts bar lengths and L, M fields - a
392
626
  * reel written in the normal way (M:4/4 L:1/8) is written
@@ -0,0 +1,455 @@
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 {
139
+ noteLetter,
140
+ octaveMarkers,
141
+ accidental,
142
+ noteWithOctave
143
+ } of noteInfos) {
144
+ const baseNoteLetter = noteLetter.toUpperCase();
145
+
146
+ if (accidental) {
147
+ // Explicit accidental - record it
148
+ accidentals.set(noteWithOctave, accidental);
149
+ } else if (!accidentals.has(noteWithOctave)) {
150
+ // No explicit accidental, use key signature default
151
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
152
+ accidentals.set(noteWithOctave, keyAccidental);
153
+ }
154
+ // If accidental already set for this note in this bar, it carries over
155
+ }
156
+ }
157
+
158
+ return accidentals;
159
+ }
160
+
161
+ /**
162
+ * Add correct accidentals when merging bars
163
+ * Modifies the tokens in the second bar to add accidentals where needed
164
+ *
165
+ * @param {Array<object>} secondBarTokens - Array of parsed tokens from second bar
166
+ * @param {Map<string, string>} firstBarAccidentals - Accidentals in effect from first bar
167
+ * @param {Map<string, string>} keyAccidentals - Accidentals from key signature
168
+ * @param {string} musicText - Original music text for reconstruction
169
+ * @returns {Array<object>} - Modified tokens with accidentals added
170
+ */
171
+ function addAccidentalsForMergedBar(
172
+ secondBarTokens,
173
+ firstBarAccidentals,
174
+ keyAccidentals,
175
+ musicText
176
+ ) {
177
+ const modifiedTokens = [];
178
+ const secondBarAccidentals = new Map();
179
+
180
+ for (const token of secondBarTokens) {
181
+ // Non-note tokens pass through unchanged
182
+ if (
183
+ token.isSilence ||
184
+ token.isDummy ||
185
+ token.isInlineField ||
186
+ token.isChordSymbol ||
187
+ token.isTuple ||
188
+ token.isBrokenRhythm ||
189
+ token.isVariantEnding ||
190
+ token.isDecoration ||
191
+ token.isGraceNote
192
+ ) {
193
+ modifiedTokens.push(token);
194
+ continue;
195
+ }
196
+
197
+ const noteInfos = extractNoteInfo(token);
198
+
199
+ // If no notes extracted, pass through
200
+ if (noteInfos.length === 0) {
201
+ modifiedTokens.push(token);
202
+ continue;
203
+ }
204
+
205
+ // Check if we need to modify this token
206
+ let needsModification = false;
207
+ const modificationsNeeded = [];
208
+
209
+ for (const {
210
+ noteLetter,
211
+ octaveMarkers,
212
+ accidental,
213
+ noteWithOctave
214
+ } of noteInfos) {
215
+ const baseNoteLetter = noteLetter.toUpperCase();
216
+ const firstBarAccidental = firstBarAccidentals.get(noteWithOctave);
217
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
218
+
219
+ if (accidental) {
220
+ // Has explicit accidental
221
+ const currentAccidental = secondBarAccidentals.get(noteWithOctave);
222
+
223
+ if (currentAccidental !== undefined) {
224
+ // Already set in this bar (merged context)
225
+ secondBarAccidentals.set(noteWithOctave, accidental);
226
+ modificationsNeeded.push(null);
227
+ } else if (accidental === firstBarAccidental) {
228
+ // Redundant - same as what's in effect from first bar
229
+ needsModification = true;
230
+ secondBarAccidentals.set(noteWithOctave, accidental);
231
+ modificationsNeeded.push("remove");
232
+ } else {
233
+ // Different accidental, keep it
234
+ secondBarAccidentals.set(noteWithOctave, accidental);
235
+ modificationsNeeded.push(null);
236
+ }
237
+ } else {
238
+ // No explicit accidental
239
+ const currentAccidental = secondBarAccidentals.get(noteWithOctave);
240
+
241
+ if (currentAccidental !== undefined) {
242
+ // Already set in this bar, no modification
243
+ modificationsNeeded.push(null);
244
+ } else if (
245
+ firstBarAccidental !== undefined &&
246
+ firstBarAccidental !== keyAccidental
247
+ ) {
248
+ // Bar 1 had this note with a different accidental than key signature
249
+ // Need to add the key signature accidental to restore it
250
+ needsModification = true;
251
+ const neededAccidental = keyAccidental || "=";
252
+ secondBarAccidentals.set(noteWithOctave, neededAccidental);
253
+ modificationsNeeded.push(neededAccidental);
254
+ } else {
255
+ // No modification needed (bar 1 didn't have this note, or had same as key)
256
+ secondBarAccidentals.set(noteWithOctave, keyAccidental);
257
+ modificationsNeeded.push(null);
258
+ }
259
+ }
260
+ }
261
+
262
+ if (needsModification) {
263
+ // Reconstruct the token with added accidentals
264
+ const modifiedToken = { ...token };
265
+ let modifiedTokenStr = token.token;
266
+
267
+ // For simple single notes (not chords), we can modify directly
268
+ if (!token.isChord && modificationsNeeded[0]) {
269
+ // Find where the note letter starts (after decorations)
270
+ const noteMatch = modifiedTokenStr.match(
271
+ /(^[~.MPSTHUV!]*(?:![^!]+!)*\s*)?(__|_|=|\^\^|\^)?([A-Ga-g])/
272
+ );
273
+ if (noteMatch) {
274
+ const prefix = noteMatch[1] || "";
275
+ const existingAcc = noteMatch[2] || "";
276
+ const noteLetter = noteMatch[3];
277
+ const afterNote = modifiedTokenStr.substring(
278
+ prefix.length + existingAcc.length + noteLetter.length
279
+ );
280
+
281
+ if (modificationsNeeded[0] === "remove") {
282
+ // Remove the existing accidental
283
+ modifiedTokenStr = prefix + noteLetter + afterNote;
284
+ } else {
285
+ // Add or change the accidental
286
+ modifiedTokenStr =
287
+ prefix + modificationsNeeded[0] + noteLetter + afterNote;
288
+ }
289
+ }
290
+ }
291
+ // For chords, this is more complex - we'd need to modify the chord parsing
292
+ // For now, mark that it needs modification and handle in integration
293
+
294
+ modifiedToken.token = modifiedTokenStr;
295
+ modifiedToken.needsAccidentalModification = needsModification;
296
+ modifiedToken.accidentalModifications = modificationsNeeded;
297
+ modifiedTokens.push(modifiedToken);
298
+ } else {
299
+ modifiedTokens.push(token);
300
+ }
301
+ }
302
+
303
+ return modifiedTokens;
304
+ }
305
+
306
+ /**
307
+ * Remove redundant accidentals when splitting a bar
308
+ *
309
+ * @param {Array<object>} secondHalfTokens - Tokens from second half after split
310
+ * @param {Map<string, string>} firstHalfAccidentals - Accidentals from first half
311
+ * @param {Map<string, string>} keyAccidentals - Accidentals from key signature
312
+ * @returns {Array<object>} - Modified tokens with redundant accidentals removed
313
+ */
314
+ function removeRedundantAccidentals(
315
+ secondHalfTokens,
316
+ firstHalfAccidentals,
317
+ keyAccidentals
318
+ ) {
319
+ const modifiedTokens = [];
320
+ const secondHalfAccidentals = new Map();
321
+
322
+ for (const token of secondHalfTokens) {
323
+ // Non-note tokens pass through
324
+ if (
325
+ token.isSilence ||
326
+ token.isDummy ||
327
+ token.isInlineField ||
328
+ token.isChordSymbol ||
329
+ token.isTuple ||
330
+ token.isBrokenRhythm ||
331
+ token.isVariantEnding ||
332
+ token.isDecoration ||
333
+ token.isGraceNote
334
+ ) {
335
+ modifiedTokens.push(token);
336
+ continue;
337
+ }
338
+
339
+ const noteInfos = extractNoteInfo(token);
340
+
341
+ if (noteInfos.length === 0) {
342
+ modifiedTokens.push(token);
343
+ continue;
344
+ }
345
+
346
+ // Check if we need to remove accidentals
347
+ let needsModification = false;
348
+ const modificationsNeeded = [];
349
+
350
+ for (const {
351
+ noteLetter,
352
+ octaveMarkers,
353
+ accidental,
354
+ noteWithOctave
355
+ } of noteInfos) {
356
+ const baseNoteLetter = noteLetter.toUpperCase();
357
+ const keyAccidental = keyAccidentals.get(baseNoteLetter) || null;
358
+ const currentAccidental = secondHalfAccidentals.get(noteWithOctave);
359
+
360
+ // Normalize accidentals for comparison: treat '=' and null as equivalent (both natural)
361
+ const normalizedAccidental = accidental === "=" ? null : accidental;
362
+ const normalizedKeyAccidental =
363
+ keyAccidental === "=" ? null : keyAccidental;
364
+
365
+ if (currentAccidental !== undefined) {
366
+ // Already set in second half
367
+ modificationsNeeded.push(null);
368
+ } else if (
369
+ normalizedAccidental === normalizedKeyAccidental &&
370
+ accidental !== null
371
+ ) {
372
+ // Redundant - explicit accidental matches key signature
373
+ // (only remove explicit accidentals, not implicit ones)
374
+ needsModification = true;
375
+ secondHalfAccidentals.set(noteWithOctave, accidental);
376
+ modificationsNeeded.push("remove");
377
+ } else {
378
+ // Keep it
379
+ if (accidental) {
380
+ secondHalfAccidentals.set(noteWithOctave, accidental);
381
+ } else {
382
+ secondHalfAccidentals.set(noteWithOctave, keyAccidental);
383
+ }
384
+ modificationsNeeded.push(null);
385
+ }
386
+ }
387
+
388
+ if (needsModification) {
389
+ const modifiedToken = { ...token };
390
+ let modifiedTokenStr = token.token;
391
+
392
+ // For simple single notes
393
+ if (!token.isChord && modificationsNeeded[0] === "remove") {
394
+ // Remove the accidental
395
+ const noteMatch = modifiedTokenStr.match(
396
+ /(^[~.MPSTHUV!]*(?:![^!]+!)*\s*)?(__|_|=|\^\^|\^)?([A-Ga-g])/
397
+ );
398
+ if (noteMatch && noteMatch[2]) {
399
+ const prefix = noteMatch[1] || "";
400
+ const accToRemove = noteMatch[2];
401
+ const noteLetter = noteMatch[3];
402
+ const afterNote = modifiedTokenStr.substring(
403
+ prefix.length + accToRemove.length + noteLetter.length
404
+ );
405
+
406
+ modifiedTokenStr = prefix + noteLetter + afterNote;
407
+ }
408
+ }
409
+
410
+ modifiedToken.token = modifiedTokenStr;
411
+ modifiedToken.needsAccidentalModification = needsModification;
412
+ modifiedToken.accidentalModifications = modificationsNeeded;
413
+ modifiedTokens.push(modifiedToken);
414
+ } else {
415
+ modifiedTokens.push(token);
416
+ }
417
+ }
418
+
419
+ return modifiedTokens;
420
+ }
421
+
422
+ /**
423
+ * Reconstruct music text from tokens
424
+ * @param {Array<object>} tokens - Array of token objects
425
+ * @param {string} originalMusicText - Original music text for spacing reference
426
+ * @returns {string} - Reconstructed music text
427
+ */
428
+ function reconstructMusicFromTokens(tokens, originalMusicText) {
429
+ if (tokens.length === 0) return "";
430
+
431
+ let result = "";
432
+
433
+ for (let i = 0; i < tokens.length; i++) {
434
+ const token = tokens[i];
435
+
436
+ // Add the token (possibly modified)
437
+ result += token.token;
438
+
439
+ // Add spacing after token (but not after the last token)
440
+ if (i < tokens.length - 1 && token.spacing && token.spacing.whitespace) {
441
+ result += token.spacing.whitespace;
442
+ }
443
+ }
444
+
445
+ return result;
446
+ }
447
+
448
+ module.exports = {
449
+ getKeySignatureAccidentals,
450
+ getBarAccidentals,
451
+ extractNoteInfo,
452
+ addAccidentalsForMergedBar,
453
+ removeRedundantAccidentals,
454
+ reconstructMusicFromTokens
455
+ };
@@ -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]
@@ -159,7 +159,7 @@ function getContour(
159
159
  avg -= 7;
160
160
  }
161
161
  else
162
- while (avg < -0.5) {
162
+ while (avg < -5) {
163
163
  sortKey.forEach((c, i) => (sortKey[i] = shiftChar(c, 1)));
164
164
  avg += 7;
165
165
  }