@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
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
const { Fraction } = require("../math.js");
|
|
2
|
+
const {
|
|
3
|
+
getTonalBase,
|
|
4
|
+
getMeter,
|
|
5
|
+
getUnitLength,
|
|
6
|
+
getMusicLines,
|
|
7
|
+
} = require("./header-parser.js");
|
|
8
|
+
const {
|
|
9
|
+
parseNote,
|
|
10
|
+
parseTuplet,
|
|
11
|
+
parseBrokenRhythm,
|
|
12
|
+
applyBrokenRhythm,
|
|
13
|
+
NOTE_TO_DEGREE,
|
|
14
|
+
} = require("./note-parser.js");
|
|
15
|
+
const { classifyBarLine } = require("./barline-parser.js");
|
|
16
|
+
const {
|
|
17
|
+
getTokenRegex,
|
|
18
|
+
parseInlineField,
|
|
19
|
+
analyzeSpacing,
|
|
20
|
+
} = require("./token-utils.js");
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// ABC PARSER - MAIN MODULE
|
|
24
|
+
// ============================================================================
|
|
25
|
+
//
|
|
26
|
+
// Main parsing entry point that orchestrates the parsing of ABC notation
|
|
27
|
+
// into structured bar data. This module coordinates the header parsing,
|
|
28
|
+
// note parsing, and bar line processing.
|
|
29
|
+
//
|
|
30
|
+
// SUPPORTED FEATURES (ABC v2.1):
|
|
31
|
+
// - Basic note notation (pitch, octave markers, accidentals)
|
|
32
|
+
// - Duration modifiers (explicit numbers, fractions, slashes)
|
|
33
|
+
// - Rests/silences (z, x)
|
|
34
|
+
// - Dummy note: y (for spacing/alignment)
|
|
35
|
+
// - Back quotes: ` (ignored spacing for legibility, preserved in metadata)
|
|
36
|
+
// - Triplets: (3ABC, (3A/B/C/, (3A2B2C2
|
|
37
|
+
// - Broken rhythms: A>B, C<<D, etc. (>, >>, >>>, <, <<, <<<)
|
|
38
|
+
// - Repeat notation: |:, :|, |1, |2, etc.
|
|
39
|
+
// - Bar lines: |, ||, |], [|, etc.
|
|
40
|
+
// - Decorations: symbol decorations (~.MPSTHUV) and !name! decorations
|
|
41
|
+
// - Chord symbols: "Dm7", "G", etc.
|
|
42
|
+
// - Chords (multiple notes): [CEG], [CEG]2, etc.
|
|
43
|
+
// - Annotations: "^text", "<text", etc. (parsed but position markers preserved)
|
|
44
|
+
// - Inline fields: [K:...], [L:...], [M:...], [P:...]
|
|
45
|
+
// - Inline comments: % comment text
|
|
46
|
+
// - Line continuations: \ at end of line
|
|
47
|
+
// - Beaming: tracks whitespace between notes for beam grouping
|
|
48
|
+
// - Line breaks: preserves information about newlines in music
|
|
49
|
+
//
|
|
50
|
+
// NOT YET SUPPORTED:
|
|
51
|
+
// - Grace notes: {ABC}
|
|
52
|
+
// - Slurs: ()
|
|
53
|
+
// - Lyrics: w: lines
|
|
54
|
+
// - Multiple voices: V: fields
|
|
55
|
+
// - Macros and user-defined symbols
|
|
56
|
+
// - MIDI directives
|
|
57
|
+
// - Stylesheet directives
|
|
58
|
+
// - Many header fields (only X, T, M, L, K extracted)
|
|
59
|
+
//
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse ABC into structured data with bars
|
|
64
|
+
*
|
|
65
|
+
* @param {string} abc - ABC notation string
|
|
66
|
+
* @param {object} options - Parsing options
|
|
67
|
+
* @param {number} options.maxBars - Maximum number of bars to parse (optional)
|
|
68
|
+
* @returns {object} - Parsed structure
|
|
69
|
+
*
|
|
70
|
+
* Returns object with:
|
|
71
|
+
* {
|
|
72
|
+
* bars: Array<Array<NoteObject>>, // Array of bars, each bar is array of notes/chords/fields
|
|
73
|
+
* barLines: Array<BarLineObject>, // Array of bar line information
|
|
74
|
+
* unitLength: Fraction, // The L: field value (default 1/8)
|
|
75
|
+
* meter: [number, number], // The M: field value (default [4,4])
|
|
76
|
+
* tonalBase: string, // The tonic from K: field (e.g., 'D', 'G')
|
|
77
|
+
* lineMetadata: Array<LineMetadata>,// Info about original lines (comments, continuations)
|
|
78
|
+
* headerLines: Array<string>, // Original header lines
|
|
79
|
+
* headerEndIndex: number, // Index where headers end
|
|
80
|
+
* musicText: string // Processed music text (headers removed)
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* NoteObject structure (regular note):
|
|
84
|
+
* {
|
|
85
|
+
* pitch: string, // 'A'-'G' (uppercase for low octave, lowercase for middle)
|
|
86
|
+
* octave: number, // Relative octave offset (0 = middle, +1 = high, -1 = low)
|
|
87
|
+
* duration: Fraction, // Note duration as fraction of whole note
|
|
88
|
+
* isSilence: boolean, // Always false for pitched notes
|
|
89
|
+
* tied: boolean, // The ABC fragment `A-B` maps to [note1, note2] with note1.tied === true
|
|
90
|
+
* token: string, // Original ABC token (e.g., 'D2', '^F/')
|
|
91
|
+
* sourceIndex: number, // Position in musicText where token starts
|
|
92
|
+
* sourceLength: number, // Length of original token
|
|
93
|
+
* spacing: { // Whitespace/beaming info after this token
|
|
94
|
+
* whitespace: string, // Actual whitespace characters (back quotes removed)
|
|
95
|
+
* backquotes: number, // Number of ` characters for reconstruction
|
|
96
|
+
* beamBreak: boolean, // True if beam should break (multiple spaces/newline)
|
|
97
|
+
* lineBreak: boolean // True if there was a newline after this token
|
|
98
|
+
* },
|
|
99
|
+
*
|
|
100
|
+
* // Optional properties (only present if applicable):
|
|
101
|
+
* decorations: Array<string>, // e.g., ['trill', 'staccato']
|
|
102
|
+
* chordSymbol: string, // e.g., 'Dm7', 'G'
|
|
103
|
+
* annotation: { // Text annotation with position
|
|
104
|
+
* position: string, // '^' (above), '_' (below), '<' (left), '>' (right), '@' (auto)
|
|
105
|
+
* text: string
|
|
106
|
+
* },
|
|
107
|
+
* isChord: true, // Present if this is a chord [CEG]
|
|
108
|
+
* chordNotes: Array<NoteObject> // All notes in the chord (when isChord=true)
|
|
109
|
+
* }
|
|
110
|
+
*
|
|
111
|
+
* NoteObject structure (silence/rest):
|
|
112
|
+
* {
|
|
113
|
+
* isSilence: true,
|
|
114
|
+
* duration: Fraction,
|
|
115
|
+
* token: string,
|
|
116
|
+
* sourceIndex: number,
|
|
117
|
+
* sourceLength: number,
|
|
118
|
+
* spacing: { ... }, // Same as regular note
|
|
119
|
+
* // Optional: decorations, chordSymbol, annotation (same as above)
|
|
120
|
+
* }
|
|
121
|
+
*
|
|
122
|
+
* NoteObject structure (dummy note):
|
|
123
|
+
* {
|
|
124
|
+
* isDummy: true,
|
|
125
|
+
* duration: Fraction,
|
|
126
|
+
* token: string,
|
|
127
|
+
* sourceIndex: number,
|
|
128
|
+
* sourceLength: number,
|
|
129
|
+
* spacing: { ... }
|
|
130
|
+
* }
|
|
131
|
+
*
|
|
132
|
+
* NoteObject structure (inline field change):
|
|
133
|
+
* {
|
|
134
|
+
* isInlineField: true,
|
|
135
|
+
* field: string, // 'K', 'L', 'M', or 'P'
|
|
136
|
+
* value: string, // The field value (e.g., 'G major', '3/4')
|
|
137
|
+
* token: string, // Original token (e.g., '[K:G]')
|
|
138
|
+
* sourceIndex: number,
|
|
139
|
+
* sourceLength: number,
|
|
140
|
+
* spacing: { ... }
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* NoteObject structure (standalone chord symbol):
|
|
144
|
+
* {
|
|
145
|
+
* isChordSymbol: true,
|
|
146
|
+
* chordSymbol: string, // The chord name
|
|
147
|
+
* token: string,
|
|
148
|
+
* sourceIndex: number,
|
|
149
|
+
* sourceLength: number,
|
|
150
|
+
* spacing: { ... }
|
|
151
|
+
* }
|
|
152
|
+
*
|
|
153
|
+
* Tuplet structure:
|
|
154
|
+
* {
|
|
155
|
+
* isTuple: true,
|
|
156
|
+
* p: number, // Tuplet ratio numerator
|
|
157
|
+
* q: number, // Tuplet ratio denominator
|
|
158
|
+
* r: number, // Number of notes in tuplet
|
|
159
|
+
* token: string,
|
|
160
|
+
* sourceIndex: number,
|
|
161
|
+
* sourceLength: number
|
|
162
|
+
* }
|
|
163
|
+
*
|
|
164
|
+
* BrokenRhythm structure:
|
|
165
|
+
* {
|
|
166
|
+
* isBrokenRhythm: true,
|
|
167
|
+
* direction: string, // '>' or '<'
|
|
168
|
+
* dots: number, // 1, 2, or 3
|
|
169
|
+
* token: string,
|
|
170
|
+
* sourceIndex: number,
|
|
171
|
+
* sourceLength: number
|
|
172
|
+
* }
|
|
173
|
+
*
|
|
174
|
+
* BarLineObject structure:
|
|
175
|
+
* {
|
|
176
|
+
* type: string, // 'regular', 'double', 'final', 'repeat-start', etc.
|
|
177
|
+
* text: string, // Original bar line string
|
|
178
|
+
* isRepeat: boolean, // Whether this involves repeats
|
|
179
|
+
* sourceIndex: number, // Position in musicText
|
|
180
|
+
* sourceLength: number, // Length of bar line
|
|
181
|
+
* barNumber: number, // Which bar this terminates (0-indexed)
|
|
182
|
+
* hasLineBreak: boolean, // Whether there's a newline after this bar line
|
|
183
|
+
* ending?: number // For repeat-ending type, which ending (1-6)
|
|
184
|
+
* }
|
|
185
|
+
*
|
|
186
|
+
* LineMetadata structure:
|
|
187
|
+
* {
|
|
188
|
+
* lineIndex: number, // Original line number in ABC
|
|
189
|
+
* originalLine: string, // Complete original line from ABC
|
|
190
|
+
* content: string, // Line content (comments/continuations removed)
|
|
191
|
+
* comment: string | null, // Text after % (null if no comment)
|
|
192
|
+
* hasContinuation: boolean // Whether line had \ continuation marker
|
|
193
|
+
* }
|
|
194
|
+
*
|
|
195
|
+
* Example:
|
|
196
|
+
* parseABCWithBars('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
|
|
197
|
+
* // Returns:
|
|
198
|
+
* {
|
|
199
|
+
* bars: [
|
|
200
|
+
* [
|
|
201
|
+
* { isChordSymbol: true, chordSymbol: 'Dm', spacing: {...}, ... },
|
|
202
|
+
* { pitch: 'D', octave: 0, duration: Fraction(1,2), chordSymbol: 'Dm', spacing: {...}, ... },
|
|
203
|
+
* { pitch: 'F', octave: 0, duration: Fraction(1,4), isChord: true, chordNotes: [...], spacing: {...}, ... },
|
|
204
|
+
* { pitch: 'A', octave: 0, duration: Fraction(1,4), spacing: {...}, ... }
|
|
205
|
+
* ],
|
|
206
|
+
* [
|
|
207
|
+
* { pitch: 'B', octave: 0, duration: Fraction(1,1), decorations: ['roll'], spacing: {...}, ... }
|
|
208
|
+
* ]
|
|
209
|
+
* ],
|
|
210
|
+
* barLines: [...],
|
|
211
|
+
* unitLength: Fraction(1,4),
|
|
212
|
+
* meter: [4,4],
|
|
213
|
+
* tonalBase: 'D',
|
|
214
|
+
* lineMetadata: [...]
|
|
215
|
+
* }
|
|
216
|
+
*/
|
|
217
|
+
function parseABCWithBars(abc, options = {}) {
|
|
218
|
+
const { maxBars = Infinity } = options;
|
|
219
|
+
|
|
220
|
+
let unitLength = getUnitLength(abc);
|
|
221
|
+
let meter = getMeter(abc);
|
|
222
|
+
let tonalBase = getTonalBase(abc);
|
|
223
|
+
|
|
224
|
+
const {
|
|
225
|
+
musicText,
|
|
226
|
+
lineMetadata,
|
|
227
|
+
headerLines,
|
|
228
|
+
headerEndIndex,
|
|
229
|
+
newlinePositions,
|
|
230
|
+
} = getMusicLines(abc);
|
|
231
|
+
|
|
232
|
+
// Create a Set of newline positions for O(1) lookup
|
|
233
|
+
const newlineSet = new Set(newlinePositions);
|
|
234
|
+
|
|
235
|
+
// Comprehensive bar line regex - includes trailing spaces
|
|
236
|
+
const barLineRegex = /(\|\]|\[\||(\|:?)|(:?\|)|::|(\|[1-6])) */g;
|
|
237
|
+
|
|
238
|
+
const bars = [];
|
|
239
|
+
const barLines = [];
|
|
240
|
+
let currentBar = [];
|
|
241
|
+
let barCount = 0;
|
|
242
|
+
|
|
243
|
+
// Split music text by bar lines while preserving positions
|
|
244
|
+
let lastBarPos = 0;
|
|
245
|
+
let match;
|
|
246
|
+
let first = true;
|
|
247
|
+
|
|
248
|
+
while ((match = barLineRegex.exec(musicText)) !== null || first) {
|
|
249
|
+
first = false;
|
|
250
|
+
const { barLineText, barLinePos } =
|
|
251
|
+
match === null
|
|
252
|
+
? { barLineText: musicText, barLinePos: musicText.length }
|
|
253
|
+
: {
|
|
254
|
+
barLineText: match[0],
|
|
255
|
+
barLinePos: match.index,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Process segment before this bar line
|
|
259
|
+
const segment = musicText.substring(lastBarPos, barLinePos);
|
|
260
|
+
|
|
261
|
+
if (segment.trim()) {
|
|
262
|
+
// Parse tokens in this segment
|
|
263
|
+
const tokenRegex = getTokenRegex();
|
|
264
|
+
|
|
265
|
+
let tokenMatch;
|
|
266
|
+
let currentTuple = null;
|
|
267
|
+
// let previousNote = null; // Track previous note for broken rhythms
|
|
268
|
+
|
|
269
|
+
while ((tokenMatch = tokenRegex.exec(segment)) !== null) {
|
|
270
|
+
// Check if all notes of the tuple have been parsed
|
|
271
|
+
if (currentTuple && currentTuple.r === 0) {
|
|
272
|
+
currentTuple = null;
|
|
273
|
+
}
|
|
274
|
+
const fullToken = tokenMatch[0];
|
|
275
|
+
const tokenStartPos = lastBarPos + tokenMatch.index;
|
|
276
|
+
const spacing = analyzeSpacing(
|
|
277
|
+
segment,
|
|
278
|
+
tokenMatch.index + fullToken.length
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Check for inline field
|
|
282
|
+
const inlineField = parseInlineField(fullToken);
|
|
283
|
+
if (inlineField) {
|
|
284
|
+
// Update context based on inline field
|
|
285
|
+
if (inlineField.field === "L") {
|
|
286
|
+
const lengthMatch = inlineField.value.match(/1\/(\d+)/);
|
|
287
|
+
if (lengthMatch) {
|
|
288
|
+
unitLength = new Fraction(1, parseInt(lengthMatch[1]));
|
|
289
|
+
}
|
|
290
|
+
} else if (inlineField.field === "M") {
|
|
291
|
+
const meterMatch = inlineField.value.match(/(\d+)\/(\d+)/);
|
|
292
|
+
if (meterMatch) {
|
|
293
|
+
meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
|
|
294
|
+
}
|
|
295
|
+
} else if (inlineField.field === "K") {
|
|
296
|
+
const keyMatch = inlineField.value.match(/^([A-G])/);
|
|
297
|
+
if (keyMatch) {
|
|
298
|
+
tonalBase = keyMatch[1].toUpperCase();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const inlineFieldObj = {
|
|
303
|
+
isInlineField: true,
|
|
304
|
+
field: inlineField.field,
|
|
305
|
+
value: inlineField.value,
|
|
306
|
+
token: fullToken,
|
|
307
|
+
sourceIndex: tokenStartPos,
|
|
308
|
+
sourceLength: fullToken.length,
|
|
309
|
+
spacing,
|
|
310
|
+
};
|
|
311
|
+
currentBar.push(inlineFieldObj);
|
|
312
|
+
// previousNote = null; // Inline fields break note sequences
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check for broken rhythm
|
|
317
|
+
const brokenRhythm = parseBrokenRhythm(fullToken);
|
|
318
|
+
if (brokenRhythm) {
|
|
319
|
+
const { firstNoteToken } = brokenRhythm;
|
|
320
|
+
const firstNote = parseNote(firstNoteToken, unitLength, currentTuple);
|
|
321
|
+
// Find the next note token
|
|
322
|
+
const nextTokenMatch = tokenRegex.exec(segment);
|
|
323
|
+
if (nextTokenMatch) {
|
|
324
|
+
const nextToken = nextTokenMatch[0];
|
|
325
|
+
const nextTokenStartPos = lastBarPos + nextTokenMatch.index;
|
|
326
|
+
const nextSpacing = analyzeSpacing(
|
|
327
|
+
segment,
|
|
328
|
+
nextTokenMatch.index + nextToken.length
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Parse the next note
|
|
332
|
+
const nextNote = parseNote(nextToken, unitLength, currentTuple);
|
|
333
|
+
if (nextNote && nextNote.duration && firstNote.duration) {
|
|
334
|
+
// Apply broken rhythm to both notes
|
|
335
|
+
applyBrokenRhythm(firstNote, nextNote, brokenRhythm);
|
|
336
|
+
|
|
337
|
+
//add the firstNote
|
|
338
|
+
currentBar.push({
|
|
339
|
+
...firstNote,
|
|
340
|
+
token: fullToken,
|
|
341
|
+
sourceIndex: tokenStartPos,
|
|
342
|
+
sourceLength: fullToken.length,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Add the broken rhythm marker to the bar
|
|
346
|
+
currentBar.push({
|
|
347
|
+
...brokenRhythm,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Add the next note to the bar
|
|
351
|
+
const nextNoteObj = {
|
|
352
|
+
...nextNote,
|
|
353
|
+
token: nextToken,
|
|
354
|
+
sourceIndex: nextTokenStartPos,
|
|
355
|
+
sourceLength: nextToken.length,
|
|
356
|
+
spacing: nextSpacing,
|
|
357
|
+
};
|
|
358
|
+
currentBar.push(nextNoteObj);
|
|
359
|
+
// previousNote = nextNoteObj;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// If we couldn't apply the broken rhythm, just skip it
|
|
364
|
+
// previousNote = null;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Tuplets
|
|
369
|
+
if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
|
|
370
|
+
const tuple = parseTuplet(fullToken);
|
|
371
|
+
if (tuple) {
|
|
372
|
+
if (currentTuple) {
|
|
373
|
+
throw new Error("nested tuples not handled");
|
|
374
|
+
}
|
|
375
|
+
currentTuple = tuple;
|
|
376
|
+
currentBar.push({
|
|
377
|
+
...tuple,
|
|
378
|
+
token: fullToken,
|
|
379
|
+
sourceIndex: tokenStartPos,
|
|
380
|
+
sourceLength: fullToken.length,
|
|
381
|
+
});
|
|
382
|
+
// previousNote = null; // Tuplet markers break note sequences
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Standalone chord symbol
|
|
388
|
+
if (fullToken.match(/^"[^"]+"$/)) {
|
|
389
|
+
currentBar.push({
|
|
390
|
+
isChordSymbol: true,
|
|
391
|
+
chordSymbol: fullToken.slice(1, -1),
|
|
392
|
+
token: fullToken,
|
|
393
|
+
sourceIndex: tokenStartPos,
|
|
394
|
+
sourceLength: fullToken.length,
|
|
395
|
+
spacing,
|
|
396
|
+
});
|
|
397
|
+
// Chord symbols do note break note sequences - leave previousNote
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Standalone decoration
|
|
402
|
+
if (fullToken.match(/^!([^!]+)!$/)) {
|
|
403
|
+
currentBar.push({
|
|
404
|
+
isDecoration: true,
|
|
405
|
+
decoration: fullToken.slice(1, -1),
|
|
406
|
+
token: fullToken,
|
|
407
|
+
sourceIndex: tokenStartPos,
|
|
408
|
+
sourceLength: fullToken.length,
|
|
409
|
+
spacing,
|
|
410
|
+
});
|
|
411
|
+
// Chord symbols do note break note sequences - leave previousNote
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Regular note, rest, or dummy, or chord in brackets
|
|
416
|
+
const note = parseNote(fullToken, unitLength, currentTuple);
|
|
417
|
+
if (note) {
|
|
418
|
+
const noteObj = {
|
|
419
|
+
...note,
|
|
420
|
+
token: fullToken,
|
|
421
|
+
sourceIndex: tokenStartPos,
|
|
422
|
+
sourceLength: fullToken.length,
|
|
423
|
+
spacing,
|
|
424
|
+
};
|
|
425
|
+
currentBar.push(noteObj);
|
|
426
|
+
// Only track as previous note if it has duration (for broken rhythms)
|
|
427
|
+
// previousNote = note.duration ? noteObj : null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check if bar line has a newline after it
|
|
433
|
+
const barLineEndPos = barLinePos + barLineText.length;
|
|
434
|
+
const hasLineBreakAfterBar =
|
|
435
|
+
newlineSet.has(barLineEndPos + 1) ||
|
|
436
|
+
(barLineEndPos < musicText.length && musicText[barLineEndPos] === "\n");
|
|
437
|
+
|
|
438
|
+
// Store bar line information
|
|
439
|
+
const barLineInfo = classifyBarLine(barLineText);
|
|
440
|
+
barLines.push({
|
|
441
|
+
...barLineInfo,
|
|
442
|
+
sourceIndex: barLinePos,
|
|
443
|
+
sourceLength: barLineText.length,
|
|
444
|
+
barNumber: barCount,
|
|
445
|
+
hasLineBreak: hasLineBreakAfterBar,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Update the last token in current bar to mark lineBreak if bar line has one
|
|
449
|
+
if (currentBar.length > 0 && hasLineBreakAfterBar) {
|
|
450
|
+
const lastToken = currentBar[currentBar.length - 1];
|
|
451
|
+
if (lastToken.spacing) {
|
|
452
|
+
lastToken.spacing.lineBreak = true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Save current bar if it has content
|
|
457
|
+
if (currentBar.length > 0) {
|
|
458
|
+
bars.push(currentBar);
|
|
459
|
+
barCount++;
|
|
460
|
+
currentBar = [];
|
|
461
|
+
|
|
462
|
+
// Check if we've reached max bars
|
|
463
|
+
if (barCount >= maxBars) {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
lastBarPos = barLineEndPos;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Add final bar if it has content and we haven't reached max
|
|
472
|
+
if (currentBar.length > 0 && barCount < maxBars) {
|
|
473
|
+
bars.push(currentBar);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
bars,
|
|
478
|
+
barLines,
|
|
479
|
+
unitLength,
|
|
480
|
+
meter,
|
|
481
|
+
tonalBase,
|
|
482
|
+
lineMetadata,
|
|
483
|
+
headerLines,
|
|
484
|
+
headerEndIndex,
|
|
485
|
+
musicText,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Calculate bar durations from parsed ABC data
|
|
491
|
+
* Returns duration for each bar as a Fraction
|
|
492
|
+
*
|
|
493
|
+
* @param {object} parsedData - Output from parseABCWithBars
|
|
494
|
+
* @returns {Array<Fraction>} - Array of bar durations
|
|
495
|
+
*/
|
|
496
|
+
function calculateBarDurations(parsedData) {
|
|
497
|
+
const { bars, barLines } = parsedData;
|
|
498
|
+
const result = [];
|
|
499
|
+
|
|
500
|
+
// If there's a bar line at the start, add a zero-duration entry
|
|
501
|
+
if (barLines && barLines[0] && barLines[0].sourceIndex === 0) {
|
|
502
|
+
result.push(new Fraction(0, 1));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
bars.forEach((bar) => {
|
|
506
|
+
let total = new Fraction(0, 1);
|
|
507
|
+
for (const note of bar) {
|
|
508
|
+
if (!note.duration) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
total = total.add(note.duration);
|
|
512
|
+
}
|
|
513
|
+
result.push(total);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
module.exports = {
|
|
520
|
+
parseABCWithBars,
|
|
521
|
+
calculateBarDurations,
|
|
522
|
+
// Re-export utilities for convenience
|
|
523
|
+
getTonalBase,
|
|
524
|
+
getMeter,
|
|
525
|
+
getUnitLength,
|
|
526
|
+
getMusicLines,
|
|
527
|
+
analyzeSpacing,
|
|
528
|
+
classifyBarLine,
|
|
529
|
+
NOTE_TO_DEGREE,
|
|
530
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ABC TOKEN UTILITIES
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Utilities for tokenising ABC music notation:
|
|
6
|
+
// - Token regex generation
|
|
7
|
+
// - Inline field parsing
|
|
8
|
+
// - Whitespace and beaming analysis
|
|
9
|
+
//
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get regex for matching ABC music tokens
|
|
14
|
+
* Matches: tuplets, inline fields, chord symbols, notes, rests, chords in brackets, decorations, broken rhythms
|
|
15
|
+
*
|
|
16
|
+
* @returns {RegExp} - Regular expression for tokenising ABC music
|
|
17
|
+
*/
|
|
18
|
+
const getTokenRegex = () =>
|
|
19
|
+
/\(\d(?::\d?){0,2}|\[([KLMP]):[^\]]+\]|"[^"]+"|(?:!([^!]+)!\s*)?[~.MPSTHUV]*[=^_]?(?:[A-Ga-gzxy]|\[[A-Ga-gzxy]+\])[',]*[0-9]*\/?[0-9]*(?:-|\s*(?:<{1,3}|>{1,3}))?|!([^!]+)!/g;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse inline field from music section
|
|
23
|
+
* Inline fields allow changing key, meter, length, or part mid-tune
|
|
24
|
+
*
|
|
25
|
+
* @param {string} token - Token string to parse
|
|
26
|
+
* @returns {object|null} - { field, value } or null if not an inline field
|
|
27
|
+
*
|
|
28
|
+
* Supported inline fields:
|
|
29
|
+
* - [K:...] - Key signature change
|
|
30
|
+
* - [L:...] - Unit length change
|
|
31
|
+
* - [M:...] - Meter change
|
|
32
|
+
* - [P:...] - Part marker
|
|
33
|
+
*/
|
|
34
|
+
function parseInlineField(token) {
|
|
35
|
+
const fieldMatch = token.match(/^\[([KLMP]):\s*([^\]]+)\]$/);
|
|
36
|
+
if (fieldMatch) {
|
|
37
|
+
return {
|
|
38
|
+
field: fieldMatch[1],
|
|
39
|
+
value: fieldMatch[2].trim(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Analyze whitespace and back quotes after a token
|
|
47
|
+
* Returns object describing the spacing/beaming context
|
|
48
|
+
* Back quotes (`) are ignored for beaming but preserved for reconstruction
|
|
49
|
+
*
|
|
50
|
+
* @param {string} segment - The music segment to analyze
|
|
51
|
+
* @param {number} tokenEndPos - Position where the token ends
|
|
52
|
+
* @returns {object} - Spacing analysis object
|
|
53
|
+
*
|
|
54
|
+
* Return object structure:
|
|
55
|
+
* {
|
|
56
|
+
* whitespace: string, // Actual whitespace characters (back quotes removed)
|
|
57
|
+
* backquotes: number, // Number of ` characters for reconstruction
|
|
58
|
+
* beamBreak: boolean, // True if beam should break (multiple spaces/newline)
|
|
59
|
+
* lineBreak: boolean // True if there was a newline after this token
|
|
60
|
+
* }
|
|
61
|
+
*/
|
|
62
|
+
function analyzeSpacing(segment, tokenEndPos) {
|
|
63
|
+
if (tokenEndPos >= segment.length) {
|
|
64
|
+
return {
|
|
65
|
+
whitespace: "",
|
|
66
|
+
backquotes: 0,
|
|
67
|
+
beamBreak: false,
|
|
68
|
+
lineBreak: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const remaining = segment.substring(tokenEndPos);
|
|
73
|
+
|
|
74
|
+
// Match whitespace and/or back quotes
|
|
75
|
+
const spacingMatch = remaining.match(/^([\s`]+)/);
|
|
76
|
+
|
|
77
|
+
if (!spacingMatch) {
|
|
78
|
+
return {
|
|
79
|
+
whitespace: "",
|
|
80
|
+
backquotes: 0,
|
|
81
|
+
beamBreak: false,
|
|
82
|
+
lineBreak: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fullSpacing = spacingMatch[1];
|
|
87
|
+
|
|
88
|
+
// Count back quotes
|
|
89
|
+
const backquotes = (fullSpacing.match(/`/g) || []).length;
|
|
90
|
+
|
|
91
|
+
// Extract just whitespace (no back quotes)
|
|
92
|
+
const whitespace = fullSpacing.replace(/`/g, "");
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
whitespace,
|
|
96
|
+
backquotes,
|
|
97
|
+
beamBreak: whitespace.length > 1 || whitespace.includes("\n"), // Multiple spaces or newline breaks beam
|
|
98
|
+
lineBreak: whitespace.includes("\n"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
getTokenRegex,
|
|
104
|
+
parseInlineField,
|
|
105
|
+
analyzeSpacing,
|
|
106
|
+
};
|