@k-l-lambda/lilylet 0.1.50 → 0.1.51

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.
@@ -31,6 +31,7 @@ import {
31
31
  PedalType,
32
32
  Tempo,
33
33
  Placement,
34
+ Metadata,
34
35
  } from "./types";
35
36
 
36
37
 
@@ -259,6 +260,9 @@ const serializeMarks = (marks: Mark[]): string => {
259
260
  if (pedalStr) parts.push(pedalStr);
260
261
  break;
261
262
  }
263
+ case 'fingering':
264
+ parts.push('-' + mark.finger);
265
+ break;
262
266
  }
263
267
  }
264
268
 
@@ -584,14 +588,16 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
584
588
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
585
589
  // If isGrandStaff is true, always output \staff command for clarity
586
590
  // measureContext provides key/time for first voice
587
- // staffClef is the clef for this voice's staff (tracked across measures)
591
+ // allStaffClefs is the clef map for all staves (tracked across measures)
592
+ // emittedClefs tracks which clefs have already been output (avoids duplicates)
588
593
  const serializeVoice = (
589
594
  voice: Voice,
590
595
  currentStaff: number,
591
596
  isGrandStaff: boolean = false,
592
597
  measureContext?: MeasureContext,
593
598
  isFirstVoice: boolean = false,
594
- staffClef?: Clef
599
+ allStaffClefs?: Record<number, Clef>,
600
+ emittedClefs?: Record<number, Clef>
595
601
  ): { str: string; newStaff: number } => {
596
602
  const parts: string[] = [];
597
603
  let prevDuration: Duration | undefined;
@@ -625,25 +631,63 @@ const serializeVoice = (
625
631
  }
626
632
  }
627
633
 
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) {
634
+ // Output clef only if not yet emitted or changed for this staff
635
+ const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
636
+ const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
637
+ if (voiceClef && !clefAlreadyEmitted) {
631
638
  parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
639
+ if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
632
640
  }
641
+ // Skip redundant clef context events if this staff's clef is already established
642
+ const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
633
643
 
634
- // Track if we've already output the clef to avoid duplication
635
- let clefOutputted = !!voiceClef;
644
+ let activeStaff = voice.staff;
645
+ let activeStemDir: StemDirection | undefined;
636
646
 
637
647
  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') {
648
+ if (event.type === 'context') {
640
649
  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
650
+ // Skip context events that belong to a different staff (cross-staff clef/ottava)
651
+ if (ctx.staff && ctx.staff !== voice.staff) {
652
+ continue;
653
+ }
654
+ // Skip clef-only context events if clef already established for this staff
655
+ if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
643
656
  continue;
644
657
  }
645
658
  }
646
659
 
660
+ if (event.type === 'note') {
661
+ const noteEvt = event as NoteEvent;
662
+
663
+ // Cross-staff: emit \staff when note's effective staff differs from active
664
+ const effectiveStaff = noteEvt.staff || voice.staff;
665
+ if (effectiveStaff !== activeStaff) {
666
+ activeStaff = effectiveStaff;
667
+ parts.push('\\staff "' + activeStaff + '"');
668
+ // Emit the target staff's clef if it differs from what was last emitted for this staff
669
+ const targetClef = allStaffClefs?.[activeStaff];
670
+ if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
671
+ parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
672
+ if (emittedClefs) emittedClefs[activeStaff] = targetClef;
673
+ }
674
+ }
675
+
676
+ // Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
677
+ const stemDir = noteEvt.stemDirection;
678
+ if (stemDir !== activeStemDir) {
679
+ if (stemDir === StemDirection.up) {
680
+ parts.push('\\stemUp');
681
+ } else if (stemDir === StemDirection.down) {
682
+ parts.push('\\stemDown');
683
+ } else if (activeStemDir) {
684
+ // Was set, now undefined → reset to neutral
685
+ parts.push('\\stemNeutral');
686
+ }
687
+ activeStemDir = stemDir;
688
+ }
689
+ }
690
+
647
691
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
648
692
  pitchEnv = newEnv;
649
693
 
