@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goplayerjuggler/abc-tools",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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", "");
@@ -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 and line continuation marker
112
+ // Remove inline comments
104
113
  trimmed = trimmed.replace(/\s*%.*$/, "").trim();
105
- trimmed = trimmed.replace(/\\\s*$/, "").trim();
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
  };
@@ -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,
@@ -459,4 +539,5 @@ module.exports = {
459
539
  parseTuplet,
460
540
  parseBrokenRhythm,
461
541
  applyBrokenRhythm,
542
+ parseGraceNotes,
462
543
  };
@@ -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> // All notes in the chord (when isChord=true)
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
- // previousNote = null; // Inline fields break note sequences
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
- 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
- });
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
- // 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;
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
- // previousNote = null;
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 tuple = parseTuplet(fullToken);
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
- // previousNote = null; // Tuplet markers break note sequences
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 do note break note sequences - leave previousNote
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
- // Chord symbols do note break note sequences - leave previousNote
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
- // previousNote = note.duration ? noteObj : null;
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) {
@@ -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 (-) 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}`,
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, chords in brackets,
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.tieOrBroken;
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");
@@ -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 (