@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.
@@ -15,6 +15,7 @@ import {
15
15
  RestEvent,
16
16
  ContextChange,
17
17
  TupletEvent,
18
+ TimesEvent,
18
19
  TremoloEvent,
19
20
  BarlineEvent,
20
21
  HarmonyEvent,
@@ -164,6 +165,53 @@ const getSpacerRest = (timeSig?: { numerator: number; denominator: number }): st
164
165
  };
165
166
 
166
167
 
168
+ // === Partial Measure Helpers ===
169
+
170
+ const TPQN = 480;
171
+
172
+ const voiceDurationTicks = (voice: Voice): number => {
173
+ let ticks = 0;
174
+ const addEvent = (e: Event, factor = 1): void => {
175
+ if (e.type === 'note' || e.type === 'rest') {
176
+ const ev = e as NoteEvent | RestEvent;
177
+ if ((ev as NoteEvent).grace) return;
178
+ let t = (TPQN * 4) / ev.duration.division;
179
+ let dot = t / 2;
180
+ for (let i = 0; i < ev.duration.dots; i++) { t += dot; dot /= 2; }
181
+ ticks += Math.round(t * factor);
182
+ } else if (e.type === 'tuplet' || e.type === 'times') {
183
+ const te = e as TupletEvent;
184
+ const f = factor * te.ratio.numerator / te.ratio.denominator;
185
+ for (const inner of te.events) addEvent(inner as Event, f);
186
+ }
187
+ };
188
+ for (const e of voice.events) addEvent(e);
189
+ return ticks;
190
+ };
191
+
192
+ const ticksToLyDuration = (ticks: number): string => {
193
+ const whole = TPQN * 4;
194
+ for (const div of [1, 2, 4, 8, 16, 32, 64]) {
195
+ const t = whole / div;
196
+ if (Math.abs(ticks - t) < 1) return String(div);
197
+ if (Math.abs(ticks - Math.round(t * 1.5)) < 1) return `${div}.`;
198
+ if (Math.abs(ticks - Math.round(t * 1.75)) < 1) return `${div}..`;
199
+ }
200
+ return '4';
201
+ };
202
+
203
+ // Return a spacer-rest suffix to pad a voice to the full measure duration.
204
+ // Uses s16*N to avoid complexity with dotted/compound values.
205
+ const padVoiceToMeasure = (voiceTicks: number, measureTicks: number): string => {
206
+ const remaining = Math.round(measureTicks - voiceTicks);
207
+ if (remaining <= 0) return '';
208
+ const sixteenth = Math.round(TPQN / 4); // 120 ticks
209
+ const numSixteenths = Math.round(remaining / sixteenth);
210
+ if (numSixteenths <= 0) return '';
211
+ return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
212
+ };
213
+
214
+
167
215
  // === Pitch Environment for Relative Mode ===
168
216
 
