@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.
@@ -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 || mark.type === HairpinType.diminuendoEnd) {
485
- result.hairpin = 'end';
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.pedal = 'down';
501
+ result.pedals.push('down');
491
502
  } else if (mark.type === PedalType.sustainOff) {
492
- result.pedal = 'up';
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.dynamic) dynamics.push({ startid: result.elementId, label: result.dynamic });
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
- xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
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 | null>; // voice key -> pending slur startId
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 | null; // For cross-measure slur tracking (startId)
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, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
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
- let currentHairpin: { form: 'cres' | 'dim'; startId: string } | null = initialHairpin;
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
- // Track slur spans - slurs must be encoded as control events in MEI
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
- let currentSlur: { startId: string } | null = initialSlur ? { startId: initialSlur } : null;
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
- if (pendingOttava !== null && pendingOttava !== 0) {
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
- if (currentOctave?.endToken) {
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
- if (pendingOttava !== null && pendingOttava !== 0) {
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
- currentHairpin = { form: 'cres', startId: result.elementId };
1319
+ openHairpins.push({ form: 'cres', startId: result.elementId });
1260
1320
  } else if (result.hairpin === 'dimStart') {
1261
- currentHairpin = { form: 'dim', startId: result.elementId };
1262
- } else if (result.hairpin === 'end' && currentHairpin) {
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: currentHairpin.form,
1265
- startId: currentHairpin.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
- if (result.pedal === 'down' || result.pedal === 'up') {
1273
- pedals.push({
1274
- startId: result.elementId,
1275
- dir: result.pedal,
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
- if (result.slurEnd && currentSlur) {
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: currentSlur.startId,
1353
+ startId: open.startId,
1284
1354
  endId: result.elementId,
1285
1355
  });
1286
- currentSlur = null;
1287
1356
  }
1288
1357
  if (result.slurStart) {
1289
- currentSlur = { startId: result.elementId };
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.dynamic) {
1317
- dynamics.push({ startid: result.elementId, label: result.dynamic });
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 any pending slurs from before this tuplet)
1450
+ // Process slur ends first (to close open slurs, LIFO), then starts.
1364
1451
  for (const endId of tupletResult.slurEnds) {
1365
- if (currentSlur) {
1452
+ if (openSlurs.length > 0) {
1453
+ const open = openSlurs.pop()!;
1366
1454
  slurs.push({
1367
- startId: currentSlur.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
- currentSlur = { startId };
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 measure start), keep currentOctave alive
1420
- // It may be continued by a subsequent ottava command with the same value
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: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
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] || null;
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
- if (result.pendingSlur) {
1621
- pendingSlurs[tieKey] = result.pendingSlur;
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
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
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`;