@goplayerjuggler/abc-tools 1.0.4 → 1.0.6

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.
Files changed (33) hide show
  1. package/.editorconfig +11 -2
  2. package/eslint.config.js +41 -44
  3. package/package.json +1 -1
  4. package/src/incipit.js +152 -10
  5. package/src/index.js +10 -1
  6. package/src/javascriptify.js +84 -0
  7. package/src/manipulator.js +1 -1
  8. package/src/math.js +16 -5
  9. package/src/parse/note-parser.js +0 -1
  10. package/src/parse/parser.js +43 -3
  11. package/src/parse/token-utils.js +72 -3
  12. package/src/{contour-sort.js → sort/contour-sort.js} +73 -96
  13. package/src/sort/contour-svg.js +238 -0
  14. package/src/sort/display-contour.js +67 -0
  15. package/src/sort/encode.js +60 -0
  16. package/test-output/svg/comparison-high-range-auto-range.svg +11 -0
  17. package/test-output/svg/comparison-high-range.svg +11 -0
  18. package/test-output/svg/comparison-low-range-auto-range.svg +11 -0
  19. package/test-output/svg/comparison-low-range.svg +11 -0
  20. package/test-output/svg/comparison-mid-range-auto-range.svg +11 -0
  21. package/test-output/svg/comparison-mid-range.svg +11 -0
  22. package/test-output/svg/held-vs-repeated-auto-range.svg +9 -0
  23. package/test-output/svg/held-vs-repeated.svg +9 -0
  24. package/test-output/svg/multiple-silences-auto-range.svg +9 -0
  25. package/test-output/svg/multiple-silences.svg +9 -0
  26. package/test-output/svg/simple-ascending-auto-range.svg +17 -0
  27. package/test-output/svg/simple-ascending.svg +17 -0
  28. package/test-output/svg/the-munster-auto-range.svg +21 -0
  29. package/test-output/svg/the-munster.svg +21 -0
  30. package/test-output/svg/with-silences-auto-range.svg +9 -0
  31. package/test-output/svg/with-silences.svg +9 -0
  32. package/test-output/svg/with-subdivisions-auto-range.svg +17 -0
  33. package/test-output/svg/with-subdivisions.svg +17 -0
package/.editorconfig CHANGED
@@ -4,7 +4,7 @@ root = true
4
4
 
5
5
  # Unix-style newlines with a newline ending every file
6
6
  [*]
7
- end_of_line = lf
7
+ end_of_line = crlf
8
8
  insert_final_newline = true
9
9
 
10
10
  # Matches multiple files with brace expansion notation
@@ -14,4 +14,13 @@ charset = utf-8
14
14
 
15
15
  # Indentation override for all JS
16
16
  [**.js]
