@k-l-lambda/lilylet 0.1.75 → 0.1.78

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.
Files changed (34) hide show
  1. package/lib/abc/grammar.jison.js +85 -79
  2. package/lib/lilylet/abcDecoder.js +167 -0
  3. package/lib/lilylet/grammar.jison.js +143 -125
  4. package/lib/lilylet/highlight.js +3 -1
  5. package/lib/lilylet/lilypondEncoder.js +10 -7
  6. package/lib/lilylet/measureLayout.d.ts +11 -0
  7. package/lib/lilylet/measureLayout.js +97 -0
  8. package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
  9. package/lib/lilylet/measureLayoutFromXml.js +3 -1
  10. package/lib/lilylet/meiEncoder.js +146 -70
  11. package/lib/lilylet/musicXmlDecoder.js +119 -24
  12. package/lib/lilylet/musicXmlEncoder.js +85 -18
  13. package/lib/lilylet/musicXmlTypes.d.ts +3 -1
  14. package/lib/lilylet/musicXmlUtils.js +1 -1
  15. package/lib/lilylet/serializer.js +21 -2
  16. package/lib/lilylet/types.d.ts +6 -2
  17. package/lib/lilylet/types.js +1 -0
  18. package/package.json +3 -1
  19. package/source/abc/abc.jison +8 -4
  20. package/source/abc/grammar.jison.js +85 -79
  21. package/source/lilylet/abcDecoder.ts +189 -0
  22. package/source/lilylet/grammar.jison.js +143 -125
  23. package/source/lilylet/highlight.ts +3 -1
  24. package/source/lilylet/lilylet.jison +33 -17
  25. package/source/lilylet/lilypondEncoder.ts +11 -7
  26. package/source/lilylet/measureLayout.ts +92 -0
  27. package/source/lilylet/measureLayoutFromXml.ts +3 -1
  28. package/source/lilylet/meiEncoder.ts +164 -66
  29. package/source/lilylet/musicXmlDecoder.ts +117 -23
  30. package/source/lilylet/musicXmlEncoder.ts +89 -16
  31. package/source/lilylet/musicXmlTypes.ts +5 -1
  32. package/source/lilylet/musicXmlUtils.ts +1 -1
  33. package/source/lilylet/serializer.ts +21 -2
  34. package/source/lilylet/types.ts +10 -1
@@ -29,7 +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
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
33
33
 
34
34
 
35
35
  // MEI key signatures: positive = sharps, negative = flats
@@ -419,6 +419,8 @@ const extractMarkOptions = (marks?: Mark[]): {
419
419
  fingerings: { finger: number; placement?: 'above' | 'below' }[];
420
420
  navigation?: 'coda' | 'segno';
421
421
  markups: { content: string; placement?: 'above' | 'below' }[];
422
+ breath: boolean;
423
+ glissando: boolean;
422
424
  } => {
423
425
  const result = {
424
426
  artics: [] as { type: string; placement?: 'above' | 'below' }[],
@@ -441,6 +443,8 @@ const extractMarkOptions = (marks?: Mark[]): {
441
443
  fingerings: [] as { finger: number; placement?: 'above' | 'below' }[],
442
444
  navigation: undefined as 'coda' | 'segno' | undefined,
443
445
  markups: [] as { content: string; placement?: 'above' | 'below' }[],
446
+ breath: false,
447
+ glissando: false,
444
448
  };
445
449
 
446
450
  if (!marks) return result;
@@ -472,6 +476,8 @@ const extractMarkOptions = (marks?: Mark[]): {
472
476
  result.mordent = 'lower';
473
477
  } else if (mark.type === OrnamentType.prall) {
474
478
  result.mordent = 'upper';
479
+ } else if (mark.type === OrnamentType.breath) {
480
+ result.breath = true;
475
481
  }
476
482
  break;
477
483
  case 'dynamic': {
@@ -537,6 +543,9 @@ const extractMarkOptions = (marks?: Mark[]): {
537
543
  placement: (mark as { placement?: 'above' | 'below' }).placement,
538
544
  });
539
545
  break;
546
+ case 'glissando':
547
+ result.glissando = true;
548
+ break;
540
549
  }
541
550
 
542
551
  // Tremolo (special case - from parser internal mark)
@@ -570,6 +579,8 @@ interface NoteEventResult {
570
579
  fingerings: { finger: number; placement?: 'above' | 'below' }[];
571
580
  navigation?: 'coda' | 'segno';
572
581
  markups: { content: string; placement?: 'above' | 'below' }[];
582
+ breath: boolean;
583
+ glissando: boolean;
573
584
  }
574
585
 
575
586
  // Convert NoteEvent to MEI
@@ -638,6 +649,8 @@ const noteEventToMEI = (
638
649
  fingerings: markOptions.fingerings,
639
650
  navigation: markOptions.navigation,
640
651
  markups: markOptions.markups,
652
+ breath: markOptions.breath,
653
+ glissando: markOptions.glissando,
641
654
  };
642
655
  }
