@k-l-lambda/lilylet 0.1.66 → 0.1.68

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.
@@ -4,22 +4,28 @@
4
4
  * Converts ABC notation files to Lilylet's internal LilyletDoc format.
5
5
  */
6
6
  import { LilyletDoc } from "./types";
7
+ /**
8
+ * Decode an ABC tune into a LilyletDoc
9
+ */
10
+ export interface DecodeOptions {
11
+ catalogComments?: boolean;
12
+ }
7
13
  /**
8
14
  * Decode ABC notation string to LilyletDoc.
9
15
  * If the ABC contains multiple tunes, only the first is decoded.
10
16
  */
11
- export declare const decode: (abcString: string) => LilyletDoc;
17
+ export declare const decode: (abcString: string, options?: DecodeOptions) => LilyletDoc;
12
18
  /**
13
19
  * Decode ABC notation string to multiple LilyletDocs (one per tune).
14
20
  */
15
- export declare const decodeAll: (abcString: string) => LilyletDoc[];
21
+ export declare const decodeAll: (abcString: string, options?: DecodeOptions) => LilyletDoc[];
16
22
  /**
17
23
  * Decode an ABC file to LilyletDoc
18
24
  */
19
- export declare const decodeFile: (filePath: string) => Promise<LilyletDoc>;
25
+ export declare const decodeFile: (filePath: string, options?: DecodeOptions) => Promise<LilyletDoc>;
20
26
  declare const _default: {
21
- decode: (abcString: string) => LilyletDoc;
22
- decodeAll: (abcString: string) => LilyletDoc[];
23
- decodeFile: (filePath: string) => Promise<LilyletDoc>;
27
+ decode: (abcString: string, options?: DecodeOptions) => LilyletDoc;
28
+ decodeAll: (abcString: string, options?: DecodeOptions) => LilyletDoc[];
29
+ decodeFile: (filePath: string, options?: DecodeOptions) => Promise<LilyletDoc>;
24
30
  };
25
31
  export default _default;
@@ -4,8 +4,35 @@
4
4
  * Converts ABC notation files to Lilylet's internal LilyletDoc format.
5
5
  */
6
6
  import parse from "../abc/parser.js";
