@goplayerjuggler/abc-tools 1.0.2 → 1.0.3

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.
@@ -1,384 +1,402 @@
1
- const { Fraction } = require("./math.js");
2
- const {
3
- getTonalBase,
4
- getUnitLength,
5
- parseABCWithBars,
6
- NOTE_TO_DEGREE,
7
- } = require("./parser.js");
8
-
9
- /**
10
- * Tune Contour Sort - Modal melody sorting algorithm
11
- * Sorts tunes by their modal contour, independent of key and mode
12
- */
13
-
14
- // ============================================================================
15
- // CORE CONSTANTS
16
- // ============================================================================
17
-
18
- const OCTAVE_SHIFT = 7; // 7 scale degrees per octave
19
-
20
- const baseChar = 0x0420; // middle of cyrillic
21
- const silenceChar = "_"; // silence character
22
-
23
- // ============================================================================
24
- // ENCODING FUNCTIONS
25
- // ============================================================================
26
-
27
- /**
28
- * Calculate modal position and octave offset for a note
29
- * Returns a compact representation: octave * 7 + degree (both 0-indexed)
30
- */
31
- function calculateModalPosition(tonalBase, pitch, octaveShift) {
32
- const tonalDegree = NOTE_TO_DEGREE[tonalBase];
33
- const noteDegree = NOTE_TO_DEGREE[pitch.toUpperCase()];
34
-
35
- // Calculate relative degree (how many scale steps from tonic)
36
- const relativeDegree = (noteDegree - tonalDegree + 7) % 7;
37
-
38
- // Adjust octave: lowercase notes are one octave higher
39
- let octave = octaveShift;
40
- if (pitch === pitch.toLowerCase()) {
41
- octave += 1;
42
- }
43
-
44
- // Return position as single number: octave * 7 + degree
45
- // Using offset of 2 octaves to keep values positive
46
- return (octave + 2) * OCTAVE_SHIFT + relativeDegree;
47
- }
48
-
49
- /**
50
- * Encode position and played/held status as a single character
51
- * This ensures held notes (even codes) sort before played notes (odd codes)
52
- *
53
- * @param {number} position - encodes the degree + octave
54
- * @param {boolean} isHeld - if the note is held or not
55
- * @returns the encoded modal degree information (MDI). Format: baseChar + (position * 2) + (isHeld ? 0 : 1)
56
- */
57
- function encodeToChar(position, isHeld) {
58
- const code = baseChar + position * 2 + (isHeld ? 0 : 1);
59
- return String.fromCharCode(code);
60
- }
61
-
62
- /**
63
- * Decode a character back to position and held status
64
- */
65
- function decodeChar(char) {
66
- if (char === silenceChar) {
67
- return { isSilence: true, position: null, isHeld: null };
68
- }
69
-
70
- const code = char.charCodeAt(0) - baseChar;
71
- const position = Math.floor(code / 2);
72
- const isHeld = code % 2 === 0;
73
- return { position, isHeld, isSilence: false };
74
- }
75
-
76
- // ============================================================================
77
- // SORT OBJECT (contour) GENERATION
78
- // ============================================================================
79
-
80
- /**
81
- * Generate sort object from ABC notation
82
- * @returns { sortKey: string, durations: Array, version: string, part: string }
83
- */
84
- function getContour(abc, options = {}) {
85
- const tonalBase = getTonalBase(abc);
86
- const unitLength = getUnitLength(abc);
87
- const { bars } = parseABCWithBars(abc, options);
88
-
89
- const sortKey = [];
90
- const durations = [];
91
- let index = 0;
92
- // get the parsed notes - notes are tokens with a duration
93
- const notes = [];
94
- for (let i = 0; i < bars.length; i++) {
95
- const bar = bars[i];
96
- for (let j = 0; j < bar.length; j++) {
97
- const token = bar[j];
98
- if (token.duration) {
99
- notes.push(token);
100
- }
101
- }
102
- }
103
-
104
- notes.forEach((note) => {
105
- const { duration, isSilence } = note;
106
- const comparison = duration.compare(unitLength);
107
- const { encoded, encodedHeld } = isSilence
108
- ? { encoded: silenceChar, encodedHeld: silenceChar }
109
- : getEncodedFromNote(note, tonalBase);
110
-
111
- if (comparison > 0) {
112
- // Held note: duration > unitLength
113
- const ratio = duration.divide(unitLength);
114
- const nbUnitLengths = Math.floor(ratio.num / ratio.den);
115
- const remainingDuration = duration.subtract(
116
- unitLength.multiply(nbUnitLengths)
117
- );
118
-
119
- // const durationRatio = Math.round(ratio.num / ratio.den);
120
-
121
- // First note is played
122
- sortKey.push(encoded);
123
-
124
- // Subsequent notes are held
125
- for (let i = 1; i < nbUnitLengths; i++) {
126
- sortKey.push(encodedHeld);
127
- }
128
-
129
- index += nbUnitLengths;
130
- if (remainingDuration.num !== 0) {
131
- pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
132
- index++;
133
- }
134
- } else if (comparison < 0) {
135
- pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
136
-
137
- index++;
138
- } else {
139
- // Normal note: duration === unitLength
140
- sortKey.push(encoded);
141
- index++;
142
- }
143
- });
144
-
145
- return {
146
- sortKey: sortKey.join(""),
147
- durations: durations.length > 0 ? durations : undefined,
148
- // version: "1.0",
149
- // part,
150
- };
151
- }
152
-
153
- /**
154
- * Adds a short note (duration < unitLength) to the contour
155
- * @param {string} encoded - the encoded representation of the note’s modal degree information (MDI)
156
- * @param {Fraction} unitLength - the unit length
157
- * @param {Fraction} duration - the duration of the note
158
- * @param {number} index - the index of the note
159
- * @param {Array<object>} durations - the durations array
160
- * @param {Array<string>} sortKey - array of MDIs
161
- */
162
- function pushShortNote(
163
- encoded,
164
- unitLength,
165
- duration,
166
- index,
167
- durations,
168
- sortKey
169
- ) {
170
- const relativeDuration = duration.divide(unitLength);
171
-
172
- durations.push({
173
- i: index,
174
- n: relativeDuration.num === 1 ? undefined : relativeDuration.num,
175
- d: relativeDuration.den,
176
- });
177
- sortKey.push(encoded);
178
- }
179
-
180
- // ============================================================================
181
- // COMPARISON FUNCTIONS
182
- // ============================================================================
183
-
184
- /**
185
- * Compare two sort objects using expansion algorithm
186
- */
187
- function sort(objA, objB) {
188
- let keyA = objA.sortKey;
189
- let keyB = objB.sortKey;
190
-
191
- const dursA = objA.durations || [];
192
- const dursB = objB.durations || [];
193
-
194
- // No durations: simple lexicographic comparison
195
- if (dursA.length === 0 && dursB.length === 0) {
196
- return keyA === keyB ? 0 : keyA < keyB ? -1 : 1;
197
- }
198
-
199
- // Build maps of position -> {n, d}
200
- const durMapA = Object.fromEntries(
201
- dursA.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
202
- );
203
- const durMapB = Object.fromEntries(
204
- dursB.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
205
- );
206
-
207
- let posA = 0;
208
- let posB = 0;
209
- let logicalIndex = 0;
210
- let counter = 0;
211
-
212
- while (posA < keyA.length && posB < keyB.length) {
213
- if (counter++ > 10000) {
214
- throw new Error("Sort algorithm iteration limit exceeded");
215
- }
216
-
217
- const durA = durMapA[logicalIndex];
218
- const durB = durMapB[logicalIndex];
219
-
220
- // Get durations as fractions
221
- const fracA = durA ? new Fraction(durA.n, durA.d) : new Fraction(1, 1);
222
- const fracB = durB ? new Fraction(durB.n, durB.d) : new Fraction(1, 1);
223
-
224
- const comp = fracA.compare(fracB);
225
-
226
- if (comp === 0) {
227
- // Same duration, compare characters directly
228
- const charA = keyA.charAt(posA);
229
- const charB = keyB.charAt(posB);
230
-
231
- if (charA < charB) {
232
- return -1;
233
- }
234
- if (charA > charB) {
235
- return 1;
236
- }
237
-
238
- posA++;
239
- posB++;
240
- logicalIndex++;
241
- } else if (comp < 0) {
242
- // fracA < fracB: expand B by inserting held note
243
- const charA = keyA.charAt(posA);
244
- const charB = keyB.charAt(posB);
245
-
246
- if (charA < charB) {
247
- return -1;
248
- }
249
- if (charA > charB) {
250
- return 1;
251
- }
252
-
253
- // Insert held note into B
254
- const decodedB = decodeChar(charB);
255
- const heldChar = decodedB.isSilence
256
- ? silenceChar
257
- : encodeToChar(decodedB.position, true);
258
-
259
- keyB = keyB.substring(0, posB + 1) + heldChar + keyB.substring(posB + 1);
260
-
261
- // Update duration map for B
262
- const remainingDur = fracB.subtract(fracA);
263
- delete durMapB[logicalIndex];
264
-
265
- // Add new duration entry for the held note
266
- durMapB[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
267
-
268
- // Shift all subsequent B durations by 1
269
- const newDurMapB = {};
270
- for (const idx in durMapB) {
271
- const numIdx = parseInt(idx);
272
- if (numIdx > logicalIndex + 1) {
273
- newDurMapB[numIdx + 1] = durMapB[idx];
274
- } else {
275
- newDurMapB[numIdx] = durMapB[idx];
276
- }
277
- }
278
- Object.assign(durMapB, newDurMapB);
279
-
280
- posA++;
281
- posB++;
282
- logicalIndex++;
283
- } else {
284
- // fracA > fracB: expand A by inserting held note
285
- const charA = keyA.charAt(posA);
286
- const charB = keyB.charAt(posB);
287
-
288
- if (charA < charB) {
289
- return -1;
290
- }
291
- if (charA > charB) {
292
- return 1;
293
- }
294
-
295
- // Insert held note into A
296
- const decodedA = decodeChar(charA);
297
- const heldChar = decodedA.isSilence
298
- ? silenceChar
299
- : encodeToChar(decodedA.position, true);
300
-
301
- keyA = keyA.substring(0, posA + 1) + heldChar + keyA.substring(posA + 1);
302
-
303
- // Update duration map for A
304
- const remainingDur = fracA.subtract(fracB);
305
- delete durMapA[logicalIndex];
306
-
307
- durMapA[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
308
-
309
- // Shift all subsequent A durations by 1
310
- const newDurMapA = {};
311
- for (const idx in durMapA) {
312
- const numIdx = parseInt(idx);
313
- if (numIdx > logicalIndex + 1) {
314
- newDurMapA[numIdx + 1] = durMapA[idx];
315
- } else {
316
- newDurMapA[numIdx] = durMapA[idx];
317
- }
318
- }
319
- Object.assign(durMapA, newDurMapA);
320
-
321
- posA++;
322
- posB++;
323
- logicalIndex++;
324
- }
325
- }
326
-
327
- if (posA >= keyA.length && posB >= keyB.length) {
328
- return 0;
329
- }
330
- return posA >= keyA.length ? -1 : 1;
331
- }
332
-
333
- /**
334
- * Sort an array of objects containing ABC notation
335
- */
336
- function sortArray(arr) {
337
- for (const item of arr) {
338
- if (!item.sortObject && item.abc) {
339
- try {
340
- item.sortObject = getContour(item.abc);
341
- } catch (err) {
342
- console.error(`Failed to generate sort object: ${err.message}`);
343
- item.sortObject = null;
344
- }
345
- }
346
- }
347
-
348
- arr.sort((a, b) => {
349
- if (!a.sortObject && !b.sortObject) {
350
- return 0;
351
- }
352
- if (!a.sortObject) {
353
- return 1;
354
- }
355
- if (!b.sortObject) {
356
- return -1;
357
- }
358
- return sort(a.sortObject, b.sortObject);
359
- });
360
-
361
- return arr;
362
- }
363
-
364
- function getEncodedFromNote(note, tonalBase) {
365
- // Handle pitched note
366
- const { pitch, octave } = note;
367
- const position = calculateModalPosition(tonalBase, pitch, octave);
368
- const encodedHeld = encodeToChar(position, true);
369
- const encoded = encodeToChar(position, false);
370
- return { encoded, encodedHeld };
371
- }
372
-
373
- // ============================================================================
374
- // EXPORTS
375
- // ============================================================================
376
-
377
- module.exports = {
378
- getContour,
379
- sort,
380
- sortArray,
381
- decodeChar,
382
- encodeToChar,
383
- calculateModalPosition,
384
- };
1
+ const { Fraction } = require("./math.js");
2
+ const {
3
+ getTonalBase,
4
+ getUnitLength,
5
+ parseABCWithBars,
6
+ NOTE_TO_DEGREE,
7
+ } = require("./parser.js");
8
+
9
+ /**
10
+ * Tune Contour Sort - Modal melody sorting algorithm
11
+ * Sorts tunes by their modal contour, independent of key and mode
12
+ */
13
+
14
+ // ============================================================================
15
+ // CORE CONSTANTS
16
+ // ============================================================================
17
+
18
+ const OCTAVE_SHIFT = 7; // 7 scale degrees per octave
19
+
20
+ const baseChar = 0x0420; // middle of cyrillic
21
+ const silenceChar = "_"; // silence character
22
+
23
+ // ============================================================================
24
+ // ENCODING FUNCTIONS
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Calculate modal position and octave offset for a note
29
+ * Returns a compact representation: octave * 7 + degree (both 0-indexed)
30
+ */
31
+ function calculateModalPosition(tonalBase, pitch, octaveShift) {
32
+ const tonalDegree = NOTE_TO_DEGREE[tonalBase];
33
+ const noteDegree = NOTE_TO_DEGREE[pitch.toUpperCase()];
34
+
35
+ // Calculate relative degree (how many scale steps from tonic)
36
+ const relativeDegree = (noteDegree - tonalDegree + 7) % 7;
37
+
38
+ // Adjust octave: lowercase notes are one octave higher
39
+ let octave = octaveShift;
40
+ if (pitch === pitch.toLowerCase()) {
41
+ octave += 1;
42
+ }
43
+
44
+ // Return position as single number: octave * 7 + degree
45
+ // Using offset of 2 octaves to keep values positive
46
+ return (octave + 2) * OCTAVE_SHIFT + relativeDegree;
47
+ }
48
+
49
+ /**
50
+ * Encode position and played/held status as a single character
51
+ * This ensures held notes (even codes) sort before played notes (odd codes)
52
+ *
53
+ * @param {number} position - encodes the degree + octave
54
+ * @param {boolean} isHeld - if the note is held or not
55
+ * @returns the encoded modal degree information (MDI). Format: baseChar + (position * 2) + (isHeld ? 0 : 1)
56
+ */
57
+ function encodeToChar(position, isHeld) {
58
+ const code = baseChar + position * 2 + (isHeld ? 0 : 1);
59
+ return String.fromCharCode(code);
60
+ }
61
+
62
+ /**
63
+ * Decode a character back to position and held status
64
+ */
65
+ function decodeChar(char) {
66
+ if (char === silenceChar) {
67
+ return { isSilence: true, position: null, isHeld: null };
68
+ }
69
+
70
+ const code = char.charCodeAt(0) - baseChar;
71
+ const position = Math.floor(code / 2);
72
+ const isHeld = code % 2 === 0;
73
+ return { position, isHeld, isSilence: false };
74
+ }
75
+
76
+ // ============================================================================
77
+ // SORT OBJECT (contour) GENERATION
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Generate sort object from ABC notation
82
+ * @returns { sortKey: string, durations: Array, version: string, part: string }
83
+ */
84
+ function getContour(abc, options = {}) {
85
+ const tonalBase = getTonalBase(abc);
86
+ const unitLength = getUnitLength(abc);
87
+ const { bars } = parseABCWithBars(abc, options);
88
+
89
+ const sortKey = [];
90
+ const durations = [];
91
+ let index = 0;
92
+ // get the parsed notes - notes are tokens with a duration
93
+ const notes = [];
94
+ let tied = false, previousPosition = null
95
+ for (let i = 0; i < bars.length; i++) {
96
+ const bar = bars[i];
97
+ for (let j = 0; j < bar.length; j++) {
98
+ const token = bar[j];
99
+ if (token.duration) {
100
+ notes.push(token);
101
+ }
102
+ }
103
+ }
104
+
105
+ notes.forEach((note) => {
106
+ const { duration, isSilence } = note;
107
+ const comparison = duration.compare(unitLength);
108
+ const { encoded, encodedHeld, position } = isSilence
109
+ ? { encoded: silenceChar, encodedHeld: silenceChar, position:0 }
110
+ : getEncodedFromNote(note, tonalBase, tied, previousPosition);
111
+
112
+ if (note.tied) {
113
+ tied = true
114
+ previousPosition = position
115
+ }
116
+ else {
117
+ tied = false
118
+ previousPosition = null
119
+ }
120
+
121
+ if (comparison > 0) {
122
+ // Held note: duration > unitLength
123
+ const ratio = duration.divide(unitLength);
124
+ const nbUnitLengths = Math.floor(ratio.num / ratio.den);
125
+ const remainingDuration = duration.subtract(
126
+ unitLength.multiply(nbUnitLengths)
127
+ );
128
+
129
+ // const durationRatio = Math.round(ratio.num / ratio.den);
130
+
131
+ // First note is played
132
+ sortKey.push(encoded);
133
+
134
+ // Subsequent notes are held
135
+ for (let i = 1; i < nbUnitLengths; i++) {
136
+ sortKey.push(encodedHeld);
137
+ }
138
+
139
+ index += nbUnitLengths;
140
+ if (remainingDuration.num !== 0) {
141
+ pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
142
+ index++;
143
+ }
144
+ } else if (comparison < 0) {
145
+ pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
146
+
147
+ index++;
148
+ } else {
149
+ // Normal note: duration === unitLength
150
+ sortKey.push(encoded);
151
+ index++;
152
+ }
153
+ });
154
+
155
+ return {
156
+ sortKey: sortKey.join(""),
157
+ durations: durations.length > 0 ? durations : undefined,
158
+ // version: "1.0",
159
+ // part,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Adds a short note (duration < unitLength) to the contour
165
+ * @param {string} encoded - the encoded representation of the note’s modal degree information (MDI)
166
+ * @param {Fraction} unitLength - the unit length
167
+ * @param {Fraction} duration - the duration of the note
168
+ * @param {number} index - the index of the note
169
+ * @param {Array<object>} durations - the durations array
170
+ * @param {Array<string>} sortKey - array of MDIs
171
+ */
172
+ function pushShortNote(
173
+ encoded,
174
+ unitLength,
175
+ duration,
176
+ index,
177
+ durations,
178
+ sortKey
179
+ ) {
180
+ const relativeDuration = duration.divide(unitLength);
181
+
182
+ durations.push({
183
+ i: index,
184
+ n: relativeDuration.num === 1 ? undefined : relativeDuration.num,
185
+ d: relativeDuration.den,
186
+ });
187
+ sortKey.push(encoded);
188
+ }
189
+
190
+ // ============================================================================
191
+ // COMPARISON FUNCTIONS
192
+ // ============================================================================
193
+
194
+ /**
195
+ * Compare two sort objects using expansion algorithm
196
+ */
197
+ function sort(objA, objB) {
198
+ let keyA = objA.sortKey;
199
+ let keyB = objB.sortKey;
200
+
201
+ const dursA = objA.durations || [];
202
+ const dursB = objB.durations || [];
203
+
204
+ // No durations: simple lexicographic comparison
205
+ if (dursA.length === 0 && dursB.length === 0) {
206
+ return keyA === keyB ? 0 : keyA < keyB ? -1 : 1;
207
+ }
208
+
209
+ // Build maps of position -> {n, d}
210
+ const durMapA = Object.fromEntries(
211
+ dursA.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
212
+ );
213
+ const durMapB = Object.fromEntries(
214
+ dursB.map((dur) => [dur.i, { n: dur.n || 1, d: dur.d }])
215
+ );
216
+
217
+ let posA = 0;
218
+ let posB = 0;
219
+ let logicalIndex = 0;
220
+ let counter = 0;
221
+
222
+ while (posA < keyA.length && posB < keyB.length) {
223
+ if (counter++ > 10000) {
224
+ throw new Error("Sort algorithm iteration limit exceeded");
225
+ }
226
+
227
+ const durA = durMapA[logicalIndex];
228
+ const durB = durMapB[logicalIndex];
229
+
230
+ // Get durations as fractions
231
+ const fracA = durA ? new Fraction(durA.n, durA.d) : new Fraction(1, 1);
232
+ const fracB = durB ? new Fraction(durB.n, durB.d) : new Fraction(1, 1);
233
+
234
+ const comp = fracA.compare(fracB);
235
+
236
+ if (comp === 0) {
237
+ // Same duration, compare characters directly
238
+ const charA = keyA.charAt(posA);
239
+ const charB = keyB.charAt(posB);
240
+
241
+ if (charA < charB) {
242
+ return -1;
243
+ }
244
+ if (charA > charB) {
245
+ return 1;
246
+ }
247
+
248
+ posA++;
249
+ posB++;
250
+ logicalIndex++;
251
+ } else if (comp < 0) {
252
+ // fracA < fracB: expand B by inserting held note
253
+ const charA = keyA.charAt(posA);
254
+ const charB = keyB.charAt(posB);
255
+
256
+ if (charA < charB) {
257
+ return -1;
258
+ }
259
+ if (charA > charB) {
260
+ return 1;
261
+ }
262
+
263
+ // Insert held note into B
264
+ const decodedB = decodeChar(charB);
265
+ const heldChar = decodedB.isSilence
266
+ ? silenceChar
267
+ : encodeToChar(decodedB.position, true);
268
+
269
+ keyB = keyB.substring(0, posB + 1) + heldChar + keyB.substring(posB + 1);
270
+
271
+ // Update duration map for B
272
+ const remainingDur = fracB.subtract(fracA);
273
+ delete durMapB[logicalIndex];
274
+
275
+ // Add new duration entry for the held note
276
+ durMapB[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
277
+
278
+ // Shift all subsequent B durations by 1
279
+ const newDurMapB = {};
280
+ for (const idx in durMapB) {
281
+ const numIdx = parseInt(idx);
282
+ if (numIdx > logicalIndex + 1) {
283
+ newDurMapB[numIdx + 1] = durMapB[idx];
284
+ } else {
285
+ newDurMapB[numIdx] = durMapB[idx];
286
+ }
287
+ }
288
+ Object.assign(durMapB, newDurMapB);
289
+
290
+ posA++;
291
+ posB++;
292
+ logicalIndex++;
293
+ } else {
294
+ // fracA > fracB: expand A by inserting held note
295
+ const charA = keyA.charAt(posA);
296
+ const charB = keyB.charAt(posB);
297
+
298
+ if (charA < charB) {
299
+ return -1;
300
+ }
301
+ if (charA > charB) {
302
+ return 1;
303
+ }
304
+
305
+ // Insert held note into A
306
+ const decodedA = decodeChar(charA);
307
+ const heldChar = decodedA.isSilence
308
+ ? silenceChar
309
+ : encodeToChar(decodedA.position, true);
310
+
311
+ keyA = keyA.substring(0, posA + 1) + heldChar + keyA.substring(posA + 1);
312
+
313
+ // Update duration map for A
314
+ const remainingDur = fracA.subtract(fracB);
315
+ delete durMapA[logicalIndex];
316
+
317
+ durMapA[logicalIndex + 1] = { n: remainingDur.num, d: remainingDur.den };
318
+
319
+ // Shift all subsequent A durations by 1
320
+ const newDurMapA = {};
321
+ for (const idx in durMapA) {
322
+ const numIdx = parseInt(idx);
323
+ if (numIdx > logicalIndex + 1) {
324
+ newDurMapA[numIdx + 1] = durMapA[idx];
325
+ } else {
326
+ newDurMapA[numIdx] = durMapA[idx];
327
+ }
328
+ }
329
+ Object.assign(durMapA, newDurMapA);
330
+
331
+ posA++;
332
+ posB++;
333
+ logicalIndex++;
334
+ }
335
+ }
336
+
337
+ if (posA >= keyA.length && posB >= keyB.length) {
338
+ return 0;
339
+ }
340
+ return posA >= keyA.length ? -1 : 1;
341
+ }
342
+
343
+ /**
344
+ * Sort an array of objects containing ABC notation
345
+ */
346
+ function sortArray(arr) {
347
+ for (const item of arr) {
348
+ if (!item.sortObject && item.abc) {
349
+ try {
350
+ item.sortObject = getContour(item.abc);
351
+ } catch (err) {
352
+ console.error(`Failed to generate sort object: ${err.message}`);
353
+ item.sortObject = null;
354
+ }
355
+ }
356
+ }
357
+
358
+ arr.sort((a, b) => {
359
+ if (!a.sortObject && !b.sortObject) {
360
+ return 0;
361
+ }
362
+ if (!a.sortObject) {
363
+ return 1;
364
+ }
365
+ if (!b.sortObject) {
366
+ return -1;
367
+ }
368
+ return sort(a.sortObject, b.sortObject);
369
+ });
370
+
371
+ return arr;
372
+ }
373
+
374
+ function getEncodedFromNote(note, tonalBase, tied, previousPosition) {
375
+
376
+ // Handle pitched note
377
+ const { pitch, octave } = note;
378
+ const position = calculateModalPosition(tonalBase, pitch, octave);
379
+ const encodedHeld = encodeToChar(position, true);
380
+ const encoded = encodeToChar(position, false);
381
+
382
+ return {
383
+ encoded :
384
+ tied && position === previousPosition
385
+ ? encodedHeld
386
+ : encoded,
387
+ encodedHeld,
388
+ position };
389
+ }
390
+
391
+ // ============================================================================
392
+ // EXPORTS
393
+ // ============================================================================
394
+
395
+ module.exports = {
396
+ getContour,
397
+ sort,
398
+ sortArray,
399
+ decodeChar,
400
+ encodeToChar,
401
+ calculateModalPosition,
402
+ };