@k-l-lambda/lilylet 0.1.67 → 0.1.69

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,7 +4,7 @@
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, 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
9
  // NotaGen catalog tags appear as leading single-% comments in ABC files,
10
10
  // in the order: period, composer, instrumentation. They are mapped to
@@ -91,7 +91,7 @@ const convertAccidental = (acc) => {
91
91
  * Uppercase C-B = octave 0, lowercase c-b = octave 1
92
92
  * quotes (from ' and ,) add/subtract octaves
93
93
  */
94
- const convertPitch = (abcPitch) => {
94
+ const convertPitch = (abcPitch, ctx) => {
95
95
  const phonet = ABC_PHONET_MAP[abcPitch.phonet];
96
96
  if (!phonet) {
97
97
  throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
@@ -101,9 +101,38 @@ const convertPitch = (abcPitch) => {
101
101
  const baseOctave = isLower ? 1 : 0;
102
102
  const octave = baseOctave + (abcPitch.quotes || 0);
103
103
  const pitch = { phonet, octave };
104
- const accidental = convertAccidental(abcPitch.acc);
105
- if (accidental) {
106
- 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;
107
136
  }
108
137
  return pitch;
109
138
  };
@@ -260,6 +289,45 @@ const convertKeySignature = (abcKey) => {
260
289
  mode: (abcKey.mode === "minor" || abcKey.mode === "min") ? "minor" : "major",
261
290
  };
262
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
+ };
263
331
  /**
264
332
  * Convert ABC clef string to Lilylet Clef
265
333
  */
@@ -492,13 +560,17 @@ const processExpressiveTerm = (term, pendingMarks, pendingContextChanges, slurDe
492
560
  /**
493
561
  * Process a single ABC BarPatch (one voice's content for one measure) into events.
494
562
  */
495
- const processBarPatch = (patch, unitLength, slurDepth) => {
563
+ const processBarPatch = (patch, unitLength, slurDepth, pitchCtx) => {
496
564
  const events = [];
497
565
  const terms = patch.terms || [];
498
566
  const pendingMarks = [];
499
567
  const pendingContextChanges = [];
500
568
  // Collect all events first, then handle broken rhythms and tuplets
501
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;
502
574
  let i = 0;
503
575
  while (i < terms.length) {
504
576
  const term = terms[i];
@@ -513,10 +585,19 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
513
585
  }
514
586
  }
515
587
  else if (ctrl.value?.root) {
588
+ const newKey = convertKeySignature(ctrl.value);
516
589
  events.push({
517
590
  type: "context",
518
- key: convertKeySignature(ctrl.value),
591
+ key: newKey,
519
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
+ }
520
601
  }
521
602
  }
522
603
  else if (ctrl.name === "M") {
@@ -555,7 +636,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
555
636
  while (j < terms.length && collected < r) {
556
637
  const nextTerm = terms[j];
557
638
  if (nextTerm.event) {
558
- const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges);
639
+ const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
559
640
  if (evt) {
560
641
  // Push any pending context changes before tuplet
561
642
  for (const ctx of pendingContextChanges.splice(0)) {
@@ -600,7 +681,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
600
681
  }
601
682
  // Grace notes
602
683
  if (term.grace) {
603
- const graceEvents = convertGraceEvents(term, unitLength);
684
+ const graceEvents = convertGraceEvents(term, unitLength, pitchCtx);
604
685
  events.push(...graceEvents);
605
686
  i++;
606
687
  continue;
@@ -614,10 +695,25 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
614
695
  // Text
615
696
  if (term.text !== undefined) {
616
697
  const text = term.text;
617
- // 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;
618
702
  if (text.startsWith("^")) {
619
- // Markup text above staff
703
+ placement = Placement.above;
704
+ content = text.slice(1);
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);
620
712
  }
713
+ const markup = { type: "markup", content };
714
+ if (placement)
715
+ markup.placement = placement;
716
+ events.push(markup);
621
717
  i++;
622
718
  continue;
623
719
  }
@@ -628,7 +724,7 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
628
724
  for (const ctx of pendingContextChanges.splice(0)) {
629
725
  events.push(ctx);
630
726
  }
631
- const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
727
+ const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
632
728
  if (evt) {
633
729
  if (Array.isArray(evt)) {
634
730
  events.push(...evt);
@@ -636,12 +732,18 @@ const processBarPatch = (patch, unitLength, slurDepth) => {
636
732
  else {
637
733
  events.push(evt);
638
734
  }
639
- // Track broken rhythm
640
- 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) {
641
738
  const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest");
642
739
  if (noteRestEvents.length >= 2) {
643
- applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, eventTerm.broken);
740
+ applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
644
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;
645
747
  }
646
748
  }
647
749
  i++;
@@ -687,7 +789,7 @@ const getDefaultTupletMultiplier = (p) => {
687
789
  /**
688
790
  * Convert a single ABC EventTerm to Lilylet event(s)
689
791
  */
690
- const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges) => {
792
+ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx) => {
691
793
  const eventData = eventTerm.event;
692
794
  if (!eventData)
693
795
  return undefined;
@@ -708,12 +810,20 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
708
810
  if (firstPitch.phonet === "Z") {
709
811
  rest.fullMeasure = true;
710
812
  }
711
- // 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 }));
712
819
  pendingMarks.length = 0;
820
+ if (leadingDynamics.length > 0) {
821
+ return [...leadingDynamics, rest];
822
+ }
713
823
  return rest;
714
824
  }
715
825
  // Note or chord
716
- 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));
717
827
  if (pitches.length === 0)
718
828
  return undefined;
719
829
  const duration = convertDuration(eventData.duration, unitLength);
@@ -742,7 +852,7 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
742
852
  /**
743
853
  * Convert grace notes to NoteEvents with grace flag
744
854
  */
745
- const convertGraceEvents = (graceTerm, unitLength) => {
855
+ const convertGraceEvents = (graceTerm, unitLength, pitchCtx) => {
746
856
  const events = [];
747
857
  if (!graceTerm.events)
748
858
  return events;
@@ -752,7 +862,7 @@ const convertGraceEvents = (graceTerm, unitLength) => {
752
862
  const chord = eventData.chord;
753
863
  if (!chord || !chord.pitches)
754
864
  continue;
755
- const pitches = chord.pitches.filter((p) => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map(convertPitch);
865
+ const pitches = chord.pitches.filter((p) => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map((p) => convertPitch(p, pitchCtx));
756
866
  if (pitches.length === 0)
757
867
  continue;
758
868
  const duration = convertDuration(eventData.duration, unitLength);
@@ -767,11 +877,7 @@ const convertGraceEvents = (graceTerm, unitLength) => {
767
877
  }
768
878
  return events;
769
879
  };
770
- // ============ Main Decoder ============
771
- /**
772
- * Decode an ABC tune into a LilyletDoc
773
- */
774
- const decodeTune = (tune) => {
880
+ const decodeTune = (tune, options = {}) => {
775
881
  const headers = tune.header;
776
882
  const body = tune.body;
777
883
  // Extract header fields
@@ -793,13 +899,15 @@ const decodeTune = (tune) => {
793
899
  for (const h of headers) {
794
900
  const comment = h.comment;
795
901
  if (typeof comment === "string") {
796
- const value = comment.trim();
797
- if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value))
798
- metadata.genre = value;
799
- else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value))
800
- metadata.instrument = value;
801
- else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value))
802
- metadata.composer = value;
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
+ }
803
911
  continue;
804
912
  }
805
913
  if (h.staffLayout)
@@ -891,6 +999,11 @@ const decodeTune = (tune) => {
891
999
  // ABC measures contain BarPatches, each with a voice control V:n
892
1000
  const measures = body.measures;
893
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();
894
1007
  // Process each ABC measure into Lilylet Measure
895
1008
  const lilyletMeasures = [];
896
1009
  for (let mi = 0; mi < measures.length; mi++) {
@@ -909,11 +1022,20 @@ const decodeTune = (tune) => {
909
1022
  for (const [voiceNum, patches] of voicePatches) {
910
1023
  const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
911
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
+ };
912
1034
  // Merge all patches for this voice in this measure
913
1035
  const allEvents = [];
914
1036
  let barline;
915
1037
  for (const patch of patches) {
916
- const result = processBarPatch(patch, unitLength, slurDepth);
1038
+ const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
917
1039
  allEvents.push(...result.events);
918
1040
  if (result.barline && result.barline !== "|") {
919
1041
  barline = result.barline;
@@ -1013,30 +1135,30 @@ const decodeTune = (tune) => {
1013
1135
  * Decode ABC notation string to LilyletDoc.
1014
1136
  * If the ABC contains multiple tunes, only the first is decoded.
1015
1137
  */
1016
- export const decode = (abcString) => {
1138
+ export const decode = (abcString, options = {}) => {
1017
1139
  const tunes = parse(abcString);
1018
1140
  if (!tunes || tunes.length === 0) {
1019
1141
  throw new Error("No tunes found in ABC notation");
1020
1142
  }
1021
- return decodeTune(tunes[0]);
1143
+ return decodeTune(tunes[0], options);
1022
1144
  };
1023
1145
  /**
1024
1146
  * Decode ABC notation string to multiple LilyletDocs (one per tune).
1025
1147
  */
1026
- export const decodeAll = (abcString) => {
1148
+ export const decodeAll = (abcString, options = {}) => {
1027
1149
  const tunes = parse(abcString);
1028
1150
  if (!tunes || tunes.length === 0) {
1029
1151
  throw new Error("No tunes found in ABC notation");
1030
1152
  }
1031
- return tunes.map(decodeTune);
1153
+ return tunes.map(t => decodeTune(t, options));
1032
1154
  };
1033
1155
  /**
1034
1156
  * Decode an ABC file to LilyletDoc
1035
1157
  */
1036
- export const decodeFile = async (filePath) => {
1158
+ export const decodeFile = async (filePath, options = {}) => {
1037
1159
  const fs = await import("fs/promises");
1038
1160
  const content = await fs.readFile(filePath, "utf-8");
1039
- return decode(content);
1161
+ return decode(content, options);
1040
1162
  };
1041
1163
  export default {
1042
1164
  decode,