@k-l-lambda/lilylet 0.1.59 → 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.
@@ -793,6 +793,43 @@ const parseLilyDocument = (lilyDocument) => {
793
793
  },
794
794
  });
795
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
+ }
796
833
  });
797
834
  // Filter out empty voices and convert to array, sorted by measure number
798
835
  const measures = Array.from(measureMap.entries())
@@ -463,10 +463,45 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
463
463
  let prevDuration;
464
464
  // Each voice starts fresh from middle C (step=0, octave=0)
465
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
+ }
466
500
  // Output staff command if voice staff differs from current parser staff,
467
- // or always output if it's a grand staff score for clarity
468
- if (isGrandStaff || voice.staff !== currentStaff) {
469
- 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 + '"');
470
505
  }
471
506
  // Output key/time signatures after \staff (for first voice only)
472
507
  if (measureContext && isFirstVoice) {
@@ -498,9 +533,14 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
498
533
  }
499
534
  // Skip redundant clef context events if this staff's clef is already established
500
535
  const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
501
- let activeStaff = voice.staff;
536
+ let activeStaff = effectiveInitialStaff;
502
537
  let activeStemDir;
503
- 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
+ }
504
544
  if (event.type === 'context') {
505
545
  const ctx = event;
506
546
  // Cross-staff context: update activeStaff and emit \staff directive
@@ -516,8 +556,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
516
556
  }
517
557
  continue;
518
558
  }
519
- if (ctx.staff)
520
- continue; // same staff, no-op for staff field; clef handled below
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 */ }
521
562
  // Skip clef-only context events if clef already established for this staff
522
563
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
523
564
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.59",
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",
@@ -924,6 +924,47 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
924
924
  });
925
925
 
926
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
+ }
927
968
  });
928
969
 
929
970
  // Filter out empty voices and convert to array, sorted by measure number
@@ -607,10 +607,41 @@ const serializeVoice = (
607
607
  // Each voice starts fresh from middle C (step=0, octave=0)
608
608
  let pitchEnv: PitchEnv = { step: 0, octave: 0 };
609
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
+
610
640
  // 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 + '"');
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 + '"');
614
645
  }
615
646
 
616
647
  // Output key/time signatures after \staff (for first voice only)
@@ -644,10 +675,15 @@ const serializeVoice = (
644
675
  // Skip redundant clef context events if this staff's clef is already established
645
676
  const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
646
677
 
647
- let activeStaff = voice.staff;
678
+ let activeStaff = effectiveInitialStaff;
648
679
  let activeStemDir: StemDirection | undefined;
649
680
 
650
- 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
+ }
651
687
  if (event.type === 'context') {
652
688
  const ctx = event as ContextChange;
653
689
  // Cross-staff context: update activeStaff and emit \staff directive
@@ -662,7 +698,8 @@ const serializeVoice = (
662
698
  }
663
699
  continue;
664
700
  }
665
- if (ctx.staff) continue; // same staff, no-op for staff field; clef handled below
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 */ }
666
703
  // Skip clef-only context events if clef already established for this staff
667
704
  if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
668
705
  continue;