@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
@@ -622,26 +622,29 @@ const encodeVoice = (voice, measureContext, voiceIndex) => {
622
622
  */
623
623
  const encodeMetadata = (metadata) => {
624
624
  const entries = [];
625
+ // LilyPond strings escape backslash and double-quote with a backslash. An
626
+ // unescaped " (or \) inside a header value otherwise breaks the \header block.
627
+ const ly = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
625
628
  if (metadata.title) {
626
- entries.push(` title = "${metadata.title}"`);
629
+ entries.push(` title = "${ly(metadata.title)}"`);
627
630
  }
628
631
  if (metadata.subtitle) {
629
- entries.push(` subtitle = "${metadata.subtitle}"`);
632
+ entries.push(` subtitle = "${ly(metadata.subtitle)}"`);
630
633
  }
631
634
  if (metadata.composer) {
632
- entries.push(` composer = "${metadata.composer}"`);
635
+ entries.push(` composer = "${ly(metadata.composer)}"`);
633
636
  }
634
637
  if (metadata.arranger) {
635
- entries.push(` arranger = "${metadata.arranger}"`);
638
+ entries.push(` arranger = "${ly(metadata.arranger)}"`);
636
639
  }
637
640
  if (metadata.lyricist) {
638
- entries.push(` poet = "${metadata.lyricist}"`);
641
+ entries.push(` poet = "${ly(metadata.lyricist)}"`);
639
642
  }
640
643
  if (metadata.opus) {
641
- entries.push(` opus = "${metadata.opus}"`);
644
+ entries.push(` opus = "${ly(metadata.opus)}"`);
642
645
  }
643
646
  if (metadata.instrument) {
644
- entries.push(` instrument = "${metadata.instrument}"`);
647
+ entries.push(` instrument = "${ly(metadata.instrument)}"`);
645
648
  }
646
649
  entries.push(' tagline = ##f');
647
650
  return entries.join('\n');
@@ -64,5 +64,16 @@ export interface VoltaEndingSpan {
64
64
  measures: number[];
65
65
  }
66
66
  export declare const collectVoltaSpans: (layout: MeasureLayout) => VoltaEndingSpan[];
67
+ export interface LayoutSegment {
68
+ id: string;
69
+ kind: "section" | "ending";
70
+ endingNumber?: number;
71
+ measures: number[];
72
+ }
73
+ export interface SegmentDecomposition {
74
+ segments: LayoutSegment[];
75
+ order: string[];
76
+ }
77
+ export declare const decomposeToSegments: (layout: MeasureLayout) => SegmentDecomposition | null;
67
78
  /** Serialize an AST back to the canonical index-wise DSL string. */
68
79
  export declare const serializeMeasureLayout: (layout: MeasureLayout) => string;
@@ -362,6 +362,103 @@ export const collectVoltaSpans = (layout) => {
362
362
  walk(layout);
363
363
  return spans;
364
364
  };
365
+ export const decomposeToSegments = (layout) => {
366
+ const segments = [];
367
+ let hasEnding = false;
368
+ let counter = 0;
369
+ const newId = (k) => `seg-${k}-${counter++}`;
370
+ // Indices a node covers in notation order (each measure once, ascending).
371
+ const indicesOf = (node) => {
372
+ switch (node.kind) {
373
+ case "single": return [node.measure];
374
+ case "block": return [].concat(...node.seq.map(indicesOf));
375
+ case "volta": return [].concat(...node.body.map(indicesOf), ...(node.alternates ? [].concat(...node.alternates.map(a => [].concat(...a.map(indicesOf)))) : []));
376
+ case "aba": return [].concat(indicesOf(node.main), ...node.rest.map(indicesOf));
377
+ }
378
+ };
379
+ const contiguousAsc = (xs) => xs.length > 0 && xs.every((v, i) => i === 0 || v === xs[i - 1] + 1);
380
+ // Accumulate a run of plain (non-volta/non-aba) measures into one section,
381
+ // flushing whenever a structural node (volta/aba) interrupts the run.
382
+ let run = [];
383
+ const flushRun = () => {
384
+ if (run.length === 0)
385
+ return;
386
+ if (!contiguousAsc(run))
387
+ throw new Error("non-contiguous section run");
388
+ segments.push({ id: newId("sec"), kind: "section", measures: run });
389
+ run = [];
390
+ };
391
+ // Emit segments for a node in document order. Plain singles/blocks extend the
392
+ // current run; a volta flushes the run then emits body-section + ending(s); an
393
+ // aba flushes then recurses into main and rest (so the replayed main is its own
394
+ // segment run). Throws on any non-contiguous group → caller falls back to flat.
395
+ const emit = (node) => {
396
+ switch (node.kind) {
397
+ case "single":
398
+ run.push(node.measure);
399
+ break;
400
+ case "block":
401
+ node.seq.forEach(emit);
402
+ break;
403
+ case "aba":
404
+ flushRun();
405
+ emit(node.main);
406
+ flushRun();
407
+ node.rest.forEach(emit);
408
+ flushRun();
409
+ break;
410
+ case "volta": {
411
+ flushRun();
412
+ const body = indicesOf({ kind: "block", seq: node.body });
413
+ if (!contiguousAsc(body))
414
+ throw new Error("non-contiguous volta body");
415
+ segments.push({ id: newId("body"), kind: "section", measures: body });
416
+ if (node.alternates) {
417
+ hasEnding = true;
418
+ node.alternates.forEach((alt, ai) => {
419
+ const m = indicesOf({ kind: "block", seq: alt });
420
+ if (!contiguousAsc(m))
421
+ throw new Error("non-contiguous ending");
422
+ segments.push({ id: newId("end"), kind: "ending", endingNumber: ai + 1, measures: m });
423
+ });
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ };
429
+ try {
430
+ emit(layout);
431
+ flushRun();
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ if (!hasEnding)
437
+ return null; // no voltas → flat measure-level path is fine
438
+ // Map each measure index to the segment whose `measures` contains it. A volta
439
+ // body measure belongs to its body section; alternate measures to their ending.
440
+ const segOfMeasure = new Map();
441
+ for (const s of segments)
442
+ for (const m of s.measures)
443
+ if (!segOfMeasure.has(m))
444
+ segOfMeasure.set(m, s.id);
445
+ // Performance order over segment ids: walk the flat Full expansion and collapse
446
+ // consecutive measures that map to the same segment into one segment ref. This
447
+ // reproduces the exact playback order (body repeats → body id appears twice,
448
+ // ABA replay → main segment id reappears), keyed off the proven expander.
449
+ const flat = expandMeasureLayout(layout, LayoutType.Full);
450
+ const order = [];
451
+ for (const m of flat) {
452
+ const id = segOfMeasure.get(m);
453
+ if (id === undefined)
454
+ return null; // a played measure isn't in any segment → bail to flat
455
+ if (order.length === 0 || order[order.length - 1] !== id)
456
+ order.push(id);
457
+ else if (segments.find(s => s.id === id).measures[0] === m)
458
+ order.push(id); // same seg restarting (repeat) → new ref
459
+ }
460
+ return { segments, order };
461
+ };
365
462
  // ── Serializer ───────────────────────────────────────────────────────────
366
463
  // Canonical index-wise form, collapsing runs of ≥3 consecutive singles into
367
464
  // "A..B" ranges (lotus's seqToCode).
@@ -39,6 +39,7 @@ export interface MeasureRepeatInfo {
39
39
  * guaranteed to expand (via measureLayout.ts) to the simulated performed order.
40
40
  */
41
41
  export declare const buildMeasureLayout: (infos: MeasureRepeatInfo[], totalMeasures: number) => string | undefined;
42
+ export declare const applyNavText: (info: MeasureRepeatInfo, raw: string) => void;
42
43
  export declare const collectRepeatInfo: (partEl: Element) => MeasureRepeatInfo[];
43
44
  /**
44
45
  * Derive metadata.measureLayout from a <part> element, or undefined if no
@@ -298,7 +298,9 @@ export const buildMeasureLayout = (infos, totalMeasures) => {
298
298
  // "D.C. al Fine" → dacapo (the Fine stop is marked separately where "Fine"
299
299
  // appears); "D.S. al Coda"/"D.S." → dalsegno; "To Coda" → tocoda; bare "Fine"
300
300
  // → fine; bare "Coda"/"Segno" → mark the landing point.
301
- const applyNavText = (info, raw) => {
301
+ // Exported so the ABC decoder can feed its !D.C.!/!D.S.!/!fine! decoration text
302
+ // through the SAME recognizer, keeping the ABC and MusicXML nav semantics identical.
303
+ export const applyNavText = (info, raw) => {
302
304
  const t = raw.trim().toLowerCase().replace(/\s+/g, " ");
303
305
  if (!t)
304
306
  return;
@@ -1,7 +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
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout.js";
5
5
  // MEI key signatures: positive = sharps, negative = flats
6
6
  const KEY_SIGS = {
7
7
  0: "0",
@@ -342,6 +342,8 @@ const extractMarkOptions = (marks) => {
342
342
  fingerings: [],
343
343
  navigation: undefined,
344
344
  markups: [],
345
+ breath: false,
346
+ glissando: false,
345
347
  };
346
348
  if (!marks)
347
349
  return result;
@@ -379,6 +381,9 @@ const extractMarkOptions = (marks) => {
379
381
  else if (mark.type === OrnamentType.prall) {
380
382
  result.mordent = 'upper';
381
383
  }
384
+ else if (mark.type === OrnamentType.breath) {
385
+ result.breath = true;
386
+ }
382
387
  break;
383
388
  case 'dynamic': {
384
389
  const dynStr = DYNAMIC_MAP[mark.type];
@@ -449,6 +454,9 @@ const extractMarkOptions = (marks) => {
449
454
  placement: mark.placement,
450
455
  });
451
456
  break;
457
+ case 'glissando':
458
+ result.glissando = true;
459
+ break;
452
460
  }
453
461
  // Tremolo (special case - from parser internal mark)
454
462
  if ('tremolo' in mark && typeof mark.tremolo === 'number') {
@@ -514,6 +522,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
514
522
  fingerings: markOptions.fingerings,
515
523
  navigation: markOptions.navigation,
516
524
  markups: markOptions.markups,
525
+ breath: markOptions.breath,
526
+ glissando: markOptions.glissando,
517
527
  };
518
528
  }
519
529
  // Chord
@@ -576,6 +586,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
576
586
  fingerings: markOptions.fingerings,
577
587
  navigation: markOptions.navigation,
578
588
  markups: markOptions.markups,
589
+ breath: markOptions.breath,
590
+ glissando: markOptions.glissando,
579
591
  };
580
592
  };
581
593
  // Convert RestEvent to MEI
@@ -846,7 +858,7 @@ const getEventBeamMarks = (event) => {
846
858
  return { beamStart: false, beamEnd: false };
847
859
  };
848
860
  // Encode a layer (voice)
849
- const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
861
+ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null, initialGliss = null) => {
850
862
  const layerId = generateId("layer");
851
863
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
852
864
  let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
@@ -921,6 +933,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
921
933
  // Track ornament refs
922
934
  const fermatas = [];
923
935
  const trills = [];
936
+ const breaths = [];
937
+ // Glissando: a note-to-note slide. Record the start note id and close it on the
938
+ // NEXT encoded note (endid), mirroring slur start→end pairing. A trailing
939
+ // glissando with no following note in the layer is dropped (no target).
940
+ const glissandos = [];
941
+ let pendingGlissStart = initialGliss;
924
942
  const mordents = [];
925
943
  const turns = [];
926
944
  const dynamics = [];
@@ -1082,6 +1100,19 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1082
1100
  if (result.trill) {
1083
1101
  trills.push({ startid: result.elementId });
1084
1102
  }
1103
+ // Breath mark follows this note.
1104
+ if (result.breath) {
1105
+ breaths.push({ startid: result.elementId });
1106
+ }
1107
+ // Glissando: close a pending start onto this note, then open a new one
1108
+ // if this note itself carries a glissando.
1109
+ if (pendingGlissStart) {
1110
+ glissandos.push({ startid: pendingGlissStart, endid: result.elementId });
1111
+ pendingGlissStart = null;
1112
+ }
1113
+ if (result.glissando) {
1114
+ pendingGlissStart = result.elementId;
1115
+ }
1085
1116
  if (result.mordent) {
1086
1117
  mordents.push({
1087
1118
  startid: result.elementId,
@@ -1350,10 +1381,10 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1350
1381
  }
1351
1382
  }
1352
1383
  xml += `${indent}</layer>\n`;
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 };
1384
+ 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 };
1354
1385
  };
1355
1386
  // Encode a staff
1356
- const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
1387
+ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef, glissState = {}) => {
1357
1388
  const staffId = generateId("staff");
1358
1389
  let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
1359
1390
  const allHairpins = [];
@@ -1363,6 +1394,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1363
1394
  const allArpeggios = [];
1364
1395
  const allFermatas = [];
1365
1396
  const allTrills = [];
1397
+ const allBreaths = [];
1398
+ const allGlissandos = [];
1366
1399
  const allMordents = [];
1367
1400
  const allTurns = [];
1368
1401
  const allDynamics = [];
@@ -1373,6 +1406,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1373
1406
  const allMarkups = [];
1374
1407
  const pendingTies = {};
1375
1408
  const pendingSlurs = {};
1409
+ const pendingGlisses = {};
1376
1410
  const pendingHairpins = {};
1377
1411
  const pendingOctaves = {};
1378
1412
  const ottavaExplicitlyClosed = {};
@@ -1390,7 +1424,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1390
1424
  const initialSlur = slurState[tieKey] || [];
1391
1425
  const initialHairpin = hairpinState[tieKey] || null;
1392
1426
  const initialOctave = ottavaState[tieKey] || null;
1393
- const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
1427
+ const initialGliss = glissState[tieKey] || null;
1428
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave, initialGliss);
1394
1429
  xml += result.xml;
1395
1430
  allHairpins.push(...result.hairpins);
1396
1431
  allPedals.push(...result.pedals);
@@ -1399,6 +1434,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1399
1434
  allArpeggios.push(...result.arpeggios);
1400
1435
  allFermatas.push(...result.fermatas);
1401
1436
  allTrills.push(...result.trills);
1437
+ allBreaths.push(...result.breaths);
1438
+ allGlissandos.push(...result.glissandos);
1402
1439
  allMordents.push(...result.mordents);
1403
1440
  allTurns.push(...result.turns);
1404
1441
  allDynamics.push(...result.dynamics);
@@ -1416,6 +1453,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1416
1453
  // Always record (even empty) so a measure that closed all its slurs
1417
1454
  // clears the carried stack rather than leaving a stale open slur.
1418
1455
  pendingSlurs[tieKey] = result.pendingSlur;
1456
+ pendingGlisses[tieKey] = result.pendingGliss;
1419
1457
  // Track pending hairpins for this layer
1420
1458
  if (result.pendingHairpin) {
1421
1459
  pendingHairpins[tieKey] = result.pendingHairpin;
@@ -1446,6 +1484,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1446
1484
  arpeggios: allArpeggios,
1447
1485
  fermatas: allFermatas,
1448
1486
  trills: allTrills,
1487
+ breaths: allBreaths,
1488
+ glissandos: allGlissandos,
1449
1489
  mordents: allMordents,
1450
1490
  turns: allTurns,
1451
1491
  dynamics: allDynamics,
@@ -1456,6 +1496,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1456
1496
  markups: allMarkups,
1457
1497
  pendingTies,
1458
1498
  pendingSlurs,
1499
+ pendingGlisses,
1459
1500
  pendingHairpins,
1460
1501
  pendingOctaves,
1461
1502
  ottavaExplicitlyClosed,
@@ -1566,7 +1607,7 @@ const BARLINE_TO_MEI = {
1566
1607
  };
1567
1608
  // Encode a measure
1568
1609
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1569
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
1610
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds, glissState = {}) => {
1570
1611
  const measureId = generateId("measure");
1571
1612
  if (measureIds)
1572
1613
  measureIds.push(measureId);
@@ -1578,6 +1619,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1578
1619
  const allArpeggios = [];
1579
1620
  const allFermatas = [];
1580
1621
  const allTrills = [];
1622
+ const allBreaths = [];
1623
+ const allGlissandos = [];
1581
1624
  const allMordents = [];
1582
1625
  const allTurns = [];
1583
1626
  const allDynamics = [];
@@ -1624,7 +1667,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1624
1667
  for (let si = 1; si <= totalStaves; si++) {
1625
1668
  const voices = voicesByStaff[si] || [];
1626
1669
  const initialClef = clefState[si];
1627
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1670
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef, glissState);
1628
1671
  staffContent += result.xml;
1629
1672
  allHairpins.push(...result.hairpins);
1630
1673
  allPedals.push(...result.pedals);
@@ -1633,6 +1676,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1633
1676
  allArpeggios.push(...result.arpeggios);
1634
1677
  allFermatas.push(...result.fermatas);
1635
1678
  allTrills.push(...result.trills);
1679
+ allBreaths.push(...result.breaths);
1680
+ allGlissandos.push(...result.glissandos);
1636
1681
  allMordents.push(...result.mordents);
1637
1682
  allTurns.push(...result.turns);
1638
1683
  allDynamics.push(...result.dynamics);
@@ -1646,6 +1691,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1646
1691
  Object.assign(tieState, result.pendingTies);
1647
1692
  // Update slur state with pending slurs from this staff
1648
1693
  Object.assign(slurState, result.pendingSlurs);
1694
+ Object.assign(glissState, result.pendingGlisses);
1649
1695
  // Update hairpin state with pending hairpins from this staff
1650
1696
  Object.assign(hairpinState, result.pendingHairpins);
1651
1697
  // Update ottava state with pending octaves from this staff.
@@ -1718,6 +1764,14 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1718
1764
  for (const tr of allTrills) {
1719
1765
  staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
1720
1766
  }
1767
+ // Generate breath marks (the breath follows its startid note)
1768
+ for (const br of allBreaths) {
1769
+ staffContent += `${indent} <breath xml:id="${generateId('breath')}" startid="#${br.startid}" />\n`;
1770
+ }
1771
+ // Generate glissando spans (note-to-note slide)
1772
+ for (const gl of allGlissandos) {
1773
+ staffContent += `${indent} <gliss xml:id="${generateId('gliss')}" startid="#${gl.startid}" endid="#${gl.endid}" />\n`;
1774
+ }
1721
1775
  // Generate mordent control events
1722
1776
  for (const mord of allMordents) {
1723
1777
  const formAttr = mord.form ? ` form="${mord.form}"` : '';
@@ -2294,35 +2348,75 @@ const encode = (doc, options = {}) => {
2294
2348
  // below — so remember where to insert it and build it afterwards).
2295
2349
  const expansionInsertPos = mei.length;
2296
2350
  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
2351
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2352
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2353
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2354
+ // whose plist references those segment ids. So when the layout decomposes into
2355
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2356
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2357
+ // Helper to check if a measure has any musical content
2358
+ const measureHasContent = (measure) => {
2359
+ for (const part of measure.parts) {
2360
+ for (const voice of part.voices) {
2361
+ for (const event of voice.events) {
2362
+ // Check for actual musical content (not just context changes or pitch resets)
2363
+ if (event.type === 'note' || event.type === 'rest' ||
2364
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2365
+ return true;
2366
+ }
2367
+ }
2368
+ }
2369
+ }
2370
+ return false;
2371
+ };
2372
+ // Filter out trailing empty measures (done before segment setup so the segment
2373
+ // guard below knows the true emitted measure count).
2374
+ let measures = doc.measures;
2375
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2376
+ measures = measures.slice(0, -1);
2377
+ }
2378
+ let segDecomp = null;
2379
+ const segOpenAt = new Map(); // measure index → opening tag for its segment
2380
+ const segCloseAt = new Map(); // measure index → closing tag after it
2381
+ const segIdById = new Map(); // layout segment id → emitted xml:id
2305
2382
  if (doc.metadata?.measureLayout) {
2306
2383
  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
- }
2384
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2385
+ }
2386
+ catch {
2387
+ segDecomp = null;
2388
+ }
2389
+ }
2390
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2391
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2392
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2393
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2394
+ // already warns-and-skips out-of-range refs.
2395
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2396
+ segDecomp = null;
2397
+ }
2398
+ if (segDecomp) {
2399
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2400
+ for (const seg of segDecomp.segments) {
2401
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2402
+ segIdById.set(seg.id, xmlId);
2403
+ const lo = seg.measures[0];
2404
+ const hi = seg.measures[seg.measures.length - 1];
2405
+ if (seg.kind === "ending") {
2406
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2407
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2408
+ }
2409
+ else {
2410
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2317
2411
  }
2412
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2318
2413
  }
2319
- catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2320
2414
  }
2321
- let endingOpen = false; // whether an <ending> is currently open in the output
2322
2415
  // Track tie state across measures for cross-measure ties
2323
2416
  const tieState = {};
2324
2417
  // Track slur state across measures for cross-measure slurs
2325
2418
  const slurState = {};
2419
+ const glissState = {};
2326
2420
  // Track hairpin state across measures for cross-measure hairpins
2327
2421
  const hairpinState = {};
2328
2422
  // Track ottava state across measures for cross-measure ottava spans
@@ -2344,26 +2438,6 @@ const encode = (doc, options = {}) => {
2344
2438
  transState[globalStaff] = `${t.diat},${t.semi}`;
2345
2439
  }
2346
2440
  }
2347
- // Helper to check if a measure has any musical content
2348
- const measureHasContent = (measure) => {
2349
- for (const part of measure.parts) {
2350
- for (const voice of part.voices) {
2351
- for (const event of voice.events) {
2352
- // Check for actual musical content (not just context changes or pitch resets)
2353
- if (event.type === 'note' || event.type === 'rest' ||
2354
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2355
- return true;
2356
- }
2357
- }
2358
- }
2359
- }
2360
- return false;
2361
- };
2362
- // Filter out trailing empty measures
2363
- let measures = doc.measures;
2364
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2365
- measures = measures.slice(0, -1);
2366
- }
2367
2441
  // Encode measures
2368
2442
  measures.forEach((measure, mi) => {
2369
2443
  // Check for key signature change and output scoreDef if needed
@@ -2427,31 +2501,33 @@ const encode = (doc, options = {}) => {
2427
2501
  }
2428
2502
  const mIndex = mi + 1;
2429
2503
  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
- }
2504
+ // When the layout decomposes into volta segments, wrap each measure run in
2505
+ // its <section>/<ending> container (opened/closed by measure index).
2506
+ const openTag = segOpenAt.get(mIndex);
2507
+ if (openTag)
2508
+ mei += openTag;
2509
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2510
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
2511
+ const closeTag = segCloseAt.get(mIndex);
2512
+ if (closeTag)
2513
+ mei += closeTag;
2444
2514
  });
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
2515
  // 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) {
2516
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2517
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2518
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2519
+ // ids in performed order (a body segment id repeats per pass). This is the
2520
+ // only form verovio plays correctly alongside <ending> house brackets.
2521
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2522
+ if (segDecomp) {
2523
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
2524
+ if (refs.length > 0) {
2525
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2526
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2527
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2528
+ }
2529
+ }
2530
+ else if (doc.metadata?.measureLayout) {
2455
2531
  try {
2456
2532
  const layout = parseMeasureLayout(doc.metadata.measureLayout);
2457
2533
  const order = expandMeasureLayout(layout);