@k-l-lambda/lilylet 0.1.56 → 0.1.58

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.
@@ -585,12 +585,9 @@ const parseLilyDocument = (lilyDocument) => {
585
585
  const restEvent = {
586
586
  type: 'rest',
587
587
  duration: convertDuration(term.durationValue),
588
+ fullMeasure: (term.name === 'R') || undefined,
588
589
  invisible: term.isSpacer || undefined,
589
590
  };
590
- // Positioned rest
591
- if (!term.isSpacer && context.pitch) {
592
- restEvent.pitch = convertPitch(context.pitch.phonetStep, 0, context.pitch.octave);
593
- }
594
591
  voice.events.push(restEvent);
595
592
  }
596
593
  }
@@ -982,7 +979,7 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
982
979
  const measures = parsedMeasures.map(pm => {
983
980
  // Filter out voices that only contain spacer rests and context changes
984
981
  const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
985
- // Group voices by partIndex, then merge voices on the same staff
982
+ // Group voices by partIndex, then collect voice arrays per staff
986
983
  const partMap = new Map();
987
984
  for (const v of filteredVoices) {
988
985
  const pi = v.partIndex || 1;
@@ -990,23 +987,23 @@ const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
990
987
  partMap.set(pi, new Map());
991
988
  }
992
989
  const staffMap = partMap.get(pi);
993
- // Merge events from voices on the same staff
990
+ // Preserve each voice as a separate array
994
991
  if (!staffMap.has(v.staff)) {
995
992
  staffMap.set(v.staff, []);
996
993
  }
997
- staffMap.get(v.staff).push(...v.events);
994
+ staffMap.get(v.staff).push(v.events);
998
995
  }
999
996
  // Convert to parts array (sorted by part index, then by staff)
1000
- // Apply deduplication to merged events
997
+ // Apply deduplication to each voice's events
1001
998
  const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
1002
999
  const parts = partIndices.map(pi => {
1003
1000
  const staffMap = partMap.get(pi);
1004
1001
  const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
1005
1002
  return {
1006
- voices: staffNums.map(staff => ({
1003
+ voices: staffNums.flatMap(staff => staffMap.get(staff).map(events => ({
1007
1004
  staff,
1008
- events: dedupeContextEvents(staffMap.get(staff)),
1009
- })),
1005
+ events: dedupeContextEvents(events),
1006
+ }))),
1010
1007
  };
1011
1008
  });
1012
1009
  // Fallback to single empty part if no voices
@@ -294,11 +294,19 @@ const encodeNoteEvent = (event, env, lastDuration) => {
294
294
  if (event.pitches.length > 1) {
295
295
  result += '<';
296
296
  const pitchStrs = [];
297
- for (const pitch of event.pitches) {
298
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
297
+ let firstPitchEnv;
298
+ for (let i = 0; i < event.pitches.length; i++) {
299
+ // In LilyPond relative mode, each pitch in a chord is relative
300
+ // to the previous pitch in the chord (cascading).
301
+ // After the chord, env becomes the first pitch.
302
+ const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
299
303
  pitchStrs.push(str);
300
304
  newEnv = ne;
305
+ if (i === 0)
306
+ firstPitchEnv = ne;
301
307
  }
308
+ // After chord, reference resets to first pitch
309
+ newEnv = firstPitchEnv;
302
310
  result += pitchStrs.join(' ');
303
311
  result += '>';
304
312
  }
@@ -347,12 +355,14 @@ const encodeRestEvent = (event, env, lastDuration) => {
347
355
  result += encodeDuration(event.duration);
348
356
  }
349
357
  // Positioned rest
358
+ let newEnv = env;
350
359
  if (event.pitch && !event.fullMeasure && !event.invisible) {
351
- const { str } = encodePitch(event.pitch, env);
360
+ const { str, newEnv: ne } = encodePitch(event.pitch, env);
352
361
  result = str + result.slice(1); // Replace 'r' with pitch
353
362
  result += '\\rest';
363
+ newEnv = ne;
354
364
  }
355
- return { str: result, newEnv: env, newDuration: event.duration };
365
+ return { str: result, newEnv, newDuration: event.duration };
356
366
  };
357
367
  /**
358
368
  * Encode a context change event
@@ -397,8 +407,9 @@ const encodeTupletEvent = (event, env, lastDuration) => {
397
407
  newDuration = nd;
398
408
  }
399
409
  else if (subEvent.type === 'rest') {
400
- const { str, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
410
+ const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
401
411
  result += str + ' ';
412
+ newEnv = ne;
402
413
  newDuration = nd;
403
414
  }
404
415
  }
@@ -415,11 +426,15 @@ const encodeTremoloEvent = (event, env) => {
415
426
  if (event.pitchA.length > 1) {
416
427
  pitchA += '<';
417
428
  const pitchStrs = [];
418
- for (const pitch of event.pitchA) {
419
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
429
+ let firstPitchEnv;
430
+ for (let i = 0; i < event.pitchA.length; i++) {
431
+ const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
420
432
  pitchStrs.push(str);
421
433
  newEnv = ne;
434
+ if (i === 0)
435
+ firstPitchEnv = ne;
422
436
  }
437
+ newEnv = firstPitchEnv;
423
438
  pitchA += pitchStrs.join(' ');
424
439
  pitchA += '>';
425
440
  }
@@ -433,11 +448,15 @@ const encodeTremoloEvent = (event, env) => {
433
448
  if (event.pitchB.length > 1) {
434
449
  pitchB += '<';
435
450
  const pitchStrs = [];
436
- for (const pitch of event.pitchB) {
437
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
451
+ let firstPitchEnv;
452
+ for (let i = 0; i < event.pitchB.length; i++) {
453
+ const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
438
454
  pitchStrs.push(str);
439
455
  newEnv = ne;
456
+ if (i === 0)
457
+ firstPitchEnv = ne;
440
458
  }
459
+ newEnv = firstPitchEnv;
441
460
  pitchB += pitchStrs.join(' ');
442
461
  pitchB += '>';
443
462
  }
@@ -490,8 +509,9 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
490
509
  break;
491
510
  }
492
511
  case 'rest': {
493
- const { str, newDuration } = encodeRestEvent(event, env, lastDuration);
512
+ const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
494
513
  result += str + ' ';
514
+ env = newEnv;
495
515
  lastDuration = newDuration;
496
516
  break;
497
517
  }
@@ -528,7 +548,8 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
528
548
  break;
529
549
  }
530
550
  case 'pitchReset': {
531
- env = { step: 0, octave: 0 };
551
+ // Ignore: each measure already gets its own \relative c' block,
552
+ // and within a measure the LilyPond reference pitch is not reset.
532
553
  break;
533
554
  }
534
555
  }
@@ -500,10 +500,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
500
500
  for (const event of voice.events) {
501
501
  if (event.type === 'context') {
502
502
  const ctx = event;
503
- // Skip context events that belong to a different staff (cross-staff clef/ottava)
504
- if (ctx.staff && ctx.staff !== voice.staff) {
503
+ // Cross-staff context: update activeStaff and emit \staff directive
504
+ if (ctx.staff && ctx.staff !== activeStaff) {
505
+ activeStaff = ctx.staff;
506
+ parts.push('\\staff "' + activeStaff + '"');
505
507
  continue;
506
508
  }
509
+ if (ctx.staff)
510
+ continue; // same staff, no-op
507
511
  // Skip clef-only context events if clef already established for this staff
508
512
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
509
513
  continue;
@@ -511,8 +515,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
511
515
  }
512
516
  if (event.type === 'note') {
513
517
  const noteEvt = event;
514
- // Cross-staff: emit \staff when note's effective staff differs from active
515
- const effectiveStaff = noteEvt.staff || voice.staff;
518
+ // Cross-staff via explicit note.staff (lilylet native cross-staff)
519
+ const effectiveStaff = noteEvt.staff || activeStaff;
516
520
  if (effectiveStaff !== activeStaff) {
517
521
  activeStaff = effectiveStaff;
518
522
  parts.push('\\staff "' + activeStaff + '"');
@@ -679,12 +683,16 @@ export const serializeLilyletDoc = (doc) => {
679
683
  // Collect clefs from this measure's voices
680
684
  for (const part of measure.parts) {
681
685
  for (const voice of part.voices) {
686
+ let clefActiveStaff = voice.staff;
682
687
  for (const event of voice.events) {
683
- if (event.type === 'context' && event.clef) {
688
+ if (event.type === 'context') {
684
689
  const ctx = event;
685
- // Use the event's staff if specified (cross-staff), otherwise the voice's staff
686
- const clefStaff = ctx.staff || voice.staff;
687
- staffClefs[clefStaff] = ctx.clef;
690
+ if (ctx.staff) {
691
+ clefActiveStaff = ctx.staff;
692
+ }
693
+ if (ctx.clef) {
694
+ staffClefs[clefActiveStaff] = ctx.clef;
695
+ }
688
696
  }
689
697
  }
690
698
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -708,18 +708,10 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
708
708
  const restEvent: RestEvent = {
709
709
  type: 'rest',
710
710
  duration: convertDuration(term.durationValue),
711
+ fullMeasure: (term.name === 'R') || undefined,
711
712
  invisible: term.isSpacer || undefined,
712
713
  };
713
714
 
714
- // Positioned rest
715
- if (!term.isSpacer && context.pitch) {
716
- restEvent.pitch = convertPitch(
717
- context.pitch.phonetStep,
718
- 0,
719
- context.pitch.octave
720
- );
721
- }
722
-
723
715
  voice.events.push(restEvent);
724
716
  }
725
717
  }
@@ -1145,8 +1137,8 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
1145
1137
  // Filter out voices that only contain spacer rests and context changes
1146
1138
  const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
1147
1139
 
1148
- // Group voices by partIndex, then merge voices on the same staff
1149
- const partMap = new Map<number, Map<number, Event[]>>();
1140
+ // Group voices by partIndex, then collect voice arrays per staff
1141
+ const partMap = new Map<number, Map<number, Event[][]>>();
1150
1142
  for (const v of filteredVoices) {
1151
1143
  const pi = v.partIndex || 1;
1152
1144
  if (!partMap.has(pi)) {
@@ -1154,24 +1146,26 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
1154
1146
  }
1155
1147
  const staffMap = partMap.get(pi)!;
1156
1148
 
1157
- // Merge events from voices on the same staff
1149
+ // Preserve each voice as a separate array
1158
1150
  if (!staffMap.has(v.staff)) {
1159
1151
  staffMap.set(v.staff, []);
1160
1152
  }
1161
- staffMap.get(v.staff)!.push(...v.events);
1153
+ staffMap.get(v.staff)!.push(v.events);
1162
1154
  }
1163
1155
 
1164
1156
  // Convert to parts array (sorted by part index, then by staff)
1165
- // Apply deduplication to merged events
1157
+ // Apply deduplication to each voice's events
1166
1158
  const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
1167
1159
  const parts = partIndices.map(pi => {
1168
1160
  const staffMap = partMap.get(pi)!;
1169
1161
  const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
1170
1162
  return {
1171
- voices: staffNums.map(staff => ({
1172
- staff,
1173
- events: dedupeContextEvents(staffMap.get(staff)!),
1174
- })),
1163
+ voices: staffNums.flatMap(staff =>
1164
+ staffMap.get(staff)!.map(events => ({
1165
+ staff,
1166
+ events: dedupeContextEvents(events),
1167
+ }))
1168
+ ),
1175
1169
  };
1176
1170
  });
1177
1171
 
@@ -394,11 +394,18 @@ const encodeNoteEvent = (event: NoteEvent, env: PitchEnv, lastDuration: Duration
394
394
  if (event.pitches.length > 1) {
395
395
  result += '<';
396
396
  const pitchStrs: string[] = [];
397
- for (const pitch of event.pitches) {
398
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
397
+ let firstPitchEnv: PitchEnv | undefined;
398
+ for (let i = 0; i < event.pitches.length; i++) {
399
+ // In LilyPond relative mode, each pitch in a chord is relative
400
+ // to the previous pitch in the chord (cascading).
401
+ // After the chord, env becomes the first pitch.
402
+ const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
399
403
  pitchStrs.push(str);
400
404
  newEnv = ne;
405
+ if (i === 0) firstPitchEnv = ne;
401
406
  }
407
+ // After chord, reference resets to first pitch
408
+ newEnv = firstPitchEnv!;
402
409
  result += pitchStrs.join(' ');
403
410
  result += '>';
404
411
  } else if (event.pitches.length === 1) {
@@ -455,13 +462,15 @@ const encodeRestEvent = (event: RestEvent, env: PitchEnv, lastDuration: Duration
455
462
  }
456
463
 
457
464
  // Positioned rest
465
+ let newEnv = env;
458
466
  if (event.pitch && !event.fullMeasure && !event.invisible) {
459
- const { str } = encodePitch(event.pitch, env);
467
+ const { str, newEnv: ne } = encodePitch(event.pitch, env);
460
468
  result = str + result.slice(1); // Replace 'r' with pitch
461
469
  result += '\\rest';
470
+ newEnv = ne;
462
471
  }
463
472
 
464
- return { str: result, newEnv: env, newDuration: event.duration };
473
+ return { str: result, newEnv, newDuration: event.duration };
465
474
  };
466
475
 
467
476
 
@@ -512,8 +521,9 @@ const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Dura
512
521
  newEnv = ne;
513
522
  newDuration = nd;
514
523
  } else if (subEvent.type === 'rest') {
515
- const { str, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
524
+ const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
516
525
  result += str + ' ';
526
+ newEnv = ne;
517
527
  newDuration = nd;
518
528
  }
519
529
  }
@@ -535,11 +545,14 @@ const encodeTremoloEvent = (event: TremoloEvent, env: PitchEnv): { str: string;
535
545
  if (event.pitchA.length > 1) {
536
546
  pitchA += '<';
537
547
  const pitchStrs: string[] = [];
538
- for (const pitch of event.pitchA) {
539
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
548
+ let firstPitchEnv: PitchEnv | undefined;
549
+ for (let i = 0; i < event.pitchA.length; i++) {
550
+ const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
540
551
  pitchStrs.push(str);
541
552
  newEnv = ne;
553
+ if (i === 0) firstPitchEnv = ne;
542
554
  }
555
+ newEnv = firstPitchEnv!;
543
556
  pitchA += pitchStrs.join(' ');
544
557
  pitchA += '>';
545
558
  } else if (event.pitchA.length === 1) {
@@ -553,11 +566,14 @@ const encodeTremoloEvent = (event: TremoloEvent, env: PitchEnv): { str: string;
553
566
  if (event.pitchB.length > 1) {
554
567
  pitchB += '<';
555
568
  const pitchStrs: string[] = [];
556
- for (const pitch of event.pitchB) {
557
- const { str, newEnv: ne } = encodePitch(pitch, newEnv);
569
+ let firstPitchEnv: PitchEnv | undefined;
570
+ for (let i = 0; i < event.pitchB.length; i++) {
571
+ const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
558
572
  pitchStrs.push(str);
559
573
  newEnv = ne;
574
+ if (i === 0) firstPitchEnv = ne;
560
575
  }
576
+ newEnv = firstPitchEnv!;
561
577
  pitchB += pitchStrs.join(' ');
562
578
  pitchB += '>';
563
579
  } else if (event.pitchB.length === 1) {
@@ -624,8 +640,9 @@ const encodeVoice = (
624
640
  break;
625
641
  }
626
642
  case 'rest': {
627
- const { str, newDuration } = encodeRestEvent(event, env, lastDuration);
643
+ const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
628
644
  result += str + ' ';
645
+ env = newEnv;
629
646
  lastDuration = newDuration;
630
647
  break;
631
648
  }
@@ -662,7 +679,8 @@ const encodeVoice = (
662
679
  break;
663
680
  }
664
681
  case 'pitchReset': {
665
- env = { step: 0, octave: 0 };
682
+ // Ignore: each measure already gets its own \relative c' block,
683
+ // and within a measure the LilyPond reference pitch is not reset.
666
684
  break;
667
685
  }
668
686
  }
@@ -648,10 +648,13 @@ const serializeVoice = (
648
648
  for (const event of voice.events) {
649
649
  if (event.type === 'context') {
650
650
  const ctx = event as ContextChange;
651
- // Skip context events that belong to a different staff (cross-staff clef/ottava)
652
- if (ctx.staff && ctx.staff !== voice.staff) {
651
+ // Cross-staff context: update activeStaff and emit \staff directive
652
+ if (ctx.staff && ctx.staff !== activeStaff) {
653
+ activeStaff = ctx.staff;
654
+ parts.push('\\staff "' + activeStaff + '"');
653
655
  continue;
654
656
  }
657
+ if (ctx.staff) continue; // same staff, no-op
655
658
  // Skip clef-only context events if clef already established for this staff
656
659
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
657
660
  continue;
@@ -661,8 +664,8 @@ const serializeVoice = (
661
664
  if (event.type === 'note') {
662
665
  const noteEvt = event as NoteEvent;
663
666
 
664
- // Cross-staff: emit \staff when note's effective staff differs from active
665
- const effectiveStaff = noteEvt.staff || voice.staff;
667
+ // Cross-staff via explicit note.staff (lilylet native cross-staff)
668
+ const effectiveStaff = noteEvt.staff || activeStaff;
666
669
  if (effectiveStaff !== activeStaff) {
667
670
  activeStaff = effectiveStaff;
668
671
  parts.push('\\staff "' + activeStaff + '"');
@@ -872,12 +875,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
872
875
  // Collect clefs from this measure's voices
873
876
  for (const part of measure.parts) {
874
877
  for (const voice of part.voices) {
878
+ let clefActiveStaff = voice.staff;
875
879
  for (const event of voice.events) {
876
- if (event.type === 'context' && (event as ContextChange).clef) {
880
+ if (event.type === 'context') {
877
881
  const ctx = event as ContextChange;
878
- // Use the event's staff if specified (cross-staff), otherwise the voice's staff
879
- const clefStaff = ctx.staff || voice.staff;
880
- staffClefs[clefStaff] = ctx.clef!;
882
+ if (ctx.staff) {
883
+ clefActiveStaff = ctx.staff;
884
+ }
885
+ if (ctx.clef) {
886
+ staffClefs[clefActiveStaff] = ctx.clef;
887
+ }
881
888
  }
882
889
  }
883
890
  }