169
217
  interface PitchEnv {
@@ -509,8 +557,12 @@ const encodeContextChange = (event: ContextChange): string => {
509
557
  /**
510
558
  * Encode a tuplet event
511
559
  */
512
- const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
513
- let result = `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator} { `;
560
+ const encodeTupletEvent = (event: TupletEvent | TimesEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
561
+ // \times preserves type:"times"; \tuplet is denominator/numerator
562
+ const header = event.type === 'times'
563
+ ? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
564
+ : `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
565
+ let result = `${header} { `;
514
566
  let newEnv = env;
515
567
  let newDuration = lastDuration;
516
568
 
@@ -525,6 +577,8 @@ const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Dura
525
577
  result += str + ' ';
526
578
  newEnv = ne;
527
579
  newDuration = nd;
580
+ } else if (subEvent.type === 'context') {
581
+ result += encodeContextChange(subEvent) + ' ';
528
582
  }
529
583
  }
530
584
 
@@ -650,8 +704,9 @@ const encodeVoice = (
650
704
  result += encodeContextChange(event) + ' ';
651
705
  break;
652
706
  }
653
- case 'tuplet': {
654
- const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
707
+ case 'tuplet':
708
+ case 'times': {
709
+ const { str, newEnv, newDuration } = encodeTupletEvent(event as TupletEvent | TimesEvent, env, lastDuration);
655
710
  result += str + ' ';
656
711
  env = newEnv;
657
712
  lastDuration = newDuration;
@@ -740,7 +795,7 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
740
795
  for (const part of measure.parts) {
741
796
  for (const voice of part.voices) {
742
797
  for (const event of voice.events) {
743
- if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
798
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
744
799
  return true;
745
800
  }
746
801
  }
@@ -769,6 +824,8 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
769
824
 
770
825
  // Track time signature for each measure (for spacer rests)
771
826
  const measureTimeSigs: Array<{ numerator: number; denominator: number } | undefined> = [];
827
+ // Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
828
+ const measurePartialDurs: Array<string | undefined> = [];
772
829
 
773
830
  let currentKey: KeySignature | undefined;
774
831
  let currentTimeSig: { numerator: number; denominator: number } | undefined;
@@ -783,6 +840,29 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
783
840
  // Store time signature for this measure
784
841
  measureTimeSigs[mi] = currentTimeSig;
785
842
 
843
+ // Detect partial (pickup) measures and compute their duration.
844
+ // Only the first measure (mi===0) can be an implicit partial;
845
+ // subsequent incomplete measures are NOT treated as partial.
846
+ const isExplicitPartial = measure.partial === true;
847
+ if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
848
+ let maxVoiceTicks = 0;
849
+ for (const part of measure.parts) {
850
+ for (const v of part.voices) {
851
+ maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
852
+ }
853
+ }
854
+ const expectedTicks = currentTimeSig
855
+ ? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
856
+ : TPQN * 4;
857
+ if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
858
+ measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
859
+ } else {
860
+ measurePartialDurs[mi] = undefined;
861
+ }
862
+ } else {
863
+ measurePartialDurs[mi] = undefined;
864
+ }
865
+
786
866
  // Process each part
787
867
  for (let pi = 0; pi < measure.parts.length; pi++) {
788
868
  const part = measure.parts[pi];
@@ -803,12 +883,21 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
803
883
  }
804
884
 
805
885
  // Encode voice content
806
- const voiceContent = encodeVoice(voice, {
886
+ let voiceContent = encodeVoice(voice, {
807
887
  key: currentKey,
808
888
  timeSig: currentTimeSig,
809
889
  isFirst: mi === 0
810
890
  }, vi);
811
891
 
892
+ // For non-partial measures, pad incomplete voices to fill the full
893
+ // measure duration. lotus doesn't auto-advance on barlines, so
894
+ // under-full voices cause measure boundary miscounting.
895
+ if (!measurePartialDurs[mi] && currentTimeSig) {
896
+ const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
897
+ const voiceTicks = voiceDurationTicks(voice);
898
+ voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
899
+ }
900
+
812
901
  staffMeasures[mi].push(voiceContent);
813
902
  }
814
903
  }
@@ -823,10 +912,28 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
823
912
  // Build voice lines
824
913
  const voiceLines: string[] = [];
825
914
  for (let vi = 0; vi < maxVoices; vi++) {
915
+ let prevTimeSigStr: string | undefined;
826
916
  const measureContents = measures.map((m, mi) => {
827
- // Use correct spacer rest based on time signature
828
- const spacer = getSpacerRest(measureTimeSigs[mi]);
829
- const content = m[vi] || spacer;
917
+ // For partial (pickup) measures, use a partial-duration spacer
918
+ const partialDur = measurePartialDurs[mi];
919
+ const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
920
+ let content = m[vi] || spacer;
921
+ // Inject \time if the content lacks it and the time sig changed.
922
+ // lotus processes each voice independently — without \time it
923
+ // defaults to 4/4, miscounting measure boundaries for other meters.
924
+ const ts = measureTimeSigs[mi];
925
+ if (ts) {
926
+ const tsStr = `${ts.numerator}/${ts.denominator}`;
927
+ if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
928
+ content = `${encodeTimeSig(ts)} ${content}`;
929
+ }
930
+ prevTimeSigStr = tsStr;
931
+ }
932
+ // For partial measures, prepend \partial DUR before all other commands
933
+ // so the lotus interpreter correctly tracks the pickup measure boundary.
934
+ if (partialDur && !content.includes('\\partial')) {
935
+ content = `\\partial ${partialDur} ${content}`;
936
+ }
830
937
  // Wrap each measure in its own \relative c' to reset pitch context
831
938
  return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
832
939
  });
@@ -7,6 +7,7 @@ import {
7
7
  RestEvent,
8
8
  ContextChange,
9
9
  TupletEvent,
10
+ TimesEvent,
10
11
  TremoloEvent,
11
12
  BarlineEvent,
12
13
  HarmonyEvent,
@@ -654,6 +655,7 @@ interface TupletEventResult {
654
655
  mordents: MordentRef[];
655
656
  turns: TurnRef[];
656
657
  arpeggios: ArpegRef[];
658
+ endingClef?: string; // Updated clef name if changed inside the tuplet
657
659
  }
658
660
 
659
661
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
@@ -672,7 +674,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
672
674
  };
673
675
 
674
676
  // Convert TupletEvent to MEI
675
- const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>): TupletEventResult => {
677
+ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>, currentClef?: string): TupletEventResult => {
676
678
  // LilyPond \times 2/3 means "multiply duration by 2/3"
677
679
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
678
680
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -700,6 +702,8 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
700
702
  // Handle internal beam groups: if notes have manual beam marks, respect them
701
703
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
702
704
  let beamOpen = false;
705
+ let activeClef = currentClef;
706
+ let endingClef: string | undefined;
703
707
 
704
708
  for (const e of event.events) {
705
709
  if (e.type === 'note') {
@@ -743,6 +747,19 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
743
747
  } else if (e.type === 'rest') {
744
748
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
745
749
  xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
750
+ } else if (e.type === 'context') {
751
+ const ctx = e as ContextChange;
752
+ if (ctx.clef && ctx.clef !== activeClef) {
753
+ const layerStaffNum = layerStaff || 1;
754
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
755
+ if (effectiveStaffNum === layerStaffNum) {
756
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
757
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
758
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
759
+ }
760
+ activeClef = ctx.clef;
761
+ endingClef = ctx.clef;
762
+ }
746
763
  }
747
764
  }
748
765
 
@@ -752,7 +769,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
752
769
  }
753
770
 
754
771
  xml += `${indent}</tuplet>\n`;
755
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
772
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
756
773
  };
757
774
 
758
775
 
@@ -943,12 +960,12 @@ interface LayerResult {
943
960
 
944
961
 
945
962
  // Helper: check if an event (or any note inside a tuplet) has beam start/end
946
- const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
963
+ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
947
964
  if (event.type === 'note') {
948
965
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
949
966
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
950
967
  }
951
- if (event.type === 'tuplet') {
968
+ if (event.type === 'tuplet' || event.type === 'times') {
952
969
  const tuplet = event as TupletEvent;
953
970
  // If the tuplet has internal beam groups, don't report beam marks to the parent
954
971
  // so the parent won't wrap the tuplet in an external <beam>
@@ -1209,12 +1226,18 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1209
1226
  xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1210
1227
  break;
1211
1228
  }
1212
- case 'tuplet': {
1229
+ case 'tuplet':
1230
+ case 'times': {
1213
1231
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1214
1232
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1215
- const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
1233
+ const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1216
1234
  xml += tupletResult.xml;
1217
1235
 
1236
+ // Propagate clef change from inside the tuplet to the parent tracker
1237
+ if (tupletResult.endingClef) {
1238
+ currentClef = tupletResult.endingClef as Clef;
1239
+ }
1240
+
1218
1241
  // Flush any pending markups onto the first note of the tuplet
1219
1242
  if (tupletResult.firstNoteId) {
1220
1243
  flushPendingMarkups(tupletResult.firstNoteId);
@@ -1943,7 +1966,7 @@ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
1943
1966
  if (m.markType === 'beam') return true;
1944
1967
  }
1945
1968
  }
1946
- } else if (event.type === 'tuplet') {
1969
+ } else if (event.type === 'tuplet' || event.type === 'times') {
1947
1970
  const tuplet = event as TupletEvent;
1948
1971
  for (const e of tuplet.events) {
1949
1972
  if (e.type === 'note') {
@@ -2118,7 +2141,7 @@ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
2118
2141
  // Rests break beam groups
2119
2142
  flushRun();
2120
2143
  position += dur;
2121
- } else if (event.type === 'tuplet') {
2144
+ } else if (event.type === 'tuplet' || event.type === 'times') {
2122
2145
  const tuplet = event as TupletEvent;
2123
2146
  const ratio = tuplet.ratio; // LilyPond ratio: num/den
2124
2147
 
@@ -2320,7 +2343,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2320
2343
  for (const event of voice.events) {
2321
2344
  // Check for actual musical content (not just context changes or pitch resets)
2322
2345
  if (event.type === 'note' || event.type === 'rest' ||
2323
- event.type === 'tuplet' || event.type === 'tremolo') {
2346
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2324
2347
  return true;
2325
2348
  }
2326
2349
  }
@@ -1201,9 +1201,9 @@ const convertMeasure = (
1201
1201
  // Calculate total duration of tuplet for voiceTracker
1202
1202
  let totalDuration = 0;
1203
1203
  for (const evt of tupletEvent.events) {
1204
- if (evt.duration) {
1204
+ if ((evt as NoteEvent | RestEvent).duration) {
1205
1205
  // Convert division to duration units (quarter = 1)
1206
- totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
1206
+ totalDuration += (4 / (evt as NoteEvent | RestEvent).duration.division) * voiceTracker.getDivisions();
1207
1207
  }
1208
1208
  }
1209
1209
  // Apply tuplet ratio to get actual duration
@@ -754,7 +754,7 @@ const encodeMeasure = (
754
754
  }
755
755
 
756
756
  case 'tuplet': {
757
- const tupletEvents = event.events;
757
+ const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest') as (NoteEvent | RestEvent)[];
758
758
  for (let ti = 0; ti < tupletEvents.length; ti++) {
759
759
  const subEvent = tupletEvents[ti];
760
760
  // Set tuplet ratio on duration so encodeDuration emits <time-modification>
@@ -157,6 +157,14 @@ const resolveDocumentPitches = (doc: LilyletDoc): void => {
157
157
  };
158
158
 
159
159
 
160
+ export interface ParseWarning {
161
+ type: 'partial-mismatch';
162
+ message: string;
163
+ declared: number; // ticks
164
+ actual: number; // ticks
165
+ }
166
+
167
+
160
168
  const parseCode = (code: string): LilyletDoc => {
161
169
  // Reset parser state before each parse to avoid contamination
162
170
  if (parser && (parser as any).resetState) {
@@ -172,7 +180,19 @@ const parseCode = (code: string): LilyletDoc => {
172
180
  };
173
181
 
174
182
 
183
+ /**
184
+ * Return warnings emitted during the most recent parseCode() call.
185
+ * Currently reports \partial duration mismatches.
186
+ */
187
+ const getParseWarnings = (): ParseWarning[] => {
188
+ if (parser && (parser as any).getWarnings) {
189
+ return (parser as any).getWarnings() as ParseWarning[];
190
+ }
191
+ return [];
192
+ };
193
+
175
194
 
176
195
  export {
177
196
  parseCode,
197
+ getParseWarnings,
178
198
  };
@@ -15,6 +15,7 @@ import {
15
15
  RestEvent,
16
16
  ContextChange,
17
17
  TupletEvent,
18
+ TimesEvent,
18
19
  TremoloEvent,
19
20
  BarlineEvent,
20
21
  Pitch,
@@ -400,6 +401,11 @@ const serializeContextChange = (event: ContextChange): string => {
400
401
  parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
401
402
  }
402
403
 
404
+ // Partial (pickup measure duration check)
405
+ if (event.partial) {
406
+ parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
407
+ }
408
+
403
409
  // Ottava
404
410
  if (event.ottava !== undefined) {
405
411
  if (event.ottava === 0) {
@@ -447,14 +453,17 @@ const serializeTempo = (tempo: Tempo): string => {
447
453
 
448
454
  // Serialize a tuplet event with pitch environment tracking
449
455
  const serializeTupletEvent = (
450
- event: TupletEvent,
456
+ event: TupletEvent | TimesEvent,
451
457
  env: PitchEnv
452
458
  ): { str: string; newEnv: PitchEnv } => {
453
459
  const parts: string[] = [];
454
460
  let currentEnv = env;
455
461
 
456
- // \times numerator/denominator { ... }
457
- parts.push('\\times ' + event.ratio.numerator + '/' + event.ratio.denominator + ' {');
462
+ // \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
463
+ const keyword = event.type === 'times'
464
+ ? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
465
+ : '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
466
+ parts.push(keyword + ' {');
458
467
 
459
468
  let prevDuration: Duration | undefined;
460
469
  for (const e of event.events) {
@@ -468,6 +477,15 @@ const serializeTupletEvent = (
468
477
  parts.push(' ' + str);
469
478
  currentEnv = newEnv;
470
479
  prevDuration = (e as RestEvent).duration;
480
+ } else if (e.type === 'context') {
481
+ const ctx = e as ContextChange;
482
+ if (ctx.staff != null) {
483
+ parts.push(' \\staff "' + ctx.staff + '"');
484
+ } else if (ctx.stemDirection != null) {
485
+ if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
486
+ else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
487
+ else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
488
+ }
471
489
  }
472
490
  }
473
491
 
@@ -554,7 +572,8 @@ const serializeEvent = (
554
572
  case 'context':
555
573
  return { str: serializeContextChange(event as ContextChange), newEnv: env };
556
574
  case 'tuplet':
557
- return serializeTupletEvent(event as TupletEvent, env);
575
+ case 'times':
576
+ return serializeTupletEvent(event as TupletEvent | TimesEvent, env);
558
577
  case 'tremolo':
559
578
  return serializeTremoloEvent(event as TremoloEvent, env);
560
579
  case 'barline':
@@ -612,7 +631,7 @@ const serializeVoice = (
612
631
  // before any music collapse to the last one (earlier ones are no-ops).
613
632
  // leadStaffScanEnd is the index of the first event that ends this scan —
614
633
  // context{staff} events before this index are skipped in the main loop.
615
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
634
+ const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
616
635
  let effectiveInitialStaff = voice.staff;
617
636
  let leadStaffScanEnd = 0;
618
637
  for (let i = 0; i < voice.events.length; i++) {
@@ -749,6 +768,12 @@ const serializeVoice = (
749
768
  prevDuration = (event as NoteEvent).duration;
750
769
  } else if (event.type === 'rest') {
751
770
  prevDuration = (event as RestEvent).duration;
771
+ } else if (event.type === 'tuplet' || event.type === 'times') {
772
+ // After a tuplet/times block the LilyPond parser's "current duration" is the
773
+ // last note duration inside the tuplet, not the duration before the tuplet.
774
+ // Reset prevDuration so the first note after the block always emits its
775
+ // duration explicitly, avoiding wrong inheritance from inside the tuplet.
776
+ prevDuration = undefined;
752
777
  } else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
753
778
  const ctx = event as ContextChange;
754
779
  emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
@@ -836,7 +861,7 @@ const serializeMeasure = (
836
861
  }
837
862
  staff = newStaff;
838
863
  }
839
- parts.push(partStrs.join(' \\\\\\\\\n'));
864
+ parts.push(partStrs.join(' \\\\\\\n'));
840
865
  }
841
866
 
842
867
  return { str: parts.join(' '), newStaff: staff };
@@ -229,6 +229,7 @@ export interface ContextChange {
229
229
  type: 'context';
230
230
  key?: KeySignature;
231
231
  time?: Fraction;
232
+ partial?: Duration; // Pickup measure duration (check-only, warns if mismatch)
232
233
  clef?: Clef;
233
234
  ottava?: number; // -1, 0, 1
234
235
  stemDirection?: StemDirection;
@@ -247,7 +248,14 @@ export interface TremoloEvent {
247
248
  export interface TupletEvent {
248
249
  type: 'tuplet';
249
250
  ratio: Fraction; // e.g., {numerator: 2, denominator: 3} for triplet
250
- events: (NoteEvent | RestEvent)[];
251
+ events: (NoteEvent | RestEvent | ContextChange)[];
252
+ }
253
+
254
+ // TimesEvent: from lilylet \times syntax (distinct from \tuplet decoded from LilyPond)
255
+ export interface TimesEvent {
256
+ type: 'times';
257
+ ratio: Fraction;
258
+ events: (NoteEvent | RestEvent | ContextChange)[];
251
259
  }
252
260
 
253
261
  export interface PitchResetEvent {
@@ -270,7 +278,7 @@ export interface MarkupEvent {
270
278
  placement?: Placement; // Optional placement (above/below)
271
279
  }
272
280
 
273
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
281
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
274
282
 
275
283
  // === Structure ===
276
284