@k-l-lambda/lilylet 0.1.60 → 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
  }
@@ -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) {
@@ -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, };
@@ -301,6 +301,10 @@ const serializeContextChange = (event) => {
301
301
  if (event.time) {
302
302
  parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
303
303
  }
304
+ // Partial (pickup measure duration check)
305
+ if (event.partial) {
306
+ parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
307
+ }
304
308
  // Ottava
305
309
  if (event.ottava !== undefined) {
306
310
  if (event.ottava === 0) {
@@ -343,8 +347,11 @@ const serializeTempo = (tempo) => {
343
347
  const serializeTupletEvent = (event, env) => {
344
348
  const parts = [];
345
349
  let currentEnv = env;
346
- // \times numerator/denominator { ... }
347
- parts.push('\\times ' + event.ratio.numerator + '/' + event.ratio.denominator + ' {');
350
+ // \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
351
+ const keyword = event.type === 'times'
352
+ ? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
353
+ : '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
354
+ parts.push(keyword + ' {');
348
355
  let prevDuration;
349
356
  for (const e of event.events) {
350
357
  if (e.type === 'note') {
@@ -359,6 +366,20 @@ const serializeTupletEvent = (event, env) => {
359
366
  currentEnv = newEnv;
360
367
  prevDuration = e.duration;
361
368
  }
369
+ else if (e.type === 'context') {
370
+ const ctx = e;
371
+ if (ctx.staff != null) {
372
+ parts.push(' \\staff "' + ctx.staff + '"');
373
+ }
374
+ else if (ctx.stemDirection != null) {
375
+ if (ctx.stemDirection === StemDirection.up)
376
+ parts.push(' \\stemUp');
377
+ else if (ctx.stemDirection === StemDirection.down)
378
+ parts.push(' \\stemDown');
379
+ else if (ctx.stemDirection === StemDirection.auto)
380
+ parts.push(' \\stemNeutral');
381
+ }
382
+ }
362
383
  }
363
384
  parts.push(' }');
364
385
  return { str: parts.join(''), newEnv: currentEnv };
@@ -428,6 +449,7 @@ const serializeEvent = (event, env, prevDuration) => {
428
449
  case 'context':
429
450
  return { str: serializeContextChange(event), newEnv: env };
430
451
  case 'tuplet':
452
+ case 'times':
431
453
  return serializeTupletEvent(event, env);
432
454
  case 'tremolo':
433
455
  return serializeTremoloEvent(event, env);
@@ -468,7 +490,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
468
490
  // before any music collapse to the last one (earlier ones are no-ops).
469
491
  // leadStaffScanEnd is the index of the first event that ends this scan —
470
492
  // context{staff} events before this index are skipped in the main loop.
471
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
493
+ const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
472
494
  let effectiveInitialStaff = voice.staff;
473
495
  let leadStaffScanEnd = 0;
474
496
  for (let i = 0; i < voice.events.length; i++) {
@@ -607,6 +629,13 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
607
629
  else if (event.type === 'rest') {
608
630
  prevDuration = event.duration;
609
631
  }
632
+ else if (event.type === 'tuplet' || event.type === 'times') {
633
+ // After a tuplet/times block the LilyPond parser's "current duration" is the
634
+ // last note duration inside the tuplet, not the duration before the tuplet.
635
+ // Reset prevDuration so the first note after the block always emits its
636
+ // duration explicitly, avoiding wrong inheritance from inside the tuplet.
637
+ prevDuration = undefined;
638
+ }
610
639
  else if (event.type === 'context' && event.clef && emittedClefs) {
611
640
  const ctx = event;
612
641
  emittedClefs[ctx.staff || activeStaff] = ctx.clef;
@@ -667,7 +696,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
667
696
  }
668
697
  staff = newStaff;
669
698
  }
670
- parts.push(partStrs.join(' \\\\\\\\\n'));
699
+ parts.push(partStrs.join(' \\\\\\\n'));
671
700
  }
672
701
  return { str: parts.join(' '), newStaff: staff };
673
702
  };
@@ -181,6 +181,7 @@ export interface ContextChange {
181
181
  type: 'context';
182
182
  key?: KeySignature;
183
183
  time?: Fraction;
184
+ partial?: Duration;
184
185
  clef?: Clef;
185
186
  ottava?: number;
186
187
  stemDirection?: StemDirection;
@@ -197,7 +198,12 @@ export interface TremoloEvent {
197
198
  export interface TupletEvent {
198
199
  type: 'tuplet';
199
200
  ratio: Fraction;
200
- events: (NoteEvent | RestEvent)[];
201
+ events: (NoteEvent | RestEvent | ContextChange)[];
202
+ }
203
+ export interface TimesEvent {
204
+ type: 'times';
205
+ ratio: Fraction;
206
+ events: (NoteEvent | RestEvent | ContextChange)[];
201
207
  }
202
208
  export interface PitchResetEvent {
203
209
  type: 'pitchReset';
@@ -215,7 +221,7 @@ export interface MarkupEvent {
215
221
  content: string;
216
222
  placement?: Placement;
217
223
  }
218
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
224
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
219
225
  export interface Voice {
220
226
  staff: number;
221
227
  events: Event[];