@k-l-lambda/lilylet 0.1.50 → 0.1.52
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.
- package/lib/lilylet/grammar.jison.js +124 -119
- package/lib/lilylet/meiEncoder.js +50 -6
- package/lib/lilylet/serializer.js +70 -20
- package/lib/lilylet/types.d.ts +2 -1
- package/lib/lilylet/types.js +1 -0
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +124 -119
- package/source/lilylet/lilylet.jison +10 -3
- package/source/lilylet/meiEncoder.ts +53 -6
- package/source/lilylet/serializer.ts +73 -21
- package/source/lilylet/types.ts +1 -0
|
@@ -125,6 +125,7 @@ const DYNAMIC_MAP: Record<string, string> = {
|
|
|
125
125
|
fff: "fff",
|
|
126
126
|
sfz: "sfz",
|
|
127
127
|
rfz: "rfz",
|
|
128
|
+
fp: "fp",
|
|
128
129
|
};
|
|
129
130
|
|
|
130
131
|
|
|
@@ -524,6 +525,10 @@ const noteEventToMEI = (
|
|
|
524
525
|
if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
|
|
525
526
|
chordAttrs += ` staff="${noteOptions.staff}"`;
|
|
526
527
|
}
|
|
528
|
+
if (noteOptions.tremolo) {
|
|
529
|
+
const stemMod = tremoloToStemMod(noteOptions.tremolo);
|
|
530
|
+
if (stemMod) chordAttrs += ` stem.mod="${stemMod}"`;
|
|
531
|
+
}
|
|
527
532
|
|
|
528
533
|
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
529
534
|
|
|
@@ -611,6 +616,21 @@ interface TupletEventResult {
|
|
|
611
616
|
arpeggios: ArpegRef[];
|
|
612
617
|
}
|
|
613
618
|
|
|
619
|
+
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
620
|
+
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
621
|
+
const tupletHasInternalBeams = (event: TupletEvent): boolean => {
|
|
622
|
+
let starts = 0;
|
|
623
|
+
let ends = 0;
|
|
624
|
+
for (const e of event.events) {
|
|
625
|
+
if (e.type === 'note') {
|
|
626
|
+
const markOptions = extractMarkOptions((e as NoteEvent).marks);
|
|
627
|
+
if (markOptions.beamStart) starts++;
|
|
628
|
+
if (markOptions.beamEnd) ends++;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return starts > 0 && starts === ends;
|
|
632
|
+
};
|
|
633
|
+
|
|
614
634
|
// Convert TupletEvent to MEI
|
|
615
635
|
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
|
|
616
636
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
@@ -636,18 +656,28 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
636
656
|
const turns: TurnRef[] = [];
|
|
637
657
|
const arpeggios: ArpegRef[] = [];
|
|
638
658
|
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
659
|
+
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
660
|
+
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
661
|
+
let beamOpen = false;
|
|
642
662
|
|
|
643
663
|
for (const e of event.events) {
|
|
644
664
|
if (e.type === 'note') {
|
|
645
|
-
// For cross-staff notation: set note's staff if different from layerStaff
|
|
646
665
|
const noteEvent = e as NoteEvent;
|
|
666
|
+
const markOptions = extractMarkOptions(noteEvent.marks);
|
|
667
|
+
|
|
668
|
+
// Open beam if this note starts a beam group
|
|
669
|
+
if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
|
|
670
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
671
|
+
beamOpen = true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
675
|
+
|
|
676
|
+
// For cross-staff notation: set note's staff if different from layerStaff
|
|
647
677
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
648
678
|
? { ...noteEvent, staff: effectiveStaff }
|
|
649
679
|
: noteEvent;
|
|
650
|
-
const result = noteEventToMEI(effectiveNoteEvent,
|
|
680
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
651
681
|
xml += result.xml;
|
|
652
682
|
|
|
653
683
|
// Collect slur info
|
|
@@ -661,11 +691,23 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
661
691
|
if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
|
|
662
692
|
if (result.turn) turns.push({ startid: result.elementId });
|
|
663
693
|
if (result.arpeggio) arpeggios.push({ plist: result.elementId });
|
|
694
|
+
|
|
695
|
+
// Close beam if this note ends a beam group
|
|
696
|
+
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
697
|
+
xml += `${baseIndent}</beam>\n`;
|
|
698
|
+
beamOpen = false;
|
|
699
|
+
}
|
|
664
700
|
} else if (e.type === 'rest') {
|
|
665
|
-
|
|
701
|
+
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
702
|
+
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift);
|
|
666
703
|
}
|
|
667
704
|
}
|
|
668
705
|
|
|
706
|
+
// Close any unclosed beam
|
|
707
|
+
if (beamOpen) {
|
|
708
|
+
xml += `${baseIndent}</beam>\n`;
|
|
709
|
+
}
|
|
710
|
+
|
|
669
711
|
xml += `${indent}</tuplet>\n`;
|
|
670
712
|
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
671
713
|
};
|
|
@@ -865,6 +907,11 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
|
|
|
865
907
|
}
|
|
866
908
|
if (event.type === 'tuplet') {
|
|
867
909
|
const tuplet = event as TupletEvent;
|
|
910
|
+
// If the tuplet has internal beam groups, don't report beam marks to the parent
|
|
911
|
+
// so the parent won't wrap the tuplet in an external <beam>
|
|
912
|
+
if (tupletHasInternalBeams(tuplet)) {
|
|
913
|
+
return { beamStart: false, beamEnd: false };
|
|
914
|
+
}
|
|
868
915
|
let beamStart = false;
|
|
869
916
|
let beamEnd = false;
|
|
870
917
|
for (const e of tuplet.events) {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
PedalType,
|
|
32
32
|
Tempo,
|
|
33
33
|
Placement,
|
|
34
|
+
Metadata,
|
|
34
35
|
} from "./types";
|
|
35
36
|
|
|
36
37
|
|
|
@@ -143,6 +144,7 @@ const DYNAMIC_MAP: Record<string, string> = {
|
|
|
143
144
|
fff: '\\fff',
|
|
144
145
|
sfz: '\\sfz',
|
|
145
146
|
rfz: '\\rfz',
|
|
147
|
+
fp: '\\fp',
|
|
146
148
|
};
|
|
147
149
|
|
|
148
150
|
|
|
@@ -259,6 +261,9 @@ const serializeMarks = (marks: Mark[]): string => {
|
|
|
259
261
|
if (pedalStr) parts.push(pedalStr);
|
|
260
262
|
break;
|
|
261
263
|
}
|
|
264
|
+
case 'fingering':
|
|
265
|
+
parts.push('-' + mark.finger);
|
|
266
|
+
break;
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
|
|
@@ -584,14 +589,16 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
|
584
589
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
585
590
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
586
591
|
// measureContext provides key/time for first voice
|
|
587
|
-
//
|
|
592
|
+
// allStaffClefs is the clef map for all staves (tracked across measures)
|
|
593
|
+
// emittedClefs tracks which clefs have already been output (avoids duplicates)
|
|
588
594
|
const serializeVoice = (
|
|
589
595
|
voice: Voice,
|
|
590
596
|
currentStaff: number,
|
|
591
597
|
isGrandStaff: boolean = false,
|
|
592
598
|
measureContext?: MeasureContext,
|
|
593
599
|
isFirstVoice: boolean = false,
|
|
594
|
-
|
|
600
|
+
allStaffClefs?: Record<number, Clef>,
|
|
601
|
+
emittedClefs?: Record<number, Clef>
|
|
595
602
|
): { str: string; newStaff: number } => {
|
|
596
603
|
const parts: string[] = [];
|
|
597
604
|
let prevDuration: Duration | undefined;
|
|
@@ -625,25 +632,63 @@ const serializeVoice = (
|
|
|
625
632
|
}
|
|
626
633
|
}
|
|
627
634
|
|
|
628
|
-
// Output clef
|
|
629
|
-
const voiceClef =
|
|
630
|
-
|
|
635
|
+
// Output clef only if not yet emitted or changed for this staff
|
|
636
|
+
const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
|
|
637
|
+
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
638
|
+
if (voiceClef && !clefAlreadyEmitted) {
|
|
631
639
|
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
640
|
+
if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
|
|
632
641
|
}
|
|
642
|
+
// Skip redundant clef context events if this staff's clef is already established
|
|
643
|
+
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
633
644
|
|
|
634
|
-
|
|
635
|
-
let
|
|
645
|
+
let activeStaff = voice.staff;
|
|
646
|
+
let activeStemDir: StemDirection | undefined;
|
|
636
647
|
|
|
637
648
|
for (const event of voice.events) {
|
|
638
|
-
|
|
639
|
-
if (clefOutputted && event.type === 'context') {
|
|
649
|
+
if (event.type === 'context') {
|
|
640
650
|
const ctx = event as ContextChange;
|
|
641
|
-
|
|
642
|
-
|
|
651
|
+
// Skip context events that belong to a different staff (cross-staff clef/ottava)
|
|
652
|
+
if (ctx.staff && ctx.staff !== voice.staff) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
// Skip clef-only context events if clef already established for this staff
|
|
656
|
+
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
643
657
|
continue;
|
|
644
658
|
}
|
|
645
659
|
}
|
|
646
660
|
|
|
661
|
+
if (event.type === 'note') {
|
|
662
|
+
const noteEvt = event as NoteEvent;
|
|
663
|
+
|
|
664
|
+
// Cross-staff: emit \staff when note's effective staff differs from active
|
|
665
|
+
const effectiveStaff = noteEvt.staff || voice.staff;
|
|
666
|
+
if (effectiveStaff !== activeStaff) {
|
|
667
|
+
activeStaff = effectiveStaff;
|
|
668
|
+
parts.push('\\staff "' + activeStaff + '"');
|
|
669
|
+
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
670
|
+
const targetClef = allStaffClefs?.[activeStaff];
|
|
671
|
+
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
672
|
+
parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
|
|
673
|
+
if (emittedClefs) emittedClefs[activeStaff] = targetClef;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
|
|
678
|
+
const stemDir = noteEvt.stemDirection;
|
|
679
|
+
if (stemDir !== activeStemDir) {
|
|
680
|
+
if (stemDir === StemDirection.up) {
|
|
681
|
+
parts.push('\\stemUp');
|
|
682
|
+
} else if (stemDir === StemDirection.down) {
|
|
683
|
+
parts.push('\\stemDown');
|
|
684
|
+
} else if (activeStemDir) {
|
|
685
|
+
// Was set, now undefined → reset to neutral
|
|
686
|
+
parts.push('\\stemNeutral');
|
|
687
|
+
}
|
|
688
|
+
activeStemDir = stemDir;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
647
692
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
648
693
|
pitchEnv = newEnv;
|
|
649
694
|
|
|
@@ -656,6 +701,9 @@ const serializeVoice = (
|
|
|
656
701
|
prevDuration = (event as NoteEvent).duration;
|
|
657
702
|
} else if (event.type === 'rest') {
|
|
658
703
|
prevDuration = (event as RestEvent).duration;
|
|
704
|
+
} else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
|
|
705
|
+
const ctx = event as ContextChange;
|
|
706
|
+
emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
|
|
659
707
|
}
|
|
660
708
|
}
|
|
661
709
|
|
|
@@ -671,7 +719,8 @@ const serializePart = (
|
|
|
671
719
|
isGrandStaff: boolean = false,
|
|
672
720
|
measureContext?: MeasureContext,
|
|
673
721
|
isFirstPart: boolean = false,
|
|
674
|
-
clefsByStaff?: Record<number, Clef
|
|
722
|
+
clefsByStaff?: Record<number, Clef>,
|
|
723
|
+
emittedClefs?: Record<number, Clef>
|
|
675
724
|
): { str: string; newStaff: number } => {
|
|
676
725
|
if (part.voices.length === 0) {
|
|
677
726
|
return { str: '', newStaff: currentStaff };
|
|
@@ -683,10 +732,8 @@ const serializePart = (
|
|
|
683
732
|
for (let i = 0; i < part.voices.length; i++) {
|
|
684
733
|
const voice = part.voices[i];
|
|
685
734
|
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
686
|
-
// Pass staff clef from clefsByStaff map
|
|
687
735
|
const isFirstVoice = isFirstPart && i === 0;
|
|
688
|
-
const
|
|
689
|
-
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
736
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
|
|
690
737
|
voiceStrs.push(str);
|
|
691
738
|
staff = newStaff;
|
|
692
739
|
}
|
|
@@ -705,7 +752,8 @@ const serializeMeasure = (
|
|
|
705
752
|
isGrandStaff: boolean = false,
|
|
706
753
|
currentKey?: KeySignature,
|
|
707
754
|
currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
|
|
708
|
-
staffClefs?: Record<number, Clef
|
|
755
|
+
staffClefs?: Record<number, Clef>,
|
|
756
|
+
emittedClefs?: Record<number, Clef>
|
|
709
757
|
): { str: string; newStaff: number } => {
|
|
710
758
|
const parts: string[] = [];
|
|
711
759
|
|
|
@@ -723,7 +771,7 @@ const serializeMeasure = (
|
|
|
723
771
|
// Parts
|
|
724
772
|
let staff = currentStaff;
|
|
725
773
|
if (measure.parts.length === 1) {
|
|
726
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
774
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
|
|
727
775
|
if (partStr) {
|
|
728
776
|
parts.push(partStr);
|
|
729
777
|
}
|
|
@@ -734,7 +782,7 @@ const serializeMeasure = (
|
|
|
734
782
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
735
783
|
const part = measure.parts[i];
|
|
736
784
|
// Pass measureContext to all parts, isFirstPart to first part only
|
|
737
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
785
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
|
|
738
786
|
if (str) {
|
|
739
787
|
partStrs.push(str);
|
|
740
788
|
}
|
|
@@ -753,7 +801,7 @@ const escapeString = (str: string): string => {
|
|
|
753
801
|
};
|
|
754
802
|
|
|
755
803
|
// Serialize metadata
|
|
756
|
-
const serializeMetadata = (metadata:
|
|
804
|
+
const serializeMetadata = (metadata: Metadata): string => {
|
|
757
805
|
const lines: string[] = [];
|
|
758
806
|
|
|
759
807
|
if (metadata.title) {
|
|
@@ -809,6 +857,7 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
809
857
|
let currentKey: KeySignature | undefined;
|
|
810
858
|
let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
|
|
811
859
|
const staffClefs: Record<number, Clef> = {}; // Track clef per staff
|
|
860
|
+
const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
|
|
812
861
|
|
|
813
862
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
814
863
|
const measure = doc.measures[i];
|
|
@@ -825,13 +874,16 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
825
874
|
for (const voice of part.voices) {
|
|
826
875
|
for (const event of voice.events) {
|
|
827
876
|
if (event.type === 'context' && (event as ContextChange).clef) {
|
|
828
|
-
|
|
877
|
+
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!;
|
|
829
881
|
}
|
|
830
882
|
}
|
|
831
883
|
}
|
|
832
884
|
}
|
|
833
885
|
|
|
834
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
886
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
|
|
835
887
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
836
888
|
measureStrs.push(measureStr || 's1');
|
|
837
889
|
currentStaff = newStaff;
|