@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.
@@ -34,11 +34,42 @@ import {
34
34
  NavigationMarkType,
35
35
  Tempo,
36
36
  BarlineEvent,
37
+ MarkupEvent,
38
+ DynamicEvent,
39
+ Placement,
37
40
  } from "./types";
38
41
 
39
42
 
40
43
  // ============ Constants ============
41
44
 
45
+ // NotaGen catalog tags appear as leading single-% comments in ABC files,
46
+ // in the order: period, composer, instrumentation. They are mapped to
47
+ // Lilylet metadata: period -> genre, instrumentation -> instrument.
48
+ const NOTAGEN_PERIOD_SET = new Set([
49
+ "Baroque", "Classical", "Romantic",
50
+ ]);
51
+ const NOTAGEN_INSTRUMENTATION_SET = new Set([
52
+ "Art Song", "Chamber", "Choral", "Keyboard", "Orchestral", "Vocal-Orchestral",
53
+ ]);
54
+ const NOTAGEN_COMPOSER_SET = new Set([
55
+ "Bach, Johann Sebastian", "Bartok, Bela", "Beethoven, Ludwig van", "Berlioz, Hector",
56
+ "Bizet, Georges", "Boulanger, Lili", "Boulton, Harold", "Brahms, Johannes",
57
+ "Burgmuller, Friedrich", "Butterworth, George", "Chaminade, Cecile", "Chausson, Ernest",
58
+ "Chopin, Frederic", "Corelli, Arcangelo", "Cornelius, Peter", "Debussy, Claude",
59
+ "Dvorak, Antonin", "Faisst, Clara", "Faure, Gabriel", "Franz, Robert",
60
+ "Gonzaga, Chiquinha", "Grandval, Clemence de", "Grieg, Edvard", "Handel, George Frideric",
61
+ "Haydn, Joseph", "Hensel, Fanny", "Holmes, Augusta Mary Anne", "Jaell, Marie",
62
+ "Kinkel, Johanna", "Kralik, Mathilde", "Lang, Josephine", "Lehmann, Liza",
63
+ "Liszt, Franz", "Mayer, Emilie", "Medtner, Nikolay", "Mendelssohn, Felix",
64
+ "Mozart, Wolfgang Amadeus", "Munktell, Helena", "Paradis, Maria Theresia von",
65
+ "Parratt, Walter", "Prokofiev, Sergey", "Rachmaninoff, Sergei", "Ravel, Maurice",
66
+ "Reichardt, Louise", "Saint-Georges, Joseph Bologne", "Saint-Saens, Camille",
67
+ "Satie, Erik", "Scarlatti, Domenico", "Schroter, Corona", "Schubert, Franz",
68
+ "Schumann, Clara", "Schumann, Robert", "Scriabin, Aleksandr", "Shostakovich, Dmitry",
69
+ "Sibelius, Jean", "Smetana, Bedrich", "Tchaikovsky, Pyotr", "Viardot, Pauline",
70
+ "Vivaldi, Antonio", "Warlock, Peter", "Wolf, Hugo", "Zumsteeg, Emilie",
71
+ ]);
72
+
42
73
  const ABC_PHONET_MAP: Record<string, Phonet> = {
43
74
  "C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
44
75
  "c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
@@ -97,12 +128,25 @@ const convertAccidental = (acc: number | null): Accidental | undefined => {
97
128
  }
98
129
  };
99
130
 
131
+ /**
132
+ * Pitch-conversion context for resolving ABC's implicit accidentals into lilylet's
133
+ * absolute pitch model. ABC applies the key signature and in-measure accidentals
134
+ * implicitly to bare note letters; lilylet note names carry their own accidental, so
135
+ * we resolve the effective accidental here.
136
+ */
137
+ interface PitchContext {
138
+ keyAlterations: Map<Phonet, Accidental>; // letters altered by the active key signature
139
+ // In-measure accidental memory, keyed by "phonet:octave". An explicit accidental
140
+ // persists for the rest of the measure at that pitch/octave (standard notation rule).
141
+ measureAccidentals: Map<string, Accidental | "natural">;
142
+ }
143
+
100
144
  /**
101
145
  * Convert ABC pitch to Lilylet Pitch
102
146
  * Uppercase C-B = octave 0, lowercase c-b = octave 1
103
147
  * quotes (from ' and ,) add/subtract octaves
104
148
  */
