@goplayerjuggler/abc-tools 1.0.6 → 1.0.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "sorting algorithm and implementation for ABC tunes; plus other tools for parsing and manipulating ABC tunes",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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
- // Strip out continuations
58
- theTextIncipit = theTextIncipit.replaceAll("\\", "");
57
+ // keep continuations!
58
+ // theTextIncipit = theTextIncipit.replaceAll("\\", "");
59
59
 
60
60
  // Segno
61
61
  theTextIncipit = theTextIncipit.replaceAll("S", "");
@@ -219,9 +219,8 @@ function StripChordsOne(theNotes) {
219
219
  }
220
220
 
221
221
  function sanitise(theTune) {
222
- let j,
223
- k,
224
- theTextIncipits = [];
222
+ let j, k;
223
+ const theTextIncipits = [];
225
224
  // Strip out annotations
226
225
  theTune = StripAnnotationsOneForIncipits(theTune);
227
226
 
@@ -247,38 +246,26 @@ function sanitise(theTune) {
247
246
  break;
248
247
  }
249
248
  }
250
- // Find the L: parameter
251
- let theL = "";
252
249
 
253
- for (j = 0; j < nLines; ++j) {
254
- theL = theLines[j];
255
-
256
- if (theL.indexOf("L:") !== -1) {
257
- break;
258
- }
259
- }
260
- // Find the M: parameter
261
- let theM = "";
262
-
263
- for (j = 0; j < nLines; ++j) {
264
- theM = theLines[j];
250
+ const unitLength = getUnitLength(theTune);
251
+ const meter = getMeter(theTune),
252
+ theM = meter ? `${meter[0]}/${meter[1]}` : "none";
265
253
 
266
- if (theM.indexOf("M:") !== -1) {
267
- break;
268
- }
269
- }
270
254
  // Use at most the first three lines following the header K:
271
255
  let added = 0;
272
256
  for (k = indexOfTheKey + 1; k < nLines; ++k) {
273
257
  const theTextIncipit = theLines[k];
274
-
258
+ if (theTextIncipit.match(/^\s*%%/)) continue; // skip lines starting with %%
275
259
  // Clean out the incipit line of any annotations besides notes and bar lines
276
260
  theTextIncipits.push(cleanIncipitLine(theTextIncipit));
277
261
  added++;
262
+
278
263
  if (added === 3) break;
279
264
  }
280
265
 
281
- return `X:1\n${theM}\n${theL}\n${theKey}\n${theTextIncipits.join("\n")}`;
266
+ return `X:1\nM:${theM}\nL:1/${
267
+ unitLength.den
268
+ }\n${theKey}\n${theTextIncipits.join("\n")}`;
282
269
  }
283
270
 
284
271
  /**
@@ -333,7 +320,7 @@ function getContourFromFullAbc(abc) {
333
320
  if (abc.length === 0) return null;
334
321
  abc = abc[0];
335
322
  }
336
- return getContour(getIncipitForContourGeneration(abc));
323
+ return getContour(getIncipitForContourGeneration(abc), { withSvg: true });
337
324
  }
338
325
 
339
326
  module.exports = {
@@ -1,6 +1,6 @@
1
1
  const { Fraction } = require("./math.js");
2
2
  const {
3
- parseABCWithBars,
3
+ parseAbc,
4
4
  getMeter,
5
5
  calculateBarDurations,
6
6
  } = require("./parse/parser.js");
@@ -43,6 +43,7 @@ function normaliseKey(keyHeader) {
43
43
  major: "major",
44
44
  ion: "major",
45
45
  ionian: "major",
46
+ m: "minor",
46
47
  min: "minor",
47
48
  minor: "minor",
48
49
  aeo: "minor",
@@ -91,7 +92,7 @@ function filterHeaders(headerLines, headersToStrip) {
91
92
 
92
93
  /**
93
94
  * Detect if ABC notation has an anacrusis (pickup bar)
94
- * @param {object} parsed - Parsed ABC data from parseABCWithBars
95
+ * @param {object} parsed - Parsed ABC data from parseAbc
95
96
  * @returns {boolean} - True if anacrusis is present
96
97
  */