17
- indent_style = tab
17
+ indent_style = tab
18
+
19
+ # Ignore paths
20
+ [{tunes.json,**/*tunebook*}.js]
21
+ charset = unset
22
+ end_of_line = unset
23
+ insert_final_newline = unset
24
+ trim_trailing_whitespace = unset
25
+ indent_style = unset
26
+ indent_size = unset
package/eslint.config.js CHANGED
@@ -1,44 +1,41 @@
1
- const js = require("@eslint/js");
2
- const globals = require("globals");
3
- const pluginJest = require('eslint-plugin-jest');
4
-
5
- module.exports = [
6
- {
7
- ...js.configs.recommended,
8
- plugins: { jest: pluginJest },
9
- languageOptions: {
10
- ...js.configs.recommended.languageOptions,
11
- globals: {
12
- ...globals.node,...pluginJest.environments.globals.globals
13
- },
14
- ecmaVersion: 12,
15
- sourceType: "module",
16
- },
17
-
18
- ignores: [
19
- "**/node_modules/",
20
- "**/coverage/",
21
- "**/dist/",
22
- ],
23
- rules: {
24
- ...js.configs.recommended.rules,
25
- // "arrow-spacing": "error"
26
- "curly": ["error", "all"],
27
- // "eol-last": ["error", "always"],
28
- "eqeqeq": ["error", "always"],
29
- "no-console": "off",
30
- "no-eval": "error",
31
- "no-implied-eval": "error",
32
- // "no-trailing-spaces": "error",
33
- "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
34
- "no-var": "error",
35
- "no-with": "error",
36
- "prefer-const": "error",
37
- "prefer-template": "error",
38
- // "comma-dangle": ["error", "never"],
39
- // "indent": ["error", 2],
40
- // "quotes": ["error", "single", { avoidEscape: true }],
41
- // "semi": ["error", "always"],
42
- }
43
- }
44
- ];
1
+ const js = require("@eslint/js");
2
+ const globals = require("globals");
3
+ const pluginJest = require("eslint-plugin-jest");
4
+
5
+ module.exports = [
6
+ {
7
+ ...js.configs.recommended,
8
+ plugins: { jest: pluginJest },
9
+ languageOptions: {
10
+ ...js.configs.recommended.languageOptions,
11
+ globals: {
12
+ ...globals.node,
13
+ ...pluginJest.environments.globals.globals,
14
+ },
15
+ ecmaVersion: 12,
16
+ sourceType: "module",
17
+ },
18
+
19
+ ignores: ["**/node_modules/", "**/coverage/", "**/dist/"],
20
+ rules: {
21
+ ...js.configs.recommended.rules,
22
+ // "arrow-spacing": "error"
23
+ //curly: ["error", "all"],
24
+ // "eol-last": ["error", "always"],
25
+ eqeqeq: ["error", "always"],
26
+ "no-console": "off",
27
+ "no-eval": "error",
28
+ "no-implied-eval": "error",
29
+ // "no-trailing-spaces": "error",
30
+ "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
31
+ "no-var": "error",
32
+ "no-with": "error",
33
+ "prefer-const": "error",
34
+ // "prefer-template": "error",
35
+ // "comma-dangle": ["error", "never"],
36
+ // "indent": ["error", 2],
37
+ // "quotes": ["error", "single", { avoidEscape: true }],
38
+ // "semi": ["error", "always"],
39
+ },
40
+ },
41
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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
@@ -4,6 +4,72 @@ const { getFirstBars } = require("./manipulator.js");
4
4
 
5
5
  const { getUnitLength, getMeter } = require("./parse/parser.js");
6
6
 
7
+ const { getContour } = require("./sort/contour-sort.js");
8
+
9
+ //this file has code that's a fork of some code in Michael Eskin's abctools
10
+
11
+ //
12
+ // Clean an incipit line
13
+ //
14
+ function cleanIncipitLine(theTextIncipit) {
15
+ //console.log("Starting incipit:");
16
+ //console.log(theTextIncipit);
17
+
18
+ // Strip any embedded voice [V:*]
19
+ let searchRegExp = /\[V:\s*\d+\]/gm;
20
+ theTextIncipit = theTextIncipit.replace(searchRegExp, "");
21
+ //console.log(theTextIncipit);
22
+
23
+ // Strip any embedded voice V: *
24
+ //searchRegExp = /V: [^ ]+ /gm
25
+ searchRegExp = /V:\s+\S+\s/gm;
26
+ theTextIncipit = theTextIncipit.replace(searchRegExp, "");
27
+ //console.log(theTextIncipit);
28
+
29
+ // Strip any embedded voice V:*
30
+ searchRegExp = /V:[^ ]+ /gm;
31
+ theTextIncipit = theTextIncipit.replace(searchRegExp, "");
32
+ //console.log(theTextIncipit);
33
+
34
+ // Sanitize !*! style annotations, but keep !fermata!
35
+ searchRegExp = /!(?!fermata!)[^!\n]*!/gm;
36
+ theTextIncipit = theTextIncipit.replace(searchRegExp, "");
37
+ //console.log(theTextIncipit);
38
+
39
+ // Strip out repeat marks
40
+ theTextIncipit = theTextIncipit.replaceAll("|:", "|");
41
+ theTextIncipit = theTextIncipit.replaceAll(":|", "|");
42
+
43
+ // strip out 1st 2nd etc time repeats
44
+ searchRegExp = /\[\d(,\d)*/gm;
45
+ theTextIncipit = theTextIncipit.replace(searchRegExp, "");
46
+
47
+ //console.log(theTextIncipit);
48
+
49
+ // Strip out brackets
50
+ // theTextIncipit = theTextIncipit.replaceAll("[", "");
51
+ //console.log(theTextIncipit);
52
+
53
+ // Strip out brackets
54
+ // theTextIncipit = theTextIncipit.replaceAll("]", "");
55
+ //console.log(theTextIncipit);
56
+
57
+ // Strip out continuations
58
+ theTextIncipit = theTextIncipit.replaceAll("\\", "");
59
+
60
+ // Segno
61
+ theTextIncipit = theTextIncipit.replaceAll("S", "");
62
+
63
+ // Strip out comments
64
+ theTextIncipit = theTextIncipit.replace(/"[^"]+"/gm, "");
65
+ // Strip out inline parts
66
+ theTextIncipit = theTextIncipit.replace(/\[P:[Ⅰ\w]\]/gm, "");
67
+
68
+ //
69
+ theTextIncipit = theTextIncipit.replace(/^\|[:|]?/, "");
70
+ return theTextIncipit;
71
+ }
72
+
7
73
  function StripAnnotationsOneForIncipits(theNotes) {
8
74
  // Strip out tempo markings
9
75
  let searchRegExp = /^Q:.*[\r\n]*/gm;
@@ -153,29 +219,85 @@ function StripChordsOne(theNotes) {
153
219
  }
154
220
 
155
221
  function sanitise(theTune) {
222
+ let j,
223
+ k,
224
+ theTextIncipits = [];
156
225
  // Strip out annotations
157
226
  theTune = StripAnnotationsOneForIncipits(theTune);
158
227
 
159
- // Strip out textnnotations
228
+ // Strip out atextnnotations
160
229
  theTune = StripTextAnnotationsOne(theTune);
161
230
 
162
231
  // Strip out chord markings
163
232
  theTune = StripChordsOne(theTune);
164
233
 
165
- return theTune;
234
+ // Parse out the first few measures
235
+ const theLines = theTune.split("\n"),
236
+ nLines = theLines.length;
237
+
238
+ // Find the key
239
+ let theKey = "";
240
+ let indexOfTheKey;
241
+
242
+ for (j = 0; j < nLines; ++j) {
243
+ theKey = theLines[j];
244
+
245
+ if (theKey.indexOf("K:") !== -1) {
246
+ indexOfTheKey = j;
247
+ break;
248
+ }
249
+ }
250
+ // Find the L: parameter
251
+ let theL = "";
252
+
253
+ for (j = 0; j < nLines; ++j) {
254
+ theL = theLines[j];
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];
265
+
266
+ if (theM.indexOf("M:") !== -1) {
267
+ break;
268
+ }
269
+ }
270
+ // Use at most the first three lines following the header K:
271
+ let added = 0;
272
+ for (k = indexOfTheKey + 1; k < nLines; ++k) {
273
+ const theTextIncipit = theLines[k];
274
+
275
+ // Clean out the incipit line of any annotations besides notes and bar lines
276
+ theTextIncipits.push(cleanIncipitLine(theTextIncipit));
277
+ added++;
278
+ if (added === 3) break;
279
+ }
280
+
281
+ return `X:1\n${theM}\n${theL}\n${theKey}\n${theTextIncipits.join("\n")}`;
166
282
  }
167
283
 
168
284
  /**
169
285
  * Get incipit (opening bars) of a tune for display/search purposes
170
- * @param {object} Object of the form {abc} with optional property: numBars
286
+ * @param {object|string} Object of the form {abc} with optional property: numBars, or a string in ABC format
171
287
  * @param {string} params.abc - ABC notation
172
- * @param {number|Fraction} params.numBars - Number of bars to return, counting the anacrucis if there is one. (default:2)
288
+ * @param {number|Fraction} params.numBars - Number of bars to return, counting the anacrucis if there is one.
289
+ * (default: 1.5 for some cases like M:4/4 L:1/16; 3 for M:3/4; otherwise 2)
173
290
  * @returns {string} - ABC incipit
174
291
  */
175
- function getIncipit({
176
- abc,
177
- numBars, //, part=null
178
- } = {}) {
292
+ function getIncipit(data) {
293
+ let {
294
+ abc,
295
+ numBars, //, part=null
296
+ } = typeof data === "string" ? { abc: data } : data;
297
+
298
+ const { withAnacrucis = true } =
299
+ typeof data === "string" ? { abc: data } : data;
300
+
179
301
  if (!numBars) {
180
302
  numBars = 2;
181
303
  const currentMeter = getMeter(abc);
@@ -195,7 +317,27 @@ function getIncipit({
195
317
  }
196
318
  }
197
319
  abc = sanitise(abc);
198
- return getFirstBars(abc, numBars, true, true, { all: true });
320
+ return getFirstBars(abc, numBars, withAnacrucis, false, { all: true });
321
+ }
322
+
323
+ function getIncipitForContourGeneration(abc) {
324
+ return getIncipit({
325
+ abc,
326
+ withAnacrucis: false,
327
+ numBars: 1,
328
+ });
329
+ }
330
+
331
+ function getContourFromFullAbc(abc) {
332
+ if (Array.isArray(abc)) {
333
+ if (abc.length === 0) return null;
334
+ abc = abc[0];
335
+ }
336
+ return getContour(getIncipitForContourGeneration(abc));
199
337
  }
200
338
 
201
- module.exports = { getIncipit };
339
+ module.exports = {
340
+ getIncipit,
341
+ getIncipitForContourGeneration,
342
+ getContourFromFullAbc,
343
+ };
package/src/index.js CHANGED
@@ -5,8 +5,12 @@
5
5
 
6
6
  const parser = require("./parse/parser.js");
7
7
  const manipulator = require("./manipulator.js");
8
- const sort = require("./contour-sort.js");
8
+ const sort = require("./sort/contour-sort.js");
9
+ const contourToSvg = require("./sort/contour-svg.js");
10
+ const displayContour = require("./sort/display-contour.js");
11
+
9
12
  const incipit = require("./incipit.js");
13
+ const javascriptify = require("./javascriptify.js");
10
14
 
11
15
  module.exports = {
12
16
  // Parser functions
@@ -17,7 +21,12 @@ module.exports = {
17
21
 
18
22
  // Sort functions
19
23
  ...sort,
24
+ ...displayContour,
25
+ ...contourToSvg,
20
26
 
21
27
  // Incipit functions
22
28
  ...incipit,
29
+
30
+ //
31
+ javascriptify,
23
32
  };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Converts a JavaScript value to a string representation using JavaScript syntax.
3
+ * Uses template literals for multiline strings and avoids quotes on property names where possible.
4
+ * Object properties with values that are falsey or empty arrays are omitted.
5
+ *
6
+ * @param {*} value - The value to convert to JavaScript syntax
7
+ * @param {number} indent - Current indentation level (for internal use)
8
+ * @returns {string} JavaScript code representation of the value
9
+ */
10
+ function javascriptify(value, indent = 0) {
11
+ const indentStr = " ".repeat(indent);
12
+ const nextIndentStr = " ".repeat(indent + 1);
13
+
14
+ // Handle null and undefined
15
+ if (value === null) {return "null";}
16
+ if (value === undefined) {return "undefined";}
17
+
18
+ // Handle primitives
19
+ if (typeof value === "number") {
20
+ return String(value);
21
+ }
22
+ if (typeof value === "boolean") {
23
+ return String(value);
24
+ }
25
+
26
+ // Handle strings
27
+ if (typeof value === "string") {
28
+ // Check if string contains newlines
29
+ if (value.includes("\n")) {
30
+ // Escape backslashes, backticks, and template literal interpolations
31
+ const escaped = value
32
+ .replace(/\\/g, "\\\\")
33
+ .replace(/`/g, "\\`")
34
+ .replace(/\$/g, "\\$");
35
+ return `\`${escaped}\``;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
39
+
40
+ // Handle arrays
41
+ if (Array.isArray(value)) {
42
+ if (value.length === 0) {return "[]";}
43
+
44
+ const items = value
45
+ .map((item) => nextIndentStr + javascriptify(item, indent + 1))
46
+ .join(",\n");
47
+
48
+ return `[\n${items}\n${indentStr}]`;
49
+ }
50
+
51
+ // Handle objects
52
+ if (typeof value === "object") {
53
+ // Filter out falsey values and empty arrays
54
+ const keys = Object.keys(value).filter((k) => {
55
+ const val = value[k];
56
+ // Omit all falsey values (including false, but keep 0)
57
+ if (val === 0) {return true;}
58
+ //if (val === null || val === undefined || val === "") return false;
59
+ if (!val) {return false;}
60
+ // Omit empty arrays
61
+ if (Array.isArray(val) && val.length === 0) {return false;}
62
+ return true;
63
+ });
64
+
65
+ if (keys.length === 0) {return "{}";}
66
+
67
+ const properties = keys
68
+ .map((key) => {
69
+ const propValue = javascriptify(value[key], indent + 1);
70
+ // Check if key is a valid identifier (can be unquoted)
71
+ const validIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key);
72
+ const keyStr = validIdentifier ? key : JSON.stringify(key);
73
+ return `${nextIndentStr}${keyStr}: ${propValue}`;
74
+ })
75
+ .join(",\n");
76
+
77
+ return `{\n${properties}\n${indentStr}}`;
78
+ }
79
+
80
+ // Fallback for functions and other types
81
+ return String(value);
82
+ }
83
+
84
+ module.exports = javascriptify;
@@ -159,7 +159,7 @@ function insertCharsAtIndexes(originalString, charToInsert, indexes) {
159
159
  /**
160
160
  * Toggle meter by doubling or halving bar length
161
161
  * Supports 4/4↔4/2 and 6/8↔12/8 transformations
162
- * This is neary a true inverse operation - going there and back preserves the ABC except for some
162
+ * This is nearly a true inverse operation - going there and back preserves the ABC except for some
163
163
  * edge cases involving spaces around the bar lines. No need to handle them.
164
164
  * Handles anacrusis correctly and preserves line breaks
165
165
  *
package/src/math.js CHANGED
@@ -1,13 +1,18 @@
1
- // ============================================================================
2
- // FRACTION CLASS (to avoid floating point errors)
3
- // ============================================================================
4
-
5
1
  class Fraction {
6
2
  constructor(numerator, denominator = 1) {
7
3
  if (denominator === 0) {
8
4
  throw new Error("Denominator cannot be zero");
9
5
  }
10
6
 
7
+ if (
8
+ isNaN(numerator) ||
9
+ isNaN(denominator) ||
10
+ numerator === null ||
11
+ denominator === null
12
+ ) {
13
+ throw new Error("invalid argument");
14
+ }
15
+
11
16
  const g = gcd(Math.abs(numerator), Math.abs(denominator));
12
17
  this.num = numerator / g;
13
18
  this.den = denominator / g;
@@ -53,7 +58,10 @@ class Fraction {
53
58
 
54
59
  compare(other) {
55
60
  // Returns -1 if this < other, 0 if equal, 1 if this > other
56
- const diff = this.num * other.den - other.num * this.den;
61
+ const diff =
62
+ typeof n === "number"
63
+ ? this.num - other * this.den
64
+ : this.num * other.den - other.num * this.den;
57
65
  return diff < 0 ? -1 : diff > 0 ? 1 : 0;
58
66
  }
59
67
 
@@ -72,6 +80,9 @@ class Fraction {
72
80
  toString() {
73
81
  return this.den === 1 ? `${this.num}` : `${this.num}/${this.den}`;
74
82
  }
83
+ toNumber() {
84
+ return this.num / this.den;
85
+ }
75
86
  }
76
87
 
77
88
  function gcd(a, b) {
@@ -459,5 +459,4 @@ module.exports = {
459
459
  parseTuplet,
460
460
  parseBrokenRhythm,
461
461
  applyBrokenRhythm,
462
- NOTE_TO_DEGREE,
463
462
  };
@@ -10,7 +10,6 @@ const {
10
10
  parseTuplet,
11
11
  parseBrokenRhythm,
12
12
  applyBrokenRhythm,
13
- NOTE_TO_DEGREE,
14
13
  } = require("./note-parser.js");
15
14
  const { classifyBarLine } = require("./barline-parser.js");
16
15
  const {
@@ -233,7 +232,7 @@ function parseABCWithBars(abc, options = {}) {
233
232
  const newlineSet = new Set(newlinePositions);
234
233
 
235
234
  // Comprehensive bar line regex - includes trailing spaces
236
- const barLineRegex = /(\|\]|\[\||(\|:?)|(:?\|)|::|(\|[1-6])) */g;
235
+ const barLineRegex = /(\|\||\|\]|\[\|\]|(\|:?)|(:?\|)|:\|\|:) */g;
237
236
 
238
237
  const bars = [];
239
238
  const barLines = [];
@@ -516,7 +515,49 @@ function calculateBarDurations(parsedData) {
516
515
  return result;
517
516
  }
518
517
 
518
+ /**
519
+ * Extracts all ABC music notation tunes from a string.
520
+ *
521
+ * @param {string} text - The input string containing one or more ABC tunes
522
+ * @returns {string[]} An array of ABC tune strings, each containing a complete tune
523
+ * @throws {TypeError} If the input is not a string
524
+ *
525
+ * @example
526
+ * const abcText = `X: 1
527
+ * T: Example Tune
528
+ * M: 4/4
529
+ * K: C
530
+ * CDEF|
531
+ *
532
+ * X: 2
533
+ * T: Another Tune
534
+ * K: G
535
+ * GABc|`;
536
+ *
537
+ * const tunes = getTunes(abcText);
538
+ * // Returns: ['X: 1\nT: Example Tune\nM: 4/4\nK: C\nCDEF|', 'X: 2\nT: Another Tune\nK: G\nGABc|']
539
+ */
540
+ function getTunes(text) {
541
+ if (typeof text !== "string") {
542
+ throw new TypeError("Input must be a string");
543
+ }
544
+ // Regex pattern to match ABC tunes:
545
+ // ^X:\s*\d+ - Matches lines starting with "X:" followed by optional whitespace and digits
546
+ // .*$ - Matches the rest of that line
547
+ // (?:\n(?!\n).*)* - Matches subsequent lines that are NOT empty lines (non-capturing group)
548
+ // \n(?!\n) ensures we have a newline NOT followed by another newline
549
+ // .* matches the content of that non-empty line
550
+ // * repeats for all consecutive non-empty lines
551
+ // Flags: g (global), m (multiline)
552
+ const getAbc = /^X:\s*\d+.*$(?:\n(?!\n).*)*$/gm;
553
+ // Extract all matches and return as an array of strings
554
+ const matches = [...text.matchAll(getAbc)];
555
+
556
+ return matches.map((match) => match[0]);
557
+ }
558
+
519
559
  module.exports = {
560
+ getTunes,
520
561
  parseABCWithBars,
521
562
  calculateBarDurations,
522
563
  // Re-export utilities for convenience
@@ -526,5 +567,4 @@ module.exports = {
526
567
  getMusicLines,
527
568
  analyzeSpacing,
528
569
  classifyBarLine,
529
- NOTE_TO_DEGREE,
530
570
  };
@@ -9,14 +9,83 @@
9
9
  //
10
10
  // ============================================================================
11
11
 
12
+ const TokenRegexComponents = {
13
+ // Tuplet notation: (3, (3:2, (3:2:4
14
+ tuplet: String.raw`\(\d(?::\d?){0,2}`,
15
+
16
+ // Inline field changes: [K:D], [L:1/4], [M:3/4], [P:A]
17
+ inlineField: String.raw`\[(?:[KLMP]):[^\]]+\]`,
18
+
19
+ // Text in quotes: chord symbols "Dm7" or annotations "^text"
20
+ quotedText: String.raw`"[^"]+"`,
21
+
22
+ // Bang decoration: !trill!, !fermata!, etc.
23
+ bangDecoration: String.raw`![^!]+!`,
24
+
25
+ // Symbol decorations before note: ~, ., M, P, S, T, H, U, V
26
+ // 0..N of them, with optional following white space
27
+ symbolDecoration: String.raw`[~.MPSTHUV]`,
28
+
29
+ symbolDecorations() {
30
+ return String.raw`(?:${this.symbolDecoration}\s*)*`;
31
+ },
32
+ bangDecorations() {
33
+ return String.raw`(?:${this.bangDecoration}\s*)*`;
34
+ },
35
+
36
+ // Accidental: :, ^, _ (natural, sharp, flat)
37
+ accidental: String.raw`[:^_]?`,
38
+
39
+ // Note pitch: A-G (lower octave), a-g (middle octave), z/x (rest), y (dummy)
40
+ // Or chord in brackets: [CEG], [DF#A]
41
+ pitch: String.raw`(?:[A-Ga-gzxy]|\[[A-Ga-g]+\])`,
42
+
43
+ // Octave modifiers: ' (up), , (down)
44
+ octave: String.raw`[',]*`,
45
+
46
+ // Duration: 2, /2, 3/4, /, //, etc.
47
+ // include all digits, even though things like A0, A5,... are not allowed
48
+ // but we could have A16, or z10 (full bar rest for M:5/4; L:1/8)
49
+ duration: String.raw`[0-9]*\/?[0-9]*`,
50
+
51
+ // Tie (-) or broken rhythm (>, >>, >>>, <, <<, <<<)
52
+ // Optional: may have neither, or may have whitespace before broken rhythm
53
+ tieOrBroken: String.raw`(?:-|\s*(?:<{1,3}|>{1,3}))?`,
54
+ };
55
+
12
56
  /**
13
57
  * Get regex for matching ABC music tokens
14
- * Matches: tuplets, inline fields, chord symbols, notes, rests, chords in brackets, decorations, broken rhythms
58
+ * Built from documented components for maintainability
59
+ *
60
+ * Matches: tuplets, inline fields, chord symbols, notes, rests, chords in brackets,
61
+ * decorations, ties, and broken rhythms
15
62
  *
16
63
  * @returns {RegExp} - Regular expression for tokenising ABC music
17
64
  */
18
- const getTokenRegex = () =>
19
- /\(\d(?::\d?){0,2}|\[([KLMP]):[^\]]+\]|"[^"]+"|(?:!([^!]+)!\s*)?[~.MPSTHUV]*[=^_]?(?:[A-Ga-gzxy]|\[[A-Ga-gzxy]+\])[',]*[0-9]*\/?[0-9]*(?:-|\s*(?:<{1,3}|>{1,3}))?|!([^!]+)!/g;
65
+ const getTokenRegex = () => {
66
+ const s = TokenRegexComponents;
67
+ // Complete note/rest/chord pattern with optional leading decoration
68
+ const notePattern =
69
+ s.bangDecorations() +
70
+ s.symbolDecorations() +
71
+ s.accidental +
72
+ s.pitch +
73
+ s.octave +
74
+ s.duration +
75
+ s.tieOrBroken;
76
+
77
+ // Combine all patterns with alternation
78
+ const fullPattern = [
79
+ s.tuplet,
80
+ s.inlineField,
81
+ s.quotedText,
82
+ //allow standalone bang and symbol decorations (?)
83
+ notePattern,
84
+ s.bangDecoration,
85
+ ].join("|");
86
+
87
+ return new RegExp(fullPattern, "g");
88
+ };
20
89
 
21
90
  /**
22
91
  * Parse inline field from music section