@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.
@@ -1,6 +1,7 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
2
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
3
  import { gmProgramOf } from "./gmInstruments.js";
4
+ import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout.js";
4
5
  // MEI key signatures: positive = sharps, negative = flats
5
6
  const KEY_SIGS = {
6
7
  0: "0",
@@ -333,8 +334,10 @@ const extractMarkOptions = (marks) => {
333
334
  beamStart: false,
334
335
  beamEnd: false,
335
336
  dynamic: undefined,
337
+ dynamics: [],
336
338
  hairpin: undefined,
337
339
  pedal: undefined,
340
+ pedals: [],
338
341
  tremolo: undefined,
339
342
  fingerings: [],
340
343
  navigation: undefined,
@@ -380,7 +383,8 @@ const extractMarkOptions = (marks) => {
380
383
  case 'dynamic': {
381
384
  const dynStr = DYNAMIC_MAP[mark.type];
382
385
  if (dynStr) {
383
- result.dynamic = dynStr;
386
+ result.dynamic = dynStr; // kept for back-compat (first dynamic)
387
+ result.dynamics.push(dynStr);
384
388
  }
385
389
  break;
386
390
  }
@@ -391,16 +395,22 @@ const extractMarkOptions = (marks) => {
391
395
  else if (mark.type === HairpinType.diminuendoStart) {
392
396
  result.hairpin = 'dimStart';
393
397
  }
394
- else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
395
- result.hairpin = 'end';
398
+ else if (mark.type === HairpinType.crescendoEnd) {
399
+ result.hairpin = 'crescEnd';
400
+ }
401
+ else if (mark.type === HairpinType.diminuendoEnd) {
402
+ result.hairpin = 'dimEnd';
396
403
  }
397
404
  break;
398
405
  case 'pedal':
406
+ // A note can carry more than one pedal mark (a pedal "bounce": an up
407
+ // to release the previous pedal and an immediate down to re-pedal on
408
+ // the same note), so collect ALL of them rather than keep one scalar.
399
409
  if (mark.type === PedalType.sustainOn) {
400
- result.pedal = 'down';
410
+ result.pedals.push('down');
401
411
  }
402
412
  else if (mark.type === PedalType.sustainOff) {
403
- result.pedal = 'up';
413
+ result.pedals.push('up');
404
414
  }
405
415
  break;
406
416
  case 'tie':
@@ -489,6 +499,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
489
499
  elementId: noteId,
490
500
  hairpin: markOptions.hairpin,
491
501
  pedal: markOptions.pedal,
502
+ pedals: markOptions.pedals,
492
503
  hasTieStart: markOptions.tieStart,
493
504
  pitches: event.pitches,
494
505
  arpeggio: markOptions.arpeggio,
@@ -497,6 +508,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
497
508
  mordent: markOptions.mordent,
498
509
  turn: markOptions.turn,
499
510
  dynamic: markOptions.dynamic,
511
+ dynamics: markOptions.dynamics,
500
512
  slurStart: markOptions.slurStart,
501
513
  slurEnd: markOptions.slurEnd,
502
514
  fingerings: markOptions.fingerings,
@@ -549,6 +561,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
549
561
  elementId: chordId,
550
562
  hairpin: markOptions.hairpin,
551
563
  pedal: markOptions.pedal,
564
+ pedals: markOptions.pedals,
552
565
  hasTieStart: markOptions.tieStart,
553
566
  pitches: event.pitches,
554
567
  arpeggio: markOptions.arpeggio,
@@ -557,6 +570,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
557
570
  mordent: markOptions.mordent,
558
571
  turn: markOptions.turn,
559
572
  dynamic: markOptions.dynamic,
573
+ dynamics: markOptions.dynamics,
560
574
  slurStart: markOptions.slurStart,
561
575
  slurEnd: markOptions.slurEnd,
562
576
  fingerings: markOptions.fingerings,
@@ -574,6 +588,15 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
574
588
  // Cross-staff attribute
575
589
  if (crossStaff)
576
590
  attrs += ` staff="${crossStaff}"`;
591
+ // A rest may carry a fermata (held silence). Surface it so the layer loop can
592
+ // emit a <fermata> control event referencing this rest's id.
593
+ let fermata;
594
+ if (event.marks) {
595
+ for (const mk of event.marks) {
596
+ if (mk.markType === 'ornament' && mk.type === 'fermata')
597
+ fermata = 'normal';
598
+ }
599
+ }
577
600
  // Pitched rest (positioned at specific pitch)
578
601
  if (event.pitch) {
579
602
  const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
@@ -581,14 +604,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
581
604
  }
582
605
  // Space rest (invisible)
583
606
  if (event.invisible) {
584
- return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
607
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
585
608
  }
586
609
  // Full measure rest
587
610
  if (event.fullMeasure) {
588
611
  const mRestId = generateId('mrest');
589
- return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
612
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
590
613
  }
591
- return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
614
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
592
615
  };
593
616
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
594
617
  // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
@@ -619,6 +642,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
619
642
  const effectiveStaff = currentStaff ?? layerStaff;
620
643
  // Collect control event info from notes inside tuplet
621
644
  let firstNoteId = null;
645
+ let lastNoteId = null; // last note/rest id — anchors an ottava span end
622
646
  const slurStarts = [];
623
647
  const slurEnds = [];
624
648
  const dynamics = [];
@@ -627,6 +651,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
627
651
  const mordents = [];
628
652
  const turns = [];
629
653
  const arpeggios = [];
654
+ const pedals = [];
655
+ const fingerings = [];
656
+ const markups = [];
630
657
  // Handle internal beam groups: if notes have manual beam marks, respect them
631
658
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
632
659
  let beamOpen = false;
@@ -650,14 +677,16 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
650
677
  xml += result.xml;
651
678
  if (!firstNoteId)
652
679
  firstNoteId = result.elementId;
680
+ lastNoteId = result.elementId;
653
681
  // Collect slur info
654
682
  if (result.slurStart)
655
683
  slurStarts.push(result.elementId);
656
684
  if (result.slurEnd)
657
685
  slurEnds.push(result.elementId);
658
686
  // Collect other control events
659
- if (result.dynamic)
660
- dynamics.push({ startid: result.elementId, label: result.dynamic });
687
+ if (result.dynamics)
688
+ for (const label of result.dynamics)
689
+ dynamics.push({ startid: result.elementId, label });
661
690
  if (result.fermata)
662
691
  fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
663
692
  if (result.trill)
@@ -668,6 +697,16 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
668
697
  turns.push({ startid: result.elementId });
669
698
  if (result.arpeggio)
670
699
  arpeggios.push({ plist: result.elementId });
700
+ if (result.pedals)
701
+ for (const dir of result.pedals)
702
+ pedals.push({ startId: result.elementId, dir });
703
+ // Fingerings and text directions (markup) on inner notes are control
704
+ // events that attach by id — collect them so the layer loop can emit
705
+ // <fing>/<dir> for them (previously silently dropped inside tuplets).
706
+ for (const fing of result.fingerings)
707
+ fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
708
+ for (const mkup of result.markups)
709
+ markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
671
710
  // Close beam if this note ends a beam group
672
711
  if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
673
712
  xml += `${baseIndent}</beam>\n`;
@@ -676,7 +715,11 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
676
715
  }
677
716
  else if (e.type === 'rest') {
678
717
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
679
- xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
718
+ const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
719
+ xml += restResult.xml;
720
+ lastNoteId = restResult.elementId;
721
+ if (restResult.fermata)
722
+ fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
680
723
  }
681
724
  else if (e.type === 'context') {
682
725
  const ctx = e;
@@ -697,7 +740,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
697
740
  xml += `${baseIndent}</beam>\n`;
698
741
  }
699
742
  xml += `${indent}</tuplet>\n`;
700
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
743
+ return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
701
744
  };
702
745
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
703
746
  const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
@@ -803,16 +846,20 @@ const getEventBeamMarks = (event) => {
803
846
  return { beamStart: false, beamEnd: false };
804
847
  };
805
848
  // Encode a layer (voice)
806
- const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
849
+ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
807
850
  const layerId = generateId("layer");
808
851
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
809
852
  let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
810
853
  const baseIndent = indent + ' ';
811
854
  // Track current clef to only emit changes
812
855
  let currentClef = initialClef;
813
- // Track hairpin spans
856
+ // Track hairpin spans. Use a stack of open hairpins (not a single slot): the
857
+ // flattened layer stream can interleave overlapping/cross-staff hairpins
858
+ // (e.g. a crescendo starting before the previous one ended), and a single slot
859
+ // would silently overwrite the earlier one. Seed with any hairpin still open
860
+ // from the previous measure (cross-measure carry).
814
861
  const hairpins = [];
815
- let currentHairpin = initialHairpin;
862
+ const openHairpins = initialHairpin ? [initialHairpin] : [];
816
863
  // Track pedal marks (each is independent, not paired spans)
817
864
  const pedals = [];
818
865
  // Track octave spans - initialize from previous measure if continuing
@@ -823,9 +870,52 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
823
870
  let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
824
871
  let lastNoteId = null; // Track last note id for ending ottava spans
825
872
  let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
826
- // Track slur spans - slurs must be encoded as control events in MEI
873
+ // ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
874
+ // Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
875
+ // ottava span running over a tuplet run (ubiquitous in etudes) or over rests
876
+ // was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
877
+ // helpers let any event type anchor a span. Mirror the note-case ordering:
878
+ // the caller updates lastNoteId to the event's first emitted id, THEN calls
879
+ // these.
880
+ // Apply a pending ottava's pitch shift before the event's notes are encoded.
881
+ const applyPendingOttavaShift = () => {
882
+ if (pendingOttava !== null && pendingOttava !== 0)
883
+ currentOttavaShift = pendingOttava;
884
+ };
885
+ // Extend a carried (endToken) span's end to this event's last emitted id.
886
+ const extendOttavaEnd = (lastId) => {
887
+ if (currentOctave?.endToken)
888
+ octaveEndReplacements[currentOctave.endToken] = lastId;
889
+ };
890
+ // Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
891
+ // closing any open span of a different value first; or resolve a continued span.
892
+ const applyOttavaAnchor = (anchorId) => {
893
+ if (pendingOttava !== null && pendingOttava !== 0) {
894
+ const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
895
+ const disPlace = pendingOttava > 0 ? 'above' : 'below';
896
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
897
+ octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
898
+ currentOctave = null;
899
+ }
900
+ if (!currentOctave)
901
+ currentOctave = { dis, disPlace, startId: anchorId };
902
+ pendingOttava = null;
903
+ }
904
+ else if (currentOctave?.continued) {
905
+ if (!currentOctave.endToken)
906
+ currentOctave.startId = anchorId;
907
+ currentOctave.continued = false;
908
+ }
909
+ };
910
+ // Track slur spans - slurs must be encoded as control events in MEI.
911
+ // A single slot can't hold the overlapping/concurrent slurs that piano writing
912
+ // produces (measured up to 3 open at once per voice); a new start while one was
913
+ // open overwrote it and the span was lost. Use a STACK and pair each end to the
914
+ // most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
915
+ // as a view of the stack top for the existing cross-measure carry plumbing.
827
916
  const slurs = [];
