@k-l-lambda/lilylet 0.1.59 → 0.1.62

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.
@@ -391,6 +391,42 @@ const parseLilyDocument = (lilyDocument) => {
391
391
  const measureMap = new Map();
392
392
  const staffNames = [];
393
393
  const interpreter = lilyDocument.interpret();
394
+ // Pre-compute partIndex for each track using staff-number-sequence heuristic.
395
+ // When staff numbers reset (decrease), a new part starts — this handles multi-PianoStaff
396
+ // scores where both pianos reuse the same staff names ("1" and "2").
397
+ // Scores that embed partIndex in the staff name ("1_1", "1_2", "2_1") are handled
398
+ // by parseStaffName; this heuristic only kicks in for plain numeric names.
399
+ let _seqPart = 1;
400
+ let _seqMaxStaff = 0;
401
+ const _trackPartIndices = interpreter.layoutMusic.musicTracks.map((track) => {
402
+ const staffName = track.contextDict?.Staff;
403
+ if (staffName && /^\d+_\d+$/.test(staffName)) {
404
+ // Staff name encodes partIndex explicitly — don't apply heuristic
405
+ return parseInt(staffName.split('_')[0], 10);
406
+ }
407
+ // PianoStaff is present (even as empty string "") when inside a grand-staff group;
408
+ // undefined means standalone instrument → different part from grand-staff tracks.
409
+ const hasPianoStaff = (track.contextDict?.PianoStaff !== undefined);
410
+ const staffNum = parseInt(staffName || '1', 10) || 1;
411
+ if (hasPianoStaff) {
412
+ // Grand-staff: new part if transitioning from standalone or staff resets
413
+ if (_seqMaxStaff > 0 && staffNum < _seqMaxStaff) {
414
+ _seqPart++;
415
+ _seqMaxStaff = 0;
416
+ }
417
+ else if (_seqMaxStaff === -1) {
418
+ // Transitioning from a standalone group
419
+ _seqPart++;
420
+ _seqMaxStaff = 0;
421
+ }
422
+ _seqMaxStaff = Math.max(_seqMaxStaff, staffNum);
423
+ }
424
+ else {
425
+ // Standalone: mark with -1 so next grand-staff group increments
426
+ _seqMaxStaff = -1;
427
+ }
428
+ return _seqPart;
429
+ });
394
430
  interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
395
431
  const appendStaff = (staffName) => {
396
432
  if (!staffNames.includes(staffName)) {
@@ -399,15 +435,20 @@ const parseLilyDocument = (lilyDocument) => {
399
435
  };
400
436
  // Parse staff name to extract partIndex and staff number
401
437
  // Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
402
- // Falls back to partIndex=1 if format doesn't match
438
+ // Falls back to positional ordering for non-numeric names (e.g., "upper"→1, "lower"→2)
403
439
  const parseStaffName = (name) => {
404
440
  const match = name.match(/^(\d+)_(\d+)$/);
405
441
  if (match) {
406
442
  return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
407
443
  }
408
- // Fallback: single part, staff number from name or 1
409
444
  const num = parseInt(name, 10);
410
- return { partIndex: 1, staffNum: isNaN(num) ? 1 : num };
445
+ if (!isNaN(num)) {
446
+ return { partIndex: 1, staffNum: num };
447
+ }
448
+ // Non-numeric name: assign staffNum by order of first appearance in staffNames
449
+ appendStaff(name);
450
+ const idx = staffNames.indexOf(name);
451
+ return { partIndex: 1, staffNum: idx + 1 };
411
452
  };
412
453
  // Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
413
454
  // This won't be affected by \change Staff commands inside the track
@@ -415,16 +456,21 @@ const parseLilyDocument = (lilyDocument) => {
415
456
  if (initialStaffName) {
416
457
  appendStaff(initialStaffName);
417
458
  }
418
- const parsedStaff = initialStaffName ? parseStaffName(initialStaffName) : { partIndex: 1, staffNum: 1 };
459
+ // Empty string staff name ("") means unnamed staff treat as staff 1 within its part
460
+ const parsedStaff = (initialStaffName != null && initialStaffName !== '')
461
+ ? parseStaffName(initialStaffName)
462
+ : { partIndex: 1, staffNum: 1 };
419
463
  // Use these as fixed values for this track - don't update from context.staffName
420
464
  const trackStaff = parsedStaff.staffNum;
421
- const trackPartIndex = parsedStaff.partIndex;
465
+ // Use sequence-based partIndex (detects multi-PianoStaff via staff number reset)
466
+ const trackPartIndex = _trackPartIndices[vi] ?? parsedStaff.partIndex;
422
467
  // Track emitted context events across measures for this voice
423
468
  let lastKey = undefined; // Track value changes (key fifths)
424
469
  let lastTimeSig = undefined; // Track value changes (as string for comparison)
425
470
  let lastClef = undefined; // Track value changes
426
471
  let lastOttava = undefined; // Track value changes
427
472
  let lastStemDirection = undefined; // Track value changes
473
+ let partialEmitted = false; // Emit \partial context once per track
428
474
  const context = new lilyParser.TrackContext(undefined, {
429
475
  listener: (term, context) => {
430
476
  const mi = term._measure;
@@ -448,11 +494,12 @@ const parseLilyDocument = (lilyDocument) => {
448
494
  };
449
495
  }
450
496
  const voice = measure.voices[vi];
451
- // Update key/time from context on music events
497
+ // Update key/time/partial from context on music events
452
498
  if (term instanceof lilyParser.MusicEvent ||
453
499
  term instanceof lilyParser.LilyTerms.StemDirection ||
454
500
  term instanceof lilyParser.LilyTerms.OctaveShift ||
455
- term instanceof lilyParser.LilyTerms.Change) {
501
+ term instanceof lilyParser.LilyTerms.Change ||
502
+ term instanceof lilyParser.LilyTerms.Partial) {
456
503
  if (context.key && measure.key === null) {
457
504
  measure.key = context.key.key;
458
505
  }
@@ -466,6 +513,32 @@ const parseLilyDocument = (lilyDocument) => {
466
513
  measure.partial = true;
467
514
  }
468
515
  }
516
+ // Emit \partial context event when the Partial term fires (before partialDuration is cleared)
517
+ if (term instanceof lilyParser.LilyTerms.Partial && context.partialDuration && !partialEmitted) {
518
+ const mag = context.partialDuration.magnitude;
519
+ const WHOLE = 1920;
520
+ let division = 1, dots = 0;
521
+ for (const div of [1, 2, 4, 8, 16, 32, 64]) {
522
+ const base = WHOLE / div;
523
+ if (Math.abs(mag - base) < 1) {
524
+ division = div;
525
+ dots = 0;
526
+ break;
527
+ }
528
+ if (Math.abs(mag - Math.round(base * 1.5)) < 1) {
529
+ division = div;
530
+ dots = 1;
531
+ break;
532
+ }
533
+ if (Math.abs(mag - Math.round(base * 1.75)) < 1) {
534
+ division = div;
535
+ dots = 2;
536
+ break;
537
+ }
538
+ }
539
+ voice.events.push({ type: 'context', partial: { division, dots } });
540
+ partialEmitted = true;
541
+ }
469
542
  // Handle music events
470
543
  if (term instanceof lilyParser.MusicEvent) {
471
544
  // Staff is fixed per track (from track definition)
@@ -583,13 +656,17 @@ const parseLilyDocument = (lilyDocument) => {
583
656
  }
584
657
  // Process Rest
585
658
  else if (term instanceof lilyParser.LilyTerms.Rest) {
586
- const restEvent = {
587
- type: 'rest',
588
- duration: convertDuration(term.durationValue),
589
- fullMeasure: (term.name === 'R') || undefined,
590
- invisible: term.isSpacer || undefined,
591
- };
592
- voice.events.push(restEvent);
659
+ // Ignore spacer rests inside grace contexts (e.g. \acciaccatura s8,
660
+ // \grace s4 — these are notation-only placeholders with no musical content)
661
+ if (!(term.isSpacer && context.inGrace)) {
662
+ const restEvent = {
663
+ type: 'rest',
664
+ duration: convertDuration(term.durationValue),
665
+ fullMeasure: (term.name === 'R') || undefined,
666
+ invisible: term.isSpacer || undefined,
667
+ };
668
+ voice.events.push(restEvent);
669
+ }
593
670
  }
594
671
  }
595
672
  // Handle standalone stem direction (emit when value changes)
@@ -712,41 +789,80 @@ const parseLilyDocument = (lilyDocument) => {
712
789
  // Handle tuplet
713
790
  // Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
714
791
  // remove the already-added notes and wrap them in a TupletEvent
715
- else if (termAny.proto === 'Tuplet') {
716
- const ratioStr = termAny.args?.[0]; // e.g., "3/2"
717
- const body = termAny.args?.[1]?.body || [];
792
+ else if (termAny.proto === 'Tuplet' || termAny.proto === 'Times') {
793
+ const isTimes = termAny.proto === 'Times';
794
+ const ratioStr = termAny.args?.[0]; // "3/2" for \tuplet, "2/3" for \times
795
+ // \tuplet supports an optional base-duration arg: \tuplet 3/2 4 { notes }
796
+ // making args = [ratio, baseDur?, body]. Use the last arg (= music block),
797
+ // matching what lotus Tuplet.music getter does: this.args[this.args.length-1]
798
+ const body = termAny.args?.[termAny.args.length - 1]?.body || [];
718
799
  if (ratioStr && body.length > 0) {
719
- // Parse ratio string
720
800
  const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
721
801
  if (ratioMatch) {
722
802
  const [, num, denom] = ratioMatch;
723
- const ratio = {
724
- numerator: parseInt(denom, 10), // Swapped: lilylet uses actual/normal
725
- denominator: parseInt(num, 10),
803
+ // \tuplet 3/2: divider = 3/2, lilylet ratio = 2/3 → swap
804
+ // \times 2/3: factor = 2/3, lilylet ratio = 2/3 → no swap
805
+ const ratio = isTimes
806
+ ? { numerator: parseInt(num, 10), denominator: parseInt(denom, 10) }
807
+ : { numerator: parseInt(denom, 10), denominator: parseInt(num, 10) };
808
+ // Count sounding (non-grace) notes/rests in the tuplet body,
809
+ // recursing into AfterGrace.body (main note) but not AfterGrace.grace.
810
+ // Grace / Acciaccatura / Appoggiatura blocks count as 0.
811
+ const countSounding = (items) => {
812
+ let n = 0;
813
+ for (const item of items) {
814
+ if (!item)
815
+ continue;
816
+ switch (item.proto) {
817
+ case 'Chord':
818
+ case 'Rest':
819
+ n++;
820
+ break;
821
+ case 'AfterGrace': {
822
+ // args[0] = main note, args[1] = after-grace notes (0-dur, skip)
823
+ const main = item.args?.[0];
824
+ if (main?.proto === 'Chord' || main?.proto === 'Rest')
825
+ n++;
826
+ else if (main?.body)
827
+ n += countSounding(main.body);
828
+ break;
829
+ }
830
+ case 'Grace':
831
+ case 'Acciaccatura':
832
+ case 'Appoggiatura': break;
833
+ default:
834
+ if (item.body)
835
+ n += countSounding(item.body);
836
+ break;
837
+ }
838
+ }
839
+ return n;
726
840
  };
727
- // Count how many note/rest events are in the tuplet body
728
- const noteCount = body.filter((item) => item.proto === 'Chord' || item.proto === 'Rest').length;
729
- // Remove the last noteCount note/rest events from voice.events
730
- // (they were already added by the Chord/Rest handlers)
841
+ const noteCount = countSounding(body);
731
842
  const tupletEvents = [];
732
843
  let removed = 0;
844
+ // Pop notes/rests from voice.events, skipping context events.
845
+ // Grace notes are moved inside the tuplet but do NOT consume a
846
+ // sounding-note slot (they have 0 duration in the time signature).
733
847
  while (removed < noteCount && voice.events.length > 0) {
734
848
  const lastEvent = voice.events[voice.events.length - 1];
735
849
  if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
736
850
  tupletEvents.unshift(voice.events.pop());
737
- removed++;
851
+ if (!lastEvent.grace)
852
+ removed++;
853
+ }
854
+ else if (lastEvent.type === 'context' || lastEvent.type === 'pitchReset') {
855
+ // Context event between tuplet notes — move it inside tuplet too
856
+ tupletEvents.unshift(voice.events.pop());
738
857
  }
739
858
  else {
740
- break; // Stop if we hit a non-note/rest event
859
+ break;
741
860
  }
742
861
  }
743
862
  if (tupletEvents.length > 0) {
744
- const tupletEvent = {
745
- type: 'tuplet',
746
- ratio,
747
- events: tupletEvents,
748
- };
749
- voice.events.push(tupletEvent);
863
+ voice.events.push(isTimes
864
+ ? { type: 'times', ratio, events: tupletEvents }
865
+ : { type: 'tuplet', ratio, events: tupletEvents });
750
866
  }
751
867
  }
752
868
  }
@@ -793,6 +909,49 @@ const parseLilyDocument = (lilyDocument) => {
793
909
  },
794
910
  });
795
911
  context.execute(track.music);
912
+ // Post-process: carry staff state across measure boundaries for this voice.
913
+ // If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
914
+ // with context { staff: 2 } so notes at the start of that measure serialize
915
+ // on the correct staff.
916
+ {
917
+ const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
918
+ let carryStaff = trackStaff;
919
+ for (const mi of measureIndices) {
920
+ const voice = measureMap.get(mi)?.voices[vi];
921
+ if (!voice)
922
+ continue;
923
+ // Prepend carry-over event if previous measure ended on a different staff,
924
+ // but skip if the voice's first explicit staff event already resets to
925
+ // trackStaff — the carry-over would be immediately cancelled (no-op).
926
+ if (carryStaff !== trackStaff) {
927
+ // Suppress carry-over if the first staff event resets to trackStaff
928
+ // AND no musical events (notes/rests/etc.) precede it — meaning the
929
+ // carry-over would be immediately cancelled with no effect.
930
+ const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
931
+ const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
932
+ const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
933
+ voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
934
+ const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
935
+ voice.events[firstStaffCtxIdx].staff === trackStaff &&
936
+ !hasMusicBeforeFirstStaff;
937
+ if (!immediatelyCancelled) {
938
+ voice.events.unshift({ type: 'context', staff: carryStaff });
939
+ }
940
+ }
941
+ // Update carryStaff from this measure's events (including inside tuplets)
942
+ const scanStaff = (events) => {
943
+ for (const e of events) {
944
+ if (e.type === 'context' && e.staff) {
945
+ carryStaff = e.staff;
946
+ }
947
+ else if ((e.type === 'tuplet' || e.type === 'times') && e.events) {
948
+ scanStaff(e.events);
949
+ }
950
+ }
951
+ };
952
+ scanStaff(voice.events);
953
+ }
954
+ }
796
955
  });
797
956
  // Filter out empty voices and convert to array, sorted by measure number
798
957
  const measures = Array.from(measureMap.entries())
@@ -812,6 +971,8 @@ const hasRealContent = (events) => {
812
971
  return true;
813
972
  if (e.type === 'tuplet')
814
973
  return true;
974
+ if (e.type === 'times')
975
+ return true;
815
976
  if (e.type === 'tremolo')
816
977
  return true;
817
978
  return false;
@@ -115,6 +115,59 @@ const getSpacerRest = (timeSig) => {
115
115
  const { numerator, denominator } = timeSig;
116
116
  return `s${denominator}*${numerator}`;
117
117
  };
118
+ // === Partial Measure Helpers ===
119
+ const TPQN = 480;
120
+ const voiceDurationTicks = (voice) => {
121
+ let ticks = 0;
122
+ const addEvent = (e, factor = 1) => {
123
+ if (e.type === 'note' || e.type === 'rest') {
124
+ const ev = e;
125
+ if (ev.grace)
126
+ return;
127
+ let t = (TPQN * 4) / ev.duration.division;
128
+ let dot = t / 2;
129
+ for (let i = 0; i < ev.duration.dots; i++) {
130
+ t += dot;
131
+ dot /= 2;
132
+ }
133
+ ticks += Math.round(t * factor);
134
+ }
135
+ else if (e.type === 'tuplet' || e.type === 'times') {
136
+ const te = e;
137
+ const f = factor * te.ratio.numerator / te.ratio.denominator;
138
+ for (const inner of te.events)
139
+ addEvent(inner, f);
140
+ }
141
+ };
142
+ for (const e of voice.events)
143
+ addEvent(e);
144
+ return ticks;
145
+ };
146
+ const ticksToLyDuration = (ticks) => {
147
+ const whole = TPQN * 4;
148
+ for (const div of [1, 2, 4, 8, 16, 32, 64]) {
149
+ const t = whole / div;
150
+ if (Math.abs(ticks - t) < 1)
151
+ return String(div);
152
+ if (Math.abs(ticks - Math.round(t * 1.5)) < 1)
153
+ return `${div}.`;
154
+ if (Math.abs(ticks - Math.round(t * 1.75)) < 1)
155
+ return `${div}..`;
156
+ }
157
+ return '4';
158
+ };
159
+ // Return a spacer-rest suffix to pad a voice to the full measure duration.
160
+ // Uses s16*N to avoid complexity with dotted/compound values.
161
+ const padVoiceToMeasure = (voiceTicks, measureTicks) => {
162
+ const remaining = Math.round(measureTicks - voiceTicks);
163
+ if (remaining <= 0)
164
+ return '';
165
+ const sixteenth = Math.round(TPQN / 4); // 120 ticks
166
+ const numSixteenths = Math.round(remaining / sixteenth);
167
+ if (numSixteenths <= 0)
168
+ return '';
169
+ return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
170
+ };
118
171
  /**
119
172
  * Calculate the octave markers needed to serialize a pitch in relative mode.
120
173
  */
@@ -396,7 +449,11 @@ const encodeContextChange = (event) => {
396
449
  * Encode a tuplet event
397
450
  */
398
451
  const encodeTupletEvent = (event, env, lastDuration) => {
399
- let result = `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator} { `;
452
+ // \times preserves type:"times"; \tuplet is denominator/numerator
453
+ const header = event.type === 'times'
454
+ ? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
455
+ : `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
456
+ let result = `${header} { `;
400
457
  let newEnv = env;
401
458
  let newDuration = lastDuration;
402
459
  for (const subEvent of event.events) {
@@ -519,7 +576,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
519
576
  result += encodeContextChange(event) + ' ';
520
577
  break;
521
578
  }
522
- case 'tuplet': {
579
+ case 'tuplet':
580
+ case 'times': {
523
581
  const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
524
582
  result += str + ' ';
525
583
  env = newEnv;
@@ -600,7 +658,7 @@ export const encode = (doc, options = {}) => {
600
658
  for (const part of measure.parts) {
601
659
  for (const voice of part.voices) {
602
660
  for (const event of voice.events) {
603
- if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
661
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
604
662
  return true;
605
663
  }
606
664
  }
@@ -622,6 +680,8 @@ export const encode = (doc, options = {}) => {
622
680
  }
623
681
  // Track time signature for each measure (for spacer rests)
624
682
  const measureTimeSigs = [];
683
+ // Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
684
+ const measurePartialDurs = [];
625
685
  let currentKey;
626
686
  let currentTimeSig;
627
687
  for (let mi = 0; mi < measures.length; mi++) {
@@ -633,6 +693,30 @@ export const encode = (doc, options = {}) => {
633
693
  currentTimeSig = measure.timeSig;
634
694
  // Store time signature for this measure
635
695
  measureTimeSigs[mi] = currentTimeSig;
696
+ // Detect partial (pickup) measures and compute their duration.
697
+ // Only the first measure (mi===0) can be an implicit partial;
698
+ // subsequent incomplete measures are NOT treated as partial.
699
+ const isExplicitPartial = measure.partial === true;
700
+ if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
701
+ let maxVoiceTicks = 0;
702
+ for (const part of measure.parts) {
703
+ for (const v of part.voices) {
704
+ maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
705
+ }
706
+ }
707
+ const expectedTicks = currentTimeSig
708
+ ? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
709
+ : TPQN * 4;
710
+ if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
711
+ measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
712
+ }
713
+ else {
714
+ measurePartialDurs[mi] = undefined;
715
+ }
716
+ }
717
+ else {
718
+ measurePartialDurs[mi] = undefined;
719
+ }
636
720
  // Process each part
637
721
  for (let pi = 0; pi < measure.parts.length; pi++) {
638
722
  const part = measure.parts[pi];
@@ -649,11 +733,19 @@ export const encode = (doc, options = {}) => {
649
733
  staffMeasures.push([]);
650
734
  }
651
735
  // Encode voice content
652
- const voiceContent = encodeVoice(voice, {
736
+ let voiceContent = encodeVoice(voice, {
653
737
  key: currentKey,
654
738
  timeSig: currentTimeSig,
655
739
  isFirst: mi === 0
656
740
  }, vi);
741
+ // For non-partial measures, pad incomplete voices to fill the full
742
+ // measure duration. lotus doesn't auto-advance on barlines, so
743
+ // under-full voices cause measure boundary miscounting.
744
+ if (!measurePartialDurs[mi] && currentTimeSig) {
745
+ const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
746
+ const voiceTicks = voiceDurationTicks(voice);
747
+ voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
748
+ }
657
749
  staffMeasures[mi].push(voiceContent);
658
750
  }
659
751
  }
@@ -666,10 +758,28 @@ export const encode = (doc, options = {}) => {
666
758
  // Build voice lines
667
759
  const voiceLines = [];
668
760
  for (let vi = 0; vi < maxVoices; vi++) {
761
+ let prevTimeSigStr;
669
762
  const measureContents = measures.map((m, mi) => {
670
- // Use correct spacer rest based on time signature
671
- const spacer = getSpacerRest(measureTimeSigs[mi]);
672
- const content = m[vi] || spacer;
763
+ // For partial (pickup) measures, use a partial-duration spacer
764
+ const partialDur = measurePartialDurs[mi];
765
+ const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
766
+ let content = m[vi] || spacer;
767
+ // Inject \time if the content lacks it and the time sig changed.
768
+ // lotus processes each voice independently — without \time it
769
+ // defaults to 4/4, miscounting measure boundaries for other meters.
770
+ const ts = measureTimeSigs[mi];
771
+ if (ts) {
772
+ const tsStr = `${ts.numerator}/${ts.denominator}`;
773
+ if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
774
+ content = `${encodeTimeSig(ts)} ${content}`;
775
+ }
776
+ prevTimeSigStr = tsStr;
777
+ }
778
+ // For partial measures, prepend \partial DUR before all other commands
779
+ // so the lotus interpreter correctly tracks the pickup measure boundary.
780
+ if (partialDur && !content.includes('\\partial')) {
781
+ content = `\\partial ${partialDur} ${content}`;
782
+ }
673
783
  // Wrap each measure in its own \relative c' to reset pitch context
674
784
  return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
675
785
  });
@@ -567,7 +567,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
567
567
  break;
568
568
  }
569
569
  case 'tuplet': {
570
- const tupletEvents = event.events;
570
+ const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest');
571
571
  for (let ti = 0; ti < tupletEvents.length; ti++) {
572
572
  const subEvent = tupletEvents[ti];
573
573
  // Set tuplet ratio on duration so encodeDuration emits <time-modification>
@@ -1,3 +1,14 @@
1
1
  import { LilyletDoc } from "./types";
2
+ export interface ParseWarning {
3
+ type: 'partial-mismatch';
4
+ message: string;
5
+ declared: number;
6
+ actual: number;
7
+ }
2
8
  declare const parseCode: (code: string) => LilyletDoc;
3
- export { parseCode, };
9
+ /**
10
+ * Return warnings emitted during the most recent parseCode() call.
11
+ * Currently reports \partial duration mismatches.
12
+ */
13
+ declare const getParseWarnings: () => ParseWarning[];
14
+ export { parseCode, getParseWarnings, };
@@ -148,4 +148,14 @@ const parseCode = (code) => {
148
148
  resolveDocumentPitches(raw);
149
149
  return raw;
150
150
  };
151
- export { parseCode, };
151
+ /**
152
+ * Return warnings emitted during the most recent parseCode() call.
153
+ * Currently reports \partial duration mismatches.
154
+ */
155
+ const getParseWarnings = () => {
156
+ if (parser && parser.getWarnings) {
157
+ return parser.getWarnings();
158
+ }
159
+ return [];
160
+ };
161
+ export { parseCode, getParseWarnings, };