@goplayerjuggler/abc-tools 1.0.2 → 1.0.3

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/src/parser.js CHANGED
@@ -1,996 +1,1004 @@
1
- const { Fraction } = require("./math.js");
2
-
3
- // ============================================================================
4
- // ABC PARSING UTILITIES
5
- // ============================================================================
6
- //
7
- // SUPPORTED FEATURES (ABC v2.1):
8
- // - Basic note notation (pitch, octave markers, accidentals)
9
- // - Duration modifiers (explicit numbers, fractions, slashes)
10
- // - Rests/silences (z, x)
11
- // - Dummy note: y (for spacing/alignment)
12
- // - Back quotes: ` (ignored spacing for legibility, preserved in metadata)
13
- // - Triplets: (3ABC, (3A/B/C/, (3A2B2C2
14
- // - Repeat notation: |:, :|, |1, |2, etc.
15
- // - Bar lines: |, ||, |], [|, etc.
16
- // - Decorations: symbol decorations (~.MPSTHUV) and !name! decorations
17
- // - Chord symbols: "Dm7", "G", etc.
18
- // - Chords (multiple notes): [CEG], [CEG]2, etc.
19
- // - Annotations: "^text", "<text", etc. (parsed but position markers preserved)
20
- // - Inline fields: [K:...], [L:...], [M:...], [P:...]
21
- // - Inline comments: % comment text
22
- // - Line continuations: \ at end of line
23
- // - Beaming: tracks whitespace between notes for beam grouping
24
- // - Line breaks: preserves information about newlines in music
25
- //
26
- // NOT YET SUPPORTED:
27
- // - Grace notes: {ABC}
28
- // - Slurs and ties: (), -
29
- // - Lyrics: w: lines
30
- // - Multiple voices: V: fields
31
- // - Macros and user-defined symbols
32
- // - MIDI directives
33
- // - Stylesheet directives
34
- // - Many header fields (only X, T, M, L, K extracted)
35
- //
36
- // ============================================================================
37
-
38
- // Note degree mapping for chord topmost note detection
39
- const NOTE_TO_DEGREE = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 };
40
-
41
- /**
42
- * Extract key signature from ABC header
43
- */
44
- function getTonalBase(abc) {
45
- const keyMatch = abc.match(/^K:\s*([A-G])/m);
46
- if (!keyMatch) {
47
- throw new Error("No key signature found in ABC");
48
- }
49
- return keyMatch[1].toUpperCase();
50
- }
51
-
52
- /**
53
- * Extract meter from ABC header
54
- */
55
- function getMeter(abc) {
56
- const meterMatch = abc.match(/^M:\s*(\d+)\/(\d+)/m);
57
- if (meterMatch) {
58
- return [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
59
- }
60
- return [4, 4]; // Default to 4/4
61
- }
62
-
63
- /**
64
- * Extract unit note length as a Fraction object
65
- */
66
- function getUnitLength(abc) {
67
- const lengthMatch = abc.match(/^L:\s*(\d+)\/(\d+)/m);
68
- if (lengthMatch) {
69
- return new Fraction(parseInt(lengthMatch[1]), parseInt(lengthMatch[2]));
70
- }
71
- return new Fraction(1, 8); // Default to 1/8
72
- }
73
-
74
- /**
75
- * Process ABC lines: extract music lines with metadata
76
- * Handles comments, line continuations, and separates headers from music
77
- * Preserves newline positions for layout tracking
78
- *
79
- * @param {string} abc - ABC notation string
80
- * @returns {object} - { musicText, lineMetadata, newlinePositions, headerLines, headerEndIndex }
81
- */
82
- function getMusicLines(abc) {
83
- const lines = abc.split("\n");
84
- const musicLines = [];
85
- const lineMetadata = [];
86
- const newlinePositions = [];
87
- const headerLines = [];
88
- let headerEndIndex = 0;
89
- let inHeaders = true;
90
- let currentPos = 0;
91
-
92
- for (let i = 0; i < lines.length; i++) {
93
- const line = lines[i];
94
- let trimmed = line.trim();
95
-
96
- // Skip empty lines and comment-only lines
97
- if (trimmed === "" || trimmed.startsWith("%")) {
98
- if (inHeaders) {
99
- headerEndIndex = i + 1;
100
- }
101
- continue;
102
- }
103
-
104
- // Check for header lines
105
- if (inHeaders && trimmed.match(/^[A-Z]:/)) {
106
- headerLines.push(line);
107
- headerEndIndex = i + 1;
108
- continue;
109
- }
110
- inHeaders = false;
111
-
112
- // Extract inline comment if present
113
- const commentMatch = trimmed.match(/\s*%(.*)$/);
114
- const comment = commentMatch ? commentMatch[1].trim() : null;
115
-
116
- // Check for line continuation
117
- const hasContinuation = trimmed.match(/\\\s*(%|$)/) !== null;
118
-
119
- // Remove inline comments and line continuation marker
120
- trimmed = trimmed.replace(/\s*%.*$/, "").trim();
121
- trimmed = trimmed.replace(/\\\s*$/, "").trim();
122
-
123
- if (trimmed) {
124
- musicLines.push(trimmed);
125
- lineMetadata.push({
126
- lineIndex: i,
127
- originalLine: line,
128
- content: trimmed,
129
- comment,
130
- hasContinuation,
131
- });
132
-
133
- // Track position where newline would be (unless continuation)
134
- if (!hasContinuation && musicLines.length > 1) {
135
- newlinePositions.push(currentPos);
136
- }
137
-
138
- currentPos += trimmed.length + 1; // +1 for the space we'll add when joining
139
- }
140
- }
141
-
142
- return {
143
- musicText: musicLines.join("\n"),
144
- lineMetadata,
145
- newlinePositions,
146
- headerLines,
147
- headerEndIndex,
148
- };
149
- }
150
-
151
- // /**
152
- // * Expand triplet notation into fractional durations
153
- // * Converts (3ABC -> A2/3 B2/3 C2/3, etc.
154
- // * Also strips back quotes (`) which are ignored spacing characters
155
- // *
156
- // * @param {string} music - Music text
157
- // * @returns {string} - Music with expanded triplets and stripped back quotes
158
- // */
159
- // function expandTriplets(music) {
160
- // return music
161
- // // Remove back quotes (ignored spacing characters per ABC spec 4.7)
162
- // .replace(/`/g, '')
163
- // // Simple triplets: (3CDE -> C2/3 D2/3 E2/3
164
- // .replace(/\(3:?([A-Ga-g][',]*)([A-Ga-g][',]*)([A-Ga-g][',]*)(?![/0-9])/g, '$12/3$22/3$32/3')
165
- // // Triplets with slashes: (3C/D/E/ -> C1/3 D1/3 E1/3
166
- // .replace(/\(3:?([A-Ga-g][',]*)\/([A-Ga-g][',]*)\/([A-Ga-g][',]*)\/(?![/0-9])/g, '$11/3$21/3$31/3')
167
- // // Triplets with double length: (3C2D2E2 -> C4/3 D4/3 E4/3
168
- // .replace(/\(3:?([A-Ga-g][',]*)2([A-Ga-g][',]*)2([A-Ga-g][',]*)2(?![/0-9])/g, '$14/3$24/3$34/3');
169
- // }
170
-
171
- // /**
172
- // * condense fractional durations into triplet notation
173
- // * Converts A2/3 B2/3 C2/3 -> (3ABC etc.
174
- // *
175
- // * @param {string} music - Music text
176
- // * @returns {string} - Music with condensed triplets
177
- // */
178
- // function condenseTriplets(music) {
179
- // return music
180
- // // Simple triplets: C2/3D2/3E2/3 -> (3CDE
181
- // .replace(/([A-Ga-g][',]*)2\/3([A-Ga-g][',]*)2\/3([A-Ga-g][',]*)2\/3/g, '(3$1$2$3')
182
- // // Triplets with slashes: C1/3D1/3E1/3 -> (3C/D/E/
183
- // .replace(/([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3/g, '(3$1/$2/$3/')
184
- // // Triplets with double length: (3C2D2E2 <- C4/3D4/3E4/3
185
- // .replace(/([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3/g, '(3$12$22$32')
186
- // }
187
-
188
- /**
189
- * Parse decorations/ornaments from a token
190
- * Returns array of decoration symbols found
191
- */
192
- function parseDecorations(noteStr) {
193
- const decorations = [];
194
-
195
- // Symbol decorations (prefix the note)
196
- const symbolDecorations = {
197
- "~": "roll",
198
- ".": "staccato",
199
- M: "lowermordent",
200
- P: "uppermordent",
201
- S: "segno",
202
- T: "trill",
203
- H: "fermata",
204
- u: "upbow",
205
- v: "downbow",
206
- };
207
-
208
- for (const [symbol, name] of Object.entries(symbolDecorations)) {
209
- if (noteStr.includes(symbol)) {
210
- decorations.push(name);
211
- }
212
- }
213
-
214
- // !decoration! style (can be anywhere in string)
215
- const bangDecorations = noteStr.match(/!([^!]+)!/g);
216
- if (bangDecorations) {
217
- bangDecorations.forEach((dec) => {
218
- const name = dec.slice(1, -1); // Remove ! marks
219
- decorations.push(name);
220
- });
221
- }
222
-
223
- return decorations.length > 0 ? decorations : null;
224
- }
225
-
226
- /**
227
- * Parse chord symbols from a token
228
- * Returns chord symbol string or null
229
- */
230
- function parseChordSymbol(noteStr) {
231
- const chordMatch = noteStr.match(/"([^"]+)"/);
232
- return chordMatch ? chordMatch[1] : null;
233
- }
234
-
235
- /**
236
- * Parse annotations from a token
237
- * Returns annotation text or null
238
- */
239
- function parseAnnotation(noteStr) {
240
- // Annotations can be in quotes with position markers like "^text" or "<text"
241
- const annotationMatch = noteStr.match(/"([<>^_@])([^"]+)"/);
242
- if (annotationMatch) {
243
- return {
244
- position: annotationMatch[1],
245
- text: annotationMatch[2],
246
- };
247
- }
248
- return null;
249
- }
250
-
251
- /**
252
- * Strip decorations, chords, and annotations from a note string
253
- * Returns clean note string for duration/pitch parsing
254
- */
255
- function stripExtras(noteStr) {
256
- return noteStr
257
- .replace(/!([^!]+)!/g, "") // Remove !decorations!
258
- .replace(/"[^"]*"/g, "") // Remove "chords" and "annotations"
259
- .replace(/[~.MPSTHUV]/g, ""); // Remove symbol decorations
260
- }
261
-
262
- /**
263
- * Analyze whitespace and back quotes after a token
264
- * Returns object describing the spacing/beaming context
265
- * Back quotes (`) are ignored for beaming but preserved for reconstruction
266
- */
267
- function analyzeSpacing(segment, tokenEndPos) {
268
- if (tokenEndPos >= segment.length) {
269
- return {
270
- whitespace: "",
271
- backquotes: 0,
272
- beamBreak: false,
273
- lineBreak: false,
274
- };
275
- }
276
-
277
- const remaining = segment.substring(tokenEndPos);
278
-
279
- // Match whitespace and/or back quotes
280
- const spacingMatch = remaining.match(/^([\s`]+)/);
281
-
282
- if (!spacingMatch) {
283
- return {
284
- whitespace: "",
285
- backquotes: 0,
286
- beamBreak: false,
287
- lineBreak: false,
288
- };
289
- }
290
-
291
- const fullSpacing = spacingMatch[1];
292
-
293
- // Count back quotes
294
- const backquotes = (fullSpacing.match(/`/g) || []).length;
295
-
296
- // Extract just whitespace (no back quotes)
297
- const whitespace = fullSpacing.replace(/`/g, "");
298
-
299
- return {
300
- whitespace,
301
- backquotes,
302
- beamBreak: whitespace.length > 1 || whitespace.includes("\n"), // Multiple spaces or newline breaks beam
303
- lineBreak: whitespace.includes("\n"),
304
- };
305
- }
306
-
307
- /**
308
- * Parse ABC note to extract pitch, octave, duration, and metadata
309
- * For chords in brackets, extracts the topmost note for melody contour analysis
310
- */
311
- function parseNote(noteStr, unitLength, currentTuple) {
312
- // Extract metadata before stripping
313
- const decorations = parseDecorations(noteStr);
314
- const chordSymbol = parseChordSymbol(noteStr);
315
- const annotation = parseAnnotation(noteStr);
316
-
317
- // Strip extras for core parsing
318
- const cleanStr = stripExtras(noteStr);
319
-
320
- // Handle dummy note 'y' (invisible placeholder)
321
- if (cleanStr.match(/^y$/)) {
322
- return {
323
- isDummy: true,
324
- duration: new Fraction(0, 1, decorations, annotation),
325
- };
326
- }
327
-
328
- // Handle chords - extract topmost note for contour sorting
329
- if (cleanStr.match(/^\[.*\]/)) {
330
- const chord = parseChord(noteStr, unitLength);
331
- if (chord && chord.notes && chord.notes.length > 0) {
332
- // Find topmost note (highest pitch + octave)
333
- let topNote = chord.notes[0];
334
- for (const note of chord.notes) {
335
- if (note.isSilence) {
336
- continue;
337
- }
338
- const topPos =
339
- (topNote.octave || 0) * 7 +
340
- (NOTE_TO_DEGREE[topNote.pitch?.toUpperCase()] || 0);
341
- const notePos =
342
- (note.octave || 0) * 7 +
343
- (NOTE_TO_DEGREE[note.pitch?.toUpperCase()] || 0);
344
- if (notePos > topPos) {
345
- topNote = note;
346
- }
347
- }
348
-
349
- const duration = getDuration({
350
- unitLength,
351
- noteString: cleanStr,
352
- currentTuple,
353
- });
354
- topNote.duration = duration;
355
- // Apply duration to all notes in chord
356
- chord.notes.forEach((note) => {
357
- note.duration = duration;
358
- });
359
- // Return top note with chord metadata
360
- return {
361
- ...topNote,
362
- isChord: true,
363
- chordNotes: chord.notes,
364
- decorations: decorations || chord.decorations,
365
- chordSymbol: chordSymbol || chord.chordSymbol,
366
- annotation,
367
- };
368
- }
369
- }
370
-
371
- // Check for rest/silence
372
- const silenceMatch = cleanStr.match(/^[zx]/);
373
- if (silenceMatch) {
374
- const duration = getDuration({
375
- unitLength,
376
- noteString: cleanStr,
377
- currentTuple,
378
- });
379
- const result = { isSilence: true, duration, text: silenceMatch[0] };
380
- if (decorations) {
381
- result.decorations = decorations;
382
- }
383
- if (chordSymbol) {
384
- result.chordSymbol = chordSymbol;
385
- }
386
- if (annotation) {
387
- result.annotation = annotation;
388
- }
389
- return result;
390
- }
391
-
392
- const { pitch, octave } = getPitch(cleanStr);
393
-
394
- const duration = getDuration({
395
- unitLength,
396
- noteString: cleanStr,
397
- currentTuple,
398
- });
399
-
400
- const result = { pitch, octave, duration, isSilence: false };
401
- if (decorations) {
402
- result.decorations = decorations;
403
- }
404
- if (chordSymbol) {
405
- result.chordSymbol = chordSymbol;
406
- }
407
- if (annotation) {
408
- result.annotation = annotation;
409
- }
410
- return result;
411
- }
412
-
413
- function getPitch(pitchStr) {
414
- const pitchMatch = pitchStr.match(/[A-Ga-g]/);
415
- if (!pitchMatch) {
416
- return null;
417
- }
418
-
419
- const pitch = pitchMatch[0];
420
-
421
- // Count octave modifiers
422
- const upOctaves = (pitchStr.match(/'/g) || []).length;
423
- const downOctaves = (pitchStr.match(/,/g) || []).length;
424
- const octave = upOctaves - downOctaves;
425
- return { pitch, octave };
426
- }
427
-
428
- /**
429
- * Parse a chord (multiple notes in brackets)
430
- * Returns array of note objects or null
431
- */
432
- function parseChord(chordStr, unitLength) {
433
- if (!chordStr.startsWith("[") || !chordStr.endsWith("]")) {
434
- return null;
435
- }
436
-
437
- // Split into individual notes
438
- const noteMatches = chordStr.match(/[=^_]?[A-Ga-g][',]*/g);
439
- if (!noteMatches) {
440
- return null;
441
- }
442
-
443
- const notes = [];
444
- // const clonedTuple = currentTuple ? {... currentTuple} : undefined
445
- for (const noteStr of noteMatches) {
446
- const note = getPitch(noteStr, unitLength);
447
- if (note) {
448
- notes.push(note);
449
- }
450
- }
451
- return {
452
- isChord: true,
453
- notes,
454
- };
455
- }
456
-
457
- function getDuration({ unitLength, noteString, currentTuple } = {}) {
458
- // Parse duration as Fraction
459
- let duration = unitLength.clone();
460
-
461
- // Handle explicit fractions (e.g., '3/2', '2/4', '/4')
462
- const fracMatch = noteString.match(/(\d+)?\/(\d+)/);
463
- if (fracMatch) {
464
- const n = fracMatch[1] ? parseInt(fracMatch[1]) : 1;
465
- duration = unitLength.multiply(n).divide(parseInt(fracMatch[2]));
466
- } else {
467
- // Handle explicit multipliers (e.g., '2', '3')
468
- const multMatch = noteString.match(/(\d+)(?!'[/]')/);
469
- if (multMatch) {
470
- duration = duration.multiply(parseInt(multMatch[1]));
471
- }
472
-
473
- // Handle divisions (e.g., '/', '//', '///')
474
- const divMatch = noteString.match(/\/+/);
475
- if (divMatch) {
476
- const slashes = divMatch[0].length;
477
- duration = duration.divide(Math.pow(2, slashes));
478
- }
479
- }
480
-
481
- if (currentTuple) {
482
- duration = duration.divide(currentTuple.p).multiply(currentTuple.q);
483
- currentTuple.r--;
484
- }
485
- return duration;
486
- }
487
-
488
- const getTokenRegex = () =>
489
- /\(\d(?::\d?){0,2}|\[([KLMP]):[^\]]+\]|"[^"]+"|(?:!([^!]+)!\s*)?[~.MPSTHUV]*[=^_]?[A-Ga-gzxy][',]*[0-9]*\/?[0-9]*|!([^!]+)!|[~.MPSTHUV]*\[[^\]]+\][0-9/]*/g;
490
-
491
- /**
492
- * Parse inline field from music section
493
- * Returns { field, value } or null
494
- */
495
- function parseInlineField(token) {
496
- const fieldMatch = token.match(/^\[([KLMP]):\s*(.+)\]$/);
497
- if (fieldMatch) {
498
- return {
499
- field: fieldMatch[1],
500
- value: fieldMatch[2].trim(),
501
- };
502
- }
503
- return null;
504
- }
505
-
506
- /**
507
- * Parse tuple from music section
508
- */
509
- function parseTuple(token, isCompoundTimeSignature) {
510
- const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
511
- if (tupleMatch) {
512
- const pqr = {
513
- p: parseInt(tupleMatch[1]),
514
- q: tupleMatch[2],
515
- r: tupleMatch[3],
516
- };
517
- const { p } = pqr;
518
- let { q, r } = pqr;
519
- if (q) {
520
- q = parseInt(q);
521
- } else {
522
- switch (p) {
523
- case 2:
524
- q = 3;
525
- break;
526
- case 3:
527
- q = 2;
528
- break;
529
- case 4:
530
- q = 3;
531
- break;
532
- case 5:
533
- case 7:
534
- case 9:
535
- q = isCompoundTimeSignature ? 3 : 2;
536
- break;
537
- case 6:
538
- q = 2;
539
- break;
540
- case 8:
541
- q = 3;
542
- break;
543
- }
544
- }
545
- if (r) {
546
- r = parseInt(r);
547
- } else {
548
- r = p;
549
- }
550
- return {
551
- isTuple: true,
552
- p,
553
- q,
554
- r,
555
- };
556
- }
557
- return null;
558
- }
559
-
560
- /**
561
- * Classify bar line type
562
- * Returns object with type classification and properties
563
- */
564
- function classifyBarLine(barLineStr) {
565
- const trimmed = barLineStr.trim();
566
-
567
- // Repeat endings
568
- if (trimmed.match(/^\|[1-6]$/)) {
569
- return {
570
- type: "repeat-ending",
571
- ending: parseInt(trimmed[1]),
572
- text: barLineStr,
573
- isRepeat: true,
574
- };
575
- }
576
-
577
- // Start repeat
578
- if (trimmed.match(/^\|:/) || trimmed.match(/^\[\|/)) {
579
- return {
580
- type: "repeat-start",
581
- text: barLineStr,
582
- isRepeat: true,
583
- };
584
- }
585
-
586
- // End repeat
587
- if (
588
- trimmed.match(/^:\|/) ||
589
- (trimmed.match(/^\|\]/) && !trimmed.match(/^\|\]$/))
590
- ) {
591
- return {
592
- type: "repeat-end",
593
- text: barLineStr,
594
- isRepeat: true,
595
- };
596
- }
597
-
598
- // Double repeat
599
- if (
600
- trimmed.match(/^::/) ||
601
- trimmed.match(/^:\|:/) ||
602
- trimmed.match(/^::\|:?/) ||
603
- trimmed.match(/^::\|\|:?/)
604
- ) {
605
- return {
606
- type: "repeat-both",
607
- text: barLineStr,
608
- isRepeat: true,
609
- };
610
- }
611
-
612
- // Final bar
613
- if (trimmed === "|]") {
614
- return {
615
- type: "final",
616
- text: barLineStr,
617
- isRepeat: false,
618
- };
619
- }
620
-
621
- // Double bar
622
- if (trimmed === "||") {
623
- return {
624
- type: "double",
625
- text: barLineStr,
626
- isRepeat: false,
627
- };
628
- }
629
-
630
- // Regular bar
631
- if (trimmed === "|") {
632
- return {
633
- type: "regular",
634
- text: barLineStr,
635
- isRepeat: false,
636
- };
637
- }
638
-
639
- // Unknown/complex bar line
640
- return {
641
- type: "other",
642
- text: barLineStr,
643
- isRepeat: trimmed.includes(":"),
644
- };
645
- }
646
-
647
- /**
648
- * Parse ABC into structured data with bars
649
- *
650
- * Returns object with:
651
- * {
652
- * bars: Array<Array<NoteObject>>, // Array of bars, each bar is array of notes/chords/fields
653
- * unitLength: Fraction, // The L: field value (default 1/8)
654
- * meter: [number, number], // The M: field value (default [4,4])
655
- * tonalBase: string, // The tonic from K: field (e.g., 'D', 'G')
656
- * lineMetadata: Array<LineMetadata> // Info about original lines (comments, continuations)
657
- * }
658
- *
659
- * NoteObject structure (regular note):
660
- * {
661
- * pitch: string, // 'A'-'G' (uppercase for low octave, lowercase for middle)
662
- * octave: number, // Relative octave offset (0 = middle, +1 = high, -1 = low)
663
- * duration: Fraction, // Note duration as fraction of whole note
664
- * isSilence: false, // Always false for pitched notes
665
- * token: string, // Original ABC token (e.g., 'D2', '^F/')
666
- * spacing: { // Whitespace/beaming info after this token
667
- * whitespace: string, // Actual whitespace characters (back quotes removed)
668
- * backquotes: number, // Number of ` characters for reconstruction
669
- * beamBreak: boolean, // True if beam should break (multiple spaces/newline)
670
- * lineBreak: boolean // True if there was a newline after this token
671
- * },
672
- *
673
- * // Optional properties (only present if applicable):
674
- * decorations: Array<string>, // e.g., ['trill', 'staccato']
675
- * chordSymbol: string, // e.g., 'Dm7', 'G'
676
- * annotation: { // Text annotation with position
677
- * position: string, // '^' (above), '_' (below), '<' (left), '>' (right), '@' (auto)
678
- * text: string
679
- * },
680
- * isChord: true, // Present if this is a chord [CEG]
681
- * chordNotes: Array<NoteObject> // All notes in the chord (when isChord=true)
682
- * }
683
- *
684
- * NoteObject structure (silence/rest):
685
- * {
686
- * isSilence: true,
687
- * duration: Fraction,
688
- * token: string,
689
- * spacing: { ... }, // Same as regular note
690
- * // Optional: decorations, chordSymbol, annotation (same as above)
691
- * }
692
- *
693
- * NoteObject structure (dummy note):
694
- * {
695
- * isDummy: true,
696
- * duration: Fraction,
697
- * token: string,
698
- * spacing: { ... }
699
- * }
700
- *
701
- * NoteObject structure (inline field change):
702
- * {
703
- * isInlineField: true,
704
- * field: string, // 'K', 'L', 'M', or 'P'
705
- * value: string, // The field value (e.g., 'G major', '3/4')
706
- * token: string // Original token (e.g., '[K:G]')
707
- * spacing: { ... }
708
- * }
709
- *
710
- * NoteObject structure (standalone chord symbol):
711
- * {
712
- * isChordSymbol: true,
713
- * chordSymbol: string, // The chord name
714
- * token: string,
715
- * spacing: { ... }
716
- * }
717
- *
718
- * LineMetadata structure:
719
- * {
720
- * originalLine: string, // Complete original line from ABC
721
- * comment: string | null, // Text after % (null if no comment)
722
- * hasContinuation: boolean // Whether line had \ continuation marker
723
- * }
724
- *
725
- * @param {string} abc - ABC notation string
726
- * @param {object} options - Parsing options
727
- * @param {number} options.maxBars - Maximum number of bars to parse (optional)
728
- * @returns {object} - Parsed structure as described above
729
- *
730
- * Example:
731
- * parseABCWithBars('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
732
- * // Returns:
733
- * {
734
- * bars: [
735
- * [
736
- * { isChordSymbol: true, chordSymbol: 'Dm', spacing: {...}, ... },
737
- * { pitch: 'D', octave: 0, duration: Fraction(1,2), chordSymbol: 'Dm', spacing: {...}, ... },
738
- * { pitch: 'F', octave: 0, duration: Fraction(1,4), isChord: true, chordNotes: [...], spacing: {...}, ... },
739
- * { pitch: 'A', octave: 0, duration: Fraction(1,4), spacing: {...}, ... }
740
- * ],
741
- * [
742
- * { pitch: 'B', octave: 0, duration: Fraction(1,1), decorations: ['roll'], spacing: {...}, ... }
743
- * ]
744
- * ],
745
- * unitLength: Fraction(1,4),
746
- * meter: [4,4],
747
- * tonalBase: 'D',
748
- * lineMetadata: [...]
749
- * }
750
- */
751
- function parseABCWithBars(abc, options = {}) {
752
- const { maxBars = Infinity } = options;
753
-
754
- let unitLength = getUnitLength(abc);
755
- let meter = getMeter(abc);
756
- let tonalBase = getTonalBase(abc);
757
-
758
- const {
759
- musicText,
760
- lineMetadata,
761
- headerLines,
762
- headerEndIndex,
763
- newlinePositions,
764
- } = getMusicLines(abc);
765
-
766
- // Create a Set of newline positions for O(1) lookup
767
- const newlineSet = new Set(newlinePositions);
768
-
769
- // Comprehensive bar line regex - includes trailing spaces
770
- const barLineRegex = /(\|\]|\[\||(\|:?)|(:?\|)|::|(\|[1-6])) */g;
771
-
772
- const bars = [];
773
- const barLines = [];
774
- let currentBar = [];
775
- let barCount = 0;
776
-
777
- // Split music text by bar lines while preserving positions
778
- let lastBarPos = 0;
779
- let match;
780
- let first = true;
781
-
782
- while ((match = barLineRegex.exec(musicText)) !== null || first) {
783
- first = false;
784
- const { barLineText, barLinePos } =
785
- match === null
786
- ? { barLineText: musicText, barLinePos: musicText.length }
787
- : {
788
- barLineText: match[0],
789
- barLinePos: match.index,
790
- };
791
-
792
- // Process segment before this bar line
793
- const segment = musicText.substring(lastBarPos, barLinePos);
794
-
795
- if (segment.trim()) {
796
- // Parse tokens in this segment
797
- // Match: inline fields, chord symbols, chords in brackets, decorations, notes/rests/dummy
798
- const tokenRegex = getTokenRegex();
799
-
800
- let tokenMatch;
801
- // let segmentPos = lastBarPos;
802
-
803
- let currentTuple = null;
804
-
805
- while ((tokenMatch = tokenRegex.exec(segment)) !== null) {
806
- //check if all notes of the tuple have been parsed
807
- if (currentTuple && currentTuple.r === 0) {
808
- currentTuple = null;
809
- }
810
- const fullToken = tokenMatch[0];
811
- const tokenStartPos = lastBarPos + tokenMatch.index;
812
- // const tokenEndPos = tokenStartPos + fullToken.length;
813
- const spacing = analyzeSpacing(
814
- segment,
815
- tokenMatch.index + fullToken.length
816
- );
817
-
818
- // Check for inline field
819
- const inlineField = parseInlineField(fullToken);
820
- if (inlineField) {
821
- // Update context based on inline field
822
- if (inlineField.field === "L") {
823
- const lengthMatch = inlineField.value.match(/1\/(\d+)/);
824
- if (lengthMatch) {
825
- unitLength = new Fraction(1, parseInt(lengthMatch[1]));
826
- }
827
- } else if (inlineField.field === "M") {
828
- const meterMatch = inlineField.value.match(/(\d+)\/(\d+)/);
829
- if (meterMatch) {
830
- meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
831
- }
832
- } else if (inlineField.field === "K") {
833
- const keyMatch = inlineField.value.match(/^([A-G])/);
834
- if (keyMatch) {
835
- tonalBase = keyMatch[1].toUpperCase();
836
- }
837
- }
838
-
839
- currentBar.push({
840
- isInlineField: true,
841
- field: inlineField.field,
842
- value: inlineField.value,
843
- token: fullToken,
844
- sourceIndex: tokenStartPos,
845
- sourceLength: fullToken.length,
846
- spacing,
847
- });
848
- continue;
849
- }
850
-
851
- // tuples
852
- if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
853
- const tuple = parseTuple(fullToken);
854
- if (tuple) {
855
- if (currentTuple) {
856
- throw new Error("nested tuples not handled");
857
- }
858
- // Update context based on inline field
859
- currentTuple = tuple;
860
- currentBar.push({
861
- ...tuple,
862
- token: fullToken,
863
- sourceIndex: tokenStartPos,
864
- sourceLength: fullToken.length,
865
- });
866
- continue;
867
- }
868
- }
869
-
870
- // Standalone chord symbol
871
- if (fullToken.match(/^"[^"]+"$/)) {
872
- currentBar.push({
873
- isChordSymbol: true,
874
- chordSymbol: fullToken.slice(1, -1),
875
- token: fullToken,
876
- sourceIndex: tokenStartPos,
877
- sourceLength: fullToken.length,
878
- spacing,
879
- });
880
- continue;
881
- }
882
-
883
- // Standalone decoration
884
- if (fullToken.match(/^!([^!]+)!$/)) {
885
- currentBar.push({
886
- isDecoration: true,
887
- decoration: fullToken.slice(1, -1),
888
- token: fullToken,
889
- sourceIndex: tokenStartPos,
890
- sourceLength: fullToken.length,
891
- spacing,
892
- });
893
- continue;
894
- }
895
-
896
- // Regular note, rest, or dummy, or chord in brackets
897
- const note = parseNote(fullToken, unitLength, currentTuple);
898
- if (note) {
899
- currentBar.push({
900
- ...note,
901
- token: fullToken,
902
- sourceIndex: tokenStartPos,
903
- sourceLength: fullToken.length,
904
- spacing,
905
- });
906
- }
907
- }
908
- }
909
-
910
- // Check if bar line has a newline after it
911
- const barLineEndPos = barLinePos + barLineText.length;
912
- const hasLineBreakAfterBar =
913
- newlineSet.has(barLineEndPos + 1) ||
914
- (barLineEndPos < musicText.length && musicText[barLineEndPos] === "\n");
915
-
916
- // Store bar line information
917
- const barLineInfo = classifyBarLine(barLineText);
918
- barLines.push({
919
- ...barLineInfo,
920
- sourceIndex: barLinePos,
921
- sourceLength: barLineText.length,
922
- barNumber: barCount,
923
- hasLineBreak: hasLineBreakAfterBar,
924
- });
925
-
926
- // Update the last token in current bar to mark lineBreak if bar line has one
927
- if (currentBar.length > 0 && hasLineBreakAfterBar) {
928
- const lastToken = currentBar[currentBar.length - 1];
929
- if (lastToken.spacing) {
930
- lastToken.spacing.lineBreak = true;
931
- }
932
- }
933
-
934
- // Save current bar if it has content
935
- if (currentBar.length > 0) {
936
- bars.push(currentBar);
937
- barCount++;
938
- currentBar = [];
939
-
940
- // Check if we've reached max bars
941
- if (barCount >= maxBars) {
942
- break;
943
- }
944
- }
945
-
946
- lastBarPos = barLineEndPos;
947
- }
948
-
949
- // Add final bar if it has content and we haven't reached max
950
- if (currentBar.length > 0 && barCount < maxBars) {
951
- bars.push(currentBar);
952
- }
953
-
954
- return {
955
- bars,
956
- barLines,
957
- unitLength,
958
- meter,
959
- tonalBase,
960
- lineMetadata,
961
- headerLines,
962
- headerEndIndex,
963
- musicText,
964
- };
965
- }
966
-
967
- /**
968
- * Calculate bar durations from parsed ABC data
969
- * Returns duration for each bar
970
- */
971
- function calculateBarDurations(parsedData) {
972
- const { bars } = parsedData;
973
-
974
- return bars.map((bar) => {
975
- let total = new Fraction(0, 1);
976
- for (const note of bar) {
977
- if (!note.duration) {
978
- continue;
979
- }
980
- total = total.add(note.duration);
981
- }
982
- return total;
983
- });
984
- }
985
-
986
- module.exports = {
987
- getTonalBase,
988
- getMeter,
989
- getUnitLength,
990
- getMusicLines,
991
- analyzeSpacing,
992
- parseABCWithBars,
993
- classifyBarLine,
994
- calculateBarDurations,
995
- NOTE_TO_DEGREE,
996
- };
1
+ const { Fraction } = require("./math.js");
2
+
3
+ // ============================================================================
4
+ // ABC PARSING UTILITIES
5
+ // ============================================================================
6
+ //
7
+ // SUPPORTED FEATURES (ABC v2.1):
8
+ // - Basic note notation (pitch, octave markers, accidentals)
9
+ // - Duration modifiers (explicit numbers, fractions, slashes)
10
+ // - Rests/silences (z, x)
11
+ // - Dummy note: y (for spacing/alignment)
12
+ // - Repeat notation: |:, :|, |1, |2, etc.
13
+ // - Bar lines: |, ||, |], [|, etc.
14
+ // - Decorations: symbol decorations (~.MPSTHUV) and !name! decorations
15
+ // - Chord symbols: "Dm7", "G", etc.
16
+ // - Chords (multiple notes): [CEG], [CEG]2, etc.
17
+ // - Annotations: "^text", "<text", etc. (parsed but position markers preserved)
18
+ // - Inline fields: [K:...], [L:...], [M:...], [P:...]
19
+ // - Inline comments: % comment text
20
+ // - Line continuations: \ at end of line
21
+ // - Beaming: tracks whitespace between notes for beam grouping
22
+ // - Line breaks: preserves information about newlines in music
23
+ // - Ties: -
24
+ // - Triplets and general tuples (`(p:q:r` format): (3ABC, (3A/B/C/, (3A2B2C2;
25
+ // - Back quotes: ` (ignored spacing for legibility, preserved in metadata)
26
+ //
27
+ // NOT YET SUPPORTED:
28
+ // - Grace notes: {ABC}
29
+ // - Slurs: ()
30
+ // - Lyrics: w: lines
31
+ // - Multiple voices: V: fields
32
+ // - Macros and user-defined symbols
33
+ // - MIDI directives
34
+ // - Stylesheet directives
35
+ // - Many header fields (only X, T, M, L, K extracted)
36
+ //
37
+ // ============================================================================
38
+
39
+ // Note degree mapping for chord topmost note detection
40
+ const NOTE_TO_DEGREE = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 };
41
+
42
+ /**
43
+ * Extract key signature from ABC header
44
+ */
45
+ function getTonalBase(abc) {
46
+ const keyMatch = abc.match(/^K:\s*([A-G])/m);
47
+ if (!keyMatch) {
48
+ throw new Error("No key signature found in ABC");
49
+ }
50
+ return keyMatch[1].toUpperCase();
51
+ }
52
+
53
+ /**
54
+ * Extract meter from ABC header
55
+ */
56
+ function getMeter(abc) {
57
+ const meterMatch = abc.match(/^M:\s*(\d+)\/(\d+)/m);
58
+ if (meterMatch) {
59
+ return [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
60
+ }
61
+ return [4, 4]; // Default to 4/4
62
+ }
63
+
64
+ /**
65
+ * Extract unit note length as a Fraction object
66
+ */
67
+ function getUnitLength(abc) {
68
+ const lengthMatch = abc.match(/^L:\s*(\d+)\/(\d+)/m);
69
+ if (lengthMatch) {
70
+ return new Fraction(parseInt(lengthMatch[1]), parseInt(lengthMatch[2]));
71
+ }
72
+ return new Fraction(1, 8); // Default to 1/8
73
+ }
74
+
75
+ /**
76
+ * Process ABC lines: extract music lines with metadata
77
+ * Handles comments, line continuations, and separates headers from music
78
+ * Preserves newline positions for layout tracking
79
+ *
80
+ * @param {string} abc - ABC notation string
81
+ * @returns {object} - { musicText, lineMetadata, newlinePositions, headerLines, headerEndIndex }
82
+ */
83
+ function getMusicLines(abc) {
84
+ const lines = abc.split("\n");
85
+ const musicLines = [];
86
+ const lineMetadata = [];
87
+ const newlinePositions = [];
88
+ const headerLines = [];
89
+ let headerEndIndex = 0;
90
+ let inHeaders = true;
91
+ let currentPos = 0;
92
+
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const line = lines[i];
95
+ let trimmed = line.trim();
96
+
97
+ // Skip empty lines and comment-only lines
98
+ if (trimmed === "" || trimmed.startsWith("%")) {
99
+ if (inHeaders) {
100
+ headerEndIndex = i + 1;
101
+ }
102
+ continue;
103
+ }
104
+
105
+ // Check for header lines
106
+ if (inHeaders && trimmed.match(/^[A-Z]:/)) {
107
+ headerLines.push(line);
108
+ headerEndIndex = i + 1;
109
+ continue;
110
+ }
111
+ inHeaders = false;
112
+
113
+ // Extract inline comment if present
114
+ const commentMatch = trimmed.match(/\s*%(.*)$/);
115
+ const comment = commentMatch ? commentMatch[1].trim() : null;
116
+
117
+ // Check for line continuation
118
+ const hasContinuation = trimmed.match(/\\\s*(%|$)/) !== null;
119
+
120
+ // Remove inline comments and line continuation marker
121
+ trimmed = trimmed.replace(/\s*%.*$/, "").trim();
122
+ trimmed = trimmed.replace(/\\\s*$/, "").trim();
123
+
124
+ if (trimmed) {
125
+ musicLines.push(trimmed);
126
+ lineMetadata.push({
127
+ lineIndex: i,
128
+ originalLine: line,
129
+ content: trimmed,
130
+ comment,
131
+ hasContinuation,
132
+ });
133
+
134
+ // Track position where newline would be (unless continuation)
135
+ if (!hasContinuation && musicLines.length > 1) {
136
+ newlinePositions.push(currentPos);
137
+ }
138
+
139
+ currentPos += trimmed.length + 1; // +1 for the space we'll add when joining
140
+ }
141
+ }
142
+
143
+ return {
144
+ musicText: musicLines.join("\n"),
145
+ lineMetadata,
146
+ newlinePositions,
147
+ headerLines,
148
+ headerEndIndex,
149
+ };
150
+ }
151
+
152
+ // /**
153
+ // * Expand triplet notation into fractional durations
154
+ // * Converts (3ABC -> A2/3 B2/3 C2/3, etc.
155
+ // * Also strips back quotes (`) which are ignored spacing characters
156
+ // *
157
+ // * @param {string} music - Music text
158
+ // * @returns {string} - Music with expanded triplets and stripped back quotes
159
+ // */
160
+ // function expandTriplets(music) {
161
+ // return music
162
+ // // Remove back quotes (ignored spacing characters per ABC spec 4.7)
163
+ // .replace(/`/g, '')
164
+ // // Simple triplets: (3CDE -> C2/3 D2/3 E2/3
165
+ // .replace(/\(3:?([A-Ga-g][',]*)([A-Ga-g][',]*)([A-Ga-g][',]*)(?![/0-9])/g, '$12/3$22/3$32/3')
166
+ // // Triplets with slashes: (3C/D/E/ -> C1/3 D1/3 E1/3
167
+ // .replace(/\(3:?([A-Ga-g][',]*)\/([A-Ga-g][',]*)\/([A-Ga-g][',]*)\/(?![/0-9])/g, '$11/3$21/3$31/3')
168
+ // // Triplets with double length: (3C2D2E2 -> C4/3 D4/3 E4/3
169
+ // .replace(/\(3:?([A-Ga-g][',]*)2([A-Ga-g][',]*)2([A-Ga-g][',]*)2(?![/0-9])/g, '$14/3$24/3$34/3');
170
+ // }
171
+
172
+ // /**
173
+ // * condense fractional durations into triplet notation
174
+ // * Converts A2/3 B2/3 C2/3 -> (3ABC etc.
175
+ // *
176
+ // * @param {string} music - Music text
177
+ // * @returns {string} - Music with condensed triplets
178
+ // */
179
+ // function condenseTriplets(music) {
180
+ // return music
181
+ // // Simple triplets: C2/3D2/3E2/3 -> (3CDE
182
+ // .replace(/([A-Ga-g][',]*)2\/3([A-Ga-g][',]*)2\/3([A-Ga-g][',]*)2\/3/g, '(3$1$2$3')
183
+ // // Triplets with slashes: C1/3D1/3E1/3 -> (3C/D/E/
184
+ // .replace(/([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3/g, '(3$1/$2/$3/')
185
+ // // Triplets with double length: (3C2D2E2 <- C4/3D4/3E4/3
186
+ // .replace(/([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3([A-Ga-g][',]*)1\/3/g, '(3$12$22$32')
187
+ // }
188
+
189
+ /**
190
+ * Parse decorations/ornaments from a token
191
+ * Returns array of decoration symbols found
192
+ */
193
+ function parseDecorations(noteStr) {
194
+ const decorations = [];
195
+
196
+ // Symbol decorations (prefix the note)
197
+ const symbolDecorations = {
198
+ "~": "roll",
199
+ ".": "staccato",
200
+ M: "lowermordent",
201
+ P: "uppermordent",
202
+ S: "segno",
203
+ T: "trill",
204
+ H: "fermata",
205
+ u: "upbow",
206
+ v: "downbow",
207
+ };
208
+
209
+ for (const [symbol, name] of Object.entries(symbolDecorations)) {
210
+ if (noteStr.includes(symbol)) {
211
+ decorations.push(name);
212
+ }
213
+ }
214
+
215
+ // !decoration! style (can be anywhere in string)
216
+ const bangDecorations = noteStr.match(/!([^!]+)!/g);
217
+ if (bangDecorations) {
218
+ bangDecorations.forEach((dec) => {
219
+ const name = dec.slice(1, -1); // Remove ! marks
220
+ decorations.push(name);
221
+ });
222
+ }
223
+
224
+ return decorations.length > 0 ? decorations : null;
225
+ }
226
+
227
+ /**
228
+ * Parse chord symbols from a token
229
+ * Returns chord symbol string or null
230
+ */
231
+ function parseChordSymbol(noteStr) {
232
+ const chordMatch = noteStr.match(/"([^"]+)"/);
233
+ return chordMatch ? chordMatch[1] : null;
234
+ }
235
+
236
+ /**
237
+ * Parse annotations from a token
238
+ * Returns annotation text or null
239
+ */
240
+ function parseAnnotation(noteStr) {
241
+ // Annotations can be in quotes with position markers like "^text" or "<text"
242
+ const annotationMatch = noteStr.match(/"([<>^_@])([^"]+)"/);
243
+ if (annotationMatch) {
244
+ return {
245
+ position: annotationMatch[1],
246
+ text: annotationMatch[2],
247
+ };
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Strip decorations, chords, and annotations from a note string
254
+ * Returns clean note string for duration/pitch parsing
255
+ */
256
+ function stripExtras(noteStr) {
257
+ return noteStr
258
+ .replace(/!([^!]+)!/g, "") // Remove !decorations!
259
+ .replace(/"[^"]*"/g, "") // Remove "chords" and "annotations"
260
+ .replace(/[~.MPSTHUV]/g, ""); // Remove symbol decorations
261
+ }
262
+
263
+ /**
264
+ * Analyze whitespace and back quotes after a token
265
+ * Returns object describing the spacing/beaming context
266
+ * Back quotes (`) are ignored for beaming but preserved for reconstruction
267
+ */
268
+ function analyzeSpacing(segment, tokenEndPos) {
269
+ if (tokenEndPos >= segment.length) {
270
+ return {
271
+ whitespace: "",
272
+ backquotes: 0,
273
+ beamBreak: false,
274
+ lineBreak: false,
275
+ };
276
+ }
277
+
278
+ const remaining = segment.substring(tokenEndPos);
279
+
280
+ // Match whitespace and/or back quotes
281
+ const spacingMatch = remaining.match(/^([\s`]+)/);
282
+
283
+ if (!spacingMatch) {
284
+ return {
285
+ whitespace: "",
286
+ backquotes: 0,
287
+ beamBreak: false,
288
+ lineBreak: false,
289
+ };
290
+ }
291
+
292
+ const fullSpacing = spacingMatch[1];
293
+
294
+ // Count back quotes
295
+ const backquotes = (fullSpacing.match(/`/g) || []).length;
296
+
297
+ // Extract just whitespace (no back quotes)
298
+ const whitespace = fullSpacing.replace(/`/g, "");
299
+
300
+ return {
301
+ whitespace,
302
+ backquotes,
303
+ beamBreak: whitespace.length > 1 || whitespace.includes("\n"), // Multiple spaces or newline breaks beam
304
+ lineBreak: whitespace.includes("\n"),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Parse ABC note to extract pitch, octave, duration, and metadata
310
+ * For chords in brackets, extracts the topmost note for melody contour analysis
311
+ */
312
+ function parseNote(noteStr, unitLength, currentTuple) {
313
+ // Extract metadata before stripping
314
+ const decorations = parseDecorations(noteStr);
315
+ const chordSymbol = parseChordSymbol(noteStr);
316
+ const annotation = parseAnnotation(noteStr);
317
+
318
+ // Strip extras for core parsing
319
+ const cleanStr = stripExtras(noteStr);
320
+
321
+ // dummy note 'y' (invisible placeholder)
322
+ if (cleanStr.match(/^y$/)) {
323
+ return {
324
+ isDummy: true,
325
+ duration: new Fraction(0, 1, decorations, annotation),
326
+ };
327
+ }
328
+
329
+ // rest/silence
330
+ const silenceMatch = cleanStr.match(/^[zx]/);
331
+ if (silenceMatch) {
332
+ const duration = getDuration({
333
+ unitLength,
334
+ noteString: cleanStr,
335
+ currentTuple,
336
+ });
337
+ const result = { isSilence: true, duration, text: silenceMatch[0] };
338
+ if (decorations) {
339
+ result.decorations = decorations;
340
+ }
341
+ if (chordSymbol) {
342
+ result.chordSymbol = chordSymbol;
343
+ }
344
+ if (annotation) {
345
+ result.annotation = annotation;
346
+ }
347
+ return result;
348
+ }
349
+
350
+ const tied = !!cleanStr.match(/-$/)
351
+ // Handle chords - extract topmost note for contour sorting
352
+ if (cleanStr.match(/^\[.*\]/)) {
353
+ const chord = parseChord(noteStr, unitLength);
354
+ if (chord && chord.notes && chord.notes.length > 0) {
355
+ // Find topmost note (highest pitch + octave)
356
+ let topNote = chord.notes[0];
357
+ for (const note of chord.notes) {
358
+ if (note.isSilence) {
359
+ continue;
360
+ }
361
+ const topPos =
362
+ (topNote.octave || 0) * 7 +
363
+ (NOTE_TO_DEGREE[topNote.pitch?.toUpperCase()] || 0);
364
+ const notePos =
365
+ (note.octave || 0) * 7 +
366
+ (NOTE_TO_DEGREE[note.pitch?.toUpperCase()] || 0);
367
+ if (notePos > topPos) {
368
+ topNote = note;
369
+ }
370
+ }
371
+
372
+ const duration = getDuration({
373
+ unitLength,
374
+ noteString: cleanStr,
375
+ currentTuple,
376
+ });
377
+ topNote.duration = duration;
378
+ // Apply duration to all notes in chord
379
+ chord.notes.forEach((note) => {
380
+ note.duration = duration;
381
+ });
382
+ // Return top note with chord metadata
383
+ return {
384
+ ...topNote,
385
+ annotation,
386
+ chordNotes: chord.notes,
387
+ chordSymbol: chordSymbol || chord.chordSymbol,
388
+ decorations: decorations || chord.decorations,
389
+ isChord: true,
390
+ tied,
391
+ };
392
+ }
393
+ }
394
+
395
+ // single note
396
+ const { pitch, octave } = getPitch(cleanStr);
397
+
398
+ const duration = getDuration({
399
+ unitLength,
400
+ noteString: cleanStr,
401
+ currentTuple,
402
+ });
403
+
404
+ const result = { pitch, octave, duration, tied };
405
+ if (decorations) {
406
+ result.decorations = decorations;
407
+ }
408
+ if (chordSymbol) {
409
+ result.chordSymbol = chordSymbol;
410
+ }
411
+ if (annotation) {
412
+ result.annotation = annotation;
413
+ }
414
+ return result;
415
+ }
416
+
417
+ function getPitch(pitchStr) {
418
+ const pitchMatch = pitchStr.match(/[A-Ga-g]/);
419
+ if (!pitchMatch) {
420
+ return null;
421
+ }
422
+
423
+ const pitch = pitchMatch[0];
424
+
425
+ // Count octave modifiers
426
+ const upOctaves = (pitchStr.match(/'/g) || []).length;
427
+ const downOctaves = (pitchStr.match(/,/g) || []).length;
428
+ const octave = upOctaves - downOctaves;
429
+ return { pitch, octave };
430
+ }
431
+
432
+ /**
433
+ * Parse a chord (multiple notes in brackets)
434
+ * Returns array of note objects or null
435
+ */
436
+ function parseChord(chordStr, unitLength) {
437
+ if (!chordStr.startsWith("[") || !chordStr.endsWith("]")) {
438
+ return null;
439
+ }
440
+
441
+ // Split into individual notes
442
+ const noteMatches = chordStr.match(/[=^_]?[A-Ga-g][',]*/g);
443
+ if (!noteMatches) {
444
+ return null;
445
+ }
446
+
447
+ const notes = [];
448
+ // const clonedTuple = currentTuple ? {... currentTuple} : undefined
449
+ for (const noteStr of noteMatches) {
450
+ const note = getPitch(noteStr, unitLength);
451
+ if (note) {
452
+ notes.push(note);
453
+ }
454
+ }
455
+ return {
456
+ isChord: true,
457
+ notes,
458
+ };
459
+ }
460
+
461
+ function getDuration({ unitLength, noteString, currentTuple } = {}) {
462
+ // Parse duration as Fraction
463
+ let duration = unitLength.clone();
464
+
465
+ // Handle explicit fractions (e.g., '3/2', '2/4', '/4')
466
+ const fracMatch = noteString.match(/(\d+)?\/(\d+)/);
467
+ if (fracMatch) {
468
+ const n = fracMatch[1] ? parseInt(fracMatch[1]) : 1;
469
+ duration = unitLength.multiply(n).divide(parseInt(fracMatch[2]));
470
+ } else {
471
+ // Handle explicit multipliers (e.g., '2', '3')
472
+ const multMatch = noteString.match(/(\d+)(?!'[/]')/);
473
+ if (multMatch) {
474
+ duration = duration.multiply(parseInt(multMatch[1]));
475
+ }
476
+
477
+ // Handle divisions (e.g., '/', '//', '///')
478
+ const divMatch = noteString.match(/\/+/);
479
+ if (divMatch) {
480
+ const slashes = divMatch[0].length;
481
+ duration = duration.divide(Math.pow(2, slashes));
482
+ }
483
+ }
484
+
485
+ if (currentTuple) {
486
+ duration = duration.divide(currentTuple.p).multiply(currentTuple.q);
487
+ currentTuple.r--;
488
+ }
489
+ return duration;
490
+ }
491
+
492
+ const getTokenRegex = () =>
493
+ /\(\d(?::\d?){0,2}|\[([KLMP]):[^\]]+\]|"[^"]+"|(?:!([^!]+)!\s*)?[~.MPSTHUV]*[=^_]?(?:[A-Ga-gzxy]|\[[A-Ga-gzxy]+\])[',]*[0-9]*\/?[0-9]*-?|!([^!]+)!/g;
494
+
495
+ /**
496
+ * Parse inline field from music section
497
+ * Returns { field, value } or null
498
+ */
499
+ function parseInlineField(token) {
500
+ const fieldMatch = token.match(/^\[([KLMP]):\s*(.+)\]$/);
501
+ if (fieldMatch) {
502
+ return {
503
+ field: fieldMatch[1],
504
+ value: fieldMatch[2].trim(),
505
+ };
506
+ }
507
+ return null;
508
+ }
509
+
510
+ /**
511
+ * Parse tuple from music section
512
+ */
513
+ function parseTuple(token, isCompoundTimeSignature) {
514
+ const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
515
+ if (tupleMatch) {
516
+ const pqr = {
517
+ p: parseInt(tupleMatch[1]),
518
+ q: tupleMatch[2],
519
+ r: tupleMatch[3],
520
+ };
521
+ const { p } = pqr;
522
+ let { q, r } = pqr;
523
+ if (q) {
524
+ q = parseInt(q);
525
+ } else {
526
+ switch (p) {
527
+ case 2:
528
+ q = 3;
529
+ break;
530
+ case 3:
531
+ q = 2;
532
+ break;
533
+ case 4:
534
+ q = 3;
535
+ break;
536
+ case 5:
537
+ case 7:
538
+ case 9:
539
+ q = isCompoundTimeSignature ? 3 : 2;
540
+ break;
541
+ case 6:
542
+ q = 2;
543
+ break;
544
+ case 8:
545
+ q = 3;
546
+ break;
547
+ }
548
+ }
549
+ if (r) {
550
+ r = parseInt(r);
551
+ } else {
552
+ r = p;
553
+ }
554
+ return {
555
+ isTuple: true,
556
+ p,
557
+ q,
558
+ r,
559
+ };
560
+ }
561
+ return null;
562
+ }
563
+
564
+ /**
565
+ * Classify bar line type
566
+ * Returns object with type classification and properties
567
+ */
568
+ function classifyBarLine(barLineStr) {
569
+ const trimmed = barLineStr.trim();
570
+
571
+ // Repeat endings
572
+ if (trimmed.match(/^\|[1-6]$/)) {
573
+ return {
574
+ type: "repeat-ending",
575
+ ending: parseInt(trimmed[1]),
576
+ text: barLineStr,
577
+ isRepeat: true,
578
+ };
579
+ }
580
+
581
+ // Start repeat
582
+ if (trimmed.match(/^\|:/) || trimmed.match(/^\[\|/)) {
583
+ return {
584
+ type: "repeat-start",
585
+ text: barLineStr,
586
+ isRepeat: true,
587
+ };
588
+ }
589
+
590
+ // End repeat
591
+ if (
592
+ trimmed.match(/^:\|/) ||
593
+ (trimmed.match(/^\|\]/) && !trimmed.match(/^\|\]$/))
594
+ ) {
595
+ return {
596
+ type: "repeat-end",
597
+ text: barLineStr,
598
+ isRepeat: true,
599
+ };
600
+ }
601
+
602
+ // Double repeat
603
+ if (
604
+ trimmed.match(/^::/) ||
605
+ trimmed.match(/^:\|:/) ||
606
+ trimmed.match(/^::\|:?/) ||
607
+ trimmed.match(/^::\|\|:?/)
608
+ ) {
609
+ return {
610
+ type: "repeat-both",
611
+ text: barLineStr,
612
+ isRepeat: true,
613
+ };
614
+ }
615
+
616
+ // Final bar
617
+ if (trimmed === "|]") {
618
+ return {
619
+ type: "final",
620
+ text: barLineStr,
621
+ isRepeat: false,
622
+ };
623
+ }
624
+
625
+ // Double bar
626
+ if (trimmed === "||") {
627
+ return {
628
+ type: "double",
629
+ text: barLineStr,
630
+ isRepeat: false,
631
+ };
632
+ }
633
+
634
+ // Regular bar
635
+ if (trimmed === "|") {
636
+ return {
637
+ type: "regular",
638
+ text: barLineStr,
639
+ isRepeat: false,
640
+ };
641
+ }
642
+
643
+ // Unknown/complex bar line
644
+ return {
645
+ type: "other",
646
+ text: barLineStr,
647
+ isRepeat: trimmed.includes(":"),
648
+ };
649
+ }
650
+
651
+ /**
652
+ * Parse ABC into structured data with bars
653
+ *
654
+ * Returns object with:
655
+ * {
656
+ * bars: Array<Array<NoteObject>>, // Array of bars, each bar is array of notes/chords/fields
657
+ * unitLength: Fraction, // The L: field value (default 1/8)
658
+ * meter: [number, number], // The M: field value (default [4,4])
659
+ * tonalBase: string, // The tonic from K: field (e.g., 'D', 'G')
660
+ * lineMetadata: Array<LineMetadata> // Info about original lines (comments, continuations)
661
+ * }
662
+ *
663
+ * NoteObject structure (regular note):
664
+ * {
665
+ * pitch: string, // 'A'-'G' (uppercase for low octave, lowercase for middle)
666
+ * octave: number, // Relative octave offset (0 = middle, +1 = high, -1 = low)
667
+ * duration: Fraction, // Note duration as fraction of whole note
668
+ * isSilence: false, // Always false for pitched notes
669
+ * token: string, // Original ABC token (e.g., 'D2', '^F/')
670
+ * spacing: { // Whitespace/beaming info after this token
671
+ * whitespace: string, // Actual whitespace characters (back quotes removed)
672
+ * backquotes: number, // Number of ` characters for reconstruction
673
+ * beamBreak: boolean, // True if beam should break (multiple spaces/newline)
674
+ * lineBreak: boolean // True if there was a newline after this token
675
+ * },
676
+ *
677
+ * // Optional properties (only present if applicable):
678
+ * decorations: Array<string>, // e.g., ['trill', 'staccato']
679
+ * chordSymbol: string, // e.g., 'Dm7', 'G'
680
+ * annotation: { // Text annotation with position
681
+ * position: string, // '^' (above), '_' (below), '<' (left), '>' (right), '@' (auto)
682
+ * text: string
683
+ * },
684
+ * isChord: true, // Present if this is a chord [CEG]
685
+ * chordNotes: Array<NoteObject> // All notes in the chord (when isChord=true)
686
+ * }
687
+ *
688
+ * NoteObject structure (silence/rest):
689
+ * {
690
+ * isSilence: true,
691
+ * duration: Fraction,
692
+ * token: string,
693
+ * spacing: { ... }, // Same as regular note
694
+ * // Optional: decorations, chordSymbol, annotation (same as above)
695
+ * }
696
+ *
697
+ * NoteObject structure (dummy note):
698
+ * {
699
+ * isDummy: true,
700
+ * duration: Fraction,
701
+ * token: string,
702
+ * spacing: { ... }
703
+ * }
704
+ *
705
+ * NoteObject structure (inline field change):
706
+ * {
707
+ * isInlineField: true,
708
+ * field: string, // 'K', 'L', 'M', or 'P'
709
+ * value: string, // The field value (e.g., 'G major', '3/4')
710
+ * token: string // Original token (e.g., '[K:G]')
711
+ * spacing: { ... }
712
+ * }
713
+ *
714
+ * NoteObject structure (standalone chord symbol):
715
+ * {
716
+ * isChordSymbol: true,
717
+ * chordSymbol: string, // The chord name
718
+ * token: string,
719
+ * spacing: { ... }
720
+ * }
721
+ *
722
+ * LineMetadata structure:
723
+ * {
724
+ * originalLine: string, // Complete original line from ABC
725
+ * comment: string | null, // Text after % (null if no comment)
726
+ * hasContinuation: boolean // Whether line had \ continuation marker
727
+ * }
728
+ *
729
+ * @param {string} abc - ABC notation string
730
+ * @param {object} options - Parsing options
731
+ * @param {number} options.maxBars - Maximum number of bars to parse (optional)
732
+ * @returns {object} - Parsed structure as described above
733
+ *
734
+ * Example:
735
+ * parseABCWithBars('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
736
+ * // Returns:
737
+ * {
738
+ * bars: [
739
+ * [
740
+ * { isChordSymbol: true, chordSymbol: 'Dm', spacing: {...}, ... },
741
+ * { pitch: 'D', octave: 0, duration: Fraction(1,2), chordSymbol: 'Dm', spacing: {...}, ... },
742
+ * { pitch: 'F', octave: 0, duration: Fraction(1,4), isChord: true, chordNotes: [...], spacing: {...}, ... },
743
+ * { pitch: 'A', octave: 0, duration: Fraction(1,4), spacing: {...}, ... }
744
+ * ],
745
+ * [
746
+ * { pitch: 'B', octave: 0, duration: Fraction(1,1), decorations: ['roll'], spacing: {...}, ... }
747
+ * ]
748
+ * ],
749
+ * unitLength: Fraction(1,4),
750
+ * meter: [4,4],
751
+ * tonalBase: 'D',
752
+ * lineMetadata: [...]
753
+ * }
754
+ */
755
+ function parseABCWithBars(abc, options = {}) {
756
+ const { maxBars = Infinity } = options;
757
+
758
+ let unitLength = getUnitLength(abc);
759
+ let meter = getMeter(abc);
760
+ let tonalBase = getTonalBase(abc);
761
+
762
+ const {
763
+ musicText,
764
+ lineMetadata,
765
+ headerLines,
766
+ headerEndIndex,
767
+ newlinePositions,
768
+ } = getMusicLines(abc);
769
+
770
+ // Create a Set of newline positions for O(1) lookup
771
+ const newlineSet = new Set(newlinePositions);
772
+
773
+ // Comprehensive bar line regex - includes trailing spaces
774
+ const barLineRegex = /(\|\]|\[\||(\|:?)|(:?\|)|::|(\|[1-6])) */g;
775
+
776
+ const bars = [];
777
+ const barLines = [];
778
+ let currentBar = [];
779
+ let barCount = 0;
780
+
781
+ // Split music text by bar lines while preserving positions
782
+ let lastBarPos = 0;
783
+ let match;
784
+ let first = true;
785
+
786
+ while ((match = barLineRegex.exec(musicText)) !== null || first) {
787
+ first = false;
788
+ const { barLineText, barLinePos } =
789
+ match === null
790
+ ? { barLineText: musicText, barLinePos: musicText.length }
791
+ : {
792
+ barLineText: match[0],
793
+ barLinePos: match.index,
794
+ };
795
+
796
+ // Process segment before this bar line
797
+ const segment = musicText.substring(lastBarPos, barLinePos);
798
+
799
+ if (segment.trim()) {
800
+ // Parse tokens in this segment
801
+ // Match: inline fields, chord symbols, chords in brackets, decorations, notes/rests/dummy
802
+ const tokenRegex = getTokenRegex();
803
+
804
+ let tokenMatch;
805
+ // let segmentPos = lastBarPos;
806
+
807
+ let currentTuple = null;
808
+
809
+ while ((tokenMatch = tokenRegex.exec(segment)) !== null) {
810
+ //check if all notes of the tuple have been parsed
811
+ if (currentTuple && currentTuple.r === 0) {
812
+ currentTuple = null;
813
+ }
814
+ const fullToken = tokenMatch[0];
815
+ const tokenStartPos = lastBarPos + tokenMatch.index;
816
+ // const tokenEndPos = tokenStartPos + fullToken.length;
817
+ const spacing = analyzeSpacing(
818
+ segment,
819
+ tokenMatch.index + fullToken.length
820
+ );
821
+
822
+ // Check for inline field
823
+ const inlineField = parseInlineField(fullToken);
824
+ if (inlineField) {
825
+ // Update context based on inline field
826
+ if (inlineField.field === "L") {
827
+ const lengthMatch = inlineField.value.match(/1\/(\d+)/);
828
+ if (lengthMatch) {
829
+ unitLength = new Fraction(1, parseInt(lengthMatch[1]));
830
+ }
831
+ } else if (inlineField.field === "M") {
832
+ const meterMatch = inlineField.value.match(/(\d+)\/(\d+)/);
833
+ if (meterMatch) {
834
+ meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
835
+ }
836
+ } else if (inlineField.field === "K") {
837
+ const keyMatch = inlineField.value.match(/^([A-G])/);
838
+ if (keyMatch) {
839
+ tonalBase = keyMatch[1].toUpperCase();
840
+ }
841
+ }
842
+
843
+ currentBar.push({
844
+ isInlineField: true,
845
+ field: inlineField.field,
846
+ value: inlineField.value,
847
+ token: fullToken,
848
+ sourceIndex: tokenStartPos,
849
+ sourceLength: fullToken.length,
850
+ spacing,
851
+ });
852
+ continue;
853
+ }
854
+
855
+ // tuples
856
+ if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
857
+ const tuple = parseTuple(fullToken);
858
+ if (tuple) {
859
+ if (currentTuple) {
860
+ throw new Error("nested tuples not handled");
861
+ }
862
+ // Update context based on inline field
863
+ currentTuple = tuple;
864
+ currentBar.push({
865
+ ...tuple,
866
+ token: fullToken,
867
+ sourceIndex: tokenStartPos,
868
+ sourceLength: fullToken.length,
869
+ });
870
+ continue;
871
+ }
872
+ }
873
+
874
+ // Standalone chord symbol
875
+ if (fullToken.match(/^"[^"]+"$/)) {
876
+ currentBar.push({
877
+ isChordSymbol: true,
878
+ chordSymbol: fullToken.slice(1, -1),
879
+ token: fullToken,
880
+ sourceIndex: tokenStartPos,
881
+ sourceLength: fullToken.length,
882
+ spacing,
883
+ });
884
+ continue;
885
+ }
886
+
887
+ // Standalone decoration
888
+ if (fullToken.match(/^!([^!]+)!$/)) {
889
+ currentBar.push({
890
+ isDecoration: true,
891
+ decoration: fullToken.slice(1, -1),
892
+ token: fullToken,
893
+ sourceIndex: tokenStartPos,
894
+ sourceLength: fullToken.length,
895
+ spacing,
896
+ });
897
+ continue;
898
+ }
899
+
900
+ // Regular note, rest, or dummy, or chord in brackets
901
+ const note = parseNote(fullToken, unitLength, currentTuple);
902
+ if (note) {
903
+ currentBar.push({
904
+ ...note,
905
+ token: fullToken,
906
+ sourceIndex: tokenStartPos,
907
+ sourceLength: fullToken.length,
908
+ spacing,
909
+ });
910
+ }
911
+ }
912
+ }
913
+
914
+ // Check if bar line has a newline after it
915
+ const barLineEndPos = barLinePos + barLineText.length;
916
+ const hasLineBreakAfterBar =
917
+ newlineSet.has(barLineEndPos + 1) ||
918
+ (barLineEndPos < musicText.length && musicText[barLineEndPos] === "\n");
919
+
920
+ // Store bar line information
921
+ const barLineInfo = classifyBarLine(barLineText);
922
+ barLines.push({
923
+ ...barLineInfo,
924
+ sourceIndex: barLinePos,
925
+ sourceLength: barLineText.length,
926
+ barNumber: barCount,
927
+ hasLineBreak: hasLineBreakAfterBar,
928
+ });
929
+
930
+ // Update the last token in current bar to mark lineBreak if bar line has one
931
+ if (currentBar.length > 0 && hasLineBreakAfterBar) {
932
+ const lastToken = currentBar[currentBar.length - 1];
933
+ if (lastToken.spacing) {
934
+ lastToken.spacing.lineBreak = true;
935
+ }
936
+ }
937
+
938
+ // Save current bar if it has content
939
+ if (currentBar.length > 0) {
940
+ bars.push(currentBar);
941
+ barCount++;
942
+ currentBar = [];
943
+
944
+ // Check if we've reached max bars
945
+ if (barCount >= maxBars) {
946
+ break;
947
+ }
948
+ }
949
+
950
+ lastBarPos = barLineEndPos;
951
+ }
952
+
953
+ // Add final bar if it has content and we haven't reached max
954
+ if (currentBar.length > 0 && barCount < maxBars) {
955
+ bars.push(currentBar);
956
+ }
957
+
958
+ return {
959
+ bars,
960
+ barLines,
961
+ unitLength,
962
+ meter,
963
+ tonalBase,
964
+ lineMetadata,
965
+ headerLines,
966
+ headerEndIndex,
967
+ musicText,
968
+ };
969
+ }
970
+
971
+ /**
972
+ * Calculate bar durations from parsed ABC data
973
+ * Returns duration for each bar
974
+ */
975
+ function calculateBarDurations(parsedData) {
976
+ const { bars, barLines } = parsedData;
977
+ const result = []
978
+ if(barLines && barLines[0] && barLines[0].sourceIndex === 0){
979
+ result.push(new Fraction(0,1))
980
+ }
981
+ bars.forEach((bar) => {
982
+ let total = new Fraction(0, 1);
983
+ for (const note of bar) {
984
+ if (!note.duration) {
985
+ continue;
986
+ }
987
+ total = total.add(note.duration);
988
+ }
989
+ result.push(total);
990
+ });
991
+ return result
992
+ }
993
+
994
+ module.exports = {
995
+ getTonalBase,
996
+ getMeter,
997
+ getUnitLength,
998
+ getMusicLines,
999
+ analyzeSpacing,
1000
+ parseABCWithBars,
1001
+ classifyBarLine,
1002
+ calculateBarDurations,
1003
+ NOTE_TO_DEGREE,
1004
+ };