@goplayerjuggler/abc-tools 1.0.24 → 1.1.0

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/README.md CHANGED
@@ -1,16 +1,20 @@
1
1
  # abc-tools
2
-
3
2
  A JavaScript library with utility functions for tunes written in ABC format. Particularly applicable to Irish, and other, traditional music.
3
+
4
4
  # features
5
5
  * contour sort algorithm
6
6
  Sorts tunes by their contour, independent of key and mode.
7
7
  The algorithm used for sorting is new/original, as far as I know, and is described [here](./docs/contour_sort.md)
8
+ * contour visualisations as SVGs
8
9
  * Extracting initial bars and incipits
9
10
  * Doubling/halving bar length - e.g. going from 4/4 to 4/2 and vice versa
10
- This is work in progress; still quite buggy.
11
+ This is work in progress; it has a few small bugs but is still useable.
11
12
 
12
- # about this project
13
- Writing up the sort algorithm, its implementation along with implementation of other features, and the project setup, were all done with the help of Claude.ai and github copilot.
13
+ # where is it used
14
+ AFAIK at present this is only used by one other repo; a project of mine, [tuneTable](https://github.com/goplayerjuggler/tuneTable), which builds on this repo. There’s a [live demo](https://goplayerjuggler.github.io/tuneTable) featuring the contour sort - it sorts first by rhythm, then by contour.
15
+
16
+ # AI
17
+ A lot of the implementation was done with the help of AI, notably with Anthropic’s Claude.ai / Sonnet 4.6.
14
18
 
15
19
  ## license
16
20
 
@@ -24,7 +28,6 @@ This means you are free to use, modify, and distribute this software, but any de
24
28
  npm i @goplayerjuggler/abc-tools
25
29
  ```
26
30
 
27
-
28
31
  ## contributing
29
32
 
30
33
  Issues and pull requests welcome at https://github.com/goplayerjuggler/abc-tools
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.24",
4
- "description": "sorting algorithm and implementation for ABC tunes; plus other tools for parsing and manipulating ABC tunes",
3
+ "version": "1.1.0",
4
+ "description": "contour-sort and other sorting algorithms implementation for ABC tunes; and other tools for parsing and manipulating ABC tunes",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
@@ -16,8 +16,9 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "keywords": [
19
- "abc",
20
- "folk",
19
+ "abc-notation",
20
+ "folk-music",
21
+ "graphical-score",
21
22
  "irish",
22
23
  "melody",
23
24
  "modal",
@@ -25,7 +26,7 @@
25
26
  "parsing",
26
27
  "sort",
27
28
  "sorting",
28
- "traditional",
29
+ "traditional-music",
29
30
  "tune"
30
31
  ],
31
32
  "author": "Malcolm Schonfield",
package/src/incipit.js CHANGED
@@ -283,34 +283,38 @@ function getIncipit(data) {
283
283
 
284
284
  const { withAnacrucis = true } =
285
285
  typeof data === "string" ? { abc: data } : data;
286
-
286
+ let countAnacrucisInTotal = false;
287
287
  if (!numBars) {
288
288
  numBars = 2;
289
289
  const currentMeter = getMeter(abc);
290
290
  const unitLength = getUnitLength(abc);
291
291
  if (!currentMeter) numBars = 2;
292
292
  else if (
293
+ currentMeter[0] === 4 &&
294
+ currentMeter[1] === 2 &&
295
+ unitLength.den === 8
296
+ ) {
297
+ numBars = 1; //hornpipes
298
+ countAnacrucisInTotal = true; //otherwise it seems a bit too long
299
+ } else if (
293
300
  (currentMeter[0] === 4 &&
294
301
  currentMeter[1] === 4 &&
295
302
  unitLength.den === 16) ||
296
- (currentMeter[0] === 4 &&
297
- currentMeter[1] === 2 &&
298
- unitLength.den === 8) ||
299
- (currentMeter[0] === 12 && currentMeter[1] === 8)
300
- ) {
303
+ (currentMeter[0] === 12 && currentMeter[1] === 8) ||
304
+ (currentMeter[0] >= 12 && currentMeter[0] < 16)
305
+ )
301
306
  numBars = new Fraction(3, 2);
302
- } else if (currentMeter[0] === 3 && currentMeter[1] === 4) {
307
+ else if (currentMeter[0] === 3 && [4, 8].indexOf(currentMeter[1]) >= 0)
303
308
  numBars = 3;
304
- }
309
+ else if (currentMeter[0] >= 16) numBars = 1;
305
310
  }
306
311
  abc = sanitise(abc);
307
- return getFirstBars(abc, numBars, withAnacrucis, false, { all: true });
312
+ return getFirstBars(abc, numBars, withAnacrucis, countAnacrucisInTotal, {
313
+ all: true
314
+ });
308
315
  }
309
316
 
310
- function getIncipitForContourGeneration(
311
- abc,
312
- { numBars = new Fraction(2, 1) } = {}
313
- ) {
317
+ function getIncipitForContourGeneration(abc, { numBars = null } = {}) {
314
318
  return getIncipit({
315
319
  abc,
316
320
  withAnacrucis: false,
package/src/index.js CHANGED
@@ -1,12 +1,9 @@
1
- /**
2
- * ABC Music Toolkit
3
- * Main entry point for ABC parsing, manipulation, and sorting
4
- */
5
-
6
1
  const parser = require("./parse/parser.js");
7
2
  const miscParser = require("./parse/misc-parser.js");
8
3
  const manipulator = require("./manipulator.js");
9
- const sort = require("./sort/contour-sort.js");
4
+ const sort = require("./sort/sort.js");
5
+ const sortConstants = require("./sort/sort-constants.js");
6
+ const { compare } = require("./sort/contour-sort.js");
10
7
  const contourToSvg = require("./sort/contour-svg.js");
11
8
  const displayContour = require("./sort/display-contour.js");
12
9
 
@@ -18,18 +15,20 @@ const { getBarInfo } = require("./parse/getBarInfo.js");
18
15
  const { getMetadata } = require("./parse/getMetadata.js");
19
16
 
20
17
  module.exports = {
21
- // Parser functions
18
+ // Get info
22
19
  ...parser,
23
20
  ...miscParser,
24
21
  getBarInfo,
25
22
  getMetadata,
26
23
  ...incipit,
27
24
 
28
- // Manipulator functions
25
+ // change things
29
26
  ...manipulator,
30
27
 
31
- // Sort functions
28
+ // Sort
32
29
  ...sort,
30
+ ...sortConstants,
31
+ compareContour: compare,
33
32
  ...displayContour,
34
33
  ...contourToSvg,
35
34
  ...getContour,
@@ -725,6 +725,30 @@ function convertStandardHornpipe(
725
725
  }
726
726
  return result;
727
727
  }
728
+
729
+ /**
730
+ * Doubles the bar length of an ABC string when the
731
+ * ABC is eligible (as determined by canDoubleBarLength()).
732
+ * @param {string} abc
733
+ * @param {string} rhythm
734
+ * @returns {string}
735
+ */
736
+ function maybeConvertStandardTune(abc, rhythm) {
737
+ if (!canDoubleBarLength(abc, { rhythm })) return abc;
738
+ switch (rhythm) {
739
+ case "reel":
740
+ return convertStandardReel(abc);
741
+ case "jig":
742
+ return convertStandardJig(abc);
743
+ case "polka":
744
+ return convertStandardPolka(abc);
745
+ case "hornpipe":
746
+ return convertStandardHornpipe(abc);
747
+ default:
748
+ return abc;
749
+ }
750
+ }
751
+
728
752
  function doubleBarLength(abc, comment = null) {
729
753
  const meter = getMeter(abc);
730
754
  if (!Array.isArray(meter) || !meter) {
@@ -1061,10 +1085,12 @@ function getFirstBars(
1061
1085
  )}`;
1062
1086
  }
