@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.
@@ -34,6 +34,9 @@ import {
34
34
  NavigationMarkType,
35
35
  Tempo,
36
36
  BarlineEvent,
37
+ MarkupEvent,
38
+ DynamicEvent,
39
+ Placement,
37
40
  } from "./types";
38
41
 
39
42
 
@@ -125,12 +128,25 @@ const convertAccidental = (acc: number | null): Accidental | undefined => {
125
128
  }
126
129
  };
127
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
+
128
144
  /**
129
145
  * Convert ABC pitch to Lilylet Pitch
130
146
  * Uppercase C-B = octave 0, lowercase c-b = octave 1
131
147
  * quotes (from ' and ,) add/subtract octaves
132
148
  */
133
- const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
149
+ const convertPitch = (abcPitch: ABC.Pitch, ctx?: PitchContext): Pitch => {
134
150
  const phonet = ABC_PHONET_MAP[abcPitch.phonet];
135
151
  if (!phonet) {
136
152
  throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
@@ -142,10 +158,36 @@ const convertPitch = (abcPitch: ABC.Pitch): Pitch => {
142
158
  const octave = baseOctave + (abcPitch.quotes || 0);
143
159
 
144
160
  const pitch: Pitch = { phonet, octave };
145
- const accidental = convertAccidental(abcPitch.acc);
146
- if (accidental) {
147
- 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;
148
189
  }
190
+
149
191
  return pitch;
150
192
  };
151
193
 
@@ -310,6 +352,42 @@ const convertKeySignature = (abcKey: ABC.KeySignature): KeySignature => {
310
352
  };
311
353
  };
312
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
+
313
391
  /**
314
392
  * Convert ABC clef string to Lilylet Clef
315
393
  */
@@ -567,7 +645,8 @@ interface VoiceConfig {
567
645
  const processBarPatch = (
568
646
  patch: ABC.BarPatch,
569
647
  unitLength: { numerator: number; denominator: number },
570
- slurDepth: { count: number }
648
+ slurDepth: { count: number },
649
+ pitchCtx?: PitchContext
571
650
  ): { events: Event[]; barline?: string } => {
572
651
  const events: Event[] = [];
573
652
  const terms = patch.terms || [];
@@ -577,6 +656,11 @@ const processBarPatch = (
577
656
  // Collect all events first, then handle broken rhythms and tuplets
578
657
  const rawNoteRests: { event: NoteEvent | RestEvent; index: number; broken?: number }[] = [];
579
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
+
580
664
  let i = 0;
581
665
  while (i < terms.length) {
582
666
  const term = terms[i];
@@ -591,10 +675,18 @@ const processBarPatch = (
591
675
  events.push({ type: "context", clef } as ContextChange);
592
676
  }
593
677
  } else if (ctrl.value?.root) {
678
+ const newKey = convertKeySignature(ctrl.value);
594
679
  events.push({
595
680
  type: "context",
596
- key: convertKeySignature(ctrl.value),
681
+ key: newKey,
597
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
+ }
598
690
  }
599
691
  } else if (ctrl.name === "M") {
600
692
  if (ctrl.value?.numerator && ctrl.value?.denominator) {
@@ -632,7 +724,7 @@ const processBarPatch = (
632
724
  while (j < terms.length && collected < r) {
633
725
  const nextTerm = terms[j];
634
726
  if ((nextTerm as ABC.EventTerm).event) {
635
- const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges);
727
+ const evt = convertEventTerm(nextTerm as ABC.EventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
636
728
  if (evt) {
637
729
  // Push any pending context changes before tuplet
638
730
  for (const ctx of pendingContextChanges.splice(0)) {
@@ -676,7 +768,7 @@ const processBarPatch = (
676
768
 
677
769
  // Grace notes
678
770
  if ((term as any).grace) {
679
- const graceEvents = convertGraceEvents(term as any, unitLength);
771
+ const graceEvents = convertGraceEvents(term as any, unitLength, pitchCtx);
680
772
  events.push(...graceEvents);
681
773
  i++;
682
774
  continue;
@@ -692,10 +784,16 @@ const processBarPatch = (
692
784
  // Text
693
785
  if ((term as ABC.TextTerm).text !== undefined) {
694
786
  const text = (term as ABC.TextTerm).text;
695
- // Check if it's a tempo/expression marking
696
- if (text.startsWith("^")) {
697
- // Markup text above staff
698
- }
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);
699
797
  i++;
700
798
  continue;
701
799
  }
@@ -709,7 +807,7 @@ const processBarPatch = (
709
807
  events.push(ctx);
710
808
  }
711
809
 
712
- const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
810
+ const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges, pitchCtx);
713
811
  if (evt) {
714
812
  if (Array.isArray(evt)) {
715
813
  events.push(...evt);
@@ -717,12 +815,19 @@ const processBarPatch = (
717
815
  events.push(evt);
718
816
  }
719
817
 
720
- // Track broken rhythm
721
- 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) {
722
821
  const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest") as (NoteEvent | RestEvent)[];
723
822
  if (noteRestEvents.length >= 2) {
724
- applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, eventTerm.broken);
823
+ applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, pendingBroken);
725
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;
726
831
  }
727
832
  }
728
833
  i++;
@@ -770,7 +875,8 @@ const convertEventTerm = (
770
875
  eventTerm: ABC.EventTerm,
771
876
  unitLength: { numerator: number; denominator: number },
772
877
  pendingMarks: Mark[],
773
- pendingContextChanges: ContextChange[]
878
+ pendingContextChanges: ContextChange[],
879
+ pitchCtx?: PitchContext
774
880
  ): Event | Event[] | undefined => {
775
881
  const eventData = eventTerm.event;
776
882
  if (!eventData) return undefined;
@@ -794,16 +900,24 @@ const convertEventTerm = (
794
900
  rest.fullMeasure = true;
795
901
  }
796
902
 
797
- // 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 }));
798
909
  pendingMarks.length = 0;
799
910
 
911
+ if (leadingDynamics.length > 0) {
912
+ return [...leadingDynamics, rest];
913
+ }
800
914
  return rest;
801
915
  }
802
916
 
803
917
  // Note or chord
804
918
  const pitches = chord.pitches.filter(p =>
805
919
  p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
806
- ).map(convertPitch);
920
+ ).map(p => convertPitch(p, pitchCtx));
807
921
 
808
922
  if (pitches.length === 0) return undefined;
809
923
 
@@ -841,7 +955,8 @@ const convertEventTerm = (
841
955
  */
842
956
  const convertGraceEvents = (
843
957
  graceTerm: any,
844
- unitLength: { numerator: number; denominator: number }
958
+ unitLength: { numerator: number; denominator: number },
959
+ pitchCtx?: PitchContext
845
960
  ): NoteEvent[] => {
846
961
  const events: NoteEvent[] = [];
847
962
  if (!graceTerm.events) return events;
@@ -854,7 +969,7 @@ const convertGraceEvents = (
854
969
 
855
970
  const pitches = chord.pitches.filter((p: ABC.Pitch) =>
856
971
  p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x"
857
- ).map(convertPitch);
972
+ ).map((p: ABC.Pitch) => convertPitch(p, pitchCtx));
858
973
 
859
974
  if (pitches.length === 0) continue;
860
975
 
@@ -878,7 +993,14 @@ const convertGraceEvents = (
878
993
  /**
879
994
  * Decode an ABC tune into a LilyletDoc
880
995
  */
881
- const decodeTune = (tune: ABC.Tune): LilyletDoc => {
996
+ export interface DecodeOptions {
997
+ // Extract NotaGen catalog metadata from the leading
998
+ // %period/%composer/%instrumentation comments. These are NotaGen's own
999
+ // convention, not standard ABC, so this is off by default.
1000
+ catalogComments?: boolean;
1001
+ }
1002
+
1003
+ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc => {
882
1004
  const headers = tune.header;
883
1005
  const body = tune.body;
884
1006
 
@@ -903,10 +1025,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
903
1025
  for (const h of headers) {
904
1026
  const comment = (h as any).comment;
905
1027
  if (typeof comment === "string") {
906
- const value = comment.trim();
907
- if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value)) metadata.genre = value;
908
- else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value)) metadata.instrument = value;
909
- else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value)) metadata.composer = value;
1028
+ if (options.catalogComments) {
1029
+ const value = comment.trim();
1030
+ if (!metadata.genre && NOTAGEN_PERIOD_SET.has(value)) metadata.genre = value;
1031
+ else if (!metadata.instrument && NOTAGEN_INSTRUMENTATION_SET.has(value)) metadata.instrument = value;
1032
+ else if (!metadata.composer && NOTAGEN_COMPOSER_SET.has(value)) metadata.composer = value;
1033
+ }
910
1034
  continue;
911
1035
  }
912
1036
  if ((h as any).staffLayout) continue;
@@ -996,6 +1120,12 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
996
1120
  const measures = body.measures;
997
1121
  const voiceSlurDepths = new Map<number, { count: number }>();
998
1122
 
1123
+ // Per-voice key alterations (persist across measures; mutated by inline [K:] changes).
1124
+ // Initialized from the tune's header key signature so bare ABC letters pick up the
1125
+ // key's sharps/flats, which lilylet pitches must carry explicitly.
1126
+ const initialKeyAlterations = keySig ? keySignatureAlterations(keySig) : new Map<Phonet, Accidental>();
1127
+ const voiceKeyAlterations = new Map<number, Map<Phonet, Accidental>>();
1128
+
999
1129
  // Process each ABC measure into Lilylet Measure
1000
1130
  const lilyletMeasures: Measure[] = [];
1001
1131
 
@@ -1019,12 +1149,22 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
1019
1149
  const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
1020
1150
  voiceSlurDepths.set(voiceNum, slurDepth);
1021
1151
 
1152
+ // Resolve this voice's persistent key alterations (seeded from the header key).
1153
+ if (!voiceKeyAlterations.has(voiceNum)) {
1154
+ voiceKeyAlterations.set(voiceNum, new Map(initialKeyAlterations));
1155
+ }
1156
+ // Fresh in-measure accidental memory for each measure (standard notation rule).
1157
+ const pitchCtx: PitchContext = {
1158
+ keyAlterations: voiceKeyAlterations.get(voiceNum)!,
1159
+ measureAccidentals: new Map(),
1160
+ };
1161
+
1022
1162
  // Merge all patches for this voice in this measure
1023
1163
  const allEvents: Event[] = [];
1024
1164
  let barline: string | undefined;
1025
1165
 
1026
1166
  for (const patch of patches) {
1027
- const result = processBarPatch(patch, unitLength, slurDepth);
1167
+ const result = processBarPatch(patch, unitLength, slurDepth, pitchCtx);
1028
1168
  allEvents.push(...result.events);
1029
1169
  if (result.barline && result.barline !== "|") {
1030
1170
  barline = result.barline;
@@ -1144,32 +1284,32 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
1144
1284
  * Decode ABC notation string to LilyletDoc.
1145
1285
  * If the ABC contains multiple tunes, only the first is decoded.
1146
1286
  */
1147
- export const decode = (abcString: string): LilyletDoc => {
1287
+ export const decode = (abcString: string, options: DecodeOptions = {}): LilyletDoc => {
1148
1288
  const tunes = parse(abcString);
1149
1289
  if (!tunes || tunes.length === 0) {
1150
1290
  throw new Error("No tunes found in ABC notation");
1151
1291
  }
1152
- return decodeTune(tunes[0]);
1292
+ return decodeTune(tunes[0], options);
1153
1293
  };
1154
1294
 
1155
1295
  /**
1156
1296
  * Decode ABC notation string to multiple LilyletDocs (one per tune).
1157
1297
  */
1158
- export const decodeAll = (abcString: string): LilyletDoc[] => {
1298
+ export const decodeAll = (abcString: string, options: DecodeOptions = {}): LilyletDoc[] => {
1159
1299
  const tunes = parse(abcString);
1160
1300
  if (!tunes || tunes.length === 0) {
1161
1301
  throw new Error("No tunes found in ABC notation");
1162
1302
  }
1163
- return tunes.map(decodeTune);
1303
+ return tunes.map(t => decodeTune(t, options));
1164
1304
  };
1165
1305
 
1166
1306
  /**
1167
1307
  * Decode an ABC file to LilyletDoc
1168
1308
  */
1169
- export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
1309
+ export const decodeFile = async (filePath: string, options: DecodeOptions = {}): Promise<LilyletDoc> => {
1170
1310
  const fs = await import("fs/promises");
1171
1311
  const content = await fs.readFile(filePath, "utf-8");
1172
- return decode(content);
1312
+ return decode(content, options);
1173
1313
  };
1174
1314
 
1175
1315
  export default {