643
656
 
@@ -701,6 +714,8 @@ const noteEventToMEI = (
701
714
  fingerings: markOptions.fingerings,
702
715
  navigation: markOptions.navigation,
703
716
  markups: markOptions.markups,
717
+ breath: markOptions.breath,
718
+ glissando: markOptions.glissando,
704
719
  };
705
720
  };
706
721
 
@@ -996,6 +1011,15 @@ interface TrillRef {
996
1011
  startid: string;
997
1012
  }
998
1013
 
1014
+ interface BreathRef {
1015
+ startid: string; // the note the breath follows
1016
+ }
1017
+
1018
+ interface GlissRef {
1019
+ startid: string;
1020
+ endid: string;
1021
+ }
1022
+
999
1023
  interface MordentRef {
1000
1024
  startid: string;
1001
1025
  form?: 'upper'; // prall = upper mordent
@@ -1044,6 +1068,7 @@ interface SlurSpan {
1044
1068
  // Tie state for cross-measure ties - maps staff:layer to pending pitches
1045
1069
  type TieState = Record<string, Pitch[]>;
1046
1070
  type SlurState = Record<string, string[]>; // voice key -> open slur startIds (stack, oldest first)
1071
+ type GlissState = Record<string, string | null>; // voice key -> pending glissando start id (awaiting next-measure target)
1047
1072
  type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
1048
1073
 
1049
1074
  // Pending octave span for cross-measure continuation
@@ -1069,6 +1094,8 @@ interface LayerResult {
1069
1094
  arpeggios: ArpegRef[];
1070
1095
  fermatas: FermataRef[];
1071
1096
  trills: TrillRef[];
1097
+ breaths: BreathRef[];
1098
+ glissandos: GlissRef[];
1072
1099
  mordents: MordentRef[];
1073
1100
  turns: TurnRef[];
1074
1101
  dynamics: DynamRef[];
@@ -1079,6 +1106,7 @@ interface LayerResult {
1079
1106
  markups: MarkupRef[];
1080
1107
  pendingTiePitches: Pitch[]; // For cross-measure tie tracking
1081
1108
  pendingSlur: string[]; // For cross-measure slur tracking (all open slur startIds)
1109
+ pendingGliss: string | null; // For cross-measure glissando (start note awaiting its target in the next measure)
1082
1110
  pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
1083
1111
  pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
1084
1112
  ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
@@ -1117,7 +1145,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
1117
1145
  };
1118
1146
 
1119
1147
  // Encode a layer (voice)
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 => {
1148
+ 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, initialGliss: string | null = null): LayerResult => {
1121
1149
  const layerId = generateId("layer");
1122
1150
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
1123
1151
 
@@ -1197,6 +1225,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1197
1225
  // Track ornament refs
1198
1226
  const fermatas: FermataRef[] = [];
1199
1227
  const trills: TrillRef[] = [];
1228
+ const breaths: BreathRef[] = [];
1229
+ // Glissando: a note-to-note slide. Record the start note id and close it on the
1230
+ // NEXT encoded note (endid), mirroring slur start→end pairing. A trailing
1231
+ // glissando with no following note in the layer is dropped (no target).
1232
+ const glissandos: GlissRef[] = [];
1233
+ let pendingGlissStart: string | null = initialGliss;
1200
1234
  const mordents: MordentRef[] = [];
1201
1235
  const turns: TurnRef[] = [];
1202
1236
  const dynamics: DynamRef[] = [];
@@ -1374,6 +1408,19 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1374
1408
  if (result.trill) {
1375
1409
  trills.push({ startid: result.elementId });
1376
1410
  }
1411
+ // Breath mark follows this note.
1412
+ if (result.breath) {
1413
+ breaths.push({ startid: result.elementId });
1414
+ }
1415
+ // Glissando: close a pending start onto this note, then open a new one
1416
+ // if this note itself carries a glissando.
1417
+ if (pendingGlissStart) {
1418
+ glissandos.push({ startid: pendingGlissStart, endid: result.elementId });
1419
+ pendingGlissStart = null;
1420
+ }
1421
+ if (result.glissando) {
1422
+ pendingGlissStart = result.elementId;
1423
+ }
1377
1424
  if (result.mordent) {
1378
1425
  mordents.push({
1379
1426
  startid: result.elementId,
@@ -1645,7 +1692,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1645
1692
  }
1646
1693
 
1647
1694
  xml += `${indent}</layer>\n`;
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 };
1695
+ return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, breaths, glissandos, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingGliss: pendingGlissStart, pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1649
1696
  };
1650
1697
 
1651
1698
  // Staff result type
@@ -1658,6 +1705,8 @@ interface StaffResult {
1658
1705
  arpeggios: ArpegRef[];
1659
1706
  fermatas: FermataRef[];
1660
1707
  trills: TrillRef[];
1708
+ breaths: BreathRef[];
1709
+ glissandos: GlissRef[];
1661
1710
  mordents: MordentRef[];
1662
1711
  turns: TurnRef[];
1663
1712
  dynamics: DynamRef[];
@@ -1668,6 +1717,7 @@ interface StaffResult {
1668
1717
  markups: MarkupRef[];
1669
1718
  pendingTies: TieState; // For cross-measure tie tracking
1670
1719
  pendingSlurs: SlurState; // For cross-measure slur tracking
1720
+ pendingGlisses: GlissState; // For cross-measure glissando tracking
1671
1721
  pendingHairpins: HairpinState; // For cross-measure hairpin tracking
1672
1722
  pendingOctaves: OttavaState; // For cross-measure ottava span tracking
1673
1723
  ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
@@ -1677,7 +1727,7 @@ interface StaffResult {
1677
1727
  }
1678
1728
 
1679
1729
  // Encode a staff
1680
- const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
1730
+ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef, glissState: GlissState = {}): StaffResult => {
1681
1731
  const staffId = generateId("staff");
1682
1732
  let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
1683
1733
  const allHairpins: HairpinSpan[] = [];
@@ -1687,6 +1737,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1687
1737
  const allArpeggios: ArpegRef[] = [];
1688
1738
  const allFermatas: FermataRef[] = [];
1689
1739
  const allTrills: TrillRef[] = [];
1740
+ const allBreaths: BreathRef[] = [];
1741
+ const allGlissandos: GlissRef[] = [];
1690
1742
  const allMordents: MordentRef[] = [];
1691
1743
  const allTurns: TurnRef[] = [];
1692
1744
  const allDynamics: DynamRef[] = [];
@@ -1697,6 +1749,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1697
1749
  const allMarkups: MarkupRef[] = [];
1698
1750
  const pendingTies: TieState = {};
1699
1751
  const pendingSlurs: SlurState = {};
1752
+ const pendingGlisses: GlissState = {};
1700
1753
  const pendingHairpins: HairpinState = {};
1701
1754
  const pendingOctaves: OttavaState = {};
1702
1755
  const ottavaExplicitlyClosed: Record<string, boolean> = {};
@@ -1714,7 +1767,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1714
1767
  const initialSlur = slurState[tieKey] || [];
1715
1768
  const initialHairpin = hairpinState[tieKey] || null;
1716
1769
  const initialOctave = ottavaState[tieKey] || null;
1717
- const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
1770
+ const initialGliss = glissState[tieKey] || null;
1771
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave, initialGliss);
1718
1772
  xml += result.xml;
1719
1773
  allHairpins.push(...result.hairpins);
1720
1774
  allPedals.push(...result.pedals);
@@ -1723,6 +1777,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1723
1777
  allArpeggios.push(...result.arpeggios);
1724
1778
  allFermatas.push(...result.fermatas);
1725
1779
  allTrills.push(...result.trills);
1780
+ allBreaths.push(...result.breaths);
1781
+ allGlissandos.push(...result.glissandos);
1726
1782
  allMordents.push(...result.mordents);
1727
1783
  allTurns.push(...result.turns);
1728
1784
  allDynamics.push(...result.dynamics);
@@ -1740,6 +1796,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1740
1796
  // Always record (even empty) so a measure that closed all its slurs
1741
1797
  // clears the carried stack rather than leaving a stale open slur.
1742
1798
  pendingSlurs[tieKey] = result.pendingSlur;
1799
+ pendingGlisses[tieKey] = result.pendingGliss;
1743
1800
  // Track pending hairpins for this layer
1744
1801
  if (result.pendingHairpin) {
1745
1802
  pendingHairpins[tieKey] = result.pendingHairpin;
@@ -1771,6 +1828,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1771
1828
  arpeggios: allArpeggios,
1772
1829
  fermatas: allFermatas,
1773
1830
  trills: allTrills,
1831
+ breaths: allBreaths,
1832
+ glissandos: allGlissandos,
1774
1833
  mordents: allMordents,
1775
1834
  turns: allTurns,
1776
1835
  dynamics: allDynamics,
@@ -1781,6 +1840,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1781
1840
  markups: allMarkups,
1782
1841
  pendingTies,
1783
1842
  pendingSlurs,
1843
+ pendingGlisses,
1784
1844
  pendingHairpins,
1785
1845
  pendingOctaves,
1786
1846
  ottavaExplicitlyClosed,
@@ -1903,7 +1963,7 @@ const BARLINE_TO_MEI: Record<string, string> = {
1903
1963
 
1904
1964
  // Encode a measure
1905
1965
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
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 => {
1966
+ 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[], glissState: GlissState = {}): string => {
1907
1967
  const measureId = generateId("measure");
1908
1968
  if (measureIds) measureIds.push(measureId);
1909
1969
  let staffContent = ''; // Build staff content first, then add measure tag with barline
@@ -1914,6 +1974,8 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1914
1974
  const allArpeggios: ArpegRef[] = [];
1915
1975
  const allFermatas: FermataRef[] = [];
1916
1976
  const allTrills: TrillRef[] = [];
1977
+ const allBreaths: BreathRef[] = [];
1978
+ const allGlissandos: GlissRef[] = [];
1917
1979
  const allMordents: MordentRef[] = [];
1918
1980
  const allTurns: TurnRef[] = [];
1919
1981
  const allDynamics: DynamRef[] = [];
@@ -1963,7 +2025,7 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1963
2025
  for (let si = 1; si <= totalStaves; si++) {
1964
2026
  const voices = voicesByStaff[si] || [];
1965
2027
  const initialClef = clefState[si];
1966
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
2028
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef, glissState);
1967
2029
  staffContent += result.xml;
1968
2030
  allHairpins.push(...result.hairpins);
1969
2031
  allPedals.push(...result.pedals);
@@ -1972,6 +2034,8 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1972
2034
  allArpeggios.push(...result.arpeggios);
1973
2035
  allFermatas.push(...result.fermatas);
1974
2036
  allTrills.push(...result.trills);
2037
+ allBreaths.push(...result.breaths);
2038
+ allGlissandos.push(...result.glissandos);
1975
2039
  allMordents.push(...result.mordents);
1976
2040
  allTurns.push(...result.turns);
1977
2041
  allDynamics.push(...result.dynamics);
@@ -1985,6 +2049,7 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1985
2049
  Object.assign(tieState, result.pendingTies);
1986
2050
  // Update slur state with pending slurs from this staff
1987
2051
  Object.assign(slurState, result.pendingSlurs);
2052
+ Object.assign(glissState, result.pendingGlisses);
1988
2053
  // Update hairpin state with pending hairpins from this staff
1989
2054
  Object.assign(hairpinState, result.pendingHairpins);
1990
2055
  // Update ottava state with pending octaves from this staff.
@@ -2066,6 +2131,16 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
2066
2131
  staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
2067
2132
  }
2068
2133
 
2134
+ // Generate breath marks (the breath follows its startid note)
2135
+ for (const br of allBreaths) {
2136
+ staffContent += `${indent} <breath xml:id="${generateId('breath')}" startid="#${br.startid}" />\n`;
2137
+ }
2138
+
2139
+ // Generate glissando spans (note-to-note slide)
2140
+ for (const gl of allGlissandos) {
2141
+ staffContent += `${indent} <gliss xml:id="${generateId('gliss')}" startid="#${gl.startid}" endid="#${gl.endid}" />\n`;
2142
+ }
2143
+
2069
2144
  // Generate mordent control events
2070
2145
  for (const mord of allMordents) {
2071
2146
  const formAttr = mord.form ? ` form="${mord.form}"` : '';
@@ -2744,33 +2819,75 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2744
2819
  const expansionInsertPos = mei.length;
2745
2820
  const measureIds: string[] = [];
2746
2821
 
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
2822
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2823
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2824
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2825
+ // whose plist references those segment ids. So when the layout decomposes into
2826
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2827
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2828
+ // Helper to check if a measure has any musical content
2829
+ const measureHasContent = (measure: Measure): boolean => {
2830
+ for (const part of measure.parts) {
2831
+ for (const voice of part.voices) {
2832
+ for (const event of voice.events) {
2833
+ // Check for actual musical content (not just context changes or pitch resets)
2834
+ if (event.type === 'note' || event.type === 'rest' ||
2835
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2836
+ return true;
2837
+ }
2838
+ }
2839
+ }
2840
+ }
2841
+ return false;
2842
+ };
2843
+
2844
+ // Filter out trailing empty measures (done before segment setup so the segment
2845
+ // guard below knows the true emitted measure count).
2846
+ let measures = doc.measures;
2847
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2848
+ measures = measures.slice(0, -1);
2849
+ }
2850
+
2851
+ let segDecomp: ReturnType<typeof decomposeToSegments> = null;
2852
+ const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
2853
+ const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
2854
+ const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
2755
2855
  if (doc.metadata?.measureLayout) {
2756
2856
  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); }
2857
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2858
+ } catch { segDecomp = null; }
2859
+ }
2860
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2861
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2862
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2863
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2864
+ // already warns-and-skips out-of-range refs.
2865
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2866
+ segDecomp = null;
2867
+ }
2868
+ if (segDecomp) {
2869
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2870
+ for (const seg of segDecomp.segments) {
2871
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2872
+ segIdById.set(seg.id, xmlId);
2873
+ const lo = seg.measures[0];
2874
+ const hi = seg.measures[seg.measures.length - 1];
2875
+ if (seg.kind === "ending") {
2876
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2877
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2878
+ } else {
2879
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2764
2880
  }
2765
- } catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2881
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2882
+ }
2766
2883
  }
2767
- let endingOpen = false; // whether an <ending> is currently open in the output
2768
2884
 
2769
2885
  // Track tie state across measures for cross-measure ties
2770
2886
  const tieState: TieState = {};
2771
2887
 
2772
2888
  // Track slur state across measures for cross-measure slurs
2773
2889
  const slurState: SlurState = {};
2890
+ const glissState: GlissState = {};
2774
2891
 
2775
2892
  // Track hairpin state across measures for cross-measure hairpins
2776
2893
  const hairpinState: HairpinState = {};
@@ -2796,28 +2913,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2796
2913
  }
2797
2914
  }
