@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
|
-
|
|
469
|
-
|
|
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 =
|
|
536
|
+
let activeStaff = effectiveInitialStaff;
|
|
502
537
|
let activeStemDir;
|
|
503
|
-
for (
|
|
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
|
|
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.
|
|
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
|
-
|
|
613
|
-
|
|
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 =
|
|
678
|
+
let activeStaff = effectiveInitialStaff;
|
|
648
679
|
let activeStemDir: StemDirection | undefined;
|
|
649
680
|
|
|
650
|
-
for (
|
|
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
|
|
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;
|