@k-l-lambda/lilylet 0.1.33 → 0.1.34

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.
package/lib/meiEncoder.js CHANGED
@@ -470,14 +470,13 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
470
470
  return `${indent}<rest ${attrs} />\n`;
471
471
  };
472
472
  // Convert TupletEvent to MEI
473
- const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0) => {
473
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
474
474
  // LilyPond \times 2/3 means "multiply duration by 2/3"
475
475
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
476
476
  // MEI: num = number of notes written, numbase = normal equivalent
477
477
  const num = event.ratio.denominator; // denominator = actual note count
478
478
  const numbase = event.ratio.numerator; // numerator = time equivalent
479
479
  let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
480
- let inBeam = false;
481
480
  const baseIndent = indent + ' ';
482
481
  // Effective staff for cross-staff notation
483
482
  const effectiveStaff = currentStaff ?? layerStaff;
@@ -490,28 +489,17 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
490
489
  const mordents = [];
491
490
  const turns = [];
492
491
  const arpeggios = [];
492
+ // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
493
+ // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
494
+ // Beam state is managed by encodeLayer, not here.
493
495
  for (const e of event.events) {
494
- // Check for beam marks in note events
495
- let beamStart = false;
496
- let beamEnd = false;
497
- if (e.type === 'note') {
498
- const markOptions = extractMarkOptions(e.marks);
499
- beamStart = markOptions.beamStart;
500
- beamEnd = markOptions.beamEnd;
501
- }
502
- // Open beam element if beam starts
503
- if (beamStart && !inBeam) {
504
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
505
- inBeam = true;
506
- }
507
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
508
496
  if (e.type === 'note') {
509
497
  // For cross-staff notation: set note's staff if different from layerStaff
510
498
  const noteEvent = e;
511
499
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
512
500
  ? { ...noteEvent, staff: effectiveStaff }
513
501
  : noteEvent;
514
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
502
+ const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
515
503
  xml += result.xml;
516
504
  // Collect slur info
517
505
  if (result.slurStart)
@@ -533,17 +521,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
533
521
  arpeggios.push({ plist: result.elementId });
534
522
  }
535
523
  else if (e.type === 'rest') {
536
- xml += restEventToMEI(e, currentIndent, keyFifths, ottavaShift);
524
+ xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
537
525
  }
538
- // Close beam element if beam ends
539
- if (beamEnd && inBeam) {
540
- xml += `${baseIndent}</beam>\n`;
541
- inBeam = false;
542
- }
543
- }
544
- // Close any unclosed beam
545
- if (inBeam) {
546
- xml += `${baseIndent}</beam>\n`;
547
526
  }
548
527
  xml += `${indent}</tuplet>\n`;
549
528
  return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
@@ -615,11 +594,34 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
615
594
  result += `${indent}</fTrem>\n`;
616
595
  return result;
617
596
  };
597
+ // Helper: check if an event (or any note inside a tuplet) has beam start/end
598
+ const getEventBeamMarks = (event) => {
599
+ if (event.type === 'note') {
600
+ const markOptions = extractMarkOptions(event.marks);
601
+ return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
602
+ }
603
+ if (event.type === 'tuplet') {
604
+ const tuplet = event;
605
+ let beamStart = false;
606
+ let beamEnd = false;
607
+ for (const e of tuplet.events) {
608
+ if (e.type === 'note') {
609
+ const markOptions = extractMarkOptions(e.marks);
610
+ if (markOptions.beamStart)
611
+ beamStart = true;
612
+ if (markOptions.beamEnd)
613
+ beamEnd = true;
614
+ }
615
+ }
616
+ return { beamStart, beamEnd };
617
+ }
618
+ return { beamStart: false, beamEnd: false };
619
+ };
618
620
  // Encode a layer (voice)
619
621
  const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null) => {
620
622
  const layerId = generateId("layer");
621
623
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
622
- let inBeam = false;
624
+ let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
623
625
  const baseIndent = indent + ' ';
624
626
  // Track current clef to only emit changes
625
627
  let currentClef = initialClef;
@@ -667,21 +669,14 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
667
669
  return true;
668
670
  };
