@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.
@@ -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
 
@@ -650,8 +702,9 @@ const encodeVoice = (
650
702
  result += encodeContextChange(event) + ' ';
651
703
  break;
652
704
  }
653
- case 'tuplet': {
654
- const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
705
+ case 'tuplet':
706
+ case 'times': {
707
+ const { str, newEnv, newDuration } = encodeTupletEvent(event as TupletEvent | TimesEvent, env, lastDuration);
655
708
  result += str + ' ';
656
709
  env = newEnv;
657
710
  lastDuration = newDuration;
@@ -740,7 +793,7 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
740
793
  for (const part of measure.parts) {
741
794
  for (const voice of part.voices) {
742
795
  for (const event of voice.events) {
743
- if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
796
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
744
797
  return true;
745
798
  }
746
799
  }
@@ -769,6 +822,8 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
769
822
 
770
823
  // Track time signature for each measure (for spacer rests)
771
824
  const measureTimeSigs: Array<{ numerator: number; denominator: number } | undefined> = [];
825
+ // Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
826
+ const measurePartialDurs: Array<string | undefined> = [];
772
827
 
773
828
  let currentKey: KeySignature | undefined;
774
829
  let currentTimeSig: { numerator: number; denominator: number } | undefined;
@@ -783,6 +838,29 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
783
838
  // Store time signature for this measure
784
839
  measureTimeSigs[mi] = currentTimeSig;
785
840
 
841
+ // Detect partial (pickup) measures and compute their duration.
842
+ // Only the first measure (mi===0) can be an implicit partial;
843
+ // subsequent incomplete measures are NOT treated as partial.
844
+ const isExplicitPartial = measure.partial === true;
845
+ if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
846
+ let maxVoiceTicks = 0;
847
+ for (const part of measure.parts) {
848
+ for (const v of part.voices) {
849
+ maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
850
+ }
851
+ }
852
+ const expectedTicks = currentTimeSig
853
+ ? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
854
+ : TPQN * 4;
855
+ if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
856
+ measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
857
+ } else {
858
+ measurePartialDurs[mi] = undefined;
859
+ }
860
+ } else {
861
+ measurePartialDurs[mi] = undefined;
862
+ }
863
+
786
864
  // Process each part
787
865
  for (let pi = 0; pi < measure.parts.length; pi++) {
788
866
  const part = measure.parts[pi];
@@ -803,12 +881,21 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
803
881
  }
804
882
 
805
883
  // Encode voice content
806
- const voiceContent = encodeVoice(voice, {
884
+ let voiceContent = encodeVoice(voice, {
807
885
  key: currentKey,
808
886
  timeSig: currentTimeSig,
809
887
  isFirst: mi === 0
810
888
  }, vi);
811
889
 
890
+ // For non-partial measures, pad incomplete voices to fill the full
891
+ // measure duration. lotus doesn't auto-advance on barlines, so
892
+ // under-full voices cause measure boundary miscounting.
893
+ if (!measurePartialDurs[mi] && currentTimeSig) {
894
+ const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
895
+ const voiceTicks = voiceDurationTicks(voice);
896
+ voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
897
+ }
898
+
812
899
  staffMeasures[mi].push(voiceContent);
813
900
  }
814
901
  }
@@ -823,10 +910,28 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
823
910
  // Build voice lines
824
911
  const voiceLines: string[] = [];
825
912
  for (let vi = 0; vi < maxVoices; vi++) {
913
+ let prevTimeSigStr: string | undefined;
826
914
  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;
915
+ // For partial (pickup) measures, use a partial-duration spacer
916
+ const partialDur = measurePartialDurs[mi];
917
+ const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
918
+ let content = m[vi] || spacer;
919
+ // Inject \time if the content lacks it and the time sig changed.
920
+ // lotus processes each voice independently — without \time it
921
+ // defaults to 4/4, miscounting measure boundaries for other meters.
922
+ const ts = measureTimeSigs[mi];
923
+ if (ts) {
924
+ const tsStr = `${ts.numerator}/${ts.denominator}`;
925
+ if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
926
+ content = `${encodeTimeSig(ts)} ${content}`;
927
+ }
928
+ prevTimeSigStr = tsStr;
929
+ }
930
+ // For partial measures, prepend \partial DUR before all other commands
931
+ // so the lotus interpreter correctly tracks the pickup measure boundary.
932
+ if (partialDur && !content.includes('\\partial')) {
933
+ content = `\\partial ${partialDur} ${content}`;
934
+ }
830
935
  // Wrap each measure in its own \relative c' to reset pitch context
831
936
  return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
832
937
  });
