@k-l-lambda/lilylet 0.1.60 → 0.1.63

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
  }
@@ -812,7 +928,7 @@ const parseLilyDocument = (lilyDocument) => {
812
928
  // AND no musical events (notes/rests/etc.) precede it — meaning the
813
929
  // carry-over would be immediately cancelled with no effect.
814
930
  const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
815
- const musicalTypes = new Set(['note', 'rest', 'tuplet', 'tremolo']);
931
+ const musicalTypes = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
816
932
  const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
817
933
  voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
818
934
  const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
@@ -822,12 +938,18 @@ const parseLilyDocument = (lilyDocument) => {
822
938
  voice.events.unshift({ type: 'context', staff: carryStaff });
823
939
  }
824
940
  }
825
- // Update carryStaff from this measure's events
826
- for (const e of voice.events) {
827
- if (e.type === 'context' && e.staff) {
828
- carryStaff = e.staff;
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
+ }
829
950
  }
830
- }
951
+ };
952
+ scanStaff(voice.events);
831
953
  }
832
954
  }
833
955
  });
@@ -849,6 +971,8 @@ const hasRealContent = (events) => {
849
971
  return true;
850
972
  if (e.type === 'tuplet')
851
973
  return true;
974
+ if (e.type === 'times')
975
+ return true;
852
976
  if (e.type === 'tremolo')
853
977
  return true;
854
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) {
@@ -412,6 +469,9 @@ const encodeTupletEvent = (event, env, lastDuration) => {
412
469
  newEnv = ne;
413
470
  newDuration = nd;
414
471
  }
472
+ else if (subEvent.type === 'context') {
473
+ result += encodeContextChange(subEvent) + ' ';
474
+ }
415
475
  }
416
476
  result += '}';
417
477
  return { str: result, newEnv, newDuration };
@@ -519,7 +579,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
519
579
  result += encodeContextChange(event) + ' ';
520
580
  break;
521
581
  }