@@ -656,6 +700,9 @@ const serializeVoice = (
656
700
  prevDuration = (event as NoteEvent).duration;
657
701
  } else if (event.type === 'rest') {
658
702
  prevDuration = (event as RestEvent).duration;
703
+ } else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
704
+ const ctx = event as ContextChange;
705
+ emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
659
706
  }
660
707
  }
661
708
 
@@ -671,7 +718,8 @@ const serializePart = (
671
718
  isGrandStaff: boolean = false,
672
719
  measureContext?: MeasureContext,
673
720
  isFirstPart: boolean = false,
674
- clefsByStaff?: Record<number, Clef>
721
+ clefsByStaff?: Record<number, Clef>,
722
+ emittedClefs?: Record<number, Clef>
675
723
  ): { str: string; newStaff: number } => {
676
724
  if (part.voices.length === 0) {
677
725
  return { str: '', newStaff: currentStaff };
@@ -683,10 +731,8 @@ const serializePart = (
683
731
  for (let i = 0; i < part.voices.length; i++) {
684
732
  const voice = part.voices[i];
685
733
  // Pass measureContext to all voices, isFirstVoice for key/time
686
- // Pass staff clef from clefsByStaff map
687
734
  const isFirstVoice = isFirstPart && i === 0;
688
- const staffClef = clefsByStaff?.[voice.staff];
689
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
735
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
690
736
  voiceStrs.push(str);
691
737
  staff = newStaff;
692
738
  }
@@ -705,7 +751,8 @@ const serializeMeasure = (
705
751
  isGrandStaff: boolean = false,
706
752
  currentKey?: KeySignature,
707
753
  currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
- staffClefs?: Record<number, Clef>
754
+ staffClefs?: Record<number, Clef>,
755
+ emittedClefs?: Record<number, Clef>
709
756
  ): { str: string; newStaff: number } => {
710
757
  const parts: string[] = [];
711
758
 
@@ -723,7 +770,7 @@ const serializeMeasure = (
723
770
  // Parts
724
771
  let staff = currentStaff;
725
772
  if (measure.parts.length === 1) {
726
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
773
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
727
774
  if (partStr) {
728
775
  parts.push(partStr);
729
776
  }
@@ -734,7 +781,7 @@ const serializeMeasure = (
734
781
  for (let i = 0; i < measure.parts.length; i++) {
735
782
  const part = measure.parts[i];
736
783
  // Pass measureContext to all parts, isFirstPart to first part only
737
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
784
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
738
785
  if (str) {
739
786
  partStrs.push(str);
740
787
  }
@@ -753,7 +800,7 @@ const escapeString = (str: string): string => {
753
800
  };
754
801
 
755
802
  // Serialize metadata
756
- const serializeMetadata = (metadata: any): string => {
803
+ const serializeMetadata = (metadata: Metadata): string => {
757
804
  const lines: string[] = [];
758
805
 
759
806
  if (metadata.title) {
@@ -809,6 +856,7 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
809
856
  let currentKey: KeySignature | undefined;
810
857
  let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
811
858
  const staffClefs: Record<number, Clef> = {}; // Track clef per staff
859
+ const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
812
860
 
813
861
  for (let i = 0; i < doc.measures.length; i++) {
814
862
  const measure = doc.measures[i];
@@ -825,13 +873,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
825
873
  for (const voice of part.voices) {
826
874
  for (const event of voice.events) {
827
875
  if (event.type === 'context' && (event as ContextChange).clef) {
828
- staffClefs[voice.staff] = (event as ContextChange).clef!;
876
+ const ctx = event as ContextChange;
877
+ // Use the event's staff if specified (cross-staff), otherwise the voice's staff
878
+ const clefStaff = ctx.staff || voice.staff;
879
+ staffClefs[clefStaff] = ctx.clef!;
829
880
  }
830
881
  }
831
882
  }
832
883
  }
833
884
 
834
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
885
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
835
886
  // Always include measure, even if empty (use space rest for empty measures)
836
887
  measureStrs.push(measureStr || 's1');
837
888
  currentStaff = newStaff;