828
- let currentSlur = initialSlur ? { startId: initialSlur } : null;
917
+ const openSlurs = initialSlurs.map(startId => ({ startId }));
918
+ let currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
829
919
  // Track arpeggio refs
830
920
  const arpeggios = [];
831
921
  // Track ornament refs
@@ -908,9 +998,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
908
998
  // Check if this note should have tie="t" (matches pending tie)
909
999
  const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
910
1000
  // If there's a pending ottava, apply it BEFORE encoding the note
911
- if (pendingOttava !== null && pendingOttava !== 0) {
912
- currentOttavaShift = pendingOttava; // Apply the shift for this note
913
- }
1001
+ applyPendingOttavaShift();
914
1002
  // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
915
1003
  const effectiveNoteEvent = currentStaff !== voice.staff
916
1004
  ? { ...noteEvent, staff: currentStaff }
@@ -918,41 +1006,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
918
1006
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
919
1007
  xml += result.xml;
920
1008
  lastNoteId = result.elementId;
921
- if (currentOctave?.endToken) {
922
- octaveEndReplacements[currentOctave.endToken] = result.elementId;
923
- }
1009
+ extendOttavaEnd(result.elementId);
924
1010
  // Flush any pending markups onto this note
925
1011
  flushPendingMarkups(result.elementId);