2798
2915
 
2799
- // Helper to check if a measure has any musical content
2800
- const measureHasContent = (measure: Measure): boolean => {
2801
- for (const part of measure.parts) {
2802
- for (const voice of part.voices) {
2803
- for (const event of voice.events) {
2804
- // Check for actual musical content (not just context changes or pitch resets)
2805
- if (event.type === 'note' || event.type === 'rest' ||
2806
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2807
- return true;
2808
- }
2809
- }
2810
- }
2811
- }
2812
- return false;
2813
- };
2814
-
2815
- // Filter out trailing empty measures
2816
- let measures = doc.measures;
2817
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2818
- measures = measures.slice(0, -1);
2819
- }
2820
-
2821
2916
  // Encode measures
2822
2917
  measures.forEach((measure, mi) => {
2823
2918
  // Check for key signature change and output scoreDef if needed
@@ -2877,29 +2972,32 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2877
2972
  }
2878
2973
  const mIndex = mi + 1;
2879
2974
  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
- }
2975
+ // When the layout decomposes into volta segments, wrap each measure run in
2976
+ // its <section>/<ending> container (opened/closed by measure index).
2977
+ const openTag = segOpenAt.get(mIndex);
2978
+ if (openTag) mei += openTag;
2979
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2980
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
2981
+ const closeTag = segCloseAt.get(mIndex);
2982
+ if (closeTag) mei += closeTag;
2894
2983
  });
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
2984
 
2898
2985
  // 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) {
2986
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2987
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2988
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2989
+ // ids in performed order (a body segment id repeats per pass). This is the
2990
+ // only form verovio plays correctly alongside <ending> house brackets.
2991
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2992
+ if (segDecomp) {
2993
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
2994
+ if (refs.length > 0) {
2995
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2996
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2997
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2998
+ }
2999
+ }
3000
+ else if (doc.metadata?.measureLayout) {
2903
3001
  try {
2904
3002
  const layout = parseMeasureLayout(doc.metadata.measureLayout);
2905
3003
  const order = expandMeasureLayout(layout);