@k-l-lambda/lilylet 0.1.50 → 0.1.52

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
 
@@ -524,6 +525,10 @@ const noteEventToMEI = (
524
525
  if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
525
526
  chordAttrs += ` staff="${noteOptions.staff}"`;
526
527
  }
528
+ if (noteOptions.tremolo) {
529
+ const stemMod = tremoloToStemMod(noteOptions.tremolo);
530
+ if (stemMod) chordAttrs += ` stem.mod="${stemMod}"`;
531
+ }
527
532
 
528
533
  let result = `${indent}<chord ${chordAttrs}>\n`;
529
534
 
@@ -611,6 +616,21 @@ interface TupletEventResult {
611
616
  arpeggios: ArpegRef[];
612
617
  }
613
618
 
619
+ // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
620
+ // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
621
+ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
622
+ let starts = 0;
623
+ let ends = 0;
624
+ for (const e of event.events) {
625
+ if (e.type === 'note') {
626
+ const markOptions = extractMarkOptions((e as NoteEvent).marks);
627
+ if (markOptions.beamStart) starts++;
628
+ if (markOptions.beamEnd) ends++;
629
+ }
630
+ }
631
+ return starts > 0 && starts === ends;
632
+ };
633
+
614
634
  // Convert TupletEvent to MEI
615
635
  const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
616
636
  // LilyPond \times 2/3 means "multiply duration by 2/3"
@@ -636,18 +656,28 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
636
656
  const turns: TurnRef[] = [];
637
657
  const arpeggios: ArpegRef[] = [];
638
658
 
639
- // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
640
- // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
641
- // Beam state is managed by encodeLayer, not here.
659
+ // Handle internal beam groups: if notes have manual beam marks, respect them
660
+ const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
661
+ let beamOpen = false;
642
662
 
643
663
  for (const e of event.events) {
644
664
  if (e.type === 'note') {
645
- // For cross-staff notation: set note's staff if different from layerStaff
646
665
  const noteEvent = e as NoteEvent;
666
+ const markOptions = extractMarkOptions(noteEvent.marks);
667
+
668
+ // Open beam if this note starts a beam group
669
+ if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
670
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
671
+ beamOpen = true;
672
+ }
673
+
674
+ const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
675
+
676
+ // For cross-staff notation: set note's staff if different from layerStaff
647
677
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
648
678
  ? { ...noteEvent, staff: effectiveStaff }
649
679
  : noteEvent;
650
- const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
680
+ const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
651
681
  xml += result.xml;
652
682
 
653
683
  // Collect slur info
@@ -661,11 +691,23 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
661
691
  if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
662
692
  if (result.turn) turns.push({ startid: result.elementId });
663
693
  if (result.arpeggio) arpeggios.push({ plist: result.elementId });
694
+
695
+ // Close beam if this note ends a beam group
696
+ if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
697
+ xml += `${baseIndent}</beam>\n`;
698
+ beamOpen = false;
699
+ }
664
700
  } else if (e.type === 'rest') {
665
- xml += restEventToMEI(e as RestEvent, baseIndent, keyFifths, ottavaShift);
701
+ const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
702
+ xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift);
666
703
  }
667
704
  }
668
705
 
706
+ // Close any unclosed beam
707
+ if (beamOpen) {
708
+ xml += `${baseIndent}</beam>\n`;
709
+ }
710
+
669
711
  xml += `${indent}</tuplet>\n`;
670
712
  return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
671
713
  };
@@ -865,6 +907,11 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
865
907
  }
866
908
  if (event.type === 'tuplet') {
867
909
  const tuplet = event as TupletEvent;
910
+ // If the tuplet has internal beam groups, don't report beam marks to the parent
911
+ // so the parent won't wrap the tuplet in an external <beam>
912
+ if (tupletHasInternalBeams(tuplet)) {
913
+ return { beamStart: false, beamEnd: false };
914
+ }
868
915
  let beamStart = false;
869
916
  let beamEnd = false;
870
917
  for (const e of tuplet.events) {
@@ -31,6 +31,7 @@ import {
31
31
  PedalType,
32
32
  Tempo,
33
33
  Placement,
34
+ Metadata,
34
35
  } from "./types";
35
36
 
36
37
 
@@ -143,6 +144,7 @@ const DYNAMIC_MAP: Record<string, string> = {
143
144
  fff: '\\fff',
144
145
  sfz: '\\sfz',
145
146
  rfz: '\\rfz',
147
+ fp: '\\fp',
146
148
  };
147
149
 
148
150
 
@@ -259,6 +261,9 @@ const serializeMarks = (marks: Mark[]): string => {
259
261
  if (pedalStr) parts.push(pedalStr);
260
262
  break;
261
263
  }
264
+ case 'fingering':
265
+ parts.push('-' + mark.finger);
266
+ break;
262
267
  }
263
268
  }
