@k-l-lambda/lilylet 0.1.33 → 0.1.35

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.
@@ -128,6 +128,7 @@
128
128
  let currentKey = null;
129
129
  let currentTimeSig = null;
130
130
  let currentDuration = { division: 4, dots: 0 }; // default quarter note
131
+ let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
131
132
 
132
133
  // Reset parser state - call before each parse
133
134
  const resetParserState = () => {
@@ -135,6 +136,7 @@
135
136
  currentKey = null;
136
137
  currentTimeSig = null;
137
138
  currentDuration = { division: 4, dots: 0 };
139
+ numericTimeSignature = false;
138
140
  };
139
141
 
140
142
  // Export reset function
@@ -168,6 +170,8 @@
168
170
  "\\clef" return 'CMD_CLEF'
169
171
  "\\key" return 'CMD_KEY'
170
172
  "\\time" return 'CMD_TIME'
173
+ "\\numericTimeSignature" return 'CMD_NUMERIC_TIME_SIG'
174
+ "\\defaultTimeSignature" return 'CMD_DEFAULT_TIME_SIG'
171
175
  "\\tempo" return 'CMD_TEMPO'
172
176
  "\\staff" return 'CMD_STAFF'
173
177
  "\\grace" return 'CMD_GRACE'
@@ -329,7 +333,7 @@ part_voices
329
333
 
330
334
  voice_events
331
335
  : /* empty */ { $$ = []; }
332
- | voice_events event { $$ = $1.concat(Array.isArray($2) ? $2 : [$2]); }
336
+ | voice_events event { $$ = $2 === null ? $1 : $1.concat(Array.isArray($2) ? $2 : [$2]); }
333
337
  ;
334
338
 
335
339
  event
@@ -413,6 +417,8 @@ context_event
413
417
  | staff_cmd -> contextChange({ staff: $1 })
414
418
  | ottava_cmd -> contextChange({ ottava: $1 })
415
419
  | stem_cmd -> contextChange({ stemDirection: $1 })
420
+ | numeric_time_sig_cmd -> null
421
+ | default_time_sig_cmd -> null
416
422
  ;
417
423
 
418
424
  clef_cmd
@@ -433,7 +439,29 @@ mode
433
439
  ;
434
440
 
435
441
  time_cmd
436
- : CMD_TIME NUMBER '/' NUMBER %{ currentTimeSig = fraction(Number($2), Number($4)); $$ = currentTimeSig; %}
442
+ : CMD_TIME NUMBER '/' NUMBER %{
443
+ const num = Number($2);
444
+ const den = Number($4);
445
+ const timeSig = fraction(num, den);
446
+ // Add symbol for 4/4 (common) and 2/2 (cut) unless numericTimeSignature is set
447
+ if (!numericTimeSignature) {
448
+ if (num === 4 && den === 4) {
449
+ timeSig.symbol = 'common';
450
+ } else if (num === 2 && den === 2) {
451
+ timeSig.symbol = 'cut';
452
+ }
453
+ }
454
+ currentTimeSig = timeSig;
455
+ $$ = currentTimeSig;
456
+ %}
457
+ ;
458
+
459
+ numeric_time_sig_cmd
460
+ : CMD_NUMERIC_TIME_SIG %{ numericTimeSignature = true; $$ = null; %}
461
+ ;
462
+
463
+ default_time_sig_cmd
464
+ : CMD_DEFAULT_TIME_SIG %{ numericTimeSignature = false; $$ = null; %}
437
465
  ;
438
466
 
439
467
  tempo_cmd
@@ -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
 
@@ -1593,11 +1586,14 @@ const encodeScoreDef = (
1593
1586
  timeNum: number,
1594
1587
  timeDen: number,
1595
1588
  partInfos: PartInfo[],
1596
- indent: string
1589
+ indent: string,
1590
+ meterSymbol?: 'common' | 'cut'
1597
1591
  ): string => {
1598
1592
  const scoreDefId = generateId("scoredef");
1599
1593
 
1600
- let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}" meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1594
+ // Build meter attributes
1595
+ const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1596
+ let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1601
1597
  xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1602
1598
 
1603
1599
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1647,6 +1643,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1647
1643
  let currentKey = 0;
1648
1644
  let currentTimeNum = 4;
1649
1645
  let currentTimeDen = 4;
1646
+ let currentMeterSymbol: 'common' | 'cut' | undefined = undefined;
1650
1647
 
1651
1648
  const firstMeasure = doc.measures[0];
