@goplayerjuggler/abc-tools 1.0.2 → 1.0.4

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.
@@ -1,449 +1,448 @@
1
- const { Fraction } = require("./math.js");
2
- const {
3
- parseABCWithBars,
4
- getMeter,
5
- calculateBarDurations,
6
- } = require("./parser.js");
7
-
8
- // ============================================================================
9
- // ABC manipulation functions
10
- // ============================================================================
11
-
12
- /**
13
- * Normalises an ABC key header into a structured array of tonic, mode, and accidentals.
14
- * Supports both ASCII and Unicode accidentals, and handles multiple modifying accidentals.
15
- *
16
- * @param {string} keyHeader - The contents of the K: header (e.g., "D#m", "Fb maj", "D min ^g ^c").
17
- * @returns {[string, string, string?]} An array containing:
18
- * - The normalised tonic (e.g., "D♯", "F♭").
19
- * - The normalised mode (e.g., "minor", "major", "mixolydian").
20
- * - Optional: A string of accidentals (e.g., "^g ^c", "=c __f").
21
- *
22
- * @example
23
- * normaliseKey('D#m'); // ["D♯", "minor"]
24
- * normaliseKey('Fb maj'); // ["F♭", "major"]
25
- * normaliseKey('G# mixolydian'); // ["G♯", "mixolydian"]
26
- * normaliseKey('Cion'); // ["C", "major"]
27
- * normaliseKey('D min ^g ^c'); // ["D", "minor", "^g ^c"]
28
- * normaliseKey('D maj =c __f'); // ["D", "major", "=c __f"]
29
- */
30
- function normaliseKey(keyHeader) {
31
- const key = keyHeader.toLowerCase().trim();
32
- // Extract note and accidental, normalizing ASCII to Unicode
33
- const noteMatch = key.match(/^([a-g])(#|b|x|bb|×|♭|♯)?/);
34
- const noteBase = noteMatch ? noteMatch[1].toUpperCase() : "C";
35
- const accidental =
36
- noteMatch && noteMatch[2]
37
- ? noteMatch[2].replace("#", "♯").replace("b", "♭")
38
- : "";
39
- const note = noteBase + accidental;
40
-
41
- const modeMap = {
42
- maj: "major",
43
- major: "major",
44
- ion: "major",
45
- ionian: "major",
46
- min: "minor",
47
- minor: "minor",
48
- aeo: "minor",
49
- aeolian: "minor",
50
- mix: "mixolydian",
51
- mixo: "mixolydian",
52
- mixolydian: "mixolydian",
53
- dor: "dorian",
54
- dorian: "dorian",
55
- phr: "phrygian",
56
- phrygian: "phrygian",
57
- lyd: "lydian",
58
- lydian: "lydian",
59
- loc: "locrian",
60
- locrian: "locrian",
61
- };
62
- const mode = Object.keys(modeMap).find((m) => key.includes(m)) || "major";
63
-
64
- // Extract all accidentals (e.g., "^g ^c", "__f", "=c")
65
- const accidentalsMatch = key.match(/(?:^|\s)(?:__|_|=|\^|\^\^)[a-g]/g);
66
- const accidentals = accidentalsMatch
67
- ? accidentalsMatch.join("").trim()
68
- : null;
69
-
70
- const result = [note, modeMap[mode]];
71
- if (accidentals) {
72
- result.push(accidentals);
73
- }
74
- return result;
75
- }
76
-
77
- /**
78
- * Filter headers based on configuration
79
- * @param {Array<string>} headerLines - Array of header line strings
80
- * @param {object} headersToStrip - Configuration {all:boolean, toKeep:string}
81
- * @returns {Array<string>} - Filtered header lines
82
- */
83
- function filterHeaders(headerLines, headersToStrip) {
84
- if (!headersToStrip || !headersToStrip.all) {
85
- return headerLines;
86
- }
87
-
88
- // Keep only X, M, L, K headers when stripping
89
- return headerLines.filter((line) => "XMLK".indexOf(line[0]) >= 0);
90
- }
91
-
92
- /**
93
- * Detect if ABC notation has an anacrusis (pickup bar)
94
- * @param {object} parsed - Parsed ABC data from parseABCWithBars
95
- * @returns {boolean} - True if anacrusis is present
96
- */
97
- function hasAnacrucisFromParsed(parsed) {
98
- const barDurations = calculateBarDurations(parsed);
99
- const expectedBarDuration = new Fraction(parsed.meter[0], parsed.meter[1]);
100
-
101
- if (parsed.bars.length === 0) {
102
- return false;
103
- }
104
-
105
- const firstBarDuration = barDurations[0];
106
- return firstBarDuration.compare(expectedBarDuration) < 0;
107
- }
108
-
109
- /**
110
- * Detect if ABC notation has an anacrusis (pickup bar)
111
- * @param {string} abc - ABC notation
112
- * @returns {boolean} - True if anacrusis is present
113
- */
114
- function hasAnacrucis(abc) {
115
- const parsed = parseABCWithBars(abc, { maxBars: 2 });
116
- return hasAnacrucisFromParsed(parsed);
117
- }
118
- /**
119
- * Inserts a specified character at multiple positions within a string.
120
- * Optimised for performance with long strings and repeated usage.
121
- *
122
- * @param {string} originalString - The original string to modify.
123
- * @param {string} charToInsert - The character to insert at the specified positions.
124
- * @param {number[]} indexes - An array of positions (zero-based) where the character should be inserted.
125
- * @returns {string} The modified string with characters inserted at the specified positions.
126
- *
127
- * // Example usage:
128
- * const originalString = "hello world";
129
- * const charToInsert = "!";
130
- * const indexes = [2, 5, 8, 2, 15];
131
- * const result = insertCharsAtIndexes(originalString, charToInsert, indexes);
132
- * console.log(result); // Output: "he!l!lo! world!"
133
- *
134
- */
135
- function insertCharsAtIndexes(originalString, charToInsert, indexes) {
136
- // Filter and sort indexes only once: remove duplicates and invalid positions
137
- const validIndexes = [...new Set(indexes)]
138
- .filter((index) => index >= 0 && index <= originalString.length)
139
- .sort((a, b) => a - b);
140
-
141
- const result = [];
142
- let prevIndex = 0;
143
-
144
- for (const index of validIndexes) {
145
- // Push the substring up to the current index
146
- result.push(originalString.slice(prevIndex, index));
147
- // Push the character to insert
148
- result.push(charToInsert);
149
- // Update the previous index
150
- prevIndex = index;
151
- }
152
-
153
- // Push the remaining part of the string
154
- result.push(originalString.slice(prevIndex));
155
-
156
- return result.join("");
157
- }
158
-
159
- /**
160
- * Toggle meter by doubling or halving bar length
161
- * Supports 4/4↔4/2 and 6/8↔12/8 transformations
162
- * This is neary a true inverse operation - going there and back preserves the ABC except for some
163
- * edge cases involving spaces around the bar lines. No need to handle them.
164
- * Handles anacrusis correctly and preserves line breaks
165
- *
166
- * @param {string} abc - ABC notation
167
- * @param {Array<number>} smallMeter - The smaller meter signature [num, den]
168
- * @param {Array<number>} largeMeter - The larger meter signature [num, den]
169
- * @returns {string} - ABC with toggled meter
170
- */
171
- function toggleMeterDoubling(abc, smallMeter, largeMeter) {
172
- const currentMeter = getMeter(abc);
173
-
174
- const isSmall =
175
- currentMeter[0] === smallMeter[0] && currentMeter[1] === smallMeter[1];
176
- const isLarge =
177
- currentMeter[0] === largeMeter[0] && currentMeter[1] === largeMeter[1];
178
-
179
- if (!isSmall && !isLarge) {
180
- throw new Error(
181
- `Meter must be ${smallMeter[0]}/${smallMeter[1]} or ${largeMeter[0]}/${largeMeter[1]}`
182
- );
183
- }
184
-
185
- if (isSmall) {
186
- // We're going to remove some bars, so ensure every bar line (pipe / `|`) has a space preceding it
187
- // Regex handles bars like :| and [|]
188
- abc = abc.replaceAll(/([^\s])([[:]?\|)/g, "$1 $2");
189
- }
190
-
191
- const parsed = parseABCWithBars(abc);
192
- const { headerLines, barLines, musicText } = parsed;
193
-
194
- // Change meter in headers
195
- const newHeaders = headerLines.map((line) => {
196
- if (line.match(/^M:/)) {
197
- return isSmall
198
- ? `M:${largeMeter[0]}/${largeMeter[1]}`
199
- : `M:${smallMeter[0]}/${smallMeter[1]}`;
200
- }
201
- return line;
202
- });
203
-
204
- const hasPickup = hasAnacrucisFromParsed(parsed);
205
-
206
- if (isSmall) {
207
- // Going from small to large: remove every other bar line (except final)
208
- const barLinesToRemove = new Set();
209
- const startIndex = hasPickup ? 1 : 0;
210
-
211
- for (let i = startIndex; i < barLines.length - 1; i += 2) {
212
- barLinesToRemove.add(barLines[i].sourceIndex);
213
- }
214
-
215
- // Reconstruct music by removing marked bar lines
216
- let newMusic = "";
217
- let lastPos = 0;
218
-
219
- for (let i = 0; i < barLines.length; i++) {
220
- const barLine = barLines[i];
221
- newMusic += musicText.substring(lastPos, barLine.sourceIndex);
222
-
223
- if (!barLinesToRemove.has(barLine.sourceIndex)) {
224
- lastPos = barLine.sourceIndex;
225
- } else {
226
- // Remove the bar line - skip over it
227
- lastPos = barLine.sourceIndex + barLine.sourceLength;
228
- }
229
- }
230
- newMusic += musicText.substring(lastPos);
231
-
232
- return `${newHeaders.join("\n")}\n${newMusic}`;
233
- } else {
234
- // Going from large to small: add bar line in middle of each bar
235
- const halfBarDuration = new Fraction(smallMeter[0], smallMeter[1]);
236
- const insertionPoints = [];
237
- const startBarIndex = hasPickup ? 1 : 0;
238
-
239
- for (let barIdx = startBarIndex; barIdx < parsed.bars.length; barIdx++) {
240
- const bar = parsed.bars[barIdx];
241
- let barDuration = new Fraction(0, 1);
242
- let insertPos = null;
243
-
244
- // Find position where we've accumulated half a bar
245
- for (let noteIdx = 0; noteIdx < bar.length; noteIdx++) {
246
- const token = bar[noteIdx];
247
-
248
- // Skip tokens with no duration
249
- if (!token.duration) {
250
- continue;
251
- }
252
-
253
- const prevDuration = barDuration.clone();
254
- barDuration = barDuration.add(token.duration);
255
-
256
- // Check if we've just crossed the halfway point
257
- if (
258
- prevDuration.compare(halfBarDuration) < 0 &&
259
- barDuration.compare(halfBarDuration) >= 0
260
- ) {
261
- // Insert bar line after this note
262
- insertPos = token.sourceIndex + token.sourceLength;
263
- // Skip any trailing space that's part of this note
264
- if (token.spacing && token.spacing.whitespace) {
265
- insertPos += token.spacing.whitespace.length;
266
- }
267
- break;
268
- }
269
- }
270
-
271
- if (insertPos !== null) {
272
- insertionPoints.push(insertPos);
273
- }
274
- }
275
-
276
- // Insert bar lines at calculated positions
277
- const newMusic = insertCharsAtIndexes(musicText, "| ", insertionPoints);
278
-
279
- return `${newHeaders.join("\n")}\n${newMusic}`;
280
- }
281
- }
282
-
283
- /**
284
- * Toggle between M:4/4 and M:4/2 by surgically adding/removing bar lines
285
- * This is a true inverse operation - going there and back preserves the ABC exactly
286
- * Handles anacrusis correctly and preserves line breaks
287
- */
288
- function toggleMeter_4_4_to_4_2(abc) {
289
- return toggleMeterDoubling(abc, [4, 4], [4, 2]);
290
- }
291
-
292
- /**
293
- * Toggle between M:6/8 and M:12/8 by surgically adding/removing bar lines
294
- * This is a true inverse operation - going there and back preserves the ABC exactly
295
- * Handles anacrusis correctly and preserves line breaks
296
- */
297
- function toggleMeter_6_8_to_12_8(abc) {
298
- return toggleMeterDoubling(abc, [6, 8], [12, 8]);
299
- }
300
-
301
- /**
302
- * Get the first N complete or partial bars from ABC notation, with or without the anacrusis
303
- * Preserves all formatting, comments, spacing, and line breaks
304
- * @param {string} abc - ABC notation
305
- * @param {number|Fraction} numBars - Number of bars to extract (can be fractional, e.g., 1.5 or new Fraction(3,2))
306
- * @param {boolean} withAnacrucis - when flagged, the returned result also includes the anacrusis - incomplete bar (default: false)
307
- * @param {boolean} countAnacrucisInTotal - when true AND withAnacrucis is true, the anacrusis counts toward numBars duration (default: false)
308
- * @param {object} headersToStrip - optional header stripping configuration {all:boolean, toKeep:string}
309
- * @returns {string} - ABC with (optionally) the anacrusis, plus the first `numBars` worth of music
310
- */
311
- function getFirstBars(
312
- abc,
313
- numBars = 1,
314
- withAnacrucis = false,
315
- countAnacrucisInTotal = false,
316
- headersToStrip
317
- ) {
318
- // Convert numBars to Fraction if it's a number
319
- const numBarsFraction =
320
- typeof numBars === "number"
321
- ? new Fraction(Math.round(numBars * 1000), 1000)
322
- : numBars;
323
-
324
- // Estimate maxBars needed - simple ceiling with buffer
325
- const estimatedMaxBars =
326
- Math.ceil(numBarsFraction.num / numBarsFraction.den) + 2;
327
-
328
- // Parse with estimated maxBars
329
- const parsed = parseABCWithBars(abc, { maxBars: estimatedMaxBars });
330
- const { bars, headerLines, barLines, musicText, meter } = parsed;
331
-
332
- const barDurations = calculateBarDurations(parsed);
333
- const expectedBarDuration = new Fraction(meter[0], meter[1]);
334
- const targetDuration = expectedBarDuration.multiply(numBarsFraction);
335
-
336
- // Find first complete bar index
337
- let firstCompleteBarIdx = -1;
338
- for (let i = 0; i < bars.length; i++) {
339
- const barDuration = barDurations[i];
340
- if (barDuration.compare(expectedBarDuration) === 0) {
341
- firstCompleteBarIdx = i;
342
- break;
343
- }
344
- }
345
-
346
- if (firstCompleteBarIdx === -1) {
347
- throw new Error("No complete bars found");
348
- }
349
-
350
- const hasPickup = firstCompleteBarIdx > 0;
351
-
352
- // Filter headers if requested
353
- const filteredHeaders = filterHeaders(headerLines, headersToStrip);
354
-
355
- // Determine starting position in the music text
356
- let startPos = 0;
357
- if (hasPickup && withAnacrucis) {
358
- // Include anacrusis in output
359
- startPos = 0;
360
- } else if (hasPickup && !withAnacrucis) {
361
- // Skip anacrusis - start after its bar line
362
- const anacrusisBarLine = barLines[firstCompleteBarIdx - 1];
363
- if (anacrusisBarLine) {
364
- startPos = anacrusisBarLine.sourceIndex + anacrusisBarLine.sourceLength;
365
- }
366
- }
367
-
368
- // Calculate accumulated duration for target calculation
369
- let accumulatedDuration = new Fraction(0, 1);
370
- if (hasPickup && withAnacrucis && countAnacrucisInTotal) {
371
- // Count anacrusis toward target
372
- accumulatedDuration = barDurations[0];
373
- }
374
-
375
- // Find the end position by accumulating bar durations from first complete bar
376
- let endPos = startPos;
377
-
378
- for (let i = firstCompleteBarIdx; i < bars.length; i++) {
379
- const barDuration = barDurations[i];
380
- const newAccumulated = accumulatedDuration.add(barDuration);
381
-
382
- if (newAccumulated.compare(targetDuration) >= 0) {
383
- // We've reached or exceeded target
384
-
385
- if (newAccumulated.compare(targetDuration) === 0) {
386
- // Exact match - include full bar with its bar line
387
- if (i < barLines.length) {
388
- endPos = barLines[i].sourceIndex + barLines[i].sourceLength;
389
- }
390
- } else {
391
- // Need partial bar
392
- const remainingDuration = targetDuration.subtract(accumulatedDuration);
393
- const bar = bars[i];
394
- let barAccumulated = new Fraction(0, 1);
395
-
396
- for (const token of bar) {
397
- // Skip tokens with no duration
398
- if (!token.duration) {
399
- continue;
400
- }
401
-
402
- barAccumulated = barAccumulated.add(token.duration);
403
-
404
- // Check if we've reached or exceeded the remaining duration
405
- if (barAccumulated.compare(remainingDuration) >= 0) {
406
- // Include this note
407
- endPos = token.sourceIndex + token.sourceLength;
408
-
409
- // Skip trailing space if present
410
- if (
411
- token.spacing &&
412
- token.spacing.whitespace &&
413
- endPos < musicText.length &&
414
- musicText[endPos] === " "
415
- ) {
416
- endPos++;
417
- }
418
- break;
419
- }
420
- }
421
- }
422
- break;
423
- }
424
-
425
- accumulatedDuration = newAccumulated;
426
- }
427
-
428
- if (endPos === startPos) {
429
- throw new Error(
430
- `Not enough bars to satisfy request. Requested ${numBars} bars.`
431
- );
432
- }
433
-
434
- // Reconstruct ABC
435
- return `${filteredHeaders.join("\n")}\n${musicText.substring(
436
- startPos,
437
- endPos
438
- )}`;
439
- }
440
-
441
-
442
- module.exports = {
443
- getFirstBars,
444
- hasAnacrucis,
445
- toggleMeter_4_4_to_4_2,
446
- toggleMeter_6_8_to_12_8,
447
- filterHeaders,
448
- normaliseKey,
449
- };
1
+ const { Fraction } = require("./math.js");
2
+ const {
3
+ parseABCWithBars,
4
+ getMeter,
5
+ calculateBarDurations,
6
+ } = require("./parse/parser.js");
7
+
8
+ // ============================================================================
9
+ // ABC manipulation functions
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Normalises an ABC key header into a structured array of tonic, mode, and accidentals.
14
+ * Supports both ASCII and Unicode accidentals, and handles multiple modifying accidentals.
15
+ *
16
+ * @param {string} keyHeader - The contents of the K: header (e.g., "D#m", "Fb maj", "D min ^g ^c").
17
+ * @returns {[string, string, string?]} An array containing:
18
+ * - The normalised tonic (e.g., "D♯", "F♭").
19
+ * - The normalised mode (e.g., "minor", "major", "mixolydian").
20
+ * - Optional: A string of accidentals (e.g., "^g ^c", "=c __f").
21
+ *
22
+ * @example
23
+ * normaliseKey('D#m'); // ["D♯", "minor"]
24
+ * normaliseKey('Fb maj'); // ["F♭", "major"]
25
+ * normaliseKey('G# mixolydian'); // ["G♯", "mixolydian"]
26
+ * normaliseKey('Cion'); // ["C", "major"]
27
+ * normaliseKey('D min ^g ^c'); // ["D", "minor", "^g ^c"]
28
+ * normaliseKey('D maj =c __f'); // ["D", "major", "=c __f"]
29
+ */
30
+ function normaliseKey(keyHeader) {
31
+ const key = keyHeader.toLowerCase().trim();
32
+ // Extract note and accidental, normalising ASCII to Unicode
33
+ const noteMatch = key.match(/^([a-g])(#|b|x|bb|×|♭|♯)?/);
34
+ const noteBase = noteMatch ? noteMatch[1].toUpperCase() : "C";
35
+ const accidental =
36
+ noteMatch && noteMatch[2]
37
+ ? noteMatch[2].replace("#", "♯").replace("b", "♭")
38
+ : "";
39
+ const note = noteBase + accidental;
40
+
41
+ const modeMap = {
42
+ maj: "major",
43
+ major: "major",
44
+ ion: "major",
45
+ ionian: "major",
46
+ min: "minor",
47
+ minor: "minor",
48
+ aeo: "minor",
49
+ aeolian: "minor",
50
+ mix: "mixolydian",
51
+ mixo: "mixolydian",
52
+ mixolydian: "mixolydian",
53
+ dor: "dorian",
54
+ dorian: "dorian",
55
+ phr: "phrygian",
56
+ phrygian: "phrygian",
57
+ lyd: "lydian",
58
+ lydian: "lydian",
59
+ loc: "locrian",
60
+ locrian: "locrian",
61
+ };
62
+ const mode = Object.keys(modeMap).find((m) => key.includes(m)) || "major";
63
+
64
+ // Extract all accidentals (e.g., "^g ^c", "__f", "=c")
65
+ const accidentalsMatch = key.match(/(?:^|\s)(?:__|_|=|\^|\^\^)[a-g]/g);
66
+ const accidentals = accidentalsMatch
67
+ ? accidentalsMatch.join("").trim()
68
+ : null;
69
+
70
+ const result = [note, modeMap[mode]];
71
+ if (accidentals) {
72
+ result.push(accidentals);
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Filter headers based on configuration
79
+ * @param {Array<string>} headerLines - Array of header line strings
80
+ * @param {object} headersToStrip - Configuration {all:boolean, toKeep:string}
81
+ * @returns {Array<string>} - Filtered header lines
82
+ */
83
+ function filterHeaders(headerLines, headersToStrip) {
84
+ if (!headersToStrip || !headersToStrip.all) {
85
+ return headerLines;
86
+ }
87
+
88
+ // Keep only X, M, L, K headers when stripping
89
+ return headerLines.filter((line) => "XMLK".indexOf(line[0]) >= 0);
90
+ }
91
+
92
+ /**
93
+ * Detect if ABC notation has an anacrusis (pickup bar)
94
+ * @param {object} parsed - Parsed ABC data from parseABCWithBars
95
+ * @returns {boolean} - True if anacrusis is present
96
+ */
97
+ function hasAnacrucisFromParsed(parsed) {
98
+ const barDurations = calculateBarDurations(parsed);
99
+ const expectedBarDuration = new Fraction(parsed.meter[0], parsed.meter[1]);
100
+
101
+ if (parsed.bars.length === 0) {
102
+ return false;
103
+ }
104
+
105
+ const firstBarDuration = barDurations[0];
106
+ return firstBarDuration.compare(expectedBarDuration) < 0;
107
+ }
108
+
109
+ /**
110
+ * Detect if ABC notation has an anacrusis (pickup bar)
111
+ * @param {string} abc - ABC notation
112
+ * @returns {boolean} - True if anacrusis is present
113
+ */
114
+ function hasAnacrucis(abc) {
115
+ const parsed = parseABCWithBars(abc, { maxBars: 2 });
116
+ return hasAnacrucisFromParsed(parsed);
117
+ }
118
+ /**
119
+ * Inserts a specified character at multiple positions within a string.
120
+ * Optimised for performance with long strings and repeated usage.
121
+ *
122
+ * @param {string} originalString - The original string to modify.
123
+ * @param {string} charToInsert - The character to insert at the specified positions.
124
+ * @param {number[]} indexes - An array of positions (zero-based) where the character should be inserted.
125
+ * @returns {string} The modified string with characters inserted at the specified positions.
126
+ *
127
+ * // Example usage:
128
+ * const originalString = "hello world";
129
+ * const charToInsert = "!";
130
+ * const indexes = [2, 5, 8, 2, 15];
131
+ * const result = insertCharsAtIndexes(originalString, charToInsert, indexes);
132
+ * console.log(result); // Output: "he!l!lo! world!"
133
+ *
134
+ */
135
+ function insertCharsAtIndexes(originalString, charToInsert, indexes) {
136
+ // Filter and sort indexes only once: remove duplicates and invalid positions
137
+ const validIndexes = [...new Set(indexes)]
138
+ .filter((index) => index >= 0 && index <= originalString.length)
139
+ .sort((a, b) => a - b);
140
+
141
+ const result = [];
142
+ let prevIndex = 0;
143
+
144
+ for (const index of validIndexes) {
145
+ // Push the substring up to the current index
146
+ result.push(originalString.slice(prevIndex, index));
147
+ // Push the character to insert
148
+ result.push(charToInsert);
149
+ // Update the previous index
150
+ prevIndex = index;
151
+ }
152
+
153
+ // Push the remaining part of the string
154
+ result.push(originalString.slice(prevIndex));
155
+
156
+ return result.join("");
157
+ }
158
+
159
+ /**
160
+ * Toggle meter by doubling or halving bar length
161
+ * Supports 4/4↔4/2 and 6/8↔12/8 transformations
162
+ * This is neary a true inverse operation - going there and back preserves the ABC except for some
163
+ * edge cases involving spaces around the bar lines. No need to handle them.
164
+ * Handles anacrusis correctly and preserves line breaks
165
+ *
166
+ * @param {string} abc - ABC notation
167
+ * @param {Array<number>} smallMeter - The smaller meter signature [num, den]
168
+ * @param {Array<number>} largeMeter - The larger meter signature [num, den]
169
+ * @returns {string} - ABC with toggled meter
170
+ */
171
+ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
172
+ const currentMeter = getMeter(abc);
173
+
174
+ const isSmall =
175
+ currentMeter[0] === smallMeter[0] && currentMeter[1] === smallMeter[1];
176
+ const isLarge =
177
+ currentMeter[0] === largeMeter[0] && currentMeter[1] === largeMeter[1];
178
+
179
+ if (!isSmall && !isLarge) {
180
+ throw new Error(
181
+ `Meter must be ${smallMeter[0]}/${smallMeter[1]} or ${largeMeter[0]}/${largeMeter[1]}`
182
+ );
183
+ }
184
+
185
+ if (isSmall) {
186
+ // We're going to remove some bars, so ensure every bar line (pipe / `|`) has a space preceding it
187
+ // Regex handles bars like :| and [|]
188
+ abc = abc.replaceAll(/([^\s])([[:]?\|)/g, "$1 $2");
189
+ }
190
+
191
+ const parsed = parseABCWithBars(abc);
192
+ const { headerLines, barLines, musicText } = parsed;
193
+
194
+ // Change meter in headers
195
+ const newHeaders = headerLines.map((line) => {
196
+ if (line.match(/^M:/)) {
197
+ return isSmall
198
+ ? `M:${largeMeter[0]}/${largeMeter[1]}`
199
+ : `M:${smallMeter[0]}/${smallMeter[1]}`;
200
+ }
201
+ return line;
202
+ });
203
+
204
+ const hasPickup = hasAnacrucisFromParsed(parsed);
205
+
206
+ if (isSmall) {
207
+ // Going from small to large: remove every other bar line (except final)
208
+ const barLinesToRemove = new Set();
209
+ const startIndex = hasPickup ? 1 : 0;
210
+
211
+ for (let i = startIndex; i < barLines.length - 1; i += 2) {
212
+ barLinesToRemove.add(barLines[i].sourceIndex);
213
+ }
214
+
215
+ // Reconstruct music by removing marked bar lines
216
+ let newMusic = "";
217
+ let lastPos = 0;
218
+
219
+ for (let i = 0; i < barLines.length; i++) {
220
+ const barLine = barLines[i];
221
+ newMusic += musicText.substring(lastPos, barLine.sourceIndex);
222
+
223
+ if (!barLinesToRemove.has(barLine.sourceIndex)) {
224
+ lastPos = barLine.sourceIndex;
225
+ } else {
226
+ // Remove the bar line - skip over it
227
+ lastPos = barLine.sourceIndex + barLine.sourceLength;
228
+ }
229
+ }
230
+ newMusic += musicText.substring(lastPos);
231
+
232
+ return `${newHeaders.join("\n")}\n${newMusic}`;
233
+ } else {
234
+ // Going from large to small: add bar line in middle of each bar
235
+ const halfBarDuration = new Fraction(smallMeter[0], smallMeter[1]);
236
+ const insertionPoints = [];
237
+ const startBarIndex = hasPickup ? 1 : 0;
238
+
239
+ for (let barIdx = startBarIndex; barIdx < parsed.bars.length; barIdx++) {
240
+ const bar = parsed.bars[barIdx];
241
+ let barDuration = new Fraction(0, 1);
242
+ let insertPos = null;
243
+
244
+ // Find position where we've accumulated half a bar
245
+ for (let noteIdx = 0; noteIdx < bar.length; noteIdx++) {
246
+ const token = bar[noteIdx];
247
+
248
+ // Skip tokens with no duration
249
+ if (!token.duration) {
250
+ continue;
251
+ }
252
+
253
+ const prevDuration = barDuration.clone();
254
+ barDuration = barDuration.add(token.duration);
255
+
256
+ // Check if we've just crossed the halfway point
257
+ if (
258
+ prevDuration.compare(halfBarDuration) < 0 &&
259
+ barDuration.compare(halfBarDuration) >= 0
260
+ ) {
261
+ // Insert bar line after this note
262
+ insertPos = token.sourceIndex + token.sourceLength;
263
+ // Skip any trailing space that's part of this note
264
+ if (token.spacing && token.spacing.whitespace) {
265
+ insertPos += token.spacing.whitespace.length;
266
+ }
267
+ break;
268
+ }
269
+ }
270
+
271
+ if (insertPos !== null) {
272
+ insertionPoints.push(insertPos);
273
+ }
274
+ }
275
+
276
+ // Insert bar lines at calculated positions
277
+ const newMusic = insertCharsAtIndexes(musicText, "| ", insertionPoints);
278
+
279
+ return `${newHeaders.join("\n")}\n${newMusic}`;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Toggle between M:4/4 and M:4/2 by surgically adding/removing bar lines
285
+ * This is a true inverse operation - going there and back preserves the ABC exactly
286
+ * Handles anacrusis correctly and preserves line breaks
287
+ */
288
+ function toggleMeter_4_4_to_4_2(abc) {
289
+ return toggleMeterDoubling(abc, [4, 4], [4, 2]);
290
+ }
291
+
292
+ /**
293
+ * Toggle between M:6/8 and M:12/8 by surgically adding/removing bar lines
294
+ * This is a true inverse operation - going there and back preserves the ABC exactly
295
+ * Handles anacrusis correctly and preserves line breaks
296
+ */
297
+ function toggleMeter_6_8_to_12_8(abc) {
298
+ return toggleMeterDoubling(abc, [6, 8], [12, 8]);
299
+ }
300
+
301
+ /**
302
+ * Get the first N complete or partial bars from ABC notation, with or without the anacrusis
303
+ * Preserves all formatting, comments, spacing, and line breaks
304
+ * @param {string} abc - ABC notation
305
+ * @param {number|Fraction} numBars - Number of bars to extract (can be fractional, e.g., 1.5 or new Fraction(3,2))
306
+ * @param {boolean} withAnacrucis - when flagged, the returned result also includes the anacrusis - incomplete bar (default: false)
307
+ * @param {boolean} countAnacrucisInTotal - when true AND withAnacrucis is true, the anacrusis counts toward numBars duration (default: false)
308
+ * @param {object} headersToStrip - optional header stripping configuration {all:boolean, toKeep:string}
309
+ * @returns {string} - ABC with (optionally) the anacrusis, plus the first `numBars` worth of music
310
+ */
311
+ function getFirstBars(
312
+ abc,
313
+ numBars = 1,
314
+ withAnacrucis = false,
315
+ countAnacrucisInTotal = false,
316
+ headersToStrip
317
+ ) {
318
+ // Convert numBars to Fraction if it's a number
319
+ const numBarsFraction =
320
+ typeof numBars === "number"
321
+ ? new Fraction(Math.round(numBars * 1000), 1000)
322
+ : numBars;
323
+
324
+ // Estimate maxBars needed - simple ceiling with buffer
325
+ const estimatedMaxBars =
326
+ Math.ceil(numBarsFraction.num / numBarsFraction.den) + 2;
327
+
328
+ // Parse with estimated maxBars
329
+ const parsed = parseABCWithBars(abc, { maxBars: estimatedMaxBars });
330
+ const { bars, headerLines, barLines, musicText, meter } = parsed;
331
+
332
+ const barDurations = calculateBarDurations(parsed);
333
+ const expectedBarDuration = new Fraction(meter[0], meter[1]);
334
+ const targetDuration = expectedBarDuration.multiply(numBarsFraction);
335
+
336
+ // Find first complete bar index
337
+ let firstCompleteBarIdx = -1;
338
+ for (let i = 0; i < bars.length; i++) {
339
+ const barDuration = barDurations[i];
340
+ if (barDuration.compare(expectedBarDuration) === 0) {
341
+ firstCompleteBarIdx = i;
342
+ break;
343
+ }
344
+ }
345
+
346
+ if (firstCompleteBarIdx === -1) {
347
+ throw new Error("No complete bars found");
348
+ }
349
+
350
+ const hasPickup = firstCompleteBarIdx > 0;
351
+
352
+ // Filter headers if requested
353
+ const filteredHeaders = filterHeaders(headerLines, headersToStrip);
354
+
355
+ // Determine starting position in the music text
356
+ let startPos = 0;
357
+ if (hasPickup && withAnacrucis) {
358
+ // Include anacrusis in output
359
+ startPos = 0;
360
+ } else if (hasPickup && !withAnacrucis) {
361
+ // Skip anacrusis - start after its bar line
362
+ const anacrusisBarLine = barLines[firstCompleteBarIdx - 1];
363
+ if (anacrusisBarLine) {
364
+ startPos = anacrusisBarLine.sourceIndex + anacrusisBarLine.sourceLength;
365
+ }
366
+ }
367
+
368
+ // Calculate accumulated duration for target calculation
369
+ let accumulatedDuration = new Fraction(0, 1);
370
+ if (hasPickup && withAnacrucis && countAnacrucisInTotal) {
371
+ // Count anacrusis toward target
372
+ accumulatedDuration = barDurations[0];
373
+ }
374
+
375
+ // Find the end position by accumulating bar durations from first complete bar
376
+ let endPos = startPos;
377
+
378
+ for (let i = firstCompleteBarIdx; i < bars.length; i++) {
379
+ const barDuration = barDurations[i];
380
+ const newAccumulated = accumulatedDuration.add(barDuration);
381
+
382
+ if (newAccumulated.compare(targetDuration) >= 0) {
383
+ // We've reached or exceeded target
384
+
385
+ if (newAccumulated.compare(targetDuration) === 0) {
386
+ // Exact match - include full bar with its bar line
387
+ if (i < barLines.length) {
388
+ endPos = barLines[i].sourceIndex + barLines[i].sourceLength;
389
+ }
390
+ } else {
391
+ // Need partial bar
392
+ const remainingDuration = targetDuration.subtract(accumulatedDuration);
393
+ const bar = bars[i];
394
+ let barAccumulated = new Fraction(0, 1);
395
+
396
+ for (const token of bar) {
397
+ // Skip tokens with no duration
398
+ if (!token.duration) {
399
+ continue;
400
+ }
401
+
402
+ barAccumulated = barAccumulated.add(token.duration);
403
+
404
+ // Check if we've reached or exceeded the remaining duration
405
+ if (barAccumulated.compare(remainingDuration) >= 0) {
406
+ // Include this note
407
+ endPos = token.sourceIndex + token.sourceLength;
408
+
409
+ // Skip trailing space if present
410
+ if (
411
+ token.spacing &&
412
+ token.spacing.whitespace &&
413
+ endPos < musicText.length &&
414
+ musicText[endPos] === " "
415
+ ) {
416
+ endPos++;
417
+ }
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ break;
423
+ }
424
+
425
+ accumulatedDuration = newAccumulated;
426
+ }
427
+
428
+ if (endPos === startPos) {
429
+ throw new Error(
430
+ `Not enough bars to satisfy request. Requested ${numBars} bars.`
431
+ );
432
+ }
433
+
434
+ // Reconstruct ABC
435
+ return `${filteredHeaders.join("\n")}\n${musicText.substring(
436
+ startPos,
437
+ endPos
438
+ )}`;
439
+ }
440
+
441
+ module.exports = {
442
+ getFirstBars,
443
+ hasAnacrucis,
444
+ toggleMeter_4_4_to_4_2,
445
+ toggleMeter_6_8_to_12_8,
446
+ filterHeaders,
447
+ normaliseKey,
448
+ };