@k-l-lambda/lilylet 0.1.49 → 0.1.50

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.
Files changed (71) hide show
  1. package/lib/abc/abc.d.ts +102 -0
  2. package/lib/abc/abc.js +25 -0
  3. package/lib/abc/grammar.jison.js +1203 -0
  4. package/lib/abc/parser.d.ts +3 -0
  5. package/lib/abc/parser.js +6 -0
  6. package/lib/abcDecoder.d.ts +1 -0
  7. package/lib/abcDecoder.js +1 -0
  8. package/lib/grammar.jison.js +1 -1303
  9. package/lib/index.d.ts +1 -8
  10. package/lib/index.js +1 -10
  11. package/lib/lilylet/abcDecoder.d.ts +25 -0
  12. package/lib/lilylet/abcDecoder.js +1007 -0
  13. package/lib/lilylet/grammar.jison.js +1308 -0
  14. package/lib/lilylet/index.d.ts +10 -0
  15. package/lib/lilylet/index.js +10 -0
  16. package/lib/lilylet/lilypondDecoder.d.ts +29 -0
  17. package/lib/lilylet/lilypondDecoder.js +1053 -0
  18. package/lib/lilylet/lilypondEncoder.d.ts +34 -0
  19. package/lib/lilylet/lilypondEncoder.js +759 -0
  20. package/lib/lilylet/meiEncoder.d.ts +8 -0
  21. package/lib/lilylet/meiEncoder.js +1808 -0
  22. package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
  23. package/lib/lilylet/musicXmlDecoder.js +1195 -0
  24. package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
  25. package/lib/lilylet/musicXmlEncoder.js +701 -0
  26. package/lib/lilylet/musicXmlTypes.d.ts +199 -0
  27. package/lib/lilylet/musicXmlTypes.js +7 -0
  28. package/lib/lilylet/musicXmlUtils.d.ts +92 -0
  29. package/lib/lilylet/musicXmlUtils.js +469 -0
  30. package/lib/lilylet/parser.d.ts +3 -0
  31. package/lib/lilylet/parser.js +151 -0
  32. package/lib/lilylet/serializer.d.ts +11 -0
  33. package/lib/lilylet/serializer.js +653 -0
  34. package/lib/lilylet/types.d.ts +245 -0
  35. package/lib/lilylet/types.js +99 -0
  36. package/lib/lilypondDecoder.d.ts +1 -29
  37. package/lib/lilypondDecoder.js +1 -1006
  38. package/lib/lilypondEncoder.d.ts +1 -34
  39. package/lib/lilypondEncoder.js +1 -759
  40. package/lib/meiEncoder.d.ts +1 -8
  41. package/lib/meiEncoder.js +1 -1545
  42. package/lib/musicXmlDecoder.d.ts +1 -20
  43. package/lib/musicXmlDecoder.js +1 -1151
  44. package/lib/musicXmlEncoder.d.ts +1 -15
  45. package/lib/musicXmlEncoder.js +1 -666
  46. package/lib/musicXmlTypes.d.ts +1 -199
  47. package/lib/musicXmlTypes.js +1 -7
  48. package/lib/musicXmlUtils.d.ts +1 -81
  49. package/lib/musicXmlUtils.js +1 -435
  50. package/lib/parser.d.ts +1 -3
  51. package/lib/parser.js +1 -151
  52. package/lib/serializer.d.ts +1 -11
  53. package/lib/serializer.js +1 -650
  54. package/lib/types.d.ts +1 -244
  55. package/lib/types.js +1 -99
  56. package/package.json +2 -1
  57. package/source/abc/abc.jison +692 -0
  58. package/source/abc/abc.ts +176 -0
  59. package/source/abc/grammar.jison.js +1203 -0
  60. package/source/abc/parser.ts +12 -0
  61. package/source/lilylet/abcDecoder.ts +1121 -0
  62. package/source/lilylet/grammar.jison.js +170 -165
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +2 -0
  65. package/source/lilylet/lilypondDecoder.ts +91 -41
  66. package/source/lilylet/meiEncoder.ts +280 -0
  67. package/source/lilylet/musicXmlDecoder.ts +74 -27
  68. package/source/lilylet/musicXmlEncoder.ts +201 -146
  69. package/source/lilylet/musicXmlUtils.ts +46 -4
  70. package/source/lilylet/serializer.ts +3 -0
  71. package/source/lilylet/types.ts +1 -0
