@k-l-lambda/lilylet 0.1.58 → 0.1.60

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
  }
@@ -626,10 +627,21 @@ const parseLilyDocument = (lilyDocument) => {
626
627
  lastOttava = term.value;
627
628
  }
628
629
  }
629
- // Handle staff change
630
+ // Handle staff change (\change Staff = "N")
631
+ // args[0] is Assignment { key: "Staff", value: { exp: '"N"' } }
630
632
  else if (term instanceof lilyParser.LilyTerms.Change) {
631
- // Ignore \change Staff commands - staff is fixed per track
632
- // (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
+ }
633
645
  }
634
646
  // Handle tempo
635
647
  else if (term instanceof lilyParser.LilyTerms.Tempo) {
@@ -781,6 +793,43 @@ const parseLilyDocument = (lilyDocument) => {
781
793
  },
782
794
  });
783
795
  context.execute(track.music);
796
+ // Post-process: carry staff state across measure boundaries for this voice.
797
+ // If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
798
+ // with context { staff: 2 } so notes at the start of that measure serialize
799
+ // on the correct staff.
800
+ {
801
+ const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
802
+ let carryStaff = trackStaff;
803
+ for (const mi of measureIndices) {
804
+ const voice = measureMap.get(mi)?.voices[vi];
805
+ if (!voice)
806
+ continue;
807
+ // Prepend carry-over event if previous measure ended on a different staff,
808
+ // but skip if the voice's first explicit staff event already resets to
809
+ // trackStaff — the carry-over would be immediately cancelled (no-op).
810
+ if (carryStaff !== trackStaff) {
811
+ // Suppress carry-over if the first staff event resets to trackStaff
812
+ // AND no musical events (notes/rests/etc.) precede it — meaning the
813
+ // carry-over would be immediately cancelled with no effect.
814
+ const firstStaffCtxIdx = voice.events.findIndex(e => e.type === 'context' && e.staff != null);
815
+ const musicalTypes = new Set(['note', 'rest', 'tuplet', 'tremolo']);
816
+ const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
817
+ voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
818
+ const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
819
+ voice.events[firstStaffCtxIdx].staff === trackStaff &&
820
+ !hasMusicBeforeFirstStaff;
821
+ if (!immediatelyCancelled) {
822
+ voice.events.unshift({ type: 'context', staff: carryStaff });
823
+ }
824
+ }
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;
829
+ }
830
+ }
831
+ }
832
+ }
784
833
  });
785
834
  // Filter out empty voices and convert to array, sorted by measure number
786
835
  const measures = Array.from(measureMap.entries())
@@ -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
  }
@@ -460,10 +463,45 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
460
463
  let prevDuration;
461
464
  // Each voice starts fresh from middle C (step=0, octave=0)
462
465
  let pitchEnv = { step: 0, octave: 0 };
466
+ // Scan leading context-staff events (before the first musical event or clef/ottava)
467
+ // to compute the effective initial staff. Multiple consecutive staff switches
468
+ // before any music collapse to the last one (earlier ones are no-ops).
469
+ // leadStaffScanEnd is the index of the first event that ends this scan —
470
+ // context{staff} events before this index are skipped in the main loop.
471
+ const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
472
+ let effectiveInitialStaff = voice.staff;
473
+ let leadStaffScanEnd = 0;
474
+ for (let i = 0; i < voice.events.length; i++) {
475
+ const e = voice.events[i];
476
+ if (e.type === 'pitchReset') {
477
+ leadStaffScanEnd = i + 1;
478
+ continue;
479
+ }
480
+ if (e.type === 'context') {
481
+ const ctx = e;
482
+ if (ctx.staff != null) {
483
+ effectiveInitialStaff = ctx.staff;
484
+ if (!ctx.clef && !ctx.ottava) {
485
+ // Pure staff-only event — absorb
486
+ leadStaffScanEnd = i + 1;
487
+ continue;
488
+ }
489
+ // Compound (staff + clef/ottava) — update effectiveInitialStaff but stop scan
490
+ break;
491
+ }
492
+ if (ctx.clef || ctx.ottava)
493
+ break; // musical context — stop scan
494
+ leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
495
+ continue;
496
+ }
497
+ if (MUSICAL_TYPES.has(e.type))
498
+ break;
499
+ }
463
500
  // Output staff command if voice staff differs from current parser staff,
