@goplayerjuggler/abc-tools 1.0.21 → 1.0.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "sorting algorithm and implementation for ABC tunes; plus other tools for parsing and manipulating ABC tunes",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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) ||
@@ -175,6 +175,8 @@ function hasAnacrucis(abc) {
175
175
  * @throws {Error} If the current meter doesn't match either smallMeter or largeMeter
176
176
  */
177
177
  function toggleMeterDoubling(abc, smallMeter, largeMeter, currentMeter) {
178
+ //if no L: header at all: add one
179
+ if (!/\nL:\s*1\/8/.test(abc)) abc = abc.replace("\nK:", "\nL:1/8\nK:");
178
180
  if (!currentMeter) currentMeter = getMeter(abc);
179
181
 
180
182
  const isSmall =
@@ -649,7 +651,7 @@ function convertStandardReel(
649
651
  }
650
652
 
651
653
  let result = //toggleMeter_4_4_to_4_2(reel, meter);
652
- toggleMeterDoubling(reel, [4, 4], [4, 2], meter);
654
+ toggleMeterDoubling(reel, meter, [4, 2], meter);
653
655
  if (comment) {
654
656
  result = result.replace(/(\nK:)/, `\nN:${comment}$1`);
655
657
  }
@@ -875,10 +877,13 @@ function getFirstBars(
875
877
 
876
878
  // Parse ABC
877
879
  const parsed = parseAbc(abc, { maxBars: estimatedMaxBars });
878
- const { bars, headerLines, barLines, musicText, meter } = parsed;
880
+ const { bars, headerLines, barLines, musicText } = parsed;
881
+ let { meter } = parsed;
882
+ if (!meter) meter = [10000, 1]; //hack(?) to handle incipits in meterless music
879
883
 
884
+ let stopAfterDuration = null;
880
885
  if (bars.length === 0 || barLines.length === 0) {
881
- throw new Error("No bars found");
886
+ stopAfterDuration = new Fraction(3, 2);
882
887
  }
883
888
 
884
889
  // Determine which bar number to stop after
@@ -910,7 +915,8 @@ function getFirstBars(
910
915
 
911
916
  // Calculate the expected duration per musical bar
912
917
  const expectedBarDuration = new Fraction(meter[0], meter[1]);
913
- const targetDuration = expectedBarDuration.multiply(numBarsFraction);
918
+ const targetDuration =
919
+ stopAfterDuration ?? expectedBarDuration.multiply(numBarsFraction);
914
920
 
915
921
  // Determine starting position and how much duration we need to accumulate
916
922
  let startPos = 0;
@@ -1070,8 +1076,8 @@ function canDoubleBarLength(abc) {
1070
1076
  !abc.match(/\[L:/) &&
1071
1077
  (((rhythm === "reel" || rhythm === "hornpipe") &&
1072
1078
  l.equals(new Fraction(1, 8)) &&
1073
- meter[0] === 4 &&
1074
- meter[1] === 4) ||
1079
+ ((meter[0] === 4 && meter[1] === 4) ||
1080
+ (meter[0] === 2 && meter[1] === 2))) ||
1075
1081
  (rhythm === "jig" && meter[0] === 6 && meter[1] === 8))) ||
1076
1082
  (rhythm === "polka" && meter[0] === 2 && meter[1] === 4)
1077
1083
  );
@@ -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 };
@@ -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 a object with properties: title, rhythm, composer, meter, key,
6
- * source, url, recording, comments, and hComments.
7
- * Minimal parsing, but a few features:
8
- * - only extracts the first T title; subsequent T entries are ignored
9
- * - the key is normalised, so C, Cmaj, C maj, C major will all map to key:"C major"
10
- * - the comments (i.e. the N / notes) go in an array called `comments`, with one array entry per N: line
11
- * - the history (H) lines are joined up with spaces into a single line that is returned as `hComments`
12
- * - the field continuation `+:` is handled only for lines following an initial H (history)
13
- * - if there’s more than one T (title), then titles after the first one are returned in an array `titles`
14
- * @param {*} abc
15
- * @returns {object} - The header info
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
- const trimmed2 = trimmed.substring(2).trim().replace(/%.+/, "");
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 = trimmed2;
31
- else titles.push(trimmed2);
45
+ if (!metadata.title) metadata.title = val;
46
+ else titles.push(val);
32
47
  } else if (trimmed.startsWith("R:")) {
33
- metadata.rhythm = trimmed2.toLowerCase();
48
+ metadata.rhythm = val.toLowerCase();
34
49
  } else if (trimmed.startsWith("C:")) {
35
- metadata.composer = trimmed2;
50
+ metadata.composer = val;
36
51
  } else if (trimmed.startsWith("M:")) {
37
- metadata.meter = trimmed2;
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 = trimmed2;
54
+ metadata.source = val;
44
55
  } else if (trimmed.startsWith("O:")) {
45
- metadata.origin = trimmed2;
56
+ metadata.origin = val;
46
57
  } else if (trimmed.startsWith("F:")) {
47
- metadata.url = trimmed2;
58
+ metadata.url = val;
48
59
  } else if (trimmed.startsWith("D:")) {
49
- metadata.recording = trimmed2;
60
+ metadata.recording = val;
50
61
  } else if (trimmed.startsWith("N:")) {
51
- comments.push(trimmed2);
62
+ comments.push(val);
52
63
  } else if (trimmed.startsWith("H:")) {
53
64
  currentHeader = "H";
54
- hComments.push(trimmed2);
55
- } else if (trimmed.startsWith("+:")) {
56
- switch (currentHeader) {
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
- if (comments.length > 0) {
64
- metadata.comments = comments;
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,
@@ -204,8 +204,10 @@ function sort(arr, options = {}) {
204
204
  ],
205
205
  applySwingTransform = ["hornpipe", "barndance", "fling", "mazurka"],
206
206
  getAbc: getAbcForContour = getAbcForContour_default,
207
- getContourOptions = {
208
- withSvg: true
207
+ getContourOptions = () => {
208
+ return {
209
+ withSvg: true
210
+ };
209
211
  }
210
212
  } = options;
211
213
 
@@ -223,13 +225,21 @@ function sort(arr, options = {}) {
223
225
  for (const tune of arr) {
224
226
  if (!tune.contour) {
225
227
  try {
228
+ const contourOptions = getContourOptions();
229
+ // if (
230
+ // tune.title?.indexOf("oldrick") >= 0 ||
231
+ // ) {
232
+ // console.log("debug");
233
+ // }
226
234
  const withSwingTransform =
227
235
  applySwingTransform.indexOf(tune.rhythm) >= 0;
228
236
  const shortAbc = getAbcForContour(tune);
229
237
 
230
238
  if (shortAbc) {
231
- getContourOptions.withSwingTransform = withSwingTransform;
232
- tune.contour = getContour(shortAbc, getContourOptions);
239
+ contourOptions.withSwingTransform = withSwingTransform;
240
+ if (Object.hasOwn(tune, "contourShift"))
241
+ contourOptions.contourShift = tune.contourShift;
242
+ tune.contour = getContour(shortAbc, contourOptions);
233
243
  }
234
244
  } catch (error) {
235
245
  console.log(error);