264
269
 
@@ -584,14 +589,16 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
584
589
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
585
590
  // If isGrandStaff is true, always output \staff command for clarity
586
591
  // measureContext provides key/time for first voice
587
- // staffClef is the clef for this voice's staff (tracked across measures)
592
+ // allStaffClefs is the clef map for all staves (tracked across measures)
593
+ // emittedClefs tracks which clefs have already been output (avoids duplicates)
588
594
  const serializeVoice = (
589
595
  voice: Voice,
590
596
  currentStaff: number,
591
597
  isGrandStaff: boolean = false,
592
598
  measureContext?: MeasureContext,
593
599
  isFirstVoice: boolean = false,
594
- staffClef?: Clef
600
+ allStaffClefs?: Record<number, Clef>,
601
+ emittedClefs?: Record<number, Clef>
595
602
  ): { str: string; newStaff: number } => {
596
603
  const parts: string[] = [];
597
604
  let prevDuration: Duration | undefined;
@@ -625,25 +632,63 @@ const serializeVoice = (
625
632
  }
626
633
  }
627
634
 
628
- // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
629
- const voiceClef = staffClef || findVoiceClef(voice);
630
- if (voiceClef) {
635
+ // Output clef only if not yet emitted or changed for this staff
636
+ const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
637
+ const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
638
+ if (voiceClef && !clefAlreadyEmitted) {
631
639
  parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
640
+ if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
632
641
  }
642
+ // Skip redundant clef context events if this staff's clef is already established
643
+ const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
633
644
 
634
- // Track if we've already output the clef to avoid duplication
635
- let clefOutputted = !!voiceClef;
645
+ let activeStaff = voice.staff;
646
+ let activeStemDir: StemDirection | undefined;
636
647
 
637
648
  for (const event of voice.events) {
638
- // Skip clef context events if we've already output the clef at the beginning
639
- if (clefOutputted && event.type === 'context') {
649
+ if (event.type === 'context') {
640
650
  const ctx = event as ContextChange;
641
- if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
642
- // This is a clef-only context event, skip it
651
+ // Skip context events that belong to a different staff (cross-staff clef/ottava)
652
+ if (ctx.staff && ctx.staff !== voice.staff) {
653
+ continue;
654
+ }
655
+ // Skip clef-only context events if clef already established for this staff
656
+ if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
643
657
  continue;
644
658
  }
645
659
  }
646
660
 
661
+ if (event.type === 'note') {
662
+ const noteEvt = event as NoteEvent;
663
+
664
+ // Cross-staff: emit \staff when note's effective staff differs from active
665
+ const effectiveStaff = noteEvt.staff || voice.staff;
666
+ if (effectiveStaff !== activeStaff) {
667
+ activeStaff = effectiveStaff;
668
+ parts.push('\\staff "' + activeStaff + '"');
669
+ // Emit the target staff's clef if it differs from what was last emitted for this staff
670
+ const targetClef = allStaffClefs?.[activeStaff];
671
+ if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
672
+ parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
673
+ if (emittedClefs) emittedClefs[activeStaff] = targetClef;
674
+ }
675
+ }
676
+
677
+ // Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
678
+ const stemDir = noteEvt.stemDirection;
679
+ if (stemDir !== activeStemDir) {
680
+ if (stemDir === StemDirection.up) {
681
+ parts.push('\\stemUp');
682
+ } else if (stemDir === StemDirection.down) {
683
+ parts.push('\\stemDown');
684
+ } else if (activeStemDir) {
685
+ // Was set, now undefined → reset to neutral
686
+ parts.push('\\stemNeutral');
687
+ }
688
+ activeStemDir = stemDir;
689
+ }
690
+ }
691
+
647
692
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
648
693
  pitchEnv = newEnv;
649
694
 
@@ -656,6 +701,9 @@ const serializeVoice = (
656
701
  prevDuration = (event as NoteEvent).duration;
657
702
  } else if (event.type === 'rest') {
658
703
  prevDuration = (event as RestEvent).duration;
704
+ } else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
705
+ const ctx = event as ContextChange;
706
+ emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
659
707
  }
