@k-l-lambda/lilylet 0.1.51 → 0.1.53

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.
@@ -125,6 +125,7 @@ const DYNAMIC_MAP: Record<string, string> = {
125
125
  fff: "fff",
126
126
  sfz: "sfz",
127
127
  rfz: "rfz",
128
+ fp: "fp",
128
129
  };
129
130
 
130
131
 
@@ -171,10 +172,12 @@ const getKeyAccidentals = (fifths: number): Record<string, string> => {
171
172
  return result;
172
173
  };
173
174
 
174
- // Convert Pitch to MEI attributes, checking against key signature
175
+ // Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
175
176
  // ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
176
177
  // The written pitch should be adjusted by subtracting the ottava shift
177
- const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0): { pname: string; oct: number; accid?: string; accidGes?: string } => {
178
+ // measureAccidentals: tracks accidentals used earlier in the same measure (keyed by "pname-oct")
179
+ // - mutated to record new accidentals; used to add cancellation naturals
180
+ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): { pname: string; oct: number; accid?: string; accidGes?: string } => {
178
181
  // Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
179
182
  // When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
180
183
  // For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
@@ -188,18 +191,40 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
188
191
  let accid: string | undefined;
189
192
  let accidGes: string | undefined;
190
193
 
194
+ const pitchKey = `${pitch.phonet}-${oct}`;
195
+
196
+ // Check what was previously established for this pitch in this measure
197
+ const prevMeasureAccid = measureAccidentals?.get(pitchKey);
198
+
191
199
  if (pitch.accidental) {
192
200
  const noteAccid = ACCIDENTALS[pitch.accidental];
193
- if (noteAccid !== keyAccid) {
201
+ if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
202
+ // Previous note in this measure had a different accidental - must re-assert
203
+ accid = noteAccid;
204
+ } else if (noteAccid !== keyAccid) {
194
205
  // Accidental differs from key signature - display it
195
206
  accid = noteAccid;
196
207
  }
197
208
  // Always set gestural accidental for MIDI generation
198
209
  accidGes = noteAccid;
210
+ // Record this accidental for in-measure tracking
211
+ if (measureAccidentals) measureAccidentals.set(pitchKey, noteAccid);
199
212
  } else if (keyAccid) {
200
213
  // Note has no accidental but key implies one - output natural
201
- accid = 'n';
214
+ if (prevMeasureAccid === 'n') {
215
+ // Already cancelled earlier in this measure - no need to show again
216
+ } else {
217
+ accid = 'n';
218
+ }
202
219
  accidGes = 'n';
220
+ if (measureAccidentals) measureAccidentals.set(pitchKey, 'n');
221
+ } else if (measureAccidentals) {
222
+ // No explicit accidental, no key accidental - check if earlier note in measure had one
223
+ if (prevMeasureAccid && prevMeasureAccid !== 'n') {
224
+ // Previous note had an accidental - add cancellation natural
225
+ accid = 'n';
226
+ measureAccidentals.set(pitchKey, 'n');
227
+ }
203
228
  }
204
229
 
205
230
  return { pname: pitch.phonet, oct, accid, accidGes };
@@ -456,7 +481,8 @@ const noteEventToMEI = (
456
481
  tieEnd?: boolean,
457
482
  contextStemDir?: StemDirection,
458
483
  keyFifths: number = 0,
459
- ottavaShift: number = 0
484
+ ottavaShift: number = 0,
485
+ measureAccidentals?: Map<string, string>
460
486
  ): NoteEventResult => {
461
487
  const dur = DURATIONS[event.duration.division] || "4";
462
488
  const dots = event.duration.dots || 0;
@@ -491,7 +517,7 @@ const noteEventToMEI = (
491
517
 
492
518
  // Single note
493
519
  if (event.pitches.length === 1) {
494
- const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
520
+ const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
495
521
  const noteId = generateId('note');
496
522
  return {
497
523
  xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
@@ -532,7 +558,7 @@ const noteEventToMEI = (
532
558
  let result = `${indent}<chord ${chordAttrs}>\n`;
533
559
 
534
560
  for (const p of event.pitches) {
535
- const pitch = encodePitch(p, keyFifths, ottavaShift);
561
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
536
562
  result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
537
563
  }
538
564
 
@@ -577,7 +603,7 @@ const noteEventToMEI = (
577
603
 
578
604
 
579
605
  // Convert RestEvent to MEI
580
- const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0): string => {
606
+ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): string => {
581
607
  const dur = DURATIONS[event.duration.division] || "4";
582
608
  let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
583
609
  if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
@@ -605,6 +631,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
605
631
  // TupletEventResult - return type for tupletEventToMEI
606
632
  interface TupletEventResult {
607
633
  xml: string;
634
+ firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
608
635
  slurStarts: string[]; // Note IDs that start slurs
609
636
  slurEnds: string[]; // Note IDs that end slurs
610
637
  dynamics: DynamRef[];
@@ -615,8 +642,23 @@ interface TupletEventResult {
615
642
  arpeggios: ArpegRef[];
616
643
  }
617
644
 
645
+ // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
646
+ // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
647
+ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
648
+ let starts = 0;
649
+ let ends = 0;
650
+ for (const e of event.events) {
651
+ if (e.type === 'note') {
652
+ const markOptions = extractMarkOptions((e as NoteEvent).marks);
653
+ if (markOptions.beamStart) starts++;
654
+ if (markOptions.beamEnd) ends++;
655
+ }
656
+ }
657
+ return starts > 0 && starts === ends;
658
+ };
659
+
618
660
  // Convert TupletEvent to MEI
619
- const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
661
+ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>): TupletEventResult => {
620
662
  // LilyPond \times 2/3 means "multiply duration by 2/3"
621
663
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
622
664
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -631,6 +673,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
631
673
  const effectiveStaff = currentStaff ?? layerStaff;
632
674
 
633
675
  // Collect control event info from notes inside tuplet
676
+ let firstNoteId: string | null = null;
634
677
  const slurStarts: string[] = [];
635
678
  const slurEnds: string[] = [];
636
679
  const dynamics: DynamRef[] = [];
@@ -640,20 +683,32 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
640
683
  const turns: TurnRef[] = [];
641
684
  const arpeggios: ArpegRef[] = [];
642
685
 
643
- // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
644
- // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
645
- // Beam state is managed by encodeLayer, not here.
686
+ // Handle internal beam groups: if notes have manual beam marks, respect them
687
+ const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
688
+ let beamOpen = false;
646
689
 
647
690
  for (const e of event.events) {
648
691
  if (e.type === 'note') {
649
- // For cross-staff notation: set note's staff if different from layerStaff
650
692
  const noteEvent = e as NoteEvent;
693
+ const markOptions = extractMarkOptions(noteEvent.marks);
694
+
695
+ // Open beam if this note starts a beam group
696
+ if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
697
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
698
+ beamOpen = true;
699
+ }
700
+
701
+ const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
702
+
703
+ // For cross-staff notation: set note's staff if different from layerStaff
651
704
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
652
705
  ? { ...noteEvent, staff: effectiveStaff }
653
706
  : noteEvent;
654
- const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
707
+ const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
655
708
  xml += result.xml;
656
709
 
710
+ if (!firstNoteId) firstNoteId = result.elementId;
711
+
657
712
  // Collect slur info
658
713
  if (result.slurStart) slurStarts.push(result.elementId);
659
714
  if (result.slurEnd) slurEnds.push(result.elementId);
@@ -665,18 +720,30 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
665
720
  if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
666
721
  if (result.turn) turns.push({ startid: result.elementId });
667
722
  if (result.arpeggio) arpeggios.push({ plist: result.elementId });
723
+
724
+ // Close beam if this note ends a beam group
725
+ if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
726
+ xml += `${baseIndent}</beam>\n`;
727
+ beamOpen = false;
728
+ }
668
729
  } else if (e.type === 'rest') {
669
- xml += restEventToMEI(e as RestEvent, baseIndent, keyFifths, ottavaShift);
730
+ const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
731
+ xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
670
732
  }
671
733
  }
672
734
 
735
+ // Close any unclosed beam
736
+ if (beamOpen) {
737
+ xml += `${baseIndent}</beam>\n`;
738
+ }
739
+
673
740
  xml += `${indent}</tuplet>\n`;
674
- return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
741
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
675
742
  };
676
743
 
677
744
 
678
745
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
679
- const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0): string => {
746
+ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): string => {
680
747
  const ftremId = generateId('fTrem');
681
748
 
682
749
  // For \repeat tremolo 4 { c16 d16 }:
@@ -700,7 +767,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
700
767
 
701
768
  // First note (or chord)
702
769
  if (event.pitchA.length === 1) {
703
- const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
770
+ const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
704
771
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
705
772
  if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
706
773
  if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
@@ -708,7 +775,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
708
775
  } else if (event.pitchA.length > 1) {
709
776
  result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
710
777
  for (const p of event.pitchA) {
711
- const pitch = encodePitch(p, keyFifths, ottavaShift);
778
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
712
779
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
713
780
  if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
714
781
  if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
@@ -719,7 +786,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
719
786
 
720
787
  // Second note (or chord)
721
788
  if (event.pitchB.length === 1) {
722
- const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
789
+ const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
723
790
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
724
791
  if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
725
792
  if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
@@ -727,7 +794,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
727
794
  } else if (event.pitchB.length > 1) {
728
795
  result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
729
796
  for (const p of event.pitchB) {
730
- const pitch = encodePitch(p, keyFifths, ottavaShift);
797
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
731
798
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
732
799
  if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
733
800
  if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
@@ -869,6 +936,11 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
869
936
  }
870
937
  if (event.type === 'tuplet') {
871
938
  const tuplet = event as TupletEvent;
939
+ // If the tuplet has internal beam groups, don't report beam marks to the parent
940
+ // so the parent won't wrap the tuplet in an external <beam>
941
+ if (tupletHasInternalBeams(tuplet)) {
942
+ return { beamStart: false, beamEnd: false };
943
+ }
872
944
  let beamStart = false;
873
945
  let beamEnd = false;
874
946
  for (const e of tuplet.events) {
@@ -928,6 +1000,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
928
1000
  const harmonies: HarmonyRef[] = [];
929
1001
  const barlines: BarlineRef[] = [];
930
1002
  const markups: MarkupRef[] = [];
1003
+ const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
931
1004
 
932
1005
  // Track current stem direction from context changes
933
1006
  let currentStemDirection: StemDirection | undefined = undefined;
@@ -935,9 +1008,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
935
1008
  // Track current staff for cross-staff notation
936
1009
  let currentStaff: number = voice.staff || 1;
937
1010
 
1011
+ // Track in-measure accidentals for cancellation naturals
1012
+ const measureAccidentals = new Map<string, string>();
1013
+
938
1014
  // Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
939
1015
  let pendingTiePitches: Pitch[] = [...initialTiePitches];
940
1016
 
1017
+ // Helper to flush pending markups onto a note ID
1018
+ const flushPendingMarkups = (noteId: string) => {
1019
+ for (const mkup of pendingMarkups) {
1020
+ markups.push({ startid: noteId, content: mkup.content, placement: mkup.placement });
1021
+ }
1022
+ pendingMarkups.length = 0;
1023
+ };
1024
+
941
1025
  // Helper to check if pitches match for tie continuation
942
1026
  const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
943
1027
  if (p1.length !== p2.length) return false;
@@ -975,10 +1059,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
975
1059
  ? { ...noteEvent, staff: currentStaff }
976
1060
  : noteEvent;
977
1061
 
978
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
1062
+ const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
979
1063
  xml += result.xml;
980
1064
  lastNoteId = result.elementId;
981
1065
 
1066
+ // Flush any pending markups onto this note
1067
+ flushPendingMarkups(result.elementId);
1068
+
982
1069
  // If there's a pending ottava, start the span on this note
983
1070
  if (pendingOttava !== null && pendingOttava !== 0) {
984
1071
  const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -1081,14 +1168,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1081
1168
  break;
1082
1169
  }
1083
1170
  case 'rest':
1084
- xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
1171
+ xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
1085
1172
  break;
1086
1173
  case 'tuplet': {
1087
1174
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1088
1175
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1089
- const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
1176
+ const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
1090
1177
  xml += tupletResult.xml;
1091
1178
 
1179
+ // Flush any pending markups onto the first note of the tuplet
1180
+ if (tupletResult.firstNoteId) {
1181
+ flushPendingMarkups(tupletResult.firstNoteId);
1182
+ lastNoteId = tupletResult.firstNoteId;
1183
+ }
1184
+
1092
1185
  // Process slur ends first (to close any pending slurs from before this tuplet)
1093
1186
  for (const endId of tupletResult.slurEnds) {
1094
1187
  if (currentSlur) {
@@ -1116,7 +1209,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1116
1209
  break;
1117
1210
  }
1118
1211
  case 'tremolo':
1119
- xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift);
1212
+ xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
1120
1213
  break;
1121
1214
  case 'context': {
1122
1215
  const ctx = event as ContextChange;
@@ -1181,16 +1274,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1181
1274
  harmonies.push({ startid: lastNoteId, text: (event as HarmonyEvent).text });
1182
1275
  }
1183
1276
  break;
1184
- case 'markup':
1185
- // Markup needs a note ID to attach to - use the last note if available
1277
+ case 'markup': {
1278
+ // Markup needs a note ID to attach to
1279
+ const mkupEvent = event as MarkupEvent;
1186
1280
  if (lastNoteId) {
1187
- const mkupEvent = event as MarkupEvent;
1188
1281
  markups.push({
1189
1282
  startid: lastNoteId,
1190
1283
  content: mkupEvent.content,
1191
1284
  placement: mkupEvent.placement,
1192
1285
  });
1286
+ } else {
1287
+ // No note yet - save as pending, will attach to next note
1288
+ pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
1193
1289
  }
1290
+ }
1194
1291
  break;
1195
1292
  }
1196
1293
 
@@ -144,6 +144,7 @@ const DYNAMIC_MAP: Record<string, string> = {
144
144
  fff: '\\fff',
145
145
  sfz: '\\sfz',
146
146
  rfz: '\\rfz',
147
+ fp: '\\fp',
147
148
  };
148
149
 
149
150
 
@@ -60,6 +60,7 @@ export enum DynamicType {
60
60
  fff = 'fff',
61
61
  sfz = 'sfz',
62
62
  rfz = 'rfz',
63
+ fp = 'fp',
63
64
  }
64
65
 
65
66
  export enum HairpinType {