1063
1087
 
1064
- function canDoubleBarLength(abc) {
1065
- const meter = getMeter(abc),
1088
+ function canDoubleBarLength(abc, info = {}) {
1089
+ const {
1090
+ meter = getMeter(abc),
1066
1091
  l = getUnitLength(abc),
1067
- rhythm = getHeaderValue(abc, "R");
1092
+ rhythm = getHeaderValue(abc, "R")
1093
+ } = info;
1068
1094
  if (
1069
1095
  !rhythm ||
1070
1096
  ["reel", "hornpipe", "jig", "polka"].indexOf(rhythm.toLowerCase()) < 0
@@ -1125,6 +1151,7 @@ module.exports = {
1125
1151
  filterHeaders,
1126
1152
  getFirstBars,
1127
1153
  hasAnacrucis,
1154
+ maybeConvertStandardTune,
1128
1155
  normaliseKey,
1129
1156
  toggleMeter_4_4_to_4_2,
1130
1157
  toggleMeter_6_8_to_12_8,
@@ -1,4 +1,3 @@
1
- const { getIncipitForContourGeneration } = require("../incipit.js");
2
1
  const { Fraction } = require("../math.js");
3
2
 
4
3
  const { decodeChar, encodeToChar, silenceChar } = require("./encode.js");
@@ -176,100 +175,6 @@ function canBeCompared(tune1, tune2) {
176
175
  return true;
177
176
  }
178
177
 
179
- function getAbcForContour_default(tune) {
180
- return getIncipitForContourGeneration(
181
- tune.incipit
182
- ? tune.incipit
183
- : Array.isArray(tune.abc)
184
- ? tune.abc[0]
185
- : tune.abc
186
- );
187
- }
188
-
189
- /**
190
- * Sort an array of objects containing ABC notation
191
- * - based on a contour property
192
- * - when the contour is missing, attempts to generate it
193
- * - adds info to each object like the contour, the type of rhythm
194
- * @param {Array<Object>} arr - array of tune objects to sort
195
- * @param {Object} [options] - options for the sorting
196
- * @param {function(Object):string} [options.getAbc] - function returning an abc fragment to be used to calculate the contour.
197
- */
198
- function sort(arr, options = {}) {
199
- const {
200
- comparable = [
201
- ["jig", "slide", "single jig", "double jig"],
202
- ["reel", "single reel", "reel (single)", "strathspey", "double reel"],
203
- ["hornpipe", "barndance", "fling"]
204
- ],
205
- applySwingTransform = ["hornpipe", "barndance", "fling", "mazurka"],
206
- getAbc: getAbcForContour = getAbcForContour_default,
207
- getContourOptions = () => {
208
- return {
209
- withSvg: true
210
- };
211
- }
212
- } = options;
213
-
214
- const comparableMap = {};
215
- //set up comparableMap s.t. all entries in each list of index i go under i_<first entry of i>
216
- //that way we show the jigs with similar rhythms followed by reels etc.
217
- for (let i = 0; i < comparable.length; i++) {
218
- const list = comparable[i];
219
- //map subsequent entries to the first entry
220
- for (let j = 0; j < list.length; j++) {
221
- comparableMap[list[j]] = `${i}_${list[0]}`;
222
- }
223
- }
224
-
225
- for (const tune of arr) {
226
- if (!tune.contour) {
227
- try {
228
- const contourOptions = getContourOptions();
229
- // if (
230
- // tune.title?.indexOf("oldrick") >= 0 ||
231
- // ) {
232
- // console.log("debug");
233
- // }
234
- const withSwingTransform =
235
- applySwingTransform.indexOf(tune.rhythm) >= 0;
236
- const shortAbc = getAbcForContour(tune);
237
-
238
- if (shortAbc) {
239
- contourOptions.withSwingTransform = withSwingTransform;
240
- if (Object.hasOwn(tune, "contourShift"))
241
- contourOptions.contourShift = tune.contourShift;
242
- tune.contour = getContour(shortAbc, contourOptions);
243
- }
244
- } catch (error) {
245
- console.log(error);
246
- }
247
- }
248
- if (!tune.baseRhythm)
249
- tune.baseRhythm = comparableMap[tune.rhythm] ?? tune.rhythm;
250
- }
251
- arr.sort((a, b) => {
252
- const comparison =
253
- a.baseRhythm !== b.baseRhythm
254
- ? a.baseRhythm < b.baseRhythm
255
- ? -1
256
- : 1
257
- : canBeCompared(a, b)
258
- ? compare(a.contour, b.contour)
259
- : a.contour && !b.contour
260
- ? -1
261
- : b.contour && !a.contour
262
- ? 1
263
- : a.name !== b.name
264
- ? a.name < b.name
265
- ? -1
266
- : 1
267
- : 0;
268
- return comparison;
269
- });
270
- arr.forEach((t) => delete t.baseRhythm);
271
- }
272
-
273
178
  function simpleSort(arr) {
274
179
  for (const item of arr) {
275
180
  if (!item.contour && item.abc) {
@@ -304,7 +209,7 @@ function simpleSort(arr) {
304
209
 
305
210
  module.exports = {
306
211
  compare,
307
- sort,
212
+ canBeCompared,
308
213
  simpleSort,
309
214
  decodeChar
310
215
  };
@@ -0,0 +1,52 @@
1
+ const DEFAULT_METER_SORT_SPECS = [
2
+ [
3
+ { meters: [[2], [4], [8]] },
4
+ {
5
+ meters: [
6
+ [6, 2],
7
+ [6, 4]
8
+ ],
9
+ unitLengths: [
10
+ [1, 8],
11
+ [1, 16]
12
+ ]
13
+ }
14
+ ],
15
+ [{ meters: [[3]] }],
16
+ [{ meters: [[6], [12]] }],
17
+ [{ meters: [[9], [18]] }],
18
+ [{ meters: [[5], [10]] }],
19
+ [{ meters: [[7], [14]] }],
20
+ [{ meters: [[11], [13]] }]
21
+ ];
22
+
23
+ const DEFAULT_RHYTHM_GROUPS = [
24
+ ["jig", "slide", "single jig", "double jig"],
25
+ ["reel", "single reel", "reel (single)", "strathspey", "double reel"],
26
+ ["hornpipe", "barndance", "fling"]
27
+ ];
28
+ const DEFAULT_NAME_PREFIXES = [
29
+ "The ",
30
+ "A ",
31
+ "An ",
32
+ /(?:(?:la )?(?:marche |bourrée |valse )|(?:le )?reel )(?:du |de la |de |à |des )?/i
33
+ ];
34
+
35
+ const DEFAULT_CONTOUR_OPTIONS = {
36
+ withSvg: true,
37
+ swingTransform: ["hornpipe", "barndance", "fling", "mazurka"],
38
+ incipitPart: "A"
39
+ };
40
+ const PREDEFINED_SORT_NAMES = [
41
+ "rhythmContourName",
42
+ "meterContourName",
43
+ "nameContour"
44
+ ];
45
+ const sortConstants = {
46
+ DEFAULT_CONTOUR_OPTIONS,
47
+ DEFAULT_METER_SORT_SPECS,
48
+ DEFAULT_NAME_PREFIXES,
49
+ DEFAULT_RHYTHM_GROUPS,
50
+ PREDEFINED_SORT_NAMES
51
+ };
52
+ module.exports = { sortConstants };
@@ -0,0 +1,287 @@
1
+ const { compare, canBeCompared } = require("./contour-sort.js");
2
+ const { getContour } = require("./get-contour.js");
3
+ const { getIncipitForContourGeneration } = require("../incipit.js");
4
+ const { getMeter, getUnitLength } = require("../parse/parser.js");
5
+ const { sortConstants } = require("./sort-constants.js");
6
+
7
+ /// Returns the tune's meter as [n, d], or null. Uses cached _sortAbc.
8
+ function resolveMeter(tune) {
9
+ if (!tune._sortMeter) {
10
+ if (tune.meter !== undefined)
11
+ tune._sortMeter =
12
+ tune.meter instanceof String
13
+ ? tune.meter?.split("/")
14
+ : Array.isArray(tune.meter) && tune.meter?.length
15
+ ? tune.meter
16
+ : null;
17
+ const abc = tune._sortAbc;
18
+ if (!abc) tune._sortMeter = null;
19
+ const m = getMeter(abc);
20
+ tune._sortMeter = m?.length ? m : null;
21
+ }
22
+ return tune._sortMeter;
23
+ }
24
+
25
+ /// Returns the tune's unit length as a Fraction, or null. Uses cached _sortAbc.
26
+ function resolveUnitLength(tune) {
27
+ if (tune.unitLength !== undefined) return tune.unitLength;
28
+ try {
29
+ return tune._sortAbc ? getUnitLength(tune._sortAbc) : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ ///Returns true if the given meter (and optionally unit length) satisfies one MeterSortSpec entry.
35
+ function meterMatchesSpec(meter, unitLength, spec) {
36
+ if (!meter) return false;
37
+ const [n, d] = meter;
38
+ const meterMatches = spec.meters.some(
39
+ ([sn, sd]) => sn === n && (sd === undefined || sd === d)
40
+ );
41
+ if (!meterMatches) return false;
42
+ if (!spec.unitLengths) return true;
43
+ if (!unitLength) return false;
44
+ return spec.unitLengths.some(
45
+ ([un, ud]) => unitLength.num === un && unitLength.den === ud
46
+ );
47
+ }
48
+
49
+ ///Returns the 0-based index of the first matching group, or meterSortSpecs.length for the fallback group.
50
+ function getMeterGroup(meter, unitLength, meterSortSpecs) {
51
+ for (let i = 0; i < meterSortSpecs.length; i++) {
52
+ if (
53
+ meterSortSpecs[i].some((spec) =>
54
+ meterMatchesSpec(meter, unitLength, spec)
55
+ )
56
+ )
57
+ return i;
58
+ }
59
+ return meterSortSpecs.length;
60
+ }
61
+
62
+ /// Returns the ABC fragment used for contour / meter resolution.
63
+ function getAbc_default(tune) {
64
+ return getIncipitForContourGeneration(
65
+ tune.incipit
66
+ ? tune.incipit
67
+ : Array.isArray(tune.abc)
68
+ ? tune.abc[0]
69
+ : tune.abc
70
+ );
71
+ }
72
+
73
+ function ensureContour(tune, contourOptions) {
74
+ if (tune.contour) return;
75
+ const {
76
+ swingTransform = sortConstants.DEFAULT_CONTOUR_OPTIONS.swingTransform,
77
+ incipitPart = "A",
78
+ ...getContourOpts
79
+ } = contourOptions;
80
+
81
+ if (incipitPart !== "A")
82
+ throw new Error(`incipitPart "${incipitPart}" is not yet implemented`);
83
+
84
+ try {
85
+ const shortAbc = tune._sortAbc;
86
+ if (!shortAbc) return;
87
+ getContourOpts.withSwingTransform = swingTransform.includes(tune.rhythm);
88
+ if (Object.hasOwn(tune, "contourShift"))
89
+ getContourOpts.contourShift = tune.contourShift;
90
+ tune.contour = getContour(shortAbc, getContourOpts);
91
+ } catch (err) {
92
+ console.error(err);
93
+ }
94
+ }
95
+ function compareContour(a, b) {
96
+ if (a.contour && b.contour && canBeCompared(a, b))
97
+ return compare(a.contour, b.contour);
98
+ if (a.contour) return -1;
99
+ if (b.contour) return 1;
100
+ return 0;
101
+ }
102
+
103
+ /**
104
+ * Returns name with any matching prefix stripped, for comparison purposes only.
105
+ * @param {string} name
106
+ * @param {(string|RegExp)[]} prefixes
107
+ */
108
+ function stripPrefix(name, prefixes) {
109
+ if (!name) return "";
110
+ for (const p of prefixes) {
111
+ if (p instanceof RegExp) {
112
+ const m = name.match(p);
113
+ if (m && m.index === 0) {
114
+ return name.slice(m[0].length);
115
+ }
116
+ } else {
117
+ if (name.toLowerCase().startsWith(p.toLowerCase()))
118
+ return name.slice(p.length);
119
+ }
120
+ }
121
+ return name;
122
+ }
123
+
124
+ function annotateTunes(tunes, levels) {
125
+ for (const level of levels) {
126
+ switch (level.type) {
127
+ case "rhythm": {
128
+ const groups =
129
+ level.rhythmGroups ?? sortConstants.DEFAULT_RHYTHM_GROUPS;
130
+ const map = {};
131
+ groups.forEach((g, i) => g.forEach((r) => (map[r] = `${i}_${g[0]}`)));
132
+ tunes.forEach((t) => (t._sortRhythm = map[t.rhythm] ?? t.rhythm ?? ""));
133
+ break;
134
+ }
135
+
136
+ case "meter": {
137
+ const specs =
138
+ level.meterSortSpecs ?? sortConstants.DEFAULT_METER_SORT_SPECS;
139
+ const needsUnitLength = specs.some((g) => g.some((s) => s.unitLengths));
140
+ tunes.forEach((t) => {
141
+ const meter = resolveMeter(t);
142
+ const ul = needsUnitLength ? resolveUnitLength(t) : null;
143
+ t._sortMeterGroup = getMeterGroup(meter, ul, specs);
144
+ console.log(`${t.name}|${t.meter}|${t._sortMeterGroup}`);
145
+ });
146
+ break;
147
+ }
148
+
149
+ case "origin":
150
+ tunes.forEach((t) => {
151
+ const raw = t.origin ?? null;
152
+ t._sortOrigin = raw ? raw.split(";")[0].trim() : null;
153
+ });
154
+ break;
155
+
156
+ case "name": {
157
+ const prefixes =
158
+ level.ignoreNamePrefixes ?? sortConstants.DEFAULT_NAME_PREFIXES;
159
+ tunes.forEach((t) => {
160
+ t._sortName = stripPrefix(t.name ?? "", prefixes);
161
+ });
162
+ break;
163
+ }
164
+
165
+ case "contour": {
166
+ const opts =
167
+ level.contourOptions ?? sortConstants.DEFAULT_CONTOUR_OPTIONS;
168
+ tunes.forEach((t) => ensureContour(t, opts));
169
+ break;
170
+ }
171
+
172
+ default:
173
+ throw new Error(`Unknown sort level type: "${level.type}"`);
174
+ }
175
+ }
176
+ }
177
+
178
+ function makeLevelComparator(level) {
179
+ const dir = level.order === "desc" ? -1 : 1;
180
+ const collator =
181
+ level.type === "name"
182
+ ? new Intl.Collator("en", { sensitivity: "base" })
183
+ : null;
184
+ switch (level.type) {
185
+ case "rhythm":
186
+ return (a, b) =>
187
+ a._sortRhythm === b._sortRhythm
188
+ ? 0
189
+ : dir * (a._sortRhythm < b._sortRhythm ? -1 : 1);
190
+ case "meter":
191
+ return (a, b) =>
192
+ a._sortMeterGroup === b._sortMeterGroup
193
+ ? 0
194
+ : dir * (a._sortMeterGroup < b._sortMeterGroup ? -1 : 1);
195
+ case "origin":
196
+ return (a, b) => {
197
+ const [oa, ob] = [a._sortOrigin, b._sortOrigin];
198
+ if (oa === ob) return 0;
199
+ if (!oa) return 1;
200
+ if (!ob) return -1;
201
+ return dir * (oa < ob ? -1 : 1);
202
+ };
203
+ case "name":
204
+ return (a, b) => {
205
+ const [na, nb] = [a._sortName ?? "", b._sortName ?? ""];
206
+ return dir * collator.compare(na, nb);
207
+ };
208
+ case "contour":
209
+ return (a, b) => dir * compareContour(a, b);
210
+ default:
211
+ throw new Error(`Unknown sort level type: "${level.type}"`);
212
+ }
213
+ }
214
+
215
+ function resolveLevels(options) {
216
+ const {
217
+ predefinedSort,
218
+ sortLevels,
219
+ contourOptions = sortConstants.DEFAULT_CONTOUR_OPTIONS
220
+ } = options;
221
+ if (predefinedSort && sortLevels)
222
+ throw new Error("Cannot specify both predefinedSort and sortLevels");
223
+ if (sortLevels) return sortLevels;
224
+
225
+ const contourLevel = { type: "contour", contourOptions };
226
+ const nameLevel = { type: "name" };
227
+
228
+ switch (predefinedSort ?? "rhythmContourName") {
229
+ case "rhythmContourName":
230
+ return [{ type: "rhythm" }, contourLevel, nameLevel];
231
+ case "meterContourName":
232
+ return [{ type: "meter" }, contourLevel, nameLevel];
233
+ case "nameContour":
234
+ return [nameLevel, contourLevel];
235
+ default:
236
+ throw new Error(`Unknown predefinedSort: "${predefinedSort}"`);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @param {Object[]} tunes - Array of tune objects to sort in-place.
242
+ * @param {Object} [options]
243
+ * @param {function(Object): string} [options.getAbc]
244
+ * Returns an ABC fragment for a tune, used for contour generation and for
245
+ * resolving meter / unit length when those fields are absent from the tune
246
+ * object. Called at most once per tune; result is cached during the sort.
247
+ * Defaults to incipit-based extraction.
248
+ * @param {string} [options.predefinedSort="rhythmContourName"]
249
+ * Name of a predefined sort. One of "rhythmContourName", "meterContourName",
250
+ * "nameContour". Mutually exclusive with `sortLevels`.
251
+ * @param {Object[]} [options.sortLevels]
252
+ * Explicit sort-level descriptors. Mutually exclusive with `predefinedSort`.
253
+ * Each entry has the shape { type, order?, ...levelOptions } where type is
254
+ * one of "rhythm" | "meter" | "origin" | "name" | "contour".
255
+ * @param {Object} [options.contourOptions]
256
+ * Options forwarded to getContour(). Applied to all contour levels in
257
+ * predefined sorts; for custom sortLevels, set contourOptions per level.
258
+ * Shape: { withSvg?, swingTransform?, incipitPart?, ...getContourPassthrough }
259
+ */
260
+ function sort(tunes, options = {}) {
261
+ const { getAbc = getAbc_default } = options;
262
+ const levels = resolveLevels(options);
263
+ const comparators = levels.map(makeLevelComparator);
264
+
265
+ tunes.forEach((t) => (t._sortAbc = getAbc(t)));
266
+ annotateTunes(tunes, levels);
267
+
268
+ tunes.sort((a, b) => {
269
+ for (const cmp of comparators) {
270
+ const r = cmp(a, b);
271
+ if (r !== 0) return r;
272
+ }
273
+ return 0;
274
+ });
275
+
276
+ const annotationKeys = [
277
+ "_sortAbc",
278
+ "_sortRhythm",
279
+ "_sortMeterGroup",
280
+ "_sortOrigin",
281
+ "_sortName",
282
+ "_sortMeter"
283
+ ];
284
+ tunes.forEach((t) => annotationKeys.forEach((k) => delete t[k]));
285
+ }
286
+
287
+ module.exports = { sort };