@goplayerjuggler/abc-tools 1.0.20 → 1.0.22
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 +2 -1
- package/src/manipulator.js +7 -3
- package/src/parse/accidental-helpers.js +1 -1
- package/src/parse/decode-abc-text.js +201 -0
- package/src/parse/getMetadata.js +45 -41
- package/src/parse/header-parser.js +33 -15
- package/src/parse/note-parser.js +4 -3
package/package.json
CHANGED
package/src/incipit.js
CHANGED
|
@@ -288,7 +288,8 @@ function getIncipit(data) {
|
|
|
288
288
|
numBars = 2;
|
|
289
289
|
const currentMeter = getMeter(abc);
|
|
290
290
|
const unitLength = getUnitLength(abc);
|
|
291
|
-
if (
|
|
291
|
+
if (!currentMeter) numBars = 2;
|
|
292
|
+
else if (
|
|
292
293
|
(currentMeter[0] === 4 &&
|
|
293
294
|
currentMeter[1] === 4 &&
|
|
294
295
|
unitLength.den === 16) ||
|
package/src/manipulator.js
CHANGED
|
@@ -875,10 +875,13 @@ function getFirstBars(
|
|
|
875
875
|
|
|
876
876
|
// Parse ABC
|
|
877
877
|
const parsed = parseAbc(abc, { maxBars: estimatedMaxBars });
|
|
878
|
-
const { bars, headerLines, barLines, musicText
|
|
878
|
+
const { bars, headerLines, barLines, musicText } = parsed;
|
|
879
|
+
let { meter } = parsed;
|
|
880
|
+
if (!meter) meter = [10000, 1]; //hack(?) to handle incipits in meterless music
|
|
879
881
|
|
|
882
|
+
let stopAfterDuration = null;
|
|
880
883
|
if (bars.length === 0 || barLines.length === 0) {
|
|
881
|
-
|
|
884
|
+
stopAfterDuration = new Fraction(3, 2);
|
|
882
885
|
}
|
|
883
886
|
|
|
884
887
|
// Determine which bar number to stop after
|
|
@@ -910,7 +913,8 @@ function getFirstBars(
|
|
|
910
913
|
|
|
911
914
|
// Calculate the expected duration per musical bar
|
|
912
915
|
const expectedBarDuration = new Fraction(meter[0], meter[1]);
|
|
913
|
-
const targetDuration =
|
|
916
|
+
const targetDuration =
|
|
917
|
+
stopAfterDuration ?? expectedBarDuration.multiply(numBarsFraction);
|
|
914
918
|
|
|
915
919
|
// Determine starting position and how much duration we need to accumulate
|
|
916
920
|
let startPos = 0;
|
|
@@ -417,7 +417,7 @@ function reconstructMusicFromTokens(tokens) {
|
|
|
417
417
|
const token = tokens[i];
|
|
418
418
|
|
|
419
419
|
// Add the token (possibly modified)
|
|
420
|
-
result += token.token;
|
|
420
|
+
if (token.token) result += token.token;
|
|
421
421
|
|
|
422
422
|
// Add spacing after token (but not after the last token)
|
|
423
423
|
if (i < tokens.length - 1 && token.spacing && token.spacing.whitespace) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps 2-char ABC mnemonic sequences (ABC 2.1 §2.3) to Unicode characters.
|
|
3
|
+
* Keys are the two characters following the backslash.
|
|
4
|
+
*/
|
|
5
|
+
const MNEMONIC_MAP = {
|
|
6
|
+
// Grave: \`X
|
|
7
|
+
"`A": "À",
|
|
8
|
+
"`a": "à",
|
|
9
|
+
"`E": "È",
|
|
10
|
+
"`e": "è",
|
|
11
|
+
"`I": "Ì",
|
|
12
|
+
"`i": "ì",
|
|
13
|
+
"`O": "Ò",
|
|
14
|
+
"`o": "ò",
|
|
15
|
+
"`U": "Ù",
|
|
16
|
+
"`u": "ù",
|
|
17
|
+
// Acute: \'X
|
|
18
|
+
"'A": "Á",
|
|
19
|
+
"'a": "á",
|
|
20
|
+
"'E": "É",
|
|
21
|
+
"'e": "é",
|
|
22
|
+
"'I": "Í",
|
|
23
|
+
"'i": "í",
|
|
24
|
+
"'O": "Ó",
|
|
25
|
+
"'o": "ó",
|
|
26
|
+
"'U": "Ú",
|
|
27
|
+
"'u": "ú",
|
|
28
|
+
"'Y": "Ý",
|
|
29
|
+
"'y": "ý",
|
|
30
|
+
// Circumflex: \^X
|
|
31
|
+
"^A": "Â",
|
|
32
|
+
"^a": "â",
|
|
33
|
+
"^E": "Ê",
|
|
34
|
+
"^e": "ê",
|
|
35
|
+
"^I": "Î",
|
|
36
|
+
"^i": "î",
|
|
37
|
+
"^O": "Ô",
|
|
38
|
+
"^o": "ô",
|
|
39
|
+
"^U": "Û",
|
|
40
|
+
"^u": "û",
|
|
41
|
+
// Tilde: \~X
|
|
42
|
+
"~A": "Ã",
|
|
43
|
+
"~a": "ã",
|
|
44
|
+
"~N": "Ñ",
|
|
45
|
+
"~n": "ñ",
|
|
46
|
+
"~O": "Õ",
|
|
47
|
+
"~o": "õ",
|
|
48
|
+
// Umlaut: \"X
|
|
49
|
+
'"A': "Ä",
|
|
50
|
+
'"a': "ä",
|
|
51
|
+
'"E': "Ë",
|
|
52
|
+
'"e': "ë",
|
|
53
|
+
'"I': "Ï",
|
|
54
|
+
'"i': "ï",
|
|
55
|
+
'"O': "Ö",
|
|
56
|
+
'"o': "ö",
|
|
57
|
+
'"U': "Ü",
|
|
58
|
+
'"u': "ü",
|
|
59
|
+
'"Y': "Ÿ",
|
|
60
|
+
'"y': "ÿ",
|
|
61
|
+
// Cedilla, ring, slash
|
|
62
|
+
cC: "Ç",
|
|
63
|
+
cc: "ç",
|
|
64
|
+
AA: "Å",
|
|
65
|
+
aa: "å",
|
|
66
|
+
"/O": "Ø",
|
|
67
|
+
"/o": "ø",
|
|
68
|
+
// Breve: \uX (note: \uXXXX hex escapes are resolved before this map is applied)
|
|
69
|
+
uA: "Ă",
|
|
70
|
+
ua: "ă",
|
|
71
|
+
uE: "Ĕ",
|
|
72
|
+
ue: "ĕ",
|
|
73
|
+
// Caron, double acute
|
|
74
|
+
vS: "Š",
|
|
75
|
+
vs: "š",
|
|
76
|
+
vZ: "Ž",
|
|
77
|
+
vz: "ž",
|
|
78
|
+
HO: "Ő",
|
|
79
|
+
Ho: "ő",
|
|
80
|
+
HU: "Ű",
|
|
81
|
+
Hu: "ű",
|
|
82
|
+
// Ligatures
|
|
83
|
+
ss: "ß",
|
|
84
|
+
AE: "Æ",
|
|
85
|
+
ae: "æ",
|
|
86
|
+
oe: "œ"
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Named HTML entities for common European characters. */
|
|
90
|
+
const HTML_ENTITY_MAP = {
|
|
91
|
+
amp: "&",
|
|
92
|
+
lt: "<",
|
|
93
|
+
gt: ">",
|
|
94
|
+
quot: '"',
|
|
95
|
+
apos: "'",
|
|
96
|
+
nbsp: "\u00A0",
|
|
97
|
+
Agrave: "À",
|
|
98
|
+
agrave: "à",
|
|
99
|
+
Aacute: "Á",
|
|
100
|
+
aacute: "á",
|
|
101
|
+
Acirc: "Â",
|
|
102
|
+
acirc: "â",
|
|
103
|
+
Atilde: "Ã",
|
|
104
|
+
atilde: "ã",
|
|
105
|
+
Auml: "Ä",
|
|
106
|
+
auml: "ä",
|
|
107
|
+
Aring: "Å",
|
|
108
|
+
aring: "å",
|
|
109
|
+
AElig: "Æ",
|
|
110
|
+
aelig: "æ",
|
|
111
|
+
Ccedil: "Ç",
|
|
112
|
+
ccedil: "ç",
|
|
113
|
+
Egrave: "È",
|
|
114
|
+
egrave: "è",
|
|
115
|
+
Eacute: "É",
|
|
116
|
+
eacute: "é",
|
|
117
|
+
Ecirc: "Ê",
|
|
118
|
+
ecirc: "ê",
|
|
119
|
+
Euml: "Ë",
|
|
120
|
+
euml: "ë",
|
|
121
|
+
Igrave: "Ì",
|
|
122
|
+
igrave: "ì",
|
|
123
|
+
Iacute: "Í",
|
|
124
|
+
iacute: "í",
|
|
125
|
+
Icirc: "Î",
|
|
126
|
+
icirc: "î",
|
|
127
|
+
Iuml: "Ï",
|
|
128
|
+
iuml: "ï",
|
|
129
|
+
Ntilde: "Ñ",
|
|
130
|
+
ntilde: "ñ",
|
|
131
|
+
Ograve: "Ò",
|
|
132
|
+
ograve: "ò",
|
|
133
|
+
Oacute: "Ó",
|
|
134
|
+
oacute: "ó",
|
|
135
|
+
Ocirc: "Ô",
|
|
136
|
+
ocirc: "ô",
|
|
137
|
+
Otilde: "Õ",
|
|
138
|
+
otilde: "õ",
|
|
139
|
+
Ouml: "Ö",
|
|
140
|
+
ouml: "ö",
|
|
141
|
+
Oslash: "Ø",
|
|
142
|
+
oslash: "ø",
|
|
143
|
+
Ugrave: "Ù",
|
|
144
|
+
ugrave: "ù",
|
|
145
|
+
Uacute: "Ú",
|
|
146
|
+
uacute: "ú",
|
|
147
|
+
Ucirc: "Û",
|
|
148
|
+
ucirc: "û",
|
|
149
|
+
Uuml: "Ü",
|
|
150
|
+
uuml: "ü",
|
|
151
|
+
Yacute: "Ý",
|
|
152
|
+
yacute: "ý",
|
|
153
|
+
Yuml: "Ÿ",
|
|
154
|
+
szlig: "ß",
|
|
155
|
+
OElig: "Œ",
|
|
156
|
+
oelig: "œ"
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Strips an ABC inline % comment (a % not preceded by \) and trims trailing whitespace.
|
|
161
|
+
* @param {string} str
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
function stripComment(str) {
|
|
165
|
+
return str.replace(/(?<!\\)%.*/, "").trimEnd();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Decodes ABC 2.1 text-string escapes (§2.3) into Unicode.
|
|
170
|
+
* Processing order:
|
|
171
|
+
* 1. Protect \\ with a placeholder
|
|
172
|
+
* 2. \uXXXX fixed-width unicode (must precede breve \uX mnemonic)
|
|
173
|
+
* 3. Protect \% and \& control escapes
|
|
174
|
+
* 4–6. HTML named / decimal / hex entities
|
|
175
|
+
* 7. Curly-brace mnemonic variants: {mnem} or {\mnem}
|
|
176
|
+
* 8. Backslash mnemonics: \XX
|
|
177
|
+
* 9. Restore placeholders
|
|
178
|
+
* @param {string} str - Header value, already comment-stripped
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
function decodeABCText(str) {
|
|
182
|
+
return str
|
|
183
|
+
.replace(/\\\\/g, "\x00")
|
|
184
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) =>
|
|
185
|
+
String.fromCodePoint(parseInt(h, 16))
|
|
186
|
+
)
|
|
187
|
+
.replace(/\\%/g, "\x01")
|
|
188
|
+
.replace(/\\&/g, "\x02")
|
|
189
|
+
.replace(/&([a-zA-Z]+);/g, (m, n) => HTML_ENTITY_MAP[n] ?? m)
|
|
190
|
+
.replace(/&#x([0-9a-fA-F]+);/gi, (_, h) =>
|
|
191
|
+
String.fromCodePoint(parseInt(h, 16))
|
|
192
|
+
)
|
|
193
|
+
.replace(/&#([0-9]+);/g, (_, n) => String.fromCodePoint(+n))
|
|
194
|
+
.replace(/\{\\?([^}{]{2})\}/g, (m, k) => MNEMONIC_MAP[k] ?? m)
|
|
195
|
+
.replace(/\\(..)/g, (m, k) => MNEMONIC_MAP[k] ?? m)
|
|
196
|
+
.replaceAll("\x00", "\\")
|
|
197
|
+
.replaceAll("\x01", "%")
|
|
198
|
+
.replaceAll("\x02", "&");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { decodeABCText, stripComment };
|
package/src/parse/getMetadata.js
CHANGED
|
@@ -1,71 +1,75 @@
|
|
|
1
1
|
const { normaliseKey } = require("../manipulator");
|
|
2
|
+
const { decodeABCText, stripComment } = require("./decode-abc-text");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Extracts data in the ABC _header_ T R C M K S F D N H fields
|
|
5
|
-
* and returns it in
|
|
6
|
-
* source, url, recording, comments, and hComments.
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @param {
|
|
15
|
-
* @returns {object}
|
|
5
|
+
* Extracts data in the ABC _header_ T R C M K S O F D N H fields
|
|
6
|
+
* and returns it in an object with properties: title, rhythm, composer, meter, key,
|
|
7
|
+
* source, origin, url, recording, comments, and hComments.
|
|
8
|
+
* - Only the first T title is stored in `title`; subsequent ones go in `titles`
|
|
9
|
+
* - The key is normalised: C, Cmaj, C maj, C major all map to "C major"
|
|
10
|
+
* - N: lines accumulate in a `comments` array
|
|
11
|
+
* - H: lines (and +: continuations) are joined with spaces into `hComments`
|
|
12
|
+
* - ABC text escapes (mnemonics, entities, etc.) are decoded by default
|
|
13
|
+
* @param {string} abc
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {boolean} [options.decode=true] - Decode ABC text escapes; pass false for raw speed
|
|
16
|
+
* @returns {object}
|
|
16
17
|
*/
|
|
17
|
-
function getMetadata(abc) {
|
|
18
|
+
function getMetadata(abc, { decode = true } = {}) {
|
|
18
19
|
const lines = abc.split("\n"),
|
|
19
20
|
metadata = {},
|
|
20
21
|
comments = [],
|
|
21
22
|
hComments = [],
|
|
22
23
|
titles = [];
|
|
23
24
|
|
|
25
|
+
const process = decode
|
|
26
|
+
? (raw) => decodeABCText(stripComment(raw))
|
|
27
|
+
: (raw) => stripComment(raw);
|
|
28
|
+
|
|
24
29
|
let currentHeader = "";
|
|
25
30
|
|
|
26
31
|
for (const line of lines) {
|
|
27
32
|
const trimmed = line.trim();
|
|
28
|
-
|
|
33
|
+
if (!trimmed || trimmed[0] === "%") continue;
|
|
34
|
+
|
|
35
|
+
if (trimmed.startsWith("K:")) {
|
|
36
|
+
metadata.key = normaliseKey(
|
|
37
|
+
stripComment(trimmed.substring(2).trim())
|
|
38
|
+
).join(" ");
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const val = process(trimmed.substring(2).trim());
|
|
43
|
+
|
|
29
44
|
if (trimmed.startsWith("T:")) {
|
|
30
|
-
if (!metadata.title) metadata.title =
|
|
31
|
-
else titles.push(
|
|
45
|
+
if (!metadata.title) metadata.title = val;
|
|
46
|
+
else titles.push(val);
|
|
32
47
|
} else if (trimmed.startsWith("R:")) {
|
|
33
|
-
metadata.rhythm =
|
|
48
|
+
metadata.rhythm = val.toLowerCase();
|
|
34
49
|
} else if (trimmed.startsWith("C:")) {
|
|
35
|
-
metadata.composer =
|
|
50
|
+
metadata.composer = val;
|
|
36
51
|
} else if (trimmed.startsWith("M:")) {
|
|
37
|
-
metadata.meter =
|
|
38
|
-
} else if (trimmed.startsWith("K:")) {
|
|
39
|
-
metadata.key = normaliseKey(trimmed2).join(" ");
|
|
40
|
-
// metadata.indexOfKey = i
|
|
41
|
-
break;
|
|
52
|
+
metadata.meter = val;
|
|
42
53
|
} else if (trimmed.startsWith("S:")) {
|
|
43
|
-
metadata.source =
|
|
54
|
+
metadata.source = val;
|
|
44
55
|
} else if (trimmed.startsWith("O:")) {
|
|
45
|
-
metadata.origin =
|
|
56
|
+
metadata.origin = val;
|
|
46
57
|
} else if (trimmed.startsWith("F:")) {
|
|
47
|
-
metadata.url =
|
|
58
|
+
metadata.url = val;
|
|
48
59
|
} else if (trimmed.startsWith("D:")) {
|
|
49
|
-
metadata.recording =
|
|
60
|
+
metadata.recording = val;
|
|
50
61
|
} else if (trimmed.startsWith("N:")) {
|
|
51
|
-
comments.push(
|
|
62
|
+
comments.push(val);
|
|
52
63
|
} else if (trimmed.startsWith("H:")) {
|
|
53
64
|
currentHeader = "H";
|
|
54
|
-
hComments.push(
|
|
55
|
-
} else if (trimmed.startsWith("+:")) {
|
|
56
|
-
|
|
57
|
-
case "H":
|
|
58
|
-
hComments.push(trimmed2);
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
65
|
+
hComments.push(val);
|
|
66
|
+
} else if (trimmed.startsWith("+:") && currentHeader === "H") {
|
|
67
|
+
hComments.push(val);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (hComments.length > 0) {
|
|
67
|
-
metadata.hComments = hComments.join(" ");
|
|
68
|
-
}
|
|
70
|
+
|
|
71
|
+
if (comments.length > 0) metadata.comments = comments;
|
|
72
|
+
if (hComments.length > 0) metadata.hComments = hComments.join(" ");
|
|
69
73
|
if (titles.length > 0) metadata.titles = titles;
|
|
70
74
|
|
|
71
75
|
return metadata;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Fraction } = require("../math.js");
|
|
2
|
+
const { decodeABCText, stripComment } = require("./decode-abc-text");
|
|
2
3
|
|
|
3
4
|
// ============================================================================
|
|
4
5
|
// ABC HEADER PARSING
|
|
@@ -12,6 +13,37 @@ const { Fraction } = require("../math.js");
|
|
|
12
13
|
//
|
|
13
14
|
// ============================================================================
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Returns all values for a given header letter in the ABC string, in document order.
|
|
18
|
+
* @param {string} abc
|
|
19
|
+
* @param {string} header - Single header letter, e.g. 'T'
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {boolean} [options.decode=true] - Decode ABC text escapes; pass false for raw values
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
function getHeaderValues(abc, header, { decode = true } = {}) {
|
|
25
|
+
const re = new RegExp(`^${header}:[ \\t]*(.*)$`, "gm");
|
|
26
|
+
const results = [];
|
|
27
|
+
for (const m of abc.matchAll(re)) {
|
|
28
|
+
const val = decode ? decodeABCText(stripComment(m[1])) : stripComment(m[1]);
|
|
29
|
+
if (val) results.push(val);
|
|
30
|
+
}
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @deprecated Use `getHeaderValues(abc, header)[0] ?? null` instead. */
|
|
35
|
+
function getHeaderValue(abc, header, options) {
|
|
36
|
+
return getHeaderValues(abc, header, options)[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @deprecated Use `getHeaderValues(abc, 'T')` instead.
|
|
41
|
+
* Note: unlike the previous implementation, this now returns decoded strings, not match objects.
|
|
42
|
+
*/
|
|
43
|
+
function getTitles(abc) {
|
|
44
|
+
return getHeaderValues(abc, "T");
|
|
45
|
+
}
|
|
46
|
+
|
|
15
47
|
/**
|
|
16
48
|
* Extract base note of key signature from ABC header
|
|
17
49
|
*
|
|
@@ -70,21 +102,6 @@ function getUnitLength(abc) {
|
|
|
70
102
|
}
|
|
71
103
|
return new Fraction(1, 8); // Default to 1/8
|
|
72
104
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Extract titles - there may be 0..N titles
|
|
75
|
-
*
|
|
76
|
-
* @param {string} abc - ABC notation string
|
|
77
|
-
* @returns {[string]} - array of titles
|
|
78
|
-
*/
|
|
79
|
-
function getTitles(abc) {
|
|
80
|
-
return [...abc.matchAll(/^(?:T:\s*(.+)\n)/gm)];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function getHeaderValue(abc, header) {
|
|
84
|
-
const r = new RegExp(String.raw`(?:${header}:\s*(.+)\n)`, "m"),
|
|
85
|
-
m = abc.match(r);
|
|
86
|
-
return m ? m[1]?.trim() : null;
|
|
87
|
-
}
|
|
88
105
|
|
|
89
106
|
/**
|
|
90
107
|
* Process ABC lines: extract music lines with metadata
|
|
@@ -167,6 +184,7 @@ function getMusicLines(abc) {
|
|
|
167
184
|
|
|
168
185
|
module.exports = {
|
|
169
186
|
getHeaderValue,
|
|
187
|
+
getHeaderValues,
|
|
170
188
|
getKey,
|
|
171
189
|
getMeter,
|
|
172
190
|
getMusicLines,
|
package/src/parse/note-parser.js
CHANGED
|
@@ -294,11 +294,12 @@ function parseGraceNotes(graceStr) {
|
|
|
294
294
|
function parseBrokenRhythm(token) {
|
|
295
295
|
const brokenMatch = token.match(/^(<{1,3}|>{1,3})$/);
|
|
296
296
|
if (brokenMatch) {
|
|
297
|
-
const
|
|
297
|
+
const token = brokenMatch[1];
|
|
298
298
|
return {
|
|
299
299
|
isBrokenRhythm: true,
|
|
300
|
-
direction:
|
|
301
|
-
|
|
300
|
+
direction: token[0],
|
|
301
|
+
token,
|
|
302
|
+
dots: token.length
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
return null;
|