@k-l-lambda/lilylet 0.1.76 → 0.1.79

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.
@@ -214,6 +214,52 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
214
214
  return parts.join(", ");
215
215
  };
216
216
 
217
+ // Render a whole piece that has one OR MORE plain repeat/volta sections in
218
+ // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
219
+ // sonata exposition+recap), which the single-span renderRepeatSpan can't express
220
+ // — it bails when it sees >1 repeat-end. Here we cut the piece at section
221
+ // boundaries (each cut ends a repeat section, voltas included) and render each
222
+ // span independently, joining with commas. Any non-repeated measures between two
223
+ // sections are absorbed as the `pre` of the following span. Returns null if any
224
+ // section isn't a clean single repeat/volta (caller then falls back to flat).
225
+ const renderRepeatSections = (infos: MeasureRepeatInfo[], total: number): string | null => {
226
+ const repeatEnds = infos.filter(i => i.repeatEnd).map(i => i.index).sort((a, b) => a - b);
227
+ const repeatStarts = infos.filter(i => i.repeatStart).map(i => i.index).sort((a, b) => a - b);
228
+ if (repeatEnds.length <= 1) return renderRepeatSpan(infos, 1, total);
229
+
230
+ const endingStops = infos.filter(i => i.endingStop !== undefined).map(i => i.index);
231
+
232
+ const parts: string[] = [];
233
+ let prevHi = 0;
234
+ for (const e of repeatEnds) {
235
+ const lo = prevHi + 1;
236
+ // The next section's repeat-start bounds this section's voltas (2nd/3rd
237
+ // endings sit AFTER the repeat-end but BEFORE the next section starts).
238
+ const nextStart = repeatStarts.find(s => s > e);
239
+ const sectionEndingStarts = infos
240
+ .filter(i => i.endingStart !== undefined && i.index >= lo && (nextStart === undefined || i.index < nextStart))
241
+ .map(i => i.index)
242
+ .sort((a, b) => a - b);
243
+
244
+ let hi: number;
245
+ if (sectionEndingStarts.length) {
246
+ const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
247
+ // the last ending's stop closes the section; else the ending start itself
248
+ const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
249
+ hi = stop ?? lastStart;
250
+ }
251
+ else hi = e;
252
+
253
+ const code = renderRepeatSpan(infos, lo, hi);
254
+ if (code === null) return null;
255
+ parts.push(code);
256
+ prevHi = hi;
257
+ }
258
+ // trailing non-repeated tail
259
+ if (prevHi < total) parts.push(rangeCode(prevHi + 1, total));
260
+ return parts.join(", ");
261
+ };
262
+
217
263
  const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
218
264
  const dacapo = infos.find(i => i.dacapo);
219
265
  const dalsegno = infos.some(i => i.dalsegno);
@@ -233,8 +279,10 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
233
279
  }
234
280
  if (dalsegno || tocoda || infos.some(i => i.fine || i.segno || i.coda)) return null;
235
281
 
236
- // No navigation: a single plain repeat/volta over the whole piece.
237
- return renderRepeatSpan(infos, 1, total);
282
+ // No navigation: one or more plain repeat/volta sections in sequence. Most
283
+ // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
284
+ // render each independently and join with commas.
285
+ return renderRepeatSections(infos, total);
238
286
  };
239
287
 
240
288
  // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
@@ -309,7 +357,9 @@ export const buildMeasureLayout = (infos: MeasureRepeatInfo[], totalMeasures: nu
309
357
  // "D.C. al Fine" → dacapo (the Fine stop is marked separately where "Fine"
310
358
  // appears); "D.S. al Coda"/"D.S." → dalsegno; "To Coda" → tocoda; bare "Fine"
311
359
  // → fine; bare "Coda"/"Segno" → mark the landing point.
312
- const applyNavText = (info: MeasureRepeatInfo, raw: string): void => {
360
+ // Exported so the ABC decoder can feed its !D.C.!/!D.S.!/!fine! decoration text
361
+ // through the SAME recognizer, keeping the ABC and MusicXML nav semantics identical.
362
+ export const applyNavText = (info: MeasureRepeatInfo, raw: string): void => {
313
363
  const t = raw.trim().toLowerCase().replace(/\s+/g, " ");
314
364
  if (!t) return;
315
365
  const isDC = /\bd\.?\s*c\.?\b|\bda\s*capo\b/.test(t);
@@ -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}"` : '';
@@ -2812,6 +2887,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2812
2887
 
2813
2888
  // Track slur state across measures for cross-measure slurs
2814
2889
  const slurState: SlurState = {};
2890
+ const glissState: GlissState = {};
2815
2891
 
2816
2892
  // Track hairpin state across measures for cross-measure hairpins
2817
2893
  const hairpinState: HairpinState = {};
@@ -2901,7 +2977,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2901
2977
  const openTag = segOpenAt.get(mIndex);
2902
2978
  if (openTag) mei += openTag;
2903
2979
  const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2904
- mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2980
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
2905
2981
  const closeTag = segCloseAt.get(mIndex);
2906
2982
  if (closeTag) mei += closeTag;
2907
2983
  });
