@goplayerjuggler/abc-tools 1.0.6 → 1.0.7
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 +3 -3
- package/src/parse/header-parser.js +14 -2
- package/src/parse/note-parser.js +85 -4
- package/src/parse/parser.js +89 -51
- package/src/parse/token-utils.js +13 -6
- package/src/sort/contour-svg.js +109 -16
package/package.json
CHANGED
package/src/incipit.js
CHANGED
|
@@ -54,8 +54,8 @@ function cleanIncipitLine(theTextIncipit) {
|
|
|
54
54
|
// theTextIncipit = theTextIncipit.replaceAll("]", "");
|
|
55
55
|
//console.log(theTextIncipit);
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
theTextIncipit = theTextIncipit.replaceAll("\\", "");
|
|
57
|
+
// keep continuations!
|
|
58
|
+
// theTextIncipit = theTextIncipit.replaceAll("\\", "");
|
|
59
59
|
|
|
60
60
|
// Segno
|
|
61
61
|
theTextIncipit = theTextIncipit.replaceAll("S", "");
|
|
@@ -333,7 +333,7 @@ function getContourFromFullAbc(abc) {
|
|
|
333
333
|
if (abc.length === 0) return null;
|
|
334
334
|
abc = abc[0];
|
|
335
335
|
}
|
|
336
|
-
return getContour(getIncipitForContourGeneration(abc));
|
|
336
|
+
return getContour(getIncipitForContourGeneration(abc), { withSvg: true });
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
module.exports = {
|
|
@@ -54,6 +54,15 @@ function getUnitLength(abc) {
|
|
|
54
54
|
}
|
|
55
55
|
return new Fraction(1, 8); // Default to 1/8
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract titles - there may be 0..N titles
|
|
59
|
+
*
|
|
60
|
+
* @param {string} abc - ABC notation string
|
|
61
|
+
* @returns {[string]} - array of titles
|
|
62
|
+
*/
|
|
63
|
+
function getTitles(abc) {
|
|
64
|
+
return [...abc.matchAll(/^(?:T:\s*.(.+)\n)/gm)];
|
|
65
|
+
}
|
|
57
66
|
|
|
58
67
|
/**
|
|
59
68
|
* Process ABC lines: extract music lines with metadata
|
|
@@ -100,9 +109,11 @@ function getMusicLines(abc) {
|
|
|
100
109
|
// Check for line continuation
|
|
101
110
|
const hasContinuation = trimmed.match(/\\\s*(%|$)/) !== null;
|
|
102
111
|
|
|
103
|
-
// Remove inline comments
|
|
112
|
+
// Remove inline comments
|
|
104
113
|
trimmed = trimmed.replace(/\s*%.*$/, "").trim();
|
|
105
|
-
|
|
114
|
+
|
|
115
|
+
// Do *not* remove continuations: `\` at the end of a line
|
|
116
|
+
// trimmed = trimmed.replace(/\\\s*$/, "").trim();
|
|
106
117
|
|
|
107
118
|
if (trimmed) {
|
|
108
119
|
musicLines.push(trimmed);
|
|
@@ -137,4 +148,5 @@ module.exports = {
|
|
|
137
148
|
getMeter,
|
|
138
149
|
getUnitLength,
|
|
139
150
|
getMusicLines,
|
|
151
|
+
getTitles,
|
|
140
152
|
};
|
package/src/parse/note-parser.js
CHANGED
|
@@ -12,6 +12,7 @@ const { Fraction } = require("../math.js");
|
|
|
12
12
|
// - Chord symbols and annotations
|
|
13
13
|
// - Tuplets/triplets
|
|
14
14
|
// - Broken rhythms
|
|
15
|
+
// - Grace notes
|
|
15
16
|
//
|
|
16
17
|
// ============================================================================
|
|
17
18
|
|
|
@@ -193,6 +194,87 @@ function parseChord(chordStr, unitLength) {
|
|
|
193
194
|
};
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Parse grace notes from a token
|
|
199
|
+
* Grace notes are enclosed in curly braces and have zero duration
|
|
200
|
+
*
|
|
201
|
+
* @param {string} graceStr - Grace note string (e.g., '{ABC}', '{^AB_c}', '{[CEG]A}')
|
|
202
|
+
* @returns {Array<object>|null} - Array of grace note objects, or null if not valid grace notes
|
|
203
|
+
*
|
|
204
|
+
* Each grace note has:
|
|
205
|
+
* - isGraceNote: true
|
|
206
|
+
* - duration: Fraction(0, 1)
|
|
207
|
+
* - pitch, octave: as normal
|
|
208
|
+
* - isChord: true (if chord in brackets)
|
|
209
|
+
* - chordNotes: Array (if chord)
|
|
210
|
+
*/
|
|
211
|
+
function parseGraceNotes(graceStr) {
|
|
212
|
+
if (!graceStr.startsWith("{") || !graceStr.endsWith("}")) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const content = graceStr.slice(1, -1);
|
|
217
|
+
if (!content) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const graceNotes = [];
|
|
222
|
+
|
|
223
|
+
// Match individual notes or chords: accidental + pitch + octave (+ duration to ignore)
|
|
224
|
+
// Supports: A, ^A, _B', [CEG], [^C_E'G], A2, B/2, etc.
|
|
225
|
+
const noteRegex = /(?:\[[^\]]+\]|[=^_]?[A-Ga-g][',]*)[0-9]*\/?[0-9]*/g;
|
|
226
|
+
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = noteRegex.exec(content)) !== null) {
|
|
229
|
+
const noteToken = match[0];
|
|
230
|
+
|
|
231
|
+
// Check if it's a chord
|
|
232
|
+
if (noteToken.startsWith("[")) {
|
|
233
|
+
const chord = parseChord(noteToken, new Fraction(1, 8)); // unitLength irrelevant
|
|
234
|
+
if (chord && chord.notes) {
|
|
235
|
+
// Find topmost note for the grace chord
|
|
236
|
+
let topNote = chord.notes[0];
|
|
237
|
+
for (const note of chord.notes) {
|
|
238
|
+
const topPos =
|
|
239
|
+
(topNote.octave || 0) * 7 +
|
|
240
|
+
(NOTE_TO_DEGREE[topNote.pitch?.toUpperCase()] || 0);
|
|
241
|
+
const notePos =
|
|
242
|
+
(note.octave || 0) * 7 +
|
|
243
|
+
(NOTE_TO_DEGREE[note.pitch?.toUpperCase()] || 0);
|
|
244
|
+
if (notePos > topPos) {
|
|
245
|
+
topNote = note;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Set zero duration for all notes in grace chord
|
|
250
|
+
chord.notes.forEach((note) => {
|
|
251
|
+
note.duration = new Fraction(0, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
graceNotes.push({
|
|
255
|
+
...topNote,
|
|
256
|
+
isGraceNote: true,
|
|
257
|
+
duration: new Fraction(0, 1),
|
|
258
|
+
isChord: true,
|
|
259
|
+
chordNotes: chord.notes,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Single grace note
|
|
264
|
+
const pitchData = getPitch(noteToken);
|
|
265
|
+
if (pitchData) {
|
|
266
|
+
graceNotes.push({
|
|
267
|
+
...pitchData,
|
|
268
|
+
isGraceNote: true,
|
|
269
|
+
duration: new Fraction(0, 1),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return graceNotes.length > 0 ? graceNotes : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
196
278
|
/**
|
|
197
279
|
* Parse broken rhythm from a token
|
|
198
280
|
* Broken rhythms modify the duration of two adjacent notes (e.g., A>B, C<<D)
|
|
@@ -210,12 +292,10 @@ function parseChord(chordStr, unitLength) {
|
|
|
210
292
|
* - 3: triple-dotted rhythm (15:1 multiplier)
|
|
211
293
|
*/
|
|
212
294
|
function parseBrokenRhythm(token) {
|
|
213
|
-
const brokenMatch = token.match(/^(
|
|
295
|
+
const brokenMatch = token.match(/^(<{1,3}|>{1,3})$/);
|
|
214
296
|
if (brokenMatch) {
|
|
215
|
-
const
|
|
216
|
-
symbol = brokenMatch[2];
|
|
297
|
+
const symbol = brokenMatch[1];
|
|
217
298
|
return {
|
|
218
|
-
firstNoteToken,
|
|
219
299
|
isBrokenRhythm: true,
|
|
220
300
|
direction: symbol[0],
|
|
221
301
|
dots: symbol.length,
|
|
@@ -459,4 +539,5 @@ module.exports = {
|
|
|
459
539
|
parseTuplet,
|
|
460
540
|
parseBrokenRhythm,
|
|
461
541
|
applyBrokenRhythm,
|
|
542
|
+
parseGraceNotes,
|
|
462
543
|
};
|
package/src/parse/parser.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
parseTuplet,
|
|
11
11
|
parseBrokenRhythm,
|
|
12
12
|
applyBrokenRhythm,
|
|
13
|
+
parseGraceNotes,
|
|
13
14
|
} = require("./note-parser.js");
|
|
14
15
|
const { classifyBarLine } = require("./barline-parser.js");
|
|
15
16
|
const {
|
|
@@ -34,6 +35,7 @@ const {
|
|
|
34
35
|
// - Back quotes: ` (ignored spacing for legibility, preserved in metadata)
|
|
35
36
|
// - Triplets: (3ABC, (3A/B/C/, (3A2B2C2
|
|
36
37
|
// - Broken rhythms: A>B, C<<D, etc. (>, >>, >>>, <, <<, <<<)
|
|
38
|
+
// - Grace notes: {ABC}, {^AB_c}, etc. (zero duration, transparent to broken rhythms)
|
|
37
39
|
// - Repeat notation: |:, :|, |1, |2, etc.
|
|
38
40
|
// - Bar lines: |, ||, |], [|, etc.
|
|
39
41
|
// - Decorations: symbol decorations (~.MPSTHUV) and !name! decorations
|
|
@@ -47,7 +49,6 @@ const {
|
|
|
47
49
|
// - Line breaks: preserves information about newlines in music
|
|
48
50
|
//
|
|
49
51
|
// NOT YET SUPPORTED:
|
|
50
|
-
// - Grace notes: {ABC}
|
|
51
52
|
// - Slurs: ()
|
|
52
53
|
// - Lyrics: w: lines
|
|
53
54
|
// - Multiple voices: V: fields
|
|
@@ -104,7 +105,8 @@ const {
|
|
|
104
105
|
* text: string
|
|
105
106
|
* },
|
|
106
107
|
* isChord: true, // Present if this is a chord [CEG]
|
|
107
|
-
* chordNotes: Array<NoteObject
|
|
108
|
+
* chordNotes: Array<NoteObject>, // All notes in the chord (when isChord=true)
|
|
109
|
+
* isGraceNote: true // Present if this is a grace note (has zero duration)
|
|
108
110
|
* }
|
|
109
111
|
*
|
|
110
112
|
* NoteObject structure (silence/rest):
|
|
@@ -238,6 +240,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
238
240
|
const barLines = [];
|
|
239
241
|
let currentBar = [];
|
|
240
242
|
let barCount = 0;
|
|
243
|
+
let previousRealNote = null; // Track last non-grace note for broken rhythms
|
|
241
244
|
|
|
242
245
|
// Split music text by bar lines while preserving positions
|
|
243
246
|
let lastBarPos = 0;
|
|
@@ -277,6 +280,34 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
277
280
|
tokenMatch.index + fullToken.length
|
|
278
281
|
);
|
|
279
282
|
|
|
283
|
+
// Check for grace notes
|
|
284
|
+
if (fullToken.startsWith("{")) {
|
|
285
|
+
const graceNotes = parseGraceNotes(fullToken);
|
|
286
|
+
if (graceNotes) {
|
|
287
|
+
// Add each grace note to the bar
|
|
288
|
+
graceNotes.forEach((graceNote, idx) => {
|
|
289
|
+
currentBar.push({
|
|
290
|
+
...graceNote,
|
|
291
|
+
token: fullToken,
|
|
292
|
+
sourceIndex: tokenStartPos,
|
|
293
|
+
sourceLength: fullToken.length,
|
|
294
|
+
// Only the last grace note gets the spacing
|
|
295
|
+
spacing:
|
|
296
|
+
idx === graceNotes.length - 1
|
|
297
|
+
? spacing
|
|
298
|
+
: {
|
|
299
|
+
whitespace: "",
|
|
300
|
+
backquotes: 0,
|
|
301
|
+
beamBreak: false,
|
|
302
|
+
lineBreak: false,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
// Grace notes don't update previousRealNote
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
280
311
|
// Check for inline field
|
|
281
312
|
const inlineField = parseInlineField(fullToken);
|
|
282
313
|
if (inlineField) {
|
|
@@ -308,65 +339,69 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
308
339
|
spacing,
|
|
309
340
|
};
|
|
310
341
|
currentBar.push(inlineFieldObj);
|
|
311
|
-
|
|
342
|
+
previousRealNote = null; // Inline fields break note sequences
|
|
312
343
|
continue;
|
|
313
344
|
}
|
|
314
345
|
|
|
315
346
|
// Check for broken rhythm
|
|
316
347
|
const brokenRhythm = parseBrokenRhythm(fullToken);
|
|
317
348
|
if (brokenRhythm) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// Parse the next note
|
|
331
|
-
const nextNote = parseNote(nextToken, unitLength, currentTuple);
|
|
332
|
-
if (nextNote && nextNote.duration && firstNote.duration) {
|
|
333
|
-
// Apply broken rhythm to both notes
|
|
334
|
-
applyBrokenRhythm(firstNote, nextNote, brokenRhythm);
|
|
335
|
-
|
|
336
|
-
//add the firstNote
|
|
337
|
-
currentBar.push({
|
|
338
|
-
...firstNote,
|
|
339
|
-
token: fullToken,
|
|
340
|
-
sourceIndex: tokenStartPos,
|
|
341
|
-
sourceLength: fullToken.length,
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// Add the broken rhythm marker to the bar
|
|
345
|
-
currentBar.push({
|
|
346
|
-
...brokenRhythm,
|
|
347
|
-
});
|
|
349
|
+
if (previousRealNote) {
|
|
350
|
+
// Find the next REAL note token (skip grace notes)
|
|
351
|
+
let nextTokenMatch = tokenRegex.exec(segment);
|
|
352
|
+
while (nextTokenMatch) {
|
|
353
|
+
const nextToken = nextTokenMatch[0];
|
|
354
|
+
// Skip grace notes
|
|
355
|
+
if (nextToken.startsWith("{")) {
|
|
356
|
+
nextTokenMatch = tokenRegex.exec(segment);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
348
361
|
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
362
|
+
if (nextTokenMatch) {
|
|
363
|
+
const nextToken = nextTokenMatch[0];
|
|
364
|
+
const nextTokenStartPos = lastBarPos + nextTokenMatch.index;
|
|
365
|
+
const nextSpacing = analyzeSpacing(
|
|
366
|
+
segment,
|
|
367
|
+
nextTokenMatch.index + nextToken.length
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Parse the next note
|
|
371
|
+
const nextNote = parseNote(nextToken, unitLength, currentTuple);
|
|
372
|
+
if (nextNote && nextNote.duration && previousRealNote.duration) {
|
|
373
|
+
// Apply broken rhythm to both notes
|
|
374
|
+
applyBrokenRhythm(previousRealNote, nextNote, brokenRhythm);
|
|
375
|
+
|
|
376
|
+
// Add the broken rhythm marker to the bar
|
|
377
|
+
currentBar.push({
|
|
378
|
+
...brokenRhythm,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Add the next note to the bar
|
|
382
|
+
const nextNoteObj = {
|
|
383
|
+
...nextNote,
|
|
384
|
+
token: nextToken,
|
|
385
|
+
sourceIndex: nextTokenStartPos,
|
|
386
|
+
sourceLength: nextToken.length,
|
|
387
|
+
spacing: nextSpacing,
|
|
388
|
+
};
|
|
389
|
+
currentBar.push(nextNoteObj);
|
|
390
|
+
previousRealNote = null; //can't have successive broken rhythms
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
360
393
|
}
|
|
361
394
|
}
|
|
362
395
|
// If we couldn't apply the broken rhythm, just skip it
|
|
363
|
-
|
|
396
|
+
previousRealNote = null;
|
|
364
397
|
continue;
|
|
365
398
|
}
|
|
366
399
|
|
|
367
400
|
// Tuplets
|
|
368
401
|
if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
|
|
369
|
-
const
|
|
402
|
+
const isCompound =
|
|
403
|
+
meter[1] === 8 && [6, 9, 12, 15, 18].indexOf(meter[0]) >= 0;
|
|
404
|
+
const tuple = parseTuplet(fullToken, isCompound);
|
|
370
405
|
if (tuple) {
|
|
371
406
|
if (currentTuple) {
|
|
372
407
|
throw new Error("nested tuples not handled");
|
|
@@ -378,7 +413,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
378
413
|
sourceIndex: tokenStartPos,
|
|
379
414
|
sourceLength: fullToken.length,
|
|
380
415
|
});
|
|
381
|
-
|
|
416
|
+
previousRealNote = null; // Tuplet markers break note sequences
|
|
382
417
|
continue;
|
|
383
418
|
}
|
|
384
419
|
}
|
|
@@ -393,7 +428,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
393
428
|
sourceLength: fullToken.length,
|
|
394
429
|
spacing,
|
|
395
430
|
});
|
|
396
|
-
// Chord symbols
|
|
431
|
+
// Chord symbols don't break note sequences
|
|
397
432
|
continue;
|
|
398
433
|
}
|
|
399
434
|
|
|
@@ -407,7 +442,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
407
442
|
sourceLength: fullToken.length,
|
|
408
443
|
spacing,
|
|
409
444
|
});
|
|
410
|
-
//
|
|
445
|
+
// Decorations don't break note sequences
|
|
411
446
|
continue;
|
|
412
447
|
}
|
|
413
448
|
|
|
@@ -422,8 +457,10 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
422
457
|
spacing,
|
|
423
458
|
};
|
|
424
459
|
currentBar.push(noteObj);
|
|
425
|
-
// Only track as previous note if it has duration (for broken rhythms)
|
|
426
|
-
|
|
460
|
+
// Only track as previous note if it has non-zero duration (for broken rhythms)
|
|
461
|
+
if (note.duration && note.duration.n !== 0) {
|
|
462
|
+
previousRealNote = noteObj;
|
|
463
|
+
}
|
|
427
464
|
}
|
|
428
465
|
}
|
|
429
466
|
}
|
|
@@ -457,6 +494,7 @@ function parseABCWithBars(abc, options = {}) {
|
|
|
457
494
|
bars.push(currentBar);
|
|
458
495
|
barCount++;
|
|
459
496
|
currentBar = [];
|
|
497
|
+
previousRealNote = null; // Bar lines break note sequences
|
|
460
498
|
|
|
461
499
|
// Check if we've reached max bars
|
|
462
500
|
if (barCount >= maxBars) {
|
package/src/parse/token-utils.js
CHANGED
|
@@ -13,6 +13,9 @@ const TokenRegexComponents = {
|
|
|
13
13
|
// Tuplet notation: (3, (3:2, (3:2:4
|
|
14
14
|
tuplet: String.raw`\(\d(?::\d?){0,2}`,
|
|
15
15
|
|
|
16
|
+
// Grace notes: {ABC}, {^AB_c}, etc.
|
|
17
|
+
graceNotes: String.raw`\{[^}]+\}`,
|
|
18
|
+
|
|
16
19
|
// Inline field changes: [K:D], [L:1/4], [M:3/4], [P:A]
|
|
17
20
|
inlineField: String.raw`\[(?:[KLMP]):[^\]]+\]`,
|
|
18
21
|
|
|
@@ -48,17 +51,19 @@ const TokenRegexComponents = {
|
|
|
48
51
|
// but we could have A16, or z10 (full bar rest for M:5/4; L:1/8)
|
|
49
52
|
duration: String.raw`[0-9]*\/?[0-9]*`,
|
|
50
53
|
|
|
51
|
-
// Tie (-)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// Tie (-) - optional
|
|
55
|
+
tie: String.raw`-?`,
|
|
56
|
+
|
|
57
|
+
// Broken rhythm (>, >>, >>>, <, <<, <<<)
|
|
58
|
+
broken: String.raw`<{1,3}|>{1,3}`,
|
|
54
59
|
};
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
62
|
* Get regex for matching ABC music tokens
|
|
58
63
|
* Built from documented components for maintainability
|
|
59
64
|
*
|
|
60
|
-
* Matches: tuplets, inline fields, chord symbols, notes, rests,
|
|
61
|
-
* decorations, ties, and broken rhythms
|
|
65
|
+
* Matches: tuplets, grace notes, inline fields, chord symbols, notes, rests,
|
|
66
|
+
* chords in brackets, decorations, ties, and broken rhythms
|
|
62
67
|
*
|
|
63
68
|
* @returns {RegExp} - Regular expression for tokenising ABC music
|
|
64
69
|
*/
|
|
@@ -72,16 +77,18 @@ const getTokenRegex = () => {
|
|
|
72
77
|
s.pitch +
|
|
73
78
|
s.octave +
|
|
74
79
|
s.duration +
|
|
75
|
-
s.
|
|
80
|
+
s.tie;
|
|
76
81
|
|
|
77
82
|
// Combine all patterns with alternation
|
|
78
83
|
const fullPattern = [
|
|
79
84
|
s.tuplet,
|
|
85
|
+
s.graceNotes,
|
|
80
86
|
s.inlineField,
|
|
81
87
|
s.quotedText,
|
|
82
88
|
//allow standalone bang and symbol decorations (?)
|
|
83
89
|
notePattern,
|
|
84
90
|
s.bangDecoration,
|
|
91
|
+
s.broken,
|
|
85
92
|
].join("|");
|
|
86
93
|
|
|
87
94
|
return new RegExp(fullPattern, "g");
|
package/src/sort/contour-svg.js
CHANGED
|
@@ -13,11 +13,19 @@ const { decodeChar } = require("./encode.js");
|
|
|
13
13
|
* @property {number} paddingBottom - Bottom padding in pixels
|
|
14
14
|
* @property {number} paddingLeft - Left padding in pixels
|
|
15
15
|
* @property {number} paddingRight - Right padding in pixels
|
|
16
|
-
* @property {boolean} forceBaseline -
|
|
16
|
+
* @property {boolean} forceBaseline - Ensures the baseline at zero is displayed
|
|
17
17
|
* @property {number|null} minDegree - Minimum degree for vertical range (null for auto)
|
|
18
18
|
* @property {number|null} maxDegree - Maximum degree for vertical range (null for auto)
|
|
19
19
|
* @property {string} class - CSS class name for the SVG element
|
|
20
20
|
* @property {string} ariaLabel - Accessible label for screen readers
|
|
21
|
+
* @property {number} noteStartRadius - Radius of the circle marking note starts (played notes only)
|
|
22
|
+
* @property {boolean} onlyShowMeaningfulStartOfPlayedNotes - If true, only show start markers when previous note is same pitch; if false, show on all played notes
|
|
23
|
+
* @property {boolean} showYAxis - Whether to display the Y axis
|
|
24
|
+
* @property {string} yAxisColor - Colour for the Y axis line
|
|
25
|
+
* @property {number} yAxisWidth - Width of the Y axis line
|
|
26
|
+
* @property {number} yAxisTickLength - Length of regular ticks (for 5th degree markers)
|
|
27
|
+
* @property {number} yAxisTonicTickLength - Length of tonic ticks (for tonic degree markers)
|
|
28
|
+
* @property {number} yAxisTickWidth - Width of tick marks
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -25,35 +33,42 @@ const { decodeChar } = require("./encode.js");
|
|
|
25
33
|
* @type {SvgConfig}
|
|
26
34
|
*/
|
|
27
35
|
const contourToSvg_defaultConfig = {
|
|
28
|
-
degreeHeight: 5,
|
|
29
|
-
paddingTop:
|
|
30
|
-
paddingBottom:
|
|
31
|
-
strokeWidth: 2,
|
|
36
|
+
degreeHeight: 5,
|
|
37
|
+
paddingTop: 3,
|
|
38
|
+
paddingBottom: 3,
|
|
39
|
+
strokeWidth: 2,
|
|
32
40
|
unitWidth: 15,
|
|
33
|
-
// degreeHeight: 12,
|
|
34
|
-
// strokeWidth: 3,
|
|
35
41
|
playedColor: "#2563eb", // blue
|
|
36
|
-
heldColor: "#
|
|
42
|
+
heldColor: "#2563eb", // same as played (no longer lighter blue)
|
|
37
43
|
baselineColor: "#555555", // Davy's grey
|
|
38
|
-
// paddingTop: 20,
|
|
39
|
-
// paddingBottom: 20,
|
|
40
44
|
paddingLeft: 10,
|
|
41
45
|
paddingRight: 10,
|
|
42
|
-
minDegree: null,
|
|
43
|
-
maxDegree: null,
|
|
46
|
+
minDegree: null,
|
|
47
|
+
maxDegree: null,
|
|
44
48
|
forceBaseline: true,
|
|
45
49
|
class: "contour-svg",
|
|
46
50
|
ariaLabel: "Tune contour",
|
|
51
|
+
noteStartRadius: 3,
|
|
52
|
+
onlyShowMeaningfulStartOfPlayedNotes: true,
|
|
53
|
+
showYAxis: true,
|
|
54
|
+
yAxisColor: "#888888",
|
|
55
|
+
yAxisWidth: 1,
|
|
56
|
+
yAxisTickLength: 4,
|
|
57
|
+
yAxisTonicTickLength: 6,
|
|
58
|
+
yAxisTickWidth: 1,
|
|
47
59
|
};
|
|
48
60
|
|
|
49
61
|
/**
|
|
50
|
-
* Converts a tune contour object into an SVG
|
|
62
|
+
* Converts a tune contour object into an SVG visualisation
|
|
51
63
|
*
|
|
52
64
|
* The SVG represents the melodic contour as a series of connected horizontal line segments.
|
|
53
65
|
* Each segment's vertical position corresponds to its modal degree (pitch relative to tonic),
|
|
54
66
|
* and its horizontal length represents its duration relative to the common subdivision of the beat.
|
|
55
67
|
* The resulting SVG is landscape-oriented (wider than tall).
|
|
56
68
|
*
|
|
69
|
+
* Played notes are marked with a small filled circle at their start point.
|
|
70
|
+
* Held notes (continuations of the same pitch) have no start marker.
|
|
71
|
+
*
|
|
57
72
|
* Silences are not drawn but occupy space on the x-axis, causing subsequent notes to be
|
|
58
73
|
* positioned after the cumulative duration of preceding silences.
|
|
59
74
|
*
|
|
@@ -75,7 +90,7 @@ const contourToSvg_defaultConfig = {
|
|
|
75
90
|
* // With fixed vertical range for comparing multiple contours
|
|
76
91
|
* const svg = contourToSvg(contour, { minDegree: -15, maxDegree: 15 });
|
|
77
92
|
*
|
|
78
|
-
* // Auto range
|
|
93
|
+
* // Auto range - calculates from the contour's actual pitch range
|
|
79
94
|
* const svg2 = contourToSvg(contour, {
|
|
80
95
|
* minDegree: null,
|
|
81
96
|
* maxDegree: null
|
|
@@ -87,7 +102,7 @@ const contourToSvg_defaultConfig = {
|
|
|
87
102
|
* // You can also set just one bound
|
|
88
103
|
* const svg3 = contourToSvg(contour, {
|
|
89
104
|
* minDegree: -15,
|
|
90
|
-
* maxDegree: null //maxDegree will auto-calculate
|
|
105
|
+
* maxDegree: null // maxDegree will auto-calculate
|
|
91
106
|
* });
|
|
92
107
|
*/
|
|
93
108
|
function contourToSvg(contour, svgConfig = {}) {
|
|
@@ -156,7 +171,11 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
156
171
|
const svgWidth = totalWidth + config.paddingLeft + config.paddingRight;
|
|
157
172
|
const svgHeight = chartHeight + config.paddingTop + config.paddingBottom;
|
|
158
173
|
|
|
159
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Converts a modal degree position to Y coordinate in SVG space
|
|
176
|
+
* @param {number} pos - Modal degree position
|
|
177
|
+
* @returns {number} Y coordinate
|
|
178
|
+
*/
|
|
160
179
|
const positionToY = (pos) => {
|
|
161
180
|
const relativePos = maxPosition - pos;
|
|
162
181
|
return config.paddingTop + relativePos * config.degreeHeight;
|
|
@@ -165,6 +184,47 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
165
184
|
// Build SVG path elements
|
|
166
185
|
const pathElements = [];
|
|
167
186
|
|
|
187
|
+
// Add Y axis if configured
|
|
188
|
+
if (config.showYAxis) {
|
|
189
|
+
const yAxisX = config.paddingLeft - 5;
|
|
190
|
+
const yAxisTop = positionToY(maxPosition);
|
|
191
|
+
const yAxisBottom = positionToY(minPosition);
|
|
192
|
+
|
|
193
|
+
pathElements.push(
|
|
194
|
+
`<line x1="${yAxisX}" y1="${yAxisTop}" ` +
|
|
195
|
+
`x2="${yAxisX}" y2="${yAxisBottom}" ` +
|
|
196
|
+
`stroke="${config.yAxisColor}" stroke-width="${config.yAxisWidth}" />`
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Add tick marks for positions within range
|
|
200
|
+
// Regular ticks at positions: ..., -11, -4, 4, 11, 18, ... (5th degree in each octave)
|
|
201
|
+
// Tonic ticks at positions: ..., -14, -7, 0, 7, 14, ... (tonic in each octave)
|
|
202
|
+
const minPos = Math.floor(minPosition);
|
|
203
|
+
const maxPos = Math.ceil(maxPosition);
|
|
204
|
+
|
|
205
|
+
for (let pos = minPos; pos <= maxPos; pos++) {
|
|
206
|
+
let tickLength = 0;
|
|
207
|
+
|
|
208
|
+
// Check if this is a tonic position (0, ±7, ±14, ...)
|
|
209
|
+
if (pos % 7 === 0) {
|
|
210
|
+
tickLength = config.yAxisTonicTickLength;
|
|
211
|
+
}
|
|
212
|
+
// Check if this is a 5th position (±4, ±11, ±18, ...)
|
|
213
|
+
else if (pos % 7 === 4 || pos % 7 === -3) {
|
|
214
|
+
tickLength = config.yAxisTickLength;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (tickLength > 0) {
|
|
218
|
+
const tickY = positionToY(pos);
|
|
219
|
+
pathElements.push(
|
|
220
|
+
`<line x1="${yAxisX - tickLength}" y1="${tickY}" ` +
|
|
221
|
+
`x2="${yAxisX}" y2="${tickY}" ` +
|
|
222
|
+
`stroke="${config.yAxisColor}" stroke-width="${config.yAxisTickWidth}" />`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
168
228
|
// Add baseline at position 0 if it's within range
|
|
169
229
|
if (minPosition <= 0 && maxPosition >= 0) {
|
|
170
230
|
const baselineY = positionToY(0);
|
|
@@ -196,6 +256,39 @@ function contourToSvg(contour, svgConfig = {}) {
|
|
|
196
256
|
`stroke-linecap="round" />`
|
|
197
257
|
);
|
|
198
258
|
|
|
259
|
+
// Add note start marker (filled circle) for played notes
|
|
260
|
+
// If onlyShowMeaningfulStartOfPlayedNotes is true, only show when previous note is same pitch
|
|
261
|
+
if (!seg.isHeld) {
|
|
262
|
+
let showStartMarker = true;
|
|
263
|
+
|
|
264
|
+
if (config.onlyShowMeaningfulStartOfPlayedNotes) {
|
|
265
|
+
// Find previous non-silence segment
|
|
266
|
+
let prevNonSilenceIdx = i - 1;
|
|
267
|
+
while (
|
|
268
|
+
prevNonSilenceIdx >= 0 &&
|
|
269
|
+
segments[prevNonSilenceIdx].isSilence
|
|
270
|
+
) {
|
|
271
|
+
prevNonSilenceIdx--;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Only show marker if previous note exists and is at same position
|
|
275
|
+
if (prevNonSilenceIdx >= 0) {
|
|
276
|
+
const prevSeg = segments[prevNonSilenceIdx];
|
|
277
|
+
showStartMarker = prevSeg.position === seg.position;
|
|
278
|
+
} else {
|
|
279
|
+
// First note: don't show marker
|
|
280
|
+
showStartMarker = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (showStartMarker) {
|
|
285
|
+
pathElements.push(
|
|
286
|
+
`<circle cx="${x1}" cy="${y}" r="${config.noteStartRadius}" ` +
|
|
287
|
+
`fill="${color}" />`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
199
292
|
// Add connecting vertical line to next non-silence segment if position changes
|
|
200
293
|
let nextNonSilenceIdx = i + 1;
|
|
201
294
|
while (
|