105
- const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
149
+ const convertPitch = (abcPitch: ABC.Pitch, ctx?: PitchContext): Pitch => {
106
150
  const phonet = ABC_PHONET_MAP[abcPitch.phonet];
107
151
  if (!phonet) {
108
152
  throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
@@ -114,10 +158,36 @@ const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
114
158
  const octave = baseOctave + (abcPitch.quotes || 0);
115
159
 
116
160
  const pitch: Pitch = { phonet, octave };
117
- const accidental = convertAccidental(abcPitch.acc);
118
- if (accidental) {
119
- pitch.accidental = accidental;
161
+
162
+ // Resolve the effective accidental.
163
+ const explicit = convertAccidental(abcPitch.acc); // accidental written on this note
164
+ const hasExplicit = abcPitch.acc !== null && abcPitch.acc !== undefined;
165
+
166
+ if (ctx) {
167
+ const memKey = `${phonet}:${octave}`;
168
+ if (hasExplicit) {
169
+ // Explicit accidental (incl. natural) overrides and is remembered for the measure.
170
+ if (abcPitch.acc === 0) {
171
+ ctx.measureAccidentals.set(memKey, "natural");
172
+ // natural cancels the key alteration → no accidental on the lilylet pitch
173
+ } else if (explicit) {
174
+ ctx.measureAccidentals.set(memKey, explicit);
175
+ pitch.accidental = explicit;
176
+ }
177
+ } else {
178
+ // No explicit accidental: inherit from in-measure memory, else the key signature.
179
+ const remembered = ctx.measureAccidentals.get(memKey);
180
+ if (remembered !== undefined) {
181
+ if (remembered !== "natural") pitch.accidental = remembered;
182
+ } else {
183
+ const fromKey = ctx.keyAlterations.get(phonet);
184
+ if (fromKey) pitch.accidental = fromKey;
185
+ }
186
+ }
187
+ } else if (explicit) {
188
+ pitch.accidental = explicit;
120
189
  }
190
+
121
191
  return pitch;
122
192
  };
123
193
 
@@ -282,16 +352,60 @@ const convertKeySignature = (abcKey: ABC.KeySignature): KeySignature => {
282
352
  };
283
353
  };
284
354
 
355
+ // Circle-of-fifths: a lilylet key (pitch+accidental+mode) → set of letters the key
356
+ // signature alters, and the direction (sharp/flat). ABC note letters inherit these
357
+ // alterations implicitly (e.g. K:Eb makes B sound as Bb); lilylet pitches are absolute,
358
+ // so the decoder must bake the alteration into each pitch's accidental.
359
+ const SHARP_ORDER: Phonet[] = [Phonet.f, Phonet.c, Phonet.g, Phonet.d, Phonet.a, Phonet.e, Phonet.b];
360
+ const FLAT_ORDER: Phonet[] = [Phonet.b, Phonet.e, Phonet.a, Phonet.d, Phonet.g, Phonet.c, Phonet.f];
361
+
362
+ const KEY_NAME_TO_FIFTHS: Record<string, number> = {
363
+ c: 0, g: 1, d: 2, a: 3, e: 4, b: 5, "f#": 6, "c#": 7,
364
+ f: -1, bb: -2, eb: -3, ab: -4, db: -5, gb: -6, cb: -7,
365
+ };
366
+
367
+ // Compute the number of sharps/flats (fifths) for a resolved KeySignature.
368
+ const keySignatureFifths = (key: KeySignature): number => {
369
+ let letter = key.pitch as string;
370
+ if (key.accidental === Accidental.sharp) letter += "#";
371
+ else if (key.accidental === Accidental.flat) letter += "b";
372
+ let fifths = KEY_NAME_TO_FIFTHS[letter];
373
+ if (fifths === undefined) fifths = 0;
374
+ // Minor keys share the fifths of their relative major (3 fifths up).
375
+ if (key.mode === "minor") fifths += 3;
376
+ return fifths;
377
+ };
378
+
379
+ // Map of phonet → accidental implied by the key signature (only altered letters present).
380
+ const keySignatureAlterations = (key: KeySignature): Map<Phonet, Accidental> => {
381
+ const fifths = keySignatureFifths(key);
382
+ const map = new Map<Phonet, Accidental>();
383
+ if (fifths > 0) {
384
+ for (let i = 0; i < fifths && i < SHARP_ORDER.length; i++) map.set(SHARP_ORDER[i], Accidental.sharp);
385
+ } else if (fifths < 0) {
386
+ for (let i = 0; i < -fifths && i < FLAT_ORDER.length; i++) map.set(FLAT_ORDER[i], Accidental.flat);
387
+ }
388
+ return map;
389
+ };
390
+
285
391
  /**
286
392
  * Convert ABC clef string to Lilylet Clef
287
393
  */
288
394
  const convertClef = (clefStr: string): Clef | undefined => {
289
- switch (clefStr?.toLowerCase()) {
290
- case "treble": return Clef.treble;
291
- case "bass": return Clef.bass;
292
- case "alto": case "tenor": return Clef.alto;
395
+ // Split off an optional ABC octave shift suffix: "treble-8", "bass+8", "treble-15".
396
+ // ABC: "-N" lowers the sounding pitch (small N drawn below), "+N" raises it.
397
+ // LilyPond/Lilylet: "_N" = below, "^N" = above. Translate the sign accordingly.
398
+ const shift = clefStr?.match(/^(.*?)([+-])(8|15)$/);
399
+ const base = shift ? shift[1] : clefStr;
400
+ let resolved: string | undefined;
401
+ switch (base?.toLowerCase()) {
402
+ case "treble": resolved = "treble"; break;
403
+ case "bass": resolved = "bass"; break;
404
+ case "alto": case "tenor": resolved = "alto"; break;
293
405
  default: return undefined;
294
406
  }
407
+ if (shift) resolved += (shift[2] === "-" ? "_" : "^") + shift[3];
408
+ return resolved as Clef;
295
409
  };
296
410
 
297
411
  /**
@@ -323,9 +437,60 @@ interface StaffAssignment {
323
437
 
324
438
  /**
325
439
  * Parse %%score layout to determine voice→(part, staff) mapping.
326
- * {(...) | (...)} = one part with two staves
327
- * (...) = voices sharing one staff
440
+ *
441
+ * Grouping semantics (recursive, any nesting depth):
442
+ * [ ... ] square: each child becomes a separate part -> serialized with \\\
443
+ * ( ... ) arc: all voices share one staff in one part -> voices joined with \\
444
+ * { ... } curly: one part, each child is a separate staff -> grand staff
445
+ * leaf a bare voice number "1" (often wrapped as {items:["1"]}) -> one part, one staff
446
+ *
447
+ * The AST wraps each leaf voice one level deep, e.g. ( 1 2 ) becomes
448
+ * {bound:'arc', items:[{items:['1']},{items:['2']}]}
449
+ * so leaves must be collected recursively rather than assuming a fixed depth.
328
450
  */
451
+
452
+ type ScoreNode = ABC.StaffGroup | string;
453
+
454
+ // All voice numbers under a node, flattened.
455
+ const collectScoreVoices = (node: ScoreNode): number[] => {
456
+ if (typeof node === "string") {
457
+ const v = parseInt(node, 10);
458
+ return isNaN(v) ? [] : [v];
459
+ }
460
+ const voices: number[] = [];
461
+ for (const item of node.items || []) {
462
+ voices.push(...collectScoreVoices(item));
463
+ }
464
+ return voices;
465
+ };
466
+
467
+ // Expand a node into a list of parts; each part is a list of staves; each staff is a list of voices.
468
+ const scoreNodeToParts = (node: ScoreNode): number[][][] => {
469
+ if (typeof node === "string") {
470
+ const v = parseInt(node, 10);
471
+ return isNaN(v) ? [] : [[[v]]];
472
+ }
473
+
474
+ if (node.bound === "square") {
475
+ // each child is its own part
476
+ const parts: number[][][] = [];
477
+ for (const child of node.items || []) {
478
+ parts.push(...scoreNodeToParts(child));
479
+ }
480
+ return parts;
481
+ }
482
+
483
+ if (node.bound === "curly") {
484
+ // one part, each child is a separate staff (grand staff)
485
+ const staves = (node.items || []).map(child => collectScoreVoices(child)).filter(s => s.length > 0);
486
+ return staves.length > 0 ? [staves] : [];
487
+ }
488
+
489
+ // arc or no bound: one part, all voices on a single staff
490
+ const voices = collectScoreVoices(node);
491
+ return voices.length > 0 ? [[voices]] : [];
492
+ };
493
+
329
494
  const parseScoreLayout = (
330
495
  headers: any[]
331
496
  ): Map<number, StaffAssignment> | null => {
@@ -336,78 +501,14 @@ const parseScoreLayout = (
336
501
  const voiceMap = new Map<number, StaffAssignment>();
337
502
 
338
503
  let partIndex = 0;
339
-
340
- for (const group of layout) {
341
- if (group.bound === "curly") {
342
- // Curly braces = one instrument/part with multiple staves
343
- let staffInPart = 1;
344
- for (const item of group.items) {
345
- if (typeof item === "string") {
346
- voiceMap.set(parseInt(item), { partIndex, staffInPart });
347
- } else if ((item as ABC.StaffGroup).items) {
348
- const sg = item as ABC.StaffGroup;
349
- for (const subItem of sg.items) {
350
- if (typeof subItem === "string") {
351
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
352
- } else if ((subItem as ABC.StaffGroup).items) {
353
- for (const leaf of (subItem as ABC.StaffGroup).items) {
354
- if (typeof leaf === "string") {
355
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
356
- }
357
- }
358
- }
359
- }
360
- staffInPart++;
361
- }
362
- }
363
- partIndex++;
364
- } else if (group.bound === "arc" || !group.bound) {
365
- // Arc or plain = voices sharing a staff in same part
366
- for (const item of group.items) {
367
- if (typeof item === "string") {
368
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
369
- } else if ((item as ABC.StaffGroup).items) {
370
- for (const subItem of (item as ABC.StaffGroup).items) {
371
- if (typeof subItem === "string") {
372
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
373
- }
374
- }
504
+ for (const top of layout) {
505
+ for (const part of scoreNodeToParts(top)) {
506
+ part.forEach((staff, staffIdx) => {
507
+ for (const voice of staff) {
508
+ voiceMap.set(voice, { partIndex, staffInPart: staffIdx + 1 });
375
509
  }
376
- }
510
+ });
377
511
  partIndex++;
378
- } else {
379
- // Square bracket or unknown - treat each item as separate part
380
- for (const item of group.items) {
381
- if (typeof item === "string") {
382
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
383
- partIndex++;
384
- } else if ((item as ABC.StaffGroup).items) {
385
- const sg = item as ABC.StaffGroup;
386
- if (sg.bound === "curly") {
387
- let staffInPart = 1;
388
- for (const subItem of sg.items) {
389
- if (typeof subItem === "string") {
390
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
391
- } else if ((subItem as ABC.StaffGroup).items) {
392
- for (const leaf of (subItem as ABC.StaffGroup).items) {
393
- if (typeof leaf === "string") {
394
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
395
- }
396
- }
397
- staffInPart++;
398
- }
399
- }
400
- partIndex++;
401
- } else {
402
- for (const subItem of sg.items) {
403
- if (typeof subItem === "string") {
404
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
405
- }
406
- }
407
- partIndex++;
408
- }
409
- }
410
- }
411
512
  }
412
513
  }
413
514
 
@@ -544,7 +645,8 @@ interface VoiceConfig {
544
645
  const processBarPatch = (
545
646
  patch: ABC.BarPatch,
546
647
  unitLength: { numerator: number; denominator: number },
547
- slurDepth: { count: number }
648
+ slurDepth: { count: number },
649
+ pitchCtx?: PitchContext
548
650
  ): { events: Event[]; barline?: string } => {
549
651
  const events: Event[] = [];
550
652
  const terms = patch.terms || [];
@@ -554,6 +656,11 @@ const processBarPatch = (
554
656
  // Collect all events first, then handle broken rhythms and tuplets
555
657
  const rawNoteRests: { event: NoteEvent | RestEvent; index: number; broken?: number }[] = [];
556
658
 
659
+ // A broken-rhythm marker (>, <) belongs to the LEFT note but modifies the pair
660
+ // (left, right). We can only apply it once the right note has been pushed, so we
661
+ // stash it here and resolve it when the next note/rest arrives.
662
+ let pendingBroken: number | null = null;
663
+
557
664
  let i = 0;
558
665
  while (i < terms.length) {
559
666
  const term = terms[i];
@@ -568,10 +675,18 @@ const processBarPatch = (
568
675
  events.push({ type: "context", clef } as ContextChange);
569
676
  }
570
677
  } else if (ctrl.value?.root) {
678
+ const newKey = convertKeySignature(ctrl.value);
571
679
  events.push({
572
680
  type: "context",
573
- key: convertKeySignature(ctrl.value),
681
+ key: newKey,
574
682
  } as ContextChange);
683
+ // Update the active key alterations and clear in-measure accidentals.
684
+ if (pitchCtx) {
685
+ const alt = keySignatureAlterations(newKey);
686
+ pitchCtx.keyAlterations.clear();
687
+ for (const [k, v] of alt) pitchCtx.keyAlterations.set(k, v);
688
+ pitchCtx.measureAccidentals.clear();
689
+ }
575
690
  }
576
691
  } else if (ctrl.name === "M") {
577
692
  if (ctrl.value?.numerator && ctrl.value?.denominator) {
@@ -609,7 +724,7 @@ const processBarPatch = (
609
724
  while (j < terms.length && collected < r) {
610
725
  const nextTerm = terms[j];
611
726
  if ((nextTerm as ABC.EventTerm).event) {
612
- const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges);
727
+ const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
613
728
  if (evt) {
614
729
  // Push any pending context changes before tuplet
615
730
  for (const ctx of pendingContextChanges.splice(0)) {
@@ -669,10 +784,16 @@ const processBarPatch = (
669
784
  // Text
670
785
  if ((term as ABC.TextTerm).text !== undefined) {
671
786
  const text = (term as ABC.TextTerm).text;
672
- // Check if it's a tempo/expression marking
673
- if (text.startsWith("^")) {
674
- // Markup text above staff
675
- }
787
+ // ABC chord/annotation text: a leading ^ / _ marks placement above / below;
788
+ // other position prefixes (<,>,@) just denote placement we don't model, so strip.
789
+ let placement: Placement | undefined;
790
+ let content = text;
791
+ if (text.startsWith("^")) { placement = Placement.above; content = text.slice(1); }
792
+ else if (text.startsWith("_")) { placement = Placement.below; content = text.slice(1); }
793
+ else if (/^[<>@]/.test(text)) { content = text.slice(1); }
794
+ const markup: MarkupEvent = { type: "markup", content };
795
+ if (placement) markup.placement = placement;
796
+ events.push(markup);
676
797
  i++;
677
798
  continue;
678
799
  }
@@ -686,7 +807,7 @@ const processBarPatch = (
686
807
  events.push(ctx);
687
808
  }
688
809
 
689
- const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
810
+ const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
690
811
  if (evt) {
691
812
  if (Array.isArray(evt)) {
692
813
  events.push(...evt);
@@ -694,12 +815,19 @@ const processBarPatch = (
694
815
  events.push(evt);
695
816
  }
696
817
 
697
- // Track broken rhythm
698
- if (eventTerm.broken) {
818
+ // Resolve a broken-rhythm marker carried by the PREVIOUS note: it modifies
819
+ // the pair (previous note, this note). Apply now that this (the right) note exists.
820
+ if (pendingBroken !== null) {
699
821
  const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest") as (NoteEvent | RestEvent)[];
700
822
  if (noteRestEvents.length >= 2) {
701
- applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, eventTerm.broken);
823
+ applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
702
824
  }
825
+ pendingBroken = null;
826
+ }
827
+
828
+ // Stash this note's own broken marker (the right note is not parsed yet).
829
+ if (eventTerm.broken) {
830
+ pendingBroken = eventTerm.broken;
703
831
  }
704
832
  }
705
833
  i++;
@@ -747,7 +875,8 @@ const convertEventTerm = (
747
875
  eventTerm: ABC.EventTerm,
748
876
  unitLength: { numerator: number; denominator: number },
749
877
  pendingMarks: Mark[],
750
- pendingContextChanges: ContextChange[]
878
+ pendingContextChanges: ContextChange[],
879
+ pitchCtx?: PitchContext
751
880
  ): Event | Event[] | undefined => {
752
881
  const eventData = eventTerm.event;
753
882
  if (!eventData) return undefined;
@@ -771,16 +900,24 @@ const convertEventTerm = (
771
900
  rest.fullMeasure = true;
772
901
  }
773
902
 
774
- // Consume pending marks (attach to rest if any)
903
+ // A dynamic that precedes a rest (e.g. ABC "!p! z") has no note to attach to.
904
+ // Emit it as a standalone leading DynamicEvent before the rest; lilylet attaches
905
+ // it to the following sounding event. Other marks on a rest are dropped.
906
+ const leadingDynamics: DynamicEvent[] = pendingMarks
907
+ .filter(m => m.markType === "dynamic")
908
+ .map(m => ({ type: "dynamic", dynamicType: (m as { type: DynamicType }).type }));
775
909
  pendingMarks.length = 0;
776
910
 
911
+ if (leadingDynamics.length > 0) {
912
+ return [...leadingDynamics, rest];
913
+ }
777
914
  return rest;
778
915
  }
779
916
 
780
917
  // Note or chord
781
918
  const pitches = chord.pitches.filter(p =>
782
919
  p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
783
- ).map(convertPitch);
920
+ ).map(p => convertPitch(p, pitchCtx));
784
921
 
785
922
  if (pitches.length === 0) return undefined;
786
923
 
@@ -855,7 +992,14 @@ const convertGraceEvents = (
855
992
  /**
856
993
  * Decode an ABC tune into a LilyletDoc
857
994
  */
858
- const decodeTune = (tune: ABC.Tune): LilyletDoc => {
995
+ export interface DecodeOptions {
996
+ // Extract NotaGen catalog metadata from the leading
997
+ // %period/%composer/%instrumentation comments. These are NotaGen's own
998
+ // convention, not standard ABC, so this is off by default.
999
+ catalogComments?: boolean;
1000
+ }
1001
+
1002
+ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc => {
859
1003
  const headers = tune.header;
860
1004
  const body = tune.body;
861
1005
 
@@ -878,7 +1022,16 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
878
1022
  }
879
1023
 
880
1024
  for (const h of headers) {
881
- if ((h as any).comment) continue;
1025
+ const comment = (h as any).comment;
1026
+ if (typeof comment === "string") {
1027
+ if (options.catalogComments) {
1028
+ const value = comment.trim();
1029
+ if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value)) metadata.genre = value;
1030
+ else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value)) metadata.instrument = value;
1031
+ else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value)) metadata.composer = value;
1032
+ }
1033
+ continue;
1034
+ }
882
1035
  if ((h as any).staffLayout) continue;
883
1036
 
884
1037
  const header = h as { name: string; value: any };
@@ -966,6 +1119,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
966
1119
  const measures = body.measures;
967
1120
  const voiceSlurDepths = new Map<number, { count: number }>();
968
1121
 
1122
+ // Per-voice key alterations (persist across measures; mutated by inline [K:] changes).
1123
+ // Initialized from the tune's header key signature so bare ABC letters pick up the
1124
+ // key's sharps/flats, which lilylet pitches must carry explicitly.
1125
+ const initialKeyAlterations = keySig ? keySignatureAlterations(keySig) : new Map<Phonet, Accidental>();
1126
+ const voiceKeyAlterations = new Map<number, Map<Phonet, Accidental>>();
1127
+
969
1128
  // Process each ABC measure into Lilylet Measure
970
1129
  const lilyletMeasures: Measure[] = [];
971
1130
 
@@ -989,12 +1148,22 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
989
1148
  const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
990
1149
  voiceSlurDepths.set(voiceNum, slurDepth);
991
1150
 
1151
+ // Resolve this voice's persistent key alterations (seeded from the header key).
1152
+ if (!voiceKeyAlterations.has(voiceNum)) {
1153
+ voiceKeyAlterations.set(voiceNum, new Map(initialKeyAlterations));
1154
+ }
1155
+ // Fresh in-measure accidental memory for each measure (standard notation rule).
1156
+ const pitchCtx: PitchContext = {
1157
+ keyAlterations: voiceKeyAlterations.get(voiceNum)!,
1158
+ measureAccidentals: new Map(),
1159
+ };
1160
+
992
1161
  // Merge all patches for this voice in this measure
993
1162
  const allEvents: Event[] = [];
994
1163
  let barline: string | undefined;
995
1164
 
996
1165
  for (const patch of patches) {
997
- const result = processBarPatch(patch, unitLength, slurDepth);
1166
+ const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
998
1167
  allEvents.push(...result.events);
999
1168
  if (result.barline && result.barline !== "|") {
1000
1169
  barline = result.barline;
@@ -1114,32 +1283,32 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
1114
1283
  * Decode ABC notation string to LilyletDoc.
1115
1284
  * If the ABC contains multiple tunes, only the first is decoded.
1116
1285
  */
1117
- export const decode = (abcString: string): LilyletDoc => {
1286
+ export const decode = (abcString: string, options: DecodeOptions = {}): LilyletDoc => {
1118
1287
  const tunes = parse(abcString);
1119
1288
  if (!tunes || tunes.length === 0) {
1120
1289
  throw new Error("No tunes found in ABC notation");
1121
1290
  }
1122
- return decodeTune(tunes[0]);
1291
+ return decodeTune(tunes[0], options);
1123
1292
  };
1124
1293
 
1125
1294
  /**
1126
1295
  * Decode ABC notation string to multiple LilyletDocs (one per tune).
1127
1296
  */
1128
- export const decodeAll = (abcString: string): LilyletDoc[] => {
1297
+ export const decodeAll = (abcString: string, options: DecodeOptions = {}): LilyletDoc[] => {
1129
1298
  const tunes = parse(abcString);
1130
1299
  if (!tunes || tunes.length === 0) {
1131
1300
  throw new Error("No tunes found in ABC notation");
1132
1301
  }
1133
- return tunes.map(decodeTune);
1302
+ return tunes.map(t => decodeTune(t, options));
1134
1303
  };
1135
1304
 
1136
1305
  /**
1137
1306
  * Decode an ABC file to LilyletDoc
1138
1307
  */
1139
- export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
1308
+ export const decodeFile = async (filePath: string, options: DecodeOptions = {}): Promise<LilyletDoc> => {
1140
1309
  const fs = await import("fs/promises");
1141
1310
  const content = await fs.readFile(filePath, "utf-8");
1142
- return decode(content);
1311
+ return decode(content, options);
1143
1312
  };
1144
1313
 
1145
1314
  export default {