926
1012
  // Flush any pending leading dynamics onto this note
927
1013
  flushPendingDynamics(result.elementId);
928
1014
  // If there's a pending ottava, start the span on this note
929
- if (pendingOttava !== null && pendingOttava !== 0) {
930
- const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
931
- const disPlace = pendingOttava > 0 ? 'above' : 'below';
932
- // Close existing span first if it has a different value
933
- if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
934
- if (lastNoteId) {
935
- octaves.push({
936
- dis: currentOctave.dis,
937
- disPlace: currentOctave.disPlace,
938
- startId: currentOctave.startId,
939
- endId: lastNoteId,
940
- });
941
- }
942
- currentOctave = null;
943
- }
944
- // Start new span if we don't already have one with the same value
945
- if (!currentOctave) {
946
- currentOctave = { dis, disPlace, startId: result.elementId };
947
- }
948
- pendingOttava = null;
949
- }
950
- else if (currentOctave?.continued) {
951
- if (!currentOctave.endToken) {
952
- currentOctave.startId = result.elementId;
953
- }
954
- currentOctave.continued = false;
955
- }
1015
+ applyOttavaAnchor(result.elementId);
956
1016
  // Update pending tie pitches
957
1017
  if (result.hasTieStart) {
958
1018
  pendingTiePitches = result.pitches;
@@ -962,38 +1022,52 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
962
1022
  }
