@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.
- package/package.json +8 -7
- package/src/contour-sort.js +402 -384
- package/src/incipit.js +3 -0
- package/src/parser.js +1004 -996
package/src/contour-sort.js
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
posA
|
|
239
|
-
posB
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
};
|