@k-l-lambda/lilylet 0.1.48 → 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 +92 -42
  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
@@ -66,6 +66,7 @@ import {
66
66
  convertBarlineStyle,
67
67
  convertHarmonyToText,
68
68
  createFraction,
69
+ TYPE_TO_DIVISION,
69
70
  } from './musicXmlUtils';
70
71
 
71
72
  // ============ Spanner Tracker ============
@@ -163,12 +164,9 @@ class TupletTracker {
163
164
  // Add to all active tuplets (in case of nested tuplets)
164
165
  for (const [, tuplet] of this.activeTuplets) {
165
166
  // Set ratio from first event's duration.tuplet
167
+ // convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
166
168
  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
- };
169
+ tuplet.ratio = { ...event.duration.tuplet };
172
170
  }
173
171
  // Store event without tuplet info in duration (it's handled at TupletEvent level)
174
172
  const cleanEvent = { ...event, duration: { ...event.duration } };
@@ -237,6 +235,7 @@ class VoiceTracker {
237
235
  private currentPosition: number = 0;
238
236
  private divisions: number = 1;
239
237
  private staves: number = 1;
238
+ private currentStaff: Map<number, number> = new Map();
240
239
 
241
240
  setDivisions(div: number): void {
242
241
  this.divisions = div;
@@ -260,6 +259,7 @@ class VoiceTracker {
260
259
  events: [],
261
260
  staff,
262
261
  });
262
+ this.currentStaff.set(voiceNum, staff);
263
263
  }
264
264
  const voice = this.voices.get(voiceNum)!;
265
265
  // Update staff if specified