963
1023
  // Track hairpin spans
964
1024
  if (result.hairpin === 'crescStart') {
965
- currentHairpin = { form: 'cres', startId: result.elementId };
1025
+ openHairpins.push({ form: 'cres', startId: result.elementId });
966
1026
  }
967
1027
  else if (result.hairpin === 'dimStart') {
968
- currentHairpin = { form: 'dim', startId: result.elementId };
969
- }
970
- else if (result.hairpin === 'end' && currentHairpin) {
1028
+ openHairpins.push({ form: 'dim', startId: result.elementId });
1029
+ }
1030
+ else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
1031
+ const endForm = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
1032
+ // Close the most-recent open hairpin of the matching form; if none
1033
+ // matches (interleaved/malformed input), fall back to the newest open.
1034
+ let idx = -1;
1035
+ for (let i = openHairpins.length - 1; i >= 0; i--) {
1036
+ if (openHairpins[i].form === endForm) {
1037
+ idx = i;
1038
+ break;
1039
+ }
1040
+ }
1041
+ if (idx < 0)
1042
+ idx = openHairpins.length - 1;
1043
+ const open = openHairpins.splice(idx, 1)[0];
971
1044
  hairpins.push({
972
- form: currentHairpin.form,
973
- startId: currentHairpin.startId,
1045
+ form: open.form,
1046
+ startId: open.startId,
974
1047
  endId: result.elementId,
975
1048
  });
976
- currentHairpin = null;
977
1049
  }
