@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
|
-
|
|
632
|
-
|
|
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.
|
|
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
|
-
|
|
466
|
-
|
|
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 =
|
|
536
|
+
let activeStaff = effectiveInitialStaff;
|
|
499
537
|
let activeStemDir;
|
|
500
|
-
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
|
+
}
|
|
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.
|
|
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
|
-
|
|
756
|
-
|
|
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.
|
|
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
|
-
|
|
611
|
-
|
|
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 =
|
|
678
|
+
let activeStaff = effectiveInitialStaff;
|
|
646
679
|
let activeStemDir: StemDirection | undefined;
|
|
647
680
|
|
|
648
|
-
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
|
+
}
|
|
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;
|