@k-l-lambda/lilylet 0.1.73 → 0.1.75
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 +180 -175
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/measureLayout.d.ts +68 -0
- package/lib/lilylet/measureLayout.js +412 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +292 -86
- package/lib/lilylet/musicXmlDecoder.d.ts +12 -2
- package/lib/lilylet/musicXmlDecoder.js +498 -67
- package/lib/lilylet/musicXmlTypes.d.ts +11 -1
- package/lib/lilylet/serializer.js +124 -10
- package/lib/lilylet/types.d.ts +3 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +6 -2
- package/source/lilylet/grammar.jison.js +180 -175
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/measureLayout.ts +460 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +280 -87
- package/source/lilylet/musicXmlDecoder.ts +494 -67
- package/source/lilylet/musicXmlTypes.ts +14 -1
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +6 -0
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./types";
|
|
30
30
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
31
|
import { gmProgramOf } from "./gmInstruments";
|
|
32
|
+
import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout";
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -410,8 +411,10 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
410
411
|
beamStart: boolean;
|
|
411
412
|
beamEnd: boolean;
|
|
412
413
|
dynamic?: string;
|
|
414
|
+
dynamics: string[]; // all dynamics on this event (a note may carry several, e.g. fp written as two marks)
|
|
413
415
|
hairpin?: string;
|
|
414
416
|
pedal?: string;
|
|
417
|
+
pedals?: ('up' | 'down')[];
|
|
415
418
|
tremolo?: number;
|
|
416
419
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
417
420
|
navigation?: 'coda' | 'segno';
|
|
@@ -430,8 +433,10 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
430
433
|
beamStart: false,
|
|
431
434
|
beamEnd: false,
|
|
432
435
|
dynamic: undefined as string | undefined,
|
|
436
|
+
dynamics: [] as string[],
|
|
433
437
|
hairpin: undefined as string | undefined,
|
|
434
438
|
pedal: undefined as string | undefined,
|
|
439
|
+
pedals: [] as ('up' | 'down')[],
|
|
435
440
|
tremolo: undefined as number | undefined,
|
|
436
441
|
fingerings: [] as { finger: number; placement?: 'above' | 'below' }[],
|
|
437
442
|
navigation: undefined as 'coda' | 'segno' | undefined,
|
|
@@ -472,7 +477,8 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
472
477
|
case 'dynamic': {
|
|
473
478
|
const dynStr = DYNAMIC_MAP[mark.type];
|
|
474
479
|
if (dynStr) {
|
|
475
|
-
result.dynamic = dynStr;
|
|
480
|
+
result.dynamic = dynStr; // kept for back-compat (first dynamic)
|
|
481
|
+
result.dynamics.push(dynStr);
|
|
476
482
|
}
|
|
477
483
|
break;
|
|
478
484
|
}
|
|
@@ -481,15 +487,20 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
481
487
|
result.hairpin = 'crescStart';
|
|
482
488
|
} else if (mark.type === HairpinType.diminuendoStart) {
|
|
483
489
|
result.hairpin = 'dimStart';
|
|
484
|
-
} else if (mark.type === HairpinType.crescendoEnd
|
|
485
|
-
result.hairpin = '
|
|
490
|
+
} else if (mark.type === HairpinType.crescendoEnd) {
|
|
491
|
+
result.hairpin = 'crescEnd';
|
|
492
|
+
} else if (mark.type === HairpinType.diminuendoEnd) {
|
|
493
|
+
result.hairpin = 'dimEnd';
|
|
486
494
|
}
|
|
487
495
|
break;
|
|
488
496
|
case 'pedal':
|
|
497
|
+
// A note can carry more than one pedal mark (a pedal "bounce": an up
|
|
498
|
+
// to release the previous pedal and an immediate down to re-pedal on
|
|
499
|
+
// the same note), so collect ALL of them rather than keep one scalar.
|
|
489
500
|
if (mark.type === PedalType.sustainOn) {
|
|
490
|
-
result.
|
|
501
|
+
result.pedals.push('down');
|
|
491
502
|
} else if (mark.type === PedalType.sustainOff) {
|
|
492
|
-
result.
|
|
503
|
+
result.pedals.push('up');
|
|
493
504
|
}
|
|
494
505
|
break;
|
|
495
506
|
case 'tie':
|
|
@@ -544,6 +555,7 @@ interface NoteEventResult {
|
|
|
544
555
|
elementId: string;
|
|
545
556
|
hairpin?: string;
|
|
546
557
|
pedal?: string;
|
|
558
|
+
pedals?: ('up' | 'down')[];
|
|
547
559
|
hasTieStart: boolean;
|
|
548
560
|
pitches: Pitch[];
|
|
549
561
|
arpeggio: boolean;
|
|
@@ -551,7 +563,8 @@ interface NoteEventResult {
|
|
|
551
563
|
trill: boolean;
|
|
552
564
|
mordent: 'lower' | 'upper' | false; // lower = mordent, upper = prall
|
|
553
565
|
turn: boolean;
|
|
554
|
-
dynamic?: string; // dynamic marking (p, pp, f, ff, etc.)
|
|
566
|
+
dynamic?: string; // dynamic marking (p, pp, f, ff, etc.) — first one (back-compat)
|
|
567
|
+
dynamics: string[]; // all dynamics on this event
|
|
555
568
|
slurStart: boolean; // For tracking slur spans
|
|
556
569
|
slurEnd: boolean; // For tracking slur spans
|
|
557
570
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
@@ -610,6 +623,7 @@ const noteEventToMEI = (
|
|
|
610
623
|
elementId: noteId,
|
|
611
624
|
hairpin: markOptions.hairpin,
|
|
612
625
|
pedal: markOptions.pedal,
|
|
626
|
+
pedals: markOptions.pedals,
|
|
613
627
|
hasTieStart: markOptions.tieStart,
|
|
614
628
|
pitches: event.pitches,
|
|
615
629
|
arpeggio: markOptions.arpeggio,
|
|
@@ -618,6 +632,7 @@ const noteEventToMEI = (
|
|
|
618
632
|
mordent: markOptions.mordent,
|
|
619
633
|
turn: markOptions.turn,
|
|
620
634
|
dynamic: markOptions.dynamic,
|
|
635
|
+
dynamics: markOptions.dynamics,
|
|
621
636
|
slurStart: markOptions.slurStart,
|
|
622
637
|
slurEnd: markOptions.slurEnd,
|
|
623
638
|
fingerings: markOptions.fingerings,
|
|
@@ -671,6 +686,7 @@ const noteEventToMEI = (
|
|
|
671
686
|
elementId: chordId,
|
|
672
687
|
hairpin: markOptions.hairpin,
|
|
673
688
|
pedal: markOptions.pedal,
|
|
689
|
+
pedals: markOptions.pedals,
|
|
674
690
|
hasTieStart: markOptions.tieStart,
|
|
675
691
|
pitches: event.pitches,
|
|
676
692
|
arpeggio: markOptions.arpeggio,
|
|
@@ -679,6 +695,7 @@ const noteEventToMEI = (
|
|
|
679
695
|
mordent: markOptions.mordent,
|
|
680
696
|
turn: markOptions.turn,
|
|
681
697
|
dynamic: markOptions.dynamic,
|
|
698
|
+
dynamics: markOptions.dynamics,
|
|
682
699
|
slurStart: markOptions.slurStart,
|
|
683
700
|
slurEnd: markOptions.slurEnd,
|
|
684
701
|
fingerings: markOptions.fingerings,
|
|
@@ -689,7 +706,7 @@ const noteEventToMEI = (
|
|
|
689
706
|
|
|
690
707
|
|
|
691
708
|
// Convert RestEvent to MEI
|
|
692
|
-
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string } => {
|
|
709
|
+
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string; fermata?: 'normal' | 'short' } => {
|
|
693
710
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
694
711
|
const restId = generateId('rest');
|
|
695
712
|
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
@@ -698,6 +715,15 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
698
715
|
// Cross-staff attribute
|
|
699
716
|
if (crossStaff) attrs += ` staff="${crossStaff}"`;
|
|
700
717
|
|
|
718
|
+
// A rest may carry a fermata (held silence). Surface it so the layer loop can
|
|
719
|
+
// emit a <fermata> control event referencing this rest's id.
|
|
720
|
+
let fermata: 'normal' | 'short' | undefined;
|
|
721
|
+
if (event.marks) {
|
|
722
|
+
for (const mk of event.marks) {
|
|
723
|
+
if (mk.markType === 'ornament' && (mk as { type?: string }).type === 'fermata') fermata = 'normal';
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
701
727
|
// Pitched rest (positioned at specific pitch)
|
|
702
728
|
if (event.pitch) {
|
|
703
729
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -706,16 +732,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
706
732
|
|
|
707
733
|
// Space rest (invisible)
|
|
708
734
|
if (event.invisible) {
|
|
709
|
-
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
735
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
|
|
710
736
|
}
|
|
711
737
|
|
|
712
738
|
// Full measure rest
|
|
713
739
|
if (event.fullMeasure) {
|
|
714
740
|
const mRestId = generateId('mrest');
|
|
715
|
-
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
741
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
|
|
716
742
|
}
|
|
717
743
|
|
|
718
|
-
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
744
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
|
|
719
745
|
};
|
|
720
746
|
|
|
721
747
|
|
|
@@ -723,6 +749,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
723
749
|
interface TupletEventResult {
|
|
724
750
|
xml: string;
|
|
725
751
|
firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
|
|
752
|
+
lastNoteId: string | null; // ID of last note/rest in tuplet (for closing/extending ottava spans)
|
|
726
753
|
slurStarts: string[]; // Note IDs that start slurs
|
|
727
754
|
slurEnds: string[]; // Note IDs that end slurs
|
|
728
755
|
dynamics: DynamRef[];
|
|
@@ -731,6 +758,9 @@ interface TupletEventResult {
|
|
|
731
758
|
mordents: MordentRef[];
|
|
732
759
|
turns: TurnRef[];
|
|
733
760
|
arpeggios: ArpegRef[];
|
|
761
|
+
pedals: PedalMark[]; // pedal marks on notes inside the tuplet (independent events)
|
|
762
|
+
fingerings: FingerRef[]; // fingering marks on notes inside the tuplet
|
|
763
|
+
markups: MarkupRef[]; // text directions (markup) on notes inside the tuplet
|
|
734
764
|
endingClef?: string; // Updated clef name if changed inside the tuplet
|
|
735
765
|
}
|
|
736
766
|
|
|
@@ -766,6 +796,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
766
796
|
|
|
767
797
|
// Collect control event info from notes inside tuplet
|
|
768
798
|
let firstNoteId: string | null = null;
|
|
799
|
+
let lastNoteId: string | null = null; // last note/rest id — anchors an ottava span end
|
|
769
800
|
const slurStarts: string[] = [];
|
|
770
801
|
const slurEnds: string[] = [];
|
|
771
802
|
const dynamics: DynamRef[] = [];
|
|
@@ -774,6 +805,9 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
774
805
|
const mordents: MordentRef[] = [];
|
|
775
806
|
const turns: TurnRef[] = [];
|
|
776
807
|
const arpeggios: ArpegRef[] = [];
|
|
808
|
+
const pedals: PedalMark[] = [];
|
|
809
|
+
const fingerings: FingerRef[] = [];
|
|
810
|
+
const markups: MarkupRef[] = [];
|
|
777
811
|
|
|
778
812
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
779
813
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
@@ -802,18 +836,27 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
802
836
|
xml += result.xml;
|
|
803
837
|
|
|
804
838
|
if (!firstNoteId) firstNoteId = result.elementId;
|
|
839
|
+
lastNoteId = result.elementId;
|
|
805
840
|
|
|
806
841
|
// Collect slur info
|
|
807
842
|
if (result.slurStart) slurStarts.push(result.elementId);
|
|
808
843
|
if (result.slurEnd) slurEnds.push(result.elementId);
|
|
809
844
|
|
|
810
845
|
// Collect other control events
|
|
811
|
-
if (result.
|
|
846
|
+
if (result.dynamics) for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
|
|
812
847
|
if (result.fermata) fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
|
|
813
848
|
if (result.trill) trills.push({ startid: result.elementId });
|
|
814
849
|
if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
|
|
815
850
|
if (result.turn) turns.push({ startid: result.elementId });
|
|
816
851
|
if (result.arpeggio) arpeggios.push({ plist: result.elementId });
|
|
852
|
+
if (result.pedals) for (const dir of result.pedals) pedals.push({ startId: result.elementId, dir });
|
|
853
|
+
// Fingerings and text directions (markup) on inner notes are control
|
|
854
|
+
// events that attach by id — collect them so the layer loop can emit
|
|
855
|
+
// <fing>/<dir> for them (previously silently dropped inside tuplets).
|
|
856
|
+
for (const fing of result.fingerings)
|
|
857
|
+
fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
|
|
858
|
+
for (const mkup of result.markups)
|
|
859
|
+
markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
|
|
817
860
|
|
|
818
861
|
// Close beam if this note ends a beam group
|
|
819
862
|
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
@@ -822,7 +865,10 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
822
865
|
}
|
|
823
866
|
} else if (e.type === 'rest') {
|
|
824
867
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
825
|
-
|
|
868
|
+
const restResult = restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
869
|
+
xml += restResult.xml;
|
|
870
|
+
lastNoteId = restResult.elementId;
|
|
871
|
+
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
826
872
|
} else if (e.type === 'context') {
|
|
827
873
|
const ctx = e as ContextChange;
|
|
828
874
|
if (ctx.clef && ctx.clef !== activeClef) {
|
|
@@ -844,7 +890,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
844
890
|
}
|
|
845
891
|
|
|
846
892
|
xml += `${indent}</tuplet>\n`;
|
|
847
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
893
|
+
return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
848
894
|
};
|
|
849
895
|
|
|
850
896
|
|
|
@@ -997,7 +1043,7 @@ interface SlurSpan {
|
|
|
997
1043
|
|
|
998
1044
|
// Tie state for cross-measure ties - maps staff:layer to pending pitches
|
|
999
1045
|
type TieState = Record<string, Pitch[]>;
|
|
1000
|
-
type SlurState = Record<string, string
|
|
1046
|
+
type SlurState = Record<string, string[]>; // voice key -> open slur startIds (stack, oldest first)
|
|
1001
1047
|
type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
|
|
1002
1048
|
|
|
1003
1049
|
// Pending octave span for cross-measure continuation
|
|
@@ -1032,7 +1078,7 @@ interface LayerResult {
|
|
|
1032
1078
|
barlines: BarlineRef[];
|
|
1033
1079
|
markups: MarkupRef[];
|
|
1034
1080
|
pendingTiePitches: Pitch[]; // For cross-measure tie tracking
|
|
1035
|
-
pendingSlur: string
|
|
1081
|
+
pendingSlur: string[]; // For cross-measure slur tracking (all open slur startIds)
|
|
1036
1082
|
pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
|
|
1037
1083
|
pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
|
|
1038
1084
|
ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
|
|
@@ -1071,7 +1117,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
|
|
|
1071
1117
|
};
|
|
1072
1118
|
|
|
1073
1119
|
// Encode a layer (voice)
|
|
1074
|
-
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef,
|
|
1120
|
+
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlurs: string[] = [], initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
|
|
1075
1121
|
const layerId = generateId("layer");
|
|
1076
1122
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
1077
1123
|
|
|
@@ -1081,9 +1127,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1081
1127
|
// Track current clef to only emit changes
|
|
1082
1128
|
let currentClef: Clef | undefined = initialClef;
|
|
1083
1129
|
|
|
1084
|
-
// Track hairpin spans
|
|
1130
|
+
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
1131
|
+
// flattened layer stream can interleave overlapping/cross-staff hairpins
|
|
1132
|
+
// (e.g. a crescendo starting before the previous one ended), and a single slot
|
|
1133
|
+
// would silently overwrite the earlier one. Seed with any hairpin still open
|
|
1134
|
+
// from the previous measure (cross-measure carry).
|
|
1085
1135
|
const hairpins: HairpinSpan[] = [];
|
|
1086
|
-
|
|
1136
|
+
const openHairpins: { form: 'cres' | 'dim'; startId: string }[] = initialHairpin ? [initialHairpin] : [];
|
|
1087
1137
|
|
|
1088
1138
|
// Track pedal marks (each is independent, not paired spans)
|
|
1089
1139
|
const pedals: PedalMark[] = [];
|
|
@@ -1098,9 +1148,48 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1098
1148
|
let lastNoteId: string | null = null; // Track last note id for ending ottava spans
|
|
1099
1149
|
let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
|
|
1100
1150
|
|
|
1101
|
-
//
|
|
1151
|
+
// ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
|
|
1152
|
+
// Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
|
|
1153
|
+
// ottava span running over a tuplet run (ubiquitous in etudes) or over rests
|
|
1154
|
+
// was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
|
|
1155
|
+
// helpers let any event type anchor a span. Mirror the note-case ordering:
|
|
1156
|
+
// the caller updates lastNoteId to the event's first emitted id, THEN calls
|
|
1157
|
+
// these.
|
|
1158
|
+
// Apply a pending ottava's pitch shift before the event's notes are encoded.
|
|
1159
|
+
const applyPendingOttavaShift = (): void => {
|
|
1160
|
+
if (pendingOttava !== null && pendingOttava !== 0) currentOttavaShift = pendingOttava;
|
|
1161
|
+
};
|
|
1162
|
+
// Extend a carried (endToken) span's end to this event's last emitted id.
|
|
1163
|
+
const extendOttavaEnd = (lastId: string): void => {
|
|
1164
|
+
if (currentOctave?.endToken) octaveEndReplacements[currentOctave.endToken] = lastId;
|
|
1165
|
+
};
|
|
1166
|
+
// Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
|
|
1167
|
+
// closing any open span of a different value first; or resolve a continued span.
|
|
1168
|
+
const applyOttavaAnchor = (anchorId: string): void => {
|
|
1169
|
+
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1170
|
+
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
1171
|
+
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
1172
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
1173
|
+
octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
|
|
1174
|
+
currentOctave = null;
|
|
1175
|
+
}
|
|
1176
|
+
if (!currentOctave) currentOctave = { dis, disPlace, startId: anchorId };
|
|
1177
|
+
pendingOttava = null;
|
|
1178
|
+
} else if (currentOctave?.continued) {
|
|
1179
|
+
if (!currentOctave.endToken) currentOctave.startId = anchorId;
|
|
1180
|
+
currentOctave.continued = false;
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
1185
|
+
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
1186
|
+
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
1187
|
+
// open overwrote it and the span was lost. Use a STACK and pair each end to the
|
|
1188
|
+
// most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
|
|
1189
|
+
// as a view of the stack top for the existing cross-measure carry plumbing.
|
|
1102
1190
|
const slurs: SlurSpan[] = [];
|
|
1103
|
-
|
|
1191
|
+
const openSlurs: { startId: string }[] = initialSlurs.map(startId => ({ startId }));
|
|
1192
|
+
let currentSlur: { startId: string } | null = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1104
1193
|
|
|
1105
1194
|
// Track arpeggio refs
|
|
1106
1195
|
const arpeggios: ArpegRef[] = [];
|
|
@@ -1197,9 +1286,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1197
1286
|
const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
|
|
1198
1287
|
|
|
1199
1288
|
// If there's a pending ottava, apply it BEFORE encoding the note
|
|
1200
|
-
|
|
1201
|
-
currentOttavaShift = pendingOttava; // Apply the shift for this note
|
|
1202
|
-
}
|
|
1289
|
+
applyPendingOttavaShift();
|
|
1203
1290
|
|
|
1204
1291
|
// For cross-staff notation: set note's staff to currentStaff if different from voice.staff
|
|
1205
1292
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
@@ -1209,9 +1296,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1209
1296
|
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1210
1297
|
xml += result.xml;
|
|
1211
1298
|
lastNoteId = result.elementId;
|
|
1212
|
-
|
|
1213
|
-
octaveEndReplacements[currentOctave.endToken] = result.elementId;
|
|
1214
|
-
}
|
|
1299
|
+
extendOttavaEnd(result.elementId);
|
|
1215
1300
|
|
|
1216
1301
|
// Flush any pending markups onto this note
|
|
1217
1302
|
flushPendingMarkups(result.elementId);
|
|
@@ -1220,32 +1305,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1220
1305
|
flushPendingDynamics(result.elementId);
|
|
1221
1306
|
|
|
1222
1307
|
// If there's a pending ottava, start the span on this note
|
|
1223
|
-
|
|
1224
|
-
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
1225
|
-
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
1226
|
-
// Close existing span first if it has a different value
|
|
1227
|
-
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
1228
|
-
if (lastNoteId) {
|
|
1229
|
-
octaves.push({
|
|
1230
|
-
dis: currentOctave.dis,
|
|
1231
|
-
disPlace: currentOctave.disPlace,
|
|
1232
|
-
startId: currentOctave.startId,
|
|
1233
|
-
endId: lastNoteId,
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
currentOctave = null;
|
|
1237
|
-
}
|
|
1238
|
-
// Start new span if we don't already have one with the same value
|
|
1239
|
-
if (!currentOctave) {
|
|
1240
|
-
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
1241
|
-
}
|
|
1242
|
-
pendingOttava = null;
|
|
1243
|
-
} else if (currentOctave?.continued) {
|
|
1244
|
-
if (!currentOctave.endToken) {
|
|
1245
|
-
currentOctave.startId = result.elementId;
|
|
1246
|
-
}
|
|
1247
|
-
currentOctave.continued = false;
|
|
1248
|
-
}
|
|
1308
|
+
applyOttavaAnchor(result.elementId);
|
|
1249
1309
|
|
|
1250
1310
|
// Update pending tie pitches
|
|
1251
1311
|
if (result.hasTieStart) {
|
|
@@ -1256,38 +1316,48 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1256
1316
|
|
|
1257
1317
|
// Track hairpin spans
|
|
1258
1318
|
if (result.hairpin === 'crescStart') {
|
|
1259
|
-
|
|
1319
|
+
openHairpins.push({ form: 'cres', startId: result.elementId });
|
|
1260
1320
|
} else if (result.hairpin === 'dimStart') {
|
|
1261
|
-
|
|
1262
|
-
} else if (result.hairpin === '
|
|
1321
|
+
openHairpins.push({ form: 'dim', startId: result.elementId });
|
|
1322
|
+
} else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
|
|
1323
|
+
const endForm: 'cres' | 'dim' = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
|
|
1324
|
+
// Close the most-recent open hairpin of the matching form; if none
|
|
1325
|
+
// matches (interleaved/malformed input), fall back to the newest open.
|
|
1326
|
+
let idx = -1;
|
|
1327
|
+
for (let i = openHairpins.length - 1; i >= 0; i--) {
|
|
1328
|
+
if (openHairpins[i].form === endForm) { idx = i; break; }
|
|
1329
|
+
}
|
|
1330
|
+
if (idx < 0) idx = openHairpins.length - 1;
|
|
1331
|
+
const open = openHairpins.splice(idx, 1)[0];
|
|
1263
1332
|
hairpins.push({
|
|
1264
|
-
form:
|
|
1265
|
-
startId:
|
|
1333
|
+
form: open.form,
|
|
1334
|
+
startId: open.startId,
|
|
1266
1335
|
endId: result.elementId,
|
|
1267
1336
|
});
|
|
1268
|
-
currentHairpin = null;
|
|
1269
1337
|
}
|
|
1270
1338
|
|
|
1271
|
-
// Track pedal marks (each is independent
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
}
|
|
1339
|
+
// Track pedal marks (each is independent; a note may carry several,
|
|
1340
|
+
// e.g. an up+down pedal bounce at the same beat).
|
|
1341
|
+
if (result.pedals) {
|
|
1342
|
+
for (const dir of result.pedals) {
|
|
1343
|
+
pedals.push({ startId: result.elementId, dir });
|
|
1344
|
+
}
|
|
1277
1345
|
}
|
|
1278
1346
|
|
|
1279
1347
|
// Track slur spans - end must be processed before start
|
|
1280
|
-
// in case a note ends one slur and starts another
|
|
1281
|
-
|
|
1348
|
+
// in case a note ends one slur and starts another.
|
|
1349
|
+
// Pair an end to the most-recent open slur (LIFO).
|
|
1350
|
+
if (result.slurEnd && openSlurs.length > 0) {
|
|
1351
|
+
const open = openSlurs.pop()!;
|
|
1282
1352
|
slurs.push({
|
|
1283
|
-
startId:
|
|
1353
|
+
startId: open.startId,
|
|
1284
1354
|
endId: result.elementId,
|
|
1285
1355
|
});
|
|
1286
|
-
currentSlur = null;
|
|
1287
1356
|
}
|
|
1288
1357
|
if (result.slurStart) {
|
|
1289
|
-
|
|
1358
|
+
openSlurs.push({ startId: result.elementId });
|
|
1290
1359
|
}
|
|
1360
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1291
1361
|
|
|
1292
1362
|
// Track arpeggio refs
|
|
1293
1363
|
if (result.arpeggio) {
|
|
@@ -1313,8 +1383,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1313
1383
|
if (result.turn) {
|
|
1314
1384
|
turns.push({ startid: result.elementId });
|
|
1315
1385
|
}
|
|
1316
|
-
if (result.
|
|
1317
|
-
dynamics.push({ startid: result.elementId, label
|
|
1386
|
+
if (result.dynamics) {
|
|
1387
|
+
for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
|
|
1318
1388
|
}
|
|
1319
1389
|
// Track fingerings
|
|
1320
1390
|
for (const fing of result.fingerings) {
|
|
@@ -1333,21 +1403,38 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1333
1403
|
case 'rest': {
|
|
1334
1404
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1335
1405
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1406
|
+
// A pending ottava shift applies to a rest's gestural octave too.
|
|
1407
|
+
applyPendingOttavaShift();
|
|
1336
1408
|
const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1337
1409
|
xml += restResult.xml;
|
|
1410
|
+
lastNoteId = restResult.elementId;
|
|
1411
|
+
// An ottava span may open on, run through, or close at a rest (e.g. a
|
|
1412
|
+
// span that starts just before LH rests). Anchor it like a note.
|
|
1413
|
+
extendOttavaEnd(restResult.elementId);
|
|
1414
|
+
applyOttavaAnchor(restResult.elementId);
|
|
1415
|
+
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1416
|
+
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1338
1417
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1339
1418
|
flushPendingMarkups(restResult.elementId);
|
|
1340
1419
|
flushPendingDynamics(restResult.elementId);
|
|
1341
|
-
lastNoteId = restResult.elementId;
|
|
1342
1420
|
break;
|
|
1343
1421
|
}
|
|
1344
1422
|
case 'tuplet':
|
|
1345
1423
|
case 'times': {
|
|
1346
1424
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1347
1425
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1426
|
+
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1427
|
+
applyPendingOttavaShift();
|
|
1348
1428
|
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1349
1429
|
xml += tupletResult.xml;
|
|
1350
1430
|
|
|
1431
|
+
// An ottava span can open on, run through, or close at a tuplet (common
|
|
1432
|
+
// in etudes where the 8va covers a whole tuplet run). Anchor the span on
|
|
1433
|
+
// the tuplet's FIRST note and let its LAST note close/extend it — without
|
|
1434
|
+
// this the entire span was dropped (start/close lived only in case 'note').
|
|
1435
|
+
if (tupletResult.firstNoteId) applyOttavaAnchor(tupletResult.firstNoteId);
|
|
1436
|
+
if (tupletResult.lastNoteId) extendOttavaEnd(tupletResult.lastNoteId);
|
|
1437
|
+
|
|
1351
1438
|
// Propagate clef change from inside the tuplet to the parent tracker
|
|
1352
1439
|
if (tupletResult.endingClef) {
|
|
1353
1440
|
currentClef = tupletResult.endingClef as Clef;
|
|
@@ -1357,24 +1444,25 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1357
1444
|
if (tupletResult.firstNoteId) {
|
|
1358
1445
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1359
1446
|
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1360
|
-
lastNoteId = tupletResult.firstNoteId;
|
|
1447
|
+
lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
|
|
1361
1448
|
}
|
|
1362
1449
|
|
|
1363
|
-
// Process slur ends first (to close
|
|
1450
|
+
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
1364
1451
|
for (const endId of tupletResult.slurEnds) {
|
|
1365
|
-
if (
|
|
1452
|
+
if (openSlurs.length > 0) {
|
|
1453
|
+
const open = openSlurs.pop()!;
|
|
1366
1454
|
slurs.push({
|
|
1367
|
-
startId:
|
|
1455
|
+
startId: open.startId,
|
|
1368
1456
|
endId: endId,
|
|
1369
1457
|
});
|
|
1370
|
-
currentSlur = null;
|
|
1371
1458
|
}
|
|
1372
1459
|
}
|
|
1373
1460
|
|
|
1374
1461
|
// Then process slur starts (to open new slurs)
|
|
1375
1462
|
for (const startId of tupletResult.slurStarts) {
|
|
1376
|
-
|
|
1463
|
+
openSlurs.push({ startId });
|
|
1377
1464
|
}
|
|
1465
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1378
1466
|
|
|
1379
1467
|
// Collect other control events from tuplet
|
|
1380
1468
|
dynamics.push(...tupletResult.dynamics);
|
|
@@ -1383,6 +1471,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1383
1471
|
mordents.push(...tupletResult.mordents);
|
|
1384
1472
|
turns.push(...tupletResult.turns);
|
|
1385
1473
|
arpeggios.push(...tupletResult.arpeggios);
|
|
1474
|
+
pedals.push(...tupletResult.pedals);
|
|
1475
|
+
fingerings.push(...tupletResult.fingerings);
|
|
1476
|
+
markups.push(...tupletResult.markups);
|
|
1386
1477
|
|
|
1387
1478
|
break;
|
|
1388
1479
|
}
|
|
@@ -1415,9 +1506,22 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1415
1506
|
}
|
|
1416
1507
|
currentOctave = null;
|
|
1417
1508
|
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1509
|
+
} else if (currentOctave && currentOctave.emitted) {
|
|
1510
|
+
// Stop landed at measure START (no note yet in this layer) but the
|
|
1511
|
+
// span was carried+emitted in a PREVIOUS measure: it genuinely ends
|
|
1512
|
+
// here. Its deferred endToken was already resolved to the correct
|
|
1513
|
+
// last note by extendOttavaEnd as the span ran through prior measures
|
|
1514
|
+
// (or by the staff-level carry's endFallbackId), so DON'T rewrite it
|
|
1515
|
+
// here — just clear the slot and mark closed, so a later same-value
|
|
1516
|
+
// ottava opens a NEW span instead of folding into this one.
|
|
1517
|
+
// Without this, sequential cross-measure spans collapse into one
|
|
1518
|
+
// (No.57: 18 spans → 8).
|
|
1519
|
+
currentOctave = null;
|
|
1520
|
+
ottavaExplicitlyClosed = true;
|
|
1418
1521
|
}
|
|
1419
|
-
// Note: if no lastNoteId (e.g., at
|
|
1420
|
-
//
|
|
1522
|
+
// Note: if no lastNoteId and not yet emitted (e.g., open+close at
|
|
1523
|
+
// measure start with no notes), keep currentOctave alive — it may be
|
|
1524
|
+
// continued by a subsequent ottava command with the same value.
|
|
1421
1525
|
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1422
1526
|
} else {
|
|
1423
1527
|
// Check if this continues an existing span (same value)
|
|
@@ -1524,8 +1628,24 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1524
1628
|
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
|
|
1525
1629
|
: null;
|
|
1526
1630
|
|
|
1631
|
+
// Resolve hairpins still open at layer end. The cross-measure carry supports a
|
|
1632
|
+
// single pending hairpin, so keep the OLDEST open span (bottom of stack — most
|
|
1633
|
+
// likely to legitimately continue into the next measure) as pendingHairpin, and
|
|
1634
|
+
// flush the rest here, ending them at the last note so they aren't dropped
|
|
1635
|
+
// (MusicXML files routinely leave wedges unclosed). Without a last note id there
|
|
1636
|
+
// is nothing to attach to, so those are unavoidably dropped.
|
|
1637
|
+
let pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null = null;
|
|
1638
|
+
if (openHairpins.length > 0) {
|
|
1639
|
+
pendingHairpin = openHairpins[0];
|
|
1640
|
+
for (let i = 1; i < openHairpins.length; i++) {
|
|
1641
|
+
if (lastNoteId) {
|
|
1642
|
+
hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1527
1647
|
xml += `${indent}</layer>\n`;
|
|
1528
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur:
|
|
1648
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1529
1649
|
};
|
|
1530
1650
|
|
|
1531
1651
|
// Staff result type
|
|
@@ -1591,7 +1711,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1591
1711
|
const layerN = vi + 1;
|
|
1592
1712
|
const tieKey = `${staffN}-${layerN}`;
|
|
1593
1713
|
const initialTies = tieState[tieKey] || [];
|
|
1594
|
-
const initialSlur = slurState[tieKey] ||
|
|
1714
|
+
const initialSlur = slurState[tieKey] || [];
|
|
1595
1715
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1596
1716
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1597
1717
|
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
@@ -1617,9 +1737,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1617
1737
|
pendingTies[tieKey] = result.pendingTiePitches;
|
|
1618
1738
|
}
|
|
1619
1739
|
// Track pending slurs for this layer
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1740
|
+
// Always record (even empty) so a measure that closed all its slurs
|
|
1741
|
+
// clears the carried stack rather than leaving a stale open slur.
|
|
1742
|
+
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1623
1743
|
// Track pending hairpins for this layer
|
|
1624
1744
|
if (result.pendingHairpin) {
|
|
1625
1745
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
@@ -1783,8 +1903,9 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1783
1903
|
|
|
1784
1904
|
// Encode a measure
|
|
1785
1905
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1786
|
-
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}): string => {
|
|
1906
|
+
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}, measureIds?: string[]): string => {
|
|
1787
1907
|
const measureId = generateId("measure");
|
|
1908
|
+
if (measureIds) measureIds.push(measureId);
|
|
1788
1909
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1789
1910
|
const allHairpins: HairpinSpan[] = [];
|
|
1790
1911
|
const allPedals: PedalMark[] = [];
|
|
@@ -2617,6 +2738,33 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2617
2738
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2618
2739
|
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2619
2740
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2741
|
+
// Splice point for an <expansion> element (must be the FIRST child of
|
|
2742
|
+
// <section>, but its plist needs the measure ids collected during the loop
|
|
2743
|
+
// below — so remember where to insert it and build it afterwards).
|
|
2744
|
+
const expansionInsertPos = mei.length;
|
|
2745
|
+
const measureIds: string[] = [];
|
|
2746
|
+
|
|
2747
|
+
// Volta (1st/2nd-ending) house brackets. The measure-layout directive already
|
|
2748
|
+
// encodes which measures form each alternate; <expansion> replays them but does
|
|
2749
|
+
// NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
|
|
2750
|
+
// span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
|
|
2751
|
+
// own MusicXML import does the same). Keyed by the 1-based measure index where
|
|
2752
|
+
// an ending opens / closes.
|
|
2753
|
+
const endingOpenAt = new Map<number, number>(); // measure index → ending number
|
|
2754
|
+
const endingCloseAt = new Set<number>(); // measure index after which </ending> closes
|
|
2755
|
+
if (doc.metadata?.measureLayout) {
|
|
2756
|
+
try {
|
|
2757
|
+
const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2758
|
+
for (const s of spans) {
|
|
2759
|
+
// Only wrap a contiguous ascending run (a house is always adjacent bars).
|
|
2760
|
+
const lo = s.measures[0];
|
|
2761
|
+
const hi = s.measures[s.measures.length - 1];
|
|
2762
|
+
const contiguous = s.measures.every((m, k) => m === lo + k);
|
|
2763
|
+
if (contiguous) { endingOpenAt.set(lo, s.number); endingCloseAt.add(hi); }
|
|
2764
|
+
}
|
|
2765
|
+
} catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2766
|
+
}
|
|
2767
|
+
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2620
2768
|
|
|
2621
2769
|
// Track tie state across measures for cross-measure ties
|
|
2622
2770
|
const tieState: TieState = {};
|
|
@@ -2727,8 +2875,53 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2727
2875
|
mei += `${sd}</scoreDef>\n`;
|
|
2728
2876
|
}
|
|
2729
2877
|
}
|
|
2730
|
-
|
|
2878
|
+
const mIndex = mi + 1;
|
|
2879
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2880
|
+
// Open an <ending> house bracket if this measure starts an alternate.
|
|
2881
|
+
const openNum = endingOpenAt.get(mIndex);
|
|
2882
|
+
if (openNum !== undefined && !endingOpen) {
|
|
2883
|
+
const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
|
|
2884
|
+
mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
|
|
2885
|
+
endingOpen = true;
|
|
2886
|
+
}
|
|
2887
|
+
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2888
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2889
|
+
// Close the <ending> after the measure that ends this alternate.
|
|
2890
|
+
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2891
|
+
mei += `${mIndent}</ending>\n`;
|
|
2892
|
+
endingOpen = false;
|
|
2893
|
+
}
|
|
2731
2894
|
});
|
|
2895
|
+
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2896
|
+
if (endingOpen) { mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`; endingOpen = false; }
|
|
2897
|
+
|
|
2898
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2899
|
+
// directive ([measures "..."]). Verovio's expand="…" consumes its plist to
|
|
2900
|
+
// unfold repeats for MIDI/time-map output. Inserted as the first child of
|
|
2901
|
+
// <section>; ids were collected in the measure loop above.
|
|
2902
|
+
if (doc.metadata?.measureLayout) {
|
|
2903
|
+
try {
|
|
2904
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2905
|
+
const order = expandMeasureLayout(layout);
|
|
2906
|
+
const refs: string[] = [];
|
|
2907
|
+
for (const idx of order) {
|
|
2908
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2909
|
+
if (id === undefined) {
|
|
2910
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2911
|
+
continue;
|
|
2912
|
+
}
|
|
2913
|
+
refs.push(`#${id}`);
|
|
2914
|
+
}
|
|
2915
|
+
if (refs.length > 0) {
|
|
2916
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2917
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2918
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
catch (err) {
|
|
2922
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${(err as Error).message}`);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2732
2925
|
|
|
2733
2926
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2734
2927
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|