669
671
  for (const event of voice.events) {
670
- // Check for beam start/end in note events
671
- let beamStart = false;
672
- let beamEnd = false;
673
- if (event.type === 'note') {
674
- const noteEvent = event;
675
- const markOptions = extractMarkOptions(noteEvent.marks);
676
- beamStart = markOptions.beamStart;
677
- beamEnd = markOptions.beamEnd;
678
- }
672
+ // Check for beam start/end in this event (including inside tuplets)
673
+ const { beamStart, beamEnd } = getEventBeamMarks(event);
679
674
  // Open beam element if beam starts
680
- if (beamStart && !inBeam) {
675
+ if (beamStart && !beamElementOpen) {
681
676
  xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
682
- inBeam = true;
677
+ beamElementOpen = true;
683
678
  }
684
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
679
+ const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
685
680
  switch (event.type) {
686
681
  case 'note': {
687
682
  const noteEvent = event;
@@ -790,7 +785,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
790
785
  xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
791
786
  break;
792
787
  case 'tuplet': {
793
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift);
788
+ // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
789
+ // Pass beamElementOpen to tuplet so it knows not to create its own beam
790
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
794
791
  xml += tupletResult.xml;
795
792
  // Process slur ends first (to close any pending slurs from before this tuplet)
796
793
  for (const endId of tupletResult.slurEnds) {
@@ -883,13 +880,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
883
880
  break;
884
881
  }
885
882
  // Close beam element if beam ends
886
- if (beamEnd && inBeam) {
883
+ if (beamEnd && beamElementOpen) {
887
884
  xml += `${baseIndent}</beam>\n`;
888
- inBeam = false;
885
+ beamElementOpen = false;
889
886
  }
890
887
  }
891
888
  // Close any unclosed beam
892
- if (inBeam) {
889
+ if (beamElementOpen) {
893
890
  xml += `${baseIndent}</beam>\n`;
894
891
  }
895
892
  // Close any unclosed ottava span at end of layer
@@ -60,6 +60,75 @@ class SpannerTracker {
60
60
  this.ties.clear();
61
61
  }
62
62
  }
63
+ // ============ Tuplet Tracker ============
64
+ /**
65
+ * Track tuplet groups by number attribute.
66
+ * Collects notes between tuplet start and stop to create TupletEvent.
67
+ */
68
+ class TupletTracker {
69
+ // Map from tuplet number to collected events and ratio
70
+ activeTuplets = new Map();
71
+ /**
72
+ * Start a new tuplet group
73
+ */
74
+ startTuplet(number = 1) {
75
+ this.activeTuplets.set(number, { events: [] });
76
+ }
77
+ /**
78
+ * Add an event to active tuplet(s)
79
+ * Returns true if the event was added to at least one tuplet
80
+ */
81
+ addEvent(event) {
82
+ if (this.activeTuplets.size === 0)
83
+ return false;
84
+ // Add to all active tuplets (in case of nested tuplets)
85
+ for (const [, tuplet] of this.activeTuplets) {
86
+ // Set ratio from first event's duration.tuplet
87
+ if (!tuplet.ratio && event.duration.tuplet) {
88
+ // In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
89
+ tuplet.ratio = {
90
+ numerator: event.duration.tuplet.denominator,
91
+ denominator: event.duration.tuplet.numerator,
92
+ };
93
+ }
94
+ // Store event without tuplet info in duration (it's handled at TupletEvent level)
95
+ const cleanEvent = { ...event, duration: { ...event.duration } };
96
+ delete cleanEvent.duration.tuplet;
97
+ tuplet.events.push(cleanEvent);
98
+ }
99
+ return true;
100
+ }
101
+ /**
102
+ * Stop a tuplet group and return the TupletEvent
103
+ */
104
+ stopTuplet(number = 1) {
105
+ const tuplet = this.activeTuplets.get(number);
106
+ if (!tuplet || tuplet.events.length === 0) {
107
+ this.activeTuplets.delete(number);
108
+ return undefined;
109
+ }
110
+ this.activeTuplets.delete(number);
111
+ // Default ratio if not set (shouldn't happen normally)
112
+ const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
113
+ return {
114
+ type: 'tuplet',
115
+ ratio,
116
+ events: tuplet.events,
117
+ };
118
+ }
119
+ /**
120
+ * Check if any tuplet is active
121
+ */
122
+ isActive() {
123
+ return this.activeTuplets.size > 0;
124
+ }
125
+ /**
126
+ * Reset tracker
127
+ */
128
+ reset() {
129
+ this.activeTuplets.clear();
130
+ }
131
+ }
63
132
  class VoiceTracker {
64
133
  voices = new Map();
65
134
  currentPosition = 0;
@@ -737,7 +806,7 @@ const directionToMarks = (direction, spannerTracker) => {
737
806
  /**
738
807
  * Convert a MusicXML measure to Lilylet events, grouped by voice
739
808
  */
740
- const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker) => {
809
+ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker) => {
741
810
  let key;
742
811
  let timeSig;
743
812
  let barline;
@@ -782,6 +851,11 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
782
851
  const voiceNum = note.voice;
783
852
  const staffNum = note.staff || 1;
784
853
  currentVoice = voiceNum;
854
+ // Check for tuplet start BEFORE processing the note
855
+ const tupletNotation = note.notations?.tuplet;
856
+ if (tupletNotation?.type === 'start') {
857
+ tupletTracker.startTuplet(tupletNotation.number);
858
+ }
785
859
  // Add any pending context changes before the note (tempo, ottava)
786
860
  if (pendingContextChanges.length > 0) {
787
861
  for (const ctx of pendingContextChanges) {
@@ -801,7 +875,13 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
801
875
  };
802
876
  // Grace notes don't advance time
803
877
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
804
- voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
878
+ // Check if we're in a tuplet
879
+ if (tupletTracker.isActive()) {
880
+ tupletTracker.addEvent(restEvent);
881
+ }
882
+ else {
883
+ voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
884
+ }
805
885
  }
806
886
  else if (note.pitch) {
807
887
  // Note or chord - convert MusicXmlPitch to Lilylet Pitch
@@ -858,7 +938,30 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
858
938
  }
859
939
  // Grace notes don't advance time
860
940
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
861
- voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
941
+ // Check if we're in a tuplet
942
+ if (tupletTracker.isActive()) {
943
+ tupletTracker.addEvent(noteEvent);
944
+ }
945
+ else {
946
+ voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
947
+ }
948
+ }
949
+ // Check for tuplet stop AFTER processing the note
950
+ if (tupletNotation?.type === 'stop') {
951
+ const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
952
+ if (tupletEvent) {
953
+ // Calculate total duration of tuplet for voiceTracker
954
+ let totalDuration = 0;
955
+ for (const evt of tupletEvent.events) {
956
+ if (evt.duration) {
957
+ // Convert division to duration units (quarter = 1)
958
+ totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
959
+ }
960
+ }
961
+ // Apply tuplet ratio to get actual duration
962
+ totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
963
+ voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
964
+ }
862
965
  }
863
966
  }
864
967
  else if (tagName === 'direction') {
@@ -917,6 +1020,7 @@ const convertPart = (partEl) => {
917
1020
  const voiceTracker = new VoiceTracker();
918
1021
  const spannerTracker = new SpannerTracker();
919
1022
  const ottavaTracker = { current: 0 };
1023
+ const tupletTracker = new TupletTracker();
920
1024
  let lastKey;
921
1025
  let lastTimeSig;
922
1026
  let isFirstMeasure = true;
@@ -924,7 +1028,7 @@ const convertPart = (partEl) => {
924
1028
  const measureEls = getDirectChildren(partEl, 'measure');
925
1029
  for (const measureEl of measureEls) {
926
1030
  voiceTracker.reset();
927
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
1031
+ const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
928
1032
  // Update running key/time
929
1033
  if (key)
930
1034
  lastKey = key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -609,7 +609,7 @@ interface TupletEventResult {
609
609
  }
610
610
 
611
611
  // Convert TupletEvent to MEI
612
- const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0): TupletEventResult => {
612
+ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
613
613
  // LilyPond \times 2/3 means "multiply duration by 2/3"
614
614
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
615
615
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -618,7 +618,6 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
618
618
 
619
619
  let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
620
620
 
621
- let inBeam = false;
622
621
  const baseIndent = indent + ' ';
623
622
 
624
623
  // Effective staff for cross-staff notation
@@ -634,31 +633,18 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
634
633
  const turns: TurnRef[] = [];
635
634
  const arpeggios: ArpegRef[] = [];
636
635
 
637
- for (const e of event.events) {
638
- // Check for beam marks in note events
639
- let beamStart = false;
640
- let beamEnd = false;
641
- if (e.type === 'note') {
642
- const markOptions = extractMarkOptions((e as NoteEvent).marks);
643
- beamStart = markOptions.beamStart;
644
- beamEnd = markOptions.beamEnd;
645
- }
646
-
647
- // Open beam element if beam starts
648
- if (beamStart && !inBeam) {
649
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
650
- inBeam = true;
651
- }
652
-
653
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
636
+ // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
637
+ // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
638
+ // Beam state is managed by encodeLayer, not here.
654
639
 
640
+ for (const e of event.events) {
655
641
  if (e.type === 'note') {
656
642
  // For cross-staff notation: set note's staff if different from layerStaff
657
643
  const noteEvent = e as NoteEvent;
658
644
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
659
645
  ? { ...noteEvent, staff: effectiveStaff }
660
646
  : noteEvent;
661
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
647
+ const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
662
648
  xml += result.xml;
663
649
 
664
650
  // Collect slur info
@@ -673,21 +659,10 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
673
659
  if (result.turn) turns.push({ startid: result.elementId });
674
660
  if (result.arpeggio) arpeggios.push({ plist: result.elementId });
675
661
  } else if (e.type === 'rest') {
676
- xml += restEventToMEI(e as RestEvent, currentIndent, keyFifths, ottavaShift);
677
- }
678
-
679
- // Close beam element if beam ends
680
- if (beamEnd && inBeam) {
681
- xml += `${baseIndent}</beam>\n`;
682
- inBeam = false;
662
+ xml += restEventToMEI(e as RestEvent, baseIndent, keyFifths, ottavaShift);
683
663
  }
684
664
  }
685
665
 
686
- // Close any unclosed beam
687
- if (inBeam) {
688
- xml += `${baseIndent}</beam>\n`;
689
- }
690
-
691
666
  xml += `${indent}</tuplet>\n`;
692
667
  return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
693
668
  };
@@ -865,12 +840,35 @@ interface LayerResult {
865
840
  endingClef?: Clef; // For cross-measure clef tracking
866
841
  }
867
842
 
843
+
844
+ // Helper: check if an event (or any note inside a tuplet) has beam start/end
845
+ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
846
+ if (event.type === 'note') {
847
+ const markOptions = extractMarkOptions((event as NoteEvent).marks);
848
+ return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
849
+ }
850
+ if (event.type === 'tuplet') {
851
+ const tuplet = event as TupletEvent;
852
+ let beamStart = false;
853
+ let beamEnd = false;
854
+ for (const e of tuplet.events) {
855
+ if (e.type === 'note') {
856
+ const markOptions = extractMarkOptions((e as NoteEvent).marks);
857
+ if (markOptions.beamStart) beamStart = true;
858
+ if (markOptions.beamEnd) beamEnd = true;
859
+ }
860
+ }
861
+ return { beamStart, beamEnd };
862
+ }
863
+ return { beamStart: false, beamEnd: false };
864
+ };
865
+
868
866
  // Encode a layer (voice)
869
867
  const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null): LayerResult => {
870
868
  const layerId = generateId("layer");
871
869
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
872
870
 
873
- let inBeam = false;
871
+ let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
874
872
  const baseIndent = indent + ' ';
875
873
 
876
874
  // Track current clef to only emit changes
@@ -928,23 +926,16 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
928
926
  };
929
927
 
930
928
  for (const event of voice.events) {
931
- // Check for beam start/end in note events
932
- let beamStart = false;
933
- let beamEnd = false;
934
- if (event.type === 'note') {
935
- const noteEvent = event as NoteEvent;
936
- const markOptions = extractMarkOptions(noteEvent.marks);
937
- beamStart = markOptions.beamStart;
938
- beamEnd = markOptions.beamEnd;
939
- }
929
+ // Check for beam start/end in this event (including inside tuplets)
930
+ const { beamStart, beamEnd } = getEventBeamMarks(event);
940
931
 
941
932
  // Open beam element if beam starts
942
- if (beamStart && !inBeam) {
933
+ if (beamStart && !beamElementOpen) {
943
934
  xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
944
- inBeam = true;
935
+ beamElementOpen = true;
945
936
  }
946
937
 
947
- const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
938
+ const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
948
939
 
949
940
  switch (event.type) {
950
941
  case 'note': {
@@ -1061,7 +1052,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1061
1052
  xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
1062
1053
  break;
1063
1054
  case 'tuplet': {
1064
- const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift);
1055
+ // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1056
+ // Pass beamElementOpen to tuplet so it knows not to create its own beam
1057
+ const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
1065
1058
  xml += tupletResult.xml;
1066
1059
 
1067
1060
  // Process slur ends first (to close any pending slurs from before this tuplet)
@@ -1158,14 +1151,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1158
1151
  }
1159
1152
 
1160
1153
  // Close beam element if beam ends
1161
- if (beamEnd && inBeam) {
1154
+ if (beamEnd && beamElementOpen) {
1162
1155
  xml += `${baseIndent}</beam>\n`;
1163
- inBeam = false;
1156
+ beamElementOpen = false;
1164
1157
  }
1165
1158
  }
1166
1159
 
1167
1160
  // Close any unclosed beam
1168
- if (inBeam) {
1161
+ if (beamElementOpen) {
1169
1162
  xml += `${baseIndent}</beam>\n`;
1170
1163
  }
1171
1164
 
@@ -27,6 +27,7 @@ import {
27
27
  NavigationMarkType,
28
28
  BarlineEvent,
29
29
  HarmonyEvent,
30
+ TupletEvent,
30
31
  } from './types';
31
32
 
32
33
  import {
@@ -132,6 +133,88 @@ class SpannerTracker {
132
133
  }
133
134
  }
134
135
 
136
+ // ============ Tuplet Tracker ============
137
+
138
+ /**
139
+ * Track tuplet groups by number attribute.
140
+ * Collects notes between tuplet start and stop to create TupletEvent.
141
+ */
142
+ class TupletTracker {
143
+ // Map from tuplet number to collected events and ratio
144
+ private activeTuplets: Map<number, {
145
+ events: (NoteEvent | RestEvent)[];
146
+ ratio?: Fraction;
147
+ }> = new Map();
148
+
149
+ /**
150
+ * Start a new tuplet group
151
+ */
152
+ startTuplet(number: number = 1): void {
153
+ this.activeTuplets.set(number, { events: [] });
154
+ }
155
+
156
+ /**
157
+ * Add an event to active tuplet(s)
158
+ * Returns true if the event was added to at least one tuplet
159
+ */
160
+ addEvent(event: NoteEvent | RestEvent): boolean {
161
+ if (this.activeTuplets.size === 0) return false;
162
+
163
+ // Add to all active tuplets (in case of nested tuplets)
164
+ for (const [, tuplet] of this.activeTuplets) {
165
+ // Set ratio from first event's duration.tuplet
166
+ if (!tuplet.ratio && event.duration.tuplet) {
167
+ // In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
168
+ tuplet.ratio = {
169
+ numerator: event.duration.tuplet.denominator,
170
+ denominator: event.duration.tuplet.numerator,
171
+ };
172
+ }
173
+ // Store event without tuplet info in duration (it's handled at TupletEvent level)
174
+ const cleanEvent = { ...event, duration: { ...event.duration } };
175
+ delete cleanEvent.duration.tuplet;
176
+ tuplet.events.push(cleanEvent);
177
+ }
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * Stop a tuplet group and return the TupletEvent
183
+ */
184
+ stopTuplet(number: number = 1): TupletEvent | undefined {
185
+ const tuplet = this.activeTuplets.get(number);
186
+ if (!tuplet || tuplet.events.length === 0) {
187
+ this.activeTuplets.delete(number);
188
+ return undefined;
189
+ }
190
+
191
+ this.activeTuplets.delete(number);
192
+
193
+ // Default ratio if not set (shouldn't happen normally)
194
+ const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
195
+
196
+ return {
197
+ type: 'tuplet',
198
+ ratio,
199
+ events: tuplet.events,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Check if any tuplet is active
205
+ */
206
+ isActive(): boolean {
207
+ return this.activeTuplets.size > 0;
208
+ }
209
+
210
+ /**
211
+ * Reset tracker
212
+ */
213
+ reset(): void {
214
+ this.activeTuplets.clear();
215
+ }
216
+ }
217
+
135
218
  // ============ Voice Position Tracker ============
136
219
 
137
220
  /**
@@ -940,7 +1023,8 @@ const convertMeasure = (
940
1023
  measureEl: Element,
941
1024
  voiceTracker: VoiceTracker,
942
1025
  spannerTracker: SpannerTracker,
943
- ottavaTracker: { current: number }
1026
+ ottavaTracker: { current: number },
1027
+ tupletTracker: TupletTracker
944
1028
  ): MeasureConversionResult => {
945
1029
  let key: KeySignature | undefined;
946
1030
  let timeSig: Fraction | undefined;
@@ -994,6 +1078,12 @@ const convertMeasure = (
994
1078
  const staffNum = note.staff || 1;
995
1079
  currentVoice = voiceNum;
996
1080
 
1081
+ // Check for tuplet start BEFORE processing the note
1082
+ const tupletNotation = note.notations?.tuplet;
1083
+ if (tupletNotation?.type === 'start') {
1084
+ tupletTracker.startTuplet(tupletNotation.number);
1085
+ }
1086
+
997
1087
  // Add any pending context changes before the note (tempo, ottava)
998
1088
  if (pendingContextChanges.length > 0) {
999
1089
  for (const ctx of pendingContextChanges) {
@@ -1023,7 +1113,13 @@ const convertMeasure = (
1023
1113
 
1024
1114
  // Grace notes don't advance time
1025
1115
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
1026
- voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
1116
+
1117
+ // Check if we're in a tuplet
1118
+ if (tupletTracker.isActive()) {
1119
+ tupletTracker.addEvent(restEvent);
1120
+ } else {
1121
+ voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
1122
+ }
1027
1123
  } else if (note.pitch) {
1028
1124
  // Note or chord - convert MusicXmlPitch to Lilylet Pitch
1029
1125
  const lilyletPitch = musicXmlPitchToLilylet(note.pitch);
@@ -1093,7 +1189,31 @@ const convertMeasure = (
1093
1189
 
1094
1190
  // Grace notes don't advance time
1095
1191
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
1096
- voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
1192
+
1193
+ // Check if we're in a tuplet
1194
+ if (tupletTracker.isActive()) {
1195
+ tupletTracker.addEvent(noteEvent);
1196
+ } else {
1197
+ voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
1198
+ }
1199
+ }
1200
+
1201
+ // Check for tuplet stop AFTER processing the note
1202
+ if (tupletNotation?.type === 'stop') {
1203
+ const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
1204
+ if (tupletEvent) {
1205
+ // Calculate total duration of tuplet for voiceTracker
1206
+ let totalDuration = 0;
1207
+ for (const evt of tupletEvent.events) {
1208
+ if (evt.duration) {
1209
+ // Convert division to duration units (quarter = 1)
1210
+ totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
1211
+ }
1212
+ }
1213
+ // Apply tuplet ratio to get actual duration
1214
+ totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
1215
+ voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
1216
+ }
1097
1217
  }
1098
1218
  } else if (tagName === 'direction') {
1099
1219
  const direction = parseDirection(child);
@@ -1158,6 +1278,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1158
1278
  const voiceTracker = new VoiceTracker();
1159
1279
  const spannerTracker = new SpannerTracker();
1160
1280
  const ottavaTracker = { current: 0 };
1281
+ const tupletTracker = new TupletTracker();
1161
1282
 
1162
1283
  let lastKey: KeySignature | undefined;
1163
1284
  let lastTimeSig: Fraction | undefined;
@@ -1168,7 +1289,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1168
1289
 
1169
1290
  for (const measureEl of measureEls) {
1170
1291
  voiceTracker.reset();
1171
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
1292
+ const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1172
1293
 
1173
1294
  // Update running key/time
1174
1295
  if (key) lastKey = key;