464
- // or always output if it's a grand staff score for clarity
465
- if (isGrandStaff || voice.staff !== currentStaff) {
466
- parts.push('\\staff "' + voice.staff + '"');
501
+ // or always output if it's a grand staff score for clarity.
502
+ // Use effectiveInitialStaff so carry-over replaces the default emission.
503
+ if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
504
+ parts.push('\\staff "' + effectiveInitialStaff + '"');
467
505
  }
468
506
  // Output key/time signatures after \staff (for first voice only)
469
507
  if (measureContext && isFirstVoice) {
@@ -495,19 +533,32 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
495
533
  }
496
534
  // Skip redundant clef context events if this staff's clef is already established
497
535
  const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
498
- let activeStaff = voice.staff;
536
+ let activeStaff = effectiveInitialStaff;
499
537
  let activeStemDir;
500
- for (const event of voice.events) {
538
+ for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
539
+ const event = voice.events[eventIdx];
540
+ // Skip leading context-staff events already absorbed into effectiveInitialStaff
541
+ if (eventIdx < leadStaffScanEnd && event.type === 'context' && event.staff != null) {
542
+ continue;
543
+ }
501
544
  if (event.type === 'context') {
502
545
  const ctx = event;
503
546
  // Cross-staff context: update activeStaff and emit \staff directive
504
547
  if (ctx.staff && ctx.staff !== activeStaff) {
505
548
  activeStaff = ctx.staff;
506
549
  parts.push('\\staff "' + activeStaff + '"');
550
+ // Emit target staff clef if the event carries one or allStaffClefs knows it
551
+ const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
552
+ if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
553
+ parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
554
+ if (emittedClefs)
555
+ emittedClefs[activeStaff] = ctxClef;
556
+ }
507
557
  continue;
508
558
  }
509
- if (ctx.staff)
510
- continue; // same staff, no-op
559
+ if (ctx.staff && !ctx.clef && !ctx.ottava)
560
+ continue; // same staff, pure no-op
561
+ if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
511
562
  // Skip clef-only context events if clef already established for this staff
512
563
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
513
564
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
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;
@@ -750,10 +751,21 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
750
751
  lastOttava = term.value;
751
752
  }
752
753
  }
753
- // Handle staff change
754
+ // Handle staff change (\change Staff = "N")
755
+ // args[0] is Assignment { key: "Staff", value: { exp: '"N"' } }
754
756
  else if (term instanceof lilyParser.LilyTerms.Change) {
755
- // Ignore \change Staff commands - staff is fixed per track
756
- // (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
+ }
757
769
  }
758
770
  // Handle tempo
759
771
  else if (term instanceof lilyParser.LilyTerms.Tempo) {
@@ -912,6 +924,47 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
912
924
  });
913
925
 
914
926
  context.execute(track.music);
927
+
928
+ // Post-process: carry staff state across measure boundaries for this voice.
929
+ // If measure N ends on staff 2 (via \change Staff), measure N+1 must begin
930
+ // with context { staff: 2 } so notes at the start of that measure serialize
931
+ // on the correct staff.
932
+ {
933
+ const measureIndices = Array.from(measureMap.keys()).sort((a, b) => a - b);
934
+ let carryStaff = trackStaff;
935
+ for (const mi of measureIndices) {
936
+ const voice = measureMap.get(mi)?.voices[vi];
937
+ if (!voice) continue;
938
+
939
+ // Prepend carry-over event if previous measure ended on a different staff,
940
+ // but skip if the voice's first explicit staff event already resets to
941
+ // trackStaff — the carry-over would be immediately cancelled (no-op).
942
+ if (carryStaff !== trackStaff) {
943
+ // Suppress carry-over if the first staff event resets to trackStaff
944
+ // AND no musical events (notes/rests/etc.) precede it — meaning the
945
+ // carry-over would be immediately cancelled with no effect.
946
+ const firstStaffCtxIdx = voice.events.findIndex(
947
+ e => e.type === 'context' && (e as any).staff != null
948
+ );
949
+ const musicalTypes = new Set(['note', 'rest', 'tuplet', 'tremolo']);
950
+ const hasMusicBeforeFirstStaff = firstStaffCtxIdx > 0 &&
951
+ voice.events.slice(0, firstStaffCtxIdx).some(e => musicalTypes.has(e.type));
952
+ const immediatelyCancelled = firstStaffCtxIdx >= 0 &&
953
+ (voice.events[firstStaffCtxIdx] as any).staff === trackStaff &&
954
+ !hasMusicBeforeFirstStaff;
955
+ if (!immediatelyCancelled) {
956
+ voice.events.unshift({ type: 'context', staff: carryStaff } as any);
957
+ }
958
+ }
959
+
960
+ // Update carryStaff from this measure's events
961
+ for (const e of voice.events) {
962
+ if (e.type === 'context' && (e as any).staff) {
963
+ carryStaff = (e as any).staff;
964
+ }
965
+ }
966
+ }
967
+ }
915
968
  });