@@ -37,11 +37,14 @@ import {
37
37
  Fraction,
38
38
  } from "./types";
39
39
 
40
+ import {
41
+ DIVISIONS,
42
+ DIVISION_TO_TYPE,
43
+ calculateDuration,
44
+ } from "./musicXmlUtils";
40
45
 
41
- // === Constants and Reverse Mappings ===
42
46
 
43
- // Standard divisions per quarter note
44
- const DIVISIONS = 4;
47
+ // === Constants and Reverse Mappings ===
45
48
 
46
49
  // Phonet to MusicXML step
47
50
  const PHONET_TO_STEP: Record<string, string> = {
@@ -63,19 +66,6 @@ const ACCIDENTAL_TO_ALTER: Record<string, number> = {
63
66
  natural: 0,
64
67
  };
65
68
 
66
- // Division to MusicXML note type
67
- const DIVISION_TO_TYPE: Record<number, string> = {
68
- 0.5: 'breve',
69
- 1: 'whole',
70
- 2: 'half',
71
- 4: 'quarter',
72
- 8: 'eighth',
73
- 16: '16th',
74
- 32: '32nd',
75
- 64: '64th',
76
- 128: '128th',
77
- };
78
-
79
69
  // Key signature to fifths (major keys)
80
70
  const KEY_TO_FIFTHS: Record<string, number> = {
81
71
  'c': 0,
@@ -161,34 +151,6 @@ const indent = (level: number): string => ' '.repeat(level);
161
151
 
162
152
  // === Encoding Functions ===
163
153
 
164
- /**
165
- * Calculate duration in MusicXML divisions
166
- */
167
- const calculateDuration = (duration: Duration): number => {
168
- // Base duration: DIVISIONS * (4 / division)
169
- // e.g., quarter (4) = DIVISIONS * 1 = 4
170
- // half (2) = DIVISIONS * 2 = 8
171
- // eighth (8) = DIVISIONS * 0.5 = 2
172
- let dur = DIVISIONS * (4 / duration.division);
173
-
174
- // Apply dots
175
- if (duration.dots) {
176
- let dotValue = dur / 2;
177
- for (let i = 0; i < duration.dots; i++) {
178
- dur += dotValue;
179
- dotValue /= 2;
180
- }
181
- }
182
-
183
- // Apply tuplet ratio
184
- if (duration.tuplet) {
185
- dur = dur * duration.tuplet.denominator / duration.tuplet.numerator;
186
- }
187
-
188
- return Math.round(dur);
189
- };
190
-
191
-
192
154
  /**
193
155
  * Encode pitch to MusicXML
194
156
  */
@@ -224,9 +186,12 @@ const encodeDuration = (duration: Duration, level: number): string => {
224
186
  }
225
187
 
226
188
  if (duration.tuplet) {
189
+ // MusicXML: actual-notes = notes played (Lilylet denominator)
190
+ // normal-notes = normal count (Lilylet numerator)
191
+ // e.g., \times 2/3 → actual=3, normal=2
227
192
  xml += `${indent(level)}<time-modification>\n`;
228
- xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.numerator}</actual-notes>\n`;
229
- xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.denominator}</normal-notes>\n`;
193
+ xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.denominator}</actual-notes>\n`;
194
+ xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.numerator}</normal-notes>\n`;
230
195
  xml += `${indent(level)}</time-modification>\n`;
231
196
  }
232
197
 