522
- case 'tuplet': {
582
+ case 'tuplet':
583
+ case 'times': {
523
584
  const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
524
585
  result += str + ' ';
525
586
  env = newEnv;
@@ -600,7 +661,7 @@ export const encode = (doc, options = {}) => {
600
661
  for (const part of measure.parts) {
601
662
  for (const voice of part.voices) {
602
663
  for (const event of voice.events) {
603
- if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
664
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
604
665
  return true;
605
666
  }
606
667
  }
@@ -622,6 +683,8 @@ export const encode = (doc, options = {}) => {
622
683
  }
623
684
  // Track time signature for each measure (for spacer rests)
624
685
  const measureTimeSigs = [];
686
+ // Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
687
+ const measurePartialDurs = [];
625
688
  let currentKey;
626
689
  let currentTimeSig;
627
690
  for (let mi = 0; mi < measures.length; mi++) {
@@ -633,6 +696,30 @@ export const encode = (doc, options = {}) => {
633
696
  currentTimeSig = measure.timeSig;
634
697
  // Store time signature for this measure
635
698
  measureTimeSigs[mi] = currentTimeSig;
699
+ // Detect partial (pickup) measures and compute their duration.
700
+ // Only the first measure (mi===0) can be an implicit partial;
701
+ // subsequent incomplete measures are NOT treated as partial.
702
+ const isExplicitPartial = measure.partial === true;
703
+ if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
704
+ let maxVoiceTicks = 0;
705
+ for (const part of measure.parts) {
706
+ for (const v of part.voices) {
707
+ maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
708
+ }
709
+ }
710
+ const expectedTicks = currentTimeSig
711
+ ? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
712
+ : TPQN * 4;
713
+ if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
714
+ measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
715
+ }
716
+ else {
717
+ measurePartialDurs[mi] = undefined;
718
+ }
719
+ }
720
+ else {
721
+ measurePartialDurs[mi] = undefined;
722
+ }
636
723
  // Process each part
637
724
  for (let pi = 0; pi < measure.parts.length; pi++) {
638
725
  const part = measure.parts[pi];
@@ -649,11 +736,19 @@ export const encode = (doc, options = {}) => {
649
736
  staffMeasures.push([]);
650
737
  }
651
738
  // Encode voice content
652
- const voiceContent = encodeVoice(voice, {
739
+ let voiceContent = encodeVoice(voice, {
653
740
  key: currentKey,
654
741
  timeSig: currentTimeSig,
655
742
  isFirst: mi === 0
656
743
  }, vi);
744
+ // For non-partial measures, pad incomplete voices to fill the full
745
+ // measure duration. lotus doesn't auto-advance on barlines, so
746
+ // under-full voices cause measure boundary miscounting.
747
+ if (!measurePartialDurs[mi] && currentTimeSig) {
748
+ const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
749
+ const voiceTicks = voiceDurationTicks(voice);
750
+ voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
751
+ }
657
752
  staffMeasures[mi].push(voiceContent);
658
753
  }
659
754
  }
@@ -666,10 +761,28 @@ export const encode = (doc, options = {}) => {
666
761
  // Build voice lines
667
762
  const voiceLines = [];
668
763
  for (let vi = 0; vi < maxVoices; vi++) {
764
+ let prevTimeSigStr;
669
765
  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;
766
+ // For partial (pickup) measures, use a partial-duration spacer
767
+ const partialDur = measurePartialDurs[mi];
768
+ const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
769
+ let content = m[vi] || spacer;
770
+ // Inject \time if the content lacks it and the time sig changed.
771
+ // lotus processes each voice independently — without \time it
772
+ // defaults to 4/4, miscounting measure boundaries for other meters.
773
+ const ts = measureTimeSigs[mi];
774
+ if (ts) {
775
+ const tsStr = `${ts.numerator}/${ts.denominator}`;
776
+ if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
777
+ content = `${encodeTimeSig(ts)} ${content}`;
778
+ }
779
+ prevTimeSigStr = tsStr;
780
+ }
781
+ // For partial measures, prepend \partial DUR before all other commands
782
+ // so the lotus interpreter correctly tracks the pickup measure boundary.
783
+ if (partialDur && !content.includes('\\partial')) {
784
+ content = `\\partial ${partialDur} ${content}`;
785
+ }
673
786
  // Wrap each measure in its own \relative c' to reset pitch context
674
787
  return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
675
788
  });
@@ -538,7 +538,7 @@ const tupletHasInternalBeams = (event) => {
538
538
  return starts > 0 && starts === ends;
539
539
  };
540
540
  // Convert TupletEvent to MEI
541
- const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals) => {
541
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
542
542
  // LilyPond \times 2/3 means "multiply duration by 2/3"
543
543
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
544
544
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -561,6 +561,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
561
561
  // Handle internal beam groups: if notes have manual beam marks, respect them
562
562
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
563
563
  let beamOpen = false;
564
+ let activeClef = currentClef;
565
+ let endingClef;
564
566
  for (const e of event.events) {
565
567
  if (e.type === 'note') {
566
568
  const noteEvent = e;
@@ -607,13 +609,27 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
607
609
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
608
610
  xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
609
611
  }
612
+ else if (e.type === 'context') {
613
+ const ctx = e;
614
+ if (ctx.clef && ctx.clef !== activeClef) {
615
+ const layerStaffNum = layerStaff || 1;
616
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
617
+ if (effectiveStaffNum === layerStaffNum) {
618
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
619
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
620
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
621
+ }
622
+ activeClef = ctx.clef;
623
+ endingClef = ctx.clef;
624
+ }
625
+ }
610
626
  }
611
627
  // Close any unclosed beam
612
628
  if (beamOpen) {
613
629
  xml += `${baseIndent}</beam>\n`;
614
630
  }
615
631
  xml += `${indent}</tuplet>\n`;
616
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
632
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
617
633
  };
618
634
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
619
635
  const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
@@ -688,7 +704,7 @@ const getEventBeamMarks = (event) => {
688
704
  const markOptions = extractMarkOptions(event.marks);
689
705
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
690
706
  }
691
- if (event.type === 'tuplet') {
707
+ if (event.type === 'tuplet' || event.type === 'times') {
692
708
  const tuplet = event;
693
709
  // If the tuplet has internal beam groups, don't report beam marks to the parent
694
710
  // so the parent won't wrap the tuplet in an external <beam>
@@ -923,11 +939,16 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
923
939
  xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
924
940
  break;
925
941
  }
926
- case 'tuplet': {
942
+ case 'tuplet':
943
+ case 'times': {
927
944
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
928
945
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
929
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
946
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
930
947
  xml += tupletResult.xml;
948
+ // Propagate clef change from inside the tuplet to the parent tracker
949
+ if (tupletResult.endingClef) {
950
+ currentClef = tupletResult.endingClef;
951
+ }
931
952
  // Flush any pending markups onto the first note of the tuplet
932
953
  if (tupletResult.firstNoteId) {
933
954
  flushPendingMarkups(tupletResult.firstNoteId);
@@ -1566,7 +1587,7 @@ const docHasBeamMarks = (doc) => {
1566
1587
  }
1567
1588
  }
1568
1589
  }
1569
- else if (event.type === 'tuplet') {
1590
+ else if (event.type === 'tuplet' || event.type === 'times') {
1570
1591
  const tuplet = event;
1571
1592
  for (const e of tuplet.events) {
1572
1593
  if (e.type === 'note') {
@@ -1729,7 +1750,7 @@ const applyAutoBeamToVoice = (events, beamGroups) => {
1729
1750
  flushRun();
1730
1751
  position += dur;
1731
1752
  }
1732
- else if (event.type === 'tuplet') {
1753
+ else if (event.type === 'tuplet' || event.type === 'times') {
1733
1754
  const tuplet = event;
1734
1755
  const ratio = tuplet.ratio; // LilyPond ratio: num/den
1735
1756
  // Check if all inner notes are beamable (division >= 8)
@@ -1907,7 +1928,7 @@ const encode = (doc, options = {}) => {
1907
1928
  for (const event of voice.events) {
1908
1929
  // Check for actual musical content (not just context changes or pitch resets)
1909
1930
  if (event.type === 'note' || event.type === 'rest' ||
1910
- event.type === 'tuplet' || event.type === 'tremolo') {
1931
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
1911
1932
  return true;
1912
1933
  }
1913
1934
  }
@@ -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, };