@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
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) ||
@@ -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, meter } = parsed;
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
- throw new Error("No bars found");
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 = expectedBarDuration.multiply(numBarsFraction);
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 };
@@ -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,
@@ -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 symbol = brokenMatch[1];
297
+ const token = brokenMatch[1];
298
298
  return {
299
299
  isBrokenRhythm: true,
300
- direction: symbol[0],
301
- dots: symbol.length
300
+ direction: token[0],
301
+ token,
302
+ dots: token.length
302
303
  };
303
304
  }
304
305
  return null;