@@ -349,6 +314,10 @@ const encodeNotations = (marks: Mark[], level: number): string => {
349
314
  otherNotations.push(`<slur type="${mark.start ? 'start' : 'stop'}" number="1"/>`);
350
315
  break;
351
316
 
317
+ case 'tuplet' as any:
318
+ otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
319
+ break;
320
+
352
321
  case 'fingering':
353
322
  // Fingering goes in technical
354
323
  break;
@@ -494,6 +463,49 @@ const encodeRest = (
494
463
  };
495
464
 
496
465
 
466
+ /**
467
+ * Encode a rest event with tuplet notation start/stop
468
+ */
469
+ const encodeRestWithTuplet = (
470
+ event: RestEvent,
471
+ voice: number,
472
+ staff: number,
473
+ level: number,
474
+ isFirst: boolean,
475
+ isLast: boolean
476
+ ): string => {
477
+ let xml = `${indent(level)}<note>\n`;
478
+
479
+ xml += `${indent(level + 1)}<rest`;
480
+ if (event.fullMeasure) {
481
+ xml += ' measure="yes"';
482
+ }
483
+ xml += '/>\n';
484
+
485
+ xml += encodeDuration(event.duration, level + 1);
486
+
487
+ xml += `${indent(level + 1)}<voice>${voice}</voice>\n`;
488
+
489
+ if (staff > 0) {
490
+ xml += `${indent(level + 1)}<staff>${staff}</staff>\n`;
491
+ }
492
+
493
+ // Add tuplet notations
494
+ xml += `${indent(level + 1)}<notations>\n`;
495
+ if (isFirst) {
496
+ xml += `${indent(level + 2)}<tuplet type="start"/>\n`;
497
+ }
498
+ if (isLast) {
499
+ xml += `${indent(level + 2)}<tuplet type="stop"/>\n`;
500
+ }
501
+ xml += `${indent(level + 1)}</notations>\n`;
502
+
503
+ xml += `${indent(level)}</note>\n`;
504
+
505
+ return xml;
506
+ };
507
+
508
+
497
509
  /**
498
510
  * Encode direction element (dynamics, tempo, etc.)
499
511
  */
@@ -627,10 +639,11 @@ const encodeHarmony = (event: HarmonyEvent, level: number): string => {
627
639
 
628
640
 
629
641
  /**
630
- * Encode a complete measure
642
+ * Encode a complete measure for a single part
631
643
  */
632
644
  const encodeMeasure = (
633
645
  measure: Measure,
646
+ partIndex: number,
634
647
  measureNumber: number,
635
648
  isFirst: boolean,
636
649
  prevKey: KeySignature | undefined,
@@ -639,32 +652,33 @@ const encodeMeasure = (
639
652
  ): string => {
640
653
  let xml = `${indent(level)}<measure number="${measureNumber}">\n`;
641
654
 
655
+ const part = measure.parts[partIndex];
656
+ if (!part) {
657
+ xml += `${indent(level)}</measure>\n`;
658
+ return xml;
659
+ }
660
+
642
661
  // Determine if we need attributes
643
662
  const needAttributes = isFirst ||
644
663
  (measure.key && JSON.stringify(measure.key) !== JSON.stringify(prevKey)) ||
645
664
  (measure.timeSig && JSON.stringify(measure.timeSig) !== JSON.stringify(prevTime));
646
665
 
647
- // Find max staff number
666
+ // Find max staff number within this part
648
667
  let maxStaff = 1;
649
- for (const part of measure.parts) {
650
- for (const voice of part.voices) {
651
- maxStaff = Math.max(maxStaff, voice.staff || 1);
652
- }
668
+ for (const voice of part.voices) {
669
+ maxStaff = Math.max(maxStaff, voice.staff || 1);
653
670
  }
654
671
 
655
672
  // Encode attributes if needed
656
673
  if (needAttributes) {
657
- // Find clef from first voice
674
+ // Find clef from first voice of this part
658
675
  let clef: Clef | undefined;
659
- for (const part of measure.parts) {
660
- for (const voice of part.voices) {
661
- for (const event of voice.events) {
662
- if (event.type === 'context' && event.clef) {
663
- clef = event.clef;
664
- break;
665
- }
676
+ for (const voice of part.voices) {
677
+ for (const event of voice.events) {
678
+ if (event.type === 'context' && event.clef) {
679
+ clef = event.clef;
680
+ break;
666
681
  }
667
- if (clef) break;
668
682
  }
669
683
  if (clef) break;
670
684
  }
@@ -678,95 +692,124 @@ const encodeMeasure = (
678
692
  });
679
693
  }
680
694
 
681
- // Encode voices
695
+ // Encode voices (voice numbering starts at 1 for each part)
682
696
  let voiceNum = 1;
683
697
  let currentPosition = 0;
684
698
 
685
- for (const part of measure.parts) {
686
- for (const voice of part.voices) {
687
- const staff = voice.staff || 1;
688
- let voicePosition = 0;
689
-
690
- // Backup if needed
691
- if (currentPosition > 0 && voiceNum > 1) {
692
- xml += `${indent(level + 1)}<backup>\n`;
693
- xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
694
- xml += `${indent(level + 1)}</backup>\n`;
695
- voicePosition = 0;
696
- }
699
+ for (const voice of part.voices) {
700
+ let currentStaff = voice.staff || 1;
701
+ let voicePosition = 0;
697
702
 
698
- for (const event of voice.events) {
699
- switch (event.type) {
700
- case 'note': {
701
- // Check for direction marks (dynamics, hairpins, pedals)
702
- const directionMarks = event.marks?.filter(m =>
703
- m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
704
- ) || [];
705
- if (directionMarks.length > 0) {
706
- xml += encodeDirection(directionMarks, level + 1);
707
- }
703
+ // Backup if needed
704
+ if (currentPosition > 0 && voiceNum > 1) {
705
+ xml += `${indent(level + 1)}<backup>\n`;
706
+ xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
707
+ xml += `${indent(level + 1)}</backup>\n`;
708
+ voicePosition = 0;
709
+ }
708
710
 
709
- // Encode main note
710
- xml += encodeNote(event, voiceNum, staff, level + 1);
711
- const dur = calculateDuration(event.duration);
712
- voicePosition += dur;
713
-
714
- // Encode chord notes
715
- for (let i = 1; i < event.pitches.length; i++) {
716
- const chordEvent: NoteEvent = {
717
- ...event,
718
- pitches: [event.pitches[i]],
719
- };
720
- xml += encodeNote(chordEvent, voiceNum, staff, level + 1, true);
721
- }
722
- break;
711
+ for (const event of voice.events) {
712
+ switch (event.type) {
713
+ case 'note': {
714
+ // Check for direction marks (dynamics, hairpins, pedals)
715
+ const directionMarks = event.marks?.filter(m =>
716
+ m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
717
+ ) || [];
718
+ if (directionMarks.length > 0) {
719
+ xml += encodeDirection(directionMarks, level + 1);
723
720
  }
724
721
 
725
- case 'rest': {
726
- xml += encodeRest(event, voiceNum, staff, level + 1);
727
- const dur = calculateDuration(event.duration);
728
- voicePosition += dur;
729
- break;
722
+ // Encode main note
723
+ xml += encodeNote(event, voiceNum, currentStaff, level + 1);
724
+ const dur = calculateDuration(event.duration);
725
+ voicePosition += dur;
726
+
727
+ // Encode chord notes
728
+ for (let i = 1; i < event.pitches.length; i++) {
729
+ const chordEvent: NoteEvent = {
730
+ ...event,
731
+ pitches: [event.pitches[i]],
732
+ };
733
+ xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
730
734
  }
735
+ break;
736
+ }
731
737
 
732
- case 'context': {
733
- if (event.tempo) {
734
- xml += encodeTempo(event.tempo, level + 1);
735
- }
736
- // Other context changes are handled in attributes
737
- break;
738
+ case 'rest': {
739
+ xml += encodeRest(event, voiceNum, currentStaff, level + 1);
740
+ const dur = calculateDuration(event.duration);
741
+ voicePosition += dur;
742
+ break;
743
+ }
744
+
745
+ case 'context': {
746
+ if (event.tempo) {
747
+ xml += encodeTempo(event.tempo, level + 1);
748
+ }
749
+ if (event.staff) {
750
+ currentStaff = event.staff;
738
751
  }
752
+ // Other context changes are handled in attributes
753
+ break;
754
+ }
739
755
 
740
- case 'tuplet': {
741
- for (const subEvent of event.events) {
742
- if (subEvent.type === 'note') {
743
- xml += encodeNote(subEvent, voiceNum, staff, level + 1);
744
- const dur = calculateDuration(subEvent.duration);
745
- voicePosition += dur;
746
- } else if (subEvent.type === 'rest') {
747
- xml += encodeRest(subEvent, voiceNum, staff, level + 1);
748
- const dur = calculateDuration(subEvent.duration);
749
- voicePosition += dur;
756
+ case 'tuplet': {
757
+ const tupletEvents = event.events;
758
+ for (let ti = 0; ti < tupletEvents.length; ti++) {
759
+ const subEvent = tupletEvents[ti];
760
+ // Set tuplet ratio on duration so encodeDuration emits <time-modification>
761
+ const originalTuplet = subEvent.duration.tuplet;
762
+ subEvent.duration.tuplet = event.ratio;
763
+
764
+ const isFirst = ti === 0;
765
+ const isLast = ti === tupletEvents.length - 1;
766
+
767
+ if (subEvent.type === 'note') {
768
+ // Add tuplet notation marks
769
+ const tupletMarks: Mark[] = [];
770
+ if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
771
+ if (isLast) tupletMarks.push({ markType: 'tuplet', start: false } as any);
772
+
773
+ if (tupletMarks.length > 0) {
774
+ const origMarks = subEvent.marks;
775
+ subEvent.marks = [...(subEvent.marks || []), ...tupletMarks];
776
+ xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
777
+ subEvent.marks = origMarks;
778
+ } else {
779
+ xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
750
780
  }
781
+ const dur = calculateDuration(subEvent.duration);
782
+ voicePosition += dur;
783
+ } else if (subEvent.type === 'rest') {
784
+ if (isFirst || isLast) {
785
+ xml += encodeRestWithTuplet(subEvent, voiceNum, currentStaff, level + 1, isFirst, isLast);
786
+ } else {
787
+ xml += encodeRest(subEvent, voiceNum, currentStaff, level + 1);
788
+ }
789
+ const dur = calculateDuration(subEvent.duration);
790
+ voicePosition += dur;
751
791
  }
752
- break;
753
- }
754
792
 
755
- case 'barline': {
756
- xml += encodeBarline(event, level + 1);
757
- break;
793
+ // Restore original tuplet value
794
+ subEvent.duration.tuplet = originalTuplet;
758
795
  }
796
+ break;
797
+ }
759
798
 
760
- case 'harmony': {
761
- xml += encodeHarmony(event, level + 1);
762
- break;
763
- }
799
+ case 'barline': {
800
+ xml += encodeBarline(event, level + 1);
801
+ break;
764
802
  }
765
- }
766
803
 
767
- currentPosition = Math.max(currentPosition, voicePosition);
768
- voiceNum++;
804
+ case 'harmony': {
805
+ xml += encodeHarmony(event, level + 1);
806
+ break;
807
+ }
808
+ }
769
809
  }
810
+
811
+ currentPosition = Math.max(currentPosition, voicePosition);
812
+ voiceNum++;
770
813
  }
771
814
 
772
815
  xml += `${indent(level)}</measure>\n`;
@@ -823,30 +866,42 @@ export const encode = (doc: LilyletDoc): string => {
823
866
  xml += encodeMetadata(doc.metadata, 1);
824
867
  }
825
868
 
826
- // Part list (single part for now)
869
+ // Determine number of parts from first measure
870
+ const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
871
+
872
+ // Part list
827
873
  xml += `${indent(1)}<part-list>\n`;
828
- xml += `${indent(2)}<score-part id="P1">\n`;
829
- xml += `${indent(3)}<part-name>${doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music'}</part-name>\n`;
830
- xml += `${indent(2)}</score-part>\n`;
874
+ for (let pi = 0; pi < numParts; pi++) {
875
+ const partId = `P${pi + 1}`;
876
+ const partName = doc.measures[0]?.parts[pi]?.name
877
+ || (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
878
+ xml += `${indent(2)}<score-part id="${partId}">\n`;
879
+ xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
880
+ xml += `${indent(2)}</score-part>\n`;
881
+ }
831
882
  xml += `${indent(1)}</part-list>\n`;
832
883
 
833
- // Part content
834
- xml += `${indent(1)}<part id="P1">\n`;
884
+ // Encode each part
885
+ for (let pi = 0; pi < numParts; pi++) {
886
+ const partId = `P${pi + 1}`;
887
+ xml += `${indent(1)}<part id="${partId}">\n`;
835
888
 
836
- let prevKey: KeySignature | undefined;
837
- let prevTime: Fraction | undefined;
889
+ let prevKey: KeySignature | undefined;
890
+ let prevTime: Fraction | undefined;
838
891
 
839
- for (let i = 0; i < doc.measures.length; i++) {
840
- const measure = doc.measures[i];
841
- const isFirst = i === 0;
892
+ for (let i = 0; i < doc.measures.length; i++) {
893
+ const measure = doc.measures[i];
894
+ const isFirst = i === 0;
842
895
 
843
- xml += encodeMeasure(measure, i + 1, isFirst, prevKey, prevTime, 2);
896
+ xml += encodeMeasure(measure, pi, i + 1, isFirst, prevKey, prevTime, 2);
897
+
898
+ if (measure.key) prevKey = measure.key;
899
+ if (measure.timeSig) prevTime = measure.timeSig;
900
+ }
844
901
 
845
- if (measure.key) prevKey = measure.key;
846
- if (measure.timeSig) prevTime = measure.timeSig;
902
+ xml += `${indent(1)}</part>\n`;
847
903
  }
848
904
 
849
- xml += `${indent(1)}</part>\n`;
850
905
  xml += '</score-partwise>\n';
851
906
 
852
907
  return xml;
@@ -158,10 +158,13 @@ export const convertPitch = (
158
158
  };
159
159
  };
160
160
 
161
- // ============ Duration Conversion ============
161
+ // ============ Duration Constants & Mappings ============
162
+
163
+ // Standard divisions per quarter note (shared by encoder/decoder)
164
+ export const DIVISIONS = 4;
162
165
 
163
166
  // MusicXML note type to division (1=whole, 2=half, 4=quarter, etc.)
164
- const TYPE_TO_DIVISION: Record<string, number> = {
167
+ export const TYPE_TO_DIVISION: Record<string, number> = {
165
168
  maxima: 0.125,
166
169
  long: 0.25,
167
170
  breve: 0.5,
@@ -178,6 +181,43 @@ const TYPE_TO_DIVISION: Record<string, number> = {
178
181
  '1024th': 1024,
179
182
  };
180
183
 
184
+ // Division to MusicXML note type (inverse of TYPE_TO_DIVISION)
185
+ export const DIVISION_TO_TYPE: Record<number, string> = Object.fromEntries(
186
+ Object.entries(TYPE_TO_DIVISION).map(([type, div]) => [div, type])
187
+ );
188
+
189
+ /**
190
+ * Calculate duration in MusicXML divisions.
191
+ * Shared by encoder (with DIVISIONS=4) and potentially decoder.
192
+ *
193
+ * Duration.tuplet is in Lilylet ratio semantics:
194
+ * \times 2/3 → {numerator:2, denominator:3} → multiply by 2/3
195
+ */
196
+ export const calculateDuration = (duration: Duration, divisions: number = DIVISIONS): number => {
197
+ // Base duration: divisions * (4 / division)
198
+ // e.g., quarter (4) = divisions * 1
199
+ // half (2) = divisions * 2
200
+ // eighth (8) = divisions * 0.5
201
+ let dur = divisions * (4 / duration.division);
202
+
203
+ // Apply dots
204
+ if (duration.dots) {
205
+ let dotValue = dur / 2;
206
+ for (let i = 0; i < duration.dots; i++) {
207
+ dur += dotValue;
208
+ dotValue /= 2;
209
+ }
210
+ }
211
+
212
+ // Apply tuplet ratio: Lilylet ratio num/den means multiply by num/den
213
+ // e.g., \times 2/3 means each note's actual duration = written * 2/3
214
+ if (duration.tuplet) {
215
+ dur = dur * duration.tuplet.numerator / duration.tuplet.denominator;
216
+ }
217
+
218
+ return Math.round(dur);
219
+ };
220
+
181
221
  /**
182
222
  * Convert MusicXML duration to Lilylet Duration
183
223
  *
@@ -218,9 +258,11 @@ export const convertDuration = (
218
258
  };
219
259
 
220
260
  if (timeModification) {
261
+ // Store as Lilylet ratio: normalNotes/actualNotes
262
+ // MusicXML actual=3, normal=2 (triplet) → Lilylet ratio {num:2, den:3}
221
263
  result.tuplet = {
222
- numerator: timeModification.actualNotes,
223
- denominator: timeModification.normalNotes,
264
+ numerator: timeModification.normalNotes,
265
+ denominator: timeModification.actualNotes,
224
266
  };
225
267
  }
226
268
 
@@ -771,6 +771,9 @@ const serializeMetadata = (metadata: any): string => {
771
771
  if (metadata.lyricist) {
772
772
  lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
773
773
  }
774
+ if (metadata.autoBeam) {
775
+ lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
776
+ }
774
777
 
775
778
  return lines.join('\n');
776
779
  };
@@ -286,6 +286,7 @@ export interface Metadata {
286
286
  opus?: string;
287
287
  instrument?: string;
288
288
  genre?: string;
289
+ autoBeam?: 'auto' | 'on' | 'off';
289
290
  }
290
291
 
291
292
  // Part within a measure: can be a single staff or grand staff (multiple staves)