1652
1649
  if (firstMeasure.key) {
@@ -1655,6 +1652,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1655
1652
  if (firstMeasure.timeSig) {
1656
1653
  currentTimeNum = firstMeasure.timeSig.numerator;
1657
1654
  currentTimeDen = firstMeasure.timeSig.denominator;
1655
+ currentMeterSymbol = firstMeasure.timeSig.symbol;
1658
1656
  }
1659
1657
 
1660
1658
  const keySig = KEY_SIGS[currentKey] || "0";
@@ -1708,7 +1706,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1708
1706
  mei += `${indent}${indent}<body>\n`;
1709
1707
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1710
1708
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1711
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`);
1709
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
1712
1710
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1713
1711
 
1714
1712
  // Track tie state across measures for cross-measure ties
@@ -1764,6 +1762,20 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1764
1762
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
1765
1763
  }
1766
1764
  }
1765
+ // Check for time signature change and output scoreDef if needed
1766
+ if (measure.timeSig && mi > 0) {
1767
+ const newTimeNum = measure.timeSig.numerator;
1768
+ const newTimeDen = measure.timeSig.denominator;
1769
+ const newMeterSymbol = measure.timeSig.symbol;
1770
+ if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
1771
+ currentTimeNum = newTimeNum;
1772
+ currentTimeDen = newTimeDen;
1773
+ currentMeterSymbol = newMeterSymbol;
1774
+ // Output a scoreDef with the new time signature
1775
+ const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
1776
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1777
+ }
1778
+ }
1767
1779
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1768
1780
  });
1769
1781
 
@@ -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;
@@ -550,7 +550,7 @@ const serializeEvent = (
550
550
  // Key/time signature info to inject into first voice
551
551
  interface MeasureContext {
552
552
  key?: KeySignature;
553
- time?: { numerator: number; denominator: number };
553
+ time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
554
554
  }
555
555
 
556
556
  // Serialize a voice with pitch environment tracking
@@ -585,7 +585,13 @@ const serializeVoice = (
585
585
  parts.push('\\key ' + keyStr);
586
586
  }
587
587
  if (measureContext.time) {
588
- parts.push('\\time ' + measureContext.time.numerator + '/' + measureContext.time.denominator);
588
+ const { numerator, denominator, symbol } = measureContext.time;
589
+ // Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
590
+ // (meaning numeric display was explicitly requested)
591
+ if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
592
+ parts.push('\\numericTimeSignature');
593
+ }
594
+ parts.push('\\time ' + numerator + '/' + denominator);
589
595
  }
590
596
  }
591
597
 
@@ -99,6 +99,12 @@ export interface Fraction {
99
99
  denominator: number;
100
100
  }
101
101
 
102
+ // Time signature with optional symbol display
103
+ // symbol: 'common' for C (4/4), 'cut' for C| (2/2), undefined for numeric
104
+ export interface TimeSig extends Fraction {
105
+ symbol?: 'common' | 'cut';
106
+ }
107
+
102
108
  export interface Pitch {
103
109
  phonet: Phonet;
104
110
  accidental?: Accidental;
@@ -292,7 +298,7 @@ export interface Part {
292
298
  // Measure contains parts separated by \\\
293
299
  export interface Measure {
294
300
  key?: KeySignature;
295
- timeSig?: Fraction;
301
+ timeSig?: TimeSig;
296
302
  parts: Part[];
297
303
  partial?: boolean;
298
304
  }
@@ -1,28 +0,0 @@
1
- /**
2
- * LilyPond to Lilylet Decoder
3
- *
4
- * Converts LilyPond notation files to Lilylet document format using the lotus parser.
5
- * This module is browser-compatible - it uses pre-compiled parser from lotus.
6
- */
7
- import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
8
- import { LilyletDoc, Event, Fraction } from "./types";
9
- interface ParsedMeasure {
10
- key: number | null;
11
- timeSig: Fraction | null;
12
- voices: ParsedVoice[];
13
- partial: boolean;
14
- }
15
- interface ParsedVoice {
16
- staff: number;
17
- events: Event[];
18
- }
19
- declare const parseLilyDocument: (lilyDocument: lilyParser.LilyDocument) => ParsedMeasure[];
20
- /**
21
- * Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
22
- */
23
- declare const decode: (lilypondSource: string) => LilyletDoc;
24
- /**
25
- * Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
26
- */
27
- declare const decodeFromDocument: (lilyDocument: lilyParser.LilyDocument) => LilyletDoc;
28
- export { decode, decodeFromDocument, parseLilyDocument, };