@goplayerjuggler/abc-tools 1.0.8 → 1.0.10
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 +1 -1
- package/src/incipit.js +18 -6
- package/src/index.js +2 -0
- package/src/manipulator.js +181 -115
- package/src/math.js +11 -2
- package/src/parse/getBarInfo.js +202 -0
- package/src/parse/header-parser.js +16 -1
- package/src/parse/parser.js +6 -17
- package/src/parse/token-utils.js +1 -1
- package/src/sort/contour-sort.js +2 -177
- package/src/sort/get-contour.js +333 -0
- package/testoutput.txt +0 -0
package/package.json
CHANGED
package/src/incipit.js
CHANGED
|
@@ -3,8 +3,7 @@ const { Fraction } = require("./math.js");
|
|
|
3
3
|
const { getFirstBars } = require("./manipulator.js");
|
|
4
4
|
|
|
5
5
|
const { getUnitLength, getMeter } = require("./parse/parser.js");
|
|
6
|
-
|
|
7
|
-
const { getContour } = require("./sort/contour-sort.js");
|
|
6
|
+
const { getContour } = require("./sort/get-contour.js");
|
|
8
7
|
|
|
9
8
|
//this file has code that's a fork of some code in Michael Eskin's abctools
|
|
10
9
|
|
|
@@ -307,20 +306,33 @@ function getIncipit(data) {
|
|
|
307
306
|
return getFirstBars(abc, numBars, withAnacrucis, false, { all: true });
|
|
308
307
|
}
|
|
309
308
|
|
|
310
|
-
function getIncipitForContourGeneration(
|
|
309
|
+
function getIncipitForContourGeneration(
|
|
310
|
+
abc,
|
|
311
|
+
{ numBars = new Fraction(3, 2) } = {}
|
|
312
|
+
) {
|
|
311
313
|
return getIncipit({
|
|
312
314
|
abc,
|
|
313
315
|
withAnacrucis: false,
|
|
314
|
-
numBars
|
|
316
|
+
numBars,
|
|
315
317
|
});
|
|
316
318
|
}
|
|
317
319
|
|
|
318
|
-
function getContourFromFullAbc(
|
|
320
|
+
function getContourFromFullAbc(
|
|
321
|
+
abc,
|
|
322
|
+
{
|
|
323
|
+
withSvg = true,
|
|
324
|
+
withSwingTransform = false,
|
|
325
|
+
numBars = new Fraction(3, 2),
|
|
326
|
+
} = {}
|
|
327
|
+
) {
|
|
319
328
|
if (Array.isArray(abc)) {
|
|
320
329
|
if (abc.length === 0) return null;
|
|
321
330
|
abc = abc[0];
|
|
322
331
|
}
|
|
323
|
-
return getContour(getIncipitForContourGeneration(abc
|
|
332
|
+
return getContour(getIncipitForContourGeneration(abc, { numBars }), {
|
|
333
|
+
withSvg,
|
|
334
|
+
withSwingTransform,
|
|
335
|
+
});
|
|
324
336
|
}
|
|
325
337
|
|
|
326
338
|
module.exports = {
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const displayContour = require("./sort/display-contour.js");
|
|
|
11
11
|
|
|
12
12
|
const incipit = require("./incipit.js");
|
|
13
13
|
const javascriptify = require("./javascriptify.js");
|
|
14
|
+
const getContour = require("./sort/get-contour.js");
|
|
14
15
|
|
|
15
16
|
module.exports = {
|
|
16
17
|
// Parser functions
|
|
@@ -23,6 +24,7 @@ module.exports = {
|
|
|
23
24
|
...sort,
|
|
24
25
|
...displayContour,
|
|
25
26
|
...contourToSvg,
|
|
27
|
+
...getContour,
|
|
26
28
|
|
|
27
29
|
// Incipit functions
|
|
28
30
|
...incipit,
|
package/src/manipulator.js
CHANGED
|
@@ -5,6 +5,8 @@ const {
|
|
|
5
5
|
calculateBarDurations,
|
|
6
6
|
} = require("./parse/parser.js");
|
|
7
7
|
|
|
8
|
+
const { getBarInfo } = require("./parse/getBarInfo.js");
|
|
9
|
+
|
|
8
10
|
// ============================================================================
|
|
9
11
|
// ABC manipulation functions
|
|
10
12
|
// ============================================================================
|
|
@@ -43,14 +45,14 @@ function normaliseKey(keyHeader) {
|
|
|
43
45
|
major: "major",
|
|
44
46
|
ion: "major",
|
|
45
47
|
ionian: "major",
|
|
48
|
+
mix: "mixolydian",
|
|
49
|
+
mixo: "mixolydian",
|
|
50
|
+
mixolydian: "mixolydian",
|
|
46
51
|
m: "minor",
|
|
47
52
|
min: "minor",
|
|
48
53
|
minor: "minor",
|
|
49
54
|
aeo: "minor",
|
|
50
55
|
aeolian: "minor",
|
|
51
|
-
mix: "mixolydian",
|
|
52
|
-
mixo: "mixolydian",
|
|
53
|
-
mixolydian: "mixolydian",
|
|
54
56
|
dor: "dorian",
|
|
55
57
|
dorian: "dorian",
|
|
56
58
|
phr: "phrygian",
|
|
@@ -116,58 +118,31 @@ function hasAnacrucis(abc) {
|
|
|
116
118
|
const parsed = parseAbc(abc, { maxBars: 2 });
|
|
117
119
|
return hasAnacrucisFromParsed(parsed);
|
|
118
120
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Inserts a specified character at multiple positions within a string.
|
|
121
|
-
* Optimised for performance with long strings and repeated usage.
|
|
122
|
-
*
|
|
123
|
-
* @param {string} originalString - The original string to modify.
|
|
124
|
-
* @param {string} charToInsert - The character to insert at the specified positions.
|
|
125
|
-
* @param {number[]} indexes - An array of positions (zero-based) where the character should be inserted.
|
|
126
|
-
* @returns {string} The modified string with characters inserted at the specified positions.
|
|
127
|
-
*
|
|
128
|
-
* // Example usage:
|
|
129
|
-
* const originalString = "hello world";
|
|
130
|
-
* const charToInsert = "!";
|
|
131
|
-
* const indexes = [2, 5, 8, 2, 15];
|
|
132
|
-
* const result = insertCharsAtIndexes(originalString, charToInsert, indexes);
|
|
133
|
-
* console.log(result); // Output: "he!l!lo! world!"
|
|
134
|
-
*
|
|
135
|
-
*/
|
|
136
|
-
function insertCharsAtIndexes(originalString, charToInsert, indexes) {
|
|
137
|
-
// Filter and sort indexes only once: remove duplicates and invalid positions
|
|
138
|
-
const validIndexes = [...new Set(indexes)]
|
|
139
|
-
.filter((index) => index >= 0 && index <= originalString.length)
|
|
140
|
-
.sort((a, b) => a - b);
|
|
141
|
-
|
|
142
|
-
const result = [];
|
|
143
|
-
let prevIndex = 0;
|
|
144
|
-
|
|
145
|
-
for (const index of validIndexes) {
|
|
146
|
-
// Push the substring up to the current index
|
|
147
|
-
result.push(originalString.slice(prevIndex, index));
|
|
148
|
-
// Push the character to insert
|
|
149
|
-
result.push(charToInsert);
|
|
150
|
-
// Update the previous index
|
|
151
|
-
prevIndex = index;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Push the remaining part of the string
|
|
155
|
-
result.push(originalString.slice(prevIndex));
|
|
156
|
-
|
|
157
|
-
return result.join("");
|
|
158
|
-
}
|
|
159
121
|
|
|
160
122
|
/**
|
|
161
123
|
* Toggle meter by doubling or halving bar length
|
|
162
124
|
* Supports 4/4↔4/2 and 6/8↔12/8 transformations
|
|
163
|
-
* This is nearly a true inverse operation - going there and back preserves the ABC except for some
|
|
164
|
-
* edge cases involving spaces around the bar lines. No need to handle them.
|
|
165
|
-
* Handles anacrusis correctly and preserves line breaks
|
|
166
125
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
126
|
+
* When going from small to large meters (e.g., 4/4→4/2):
|
|
127
|
+
* - Removes alternate bar lines to combine pairs of bars
|
|
128
|
+
* - Converts variant ending markers from |1, |2 to [1, [2 format
|
|
129
|
+
* - Respects section breaks (||, :|, etc.) and resets pairing after them
|
|
130
|
+
* - Handles bars starting with variant endings by keeping the bar line after them
|
|
131
|
+
*
|
|
132
|
+
* When going from large to small meters (e.g., 4/2→4/4):
|
|
133
|
+
* - Inserts bar lines at halfway points within each bar
|
|
134
|
+
* - Preserves variant ending markers in [1, [2 format (does not convert back to |1, |2)
|
|
135
|
+
* - Inserts bar lines before variant endings when they occur at the split point
|
|
136
|
+
*
|
|
137
|
+
* This is nearly a true inverse operation - going there and back preserves musical content
|
|
138
|
+
* but may change spacing around bar lines and normalises variant ending syntax to [1, [2 format.
|
|
139
|
+
* Correctly handles anacrusis (pickup bars), multi-bar variant endings, partial bars, and preserves line breaks.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} abc - ABC notation string
|
|
142
|
+
* @param {Array<number>} smallMeter - The smaller meter signature [numerator, denominator]
|
|
143
|
+
* @param {Array<number>} largeMeter - The larger meter signature [numerator, denominator]
|
|
144
|
+
* @returns {string} ABC notation with toggled meter
|
|
145
|
+
* @throws {Error} If the current meter doesn't match either smallMeter or largeMeter
|
|
171
146
|
*/
|
|
172
147
|
function toggleMeterDoubling(abc, smallMeter, largeMeter) {
|
|
173
148
|
const currentMeter = getMeter(abc);
|
|
@@ -183,14 +158,8 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
|
|
|
183
158
|
);
|
|
184
159
|
}
|
|
185
160
|
|
|
186
|
-
if (isSmall) {
|
|
187
|
-
// We're going to remove some bars, so ensure every bar line (pipe / `|`) has a space preceding it
|
|
188
|
-
// Regex handles bars like :| and [|]
|
|
189
|
-
abc = abc.replaceAll(/([^\s])([[:]?\|)/g, "$1 $2");
|
|
190
|
-
}
|
|
191
|
-
|
|
192
161
|
const parsed = parseAbc(abc);
|
|
193
|
-
const { headerLines, barLines, musicText } = parsed;
|
|
162
|
+
const { headerLines, barLines, musicText, bars, meter } = parsed;
|
|
194
163
|
|
|
195
164
|
// Change meter in headers
|
|
196
165
|
const newHeaders = headerLines.map((line) => {
|
|
@@ -202,80 +171,174 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
|
|
|
202
171
|
return line;
|
|
203
172
|
});
|
|
204
173
|
|
|
205
|
-
const hasPickup = hasAnacrucisFromParsed(parsed);
|
|
206
|
-
|
|
207
174
|
if (isSmall) {
|
|
208
|
-
// Going from small to large: remove every other bar line
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
175
|
+
// Going from small to large: remove every other bar line
|
|
176
|
+
// Get bar info with barNumbers to understand the musical structure
|
|
177
|
+
getBarInfo(bars, barLines, meter, {
|
|
178
|
+
barNumbers: true,
|
|
179
|
+
isPartial: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Build a map of which bars start with variant endings
|
|
183
|
+
const barStartsWithVariant = new Map();
|
|
184
|
+
for (let i = 0; i < bars.length; i++) {
|
|
185
|
+
if (bars[i].length > 0 && bars[i][0].isVariantEnding) {
|
|
186
|
+
barStartsWithVariant.set(i, bars[i][0]);
|
|
187
|
+
}
|
|
214
188
|
}
|
|
215
189
|
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
let
|
|
190
|
+
// Determine which bar lines to keep or remove
|
|
191
|
+
const barLineDecisions = new Map(); // barLineIndex -> {action, variantToken?}
|
|
192
|
+
let barPosition = 0; // Position within current pairing (0 or 1)
|
|
193
|
+
let barNumberOfStartOfSection = 0;
|
|
219
194
|
|
|
220
195
|
for (let i = 0; i < barLines.length; i++) {
|
|
221
196
|
const barLine = barLines[i];
|
|
222
|
-
newMusic += musicText.substring(lastPos, barLine.sourceIndex);
|
|
223
197
|
|
|
224
|
-
|
|
225
|
-
|
|
198
|
+
// Initial bar line is always kept
|
|
199
|
+
if (barLine.barNumber === null) {
|
|
200
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// initial anacrucis bar line of section is always kept
|
|
205
|
+
if (
|
|
206
|
+
barLine.barNumber === barNumberOfStartOfSection &&
|
|
207
|
+
barLine.isPartial
|
|
208
|
+
) {
|
|
209
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Final bar line is always kept
|
|
214
|
+
if (i === barLines.length - 1) {
|
|
215
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Section breaks are always kept and reset pairing
|
|
220
|
+
if (barLine.isSectionBreak) {
|
|
221
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
222
|
+
barPosition = 0;
|
|
223
|
+
barNumberOfStartOfSection = barLine.barNumber;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// barLines[i] comes after bars[i], so the NEXT bar is bars[i+1]
|
|
228
|
+
const nextBarIdx = i + 1;
|
|
229
|
+
const nextBarVariant =
|
|
230
|
+
nextBarIdx < bars.length ? barStartsWithVariant.get(nextBarIdx) : null;
|
|
231
|
+
|
|
232
|
+
// If the current bar (bars[i]) starts with a variant, keep its bar line and reset position
|
|
233
|
+
const currentBarVariant = barStartsWithVariant.get(i);
|
|
234
|
+
if (currentBarVariant) {
|
|
235
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
236
|
+
barPosition = 0;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Normal pairing logic
|
|
241
|
+
if (barPosition === 0) {
|
|
242
|
+
// First of pair - remove
|
|
243
|
+
if (nextBarVariant) {
|
|
244
|
+
barLineDecisions.set(i, {
|
|
245
|
+
action: "remove",
|
|
246
|
+
variantToken: nextBarVariant,
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
barLineDecisions.set(i, { action: "remove" });
|
|
250
|
+
}
|
|
251
|
+
barPosition = 1;
|
|
226
252
|
} else {
|
|
227
|
-
//
|
|
228
|
-
|
|
253
|
+
// Second of pair - keep
|
|
254
|
+
barLineDecisions.set(i, { action: "keep" });
|
|
255
|
+
barPosition = 0;
|
|
229
256
|
}
|
|
230
257
|
}
|
|
231
|
-
newMusic += musicText.substring(lastPos);
|
|
232
258
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
259
|
+
// Track variant replacements
|
|
260
|
+
const variantReplacements = new Map();
|
|
261
|
+
for (const [, decision] of barLineDecisions) {
|
|
262
|
+
if (decision.action === "remove" && decision.variantToken) {
|
|
263
|
+
const token = decision.variantToken;
|
|
264
|
+
const newToken = token.token.replace(/^\|/, "[");
|
|
265
|
+
variantReplacements.set(token.sourceIndex, {
|
|
266
|
+
oldLength: token.sourceLength,
|
|
267
|
+
newText: " " + newToken,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Reconstruct music
|
|
273
|
+
let newMusic = "";
|
|
274
|
+
let pos = 0;
|
|
275
|
+
|
|
276
|
+
while (pos < musicText.length) {
|
|
277
|
+
// Check for variant replacement
|
|
278
|
+
if (variantReplacements.has(pos)) {
|
|
279
|
+
const replacement = variantReplacements.get(pos);
|
|
280
|
+
newMusic += replacement.newText;
|
|
281
|
+
pos += replacement.oldLength;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
253
284
|
|
|
254
|
-
|
|
255
|
-
|
|
285
|
+
// Check for bar line
|
|
286
|
+
const barLineIdx = barLines.findIndex((bl) => bl.sourceIndex === pos);
|
|
287
|
+
if (barLineIdx >= 0) {
|
|
288
|
+
const decision = barLineDecisions.get(barLineIdx);
|
|
289
|
+
const barLine = barLines[barLineIdx];
|
|
256
290
|
|
|
257
|
-
// Check if we've just crossed the halfway point
|
|
258
291
|
if (
|
|
259
|
-
|
|
260
|
-
|
|
292
|
+
decision &&
|
|
293
|
+
decision.action === "remove" &&
|
|
294
|
+
!decision.variantToken
|
|
261
295
|
) {
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
296
|
+
// Remove bar line and ensure there's a space
|
|
297
|
+
// Check if we already added a space (last char in newMusic)
|
|
298
|
+
const needsSpace =
|
|
299
|
+
newMusic.length === 0 || newMusic[newMusic.length - 1] !== " ";
|
|
300
|
+
if (needsSpace) {
|
|
301
|
+
newMusic += " ";
|
|
267
302
|
}
|
|
268
|
-
|
|
303
|
+
let skipLength = barLine.sourceLength;
|
|
304
|
+
// Skip any trailing space after the bar line to avoid double spaces
|
|
305
|
+
if (
|
|
306
|
+
pos + skipLength < musicText.length &&
|
|
307
|
+
musicText[pos + skipLength] === " "
|
|
308
|
+
) {
|
|
309
|
+
skipLength++;
|
|
310
|
+
}
|
|
311
|
+
pos += skipLength;
|
|
312
|
+
continue;
|
|
313
|
+
} else if (decision && decision.action === "keep") {
|
|
314
|
+
// Keep this bar line
|
|
315
|
+
newMusic += musicText.substring(pos, pos + barLine.sourceLength);
|
|
316
|
+
pos += barLine.sourceLength;
|
|
317
|
+
continue;
|
|
269
318
|
}
|
|
270
319
|
}
|
|
271
320
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
321
|
+
// Regular character
|
|
322
|
+
newMusic += musicText[pos];
|
|
323
|
+
pos++;
|
|
275
324
|
}
|
|
276
325
|
|
|
326
|
+
return `${newHeaders.join("\n")}\n${newMusic}`;
|
|
327
|
+
} else {
|
|
328
|
+
// Going from large to small: add bar lines at midpoints
|
|
329
|
+
const barInfo = getBarInfo(bars, barLines, meter, {
|
|
330
|
+
divideBarsBy: 2,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const { midpoints } = barInfo;
|
|
334
|
+
|
|
277
335
|
// Insert bar lines at calculated positions
|
|
278
|
-
const
|
|
336
|
+
const insertionPoints = [...midpoints].sort((a, b) => b - a);
|
|
337
|
+
let newMusic = musicText;
|
|
338
|
+
|
|
339
|
+
for (const pos of insertionPoints) {
|
|
340
|
+
newMusic = newMusic.substring(0, pos) + "| " + newMusic.substring(pos);
|
|
341
|
+
}
|
|
279
342
|
|
|
280
343
|
return `${newHeaders.join("\n")}\n${newMusic}`;
|
|
281
344
|
}
|
|
@@ -318,9 +381,7 @@ function getFirstBars(
|
|
|
318
381
|
) {
|
|
319
382
|
// Convert numBars to Fraction if it's a number
|
|
320
383
|
const numBarsFraction =
|
|
321
|
-
typeof numBars === "number"
|
|
322
|
-
? new Fraction(Math.round(numBars * 1000), 1000)
|
|
323
|
-
: numBars;
|
|
384
|
+
typeof numBars === "number" ? new Fraction(numBars) : numBars;
|
|
324
385
|
|
|
325
386
|
// Estimate maxBars needed - simple ceiling with buffer
|
|
326
387
|
const estimatedMaxBars =
|
|
@@ -344,6 +405,7 @@ function getFirstBars(
|
|
|
344
405
|
}
|
|
345
406
|
}
|
|
346
407
|
|
|
408
|
+
//todo
|
|
347
409
|
if (firstCompleteBarIdx === -1) {
|
|
348
410
|
throw new Error("No complete bars found");
|
|
349
411
|
}
|
|
@@ -426,10 +488,14 @@ function getFirstBars(
|
|
|
426
488
|
accumulatedDuration = newAccumulated;
|
|
427
489
|
}
|
|
428
490
|
|
|
491
|
+
// if (endPos === startPos) {
|
|
492
|
+
// throw new Error(
|
|
493
|
+
// `Not enough bars to satisfy request. Requested ${numBars} bars.`
|
|
494
|
+
// );
|
|
495
|
+
// }
|
|
496
|
+
|
|
429
497
|
if (endPos === startPos) {
|
|
430
|
-
|
|
431
|
-
`Not enough bars to satisfy request. Requested ${numBars} bars.`
|
|
432
|
-
);
|
|
498
|
+
endPos = musicText.length - 1;
|
|
433
499
|
}
|
|
434
500
|
|
|
435
501
|
// Reconstruct ABC
|
package/src/math.js
CHANGED
|
@@ -12,6 +12,10 @@ class Fraction {
|
|
|
12
12
|
) {
|
|
13
13
|
throw new Error("invalid argument");
|
|
14
14
|
}
|
|
15
|
+
// if (!Number.isInteger(numerator)) {
|
|
16
|
+
// numerator = Math.round(numerator * 1000);
|
|
17
|
+
// denominator = denominator * 1000;
|
|
18
|
+
// }
|
|
15
19
|
|
|
16
20
|
const g = gcd(Math.abs(numerator), Math.abs(denominator));
|
|
17
21
|
this.num = numerator / g;
|
|
@@ -23,11 +27,16 @@ class Fraction {
|
|
|
23
27
|
this.den = -this.den;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
|
-
|
|
27
30
|
clone() {
|
|
28
31
|
return new Fraction(this.num, this.den);
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
static min(x, y) {
|
|
35
|
+
if (!x) return y;
|
|
36
|
+
if (!y) return x;
|
|
37
|
+
return x.compare(y) < 0 ? x : y;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
multiply(n) {
|
|
32
41
|
if (typeof n === "number") {
|
|
33
42
|
return new Fraction(this.num * n, this.den);
|
|
@@ -59,7 +68,7 @@ class Fraction {
|
|
|
59
68
|
compare(other) {
|
|
60
69
|
// Returns -1 if this < other, 0 if equal, 1 if this > other
|
|
61
70
|
const diff =
|
|
62
|
-
typeof
|
|
71
|
+
typeof other === "number"
|
|
63
72
|
? this.num - other * this.den
|
|
64
73
|
: this.num * other.den - other.num * this.den;
|
|
65
74
|
return diff < 0 ? -1 : diff > 0 ? 1 : 0;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const { Fraction } = require("../math.js");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enriches parsed ABC bar data with musical bar information
|
|
5
|
+
*
|
|
6
|
+
* Analyzes bars and bar lines to add:
|
|
7
|
+
* - barNumber: Index of the musical bar (null for initial bar lines)
|
|
8
|
+
* - isPartial: Flag for bar lines that occur mid-musical-bar
|
|
9
|
+
* - cumulativeDuration: Duration tracking for bar segments
|
|
10
|
+
* - midpoints: Positions where bars should be split (when divideBarsBy specified)
|
|
11
|
+
*
|
|
12
|
+
* A "musical bar" is defined by the meter (e.g., 4/4 means 4 quarter notes).
|
|
13
|
+
* Bar lines can create "partial bars" when repeats or variant endings split a musical bar.
|
|
14
|
+
* Consecutive partial bars within the same musical bar will have the same barNumber.
|
|
15
|
+
* Variant endings create alternative paths, so duration tracking follows one path at a time.
|
|
16
|
+
*
|
|
17
|
+
* @param {Array<Array<Object>>} bars - Array of bar arrays from parseAbc
|
|
18
|
+
* @param {Array<Object>} barLines - Array of barLine objects from parseAbc
|
|
19
|
+
* @param {Array<number>} meter - [numerator, denominator] from parseAbc
|
|
20
|
+
* @param {Object} options - Configuration options
|
|
21
|
+
* @param {boolean} options.barNumbers - Add barNumber to each barLine. Default value: true.
|
|
22
|
+
* @param {boolean} options.isPartial - Add isPartial flag to partial barLines. Default value: true.
|
|
23
|
+
* @param {boolean} options.cumulativeDuration - Add duration tracking to barLines. Default value: true.
|
|
24
|
+
* @param {number|null} options.divideBarsBy - Find midpoints for splitting (only 2 supported)
|
|
25
|
+
* @returns {Object} - { barLines: enriched array, midpoints: insertion positions }
|
|
26
|
+
*/
|
|
27
|
+
function getBarInfo(bars, barLines, meter, options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
barNumbers = true,
|
|
30
|
+
isPartial = true,
|
|
31
|
+
cumulativeDuration = true,
|
|
32
|
+
divideBarsBy = null,
|
|
33
|
+
} = options;
|
|
34
|
+
|
|
35
|
+
if (divideBarsBy !== null && divideBarsBy !== 2) {
|
|
36
|
+
throw new Error("divideBarsBy currently only supports value 2");
|
|
37
|
+
}
|
|
38
|
+
if (!barLines || barLines.length < bars.length) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"currently not handling bars without a bar line at the end"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fullBarDuration = new Fraction(meter[0], meter[1]);
|
|
45
|
+
const midpoints = [];
|
|
46
|
+
|
|
47
|
+
let currentBarNumber = 0;
|
|
48
|
+
let durationSinceLastComplete = new Fraction(0, 1);
|
|
49
|
+
let lastCompleteBarLineIdx = -1;
|
|
50
|
+
let barLineOffset = 0;
|
|
51
|
+
|
|
52
|
+
// Check for initial bar line (before any music)
|
|
53
|
+
if (
|
|
54
|
+
bars.length > 0 &&
|
|
55
|
+
bars[0].length > 0 &&
|
|
56
|
+
barLines.length > 0 &&
|
|
57
|
+
barLines[0].sourceIndex < bars[0][0].sourceIndex
|
|
58
|
+
) {
|
|
59
|
+
// Initial bar line exists
|
|
60
|
+
if (barNumbers) {
|
|
61
|
+
barLines[0].barNumber = null;
|
|
62
|
+
}
|
|
63
|
+
barLineOffset = 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Process each bar and its following bar line
|
|
67
|
+
for (let barIdx = 0; barIdx < bars.length; barIdx++) {
|
|
68
|
+
const bar = bars[barIdx];
|
|
69
|
+
const barLineIdx = barIdx + barLineOffset;
|
|
70
|
+
|
|
71
|
+
// Check if this bar starts with a variant ending
|
|
72
|
+
const startsWithVariant = bar.length > 0 && bar[0].isVariantEnding;
|
|
73
|
+
|
|
74
|
+
// If this bar starts with a variant, reset duration tracking
|
|
75
|
+
// (variant endings create alternative paths)
|
|
76
|
+
if (startsWithVariant && lastCompleteBarLineIdx >= 0) {
|
|
77
|
+
durationSinceLastComplete = new Fraction(0, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Calculate duration of this bar segment
|
|
81
|
+
let barDuration = new Fraction(0, 1);
|
|
82
|
+
for (const token of bar) {
|
|
83
|
+
if (token.duration) {
|
|
84
|
+
barDuration = barDuration.add(token.duration);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
durationSinceLastComplete = durationSinceLastComplete.add(barDuration);
|
|
89
|
+
|
|
90
|
+
// Get the bar line that follows this bar
|
|
91
|
+
if (barLineIdx < barLines.length) {
|
|
92
|
+
const barLine = barLines[barLineIdx];
|
|
93
|
+
|
|
94
|
+
// Determine if this bar line is partial
|
|
95
|
+
// A bar line is partial if THIS bar segment is less than full bar duration
|
|
96
|
+
const isPartialBar = barDuration.compare(fullBarDuration) < 0;
|
|
97
|
+
|
|
98
|
+
// Add barNumber
|
|
99
|
+
if (barNumbers) {
|
|
100
|
+
if (isPartialBar) {
|
|
101
|
+
// Partial bar line: barNumber is lastComplete + 1
|
|
102
|
+
barLine.barNumber =
|
|
103
|
+
lastCompleteBarLineIdx >= 0
|
|
104
|
+
? barLines[lastCompleteBarLineIdx].barNumber + 1
|
|
105
|
+
: 0;
|
|
106
|
+
|
|
107
|
+
// If this is the initial anacrusis (barNumber 0), mark it as complete for numbering purposes
|
|
108
|
+
if (barLine.barNumber === 0) {
|
|
109
|
+
lastCompleteBarLineIdx = barLineIdx;
|
|
110
|
+
currentBarNumber = 1; // Next complete bar will be bar 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If partial bars have accumulated to a full bar, increment currentBarNumber
|
|
114
|
+
if (durationSinceLastComplete.compare(fullBarDuration) >= 0) {
|
|
115
|
+
currentBarNumber = barLine.barNumber + 1;
|
|
116
|
+
lastCompleteBarLineIdx = barLineIdx;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Complete bar line
|
|
120
|
+
barLine.barNumber = currentBarNumber;
|
|
121
|
+
currentBarNumber++;
|
|
122
|
+
lastCompleteBarLineIdx = barLineIdx;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Add isPartial flag (only when true)
|
|
127
|
+
if (isPartial && isPartialBar) {
|
|
128
|
+
barLine.isPartial = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Add cumulative duration
|
|
132
|
+
if (cumulativeDuration) {
|
|
133
|
+
barLine.cumulativeDuration = {
|
|
134
|
+
sinceLastBarLine: barDuration.clone(),
|
|
135
|
+
sinceLastComplete: durationSinceLastComplete.clone(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Reset duration tracking if this completes a musical bar
|
|
140
|
+
// Also reset after initial anacrusis (barNumber 0)
|
|
141
|
+
if (lastCompleteBarLineIdx === barLineIdx || barLine.barNumber === 0) {
|
|
142
|
+
durationSinceLastComplete = new Fraction(0, 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Calculate midpoints if requested
|
|
148
|
+
if (divideBarsBy === 2) {
|
|
149
|
+
const halfBarDuration = fullBarDuration.divide(new Fraction(2, 1));
|
|
150
|
+
|
|
151
|
+
for (let barIdx = 0; barIdx < bars.length; barIdx++) {
|
|
152
|
+
const bar = bars[barIdx];
|
|
153
|
+
let accumulated = new Fraction(0, 1);
|
|
154
|
+
|
|
155
|
+
// Check if this bar starts with a variant - if so, skip it
|
|
156
|
+
// (variant endings already create splits)
|
|
157
|
+
if (bar.length > 0 && bar[0].isVariantEnding) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Find the halfway point in this bar
|
|
162
|
+
for (let tokenIdx = 0; tokenIdx < bar.length; tokenIdx++) {
|
|
163
|
+
const token = bar[tokenIdx];
|
|
164
|
+
|
|
165
|
+
// If we hit a variant ending before halfway, don't split
|
|
166
|
+
if (token.isVariantEnding) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!token.duration) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const prevAccumulated = accumulated.clone();
|
|
175
|
+
accumulated = accumulated.add(token.duration);
|
|
176
|
+
|
|
177
|
+
// Check if we just crossed the halfway point
|
|
178
|
+
if (
|
|
179
|
+
prevAccumulated.compare(halfBarDuration) < 0 &&
|
|
180
|
+
accumulated.compare(halfBarDuration) >= 0
|
|
181
|
+
) {
|
|
182
|
+
// Insert after this token
|
|
183
|
+
let insertPos = token.sourceIndex + token.sourceLength;
|
|
184
|
+
if (token.spacing && token.spacing.whitespace) {
|
|
185
|
+
insertPos += token.spacing.whitespace.length;
|
|
186
|
+
}
|
|
187
|
+
midpoints.push(insertPos);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
barLines,
|
|
196
|
+
midpoints,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
getBarInfo,
|
|
202
|
+
};
|
|
@@ -13,7 +13,7 @@ const { Fraction } = require("../math.js");
|
|
|
13
13
|
// ============================================================================
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Extract key signature from ABC header
|
|
16
|
+
* Extract base note of key signature from ABC header
|
|
17
17
|
*
|
|
18
18
|
* @param {string} abc - ABC notation string
|
|
19
19
|
* @returns {string} - Tonic note (e.g., 'C', 'D', 'G')
|
|
@@ -27,6 +27,20 @@ function getTonalBase(abc) {
|
|
|
27
27
|
return keyMatch[1].toUpperCase();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Extract key signature from ABC header
|
|
32
|
+
*
|
|
33
|
+
* @param {string} abc - ABC notation string
|
|
34
|
+
* @returns {string} - Tonic note (e.g., 'C', 'D', 'G')
|
|
35
|
+
* @throws {Error} - If no key signature found
|
|
36
|
+
*/
|
|
37
|
+
function getKey(abc) {
|
|
38
|
+
const keyMatch = abc.match(/^K:\s*([A-G].*)/m);
|
|
39
|
+
if (!keyMatch) {
|
|
40
|
+
throw new Error("No key signature found in ABC");
|
|
41
|
+
}
|
|
42
|
+
return keyMatch[1];
|
|
43
|
+
}
|
|
30
44
|
/**
|
|
31
45
|
* Extract meter/time signature from ABC header
|
|
32
46
|
*
|
|
@@ -151,4 +165,5 @@ module.exports = {
|
|
|
151
165
|
getUnitLength,
|
|
152
166
|
getMusicLines,
|
|
153
167
|
getTitles,
|
|
168
|
+
getKey,
|
|
154
169
|
};
|
package/src/parse/parser.js
CHANGED
|
@@ -2,6 +2,7 @@ const { Fraction } = require("../math.js");
|
|
|
2
2
|
const {
|
|
3
3
|
getTonalBase,
|
|
4
4
|
getMeter,
|
|
5
|
+
getKey,
|
|
5
6
|
getUnitLength,
|
|
6
7
|
getMusicLines,
|
|
7
8
|
} = require("./header-parser.js");
|
|
@@ -70,7 +71,7 @@ const {
|
|
|
70
71
|
* Returns object with:
|
|
71
72
|
* {
|
|
72
73
|
* bars: Array<Array<ScoreObject>>, // Array of bars, each bar is array of ScoreObjects
|
|
73
|
-
* // A
|
|
74
|
+
* // A ScoreObject is almost anything that isn’t a bar line: note/chord/field/broken rhythm/tuplet/1st or 2nd repeat or variant ending
|
|
74
75
|
* barLines: Array<BarLineObject>, // Array of bar line information
|
|
75
76
|
* unitLength: Fraction, // The L: field value (default 1/8)
|
|
76
77
|
* meter: [number, number], // The M: field value (default [4,4])
|
|
@@ -223,8 +224,8 @@ const {
|
|
|
223
224
|
function parseAbc(abc, options = {}) {
|
|
224
225
|
const { maxBars = Infinity } = options;
|
|
225
226
|
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
const unitLength = getUnitLength(abc);
|
|
228
|
+
const meter = getMeter(abc);
|
|
228
229
|
|
|
229
230
|
const {
|
|
230
231
|
musicText,
|
|
@@ -334,19 +335,6 @@ function parseAbc(abc, options = {}) {
|
|
|
334
335
|
// Check for inline field
|
|
335
336
|
const inlineField = parseInlineField(fullToken);
|
|
336
337
|
if (inlineField) {
|
|
337
|
-
// Update context based on inline field
|
|
338
|
-
if (inlineField.field === "L") {
|
|
339
|
-
const lengthMatch = inlineField.value.match(/1\/(\d+)/);
|
|
340
|
-
if (lengthMatch) {
|
|
341
|
-
unitLength = new Fraction(1, parseInt(lengthMatch[1]));
|
|
342
|
-
}
|
|
343
|
-
} else if (inlineField.field === "M") {
|
|
344
|
-
const meterMatch = inlineField.value.match(/(\d+)\/(\d+)/);
|
|
345
|
-
if (meterMatch) {
|
|
346
|
-
meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
338
|
const inlineFieldObj = {
|
|
351
339
|
isInlineField: true,
|
|
352
340
|
field: inlineField.field,
|
|
@@ -497,7 +485,7 @@ function parseAbc(abc, options = {}) {
|
|
|
497
485
|
...barLineInfo,
|
|
498
486
|
sourceIndex: barLinePos,
|
|
499
487
|
sourceLength: barLineText.length,
|
|
500
|
-
barNumber: barCount,
|
|
488
|
+
//barNumber: barCount,
|
|
501
489
|
hasLineBreak: hasLineBreakAfterBar,
|
|
502
490
|
});
|
|
503
491
|
|
|
@@ -615,6 +603,7 @@ function getTunes(text) {
|
|
|
615
603
|
|
|
616
604
|
module.exports = {
|
|
617
605
|
getTunes,
|
|
606
|
+
getKey,
|
|
618
607
|
parseAbc,
|
|
619
608
|
calculateBarDurations,
|
|
620
609
|
// Re-export utilities for convenience
|
package/src/parse/token-utils.js
CHANGED
|
@@ -84,7 +84,7 @@ const getTokenRegex = (options = {}) => {
|
|
|
84
84
|
if (options) {
|
|
85
85
|
if (options.variantEndings)
|
|
86
86
|
return new RegExp(`^${s.variantEnding}|${s.repeat_1Or2}$`);
|
|
87
|
-
if (options.inlineField) return new RegExp(
|
|
87
|
+
if (options.inlineField) return new RegExp(`^${s.inlineField}$`);
|
|
88
88
|
}
|
|
89
89
|
// Complete note/rest/chord pattern with optional leading decoration
|
|
90
90
|
const notePattern =
|
package/src/sort/contour-sort.js
CHANGED
|
@@ -1,173 +1,13 @@
|
|
|
1
1
|
const { Fraction } = require("../math.js");
|
|
2
|
-
const {
|
|
3
|
-
getTonalBase,
|
|
4
|
-
getUnitLength,
|
|
5
|
-
parseAbc,
|
|
6
|
-
getMeter,
|
|
7
|
-
} = require("../parse/parser.js");
|
|
8
2
|
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
const {
|
|
12
|
-
calculateModalPosition,
|
|
13
|
-
decodeChar,
|
|
14
|
-
encodeToChar,
|
|
15
|
-
silenceChar,
|
|
16
|
-
} = require("./encode.js");
|
|
3
|
+
const { decodeChar, encodeToChar, silenceChar } = require("./encode.js");
|
|
4
|
+
const { getContour } = require("./get-contour.js");
|
|
17
5
|
|
|
18
6
|
/**
|
|
19
7
|
* Tune Contour Sort - Modal melody sorting algorithm
|
|
20
8
|
* Sorts tunes by their modal contour, independent of key and mode
|
|
21
9
|
*/
|
|
22
10
|
|
|
23
|
-
// ============================================================================
|
|
24
|
-
// Contour (compare object) generation
|
|
25
|
-
// ============================================================================
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Generate contour (compare object) from ABC notation
|
|
29
|
-
* @returns { sortKey: string, durations: Array, version: string, part: string }
|
|
30
|
-
*
|
|
31
|
-
* todo: complete this header. options.withSvg; options.maxNbUnitLengths
|
|
32
|
-
*/
|
|
33
|
-
function getContour(
|
|
34
|
-
abc,
|
|
35
|
-
{ withSvg = false, maxNbUnitLengths = 10, svgConfig = {} } = {}
|
|
36
|
-
) {
|
|
37
|
-
const tonalBase = getTonalBase(abc);
|
|
38
|
-
const unitLength = getUnitLength(abc);
|
|
39
|
-
const maxDuration = unitLength.multiply(maxNbUnitLengths);
|
|
40
|
-
const meter = getMeter(abc);
|
|
41
|
-
const maxNbBars = meter
|
|
42
|
-
? maxDuration.divide(new Fraction(meter[0], meter[1]))
|
|
43
|
-
: new Fraction(2, 1); //default 2 bars when no meter (free meter)
|
|
44
|
-
const { bars } = parseAbc(abc, {
|
|
45
|
-
maxBars: Math.ceil(maxNbBars.toNumber()),
|
|
46
|
-
});
|
|
47
|
-
let cumulatedDuration = new Fraction(0, 1);
|
|
48
|
-
const sortKey = [];
|
|
49
|
-
const durations = [];
|
|
50
|
-
// const debugPositions = [];
|
|
51
|
-
let index = 0;
|
|
52
|
-
// get the parsed notes - notes are tokens with a duration
|
|
53
|
-
const notes = [];
|
|
54
|
-
let tied = false,
|
|
55
|
-
previousPosition = null;
|
|
56
|
-
for (let i = 0; i < bars.length; i++) {
|
|
57
|
-
const bar = bars[i];
|
|
58
|
-
for (let j = 0; j < bar.length; j++) {
|
|
59
|
-
const token = bar[j];
|
|
60
|
-
if (token.duration && token.duration.num > 0) {
|
|
61
|
-
cumulatedDuration = cumulatedDuration.add(token.duration);
|
|
62
|
-
if (cumulatedDuration.isGreaterThan(maxDuration)) break;
|
|
63
|
-
notes.push(token);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
notes.forEach((note) => {
|
|
69
|
-
const { duration, isSilence } = note;
|
|
70
|
-
const comparison = duration.compare(unitLength);
|
|
71
|
-
const { encoded, encodedHeld, position } = isSilence
|
|
72
|
-
? { encoded: silenceChar, encodedHeld: silenceChar, position: 0 }
|
|
73
|
-
: getEncodedFromNote(note, tonalBase, tied, previousPosition);
|
|
74
|
-
|
|
75
|
-
if (note.tied) {
|
|
76
|
-
tied = true;
|
|
77
|
-
previousPosition = position;
|
|
78
|
-
} else {
|
|
79
|
-
tied = false;
|
|
80
|
-
previousPosition = null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (comparison > 0) {
|
|
84
|
-
// Held note: duration > unitLength
|
|
85
|
-
const ratio = duration.divide(unitLength);
|
|
86
|
-
const nbUnitLengths = Math.floor(ratio.num / ratio.den);
|
|
87
|
-
const remainingDuration = duration.subtract(
|
|
88
|
-
unitLength.multiply(nbUnitLengths)
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
// const durationRatio = Math.round(ratio.num / ratio.den);
|
|
92
|
-
|
|
93
|
-
// First note is played
|
|
94
|
-
sortKey.push(encoded);
|
|
95
|
-
//debugPositions.push(position);
|
|
96
|
-
|
|
97
|
-
// Subsequent notes are held
|
|
98
|
-
for (let i = 1; i < nbUnitLengths; i++) {
|
|
99
|
-
sortKey.push(encodedHeld);
|
|
100
|
-
//debugPositions.push(position);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
index += nbUnitLengths;
|
|
104
|
-
if (remainingDuration.num !== 0) {
|
|
105
|
-
pushShortNote(
|
|
106
|
-
encodedHeld,
|
|
107
|
-
unitLength,
|
|
108
|
-
duration,
|
|
109
|
-
index,
|
|
110
|
-
durations,
|
|
111
|
-
sortKey
|
|
112
|
-
);
|
|
113
|
-
//debugPositions.push(position);
|
|
114
|
-
index++;
|
|
115
|
-
}
|
|
116
|
-
} else if (comparison < 0) {
|
|
117
|
-
pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
|
|
118
|
-
//debugPositions.push(position);
|
|
119
|
-
index++;
|
|
120
|
-
} else {
|
|
121
|
-
// Normal note: duration === unitLength
|
|
122
|
-
sortKey.push(encoded);
|
|
123
|
-
//debugPositions.push(position);
|
|
124
|
-
index++;
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const result = {
|
|
129
|
-
sortKey: sortKey.join(""),
|
|
130
|
-
//debugPositions: debugPositions.join(","),
|
|
131
|
-
};
|
|
132
|
-
if (durations.length > 0) {
|
|
133
|
-
result.durations = durations;
|
|
134
|
-
}
|
|
135
|
-
if (withSvg) {
|
|
136
|
-
result.svg = contourToSvg(result, svgConfig);
|
|
137
|
-
}
|
|
138
|
-
return result;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Adds a short note (duration < unitLength) to the contour
|
|
143
|
-
* @param {string} encoded - the encoded representation of the note’s modal degree information (MDI)
|
|
144
|
-
* @param {Fraction} unitLength - the unit length
|
|
145
|
-
* @param {Fraction} duration - the duration of the note
|
|
146
|
-
* @param {number} index - the index of the note
|
|
147
|
-
* @param {Array<object>} durations - the durations array
|
|
148
|
-
* @param {Array<string>} sortKey - array of MDIs
|
|
149
|
-
*/
|
|
150
|
-
function pushShortNote(
|
|
151
|
-
encoded,
|
|
152
|
-
unitLength,
|
|
153
|
-
duration,
|
|
154
|
-
index,
|
|
155
|
-
durations,
|
|
156
|
-
sortKey
|
|
157
|
-
) {
|
|
158
|
-
const relativeDuration = duration.divide(unitLength),
|
|
159
|
-
d = {
|
|
160
|
-
i: index,
|
|
161
|
-
d: relativeDuration.den,
|
|
162
|
-
};
|
|
163
|
-
if (relativeDuration.num !== 1) {
|
|
164
|
-
d.n = relativeDuration.num;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
durations.push(d);
|
|
168
|
-
sortKey.push(encoded);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
11
|
// ============================================================================
|
|
172
12
|
// COMPARISON FUNCTIONS
|
|
173
13
|
// ============================================================================
|
|
@@ -352,26 +192,11 @@ function sortArray(arr) {
|
|
|
352
192
|
return arr;
|
|
353
193
|
}
|
|
354
194
|
|
|
355
|
-
function getEncodedFromNote(note, tonalBase, tied, previousPosition) {
|
|
356
|
-
// Handle pitched note
|
|
357
|
-
const { pitch, octave } = note;
|
|
358
|
-
const position = calculateModalPosition(tonalBase, pitch, octave);
|
|
359
|
-
const encodedHeld = encodeToChar(position, true);
|
|
360
|
-
const encoded = encodeToChar(position, false);
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
encoded: tied && position === previousPosition ? encodedHeld : encoded,
|
|
364
|
-
encodedHeld,
|
|
365
|
-
position,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
|
|
369
195
|
// ============================================================================
|
|
370
196
|
// EXPORTS
|
|
371
197
|
// ============================================================================
|
|
372
198
|
|
|
373
199
|
module.exports = {
|
|
374
|
-
getContour,
|
|
375
200
|
compare,
|
|
376
201
|
sortArray,
|
|
377
202
|
decodeChar,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
const { Fraction } = require("../math.js");
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
calculateModalPosition,
|
|
5
|
+
encodeToChar,
|
|
6
|
+
silenceChar,
|
|
7
|
+
} = require("./encode.js");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
getTonalBase,
|
|
11
|
+
getUnitLength,
|
|
12
|
+
parseAbc,
|
|
13
|
+
getMeter,
|
|
14
|
+
} = require("../parse/parser.js");
|
|
15
|
+
|
|
16
|
+
const { contourToSvg } = require("./contour-svg.js");
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Contour (compare object) generation
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate contour (compare object) from ABC notation
|
|
24
|
+
* @returns { sortKey: string, durations: Array, version: string, part: string }
|
|
25
|
+
*
|
|
26
|
+
* todo: complete this header. options.withSvg; options.maxNbUnitLengths
|
|
27
|
+
*/
|
|
28
|
+
function getContour(
|
|
29
|
+
abc,
|
|
30
|
+
{
|
|
31
|
+
withSvg = false,
|
|
32
|
+
withSwingTransform = false,
|
|
33
|
+
maxNbBars = new Fraction(3, 2),
|
|
34
|
+
maxNbUnitLengths = 12,
|
|
35
|
+
svgConfig = {},
|
|
36
|
+
} = {}
|
|
37
|
+
) {
|
|
38
|
+
const tonalBase = getTonalBase(abc);
|
|
39
|
+
|
|
40
|
+
const unitLength = getUnitLength(abc); //todo: could add as an argument; default null
|
|
41
|
+
if (typeof maxNbBars === "number") maxNbBars = new Fraction(maxNbBars);
|
|
42
|
+
let meter = getMeter(abc); //todo: could add as an argument; default null
|
|
43
|
+
if (!meter) meter = [4, 4]; //temp
|
|
44
|
+
const meterFraction = new Fraction(meter[0], meter[1]);
|
|
45
|
+
if (maxNbUnitLengths) {
|
|
46
|
+
const maxNbBarsFromMaxUnitLength = unitLength
|
|
47
|
+
.multiply(maxNbUnitLengths)
|
|
48
|
+
.divide(meterFraction);
|
|
49
|
+
|
|
50
|
+
maxNbBars = Fraction.min(maxNbBarsFromMaxUnitLength, maxNbBars);
|
|
51
|
+
}
|
|
52
|
+
const maxDuration = maxNbBars * meterFraction;
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
bars,
|
|
56
|
+
} = //todo: could add as an argument; default null
|
|
57
|
+
parseAbc(abc, {
|
|
58
|
+
maxBars: Math.ceil(maxNbBars.toNumber()),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let cumulatedDuration = new Fraction(0, 1);
|
|
62
|
+
const sortKey = [];
|
|
63
|
+
const durations = [];
|
|
64
|
+
// const debugPositions = [];
|
|
65
|
+
let index = 0;
|
|
66
|
+
// get the parsed notes - notes are tokens with a duration
|
|
67
|
+
const notes = [];
|
|
68
|
+
let tied = false,
|
|
69
|
+
previousPosition = null;
|
|
70
|
+
for (let i = 0; i < bars.length; i++) {
|
|
71
|
+
const bar = bars[i];
|
|
72
|
+
for (let j = 0; j < bar.length; j++) {
|
|
73
|
+
const token = bar[j];
|
|
74
|
+
if (token.duration && token.duration.num > 0) {
|
|
75
|
+
cumulatedDuration = cumulatedDuration.add(token.duration);
|
|
76
|
+
if (cumulatedDuration.isGreaterThan(maxDuration)) break;
|
|
77
|
+
notes.push(token);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (withSwingTransform) {
|
|
83
|
+
swingTransform(notes, unitLength, meter);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
notes.forEach((note) => {
|
|
87
|
+
const { duration, isSilence } = note;
|
|
88
|
+
const comparison = duration.compare(unitLength);
|
|
89
|
+
const { encoded, encodedHeld, position } = isSilence
|
|
90
|
+
? { encoded: silenceChar, encodedHeld: silenceChar, position: 0 }
|
|
91
|
+
: getEncodedFromNote(note, tonalBase, tied, previousPosition);
|
|
92
|
+
|
|
93
|
+
if (note.tied) {
|
|
94
|
+
tied = true;
|
|
95
|
+
previousPosition = position;
|
|
96
|
+
} else {
|
|
97
|
+
tied = false;
|
|
98
|
+
previousPosition = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (comparison > 0) {
|
|
102
|
+
// Held note: duration > unitLength
|
|
103
|
+
const { nbUnitLengths, remainingDuration } = divideDuration(
|
|
104
|
+
duration,
|
|
105
|
+
unitLength
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// First note is played
|
|
109
|
+
sortKey.push(encoded);
|
|
110
|
+
//debugPositions.push(position);
|
|
111
|
+
|
|
112
|
+
// Subsequent notes are held
|
|
113
|
+
for (let i = 1; i < nbUnitLengths; i++) {
|
|
114
|
+
sortKey.push(encodedHeld);
|
|
115
|
+
//debugPositions.push(position);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
index += nbUnitLengths;
|
|
119
|
+
if (remainingDuration.num !== 0) {
|
|
120
|
+
pushShortNote(
|
|
121
|
+
encodedHeld,
|
|
122
|
+
unitLength,
|
|
123
|
+
duration,
|
|
124
|
+
index,
|
|
125
|
+
durations,
|
|
126
|
+
sortKey
|
|
127
|
+
);
|
|
128
|
+
//debugPositions.push(position);
|
|
129
|
+
index++;
|
|
130
|
+
}
|
|
131
|
+
} else if (comparison < 0) {
|
|
132
|
+
pushShortNote(encoded, unitLength, duration, index, durations, sortKey);
|
|
133
|
+
//debugPositions.push(position);
|
|
134
|
+
index++;
|
|
135
|
+
} else {
|
|
136
|
+
// Normal note: duration === unitLength
|
|
137
|
+
sortKey.push(encoded);
|
|
138
|
+
//debugPositions.push(position);
|
|
139
|
+
index++;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const result = {
|
|
144
|
+
sortKey: sortKey.join(""),
|
|
145
|
+
//debugPositions: debugPositions.join(","),
|
|
146
|
+
};
|
|
147
|
+
if (durations.length > 0) {
|
|
148
|
+
result.durations = durations;
|
|
149
|
+
}
|
|
150
|
+
if (withSvg) {
|
|
151
|
+
result.svg = contourToSvg(result, svgConfig);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function divideDuration(duration, unitLength) {
|
|
157
|
+
const ratio = duration.divide(unitLength);
|
|
158
|
+
const nbUnitLengths = Math.floor(ratio.num / ratio.den);
|
|
159
|
+
const remainingDuration = duration.subtract(
|
|
160
|
+
unitLength.multiply(nbUnitLengths)
|
|
161
|
+
);
|
|
162
|
+
return { nbUnitLengths, remainingDuration };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function swingTransform(notes, unitLength, meter) {
|
|
166
|
+
// if (meter[0] % 2 !== 0) {
|
|
167
|
+
//check meter is an even multiple of the unit length
|
|
168
|
+
{
|
|
169
|
+
const nbUnitLengths = new Fraction(meter[0], meter[1]).divide(unitLength);
|
|
170
|
+
if (nbUnitLengths.den !== 1 || nbUnitLengths.num % 2 !== 0)
|
|
171
|
+
throw new Error("invalid meter for swing transform");
|
|
172
|
+
}
|
|
173
|
+
// modify notes to ensure all are of duration <= 2*unitLength
|
|
174
|
+
{
|
|
175
|
+
const twoUnits = unitLength.multiply(2);
|
|
176
|
+
let tooLong = notes
|
|
177
|
+
.map((n, i) => {
|
|
178
|
+
return { n, i };
|
|
179
|
+
})
|
|
180
|
+
.filter((n) => n.n.duration.compare(twoUnits) > 0),
|
|
181
|
+
safety = 0;
|
|
182
|
+
|
|
183
|
+
while (tooLong.length > 0) {
|
|
184
|
+
if (safety > 1000) throw new Error("swingTransform safety check failed");
|
|
185
|
+
|
|
186
|
+
const noteToSplit = tooLong[0].n;
|
|
187
|
+
const { nbUnitLengths, remainingDuration } = divideDuration(
|
|
188
|
+
noteToSplit.duration,
|
|
189
|
+
twoUnits
|
|
190
|
+
);
|
|
191
|
+
noteToSplit.duration = twoUnits;
|
|
192
|
+
if (!tooLong.isSilence) noteToSplit.tied = true;
|
|
193
|
+
const toAdd = [];
|
|
194
|
+
for (let i = 1; i < nbUnitLengths; i++) {
|
|
195
|
+
toAdd.push({ ...noteToSplit });
|
|
196
|
+
}
|
|
197
|
+
const lastNote = { ...noteToSplit };
|
|
198
|
+
lastNote.duration = remainingDuration;
|
|
199
|
+
toAdd.push(lastNote);
|
|
200
|
+
notes.splice(tooLong[0].i + 1, 0, ...toAdd);
|
|
201
|
+
/*
|
|
202
|
+
myArray.splice(index, 0, ...itemsToInsert): The splice method takes three arguments:
|
|
203
|
+
The first argument (index) is the starting index at which to modify the array.
|
|
204
|
+
The second argument (0) indicates that no elements should be removed from the array.
|
|
205
|
+
The third argument uses the spread operator (...itemsToInsert) to insert the elements of itemsToInsert into myArray at the specified index.
|
|
206
|
+
*/
|
|
207
|
+
safety++;
|
|
208
|
+
tooLong = notes
|
|
209
|
+
.map((n, i) => {
|
|
210
|
+
return { n, i };
|
|
211
|
+
})
|
|
212
|
+
.filter((n) => n.n.duration.compare(twoUnits) > 0);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const dotted = unitLength.multiply(3).divide(2), // dotted quaver, if L:1/8; long part of broken rhythm
|
|
217
|
+
semi = unitLength.divide(2), // semiquaver, if L:1/8; short part of broken rhythm
|
|
218
|
+
triplet = unitLength.multiply(2).divide(3),
|
|
219
|
+
multiplier = new Fraction(3, 2);
|
|
220
|
+
|
|
221
|
+
let i = 0;
|
|
222
|
+
while (true) {
|
|
223
|
+
if (i >= notes.length) break;
|
|
224
|
+
const n1 = notes[i],
|
|
225
|
+
n2 = i + 1 < notes.length ? notes[i + 1] : null,
|
|
226
|
+
n3 = i + 2 < notes.length ? notes[i + 2] : null;
|
|
227
|
+
|
|
228
|
+
//basic: change AB to A2B
|
|
229
|
+
if (
|
|
230
|
+
n2 &&
|
|
231
|
+
n1.duration.equals(unitLength) &&
|
|
232
|
+
n2.duration.equals(unitLength)
|
|
233
|
+
) {
|
|
234
|
+
n1.duration = unitLength.multiply(2);
|
|
235
|
+
i += 2;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
//broken
|
|
239
|
+
if (n2 && n1.duration.equals(dotted) && n2.duration.equals(semi)) {
|
|
240
|
+
n1.duration = unitLength.multiply(2);
|
|
241
|
+
n2.duration = unitLength;
|
|
242
|
+
i += 2;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
//reverse broken
|
|
246
|
+
if (n2 && n2.duration.equals(dotted) && n1.duration.equals(semi)) {
|
|
247
|
+
n2.duration = unitLength.multiply(2);
|
|
248
|
+
n1.duration = unitLength;
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
//triplets
|
|
254
|
+
if (
|
|
255
|
+
n2 &&
|
|
256
|
+
n3 &&
|
|
257
|
+
n1.duration.equals(triplet) &&
|
|
258
|
+
n2.duration.equals(triplet) &&
|
|
259
|
+
n3.duration.equals(triplet)
|
|
260
|
+
) {
|
|
261
|
+
n1.duration = unitLength;
|
|
262
|
+
n2.duration = unitLength;
|
|
263
|
+
n3.duration = unitLength;
|
|
264
|
+
i += 3;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
//two short + long, eg d/c/B
|
|
268
|
+
if (
|
|
269
|
+
n2 &&
|
|
270
|
+
n3 &&
|
|
271
|
+
n1.duration.equals(semi) &&
|
|
272
|
+
n2.duration.equals(semi) &&
|
|
273
|
+
n3.duration.equals(unitLength)
|
|
274
|
+
) {
|
|
275
|
+
n1.duration = unitLength.divide(2);
|
|
276
|
+
n2.duration = unitLength.divide(2);
|
|
277
|
+
n3.duration = unitLength.multiply(2);
|
|
278
|
+
i += 3;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// other
|
|
282
|
+
n1.duration = n1.duration.multiply(multiplier);
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Adds a short note (duration < unitLength) to the contour
|
|
289
|
+
* @param {string} encoded - the encoded representation of the note’s modal degree information (MDI)
|
|
290
|
+
* @param {Fraction} unitLength - the unit length
|
|
291
|
+
* @param {Fraction} duration - the duration of the note
|
|
292
|
+
* @param {number} index - the index of the note
|
|
293
|
+
* @param {Array<object>} durations - the durations array
|
|
294
|
+
* @param {Array<string>} sortKey - array of MDIs
|
|
295
|
+
*/
|
|
296
|
+
function pushShortNote(
|
|
297
|
+
encoded,
|
|
298
|
+
unitLength,
|
|
299
|
+
duration,
|
|
300
|
+
index,
|
|
301
|
+
durations,
|
|
302
|
+
sortKey
|
|
303
|
+
) {
|
|
304
|
+
const relativeDuration = duration.divide(unitLength),
|
|
305
|
+
d = {
|
|
306
|
+
i: index,
|
|
307
|
+
d: relativeDuration.den,
|
|
308
|
+
};
|
|
309
|
+
if (relativeDuration.num !== 1) {
|
|
310
|
+
d.n = relativeDuration.num;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
durations.push(d);
|
|
314
|
+
sortKey.push(encoded);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getEncodedFromNote(note, tonalBase, tied, previousPosition) {
|
|
318
|
+
// Handle pitched note
|
|
319
|
+
const { pitch, octave } = note;
|
|
320
|
+
const position = calculateModalPosition(tonalBase, pitch, octave);
|
|
321
|
+
const encodedHeld = encodeToChar(position, true);
|
|
322
|
+
const encoded = encodeToChar(position, false);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
encoded: tied && position === previousPosition ? encodedHeld : encoded,
|
|
326
|
+
encodedHeld,
|
|
327
|
+
position,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
getContour,
|
|
333
|
+
};
|
package/testoutput.txt
ADDED
|
Binary file
|