660
708
  }
661
709
 
@@ -671,7 +719,8 @@ const serializePart = (
671
719
  isGrandStaff: boolean = false,
672
720
  measureContext?: MeasureContext,
673
721
  isFirstPart: boolean = false,
674
- clefsByStaff?: Record<number, Clef>
722
+ clefsByStaff?: Record<number, Clef>,
723
+ emittedClefs?: Record<number, Clef>
675
724
  ): { str: string; newStaff: number } => {
676
725
  if (part.voices.length === 0) {
677
726
  return { str: '', newStaff: currentStaff };
@@ -683,10 +732,8 @@ const serializePart = (
683
732
  for (let i = 0; i < part.voices.length; i++) {
684
733
  const voice = part.voices[i];
685
734
  // Pass measureContext to all voices, isFirstVoice for key/time
686
- // Pass staff clef from clefsByStaff map
687
735
  const isFirstVoice = isFirstPart && i === 0;
688
- const staffClef = clefsByStaff?.[voice.staff];
689
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
736
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
690
737
  voiceStrs.push(str);
691
738
  staff = newStaff;
692
739
  }
@@ -705,7 +752,8 @@ const serializeMeasure = (
705
752
  isGrandStaff: boolean = false,
706
753
  currentKey?: KeySignature,
707
754
  currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
- staffClefs?: Record<number, Clef>
755
+ staffClefs?: Record<number, Clef>,
756
+ emittedClefs?: Record<number, Clef>
709
757
  ): { str: string; newStaff: number } => {
710
758
  const parts: string[] = [];
711
759
 
@@ -723,7 +771,7 @@ const serializeMeasure = (
723
771
  // Parts
724
772
  let staff = currentStaff;
725
773
  if (measure.parts.length === 1) {
726
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
774
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
727
775
  if (partStr) {
728
776
  parts.push(partStr);
729
777
  }
@@ -734,7 +782,7 @@ const serializeMeasure = (
734
782
  for (let i = 0; i < measure.parts.length; i++) {
735
783
  const part = measure.parts[i];
736
784
  // Pass measureContext to all parts, isFirstPart to first part only
737
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
785
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
738
786
  if (str) {
739
787
  partStrs.push(str);
740
788
  }
@@ -753,7 +801,7 @@ const escapeString = (str: string): string => {
753
801
  };
754
802
 
755
803
  // Serialize metadata
756
- const serializeMetadata = (metadata: any): string => {
804
+ const serializeMetadata = (metadata: Metadata): string => {
757
805
  const lines: string[] = [];
758
806
 
759
807
  if (metadata.title) {
@@ -809,6 +857,7 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
809
857
  let currentKey: KeySignature | undefined;
810
858
  let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
811
859
  const staffClefs: Record<number, Clef> = {}; // Track clef per staff
860
+ const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
812
861
 
813
862
  for (let i = 0; i < doc.measures.length; i++) {
814
863
  const measure = doc.measures[i];
@@ -825,13 +874,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
825
874
  for (const voice of part.voices) {
826
875
  for (const event of voice.events) {
827
876
  if (event.type === 'context' && (event as ContextChange).clef) {
828
- staffClefs[voice.staff] = (event as ContextChange).clef!;
877
+ const ctx = event as ContextChange;
878
+ // Use the event's staff if specified (cross-staff), otherwise the voice's staff
879
+ const clefStaff = ctx.staff || voice.staff;
880
+ staffClefs[clefStaff] = ctx.clef!;
829
881
  }
830
882
  }
831
883
  }
832
884
  }
833
885
 
834
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
886
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
835
887
  // Always include measure, even if empty (use space rest for empty measures)
836
888
  measureStrs.push(measureStr || 's1');
837
889
  currentStaff = newStaff;
@@ -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 {