@@ -25,6 +25,7 @@ import {
25
25
  HairpinType,
26
26
  PedalType,
27
27
  NavigationMarkType,
28
+ OrnamentType,
28
29
  Placement,
29
30
  BarlineEvent,
30
31
  HarmonyEvent,
@@ -305,12 +306,13 @@ class VoiceTracker {
305
306
  });
306
307
  this.currentStaff.set(voiceNum, staff);
307
308
  }
308
- const voice = this.voices.get(voiceNum)!;
309
- // Update staff if specified
310
- if (staff > 0) {
311
- voice.staff = staff;
312
- }
313
- return voice;
309
+ // NOTE: do NOT overwrite voice.staff on later notes. voice.staff is the voice's
310
+ // HOME staff — the staff of its first note — matching the parser, which fixes
311
+ // voice.staff to the first leading \staff (so `\staff "1" … \staff "2" …` gives
312
+ // voice.staff=1, with the switch carried by an inline ctx(staff=2) event). The
313
+ // previous code clobbered it to every note's staff, so a voice that ended on a
314
+ // different staff round-tripped with the wrong home staff.
315
+ return this.voices.get(voiceNum)!;
314
316
  }
315
317
 
316
318
  addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
@@ -430,9 +432,11 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
430
432
  }
431
433
  }
432
434
 
433
- // Fermata
434
- if (hasElement(notationsEl, 'fermata')) {
435
- result.fermata = true;
435
+ // Fermata. The "angled" shape is LilyPond's \shortfermata; everything else
436
+ // (normal/square/unset) is the ordinary \fermata.
437
+ const fermataEl = getElements(notationsEl, 'fermata')[0];
438
+ if (fermataEl) {
439
+ result.fermata = (fermataEl.textContent || '').trim() === 'angled' ? 'short' : 'normal';
436
440
  }
437
441
 
438
442
  // Arpeggiate
@@ -457,6 +461,14 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
457
461
  };
458
462
  }
459
463
 
464
+ // Glissando / slide — both are note-to-note slide lines. Only the START note
465
+ // carries the mark (LilyPond \glissando auto-connects to the next note), so we
466
+ // record presence of any start; stops are ignored.
467
+ const slideEls = [...getElements(notationsEl, 'glissando'), ...getElements(notationsEl, 'slide')];
468
+ if (slideEls.some(el => getAttribute(el, 'type') === 'start')) {
469
+ result.glissando = true;
470
+ }
471
+
460
472
  return result;
461
473
  };
462
474
 
@@ -726,6 +738,17 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
726
738
  result.segno = true;
727
739
  }
728
740
 
741
+ // Rehearsal mark (<direction-type><rehearsal>A</rehearsal>). May be split into
742
+ // multiple <rehearsal> (e.g. "A" + a Chinese gloss "段"); join them. Collected
743
+ // from the whole direction so it is found regardless of which direction-type it
744
+ // sits in. Surfaced as text → markup by directionToMarks (per the text-as-markup
745
+ // convention; LilyPond's musicxml2ly emits \mark \markup { \box { … } }).
746
+ const rehearsalEls = Array.from(dirEl.getElementsByTagName('rehearsal'));
747
+ if (rehearsalEls.length > 0) {
748
+ const text = rehearsalEls.map(el => el.textContent?.trim() || '').filter(Boolean).join(' ');
749
+ if (text) result.rehearsal = text;
750
+ }
751
+
729
752
  return result;
730
753
  };
731
754
 
@@ -811,6 +834,21 @@ const parseMetadata = (doc: Document): Metadata => {
811
834
  metadata.title = movementTitleEl.textContent.trim();
812
835
  }
813
836
 