7
- import { Phonet, Accidental, Clef, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
7
+ import { Phonet, Accidental, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
8
8
  // ============ Constants ============
9
+ // NotaGen catalog tags appear as leading single-% comments in ABC files,
10
+ // in the order: period, composer, instrumentation. They are mapped to
11
+ // Lilylet metadata: period -> genre, instrumentation -> instrument.
12
+ const NOTAGEN_PERIOD_SET = new Set([
13
+ "Baroque", "Classical", "Romantic",
14
+ ]);
15
+ const NOTAGEN_INSTRUMENTATION_SET = new Set([
16
+ "Art Song", "Chamber", "Choral", "Keyboard", "Orchestral", "Vocal-Orchestral",
17
+ ]);
18
+ const NOTAGEN_COMPOSER_SET = new Set([
19
+ "Bach, Johann Sebastian", "Bartok, Bela", "Beethoven, Ludwig van", "Berlioz, Hector",
20
+ "Bizet, Georges", "Boulanger, Lili", "Boulton, Harold", "Brahms, Johannes",
21
+ "Burgmuller, Friedrich", "Butterworth, George", "Chaminade, Cecile", "Chausson, Ernest",
22
+ "Chopin, Frederic", "Corelli, Arcangelo", "Cornelius, Peter", "Debussy, Claude",
23
+ "Dvorak, Antonin", "Faisst, Clara", "Faure, Gabriel", "Franz, Robert",
24
+ "Gonzaga, Chiquinha", "Grandval, Clemence de", "Grieg, Edvard", "Handel, George Frideric",
25
+ "Haydn, Joseph", "Hensel, Fanny", "Holmes, Augusta Mary Anne", "Jaell, Marie",
26
+ "Kinkel, Johanna", "Kralik, Mathilde", "Lang, Josephine", "Lehmann, Liza",
27
+ "Liszt, Franz", "Mayer, Emilie", "Medtner, Nikolay", "Mendelssohn, Felix",
28
+ "Mozart, Wolfgang Amadeus", "Munktell, Helena", "Paradis, Maria Theresia von",
29
+ "Parratt, Walter", "Prokofiev, Sergey", "Rachmaninoff, Sergei", "Ravel, Maurice",
30
+ "Reichardt, Louise", "Saint-Georges, Joseph Bologne", "Saint-Saens, Camille",
31
+ "Satie, Erik", "Scarlatti, Domenico", "Schroter, Corona", "Schubert, Franz",
32
+ "Schumann, Clara", "Schumann, Robert", "Scriabin, Aleksandr", "Shostakovich, Dmitry",
33
+ "Sibelius, Jean", "Smetana, Bedrich", "Tchaikovsky, Pyotr", "Viardot, Pauline",
34
+ "Vivaldi, Antonio", "Warlock, Peter", "Wolf, Hugo", "Zumsteeg, Emilie",
35
+ ]);
9
36
  const ABC_PHONET_MAP = {
10
37
  "C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
11
38
  "c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
@@ -64,7 +91,7 @@ const convertAccidental = (acc) => {
64
91
  * Uppercase C-B = octave 0, lowercase c-b = octave 1
65
92
  * quotes (from ' and ,) add/subtract octaves
66
93
  */
67
- const convertPitch = (abcPitch) => {
94
+ const convertPitch = (abcPitch, ctx) => {
68
95
  const phonet = ABC_PHONET_MAP[abcPitch.phonet];
69
96
  if (!phonet) {
70
97
  throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
@@ -74,9 +101,38 @@ const convertPitch = (abcPitch) => {
74
101
  const baseOctave = isLower ? 1 : 0;
75
102
  const octave = baseOctave + (abcPitch.quotes || 0);
76
103
  const pitch = { phonet, octave };
77
- const accidental = convertAccidental(abcPitch.acc);
78
- if (accidental) {
79
- pitch.accidental = accidental;
104
+ // Resolve the effective accidental.
105
+ const explicit = convertAccidental(abcPitch.acc); // accidental written on this note
106
+ const hasExplicit = abcPitch.acc !== null && abcPitch.acc !== undefined;
107
+ if (ctx) {
108
+ const memKey = `${phonet}:${octave}`;
109
+ if (hasExplicit) {
110
+ // Explicit accidental (incl. natural) overrides and is remembered for the measure.
111
+ if (abcPitch.acc === 0) {
112
+ ctx.measureAccidentals.set(memKey, "natural");
113
+ // natural cancels the key alteration → no accidental on the lilylet pitch
114
+ }
115
+ else if (explicit) {
116
+ ctx.measureAccidentals.set(memKey, explicit);
117
+ pitch.accidental = explicit;
118
+ }
119
+ }
120
+ else {
121
+ // No explicit accidental: inherit from in-measure memory, else the key signature.
122
+ const remembered = ctx.measureAccidentals.get(memKey);
123
+ if (remembered !== undefined) {
124
+ if (remembered !== "natural")
125
+ pitch.accidental = remembered;
126
+ }
127
+ else {
128
+ const fromKey = ctx.keyAlterations.get(phonet);
129
+ if (fromKey)
130
+ pitch.accidental = fromKey;
131
+ }
132
+ }
133
+ }
134
+ else if (explicit) {
135
+ pitch.accidental = explicit;
80
136
  }
81
137
  return pitch;
82
138
  };
@@ -233,17 +289,71 @@ const convertKeySignature = (abcKey) => {
233
289
  mode: (abcKey.mode === "minor" || abcKey.mode === "min") ? "minor" : "major",
234
290
  };
235
291
  };
292
+ // Circle-of-fifths: a lilylet key (pitch+accidental+mode) → set of letters the key
293
+ // signature alters, and the direction (sharp/flat). ABC note letters inherit these
294
+ // alterations implicitly (e.g. K:Eb makes B sound as Bb); lilylet pitches are absolute,
295
+ // so the decoder must bake the alteration into each pitch's accidental.
296
+ const SHARP_ORDER = [Phonet.f, Phonet.c, Phonet.g, Phonet.d, Phonet.a, Phonet.e, Phonet.b];
297
+ const FLAT_ORDER = [Phonet.b, Phonet.e, Phonet.a, Phonet.d, Phonet.g, Phonet.c, Phonet.f];
298
+ const KEY_NAME_TO_FIFTHS = {
299
+ c: 0, g: 1, d: 2, a: 3, e: 4, b: 5, "f#": 6, "c#": 7,
300
+ f: -1, bb: -2, eb: -3, ab: -4, db: -5, gb: -6, cb: -7,
301
+ };
302
+ // Compute the number of sharps/flats (fifths) for a resolved KeySignature.
303
+ const keySignatureFifths = (key) => {
304
+ let letter = key.pitch;
305
+ if (key.accidental === Accidental.sharp)
306
+ letter += "#";
307
+ else if (key.accidental === Accidental.flat)
308
+ letter += "b";
309
+ let fifths = KEY_NAME_TO_FIFTHS[letter];
310
+ if (fifths === undefined)
311
+ fifths = 0;
312
+ // Minor keys share the fifths of their relative major (3 fifths up).
313
+ if (key.mode === "minor")
314
+ fifths += 3;
315
+ return fifths;
316
+ };
317
+ // Map of phonet → accidental implied by the key signature (only altered letters present).
318
+ const keySignatureAlterations = (key) => {
319
+ const fifths = keySignatureFifths(key);
320
+ const map = new Map();
321
+ if (fifths > 0) {
322
+ for (let i = 0; i < fifths && i < SHARP_ORDER.length; i++)
323
+ map.set(SHARP_ORDER[i], Accidental.sharp);
324
+ }
325
+ else if (fifths < 0) {
326
+ for (let i = 0; i < -fifths && i < FLAT_ORDER.length; i++)
327
+ map.set(FLAT_ORDER[i], Accidental.flat);
328
+ }
329
+ return map;
330
+ };
236
331
  /**
237
332
  * Convert ABC clef string to Lilylet Clef
238
333
  */
239
334
  const convertClef = (clefStr) => {
240
- switch (clefStr?.toLowerCase()) {
241
- case "treble": return Clef.treble;
242
- case "bass": return Clef.bass;
335
+ // Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
336
+ // ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
337
+ // LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
338
+ const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
339
+ const base = shift ? shift[1] : clefStr;
340
+ let resolved;
341
+ switch (base?.toLowerCase()) {
342
+ case "treble":
343
+ resolved = "treble";
344
+ break;
345
+ case "bass":
346
+ resolved = "bass";
347
+ break;
243
348
  case "alto":
244
- case "tenor": return Clef.alto;
349
+ case "tenor":
350
+ resolved = "alto";
351
+ break;
245
352
  default: return undefined;
246
353
  }
354
+ if (shift)
355
+ resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
356
+ return resolved;
247
357
  };
248
358
  /**
249
359
  * Convert ABC barline to Lilylet barline style
@@ -267,11 +377,41 @@ const convertBarline = (bar) => {
267
377
  return "|";
268
378
  }
269
379
  };
270
- /**
271
- * Parse %%score layout to determine voice→(part, staff) mapping.
272
- * {(...) | (...)} = one part with two staves
273
- * (...) = voices sharing one staff
274
- */
380
+ // All voice numbers under a node, flattened.
381
+ const collectScoreVoices = (node) => {
382
+ if (typeof node === "string") {
383
+ const v = parseInt(node, 10);
384
+ return isNaN(v) ? [] : [v];
385
+ }
386
+ const voices = [];
387
+ for (const item of node.items || []) {
388
+ voices.push(...collectScoreVoices(item));
389
+ }
390
+ return voices;
391
+ };
392
+ // Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
393
+ const scoreNodeToParts = (node) => {
394
+ if (typeof node === "string") {
395
+ const v = parseInt(node, 10);
396
+ return isNaN(v) ? [] : [[[v]]];
397
+ }
398
+ if (node.bound === "square") {
399
+ // each child is its own part
400
+ const parts = [];
401
+ for (const child of node.items || []) {
402
+ parts.push(...scoreNodeToParts(child));
403
+ }
404
+ return parts;
405
+ }
406
+ if (node.bound === "curly") {
407
+ // one part, each child is a separate staff (grand staff)
408
+ const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
409
+ return staves.length > 0 ? [staves] : [];
410
+ }
411
+ // arc or no bound: one part, all voices on a single staff
412
+ const voices = collectScoreVoices(node);
413
+ return voices.length > 0 ? [[voices]] : [];
414
+ };
275
415
  const parseScoreLayout = (headers) => {
276
416
  const layoutHeader = headers.find((h) => h.staffLayout);
277
417
  if (!layoutHeader)
@@ -279,86 +419,15 @@ const parseScoreLayout = (headers) => {
279
419
  const layout = layoutHeader.staffLayout;
280
420
  const voiceMap = new Map();
281
421
  let partIndex = 0;
282
- for (const group of layout) {
283
- if (group.bound === "curly") {
284
- // Curly braces = one instrument/part with multiple staves
285
- let staffInPart = 1;
286
- for (const item of group.items) {
287
- if (typeof item === "string") {
288
- voiceMap.set(parseInt(item), { partIndex, staffInPart });
422
+ for (const top of layout) {
423
+ for (const part of scoreNodeToParts(top)) {
424
+ part.forEach((staff, staffIdx) => {
425
+ for (const voice of staff) {
426
+ voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
289
427
  }
290
- else if (item.items) {
291
- const sg = item;
292
- for (const subItem of sg.items) {
293
- if (typeof subItem === "string") {
294
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
295
- }
296
- else if (subItem.items) {
297
- for (const leaf of subItem.items) {
298
- if (typeof leaf === "string") {
299
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
300
- }
301
- }
302
- }
303
- }
304
- staffInPart++;
305
- }
306
- }
307
- partIndex++;
308
- }
309
- else if (group.bound === "arc" || !group.bound) {
310
- // Arc or plain = voices sharing a staff in same part
311
- for (const item of group.items) {
312
- if (typeof item === "string") {
313
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
314
- }
315
- else if (item.items) {
316
- for (const subItem of item.items) {
317
- if (typeof subItem === "string") {
318
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
319
- }
320
- }
321
- }
322
- }
428
+ });
323
429
  partIndex++;
324
430
  }
325
- else {
326
- // Square bracket or unknown - treat each item as separate part
327
- for (const item of group.items) {
328
- if (typeof item === "string") {
329
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
330
- partIndex++;
331
- }
332
- else if (item.items) {
333
- const sg = item;
334
- if (sg.bound === "curly") {
335
- let staffInPart = 1;
336
- for (const subItem of sg.items) {
337
- if (typeof subItem === "string") {
338
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
339
- }
340
- else if (subItem.items) {
341
- for (const leaf of subItem.items) {
342
- if (typeof leaf === "string") {
343
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
344
- }
345
- }
346
- staffInPart++;
347
- }
348
- }
349
- partIndex++;
350
- }
351
- else {
352
- for (const subItem of sg.items) {
353
- if (typeof subItem === "string") {
354
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
355
- }
356
- }
357
- partIndex++;
358
- }
359
- }
360
- }
361
- }
362
431
  }
363
432
  return voiceMap.size > 0 ? voiceMap : null;
364
433
  };
@@ -491,13 +560,17 @@ const processExpressiveTerm = (term, pendingMarks, pendingContextChanges, slurDe
491
560
  /**
492
561
  * Process a single ABC BarPatch (one voice's content for one measure) into events.
493
562
  */
494
- const processBarPatch = (patch, unitLength, slurDepth) => {
563
+ const processBarPatch = (patch, unitLength, slurDepth, pitchCtx) => {
495
564
  const events = [];
496
565
  const terms = patch.terms || [];
497
566
  const pendingMarks = [];
498
567
  const pendingContextChanges = [];
499
568
  // Collect all events first, then handle broken rhythms and tuplets
500
569
  const rawNoteRests = [];
570
+ // A broken-rhythm marker (>, <) belongs to the LEFT note but modifies the pair
571
+ // (left, right). We can only apply it once the right note has been pushed, so we
572
+ // stash it here and resolve it when the next note/rest arrives.
573
+ let pendingBroken = null;
501
574
  let i = 0;
502
575
  while (i < terms.length) {
503
576
  const term = terms[i];
@@ -512,10 +585,19 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
512
585
  }
513
586
  }
514
587
  else if (ctrl.value?.root) {
588
+ const newKey = convertKeySignature(ctrl.value);
515
589
  events.push({
516
590
  type: "context",
517
- key: convertKeySignature(ctrl.value),
591
+ key: newKey,
518
592
  });
593
+ // Update the active key alterations and clear in-measure accidentals.
594
+ if (pitchCtx) {
595
+ const alt = keySignatureAlterations(newKey);
596
+ pitchCtx.keyAlterations.clear();
597
+ for (const [k, v] of alt)
598
+ pitchCtx.keyAlterations.set(k, v);
599
+ pitchCtx.measureAccidentals.clear();
600
+ }
519
601
  }
520
602
  }
521
603
  else if (ctrl.name === "M") {
@@ -554,7 +636,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
554
636
  while (j < terms.length && collected < r) {
555
637
  const nextTerm = terms[j];
556
638
  if (nextTerm.event) {
557
- const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges);
639
+ const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
558
640
  if (evt) {
559
641
  // Push any pending context changes before tuplet
560
642
  for (const ctx of pendingContextChanges.splice(0)) {
@@ -613,10 +695,25 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
613
695
  // Text
614
696
  if (term.text !== undefined) {
615
697
  const text = term.text;
616
- // Check if it's a tempo/expression marking
698
+ // ABC chord/annotation text: a leading ^ / _ marks placement above / below;
699
+ // other position prefixes (<,>,@) just denote placement we don't model, so strip.
700
+ let placement;
701
+ let content = text;
617
702
  if (text.startsWith("^")) {
618
- // Markup text above staff
703
+ placement = Placement.above;
704
+ content = text.slice(1);
619
705
  }
706
+ else if (text.startsWith("_")) {
707
+ placement = Placement.below;
708
+ content = text.slice(1);
709
+ }
710
+ else if (/^[<>@]/.test(text)) {
711
+ content = text.slice(1);
712
+ }
713
+ const markup = { type: "markup", content };
714
+ if (placement)
715
+ markup.placement = placement;
716
+ events.push(markup);
620
717
  i++;
621
718
  continue;
622
719
  }
@@ -627,7 +724,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
627
724
  for (const ctx of pendingContextChanges.splice(0)) {
628
725
  events.push(ctx);
629
726
  }
630
- const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
727
+ const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
631
728
  if (evt) {
632
729
  if (Array.isArray(evt)) {
633
730
  events.push(...evt);
@@ -635,12 +732,18 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
635
732
  else {
636
733
  events.push(evt);
637
734
  }
638
- // Track broken rhythm
639
- if (eventTerm.broken) {
735
+ // Resolve a broken-rhythm marker carried by the PREVIOUS note: it modifies
736
+ // the pair (previous note, this note). Apply now that this (the right) note exists.
737
+ if (pendingBroken !== null) {
640
738
  const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest");
641
739
  if (noteRestEvents.length >= 2) {
642
- applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, eventTerm.broken);
740
+ applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
643
741
  }
742
+ pendingBroken = null;
743
+ }
744
+ // Stash this note's own broken marker (the right note is not parsed yet).
745
+ if (eventTerm.broken) {
746
+ pendingBroken = eventTerm.broken;
644
747
  }
645
748
  }
646
749
  i++;
@@ -686,7 +789,7 @@ const getDefaultTupletMultiplier = (p) => {
686
789
  /**
687
790
  * Convert a single ABC EventTerm to Lilylet event(s)
688
791
  */
689
- const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges) => {
792
+ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx) => {
690
793
  const eventData = eventTerm.event;
691
794
  if (!eventData)
692
795
  return undefined;
@@ -707,12 +810,20 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
707
810
  if (firstPitch.phonet === "Z") {
708
811
  rest.fullMeasure = true;
709
812
  }
710
- // Consume pending marks (attach to rest if any)
813
+ // A dynamic that precedes a rest (e.g. ABC "!p! z") has no note to attach to.
814
+ // Emit it as a standalone leading DynamicEvent before the rest; lilylet attaches
815
+ // it to the following sounding event. Other marks on a rest are dropped.
816
+ const leadingDynamics = pendingMarks
817
+ .filter(m => m.markType === "dynamic")
818
+ .map(m => ({ type: "dynamic", dynamicType: m.type }));
711
819
  pendingMarks.length = 0;
820
+ if (leadingDynamics.length > 0) {
821
+ return [...leadingDynamics, rest];
822
+ }
712
823
  return rest;
713
824
  }
714
825
  // Note or chord
715
- const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(convertPitch);
826
+ const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(p => convertPitch(p, pitchCtx));
716
827
  if (pitches.length === 0)
717
828
  return undefined;
718
829
  const duration = convertDuration(eventData.duration, unitLength);
@@ -766,11 +877,7 @@ const convertGraceEvents = (graceTerm, unitLength) => {
766
877
  }
767
878
  return events;
768
879
  };
769
- // ============ Main Decoder ============
770
- /**
771
- * Decode an ABC tune into a LilyletDoc
772
- */
773
- const decodeTune = (tune) => {
880
+ const decodeTune = (tune, options = {}) => {
774
881
  const headers = tune.header;
775
882
  const body = tune.body;
776
883
  // Extract header fields
@@ -790,8 +897,19 @@ const decodeTune = (tune) => {
790
897
  }
791
898
  }
792
899
  for (const h of headers) {
793
- if (h.comment)
900
+ const comment = h.comment;
901
+ if (typeof comment === "string") {
902
+ if (options.catalogComments) {
903
+ const value = comment.trim();
904
+ if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value))
905
+ metadata.genre = value;
906
+ else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value))
907
+ metadata.instrument = value;
908
+ else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value))
909
+ metadata.composer = value;
910
+ }
794
911
  continue;
912
+ }
795
913
  if (h.staffLayout)
796
914
  continue;
797
915
  const header = h;
@@ -881,6 +999,11 @@ const decodeTune = (tune) => {
881
999
  // ABC measures contain BarPatches, each with a voice control V:n
882
1000
  const measures = body.measures;
883
1001
  const voiceSlurDepths = new Map();
1002
+ // Per-voice key alterations (persist across measures; mutated by inline [K:] changes).
1003
+ // Initialized from the tune's header key signature so bare ABC letters pick up the
1004
+ // key's sharps/flats, which lilylet pitches must carry explicitly.
1005
+ const initialKeyAlterations = keySig ? keySignatureAlterations(keySig) : new Map();
1006
+ const voiceKeyAlterations = new Map();
884
1007
  // Process each ABC measure into Lilylet Measure
885
1008
  const lilyletMeasures = [];
886
1009
  for (let mi = 0; mi < measures.length; mi++) {
@@ -899,11 +1022,20 @@ const decodeTune = (tune) => {
899
1022
  for (const [voiceNum, patches] of voicePatches) {
900
1023
  const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
901
1024
  voiceSlurDepths.set(voiceNum, slurDepth);
1025
+ // Resolve this voice's persistent key alterations (seeded from the header key).
1026
+ if (!voiceKeyAlterations.has(voiceNum)) {
1027
+ voiceKeyAlterations.set(voiceNum, new Map(initialKeyAlterations));
1028
+ }
1029
+ // Fresh in-measure accidental memory for each measure (standard notation rule).
1030
+ const pitchCtx = {
1031
+ keyAlterations: voiceKeyAlterations.get(voiceNum),
1032
+ measureAccidentals: new Map(),
1033
+ };
902
1034
  // Merge all patches for this voice in this measure
903
1035
  const allEvents = [];
904
1036
  let barline;
905
1037
  for (const patch of patches) {
906
- const result = processBarPatch(patch, unitLength, slurDepth);
1038
+ const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
907
1039
  allEvents.push(...result.events);
908
1040
  if (result.barline && result.barline !== "|") {
909
1041
  barline = result.barline;
@@ -1003,30 +1135,30 @@ const decodeTune = (tune) => {
1003
1135
  * Decode ABC notation string to LilyletDoc.
1004
1136
  * If the ABC contains multiple tunes, only the first is decoded.
1005
1137
  */
1006
- export const decode = (abcString) => {
1138
+ export const decode = (abcString, options = {}) => {
1007
1139
  const tunes = parse(abcString);
1008
1140
  if (!tunes || tunes.length === 0) {
1009
1141
  throw new Error("No tunes found in ABC notation");
1010
1142
  }
1011
- return decodeTune(tunes[0]);
1143
+ return decodeTune(tunes[0], options);
1012
1144
  };
1013
1145
  /**
1014
1146
  * Decode ABC notation string to multiple LilyletDocs (one per tune).
1015
1147
  */
1016
- export const decodeAll = (abcString) => {
1148
+ export const decodeAll = (abcString, options = {}) => {
1017
1149
  const tunes = parse(abcString);
1018
1150
  if (!tunes || tunes.length === 0) {
1019
1151
  throw new Error("No tunes found in ABC notation");
1020
1152
  }
1021
- return tunes.map(decodeTune);
1153
+ return tunes.map(t => decodeTune(t, options));
1022
1154
  };
1023
1155
  /**
1024
1156
  * Decode an ABC file to LilyletDoc
1025
1157
  */
1026
- export const decodeFile = async (filePath) => {
1158
+ export const decodeFile = async (filePath, options = {}) => {
1027
1159
  const fs = await import("fs/promises");
1028
1160
  const content = await fs.readFile(filePath, "utf-8");
1029
- return decode(content);
1161
+ return decode(content, options);
1030
1162
  };
1031
1163
  export default {
1032
1164
  decode,