@k-l-lambda/lilylet 0.1.57 → 0.1.59

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.
@@ -451,7 +451,8 @@ const parseLilyDocument = (lilyDocument) => {
451
451
  // Update key/time from context on music events
452
452
  if (term instanceof lilyParser.MusicEvent ||
453
453
  term instanceof lilyParser.LilyTerms.StemDirection ||
454
- term instanceof lilyParser.LilyTerms.OctaveShift) {
454
+ term instanceof lilyParser.LilyTerms.OctaveShift ||
455
+ term instanceof lilyParser.LilyTerms.Change) {
455
456
  if (context.key && measure.key === null) {
456
457
  measure.key = context.key.key;
457
458
  }
@@ -585,6 +586,7 @@ const parseLilyDocument = (lilyDocument) => {
585
586
  const restEvent = {
586
587
  type: 'rest',
587
588
  duration: convertDuration(term.durationValue),
589
+ fullMeasure: (term.name === 'R') || undefined,
588
590
  invisible: term.isSpacer || undefined,
589
591
  };
590
592
  voice.events.push(restEvent);
@@ -625,10 +627,21 @@ const parseLilyDocument = (lilyDocument) => {
625
627
  lastOttava = term.value;
626
628
  }
627
629
  }
628
- // Handle staff change
630
+ // Handle staff change (\change Staff = "N")
631
+ // args[0] is Assignment { key: "Staff", value: { exp: '"N"' } }
629
632
  else if (term instanceof lilyParser.LilyTerms.Change) {
630
- // Ignore \change Staff commands - staff is fixed per track
631
- // (Cross-staff notation is not supported in this decoder)
633
+ const assignment = term.args?.[0];
634
+ if (assignment?.key === 'Staff') {
635
+ // exp is like '"2"' — strip surrounding quotes then parse strictly
636
+ const exp = assignment?.value?.exp ?? '';
637
+ const raw = exp.replace(/^"|"$/g, '');
638
+ // Only accept pure integer staff names (e.g. "1", "2")
639
+ // Reject compound names like "1_2", "RH", "LH"
640
+ const staffNum = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
641
+ if (!isNaN(staffNum)) {
642
+ voice.events.push({ type: 'context', staff: staffNum });
643
+ }
644
+ }
632
645
  }
633
646
  // Handle tempo
634
647
  else if (term instanceof lilyParser.LilyTerms.Tempo) {
@@ -439,10 +439,13 @@ const serializeEvent = (event, env, prevDuration) => {
439
439
  };
440
440
  // Find first clef in voice events
441
441
  const findVoiceClef = (voice) => {
442
+ let activeStaff = voice.staff;
442
443
  for (const event of voice.events) {
443
444
  if (event.type === 'context') {
444
445
  const ctx = event;
445
- if (ctx.clef) {
446
+ if (ctx.staff)
447
+ activeStaff = ctx.staff;
448
+ if (ctx.clef && activeStaff === voice.staff) {
446
449
  return ctx.clef;
447
450
  }
448
451
  }
@@ -500,10 +503,21 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
500
503
  for (const event of voice.events) {
501
504
  if (event.type === 'context') {
502
505
  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) {
506
+ // Cross-staff context: update activeStaff and emit \staff directive
507
+ if (ctx.staff && ctx.staff !== activeStaff) {
508
+ activeStaff = ctx.staff;
509
+ parts.push('\\staff "' + activeStaff + '"');
510
+ // Emit target staff clef if the event carries one or allStaffClefs knows it
511
+ const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
512
+ if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
513
+ parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
514
+ if (emittedClefs)
515
+ emittedClefs[activeStaff] = ctxClef;
516
+ }
505
517
  continue;
506
518
  }
519
+ if (ctx.staff)
520
+ continue; // same staff, no-op for staff field; clef handled below
507
521
  // Skip clef-only context events if clef already established for this staff
508
522
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
509
523
  continue;
@@ -511,8 +525,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
511
525
  }
512
526
  if (event.type === 'note') {
513
527
  const noteEvt = event;
514
- // Cross-staff: emit \staff when note's effective staff differs from active
515
- const effectiveStaff = noteEvt.staff || voice.staff;
528
+ // Cross-staff via explicit note.staff (lilylet native cross-staff)
529
+ const effectiveStaff = noteEvt.staff || activeStaff;
516
530
  if (effectiveStaff !== activeStaff) {
517
531
  activeStaff = effectiveStaff;
518
532
  parts.push('\\staff "' + activeStaff + '"');
@@ -679,12 +693,16 @@ export const serializeLilyletDoc = (doc) => {
679
693
  // Collect clefs from this measure's voices
680
694
  for (const part of measure.parts) {
681
695
  for (const voice of part.voices) {
696
+ let clefActiveStaff = voice.staff;
682
697
  for (const event of voice.events) {
683
- if (event.type === 'context' && event.clef) {
698
+ if (event.type === 'context') {
684
699
  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;
700
+ if (ctx.staff) {
701
+ clefActiveStaff = ctx.staff;
702
+ }
703
+ if (ctx.clef) {
704
+ staffClefs[clefActiveStaff] = ctx.clef;
705
+ }
688
706
  }
689
707
  }
690
708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
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",
@@ -556,7 +556,8 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
556
556
  // Update key/time from context on music events
557
557
  if (term instanceof lilyParser.MusicEvent ||
558
558
  term instanceof lilyParser.LilyTerms.StemDirection ||
559
- term instanceof lilyParser.LilyTerms.OctaveShift) {
559
+ term instanceof lilyParser.LilyTerms.OctaveShift ||
560
+ term instanceof lilyParser.LilyTerms.Change) {
560
561
 
561
562
  if (context.key && measure.key === null) {
562
563
  measure.key = context.key.key;
@@ -708,6 +709,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
708
709
  const restEvent: RestEvent = {
709
710
  type: 'rest',
710
711
  duration: convertDuration(term.durationValue),
712
+ fullMeasure: (term.name === 'R') || undefined,
711
713
  invisible: term.isSpacer || undefined,
712
714
  };
713
715
 
@@ -749,10 +751,21 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
749
751
  lastOttava = term.value;
750
752
  }
751
753
  }
752
- // Handle staff change
754
+ // Handle staff change (\change Staff = "N")
755
+ // args[0] is Assignment { key: "Staff", value: { exp: '"N"' } }
753
756
  else if (term instanceof lilyParser.LilyTerms.Change) {
754
- // Ignore \change Staff commands - staff is fixed per track
755
- // (Cross-staff notation is not supported in this decoder)
757
+ const assignment = (term as any).args?.[0];
758
+ if (assignment?.key === 'Staff') {
759
+ // exp is like '"2"' — strip surrounding quotes then parse strictly
760
+ const exp: string = assignment?.value?.exp ?? '';
761
+ const raw = exp.replace(/^"|"$/g, '');
762
+ // Only accept pure integer staff names (e.g. "1", "2")
763
+ // Reject compound names like "1_2", "RH", "LH"
764
+ const staffNum = /^\d+$/.test(raw) ? parseInt(raw, 10) : NaN;
765
+ if (!isNaN(staffNum)) {
766
+ voice.events.push({ type: 'context', staff: staffNum } as any);
767
+ }
768
+ }
756
769
  }
757
770
  // Handle tempo
758
771
  else if (term instanceof lilyParser.LilyTerms.Tempo) {
@@ -574,10 +574,12 @@ interface MeasureContext {
574
574
 
575
575
  // Find first clef in voice events
576
576
  const findVoiceClef = (voice: Voice): Clef | undefined => {
577
+ let activeStaff = voice.staff;
577
578
  for (const event of voice.events) {
578
579
  if (event.type === 'context') {
579
580
  const ctx = event as ContextChange;
580
- if (ctx.clef) {
581
+ if (ctx.staff) activeStaff = ctx.staff;
582
+ if (ctx.clef && activeStaff === voice.staff) {
581
583
  return ctx.clef;
582
584
  }
583
585
  }
@@ -648,10 +650,19 @@ const serializeVoice = (
648
650
  for (const event of voice.events) {
649
651
  if (event.type === 'context') {
650
652
  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) {
653
+ // Cross-staff context: update activeStaff and emit \staff directive
654
+ if (ctx.staff && ctx.staff !== activeStaff) {
655
+ activeStaff = ctx.staff;
656
+ parts.push('\\staff "' + activeStaff + '"');
657
+ // Emit target staff clef if the event carries one or allStaffClefs knows it
658
+ const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
659
+ if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
660
+ parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
661
+ if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
662
+ }
653
663
  continue;
654
664
  }
665
+ if (ctx.staff) continue; // same staff, no-op for staff field; clef handled below
655
666
  // Skip clef-only context events if clef already established for this staff
656
667
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
657
668
  continue;
@@ -661,8 +672,8 @@ const serializeVoice = (
661
672
  if (event.type === 'note') {
662
673
  const noteEvt = event as NoteEvent;
663
674
 
664
- // Cross-staff: emit \staff when note's effective staff differs from active
665
- const effectiveStaff = noteEvt.staff || voice.staff;
675
+ // Cross-staff via explicit note.staff (lilylet native cross-staff)
676
+ const effectiveStaff = noteEvt.staff || activeStaff;
666
677
  if (effectiveStaff !== activeStaff) {
667
678
  activeStaff = effectiveStaff;
668
679
  parts.push('\\staff "' + activeStaff + '"');
@@ -872,12 +883,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
872
883
  // Collect clefs from this measure's voices
873
884
  for (const part of measure.parts) {
874
885
  for (const voice of part.voices) {
886
+ let clefActiveStaff = voice.staff;
875
887
  for (const event of voice.events) {
876
- if (event.type === 'context' && (event as ContextChange).clef) {
888
+ if (event.type === 'context') {
877
889
  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!;
890
+ if (ctx.staff) {
891
+ clefActiveStaff = ctx.staff;
892
+ }
893
+ if (ctx.clef) {
894
+ staffClefs[clefActiveStaff] = ctx.clef;
895
+ }
881
896
  }
882
897
  }
883
898
  }