@@ -271,6 +271,11 @@ class VoiceTracker {
271
271
 
272
272
  addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
273
273
  const voice = this.getOrCreateVoice(voiceNum, staff);
274
+ const prevStaff = this.currentStaff.get(voiceNum) || 1;
275
+ if (staff > 0 && staff !== prevStaff) {
276
+ voice.events.push({ type: 'context', staff } as ContextChange);
277
+ this.currentStaff.set(voiceNum, staff);
278
+ }
274
279
  voice.events.push(event);
275
280
  voice.lastEvent = event;
276
281
  this.currentPosition += duration;
@@ -306,6 +311,7 @@ class VoiceTracker {
306
311
  reset(): void {
307
312
  this.voices.clear();
308
313
  this.currentPosition = 0;
314
+ this.currentStaff.clear();
309
315
  }
310
316
  }
311
317
 
@@ -825,20 +831,6 @@ const notationsToMarks = (
825
831
  return marks;
826
832
  };
827
833
 
828
- // MusicXML beat-unit to division mapping
829
- const BEAT_UNIT_TO_DIVISION: Record<string, number> = {
830
- 'maxima': 0.125,
831
- 'long': 0.25,
832
- 'breve': 0.5,
833
- 'whole': 1,
834
- 'half': 2,
835
- 'quarter': 4,
836
- 'eighth': 8,
837
- '16th': 16,
838
- '32nd': 32,
839
- '64th': 64,
840
- };
841
-
842
834
  // Common tempo words that should be converted to \tempo
843
835
  const TEMPO_WORDS = new Set([
844
836
  // Very slow
@@ -878,7 +870,7 @@ const directionToContextChange = (
878
870
  // Metronome → Tempo (may combine with words)
879
871
  if (direction.metronome) {
880
872
  const { beatUnit, beatUnitDot, perMinute } = direction.metronome;
881
- const division = BEAT_UNIT_TO_DIVISION[beatUnit] || 4;
873
+ const division = TYPE_TO_DIVISION[beatUnit] || 4;
882
874
 
883
875
  // Check if there's accompanying tempo text
884
876
  let tempoText: string | undefined;
@@ -1078,6 +1070,10 @@ const convertMeasure = (
1078
1070
  const staffNum = note.staff || 1;
1079
1071
  currentVoice = voiceNum;
1080
1072
 
1073
+ // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
1074
+ // where notes go to tupletTracker but voice must be initialized for staff detection)
1075
+ voiceTracker.getOrCreateVoice(voiceNum, staffNum);
1076
+
1081
1077
  // Check for tuplet start BEFORE processing the note
1082
1078
  const tupletNotation = note.notations?.tuplet;
1083
1079
  if (tupletNotation?.type === 'start') {
@@ -1283,6 +1279,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1283
1279
  let lastKey: KeySignature | undefined;
1284
1280
  let lastTimeSig: Fraction | undefined;
1285
1281
  let isFirstMeasure = true;
1282
+ let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1286
1283
  const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
1287
1284
 
1288
1285
  const measureEls = getDirectChildren(partEl, 'measure');
@@ -1351,7 +1348,9 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1351
1348
 
1352
1349
  // If no voices found, create an empty one
1353
1350
  if (voices.length === 0) {
1354
- voices.push({ staff: 1, events: [] });
1351
+ voices.push({ staff: lastVoiceStaff, events: [] });
1352
+ } else {
1353
+ lastVoiceStaff = voices[0].staff || 1;
1355
1354
  }
1356
1355
 
1357
1356
  const measure: Measure = {
@@ -1395,19 +1394,67 @@ export const decode = (xmlString: string): LilyletDoc => {
1395
1394
  // Parse metadata
1396
1395
  const metadata = parseMetadata(doc);
1397
1396
 
1397
+ // Parse <part-list> to get part names
1398
+ const partNames: Map<string, string> = new Map();
1399
+ const partListEl = doc.getElementsByTagName('part-list')[0];
1400
+ if (partListEl) {
1401
+ const scorePartEls = getElements(partListEl, 'score-part');
1402
+ for (const sp of scorePartEls) {
1403
+ const id = getAttribute(sp, 'id');
1404
+ const name = getElementText(sp, 'part-name');
1405
+ if (id && name) {
1406
+ partNames.set(id, name);
1407
+ }
1408
+ }
1409
+ }
1410
+
1398
1411
  // Get parts
1399
- const partEls = Array.from(doc.getElementsByTagName('part'));
1412
+ const partEls = getDirectChildren(root, 'part');
1400
1413
  if (partEls.length === 0) {
1401
1414
  throw new Error('No parts found in MusicXML');
1402
1415
  }
1403
1416
 
1404
- // For now, convert only the first part
1405
- // TODO: Handle multiple parts
1406
- const firstPart = partEls[0];
1407
- const { measures } = convertPart(firstPart);
1417
+ // Convert all parts
1418
+ const allPartResults: { measures: Measure[]; name?: string; partId?: string }[] = [];
1419
+ for (const partEl of partEls) {
1420
+ const partId = getAttribute(partEl, 'id') || undefined;
1421
+ const { measures } = convertPart(partEl);
1422
+ const name = partId ? partNames.get(partId) : undefined;
1423
+ allPartResults.push({ measures, name, partId });
1424
+ }
1425
+
1426
+ // Merge parts: combine into multi-part measures
1427
+ const numMeasures = Math.max(...allPartResults.map(p => p.measures.length));
1428
+ const mergedMeasures: Measure[] = [];
1429
+
1430
+ for (let mi = 0; mi < numMeasures; mi++) {
1431
+ const parts: Part[] = [];
1432
+
1433
+ for (const partResult of allPartResults) {
1434
+ const sourceMeasure = partResult.measures[mi];
1435
+ if (sourceMeasure && sourceMeasure.parts.length > 0) {
1436
+ const part = sourceMeasure.parts[0];
1437
+ if (partResult.name) {
1438
+ part.name = partResult.name;
1439
+ }
1440
+ parts.push(part);
1441
+ } else {
1442
+ // Empty part placeholder
1443
+ parts.push({ voices: [{ staff: 1, events: [] }] });
1444
+ }
1445
+ }
1446
+
1447
+ // Use key/timeSig from the first part's measure (they should be consistent)
1448
+ const firstPartMeasure = allPartResults[0].measures[mi];
1449
+ const measure: Measure = { parts };
1450
+ if (firstPartMeasure?.key) measure.key = firstPartMeasure.key;
1451
+ if (firstPartMeasure?.timeSig) measure.timeSig = firstPartMeasure.timeSig;
1452
+
1453
+ mergedMeasures.push(measure);
1454
+ }
1408
1455
 
1409
1456
  const result: LilyletDoc = {
1410
- measures,
1457
+ measures: mergedMeasures,
1411
1458
  };
1412
1459
 
1413
1460
  if (Object.keys(metadata).length > 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;