978
- // Track pedal marks (each is independent)
979
- if (result.pedal === 'down' || result.pedal === 'up') {
980
- pedals.push({
981
- startId: result.elementId,
982
- dir: result.pedal,
983
- });
1050
+ // Track pedal marks (each is independent; a note may carry several,
1051
+ // e.g. an up+down pedal bounce at the same beat).
1052
+ if (result.pedals) {
1053
+ for (const dir of result.pedals) {
1054
+ pedals.push({ startId: result.elementId, dir });
1055
+ }
984
1056
  }
985
1057
  // Track slur spans - end must be processed before start
986
- // in case a note ends one slur and starts another
987
- if (result.slurEnd && currentSlur) {
1058
+ // in case a note ends one slur and starts another.
1059
+ // Pair an end to the most-recent open slur (LIFO).
1060
+ if (result.slurEnd && openSlurs.length > 0) {
1061
+ const open = openSlurs.pop();
988
1062
  slurs.push({
989
- startId: currentSlur.startId,
1063
+ startId: open.startId,
990
1064
  endId: result.elementId,
991
1065
  });
992
- currentSlur = null;
993
1066
  }
994
1067
  if (result.slurStart) {
995
- currentSlur = { startId: result.elementId };
1068
+ openSlurs.push({ startId: result.elementId });
996
1069
  }
1070
+ currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
997
1071
  // Track arpeggio refs
998
1072
  if (result.arpeggio) {
999
1073
  arpeggios.push({ plist: result.elementId });
@@ -1017,8 +1091,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1017
1091
  if (result.turn) {
1018
1092
  turns.push({ startid: result.elementId });
1019
1093
  }
1020
- if (result.dynamic) {
1021
- dynamics.push({ startid: result.elementId, label: result.dynamic });
1094
+ if (result.dynamics) {
1095
+ for (const label of result.dynamics)
1096
+ dynamics.push({ startid: result.elementId, label });
1022
1097
  }
1023
1098
  // Track fingerings
1024
1099
  for (const fing of result.fingerings) {
@@ -1037,20 +1112,39 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1037
1112
  case 'rest': {
1038
1113
  // For cross-staff notation: pass staff number if different from voice's home staff
1039
1114
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1115
+ // A pending ottava shift applies to a rest's gestural octave too.
1116
+ applyPendingOttavaShift();
1040
1117
  const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1041
1118
  xml += restResult.xml;
1119
+ lastNoteId = restResult.elementId;
1120
+ // An ottava span may open on, run through, or close at a rest (e.g. a
1121
+ // span that starts just before LH rests). Anchor it like a note.
1122
+ extendOttavaEnd(restResult.elementId);
1123
+ applyOttavaAnchor(restResult.elementId);
1124
+ // Fermata over a rest (held silence) — emit as a control event on the rest.
1125
+ if (restResult.fermata)
1126
+ fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
1042
1127
  // A leading dynamic/markup attaches to the next event, which may be this rest
1043
1128
  flushPendingMarkups(restResult.elementId);
1044
1129
  flushPendingDynamics(restResult.elementId);
1045
- lastNoteId = restResult.elementId;
1046
1130
  break;
1047
1131
  }
1048
1132
  case 'tuplet':
1049
1133
  case 'times': {
1050
1134
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1051
1135
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1136
+ // A pending ottava shift applies to the tuplet's notes' written octave.
1137
+ applyPendingOttavaShift();
1052
1138
  const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1053
1139
  xml += tupletResult.xml;
1140
+ // An ottava span can open on, run through, or close at a tuplet (common
1141
+ // in etudes where the 8va covers a whole tuplet run). Anchor the span on
1142
+ // the tuplet's FIRST note and let its LAST note close/extend it — without
1143
+ // this the entire span was dropped (start/close lived only in case 'note').
1144
+ if (tupletResult.firstNoteId)
1145
+ applyOttavaAnchor(tupletResult.firstNoteId);
1146
+ if (tupletResult.lastNoteId)
1147
+ extendOttavaEnd(tupletResult.lastNoteId);
1054
1148
  // Propagate clef change from inside the tuplet to the parent tracker
1055
1149
  if (tupletResult.endingClef) {
1056
1150
  currentClef = tupletResult.endingClef;
@@ -1059,22 +1153,23 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1059
1153
  if (tupletResult.firstNoteId) {
1060
1154
  flushPendingMarkups(tupletResult.firstNoteId);
1061
1155
  flushPendingDynamics(tupletResult.firstNoteId);
1062
- lastNoteId = tupletResult.firstNoteId;
1156
+ lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
1063
1157
  }
1064
- // Process slur ends first (to close any pending slurs from before this tuplet)
1158
+ // Process slur ends first (to close open slurs, LIFO), then starts.
1065
1159
  for (const endId of tupletResult.slurEnds) {
1066
- if (currentSlur) {
1160
+ if (openSlurs.length > 0) {
1161
+ const open = openSlurs.pop();
1067
1162
  slurs.push({
1068
- startId: currentSlur.startId,
1163
+ startId: open.startId,
1069
1164
  endId: endId,
1070
1165
  });
1071
- currentSlur = null;
1072
1166
  }
1073
1167
  }
1074
1168
  // Then process slur starts (to open new slurs)
1075
1169
  for (const startId of tupletResult.slurStarts) {
1076
- currentSlur = { startId };
1170
+ openSlurs.push({ startId });
1077
1171
  }
1172
+ currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
1078
1173
  // Collect other control events from tuplet
1079
1174
  dynamics.push(...tupletResult.dynamics);
1080
1175
  fermatas.push(...tupletResult.fermatas);
@@ -1082,6 +1177,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1082
1177
  mordents.push(...tupletResult.mordents);
1083
1178
  turns.push(...tupletResult.turns);
1084
1179
  arpeggios.push(...tupletResult.arpeggios);
1180
+ pedals.push(...tupletResult.pedals);
1181
+ fingerings.push(...tupletResult.fingerings);
1182
+ markups.push(...tupletResult.markups);
1085
1183
  break;
1086
1184
  }
1087
1185
  case 'tremolo':
@@ -1114,8 +1212,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1114
1212
  currentOctave = null;
1115
1213
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1116
1214
  }
1117
- // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1118
- // It may be continued by a subsequent ottava command with the same value
1215
+ else if (currentOctave && currentOctave.emitted) {
1216
+ // Stop landed at measure START (no note yet in this layer) but the
1217
+ // span was carried+emitted in a PREVIOUS measure: it genuinely ends
1218
+ // here. Its deferred endToken was already resolved to the correct
1219
+ // last note by extendOttavaEnd as the span ran through prior measures
1220
+ // (or by the staff-level carry's endFallbackId), so DON'T rewrite it
1221
+ // here — just clear the slot and mark closed, so a later same-value
1222
+ // ottava opens a NEW span instead of folding into this one.
1223
+ // Without this, sequential cross-measure spans collapse into one
1224
+ // (No.57: 18 spans → 8).
1225
+ currentOctave = null;
1226
+ ottavaExplicitlyClosed = true;
1227
+ }
1228
+ // Note: if no lastNoteId and not yet emitted (e.g., open+close at
1229
+ // measure start with no notes), keep currentOctave alive — it may be
1230
+ // continued by a subsequent ottava command with the same value.
1119
1231
  currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1120
1232
  }
1121
1233
  else {
@@ -1222,8 +1334,23 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1222
1334
  const pendingOctave = currentOctave
1223
1335
  ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
1224
1336
  : null;
1337
+ // Resolve hairpins still open at layer end. The cross-measure carry supports a
1338
+ // single pending hairpin, so keep the OLDEST open span (bottom of stack — most
1339
+ // likely to legitimately continue into the next measure) as pendingHairpin, and
1340
+ // flush the rest here, ending them at the last note so they aren't dropped
1341
+ // (MusicXML files routinely leave wedges unclosed). Without a last note id there
1342
+ // is nothing to attach to, so those are unavoidably dropped.
1343
+ let pendingHairpin = null;
1344
+ if (openHairpins.length > 0) {
1345
+ pendingHairpin = openHairpins[0];
1346
+ for (let i = 1; i < openHairpins.length; i++) {
1347
+ if (lastNoteId) {
1348
+ hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
1349
+ }
1350
+ }
1351
+ }
1225
1352
  xml += `${indent}</layer>\n`;
1226
- 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 };
1353
+ 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 };
1227
1354
  };
1228
1355
  // Encode a staff
1229
1356
  const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
@@ -1260,7 +1387,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1260
1387
  const layerN = vi + 1;
1261
1388
  const tieKey = `${staffN}-${layerN}`;
1262
1389
  const initialTies = tieState[tieKey] || [];
1263
- const initialSlur = slurState[tieKey] || null;
1390
+ const initialSlur = slurState[tieKey] || [];
1264
1391
  const initialHairpin = hairpinState[tieKey] || null;
1265
1392
  const initialOctave = ottavaState[tieKey] || null;
1266
1393
  const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
@@ -1286,9 +1413,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1286
1413
  pendingTies[tieKey] = result.pendingTiePitches;
1287
1414
  }
1288
1415
  // Track pending slurs for this layer
1289
- if (result.pendingSlur) {
1290
- pendingSlurs[tieKey] = result.pendingSlur;
1291
- }
1416
+ // Always record (even empty) so a measure that closed all its slurs
1417
+ // clears the carried stack rather than leaving a stale open slur.
1418
+ pendingSlurs[tieKey] = result.pendingSlur;
1292
1419
  // Track pending hairpins for this layer
1293
1420
  if (result.pendingHairpin) {
1294
1421
  pendingHairpins[tieKey] = result.pendingHairpin;
@@ -1439,8 +1566,10 @@ const BARLINE_TO_MEI = {
1439
1566
  };
1440
1567
  // Encode a measure
1441
1568
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1442
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}) => {
1569
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
1443
1570
  const measureId = generateId("measure");
1571
+ if (measureIds)
1572
+ measureIds.push(measureId);
1444
1573
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1445
1574
  const allHairpins = [];
1446
1575
  const allPedals = [];
@@ -2160,6 +2289,36 @@ const encode = (doc, options = {}) => {
2160
2289
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2161
2290
  mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2162
2291
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2292
+ // Splice point for an <expansion> element (must be the FIRST child of
2293
+ // <section>, but its plist needs the measure ids collected during the loop
2294
+ // below — so remember where to insert it and build it afterwards).
2295
+ const expansionInsertPos = mei.length;
2296
+ const measureIds = [];
2297
+ // Volta (1st/2nd-ending) house brackets. The measure-layout directive already
2298
+ // encodes which measures form each alternate; <expansion> replays them but does
2299
+ // NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
2300
+ // span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
2301
+ // own MusicXML import does the same). Keyed by the 1-based measure index where
2302
+ // an ending opens / closes.
2303
+ const endingOpenAt = new Map(); // measure index → ending number
2304
+ const endingCloseAt = new Set(); // measure index after which </ending> closes
2305
+ if (doc.metadata?.measureLayout) {
2306
+ try {
2307
+ const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
2308
+ for (const s of spans) {
2309
+ // Only wrap a contiguous ascending run (a house is always adjacent bars).
2310
+ const lo = s.measures[0];
2311
+ const hi = s.measures[s.measures.length - 1];
2312
+ const contiguous = s.measures.every((m, k) => m === lo + k);
2313
+ if (contiguous) {
2314
+ endingOpenAt.set(lo, s.number);
2315
+ endingCloseAt.add(hi);
2316
+ }
2317
+ }
2318
+ }
2319
+ catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2320
+ }
2321
+ let endingOpen = false; // whether an <ending> is currently open in the output
2163
2322
  // Track tie state across measures for cross-measure ties
2164
2323
  const tieState = {};
2165
2324
  // Track slur state across measures for cross-measure slurs
@@ -2266,8 +2425,55 @@ const encode = (doc, options = {}) => {
2266
2425
  mei += `${sd}</scoreDef>\n`;
2267
2426
  }
2268
2427
  }
2269
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2428
+ const mIndex = mi + 1;
2429
+ const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2430
+ // Open an <ending> house bracket if this measure starts an alternate.
2431
+ const openNum = endingOpenAt.get(mIndex);
2432
+ if (openNum !== undefined && !endingOpen) {
2433
+ const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
2434
+ mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
2435
+ endingOpen = true;
2436
+ }
2437
+ const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
2438
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2439
+ // Close the <ending> after the measure that ends this alternate.
2440
+ if (endingOpen && endingCloseAt.has(mIndex)) {
2441
+ mei += `${mIndent}</ending>\n`;
2442
+ endingOpen = false;
2443
+ }
2270
2444
  });
2445
+ // Defensive: never leave an <ending> unclosed at the end of the section.
2446
+ if (endingOpen) {
2447
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`;
2448
+ endingOpen = false;
2449
+ }
2450
+ // Emit an <expansion> describing playback order from the measure-layout
2451
+ // directive ([measures "..."]). Verovio's expand="…" consumes its plist to
2452
+ // unfold repeats for MIDI/time-map output. Inserted as the first child of
2453
+ // <section>; ids were collected in the measure loop above.
2454
+ if (doc.metadata?.measureLayout) {
2455
+ try {
2456
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
2457
+ const order = expandMeasureLayout(layout);
2458
+ const refs = [];
2459
+ for (const idx of order) {
2460
+ const id = measureIds[idx - 1]; // 1-based index into notated measures
2461
+ if (id === undefined) {
2462
+ console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
2463
+ continue;
2464
+ }
2465
+ refs.push(`#${id}`);
2466
+ }
2467
+ if (refs.length > 0) {
2468
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2469
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2470
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2471
+ }
2472
+ }
2473
+ catch (err) {
2474
+ console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
2475
+ }
2476
+ }
2271
2477
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
2272
2478
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
2273
2479
  mei += `${indent}${indent}${indent}</mdiv>\n`;