97
98
  function hasAnacrucisFromParsed(parsed) {
@@ -112,7 +113,7 @@ function hasAnacrucisFromParsed(parsed) {
112
113
  * @returns {boolean} - True if anacrusis is present
113
114
  */
114
115
  function hasAnacrucis(abc) {
115
- const parsed = parseABCWithBars(abc, { maxBars: 2 });
116
+ const parsed = parseAbc(abc, { maxBars: 2 });
116
117
  return hasAnacrucisFromParsed(parsed);
117
118
  }
118
119
  /**
@@ -188,7 +189,7 @@ function toggleMeterDoubling(abc, smallMeter, largeMeter) {
188
189
  abc = abc.replaceAll(/([^\s])([[:]?\|)/g, "$1 $2");
189
190
  }
190
191
 
191
- const parsed = parseABCWithBars(abc);
192
+ const parsed = parseAbc(abc);
192
193
  const { headerLines, barLines, musicText } = parsed;
193
194
 
194
195
  // Change meter in headers
@@ -326,7 +327,7 @@ function getFirstBars(
326
327
  Math.ceil(numBarsFraction.num / numBarsFraction.den) + 2;
327
328
 
328
329
  // Parse with estimated maxBars
329
- const parsed = parseABCWithBars(abc, { maxBars: estimatedMaxBars });
330
+ const parsed = parseAbc(abc, { maxBars: estimatedMaxBars });
330
331
  const { bars, headerLines, barLines, musicText, meter } = parsed;
331
332
 
332
333
  const barDurations = calculateBarDurations(parsed);
@@ -17,99 +17,48 @@
17
17
  * Classify bar line type
18
18
  *
19
19
  * @param {string} barLineStr - Bar line string from ABC notation
20
- * @returns {object} - Classification with type, text, and properties
20
+ * @returns {object} - parsed barline with text, and properties
21
21
  *
22
22
  * Return object structure:
23
23
  * {
24
- * type: string, // 'regular', 'double', 'final', 'repeat-start', 'repeat-end', 'repeat-both', 'repeat-ending', 'other'
25
24
  * text: string, // Original bar line string
26
- * isRepeat: boolean, // Whether this bar line involves repeats
27
- * ending?: number // For repeat-ending type, which ending (1-6)
25
+ * trimmed: string, // trimmed bar line string
26
+ * isSectionBreak, // double bars and ends of repeats are section breaks
27
+ * isRepeatL: boolean,
28
+ * // true iff there’s a repeat to the left of the bar line; if not the property is omitted
29
+ * // indicates the end of a repeated section
30
+ *
31
+ * isRepeatR: boolean,
32
+ * // true iff there’s a repeat to the rightt of the bar line; if not the property is omitted
33
+ * // indicates the start of a repeated section
34
+ *
28
35
  * }
36
+ *
37
+ *
29
38
  */
30
- function classifyBarLine(barLineStr) {
39
+ function parseBarLine(barLineStr) {
31
40
  const trimmed = barLineStr.trim();
32
-
33
- // Repeat endings
34
- if (trimmed.match(/^\|[1-6]$/)) {
35
- return {
36
- type: "repeat-ending",
37
- ending: parseInt(trimmed[1]),
38
- text: barLineStr,
39
- isRepeat: true,
40
- };
41
- }
42
-
41
+ const result = {
42
+ text: barLineStr,
43
+ trimmed,
44
+ };
43
45
  // Start repeat
44
- if (trimmed.match(/^\|:/) || trimmed.match(/^\[\|/)) {
45
- return {
46
- type: "repeat-start",
47
- text: barLineStr,
48
- isRepeat: true,
49
- };
46
+ if (trimmed.match(/:$/)) {
47
+ result.isRepeatR = true;
50
48
  }
51
-
52
49
  // End repeat
53
- if (
54
- trimmed.match(/^:\|/) ||
55
- (trimmed.match(/^\|\]/) && !trimmed.match(/^\|\]$/))
56
- ) {
57
- return {
58
- type: "repeat-end",
59
- text: barLineStr,
60
- isRepeat: true,
61
- };
62
- }
63
-
64
- // Double repeat
65
- if (
66
- trimmed.match(/^::/) ||
67
- trimmed.match(/^:\|:/) ||
68
- trimmed.match(/^::\|:?/) ||
69
- trimmed.match(/^::\|\|:?/)
70
- ) {
71
- return {
72
- type: "repeat-both",
73
- text: barLineStr,
74
- isRepeat: true,
75
- };
76
- }
77
-
78
- // Final bar
79
- if (trimmed === "|]") {
80
- return {
81
- type: "final",
82
- text: barLineStr,
83
- isRepeat: false,
84
- };
50
+ if (trimmed.match(/^:/)) {
51
+ result.isRepeatL = true;
52
+ result.isSectionBreak = true
85
53
  }
86
54
 
87
55
  // Double bar
88
- if (trimmed === "||") {
89
- return {
90
- type: "double",
91
- text: barLineStr,
92
- isRepeat: false,
93
- };
56
+ if (trimmed.match(/\|\|/)){
57
+ result.isSectionBreak = true;
94
58
  }
95
-
96
- // Regular bar
97
- if (trimmed === "|") {
98
- return {
99
- type: "regular",
100
- text: barLineStr,
101
- isRepeat: false,
102
- };
103
- }
104
-
105
- // Unknown/complex bar line
106
- return {
107
- type: "other",
108
- text: barLineStr,
109
- isRepeat: trimmed.includes(":"),
110
- };
59
+ return result;
111
60
  }
112
61
 
113
62
  module.exports = {
114
- classifyBarLine,
63
+ parseBarLine,
115
64
  };
@@ -38,7 +38,9 @@ function getMeter(abc) {
38
38
  if (meterMatch) {
39
39
  return [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
40
40
  }
41
- return [4, 4]; // Default to 4/4
41
+ if (abc.match(/^M:\s*C\|/m)) return [2, 2];
42
+ if (abc.match(/^M:\s*C/m)) return [4, 4];
43
+ return null;
42
44
  }
43
45
 
44
46
  /**
@@ -54,6 +56,15 @@ function getUnitLength(abc) {
54
56
  }
55
57
  return new Fraction(1, 8); // Default to 1/8
56
58
  }
59
+ /**
60
+ * Extract titles - there may be 0..N titles
61
+ *
62
+ * @param {string} abc - ABC notation string
63
+ * @returns {[string]} - array of titles
64
+ */
65
+ function getTitles(abc) {
66
+ return [...abc.matchAll(/^(?:T:\s*.(.+)\n)/gm)];
67
+ }
57
68
 
58
69
  /**
59
70
  * Process ABC lines: extract music lines with metadata
@@ -100,9 +111,11 @@ function getMusicLines(abc) {
100
111
  // Check for line continuation
101
112
  const hasContinuation = trimmed.match(/\\\s*(%|$)/) !== null;
102
113
 
103
- // Remove inline comments and line continuation marker
114
+ // Remove inline comments
104
115
  trimmed = trimmed.replace(/\s*%.*$/, "").trim();
105
- trimmed = trimmed.replace(/\\\s*$/, "").trim();
116
+
117
+ // Do *not* remove continuations: `\` at the end of a line
118
+ // trimmed = trimmed.replace(/\\\s*$/, "").trim();
106
119
 
107
120
  if (trimmed) {
108
121
  musicLines.push(trimmed);
@@ -137,4 +150,5 @@ module.exports = {
137
150
  getMeter,
138
151
  getUnitLength,
139
152
  getMusicLines,
153
+ getTitles,
140
154
  };
@@ -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(/^(.+?)\s*(<{1,3}|>{1,3})$/);
295
+ const brokenMatch = token.match(/^(<{1,3}|>{1,3})$/);
214
296
  if (brokenMatch) {
215
- const firstNoteToken = brokenMatch[1],
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,
@@ -389,64 +469,6 @@ function parseNote(noteStr, unitLength, currentTuple) {
389
469
  return result;
390
470
  }
391
471
 
392
- /**
393
- * Parse tuplet notation from a token
394
- *
395
- * @param {string} token - Tuplet token (e.g., '(3', '(3:2', '(3:2:4')
396
- * @param {boolean} isCompoundTimeSignature - Whether current time signature is compound (affects default q value)
397
- * @returns {object|null} - { isTuple: true, p, q, r } or null if not a valid tuplet
398
- */
399
- function parseTuplet(token, isCompoundTimeSignature) {
400
- const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
401
- if (tupleMatch) {
402
- const pqr = {
403
- p: parseInt(tupleMatch[1]),
404
- q: tupleMatch[2],
405
- r: tupleMatch[3],
406
- };
407
- const { p } = pqr;
408
- let { q, r } = pqr;
409
- if (q) {
410
- q = parseInt(q);
411
- } else {
412
- switch (p) {
413
- case 2:
414
- q = 3;
415
- break;
416
- case 3:
417
- q = 2;
418
- break;
419
- case 4:
420
- q = 3;
421
- break;
422
- case 5:
423
- case 7:
424
- case 9:
425
- q = isCompoundTimeSignature ? 3 : 2;
426
- break;
427
- case 6:
428
- q = 2;
429
- break;
430
- case 8:
431
- q = 3;
432
- break;
433
- }
434
- }
435
- if (r) {
436
- r = parseInt(r);
437
- } else {
438
- r = p;
439
- }
440
- return {
441
- isTuple: true,
442
- p,
443
- q,
444
- r,
445
- };
446
- }
447
- return null;
448
- }
449
-
450
472
  module.exports = {
451
473
  parseDecorations,
452
474
  parseChordSymbol,
@@ -456,7 +478,7 @@ module.exports = {
456
478
  getDuration,
457
479
  parseChord,
458
480
  parseNote,
459
- parseTuplet,
460
481
  parseBrokenRhythm,
461
482
  applyBrokenRhythm,
483
+ parseGraceNotes,
462
484
  };
@@ -7,15 +7,16 @@ const {
7
7
  } = require("./header-parser.js");
8
8
  const {
9
9
  parseNote,
10
- parseTuplet,
11
10
  parseBrokenRhythm,
12
11
  applyBrokenRhythm,
12
+ parseGraceNotes,
13
13
  } = require("./note-parser.js");
14
- const { classifyBarLine } = require("./barline-parser.js");
14
+ const { parseBarLine } = require("./barline-parser.js");
15
15
  const {
16
+ analyzeSpacing,
16
17
  getTokenRegex,
17
18
  parseInlineField,
18
- analyzeSpacing,
19
+ parseTuplet,
19
20
  } = require("./token-utils.js");
20
21
 
21
22
  // ============================================================================
@@ -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
@@ -59,7 +60,7 @@ const {
59
60
  // ============================================================================
60
61
 
61
62
  /**
62
- * Parse ABC into structured data with bars
63
+ * Parse ABC into structured data
63
64
  *
64
65
  * @param {string} abc - ABC notation string
65
66
  * @param {object} options - Parsing options
@@ -68,18 +69,18 @@ const {
68
69
  *
69
70
  * Returns object with:
70
71
  * {
71
- * bars: Array<Array<NoteObject>>, // Array of bars, each bar is array of notes/chords/fields
72
+ * bars: Array<Array<ScoreObject>>, // Array of bars, each bar is array of ScoreObjects
73
+ * // A Score object is almost anything that isn’t a bar line: note/chord/field/broken rhythm/tuplet/1st or 2nd repeat or variant ending
72
74
  * barLines: Array<BarLineObject>, // Array of bar line information
73
75
  * unitLength: Fraction, // The L: field value (default 1/8)
74
76
  * meter: [number, number], // The M: field value (default [4,4])
75
- * tonalBase: string, // The tonic from K: field (e.g., 'D', 'G')
76
77
  * lineMetadata: Array<LineMetadata>,// Info about original lines (comments, continuations)
77
78
  * headerLines: Array<string>, // Original header lines
78
79
  * headerEndIndex: number, // Index where headers end
79
80
  * musicText: string // Processed music text (headers removed)
80
81
  * }
81
82
  *
82
- * NoteObject structure (regular note):
83
+ * ScoreObject structure (normal note):
83
84
  * {
84
85
  * pitch: string, // 'A'-'G' (uppercase for low octave, lowercase for middle)
85
86
  * octave: number, // Relative octave offset (0 = middle, +1 = high, -1 = low)
@@ -104,10 +105,11 @@ const {
104
105
  * text: string
105
106
  * },
106
107
  * isChord: true, // Present if this is a chord [CEG]
107
- * chordNotes: Array<NoteObject> // All notes in the chord (when isChord=true)
108
+ * chordNotes: Array<ScoreObject>, // 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
- * NoteObject structure (silence/rest):
112
+ * ScoreObject structure (silence/rest):
111
113
  * {
112
114
  * isSilence: true,
113
115
  * duration: Fraction,
@@ -118,7 +120,7 @@ const {
118
120
  * // Optional: decorations, chordSymbol, annotation (same as above)
119
121
  * }
120
122
  *
121
- * NoteObject structure (dummy note):
123
+ * ScoreObject structure (dummy note):
122
124
  * {
123
125
  * isDummy: true,
124
126
  * duration: Fraction,
@@ -128,7 +130,7 @@ const {
128
130
  * spacing: { ... }
129
131
  * }
130
132
  *
131
- * NoteObject structure (inline field change):
133
+ * ScoreObject structure (inline field change):
132
134
  * {
133
135
  * isInlineField: true,
134
136
  * field: string, // 'K', 'L', 'M', or 'P'
@@ -139,7 +141,7 @@ const {
139
141
  * spacing: { ... }
140
142
  * }
141
143
  *
142
- * NoteObject structure (standalone chord symbol):
144
+ * ScoreObject structure (standalone chord symbol):
143
145
  * {
144
146
  * isChordSymbol: true,
145
147
  * chordSymbol: string, // The chord name
@@ -149,7 +151,7 @@ const {
149
151
  * spacing: { ... }
150
152
  * }
151
153
  *
152
- * Tuplet structure:
154
+ * ScoreObject structure (Tuplet):
153
155
  * {
154
156
  * isTuple: true,
155
157
  * p: number, // Tuplet ratio numerator
@@ -157,29 +159,35 @@ const {
157
159
  * r: number, // Number of notes in tuplet
158
160
  * token: string,
159
161
  * sourceIndex: number,
160
- * sourceLength: number
162
+ * sourceLength: number,
163
+ * spacing: { ... }
161
164
  * }
162
165
  *
163
- * BrokenRhythm structure:
166
+ * ScoreObject structure ((BrokenRhythm):
164
167
  * {
165
168
  * isBrokenRhythm: true,
166
169
  * direction: string, // '>' or '<'
167
170
  * dots: number, // 1, 2, or 3
168
171
  * token: string,
169
172
  * sourceIndex: number,
170
- * sourceLength: number
173
+ * sourceLength: number,
174
+ * spacing: { ... }
171
175
  * }
172
176
  *
173
177
  * BarLineObject structure:
174
178
  * {
175
- * type: string, // 'regular', 'double', 'final', 'repeat-start', etc.
176
179
  * text: string, // Original bar line string
177
- * isRepeat: boolean, // Whether this involves repeats
180
+ * trimmed: string, // trimmed bar line string
181
+ * isSectionBreak, // double bars and repeats are section breaks
182
+ * isRepeatL: boolean,
183
+ * // true iff there’s a repeat to the left of the bar line; if not the property is omitted
184
+ * // indicates the end of a repeated section
185
+ * isRepeatR: boolean,
186
+ * // true iff there’s a repeat to the right of the bar line; if not the property is omitted
187
+ * // indicates the start of a repeated section
178
188
  * sourceIndex: number, // Position in musicText
179
189
  * sourceLength: number, // Length of bar line
180
- * barNumber: number, // Which bar this terminates (0-indexed)
181
190
  * hasLineBreak: boolean, // Whether there's a newline after this bar line
182
- * ending?: number // For repeat-ending type, which ending (1-6)
183
191
  * }
184
192
  *
185
193
  * LineMetadata structure:
@@ -192,7 +200,7 @@ const {
192
200
  * }
193
201
  *
194
202
  * Example:
195
- * parseABCWithBars('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
203
+ * parseAbc('X:1\nL:1/4\nK:D\n"Dm"D2 [DF]A | ~B4 |]')
196
204
  * // Returns:
197
205
  * {
198
206
  * bars: [
@@ -209,16 +217,14 @@ const {
209
217
  * barLines: [...],
210
218
  * unitLength: Fraction(1,4),
211
219
  * meter: [4,4],
212
- * tonalBase: 'D',
213
220
  * lineMetadata: [...]
214
221
  * }
215
222
  */
216
- function parseABCWithBars(abc, options = {}) {
223
+ function parseAbc(abc, options = {}) {
217
224
  const { maxBars = Infinity } = options;
218
225
 
219
226
  let unitLength = getUnitLength(abc);
220
227
  let meter = getMeter(abc);
221
- let tonalBase = getTonalBase(abc);
222
228
 
223
229
  const {
224
230
  musicText,
@@ -231,13 +237,19 @@ function parseABCWithBars(abc, options = {}) {
231
237
  // Create a Set of newline positions for O(1) lookup
232
238
  const newlineSet = new Set(newlinePositions);
233
239
 
234
- // Comprehensive bar line regex - includes trailing spaces
235
- const barLineRegex = /(\|\||\|\]|\[\|\]|(\|:?)|(:?\|)|:\|\|:) */g;
240
+ /*
241
+ Bar line regex - includes trailing spaces; does not include: 1st & 2nd repeats and variant endings - tokenised by tokenRegex below
242
+ From the spec: "Abc parsers should be quite liberal in recognizing bar lines. In the wild, bar lines may have any shape, using a sequence of | (thin bar line), [ or ] (thick bar line), and : (dots), e.g. |[| or [|:::"
243
+ All the following bar lines are legal, given with most frequently seen first - according to my limited knowledge of what’s out there. AFAIK all but the last 4 are quite commonly seen.
244
+ `|`, `:|`, `|:`, `|]`, `||`, `:||:`, `:|:`, `::`, `[|`, `[|]`, `.|`
245
+ */
246
+ const barLineRegex = /:*\.*[|[\]]*(?:\||::+)[|[\]]*:* */g; //captures expressions with at least one `|`, or at least two `:`.
236
247
 
237
248
  const bars = [];
238
249
  const barLines = [];
239
250
  let currentBar = [];
240
251
  let barCount = 0;
252
+ let previousRealNote = null; // Track last non-grace note for broken rhythms
241
253
 
242
254
  // Split music text by bar lines while preserving positions
243
255
  let lastBarPos = 0;
@@ -254,6 +266,7 @@ function parseABCWithBars(abc, options = {}) {
254
266
  barLinePos: match.index,
255
267
  };
256
268
 
269
+ if (lastBarPos > 0) lastBarPos--; //the last character in a barline expression may be needed to match variant endings - eg `|1`
257
270
  // Process segment before this bar line
258
271
  const segment = musicText.substring(lastBarPos, barLinePos);
259
272
 
@@ -277,6 +290,47 @@ function parseABCWithBars(abc, options = {}) {
277
290
  tokenMatch.index + fullToken.length
278
291
  );
279
292
 
293
+ if (fullToken.match(getTokenRegex({ variantEndings: true }))) {
294
+ const variantEnding = {
295
+ isVariantEnding: true,
296
+ token: fullToken,
297
+ sourceIndex: tokenStartPos,
298
+ sourceLength: fullToken.length,
299
+ spacing,
300
+ };
301
+ currentBar.push(variantEnding);
302
+
303
+ continue;
304
+ }
305
+
306
+ // Check for grace notes
307
+ if (fullToken.startsWith("{")) {
308
+ const graceNotes = parseGraceNotes(fullToken);
309
+ if (graceNotes) {
310
+ // Add each grace note to the bar
311
+ graceNotes.forEach((graceNote, idx) => {
312
+ currentBar.push({
313
+ ...graceNote,
314
+ token: fullToken,
315
+ sourceIndex: tokenStartPos,
316
+ sourceLength: fullToken.length,
317
+ // Only the last grace note gets the spacing
318
+ spacing:
319
+ idx === graceNotes.length - 1
320
+ ? spacing
321
+ : {
322
+ whitespace: "",
323
+ backquotes: 0,
324
+ beamBreak: false,
325
+ lineBreak: false,
326
+ },
327
+ });
328
+ });
329
+ // Grace notes don't update previousRealNote
330
+ continue;
331
+ }
332
+ }
333
+
280
334
  // Check for inline field
281
335
  const inlineField = parseInlineField(fullToken);
282
336
  if (inlineField) {
@@ -291,11 +345,6 @@ function parseABCWithBars(abc, options = {}) {
291
345
  if (meterMatch) {
292
346
  meter = [parseInt(meterMatch[1]), parseInt(meterMatch[2])];
293
347
  }
294
- } else if (inlineField.field === "K") {
295
- const keyMatch = inlineField.value.match(/^([A-G])/);
296
- if (keyMatch) {
297
- tonalBase = keyMatch[1].toUpperCase();
298
- }
299
348
  }
300
349
 
301
350
  const inlineFieldObj = {
@@ -308,65 +357,71 @@ function parseABCWithBars(abc, options = {}) {
308
357
  spacing,
309
358
  };
310
359
  currentBar.push(inlineFieldObj);
311
- // previousNote = null; // Inline fields break note sequences
360
+ previousRealNote = null; // Inline fields break note sequences
312
361
  continue;
313
362
  }
314
363
 
315
364
  // Check for broken rhythm
316
365
  const brokenRhythm = parseBrokenRhythm(fullToken);
317
366
  if (brokenRhythm) {
318
- const { firstNoteToken } = brokenRhythm;
319
- const firstNote = parseNote(firstNoteToken, unitLength, currentTuple);
320
- // Find the next note token
321
- const nextTokenMatch = tokenRegex.exec(segment);
322
- if (nextTokenMatch) {
323
- const nextToken = nextTokenMatch[0];
324
- const nextTokenStartPos = lastBarPos + nextTokenMatch.index;
325
- const nextSpacing = analyzeSpacing(
326
- segment,
327
- nextTokenMatch.index + nextToken.length
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
- });
367
+ if (previousRealNote) {
368
+ // Find the next REAL note token (skip grace notes)
369
+ let nextTokenMatch = tokenRegex.exec(segment);
370
+ while (nextTokenMatch) {
371
+ const nextToken = nextTokenMatch[0];
372
+ // Skip grace notes
373
+ if (nextToken.startsWith("{")) {
374
+ nextTokenMatch = tokenRegex.exec(segment);
375
+ continue;
376
+ }
377
+ break;
378
+ }
348
379
 
349
- // Add the next note to the bar
350
- const nextNoteObj = {
351
- ...nextNote,
352
- token: nextToken,
353
- sourceIndex: nextTokenStartPos,
354
- sourceLength: nextToken.length,
355
- spacing: nextSpacing,
356
- };
357
- currentBar.push(nextNoteObj);
358
- // previousNote = nextNoteObj;
359
- continue;
380
+ if (nextTokenMatch) {
381
+ const nextToken = nextTokenMatch[0];
382
+ const nextTokenStartPos = lastBarPos + nextTokenMatch.index;
383
+ const nextSpacing = analyzeSpacing(
384
+ segment,
385
+ nextTokenMatch.index + nextToken.length
386
+ );
387
+
388
+ // Parse the next note
389
+ const nextNote = parseNote(nextToken, unitLength, currentTuple);
390
+ if (nextNote && nextNote.duration && previousRealNote.duration) {
391
+ // Apply broken rhythm to both notes
392
+ applyBrokenRhythm(previousRealNote, nextNote, brokenRhythm);
393
+
394
+ // Add the broken rhythm marker to the bar
395
+ currentBar.push({
396
+ ...brokenRhythm,
397
+ });
398
+
399
+ // Add the next note to the bar
400
+ const nextNoteObj = {
401
+ ...nextNote,
402
+ token: nextToken,
403
+ sourceIndex: nextTokenStartPos,
404
+ sourceLength: nextToken.length,
405
+ spacing: nextSpacing,
406
+ };
407
+ currentBar.push(nextNoteObj);
408
+ previousRealNote = null; //can't have successive broken rhythms
409
+ continue;
410
+ }
360
411
  }
361
412
  }
362
413
  // If we couldn't apply the broken rhythm, just skip it
363
- // previousNote = null;
414
+ previousRealNote = null;
364
415
  continue;
365
416
  }
366
417
 
367
418
  // Tuplets
368
419
  if (fullToken.match(/\(\d(?::\d?){0,2}/g)) {
369
- const tuple = parseTuplet(fullToken);
420
+ const isCompound =
421
+ meter &&
422
+ meter[1] === 8 &&
423
+ [6, 9, 12, 15, 18].indexOf(meter[0]) >= 0;
424
+ const tuple = parseTuplet(fullToken, isCompound);
370
425
  if (tuple) {
371
426
  if (currentTuple) {
372
427
  throw new Error("nested tuples not handled");
@@ -378,7 +433,7 @@ function parseABCWithBars(abc, options = {}) {
378
433
  sourceIndex: tokenStartPos,
379
434
  sourceLength: fullToken.length,
380
435
  });
381
- // previousNote = null; // Tuplet markers break note sequences
436
+ previousRealNote = null; // Tuplet markers break note sequences
382
437
  continue;
383
438
  }
384
439
  }
@@ -393,7 +448,7 @@ function parseABCWithBars(abc, options = {}) {
393
448
  sourceLength: fullToken.length,
394
449
  spacing,
395
450
  });
396
- // Chord symbols do note break note sequences - leave previousNote
451
+ // Chord symbols don't break note sequences
397
452
  continue;
398
453
  }
399
454
 
@@ -407,7 +462,7 @@ function parseABCWithBars(abc, options = {}) {
407
462
  sourceLength: fullToken.length,
408
463
  spacing,
409
464
  });
410
- // Chord symbols do note break note sequences - leave previousNote
465
+ // Decorations don't break note sequences
411
466
  continue;
412
467
  }
413
468
 
@@ -422,8 +477,10 @@ function parseABCWithBars(abc, options = {}) {
422
477
  spacing,
423
478
  };
424
479
  currentBar.push(noteObj);
425
- // Only track as previous note if it has duration (for broken rhythms)
426
- // previousNote = note.duration ? noteObj : null;
480
+ // Only track as previous note if it has non-zero duration (for broken rhythms)
481
+ if (note.duration && note.duration.n !== 0) {
482
+ previousRealNote = noteObj;
483
+ }
427
484
  }
428
485
  }
429
486
  }
@@ -435,7 +492,7 @@ function parseABCWithBars(abc, options = {}) {
435
492
  (barLineEndPos < musicText.length && musicText[barLineEndPos] === "\n");
436
493
 
437
494
  // Store bar line information
438
- const barLineInfo = classifyBarLine(barLineText);
495
+ const barLineInfo = parseBarLine(barLineText);
439
496
  barLines.push({
440
497
  ...barLineInfo,
441
498
  sourceIndex: barLinePos,
@@ -457,6 +514,7 @@ function parseABCWithBars(abc, options = {}) {
457
514
  bars.push(currentBar);
458
515
  barCount++;
459
516
  currentBar = [];
517
+ previousRealNote = null; // Bar lines break note sequences
460
518
 
461
519
  // Check if we've reached max bars
462
520
  if (barCount >= maxBars) {
@@ -477,7 +535,6 @@ function parseABCWithBars(abc, options = {}) {
477
535
  barLines,
478
536
  unitLength,
479
537
  meter,
480
- tonalBase,
481
538
  lineMetadata,
482
539
  headerLines,
483
540
  headerEndIndex,
@@ -489,7 +546,7 @@ function parseABCWithBars(abc, options = {}) {
489
546
  * Calculate bar durations from parsed ABC data
490
547
  * Returns duration for each bar as a Fraction
491
548
  *
492
- * @param {object} parsedData - Output from parseABCWithBars
549
+ * @param {object} parsedData - Output from parseAbc
493
550
  * @returns {Array<Fraction>} - Array of bar durations
494
551
  */
495
552
  function calculateBarDurations(parsedData) {
@@ -558,7 +615,7 @@ function getTunes(text) {
558
615
 
559
616
  module.exports = {
560
617
  getTunes,
561
- parseABCWithBars,
618
+ parseAbc,
562
619
  calculateBarDurations,
563
620
  // Re-export utilities for convenience
564
621
  getTonalBase,
@@ -566,5 +623,5 @@ module.exports = {
566
623
  getUnitLength,
567
624
  getMusicLines,
568
625
  analyzeSpacing,
569
- classifyBarLine,
626
+ parseBarLine,
570
627
  };
@@ -6,15 +6,18 @@
6
6
  // - Token regex generation
7
7
  // - Inline field parsing
8
8
  // - Whitespace and beaming analysis
9
- //
9
+ // - Bar lines are not captured, but variant endings and 1st and 2nd repeats are captured
10
10
  // ============================================================================
11
11
 
12
12
  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
- inlineField: String.raw`\[(?:[KLMP]):[^\]]+\]`,
20
+ inlineField: String.raw`\[([KLMP]):\s*([^\]]+)\]`,
18
21
 
19
22
  // Text in quotes: chord symbols "Dm7" or annotations "^text"
20
23
  quotedText: String.raw`"[^"]+"`,
@@ -48,22 +51,41 @@ 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 (-) or broken rhythm (>, >>, >>>, <, <<, <<<)
52
- // Optional: may have neither, or may have whitespace before broken rhythm
53
- tieOrBroken: String.raw`(?:-|\s*(?:<{1,3}|>{1,3}))?`,
54
+ // Tie (-) - optional
55
+ tie: String.raw`-?`,
56
+
57
+ // Broken rhythm (>, >>, >>>, <, <<, <<<)
58
+ broken: String.raw`<{1,3}|>{1,3}`,
59
+
60
+ // variant ending - including one way of writing 1st and second repeats. Only handle single digits.
61
+ // examples: [1 [2 [3 [4 [1-3 [1-3,5-7
62
+ variantEnding: String.raw`\[\d(?:-\d)?(?:,\d(?:-\d)?)*`,
63
+
64
+ // 1st & 2nd repeats. Only handle single digits.
65
+ // examples: `|1`, `|2`
66
+ // Note that the other syntax, `[1`, `[2`, will be captured by the variantEnding component
67
+ repeat_1Or2: String.raw`\|[12]`,
54
68
  };
55
69
 
56
70
  /**
71
+ *
57
72
  * Get regex for matching ABC music tokens
58
- * Built from documented components for maintainability
59
73
  *
60
- * Matches: tuplets, inline fields, chord symbols, notes, rests, chords in brackets,
61
- * decorations, ties, and broken rhythms
74
+ * Matches: tuplets, grace notes, inline fields, chord symbols, notes, rests,
75
+ * chords in brackets, decorations, ties, broken rhythms, and variant endings including 1st and 2nd repeats
62
76
  *
77
+ * @param {object} options -
78
+ * when options.variantEndings is flagged, the returned regex just matches the next variant ending / 1st or 2nd repeat
79
+ * when options.inlineField is flagged, the returned regex just matches the next inline field
63
80
  * @returns {RegExp} - Regular expression for tokenising ABC music
64
81
  */
65
- const getTokenRegex = () => {
82
+ const getTokenRegex = (options = {}) => {
66
83
  const s = TokenRegexComponents;
84
+ if (options) {
85
+ if (options.variantEndings)
86
+ return new RegExp(`^${s.variantEnding}|${s.repeat_1Or2}$`);
87
+ if (options.inlineField) return new RegExp(`^$${s.inlineField}$`);
88
+ }
67
89
  // Complete note/rest/chord pattern with optional leading decoration
68
90
  const notePattern =
69
91
  s.bangDecorations() +
@@ -72,16 +94,20 @@ const getTokenRegex = () => {
72
94
  s.pitch +
73
95
  s.octave +
74
96
  s.duration +
75
- s.tieOrBroken;
97
+ s.tie;
76
98
 
77
99
  // Combine all patterns with alternation
78
100
  const fullPattern = [
79
101
  s.tuplet,
102
+ s.graceNotes,
80
103
  s.inlineField,
81
104
  s.quotedText,
82
105
  //allow standalone bang and symbol decorations (?)
83
106
  notePattern,
84
107
  s.bangDecoration,
108
+ s.broken,
109
+ s.variantEnding,
110
+ s.repeat_1Or2,
85
111
  ].join("|");
86
112
 
87
113
  return new RegExp(fullPattern, "g");
@@ -101,7 +127,7 @@ const getTokenRegex = () => {
101
127
  * - [P:...] - Part marker
102
128
  */
103
129
  function parseInlineField(token) {
104
- const fieldMatch = token.match(/^\[([KLMP]):\s*([^\]]+)\]$/);
130
+ const fieldMatch = token.match(getTokenRegex({ inlineField: true }));
105
131
  if (fieldMatch) {
106
132
  return {
107
133
  field: fieldMatch[1],
@@ -168,8 +194,66 @@ function analyzeSpacing(segment, tokenEndPos) {
168
194
  };
169
195
  }
170
196
 
197
+ /**
198
+ * Parse tuplet notation from a token
199
+ *
200
+ * @param {string} token - Tuplet token (e.g., '(3', '(3:2', '(3:2:4')
201
+ * @param {boolean} isCompoundTimeSignature - Whether current time signature is compound (affects default q value)
202
+ * @returns {object|null} - { isTuple: true, p, q, r } or null if not a valid tuplet
203
+ */
204
+ function parseTuplet(token, isCompoundTimeSignature) {
205
+ const tupleMatch = token.match(/^\(([2-9])(?::(\d)?)?(?::(\d)?)?$/);
206
+ if (tupleMatch) {
207
+ const pqr = {
208
+ p: parseInt(tupleMatch[1]),
209
+ q: tupleMatch[2],
210
+ r: tupleMatch[3],
211
+ };
212
+ const { p } = pqr;
213
+ let { q, r } = pqr;
214
+ if (q) {
215
+ q = parseInt(q);
216
+ } else {
217
+ switch (p) {
218
+ case 2:
219
+ q = 3;
220
+ break;
221
+ case 3:
222
+ q = 2;
223
+ break;
224
+ case 4:
225
+ q = 3;
226
+ break;
227
+ case 5:
228
+ case 7:
229
+ case 9:
230
+ q = isCompoundTimeSignature ? 3 : 2;
231
+ break;
232
+ case 6:
233
+ q = 2;
234
+ break;
235
+ case 8:
236
+ q = 3;
237
+ break;
238
+ }
239
+ }
240
+ if (r) {
241
+ r = parseInt(r);
242
+ } else {
243
+ r = p;
244
+ }
245
+ return {
246
+ isTuple: true,
247
+ p,
248
+ q,
249
+ r,
250
+ };
251
+ }
252
+ return null;
253
+ }
171
254
  module.exports = {
255
+ analyzeSpacing,
172
256
  getTokenRegex,
173
257
  parseInlineField,
174
- analyzeSpacing,
258
+ parseTuplet,
175
259
  };
@@ -2,7 +2,7 @@ const { Fraction } = require("../math.js");
2
2
  const {
3
3
  getTonalBase,
4
4
  getUnitLength,
5
- parseABCWithBars,
5
+ parseAbc,
6
6
  getMeter,
7
7
  } = require("../parse/parser.js");
8
8
 
@@ -38,8 +38,10 @@ function getContour(
38
38
  const unitLength = getUnitLength(abc);
39
39
  const maxDuration = unitLength.multiply(maxNbUnitLengths);
40
40
  const meter = getMeter(abc);
41
- const maxNbBars = maxDuration.divide(new Fraction(meter[0], meter[1]));
42
- const { bars } = parseABCWithBars(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, {
43
45
  maxBars: Math.ceil(maxNbBars.toNumber()),
44
46
  });
45
47
  let cumulatedDuration = new Fraction(0, 1);
@@ -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 - ensures the baseline at zero is displayed
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, // much smaller (default is 12)
29
- paddingTop: 1, // less padding (default is 20)
30
- paddingBottom: 1, // less padding (default is 20)
31
- strokeWidth: 2, // slightly thinner lines (default is 3)
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: "#93c5fd", // lighter blue (held notes)
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, // null means auto-calculate from contour
43
- maxDegree: null, // null means auto-calculate from contour
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 visualization
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 - calculates from the contour's actual pitch 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
- // Helper function to convert position to Y coordinate
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 (