@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.
- package/.editorconfig +17 -0
- package/package.json +8 -7
- package/src/contour-sort.js +399 -384
- package/src/incipit.js +134 -201
- package/src/index.js +23 -23
- package/src/manipulator.js +448 -449
- package/src/math.js +63 -63
- package/src/parse/barline-parser.js +115 -0
- package/src/parse/header-parser.js +140 -0
- package/src/parse/note-parser.js +463 -0
- package/src/parse/parser.js +530 -0
- package/src/parse/token-utils.js +106 -0
- package/src/parser.js +0 -996
package/src/manipulator.js
CHANGED
|
@@ -1,449 +1,448 @@
|
|
|
1
|
-
const { Fraction } = require("./math.js");
|
|
2
|
-
const {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
};
|