@goplayerjuggler/abc-tools 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/incipit.js +13 -26
- package/src/manipulator.js +6 -5
- package/src/parse/barline-parser.js +27 -78
- package/src/parse/header-parser.js +17 -3
- package/src/parse/note-parser.js +85 -63
- package/src/parse/parser.js +143 -86
- package/src/parse/token-utils.js +96 -12
- package/src/sort/contour-sort.js +5 -3
- package/src/sort/contour-svg.js +109 -16
package/package.json
CHANGED
package/src/incipit.js
CHANGED
|
@@ -54,8 +54,8 @@ function cleanIncipitLine(theTextIncipit) {
|
|
|
54
54
|
// theTextIncipit = theTextIncipit.replaceAll("]", "");
|
|
55
55
|
//console.log(theTextIncipit);
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
theTextIncipit = theTextIncipit.replaceAll("\\", "");
|
|
57
|
+
// keep continuations!
|
|
58
|
+
// theTextIncipit = theTextIncipit.replaceAll("\\", "");
|
|
59
59
|
|
|
60
60
|
// Segno
|
|
61
61
|
theTextIncipit = theTextIncipit.replaceAll("S", "");
|
|
@@ -219,9 +219,8 @@ function StripChordsOne(theNotes) {
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
function sanitise(theTune) {
|
|
222
|
-
let j,
|
|
223
|
-
|
|
224
|
-
theTextIncipits = [];
|
|
222
|
+
let j, k;
|
|
223
|
+
const theTextIncipits = [];
|
|
225
224
|
// Strip out annotations
|
|
226
225
|
theTune = StripAnnotationsOneForIncipits(theTune);
|
|
227
226
|
|
|
@@ -247,38 +246,26 @@ function sanitise(theTune) {
|
|
|
247
246
|
break;
|
|
248
247
|
}
|
|
249
248
|
}
|
|
250
|
-
// Find the L: parameter
|
|
251
|
-
let theL = "";
|
|
252
249
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (theL.indexOf("L:") !== -1) {
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
// Find the M: parameter
|
|
261
|
-
let theM = "";
|
|
262
|
-
|
|
263
|
-
for (j = 0; j < nLines; ++j) {
|
|
264
|
-
theM = theLines[j];
|
|
250
|
+
const unitLength = getUnitLength(theTune);
|
|
251
|
+
const meter = getMeter(theTune),
|
|
252
|
+
theM = meter ? `${meter[0]}/${meter[1]}` : "none";
|
|
265
253
|
|
|
266
|
-
if (theM.indexOf("M:") !== -1) {
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
254
|
// Use at most the first three lines following the header K:
|
|
271
255
|
let added = 0;
|
|
272
256
|
for (k = indexOfTheKey + 1; k < nLines; ++k) {
|
|
273
257
|
const theTextIncipit = theLines[k];
|
|
274
|
-
|
|
258
|
+
if (theTextIncipit.match(/^\s*%%/)) continue; // skip lines starting with %%
|
|
275
259
|
// Clean out the incipit line of any annotations besides notes and bar lines
|
|
276
260
|
theTextIncipits.push(cleanIncipitLine(theTextIncipit));
|
|
277
261
|
added++;
|
|
262
|
+
|
|
278
263
|
if (added === 3) break;
|
|
279
264
|
}
|
|
280
265
|
|
|
281
|
-
return `X:1\
|
|
266
|
+
return `X:1\nM:${theM}\nL:1/${
|
|
267
|
+
unitLength.den
|
|
268
|
+
}\n${theKey}\n${theTextIncipits.join("\n")}`;
|
|
282
269
|
}
|
|
283
270
|
|
|
284
271
|
/**
|
|
@@ -333,7 +320,7 @@ function getContourFromFullAbc(abc) {
|
|
|
333
320
|
if (abc.length === 0) return null;
|
|
334
321
|
abc = abc[0];
|
|
335
322
|
}
|
|
336
|
-
return getContour(getIncipitForContourGeneration(abc));
|
|
323
|
+
return getContour(getIncipitForContourGeneration(abc), { withSvg: true });
|
|
337
324
|
}
|
|
338
325
|
|
|
339
326
|
module.exports = {
|
package/src/manipulator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Fraction } = require("./math.js");
|
|
2
2
|
const {
|
|
3
|
-
|
|
3
|
+
parseAbc,
|
|
4
4
|
getMeter,
|
|
5
5
|
calculateBarDurations,
|
|
6
6
|
} = require("./parse/parser.js");
|
|
@@ -43,6 +43,7 @@ function normaliseKey(keyHeader) {
|
|
|
43
43
|
major: "major",
|
|
44
44
|
ion: "major",
|
|
45
45
|
ionian: "major",
|
|
46
|
+
m: "minor",
|
|
46
47
|
min: "minor",
|
|
47
48
|
minor: "minor",
|
|
48
49
|
aeo: "minor",
|
|
@@ -91,7 +92,7 @@ function filterHeaders(headerLines, headersToStrip) {
|
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Detect if ABC notation has an anacrusis (pickup bar)
|
|
94
|
-
* @param {object} parsed - Parsed ABC data from
|
|
95
|
+
* @param {object} parsed - Parsed ABC data from parseAbc
|
|
95
96
|
* @returns {boolean} - True if anacrusis is present
|
|
96
97
|
*/
|
|
97
98
|
function hasAnacrucisFromParsed(parsed) {
|
|
@@ -112,7 +113,7 @@ function hasAnacrucisFromParsed(parsed) {
|
|
|
112
113
|
* @returns {boolean} - True if anacrusis is present
|
|
113
114
|
*/
|
|
114
115
|
function hasAnacrucis(abc) {
|
|
115
|
-
const parsed =
|
|
116
|
+
const parsed = parseAbc(abc, { maxBars: 2 });
|
|
116
117
|
return hasAnacrucisFromParsed(parsed);
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
@@ -188,7 +189,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
|
|
|
188
189
|
abc = abc.replaceAll(/([^\s])([[:]?\|)/g, "$1 $2");
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
const parsed =
|
|
192
|
+
const parsed = parseAbc(abc);
|
|
192
193
|
const { headerLines, barLines, musicText } = parsed;
|
|
193
194
|
|
|
194
195
|
// Change meter in headers
|
|
@@ -326,7 +327,7 @@ function getFirstBars(
|
|
|
326
327
|
Math.ceil(numBarsFraction.num / numBarsFraction.den) + 2;
|
|
327
328
|
|
|
328
329
|
// Parse with estimated maxBars
|
|
329
|
-
const parsed =
|
|
330
|
+
const parsed = parseAbc(abc, { maxBars: estimatedMaxBars });
|
|
330
331
|
const { bars, headerLines, barLines, musicText, meter } = parsed;
|
|
331
332
|
|
|
332
333
|
const barDurations = calculateBarDurations(parsed);
|
|
@@ -17,99 +17,48 @@
|
|
|
17
17
|
* Classify bar line type
|
|
18
18
|
*
|
|
19
19
|
* @param {string} barLineStr - Bar line string from ABC notation
|
|
20
|
-
* @returns {object} -
|
|
20
|
+
* @returns {object} - parsed barline with text, and properties
|
|
21
21
|
*
|
|
22
22
|
* Return object structure:
|
|
23
23
|
* {
|
|
24
|
-
* type: string, // 'regular', 'double', 'final', 'repeat-start', 'repeat-end', 'repeat-both', 'repeat-ending', 'other'
|
|
25
24
|
* text: string, // Original bar line string
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* trimmed: string, // trimmed bar line string
|
|
26
|
+
* isSectionBreak, // double bars and ends of repeats are section breaks
|
|
27
|
+
* isRepeatL: boolean,
|
|
28
|
+
* // true iff there’s a repeat to the left of the bar line; if not the property is omitted
|
|
29
|
+
* // indicates the end of a repeated section
|
|
30
|
+
*
|
|
31
|
+
* isRepeatR: boolean,
|
|
32
|
+
* // true iff there’s a repeat to the rightt of the bar line; if not the property is omitted
|
|
33
|
+
* // indicates the start of a repeated section
|
|
34
|
+
*
|
|
28
35
|
* }
|
|
36
|
+
*
|
|
37
|
+
*
|
|
29
38
|
*/
|
|
30
|
-
function
|
|
39
|
+
function parseBarLine(barLineStr) {
|
|
31
40
|
const trimmed = barLineStr.trim();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
type: "repeat-ending",
|
|
37
|
-
ending: parseInt(trimmed[1]),
|
|
38
|
-
text: barLineStr,
|
|
39
|
-
isRepeat: true,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
41
|
+
const result = {
|
|
42
|
+
text: barLineStr,
|
|
43
|
+
trimmed,
|
|
44
|
+
};
|
|
43
45
|
// Start repeat
|
|
44
|
-
if (trimmed.match(
|
|
45
|
-
|
|
46
|
-
type: "repeat-start",
|
|
47
|
-
text: barLineStr,
|
|
48
|
-
isRepeat: true,
|
|
49
|
-
};
|
|
46
|
+
if (trimmed.match(/:$/)) {
|
|
47
|
+
result.isRepeatR = true;
|
|
50
48
|
}
|
|
51
|
-
|
|
52
49
|
// End repeat
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
) {
|
|
57
|
-
return {
|
|
58
|
-
type: "repeat-end",
|
|
59
|
-
text: barLineStr,
|
|
60
|
-
isRepeat: true,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Double repeat
|
|
65
|
-
if (
|
|
66
|
-
trimmed.match(/^::/) ||
|
|
67
|
-
trimmed.match(/^:\|:/) ||
|
|
68
|
-
trimmed.match(/^::\|:?/) ||
|
|
69
|
-
trimmed.match(/^::\|\|:?/)
|
|
70
|
-
) {
|
|
71
|
-
return {
|
|
72
|
-
type: "repeat-both",
|
|
73
|
-
text: barLineStr,
|
|
74
|
-
isRepeat: true,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Final bar
|
|
79
|
-
if (trimmed === "|]") {
|
|
80
|
-
return {
|
|
81
|
-
type: "final",
|
|
82
|
-
text: barLineStr,
|
|
83
|
-
isRepeat: false,
|
|
84
|
-
};
|
|
50
|
+
if (trimmed.match(/^:/)) {
|
|
51
|
+
result.isRepeatL = true;
|
|
52
|
+
result.isSectionBreak = true
|
|
85
53
|
}
|
|
86
54
|
|
|
87
55
|
// Double bar
|
|
88
|
-
if (trimmed
|
|
89
|
-
|
|
90
|
-
type: "double",
|
|
91
|
-
text: barLineStr,
|
|
92
|
-
isRepeat: false,
|
|
93
|
-
};
|
|
56
|
+
if (trimmed.match(/\|\|/)){
|
|
57
|
+
result.isSectionBreak = true;
|
|
94
58
|
}
|
|
95
|
-
|
|
96
|
-
// Regular bar
|
|
97
|
-
if (trimmed === "|") {
|
|
98
|
-
return {
|
|
99
|
-
type: "regular",
|
|
100
|
-
text: barLineStr,
|
|
101
|
-
isRepeat: false,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Unknown/complex bar line
|
|
106
|
-
return {
|
|
107
|
-
type: "other",
|
|
108
|
-
text: barLineStr,
|
|
109
|
-
isRepeat: trimmed.includes(":"),
|
|
110
|
-
};
|
|
59
|
+
return result;
|
|
111
60
|
}
|
|
112
61
|
|
|
113
62
|
module.exports = {
|
|
114
|
-
|
|
63
|
+
parseBarLine,
|
|
115
64
|
};
|
|
@@ -38,7 +38,9 @@ function getMeter(abc) {
|
|
|
38
38
|
if (meterMatch) {
|
|
39
39
|
return [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
|
|
40
40
|
}
|
|
41
|
-
return [
|
|
41
|
+
if (abc.match(/^M:\s*C\|/m)) return [2, 2];
|
|
42
|
+
if (abc.match(/^M:\s*C/m)) return [4, 4];
|
|
43
|
+
return null;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -54,6 +56,15 @@ function getUnitLength(abc) {
|
|
|
54
56
|
}
|
|
55
57
|
return new Fraction(1, 8); // Default to 1/8
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Extract titles - there may be 0..N titles
|
|
61
|
+
*
|
|
62
|
+
* @param {string} abc - ABC notation string
|
|
63
|
+
* @returns {[string]} - array of titles
|
|
64
|
+
*/
|
|
65
|
+
function getTitles(abc) {
|
|
66
|
+
return [...abc.matchAll(/^(?:T:\s*.(.+)\n)/gm)];
|
|
67
|
+
}
|
|
57
68
|
|
|
58
69
|
/**
|
|
59
70
|
* Process ABC lines: extract music lines with metadata
|
|
@@ -100,9 +111,11 @@ function getMusicLines(abc) {
|
|
|
100
111
|
// Check for line continuation
|
|
101
112
|
const hasContinuation = trimmed.match(/\\\s*(%|$)/) !== null;
|
|
102
113
|
|
|
103
|
-
// Remove inline comments
|
|
114
|
+
// Remove inline comments
|
|
104
115
|
trimmed = trimmed.replace(/\s*%.*$/, "").trim();
|
|
105
|
-
|
|
116
|
+
|
|
117
|
+
// Do *not* remove continuations: `\` at the end of a line
|
|
118
|
+
// trimmed = trimmed.replace(/\\\s*$/, "").trim();
|
|
106
119
|
|
|
107
120
|
if (trimmed) {
|
|
108
121
|
musicLines.push(trimmed);
|
|
@@ -137,4 +150,5 @@ module.exports = {
|
|
|
137
150
|
getMeter,
|
|
138
151
|
getUnitLength,
|
|
139
152
|
getMusicLines,
|
|
153
|
+
getTitles,
|
|
140
154
|
};
|
package/src/parse/note-parser.js
CHANGED
|
@@ -12,6 +12,7 @@ const { Fraction } = require("../math.js");
|
|
|
12
12
|
// - Chord symbols and annotations
|
|
13
13
|
// - Tuplets/triplets
|
|
14
14
|
// - Broken rhythms
|
|
15
|
+
// - Grace notes
|
|
15
16
|
//
|
|
16
17
|
// ============================================================================
|
|
17
18
|
|
|
@@ -193,6 +194,87 @@ function parseChord(chordStr, unitLength) {
|
|
|
193
194
|
};
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Parse grace notes from a token
|
|
199
|
+
* Grace notes are enclosed in curly braces and have zero duration
|
|
200
|
+
*
|
|
201
|
+
* @param {string} graceStr - Grace note string (e.g., '{ABC}', '{^AB_c}', '{[CEG]A}')
|
|
202
|
+
* @returns {Array<object>|null} - Array of grace note objects, or null if not valid grace notes
|
|
203
|
+
*
|
|
204
|
+
* Each grace note has:
|
|
205
|
+
* - isGraceNote: true
|
|
206
|
+
* - duration: Fraction(0, 1)
|
|
207
|
+
* - pitch, octave: as normal
|
|
208
|
+
* - isChord: true (if chord in brackets)
|
|
209
|
+
* - chordNotes: Array (if chord)
|
|
210
|
+
*/
|
|
211
|
+
function parseGraceNotes(graceStr) {
|
|
212
|
+
if (!graceStr.startsWith("{") || !graceStr.endsWith("}")) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const content = graceStr.slice(1, -1);
|
|
217
|
+
if (!content) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const graceNotes = [];
|
|
222
|
+
|
|
223
|
+
// Match individual notes or chords: accidental + pitch + octave (+ duration to ignore)
|
|
224
|
+
// Supports: A, ^A, _B', [CEG], [^C_E'G], A2, B/2, etc.
|
|
225
|
+
const noteRegex = /(?:\[[^\]]+\]|[=^_]?[A-Ga-g][',]*)[0-9]*\/?[0-9]*/g;
|
|
226
|
+
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = noteRegex.exec(content)) !== null) {
|
|
229
|
+
const noteToken = match[0];
|
|
230
|
+
|
|
231
|
+
// Check if it's a chord
|
|
232
|
+
if (noteToken.startsWith("[")) {
|
|
233
|
+
const chord = parseChord(noteToken, new Fraction(1, 8)); // unitLength irrelevant
|
|
234
|
+
if (chord && chord.notes) {
|
|
235
|
+
// Find topmost note for the grace chord
|
|
236
|
+
let topNote = chord.notes[0];
|
|
237
|
+
for (const note of chord.notes) {
|
|
238
|
+
const topPos =
|
|
239
|
+
(topNote.octave || 0) * 7 +
|
|
240
|
+
(NOTE_TO_DEGREE[topNote.pitch?.toUpperCase()] || 0);
|
|
241
|
+
const notePos =
|
|
242
|
+
(note.octave || 0) * 7 +
|
|
243
|
+
(NOTE_TO_DEGREE[note.pitch?.toUpperCase()] || 0);
|
|
244
|
+
if (notePos > topPos) {
|
|
245
|
+
topNote = note;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Set zero duration for all notes in grace chord
|
|
250
|
+
chord.notes.forEach((note) => {
|
|
251
|
+
note.duration = new Fraction(0, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
graceNotes.push({
|
|
255
|
+
...topNote,
|
|
256
|
+
isGraceNote: true,
|
|
257
|
+
duration: new Fraction(0, 1),
|
|
258
|
+
isChord: true,
|
|
259
|
+
chordNotes: chord.notes,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Single grace note
|
|
264
|
+
const pitchData = getPitch(noteToken);
|
|
265
|
+
if (pitchData) {
|
|
266
|
+
graceNotes.push({
|
|
267
|
+
...pitchData,
|
|
268
|
+
isGraceNote: true,
|
|
269
|
+
duration: new Fraction(0, 1),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return graceNotes.length > 0 ? graceNotes : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
196
278
|
/**
|
|
197
279
|
* Parse broken rhythm from a token
|
|
198
280
|
* Broken rhythms modify the duration of two adjacent notes (e.g., A>B, C<<D)
|
|
@@ -210,12 +292,10 @@ function parseChord(chordStr, unitLength) {
|
|
|
210
292
|
* - 3: triple-dotted rhythm (15:1 multiplier)
|
|
211
293
|
*/
|
|
212
294
|
function parseBrokenRhythm(token) {
|
|
213
|
-
const brokenMatch = token.match(/^(
|
|
295
|
+
const brokenMatch = token.match(/^(<{1,3}|>{1,3})$/);
|
|
214
296
|
if (brokenMatch) {
|
|
215
|
-
const
|
|
216
|
-
symbol = brokenMatch[2];
|
|
297
|
+
const symbol = brokenMatch[1];
|
|
217
298
|
return {
|
|
218
|
-
firstNoteToken,
|
|
219
299
|
isBrokenRhythm: true,
|
|
220
300
|
direction: symbol[0],
|
|
221
301
|
dots: symbol.length,
|
|
@@ -389,64 +469,6 @@ function parseNote(noteStr, unitLength, currentTuple) {
|
|
|
389
469
|
return result;
|
|
390
470
|
}
|
|
391
471
|
|
|
392
|
-
/**
|
|
393
|
-
* Parse tuplet notation from a token
|
|
394
|
-
*
|
|
395
|
-
* @param {string} token - Tuplet token (e.g., '(3', '(3:2', '(3:2:4')
|
|
396
|
-
* @param {boolean} isCompoundTimeSignature - Whether current time signature is compound (affects default q value)
|
|
397
|
-
* @returns {object|null} - { isTuple: true, p, q, r } or null if not a valid tuplet
|
|
398
|
-
*/
|
|
399
|
-
function parseTuplet(token, isCompoundTimeSignature) {
|
|
400
|
-
const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
|
|
401
|
-
if (tupleMatch) {
|
|
402
|
-
const pqr = {
|
|
403
|
-
p: parseInt(tupleMatch[1]),
|
|
404
|
-
q: tupleMatch[2],
|
|
405
|
-
r: tupleMatch[3],
|
|
406
|
-
};
|
|
407
|
-
const { p } = pqr;
|
|
408
|
-
let { q, r } = pqr;
|
|
409
|
-
if (q) {
|
|
410
|
-
q = parseInt(q);
|
|
411
|
-
} else {
|
|
412
|
-
switch (p) {
|
|
413
|
-
case 2:
|
|
414
|
-
q = 3;
|
|
415
|
-
break;
|
|
416
|
-
case 3:
|
|
417
|
-
q = 2;
|
|
418
|
-
break;
|
|
419
|
-
case 4:
|
|
420
|
-
q = 3;
|
|
421
|
-
break;
|
|
422
|
-
case 5:
|
|
423
|
-
case 7:
|
|
424
|
-
case 9:
|
|
425
|
-
q = isCompoundTimeSignature ? 3 : 2;
|
|
426
|
-
break;
|
|
427
|
-
case 6:
|
|
428
|
-
q = 2;
|
|
429
|
-
break;
|
|
430
|
-
case 8:
|
|
431
|
-
q = 3;
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
if (r) {
|
|
436
|
-
r = parseInt(r);
|
|
437
|
-
} else {
|
|
438
|
-
r = p;
|
|
439
|
-
}
|
|
440
|
-
return {
|
|
441
|
-
isTuple: true,
|
|
442
|
-
p,
|
|
443
|
-
q,
|
|
444
|
-
r,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
472
|
module.exports = {
|
|
451
473
|
parseDecorations,
|
|
452
474
|
parseChordSymbol,
|
|
@@ -456,7 +478,7 @@ module.exports = {
|
|
|
456
478
|
getDuration,
|
|
457
479
|
parseChord,
|
|
458
480
|
parseNote,
|
|
459
|
-
parseTuplet,
|
|
460
481
|
parseBrokenRhythm,
|
|
461
482
|
applyBrokenRhythm,
|
|
483
|
+
parseGraceNotes,
|
|
462
484
|
};
|
package/src/parse/parser.js
CHANGED
|
@@ -7,15 +7,16 @@ const {
|
|
|
7
7
|
} = require("./header-parser.js");
|
|
8
8
|
const {
|
|
9
9
|
parseNote,
|
|
10
|
-
parseTuplet,
|
|
11
10
|
parseBrokenRhythm,
|
|
12
11
|
applyBrokenRhythm,
|
|
12
|
+
parseGraceNotes,
|
|
13
13
|
} = require("./note-parser.js");
|
|
14
|
-
const {
|
|
14
|
+
const { parseBarLine } = require("./barline-parser.js");
|
|
15
15
|
const {
|
|
16
|
+
analyzeSpacing,
|
|
16
17
|
getTokenRegex,
|
|
17
18
|
parseInlineField,
|
|
18
|
-
|
|
19
|
+
parseTuplet,
|
|
19
20
|
} = require("./token-utils.js");
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
@@ -34,6 +35,7 @@ const {
|
|
|
34
35
|
// - Back quotes: ` (ignored spacing for legibility, preserved in metadata)
|
|
35
36
|
// - Triplets: (3ABC, (3A/B/C/, (3A2B2C2
|
|
36
37
|
// - Broken rhythms: A>B, C<<D, etc. (>, >>, >>>, <, <<, <<<)
|
|
38
|
+
// - Grace notes: {ABC}, {^AB_c}, etc. (zero duration, transparent to broken rhythms)
|
|
37
39
|
// - Repeat notation: |:, :|, |1, |2, etc.
|
|
38
40
|
// - Bar lines: |, ||, |], [|, etc.
|
|
39
41
|
// - Decorations: symbol decorations (~.MPSTHUV) and !name! decorations
|
|
@@ -47,7 +49,6 @@ const {
|
|
|
47
49
|
// - Line breaks: preserves information about newlines in music
|
|
48
50
|
//
|
|
49
51
|
// NOT YET SUPPORTED:
|
|
50
|
-
// - Grace notes: {ABC}
|
|
51
52
|
// - Slurs: ()
|
|
52
53
|
// - Lyrics: w: lines
|
|
53
54
|
// - Multiple voices: V: fields
|
|
@@ -59,7 +60,7 @@ const {
|
|
|
59
60
|
// ============================================================================
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
* Parse ABC into structured data
|
|
63
|
+
* Parse ABC into structured data
|
|
63
64
|
*
|
|
64
65
|
* @param {string} abc - ABC notation string
|
|
65
66
|
* @param {object} options - Parsing options
|
|
@@ -68,18 +69,18 @@ const {
|
|
|
68
69
|
*
|
|
69
70
|
* Returns object with:
|
|
70
71
|
* {
|
|
71
|
-
* bars: Array<Array<
|
|
72
|
+
* bars: Array<Array<ScoreObject>>, // Array of bars, each bar is array of ScoreObjects
|
|
73
|
+
* // A Score object is almost anything that isn’t a bar line: note/chord/field/broken rhythm/tuplet/1st or 2nd repeat or variant ending
|
|
72
74
|
* barLines: Array<BarLineObject>, // Array of bar line information
|
|
73
75
|
* unitLength: Fraction, // The L: field value (default 1/8)
|
|
74
76
|
* meter: [number, number], // The M: field value (default [4,4])
|
|
75
|
-
* tonalBase: string, // The tonic from K: field (e.g., 'D', 'G')
|
|
76
77
|
* lineMetadata: Array<LineMetadata>,// Info about original lines (comments, continuations)
|
|
77
78
|
* headerLines: Array<string>, // Original header lines
|
|
78
79
|
* headerEndIndex: number, // Index where headers end
|
|
79
80
|
* musicText: string // Processed music text (headers removed)
|
|
80
81
|
* }
|
|
81
82
|
*
|
|
82
|
-
*
|
|
83
|
+
* ScoreObject structure (normal note):
|
|
83
84
|
* {
|
|
84
85
|
* pitch: string, // 'A'-'G' (uppercase for low octave, lowercase for middle)
|
|
85
86
|
* octave: number, // Relative octave offset (0 = middle, +1 = high, -1 = low)
|
|
@@ -104,10 +105,11 @@ const {
|
|
|
104
105
|
* text: string
|
|
105
106
|
* },
|
|
106
107
|
* isChord: true, // Present if this is a chord [CEG]
|
|
107
|
-
* chordNotes: Array<
|
|
108
|
+
* chordNotes: Array<ScoreObject>, // All notes in the chord (when isChord=true)
|
|
109
|
+
* isGraceNote: true // Present if this is a grace note (has zero duration)
|
|
108
110
|
* }
|
|
109
111
|
*
|
|
110
|
-
*
|
|
112
|
+
* ScoreObject structure (silence/rest):
|
|
111
113
|
* {
|
|
112
114
|
* isSilence: true,
|
|
113
115
|
* duration: Fraction,
|
|
@@ -118,7 +120,7 @@ const {
|
|
|
118
120
|
* // Optional: decorations, chordSymbol, annotation (same as above)
|
|
119
121
|
* }
|
|
120
122
|
*
|
|
121
|
-
*
|
|
123
|
+
* ScoreObject structure (dummy note):
|
|
122
124
|
* {
|
|
123
125
|
* isDummy: true,
|
|
124
126
|
* duration: Fraction,
|
|
@@ -128,7 +130,7 @@ const {
|
|
|
128
130
|
* spacing: { ... }
|
|
129
131
|
* }
|
|
130
132
|
*
|
|
131
|
-
*
|
|
133
|
+
* ScoreObject structure (inline field change):
|
|
132
134
|
* {
|
|
133
135
|
* isInlineField: true,
|
|
134
136
|
* field: string, // 'K', 'L', 'M', or 'P'
|
|
@@ -139,7 +141,7 @@ const {
|
|
|
139
141
|
* spacing: { ... }
|
|
140
142
|
* }
|
|
141
143
|
*
|
|
142
|
-
*
|
|
144
|
+
* ScoreObject structure (standalone chord symbol):
|
|
143
145
|
* {
|
|
144
146
|
* isChordSymbol: true,
|
|
145
147
|
* chordSymbol: string, // The chord name
|
|
@@ -149,7 +151,7 @@ const {
|
|
|
149
151
|
* spacing: { ... }
|
|
150
152
|
* }
|
|
151
153
|
*
|
|
152
|
-
*
|
|
154
|
+
* ScoreObject structure (Tuplet):
|
|
153
155
|
* {
|
|
154
156
|
* isTuple: true,
|
|
155
157
|
* p: number, // Tuplet ratio numerator
|
|
@@ -157,29 +159,35 @@ const {
|
|
|
157
159
|
* r: number, // Number of notes in tuplet
|
|
158
160
|
* token: string,
|
|
159
161
|
* sourceIndex: number,
|
|
160
|
-
* sourceLength: number
|
|
162
|
+
* sourceLength: number,
|
|
163
|
+
* spacing: { ... }
|
|
161
164
|
* }
|
|
162
165
|
*
|
|
163
|
-
*
|
|
166
|
+
* ScoreObject structure ((BrokenRhythm):
|
|
164
167
|
* {
|
|
165
168
|
* isBrokenRhythm: true,
|
|
166
169
|
* direction: string, // '>' or '<'
|
|
167
170
|
* dots: number, // 1, 2, or 3
|
|
168
171
|
* token: string,
|
|
169
172
|
* sourceIndex: number,
|
|
170
|
-
* sourceLength: number
|
|
173
|
+
* sourceLength: number,
|
|
174
|
+
* spacing: { ... }
|
|
171
175
|
* }
|
|
172
176
|
*
|
|
173
177
|
* BarLineObject structure:
|
|
174
178
|
* {
|
|
175
|
-
* type: string, // 'regular', 'double', 'final', 'repeat-start', etc.
|
|
176
179
|
* text: string, // Original bar line string
|
|
177
|
-
*
|
|
180
|
+
* trimmed: string, // trimmed bar line string
|
|
181
|
+
* isSectionBreak, // double bars and repeats are section breaks
|
|
182
|
+
* isRepeatL: boolean,
|
|
183
|
+
* // true iff there’s a repeat to the left of the bar line; if not the property is omitted
|
|
184
|
+
* // indicates the end of a repeated section
|
|
185
|
+
* isRepeatR: boolean,
|
|
186
|
+
* // true iff there’s a repeat to the right of the bar line; if not the property is omitted
|
|
187
|
+
* // indicates the start of a repeated section
|
|
178
188
|
* sourceIndex: number, // Position in musicText
|
|
179
189
|
* sourceLength: number, // Length of bar line
|
|
180
|
-
* barNumber: number, // Which bar this terminates (0-indexed)
|
|
181
190
|
* hasLineBreak: boolean, // Whether there's a newline after this bar line
|
|
182
|
-
* ending?: number // For repeat-ending type, which ending (1-6)
|
|
183
191
|
* }
|
|
184
192
|
*
|
|
185
193
|
* LineMetadata structure:
|
|
@@ -192,7 +200,7 @@ const {
|
|
|
192
200
|
* }
|
|
193
201
|
*
|
|
194
202
|
* Example:
|
|
195
|
-
*
|
|
203
|
+
* parseAbc('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
|
|
196
204
|
* // Returns:
|
|
197
205
|
* {
|
|
198
206
|
* bars: [
|
|
@@ -209,16 +217,14 @@ const {
|
|
|
209
217
|
* barLines: [...],
|
|
210
218
|
* unitLength: Fraction(1,4),
|
|
211
219
|
* meter: [4,4],
|
|
212
|
-
* tonalBase: 'D',
|
|
213
220
|
* lineMetadata: [...]
|
|
214
221
|
* }
|
|
215
222
|
*/
|
|
216
|
-
function
|
|
223
|
+
function parseAbc(abc, options = {}) {
|
|
217
224
|
const { maxBars = Infinity } = options;
|
|
218
225
|
|
|
219
226
|
let unitLength = getUnitLength(abc);
|
|
220
227
|
let meter = getMeter(abc);
|
|
221
|
-
let tonalBase = getTonalBase(abc);
|
|
222
228
|
|
|
223
229
|
const {
|
|
224
230
|
musicText,
|
|
@@ -231,13 +237,19 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
231
237
|
// Create a Set of newline positions for O(1) lookup
|
|
232
238
|
const newlineSet = new Set(newlinePositions);
|
|
233
239
|
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
/*
|
|
241
|
+
Bar line regex - includes trailing spaces; does not include: 1st & 2nd repeats and variant endings - tokenised by tokenRegex below
|
|
242
|
+
From the spec: "Abc parsers should be quite liberal in recognizing bar lines. In the wild, bar lines may have any shape, using a sequence of | (thin bar line), [ or ] (thick bar line), and : (dots), e.g. |[| or [|:::"
|
|
243
|
+
All the following bar lines are legal, given with most frequently seen first - according to my limited knowledge of what’s out there. AFAIK all but the last 4 are quite commonly seen.
|
|
244
|
+
`|`, `:|`, `|:`, `|]`, `||`, `:||:`, `:|:`, `::`, `[|`, `[|]`, `.|`
|
|
245
|
+
*/
|
|
246
|
+
const barLineRegex = /:*\.*[|[\]]*(?:\||::+)[|[\]]*:* */g; //captures expressions with at least one `|`, or at least two `:`.
|
|
236
247
|
|
|
237
248
|
const bars = [];
|
|
238
249
|
const barLines = [];
|
|
239
250
|
let currentBar = [];
|
|
240
251
|
let barCount = 0;
|
|
252
|
+
let previousRealNote = null; // Track last non-grace note for broken rhythms
|
|
241
253
|
|
|
242
254
|
// Split music text by bar lines while preserving positions
|
|
243
255
|
let lastBarPos = 0;
|
|
@@ -254,6 +266,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
254
266
|
barLinePos: match.index,
|
|
255
267
|
};
|
|
256
268
|
|
|
269
|
+
if (lastBarPos > 0) lastBarPos--; //the last character in a barline expression may be needed to match variant endings - eg `|1`
|
|
257
270
|
// Process segment before this bar line
|
|
258
271
|
const segment = musicText.substring(lastBarPos, barLinePos);
|
|
259
272
|
|
|
@@ -277,6 +290,47 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
277
290
|
tokenMatch.index + fullToken.length
|
|
278
291
|
);
|
|
279
292
|
|
|
293
|
+
if (fullToken.match(getTokenRegex({ variantEndings: true }))) {
|
|
294
|
+
const variantEnding = {
|
|
295
|
+
isVariantEnding: true,
|
|
296
|
+
token: fullToken,
|
|
297
|
+
sourceIndex: tokenStartPos,
|
|
298
|
+
sourceLength: fullToken.length,
|
|
299
|
+
spacing,
|
|
300
|
+
};
|
|
301
|
+
currentBar.push(variantEnding);
|
|
302
|
+
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check for grace notes
|
|
307
|
+
if (fullToken.startsWith("{")) {
|
|
308
|
+
const graceNotes = parseGraceNotes(fullToken);
|
|
309
|
+
if (graceNotes) {
|
|
310
|
+
// Add each grace note to the bar
|
|
311
|
+
graceNotes.forEach((graceNote, idx) => {
|
|
312
|
+
currentBar.push({
|
|
313
|
+
...graceNote,
|
|
314
|
+
token: fullToken,
|
|
315
|
+
sourceIndex: tokenStartPos,
|
|
316
|
+
sourceLength: fullToken.length,
|
|
317
|
+
// Only the last grace note gets the spacing
|
|
318
|
+
spacing:
|
|
319
|
+
idx === graceNotes.length - 1
|
|
320
|
+
? spacing
|
|
321
|
+
: {
|
|
322
|
+
whitespace: "",
|
|
323
|
+
backquotes: 0,
|
|
324
|
+
beamBreak: false,
|
|
325
|
+
lineBreak: false,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
// Grace notes don't update previousRealNote
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
280
334
|
// Check for inline field
|
|
281
335
|
const inlineField = parseInlineField(fullToken);
|
|
282
336
|
if (inlineField) {
|
|
@@ -291,11 +345,6 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
291
345
|
if (meterMatch) {
|
|
292
346
|
meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
|
|
293
347
|
}
|
|
294
|
-
} else if (inlineField.field === "K") {
|
|
295
|
-
const keyMatch = inlineField.value.match(/^([A-G])/);
|
|
296
|
-
if (keyMatch) {
|
|
297
|
-
tonalBase = keyMatch[1].toUpperCase();
|
|
298
|
-
}
|
|
299
348
|
}
|
|
300
349
|
|
|
301
350
|
const inlineFieldObj = {
|
|
@@ -308,65 +357,71 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
308
357
|
spacing,
|
|
309
358
|
};
|
|
310
359
|
currentBar.push(inlineFieldObj);
|
|
311
|
-
|
|
360
|
+
previousRealNote = null; // Inline fields break note sequences
|
|
312
361
|
continue;
|
|
313
362
|
}
|
|
314
363
|
|
|
315
364
|
// Check for broken rhythm
|
|
316
365
|
const brokenRhythm = parseBrokenRhythm(fullToken);
|
|
317
366
|
if (brokenRhythm) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// Parse the next note
|
|
331
|
-
const nextNote = parseNote(nextToken, unitLength, currentTuple);
|
|
332
|
-
if (nextNote && nextNote.duration && firstNote.duration) {
|
|
333
|
-
// Apply broken rhythm to both notes
|
|
334
|
-
applyBrokenRhythm(firstNote, nextNote, brokenRhythm);
|
|
335
|
-
|
|
336
|
-
//add the firstNote
|
|
337
|
-
currentBar.push({
|
|
338
|
-
...firstNote,
|
|
339
|
-
token: fullToken,
|
|
340
|
-
sourceIndex: tokenStartPos,
|
|
341
|
-
sourceLength: fullToken.length,
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// Add the broken rhythm marker to the bar
|
|
345
|
-
currentBar.push({
|
|
346
|
-
...brokenRhythm,
|
|
347
|
-
});
|
|
367
|
+
if (previousRealNote) {
|
|
368
|
+
// Find the next REAL note token (skip grace notes)
|
|
369
|
+
let nextTokenMatch = tokenRegex.exec(segment);
|
|
370
|
+
while (nextTokenMatch) {
|
|
371
|
+
const nextToken = nextTokenMatch[0];
|
|
372
|
+
// Skip grace notes
|
|
373
|
+
if (nextToken.startsWith("{")) {
|
|
374
|
+
nextTokenMatch = tokenRegex.exec(segment);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
348
379
|
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
380
|
+
if (nextTokenMatch) {
|
|
381
|
+
const nextToken = nextTokenMatch[0];
|
|
382
|
+
const nextTokenStartPos = lastBarPos + nextTokenMatch.index;
|
|
383
|
+
const nextSpacing = analyzeSpacing(
|
|
384
|
+
segment,
|
|
385
|
+
nextTokenMatch.index + nextToken.length
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Parse the next note
|
|
389
|
+
const nextNote = parseNote(nextToken, unitLength, currentTuple);
|
|
390
|
+
if (nextNote && nextNote.duration && previousRealNote.duration) {
|
|
391
|
+
// Apply broken rhythm to both notes
|
|
392
|
+
applyBrokenRhythm(previousRealNote, nextNote, brokenRhythm);
|
|
393
|
+
|
|
394
|
+
// Add the broken rhythm marker to the bar
|
|
395
|
+
currentBar.push({
|
|
396
|
+
...brokenRhythm,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Add the next note to the bar
|
|
400
|
+
const nextNoteObj = {
|
|
401
|
+
...nextNote,
|
|
402
|
+
token: nextToken,
|
|
403
|
+
sourceIndex: nextTokenStartPos,
|
|
404
|
+
sourceLength: nextToken.length,
|
|
405
|
+
spacing: nextSpacing,
|
|
406
|
+
};
|
|
407
|
+
currentBar.push(nextNoteObj);
|
|
408
|
+
previousRealNote = null; //can't have successive broken rhythms
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
360
411
|
}
|
|
361
412
|
}
|
|
362
413
|
// If we couldn't apply the broken rhythm, just skip it
|
|
363
|
-
|
|
414
|
+
previousRealNote = null;
|
|
364
415
|
continue;
|
|
365
416
|
}
|
|
366
417
|
|
|
367
418
|
// Tuplets
|
|
368
419
|
if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
|
|
369
|
-
const
|
|
420
|
+
const isCompound =
|
|
421
|
+
meter &&
|
|
422
|
+
meter[1] === 8 &&
|
|
423
|
+
[6, 9, 12, 15, 18].indexOf(meter[0]) >= 0;
|
|
424
|
+
const tuple = parseTuplet(fullToken, isCompound);
|
|
370
425
|
if (tuple) {
|
|
371
426
|
if (currentTuple) {
|
|
372
427
|
throw new Error("nested tuples not handled");
|
|
@@ -378,7 +433,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
378
433
|
sourceIndex: tokenStartPos,
|
|
379
434
|
sourceLength: fullToken.length,
|
|
380
435
|
});
|
|
381
|
-
|
|
436
|
+
previousRealNote = null; // Tuplet markers break note sequences
|
|
382
437
|
continue;
|
|
383
438
|
}
|
|
384
439
|
}
|
|
@@ -393,7 +448,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
393
448
|
sourceLength: fullToken.length,
|
|
394
449
|
spacing,
|
|
395
450
|
});
|
|
396
|
-
// Chord symbols
|
|
451
|
+
// Chord symbols don't break note sequences
|
|
397
452
|
continue;
|
|
398
453
|
}
|
|
399
454
|
|
|
@@ -407,7 +462,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
407
462
|
sourceLength: fullToken.length,
|
|
408
463
|
spacing,
|
|
409
464
|
});
|
|
410
|
-
//
|
|
465
|
+
// Decorations don't break note sequences
|
|
411
466
|
continue;
|
|
412
467
|
}
|
|
413
468
|
|
|
@@ -422,8 +477,10 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
422
477
|
spacing,
|
|
423
478
|
};
|
|
424
479
|
currentBar.push(noteObj);
|
|
425
|
-
// Only track as previous note if it has duration (for broken rhythms)
|
|
426
|
-
|
|
480
|
+
// Only track as previous note if it has non-zero duration (for broken rhythms)
|
|
481
|
+
if (note.duration && note.duration.n !== 0) {
|
|
482
|
+
previousRealNote = noteObj;
|
|
483
|
+
}
|
|
427
484
|
}
|
|
428
485
|
}
|
|
429
486
|
}
|
|
@@ -435,7 +492,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
435
492
|
(barLineEndPos < musicText.length && musicText[barLineEndPos] === "\n");
|
|
436
493
|
|
|
437
494
|
// Store bar line information
|
|
438
|
-
const barLineInfo =
|
|
495
|
+
const barLineInfo = parseBarLine(barLineText);
|
|
439
496
|
barLines.push({
|
|
440
497
|
...barLineInfo,
|
|
441
498
|
sourceIndex: barLinePos,
|
|
@@ -457,6 +514,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
457
514
|
bars.push(currentBar);
|
|
458
515
|
barCount++;
|
|
459
516
|
currentBar = [];
|
|
517
|
+
previousRealNote = null; // Bar lines break note sequences
|
|
460
518
|
|
|
461
519
|
// Check if we've reached max bars
|
|
462
520
|
if (barCount >= maxBars) {
|
|
@@ -477,7 +535,6 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
477
535
|
barLines,
|
|
478
536
|
unitLength,
|
|
479
537
|
meter,
|
|
480
|
-
tonalBase,
|
|
481
538
|
lineMetadata,
|
|
482
539
|
headerLines,
|
|
483
540
|
headerEndIndex,
|
|
@@ -489,7 +546,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
489
546
|
* Calculate bar durations from parsed ABC data
|
|
490
547
|
* Returns duration for each bar as a Fraction
|
|
491
548
|
*
|
|
492
|
-
* @param {object} parsedData - Output from
|
|
549
|
+
* @param {object} parsedData - Output from parseAbc
|
|
493
550
|
* @returns {Array<Fraction>} - Array of bar durations
|
|
494
551
|
*/
|
|
495
552
|
function calculateBarDurations(parsedData) {
|
|
@@ -558,7 +615,7 @@ function getTunes(text) {
|
|
|
558
615
|
|
|
559
616
|
module.exports = {
|
|
560
617
|
getTunes,
|
|
561
|
-
|
|
618
|
+
parseAbc,
|
|
562
619
|
calculateBarDurations,
|
|
563
620
|
// Re-export utilities for convenience
|
|
564
621
|
getTonalBase,
|
|
@@ -566,5 +623,5 @@ module.exports = {
|
|
|
566
623
|
getUnitLength,
|
|
567
624
|
getMusicLines,
|
|
568
625
|
analyzeSpacing,
|
|
569
|
-
|
|
626
|
+
parseBarLine,
|
|
570
627
|
};
|
package/src/parse/token-utils.js
CHANGED
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
// - Token regex generation
|
|
7
7
|
// - Inline field parsing
|
|
8
8
|
// - Whitespace and beaming analysis
|
|
9
|
-
//
|
|
9
|
+
// - Bar lines are not captured, but variant endings and 1st and 2nd repeats are captured
|
|
10
10
|
// ============================================================================
|
|
11
11
|
|
|
12
12
|
const TokenRegexComponents = {
|
|
13
13
|
// Tuplet notation: (3, (3:2, (3:2:4
|
|
14
14
|
tuplet: String.raw`\(\d(?::\d?){0,2}`,
|
|
15
15
|
|
|
16
|
+
// Grace notes: {ABC}, {^AB_c}, etc.
|
|
17
|
+
graceNotes: String.raw`\{[^}]+\}`,
|
|
18
|
+
|
|
16
19
|
// Inline field changes: [K:D], [L:1/4], [M:3/4], [P:A]
|
|
17
|
-
inlineField: String.raw`\[(
|
|
20
|
+
inlineField: String.raw`\[([KLMP]):\s*([^\]]+)\]`,
|
|
18
21
|
|
|
19
22
|
// Text in quotes: chord symbols "Dm7" or annotations "^text"
|
|
20
23
|
quotedText: String.raw`"[^"]+"`,
|
|
@@ -48,22 +51,41 @@ const TokenRegexComponents = {
|
|
|
48
51
|
// but we could have A16, or z10 (full bar rest for M:5/4; L:1/8)
|
|
49
52
|
duration: String.raw`[0-9]*\/?[0-9]*`,
|
|
50
53
|
|
|
51
|
-
// Tie (-)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// Tie (-) - optional
|
|
55
|
+
tie: String.raw`-?`,
|
|
56
|
+
|
|
57
|
+
// Broken rhythm (>, >>, >>>, <, <<, <<<)
|
|
58
|
+
broken: String.raw`<{1,3}|>{1,3}`,
|
|
59
|
+
|
|
60
|
+
// variant ending - including one way of writing 1st and second repeats. Only handle single digits.
|
|
61
|
+
// examples: [1 [2 [3 [4 [1-3 [1-3,5-7
|
|
62
|
+
variantEnding: String.raw`\[\d(?:-\d)?(?:,\d(?:-\d)?)*`,
|
|
63
|
+
|
|
64
|
+
// 1st & 2nd repeats. Only handle single digits.
|
|
65
|
+
// examples: `|1`, `|2`
|
|
66
|
+
// Note that the other syntax, `[1`, `[2`, will be captured by the variantEnding component
|
|
67
|
+
repeat_1Or2: String.raw`\|[12]`,
|
|
54
68
|
};
|
|
55
69
|
|
|
56
70
|
/**
|
|
71
|
+
*
|
|
57
72
|
* Get regex for matching ABC music tokens
|
|
58
|
-
* Built from documented components for maintainability
|
|
59
73
|
*
|
|
60
|
-
* Matches: tuplets, inline fields, chord symbols, notes, rests,
|
|
61
|
-
* decorations, ties, and
|
|
74
|
+
* Matches: tuplets, grace notes, inline fields, chord symbols, notes, rests,
|
|
75
|
+
* chords in brackets, decorations, ties, broken rhythms, and variant endings including 1st and 2nd repeats
|
|
62
76
|
*
|
|
77
|
+
* @param {object} options -
|
|
78
|
+
* when options.variantEndings is flagged, the returned regex just matches the next variant ending / 1st or 2nd repeat
|
|
79
|
+
* when options.inlineField is flagged, the returned regex just matches the next inline field
|
|
63
80
|
* @returns {RegExp} - Regular expression for tokenising ABC music
|
|
64
81
|
*/
|
|
65
|
-
const getTokenRegex = () => {
|
|
82
|
+
const getTokenRegex = (options = {}) => {
|
|
66
83
|
const s = TokenRegexComponents;
|
|
84
|
+
if (options) {
|
|
85
|
+
if (options.variantEndings)
|
|
86
|
+
return new RegExp(`^${s.variantEnding}|${s.repeat_1Or2}$`);
|
|
87
|
+
if (options.inlineField) return new RegExp(`^$${s.inlineField}$`);
|
|
88
|
+
}
|
|
67
89
|
// Complete note/rest/chord pattern with optional leading decoration
|
|
68
90
|
const notePattern =
|
|
69
91
|
s.bangDecorations() +
|
|
@@ -72,16 +94,20 @@ const getTokenRegex = () => {
|
|
|
72
94
|
s.pitch +
|
|
73
95
|
s.octave +
|
|
74
96
|
s.duration +
|
|
75
|
-
s.
|
|
97
|
+
s.tie;
|
|
76
98
|
|
|
77
99
|
// Combine all patterns with alternation
|
|
78
100
|
const fullPattern = [
|
|
79
101
|
s.tuplet,
|
|
102
|
+
s.graceNotes,
|
|
80
103
|
s.inlineField,
|
|
81
104
|
s.quotedText,
|
|
82
105
|
//allow standalone bang and symbol decorations (?)
|
|
83
106
|
notePattern,
|
|
84
107
|
s.bangDecoration,
|
|
108
|
+
s.broken,
|
|
109
|
+
s.variantEnding,
|
|
110
|
+
s.repeat_1Or2,
|
|
85
111
|
].join("|");
|
|
86
112
|
|
|
87
113
|
return new RegExp(fullPattern, "g");
|
|
@@ -101,7 +127,7 @@ const getTokenRegex = () => {
|
|
|
101
127
|
* - [P:...] - Part marker
|
|
102
128
|
*/
|
|
103
129
|
function parseInlineField(token) {
|
|
104
|
-
const fieldMatch = token.match(
|
|
130
|
+
const fieldMatch = token.match(getTokenRegex({ inlineField: true }));
|
|
105
131
|
if (fieldMatch) {
|
|
106
132
|
return {
|
|
107
133
|
field: fieldMatch[1],
|
|
@@ -168,8 +194,66 @@ function analyzeSpacing(segment, tokenEndPos) {
|
|
|
168
194
|
};
|
|
169
195
|
}
|
|
170
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Parse tuplet notation from a token
|
|
199
|
+
*
|
|
200
|
+
* @param {string} token - Tuplet token (e.g., '(3', '(3:2', '(3:2:4')
|
|
201
|
+
* @param {boolean} isCompoundTimeSignature - Whether current time signature is compound (affects default q value)
|
|
202
|
+
* @returns {object|null} - { isTuple: true, p, q, r } or null if not a valid tuplet
|
|
203
|
+
*/
|
|
204
|
+
function parseTuplet(token, isCompoundTimeSignature) {
|
|
205
|
+
const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
|
|
206
|
+
if (tupleMatch) {
|
|
207
|
+
const pqr = {
|
|
208
|
+
p: parseInt(tupleMatch[1]),
|
|
209
|
+
q: tupleMatch[2],
|
|
210
|
+
r: tupleMatch[3],
|
|
211
|
+
};
|
|
212
|
+
const { p } = pqr;
|
|
213
|
+
let { q, r } = pqr;
|
|
214
|
+
if (q) {
|
|
215
|
+
q = parseInt(q);
|
|
216
|
+
} else {
|
|
217
|
+
switch (p) {
|
|
218
|
+
case 2:
|
|
219
|
+
q = 3;
|
|
220
|
+
break;
|
|
221
|
+
case 3:
|
|
222
|
+
q = 2;
|
|
223
|
+
break;
|
|
224
|
+
case 4:
|
|
225
|
+
q = 3;
|
|
226
|
+
break;
|
|
227
|
+
case 5:
|
|
228
|
+
case 7:
|
|
229
|
+
case 9:
|
|
230
|
+
q = isCompoundTimeSignature ? 3 : 2;
|
|
231
|
+
break;
|
|
232
|
+
case 6:
|
|
233
|
+
q = 2;
|
|
234
|
+
break;
|
|
235
|
+
case 8:
|
|
236
|
+
q = 3;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (r) {
|
|
241
|
+
r = parseInt(r);
|
|
242
|
+
} else {
|
|
243
|
+
r = p;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
isTuple: true,
|
|
247
|
+
p,
|
|
248
|
+
q,
|
|
249
|
+
r,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
171
254
|
module.exports = {
|
|
255
|
+
analyzeSpacing,
|
|
172
256
|
getTokenRegex,
|
|
173
257
|
parseInlineField,
|
|
174
|
-
|
|
258
|
+
parseTuplet,
|
|
175
259
|
};
|
package/src/sort/contour-sort.js
CHANGED
|
@@ -2,7 +2,7 @@ const { Fraction } = require("../math.js");
|
|
|
2
2
|
const {
|
|
3
3
|
getTonalBase,
|
|
4
4
|
getUnitLength,
|
|
5
|
-
|
|
5
|
+
parseAbc,
|
|
6
6
|
getMeter,
|
|
7
7
|
} = require("../parse/parser.js");
|
|
8
8
|
|
|
@@ -38,8 +38,10 @@ function getContour(
|
|
|
38
38
|
const unitLength = getUnitLength(abc);
|
|
39
39
|
const maxDuration = unitLength.multiply(maxNbUnitLengths);
|
|
40
40
|
const meter = getMeter(abc);
|
|
41
|
-
const maxNbBars =
|
|
42
|
-
|
|
41
|
+
const maxNbBars = meter
|
|
42
|
+
? maxDuration.divide(new Fraction(meter[0], meter[1]))
|
|
43
|
+
: new Fraction(2, 1); //default 2 bars when no meter (free meter)
|
|
44
|
+
const { bars } = parseAbc(abc, {
|
|
43
45
|
maxBars: Math.ceil(maxNbBars.toNumber()),
|
|
44
46
|
});
|
|
45
47
|
let cumulatedDuration = new Fraction(0, 1);
|
package/src/sort/contour-svg.js
CHANGED
|
@@ -13,11 +13,19 @@ const { decodeChar } = require("./encode.js");
|
|
|
13
13
|
* @property {number} paddingBottom - Bottom padding in pixels
|
|
14
14
|
* @property {number} paddingLeft - Left padding in pixels
|
|
15
15
|
* @property {number} paddingRight - Right padding in pixels
|
|
16
|
-
* @property {boolean} forceBaseline -
|
|
16
|
+
* @property {boolean} forceBaseline - Ensures the baseline at zero is displayed
|
|
17
17
|
* @property {number|null} minDegree - Minimum degree for vertical range (null for auto)
|
|
18
18
|
* @property {number|null} maxDegree - Maximum degree for vertical range (null for auto)
|
|
19
19
|
* @property {string} class - CSS class name for the SVG element
|
|
20
20
|
* @property {string} ariaLabel - Accessible label for screen readers
|
|
21
|
+
* @property {number} noteStartRadius - Radius of the circle marking note starts (played notes only)
|
|
22
|
+
* @property {boolean} onlyShowMeaningfulStartOfPlayedNotes - If true, only show start markers when previous note is same pitch; if false, show on all played notes
|
|
23
|
+
* @property {boolean} showYAxis - Whether to display the Y axis
|
|
24
|
+
* @property {string} yAxisColor - Colour for the Y axis line
|
|
25
|
+
* @property {number} yAxisWidth - Width of the Y axis line
|
|
26
|
+
* @property {number} yAxisTickLength - Length of regular ticks (for 5th degree markers)
|
|
27
|
+
* @property {number} yAxisTonicTickLength - Length of tonic ticks (for tonic degree markers)
|
|
28
|
+
* @property {number} yAxisTickWidth - Width of tick marks
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -25,35 +33,42 @@ const { decodeChar } = require("./encode.js");
|
|
|
25
33
|
* @type {SvgConfig}
|
|
26
34
|
*/
|
|
27
35
|
const contourToSvg_defaultConfig = {
|
|
28
|
-
degreeHeight: 5,
|
|
29
|
-
paddingTop:
|
|
30
|
-
paddingBottom:
|
|
31
|
-
strokeWidth: 2,
|
|
36
|
+
degreeHeight: 5,
|
|
37
|
+
paddingTop: 3,
|
|
38
|
+
paddingBottom: 3,
|
|
39
|
+
strokeWidth: 2,
|
|
32
40
|
unitWidth: 15,
|
|
33
|
-
// degreeHeight: 12,
|
|
34
|
-
// strokeWidth: 3,
|
|
35
41
|
playedColor: "#2563eb", // blue
|
|
36
|
-
heldColor: "#
|
|
42
|
+
heldColor: "#2563eb", // same as played (no longer lighter blue)
|
|
37
43
|
baselineColor: "#555555", // Davy's grey
|
|
38
|
-
// paddingTop: 20,
|
|
39
|
-
// paddingBottom: 20,
|
|
40
44
|
paddingLeft: 10,
|
|
41
45
|
paddingRight: 10,
|
|
42
|
-
minDegree: null,
|
|
43
|
-
maxDegree: null,
|
|
46
|
+
minDegree: null,
|
|
47
|
+
maxDegree: null,
|
|
44
48
|
forceBaseline: true,
|
|
45
49
|
class: "contour-svg",
|
|
46
50
|
ariaLabel: "Tune contour",
|
|
51
|
+
noteStartRadius: 3,
|
|
52
|
+
onlyShowMeaningfulStartOfPlayedNotes: true,
|
|
53
|
+
showYAxis: true,
|
|
54
|
+
yAxisColor: "#888888",
|
|
55
|
+
yAxisWidth: 1,
|
|
56
|
+
yAxisTickLength: 4,
|
|
57
|
+
yAxisTonicTickLength: 6,
|
|
58
|
+
yAxisTickWidth: 1,
|
|
47
59
|
};
|
|
48
60
|
|
|
49
61
|
/**
|
|
50
|
-
* Converts a tune contour object into an SVG
|
|
62
|
+
* Converts a tune contour object into an SVG visualisation
|
|
51
63
|
*
|
|
52
64
|
* The SVG represents the melodic contour as a series of connected horizontal line segments.
|
|
53
65
|
* Each segment's vertical position corresponds to its modal degree (pitch relative to tonic),
|
|
54
66
|
* and its horizontal length represents its duration relative to the common subdivision of the beat.
|
|
55
67
|
* The resulting SVG is landscape-oriented (wider than tall).
|
|
56
68
|
*
|
|
69
|
+
* Played notes are marked with a small filled circle at their start point.
|
|
70
|
+
* Held notes (continuations of the same pitch) have no start marker.
|
|
71
|
+
*
|
|
57
72
|
* Silences are not drawn but occupy space on the x-axis, causing subsequent notes to be
|
|
58
73
|
* positioned after the cumulative duration of preceding silences.
|
|
59
74
|
*
|
|
@@ -75,7 +90,7 @@ const contourToSvg_defaultConfig = {
|
|
|
75
90
|
* // With fixed vertical range for comparing multiple contours
|
|
76
91
|
* const svg = contourToSvg(contour, { minDegree: -15, maxDegree: 15 });
|
|
77
92
|
*
|
|
78
|
-
* // Auto range
|
|
93
|
+
* // Auto range - calculates from the contour's actual pitch range
|
|
79
94
|
* const svg2 = contourToSvg(contour, {
|
|
80
95
|
* minDegree: null,
|
|
81
96
|
* maxDegree: null
|
|
@@ -87,7 +102,7 @@ const contourToSvg_defaultConfig = {
|
|
|
87
102
|
* // You can also set just one bound
|
|
88
103
|
* const svg3 = contourToSvg(contour, {
|
|
89
104
|
* minDegree: -15,
|
|
90
|
-
* maxDegree: null //maxDegree will auto-calculate
|
|
105
|
+
* maxDegree: null // maxDegree will auto-calculate
|
|
91
106
|
* });
|
|
92
107
|
*/
|
|
93
108
|
function contourToSvg(contour, svgConfig = {}) {
|
|
@@ -156,7 +171,11 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
156
171
|
const svgWidth = totalWidth + config.paddingLeft + config.paddingRight;
|
|
157
172
|
const svgHeight = chartHeight + config.paddingTop + config.paddingBottom;
|
|
158
173
|
|
|
159
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Converts a modal degree position to Y coordinate in SVG space
|
|
176
|
+
* @param {number} pos - Modal degree position
|
|
177
|
+
* @returns {number} Y coordinate
|
|
178
|
+
*/
|
|
160
179
|
const positionToY = (pos) => {
|
|
161
180
|
const relativePos = maxPosition - pos;
|
|
162
181
|
return config.paddingTop + relativePos * config.degreeHeight;
|
|
@@ -165,6 +184,47 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
165
184
|
// Build SVG path elements
|
|
166
185
|
const pathElements = [];
|
|
167
186
|
|
|
187
|
+
// Add Y axis if configured
|
|
188
|
+
if (config.showYAxis) {
|
|
189
|
+
const yAxisX = config.paddingLeft - 5;
|
|
190
|
+
const yAxisTop = positionToY(maxPosition);
|
|
191
|
+
const yAxisBottom = positionToY(minPosition);
|
|
192
|
+
|
|
193
|
+
pathElements.push(
|
|
194
|
+
`<line x1="${yAxisX}" y1="${yAxisTop}" ` +
|
|
195
|
+
`x2="${yAxisX}" y2="${yAxisBottom}" ` +
|
|
196
|
+
`stroke="${config.yAxisColor}" stroke-width="${config.yAxisWidth}" />`
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Add tick marks for positions within range
|
|
200
|
+
// Regular ticks at positions: ..., -11, -4, 4, 11, 18, ... (5th degree in each octave)
|
|
201
|
+
// Tonic ticks at positions: ..., -14, -7, 0, 7, 14, ... (tonic in each octave)
|
|
202
|
+
const minPos = Math.floor(minPosition);
|
|
203
|
+
const maxPos = Math.ceil(maxPosition);
|
|
204
|
+
|
|
205
|
+
for (let pos = minPos; pos <= maxPos; pos++) {
|
|
206
|
+
let tickLength = 0;
|
|
207
|
+
|
|
208
|
+
// Check if this is a tonic position (0, ±7, ±14, ...)
|
|
209
|
+
if (pos % 7 === 0) {
|
|
210
|
+
tickLength = config.yAxisTonicTickLength;
|
|
211
|
+
}
|
|
212
|
+
// Check if this is a 5th position (±4, ±11, ±18, ...)
|
|
213
|
+
else if (pos % 7 === 4 || pos % 7 === -3) {
|
|
214
|
+
tickLength = config.yAxisTickLength;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (tickLength > 0) {
|
|
218
|
+
const tickY = positionToY(pos);
|
|
219
|
+
pathElements.push(
|
|
220
|
+
`<line x1="${yAxisX - tickLength}" y1="${tickY}" ` +
|
|
221
|
+
`x2="${yAxisX}" y2="${tickY}" ` +
|
|
222
|
+
`stroke="${config.yAxisColor}" stroke-width="${config.yAxisTickWidth}" />`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
168
228
|
// Add baseline at position 0 if it's within range
|
|
169
229
|
if (minPosition <= 0 && maxPosition >= 0) {
|
|
170
230
|
const baselineY = positionToY(0);
|
|
@@ -196,6 +256,39 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
196
256
|
`stroke-linecap="round" />`
|
|
197
257
|
);
|
|
198
258
|
|
|
259
|
+
// Add note start marker (filled circle) for played notes
|
|
260
|
+
// If onlyShowMeaningfulStartOfPlayedNotes is true, only show when previous note is same pitch
|
|
261
|
+
if (!seg.isHeld) {
|
|
262
|
+
let showStartMarker = true;
|
|
263
|
+
|
|
264
|
+
if (config.onlyShowMeaningfulStartOfPlayedNotes) {
|
|
265
|
+
// Find previous non-silence segment
|
|
266
|
+
let prevNonSilenceIdx = i - 1;
|
|
267
|
+
while (
|
|
268
|
+
prevNonSilenceIdx >= 0 &&
|
|
269
|
+
segments[prevNonSilenceIdx].isSilence
|
|
270
|
+
) {
|
|
271
|
+
prevNonSilenceIdx--;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Only show marker if previous note exists and is at same position
|
|
275
|
+
if (prevNonSilenceIdx >= 0) {
|
|
276
|
+
const prevSeg = segments[prevNonSilenceIdx];
|
|
277
|
+
showStartMarker = prevSeg.position === seg.position;
|
|
278
|
+
} else {
|
|
279
|
+
// First note: don't show marker
|
|
280
|
+
showStartMarker = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (showStartMarker) {
|
|
285
|
+
pathElements.push(
|
|
286
|
+
`<circle cx="${x1}" cy="${y}" r="${config.noteStartRadius}" ` +
|
|
287
|
+
`fill="${color}" />`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
199
292
|
// Add connecting vertical line to next non-silence segment if position changes
|
|
200
293
|
let nextNonSilenceIdx = i + 1;
|
|
201
294
|
while (
|