837
+ // Subtitle: MusicXML has no <subtitle> element — it lives in a page <credit>
838
+ // tagged <credit-type>subtitle</credit-type>, with the text in <credit-words>.
839
+ const creditEls = doc.getElementsByTagName('credit');
840
+ for (let i = 0; i < creditEls.length; i++) {
841
+ const credit = creditEls[i];
842
+ const typeEl = credit.getElementsByTagName('credit-type')[0];
843
+ if (typeEl?.textContent?.trim().toLowerCase() === 'subtitle') {
844
+ const words = Array.from(credit.getElementsByTagName('credit-words'))
845
+ .map(w => w.textContent?.trim() || '')
846
+ .filter(Boolean)
847
+ .join(' ');
848
+ if (words) { metadata.subtitle = words; break; }
849
+ }
850
+ }
851
+
814
852
  // Identification (composer, arranger, lyricist)
815
853
  const identificationEl = doc.getElementsByTagName('identification')[0];
816
854
  if (identificationEl) {
@@ -890,6 +928,12 @@ const notationsToMarks = (
890
928
  // Articulations
891
929
  if (notations.articulations) {
892
930
  for (const artName of notations.articulations) {
931
+ // breath-mark is a NoDirection articulation in MusicXML but maps to
932
+ // LilyPond's \breathe — model it as the breath ornament.
933
+ if (artName === 'breath-mark') {
934
+ marks.push({ markType: 'ornament', type: OrnamentType.breath });
935
+ continue;
936
+ }
893
937
  const artType = convertArticulation(artName);
894
938
  if (artType) {
895
939
  marks.push({ markType: 'articulation', type: artType });
@@ -909,7 +953,7 @@ const notationsToMarks = (
909
953
 
910
954
  // Fermata
911
955
  if (notations.fermata) {
912
- marks.push({ markType: 'ornament', type: 'fermata' as any });
956
+ marks.push({ markType: 'ornament', type: (notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any });
913
957
  }
914
958
 
915
959
  // Arpeggiate
@@ -917,6 +961,11 @@ const notationsToMarks = (
917
961
  marks.push({ markType: 'ornament', type: 'arpeggio' as any });
918
962
  }
919
963
 
964
+ // Glissando / slide (start note only)
965
+ if (notations.glissando) {
966
+ marks.push({ markType: 'glissando' });
967
+ }
968
+
920
969
  return marks;
921
970
  };
922
971
 
@@ -1082,16 +1131,31 @@ const directionToMarks = (
1082
1131
  marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
1083
1132
  }
1084
1133
 
1134
+ // Rehearsal mark → markup (text-as-markup convention; LilyPond uses \mark).
1135
+ if (direction.rehearsal) {
1136
+ const placement = direction.placement === 'above' ? Placement.above
1137
+ : direction.placement === 'below' ? Placement.below
1138
+ : undefined;
1139
+ marks.push({ markType: 'markup', content: direction.rehearsal, placement });
1140
+ }
1141
+
1085
1142
  // Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
1086
1143
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
1087
- // ContextChange by directionToContextChange, so skip those here to avoid
1088
- // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
1089
- // directions are tempo too, never markup. Navigation text ("D.C. al Fine",
1090
- // "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
1091
- // markup glyph — the jump SEMANTICS are captured separately as measure-layout.
1144
+ // ContextChange by directionToContextChange (which takes words[0]), so skip that
1145
+ // one here to avoid double-emitting; the REST become a markup mark → MEI <dir>.
1146
+ // A single <direction> often carries a tempo word AND an expressive/translation
1147
+ // word together (e.g. <words>Moderato</words><words>中板</words>) — only the tempo
1148
+ // word is consumed; the rest must still surface as markup. Metronome directions
1149
+ // are tempo too, never markup. Navigation text ("D.C. al Fine", "To Coda",
1150
+ // "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a markup glyph
1151
+ // — the jump SEMANTICS are captured separately as measure-layout.
1092
1152
  let emittedWords = false;
1093
1153
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
1094
- const text = direction.words.map(w => w.text).join('').trim();
1154
+ // If words[0] was consumed as the tempo text, drop it and keep the remainder;
1155
+ // otherwise the whole run is one expressive phrase, joined as today.
1156
+ const firstIsTempo = isTempoWord(direction.words[0].text.trim());
1157
+ const rest = firstIsTempo ? direction.words.slice(1) : direction.words;
1158
+ const text = rest.map(w => w.text).join('').trim();
1095
1159
  if (text && !isTempoWord(text)) {
1096
1160
  const placement = direction.placement === 'above' ? Placement.above
1097
1161
  : direction.placement === 'below' ? Placement.below
@@ -1338,7 +1402,16 @@ const convertMeasure = (
1338
1402
  // hairpin stop that lands just before a rest (common in piano scores:
1339
1403
  // `<pedal stop/>` followed by rests filling the voice) is silently dropped.
1340
1404
  const marks: Mark[] = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
1341
- if (!note.isRest) pendingMarks.delete(voiceNum);
1405
+ if (!note.isRest) {
1406
+ pendingMarks.delete(voiceNum);
1407
+ // Drain directions that named this note's staff but had no known voice
1408
+ // yet (queued under the -staff placeholder key by the <direction> handler).
1409
+ const staffPending = pendingMarks.get(-staffNum);
1410
+ if (staffPending && staffPending.length > 0) {
1411
+ marks.push(...staffPending);
1412
+ pendingMarks.delete(-staffNum);
1413
+ }
1414
+ }
1342
1415
 
1343
1416
  if (note.isRest) {
1344
1417
  // Rest event
@@ -1366,7 +1439,7 @@ const convertMeasure = (
1366
1439
  // so the encoder can emit <fermata startid="#rest">; without this the
1367
1440
  // 3-of-4 fermatas that sit on rests in typical piano scores are lost.
1368
1441
  if (note.notations?.fermata) {
1369
- restEvent.marks = [{ markType: 'ornament', type: 'fermata' as any }];
1442
+ restEvent.marks = [{ markType: 'ornament', type: (note.notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any }];
1370
1443
  }
1371
1444
 
1372
1445
  // Grace notes don't advance time
@@ -1480,9 +1553,17 @@ const convertMeasure = (
1480
1553
  // Handle marks (dynamics, hairpins, etc.)
1481
1554
  const marks = directionToMarks(direction, spannerTracker);
1482
1555
  if (marks.length > 0) {
1483
- // Store marks to attach to next note in current voice
1484
- const existing = pendingMarks.get(currentVoice) || [];
1485
- pendingMarks.set(currentVoice, [...existing, ...marks]);
1556
+ // Route to the voice the marks belong to. A <direction> in a grand-staff
1557
+ // part names its <staff>; the matching note is the NEXT note on that staff
1558
+ // — NOT currentVoice, which after a <backup> still points at the voice that
1559
+ // just ended (so a staff-2 direction emitted after the backup would wrongly
1560
+ // land on staff 1). Queue under a -staff placeholder key; the next note on
1561
+ // that staff drains it, and the end-of-measure flush falls back to that
1562
+ // staff's last note if no further note appears. Without a staff (single-
1563
+ // staff parts), fall back to currentVoice as before.
1564
+ const targetKey = direction.staff ? -direction.staff : currentVoice;
1565
+ const existing = pendingMarks.get(targetKey) || [];
1566
+ pendingMarks.set(targetKey, [...existing, ...marks]);
1486
1567
  }
1487
1568
  } else if (tagName === 'backup') {
1488
1569
  // A <forward> with no note after it (before this backup) is cursor
@@ -1560,10 +1641,23 @@ const convertMeasure = (
1560
1641
  }
1561
1642
  return undefined;
1562
1643
  };
1644
+ // A staff-keyed leftover (negative key = -staffNum, queued for a "next note on
1645
+ // this staff" that never came): resolve to the last note on any voice that lives
1646
+ // on that staff.
1647
+ const findLastNoteOnStaff = (staff: number): NoteEvent | undefined => {
1648
+ let best: NoteEvent | undefined;
1649
+ for (const [, vs] of allVoices) {
1650
+ if ((vs.staff || 1) !== staff) continue;
1651
+ for (let i = vs.events.length - 1; i >= 0; i--) {
1652
+ if (vs.events[i].type === 'note') { best = vs.events[i] as NoteEvent; break; }
1653
+ }
1654
+ }
1655
+ return best;
1656
+ };
1563
1657
  for (const [voiceNum, marks] of pendingMarks) {
1564
1658
  if (marks.length === 0) continue;
1565
- let target = findLastNote(voiceNum);
1566
- // Voice had no note (e.g. direction-only or rest-only): attach to the
1659
+ let target = voiceNum < 0 ? findLastNoteOnStaff(-voiceNum) : findLastNote(voiceNum);
1660
+ // Voice/staff had no note (e.g. direction-only or rest-only): attach to the
1567
1661
  // last note of any voice so the marking is not silently dropped.
1568
1662
  if (!target) {
1569
1663
  for (const vn of allVoices.keys()) {