916
969
 
917
970
  // Filter out empty voices and convert to array, sorted by measure number
@@ -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
  }
@@ -605,10 +607,41 @@ const serializeVoice = (
605
607
  // Each voice starts fresh from middle C (step=0, octave=0)
606
608
  let pitchEnv: PitchEnv = { step: 0, octave: 0 };
607
609
 
610
+ // Scan leading context-staff events (before the first musical event or clef/ottava)
611
+ // to compute the effective initial staff. Multiple consecutive staff switches
612
+ // before any music collapse to the last one (earlier ones are no-ops).
613
+ // leadStaffScanEnd is the index of the first event that ends this scan —
614
+ // context{staff} events before this index are skipped in the main loop.
615
+ const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
616
+ let effectiveInitialStaff = voice.staff;
617
+ let leadStaffScanEnd = 0;
618
+ for (let i = 0; i < voice.events.length; i++) {
619
+ const e = voice.events[i];
620
+ if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
621
+ if (e.type === 'context') {
622
+ const ctx = e as ContextChange;
623
+ if (ctx.staff != null) {
624
+ effectiveInitialStaff = ctx.staff;
625
+ if (!ctx.clef && !ctx.ottava) {
626
+ // Pure staff-only event — absorb
627
+ leadStaffScanEnd = i + 1;
628
+ continue;
629
+ }
630
+ // Compound (staff + clef/ottava) — update effectiveInitialStaff but stop scan
631
+ break;
632
+ }
633
+ if (ctx.clef || ctx.ottava) break; // musical context — stop scan
634
+ leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
635
+ continue;
636
+ }
637
+ if (MUSICAL_TYPES.has(e.type)) break;
638
+ }
639
+
608
640
  // Output staff command if voice staff differs from current parser staff,
609
- // or always output if it's a grand staff score for clarity
610
- if (isGrandStaff || voice.staff !== currentStaff) {
611
- parts.push('\\staff "' + voice.staff + '"');
641
+ // or always output if it's a grand staff score for clarity.
642
+ // Use effectiveInitialStaff so carry-over replaces the default emission.
643
+ if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
644
+ parts.push('\\staff "' + effectiveInitialStaff + '"');
612
645
  }
613
646
 
614
647
  // Output key/time signatures after \staff (for first voice only)
@@ -642,19 +675,31 @@ const serializeVoice = (
642
675
  // Skip redundant clef context events if this staff's clef is already established
643
676
  const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
644
677
 
645
- let activeStaff = voice.staff;
678
+ let activeStaff = effectiveInitialStaff;
646
679
  let activeStemDir: StemDirection | undefined;
647
680
 
648
- for (const event of voice.events) {
681
+ for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
682
+ const event = voice.events[eventIdx];
683
+ // Skip leading context-staff events already absorbed into effectiveInitialStaff
684
+ if (eventIdx < leadStaffScanEnd && event.type === 'context' && (event as ContextChange).staff != null) {
685
+ continue;
686
+ }
649
687
  if (event.type === 'context') {
650
688
  const ctx = event as ContextChange;
651
689
  // Cross-staff context: update activeStaff and emit \staff directive
652
690
  if (ctx.staff && ctx.staff !== activeStaff) {
653
691
  activeStaff = ctx.staff;
654
692
  parts.push('\\staff "' + activeStaff + '"');
693
+ // Emit target staff clef if the event carries one or allStaffClefs knows it
694
+ const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
695
+ if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
696
+ parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
697
+ if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
698
+ }
655
699
  continue;
656
700
  }
657
- if (ctx.staff) continue; // same staff, no-op
701
+ if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
702
+ if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
658
703
  // Skip clef-only context events if clef already established for this staff
659
704
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
660
705
  continue;