@@ -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,
@@ -943,7 +944,7 @@ interface LayerResult {
943
944
 
944
945
 
945
946
  // 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 } => {
947
+ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
947
948
  if (event.type === 'note') {
948
949
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
949
950
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
@@ -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':
@@ -607,10 +626,41 @@ const serializeVoice = (
607
626
  // Each voice starts fresh from middle C (step=0, octave=0)
608
627
  let pitchEnv: PitchEnv = { step: 0, octave: 0 };
609
628
 
629
+ // Scan leading context-staff events (before the first musical event or clef/ottava)
630
+ // to compute the effective initial staff. Multiple consecutive staff switches
631
+ // before any music collapse to the last one (earlier ones are no-ops).
632
+ // leadStaffScanEnd is the index of the first event that ends this scan —
633
+ // context{staff} events before this index are skipped in the main loop.
634
+ const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
635
+ let effectiveInitialStaff = voice.staff;
636
+ let leadStaffScanEnd = 0;
637
+ for (let i = 0; i < voice.events.length; i++) {
638
+ const e = voice.events[i];
639
+ if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
640
+ if (e.type === 'context') {
641
+ const ctx = e as ContextChange;
642
+ if (ctx.staff != null) {
643
+ effectiveInitialStaff = ctx.staff;
644
+ if (!ctx.clef && !ctx.ottava) {
645
+ // Pure staff-only event — absorb
646
+ leadStaffScanEnd = i + 1;
647
+ continue;
648
+ }
649
+ // Compound (staff + clef/ottava) — update effectiveInitialStaff but stop scan
650
+ break;
651
+ }
652
+ if (ctx.clef || ctx.ottava) break; // musical context — stop scan
653
+ leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
654
+ continue;
655
+ }
656
+ if (MUSICAL_TYPES.has(e.type)) break;
657
+ }
658
+
610
659
  // Output staff command if voice staff differs from current parser staff,
611
- // or always output if it's a grand staff score for clarity
612
- if (isGrandStaff || voice.staff !== currentStaff) {
613
- parts.push('\\staff "' + voice.staff + '"');
660
+ // or always output if it's a grand staff score for clarity.
661
+ // Use effectiveInitialStaff so carry-over replaces the default emission.
662
+ if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
663
+ parts.push('\\staff "' + effectiveInitialStaff + '"');
614
664
  }
615
665
 
616
666
  // Output key/time signatures after \staff (for first voice only)
@@ -644,10 +694,15 @@ const serializeVoice = (
644
694
  // Skip redundant clef context events if this staff's clef is already established
645
695
  const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
646
696
 
647
- let activeStaff = voice.staff;
697
+ let activeStaff = effectiveInitialStaff;
648
698
  let activeStemDir: StemDirection | undefined;
649
699
 
650
- for (const event of voice.events) {
700
+ for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
701
+ const event = voice.events[eventIdx];
702
+ // Skip leading context-staff events already absorbed into effectiveInitialStaff
703
+ if (eventIdx < leadStaffScanEnd && event.type === 'context' && (event as ContextChange).staff != null) {
704
+ continue;
705
+ }
651
706
  if (event.type === 'context') {
652
707
  const ctx = event as ContextChange;
653
708
  // Cross-staff context: update activeStaff and emit \staff directive
@@ -662,7 +717,8 @@ const serializeVoice = (
662
717
  }
663
718
  continue;
664
719
  }
665
- if (ctx.staff) continue; // same staff, no-op for staff field; clef handled below
720
+ if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
721
+ if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
666
722
  // Skip clef-only context events if clef already established for this staff
667
723
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
668
724
  continue;
@@ -712,6 +768,12 @@ const serializeVoice = (
712
768
  prevDuration = (event as NoteEvent).duration;
713
769
  } else if (event.type === 'rest') {
714
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;
715
777
  } else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
716
778
  const ctx = event as ContextChange;
717
779
  emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
@@ -799,7 +861,7 @@ const serializeMeasure = (
799
861
  }
800
862
  staff = newStaff;
801
863
  }
802
- parts.push(partStrs.join(' \\\\\\\\\n'));
864
+ parts.push(partStrs.join(' \\\\\\\n'));
803
865
  }
804
866
 
805
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