@k-l-lambda/lilylet 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ import {
29
29
  } from "./types";
30
30
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
31
31
  import { gmProgramOf } from "./gmInstruments";
32
+ import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
32
33
 
33
34
 
34
35
  // MEI key signatures: positive = sharps, negative = flats
@@ -748,6 +749,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
748
749
  interface TupletEventResult {
749
750
  xml: string;
750
751
  firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
752
+ lastNoteId: string | null; // ID of last note/rest in tuplet (for closing/extending ottava spans)
751
753
  slurStarts: string[]; // Note IDs that start slurs
752
754
  slurEnds: string[]; // Note IDs that end slurs
753
755
  dynamics: DynamRef[];
@@ -794,6 +796,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
794
796
 
795
797
  // Collect control event info from notes inside tuplet
796
798
  let firstNoteId: string | null = null;
799
+ let lastNoteId: string | null = null; // last note/rest id — anchors an ottava span end
797
800
  const slurStarts: string[] = [];
798
801
  const slurEnds: string[] = [];
799
802
  const dynamics: DynamRef[] = [];
@@ -833,6 +836,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
833
836
  xml += result.xml;
834
837
 
835
838
  if (!firstNoteId) firstNoteId = result.elementId;
839
+ lastNoteId = result.elementId;
836
840
 
837
841
  // Collect slur info
838
842
  if (result.slurStart) slurStarts.push(result.elementId);
@@ -863,6 +867,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
863
867
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
864
868
  const restResult = restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
865
869
  xml += restResult.xml;
870
+ lastNoteId = restResult.elementId;
866
871
  if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
867
872
  } else if (e.type === 'context') {
868
873
  const ctx = e as ContextChange;
@@ -885,7 +890,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
885
890
  }
886
891
 
887
892
  xml += `${indent}</tuplet>\n`;
888
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
893
+ return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
889
894
  };
890
895
 
891
896
 
@@ -1143,6 +1148,39 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1143
1148
  let lastNoteId: string | null = null; // Track last note id for ending ottava spans
1144
1149
  let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
1145
1150
 
1151
+ // ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
1152
+ // Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
1153
+ // ottava span running over a tuplet run (ubiquitous in etudes) or over rests
1154
+ // was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
1155
+ // helpers let any event type anchor a span. Mirror the note-case ordering:
1156
+ // the caller updates lastNoteId to the event's first emitted id, THEN calls
1157
+ // these.
1158
+ // Apply a pending ottava's pitch shift before the event's notes are encoded.
1159
+ const applyPendingOttavaShift = (): void => {
1160
+ if (pendingOttava !== null && pendingOttava !== 0) currentOttavaShift = pendingOttava;
1161
+ };
1162
+ // Extend a carried (endToken) span's end to this event's last emitted id.
1163
+ const extendOttavaEnd = (lastId: string): void => {
1164
+ if (currentOctave?.endToken) octaveEndReplacements[currentOctave.endToken] = lastId;
1165
+ };
1166
+ // Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
1167
+ // closing any open span of a different value first; or resolve a continued span.
1168
+ const applyOttavaAnchor = (anchorId: string): void => {
1169
+ if (pendingOttava !== null && pendingOttava !== 0) {
1170
+ const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
1171
+ const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
1172
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
1173
+ octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
1174
+ currentOctave = null;
1175
+ }
1176
+ if (!currentOctave) currentOctave = { dis, disPlace, startId: anchorId };
1177
+ pendingOttava = null;
1178
+ } else if (currentOctave?.continued) {
1179
+ if (!currentOctave.endToken) currentOctave.startId = anchorId;
1180
+ currentOctave.continued = false;
1181
+ }
1182
+ };
1183
+
1146
1184
  // Track slur spans - slurs must be encoded as control events in MEI.
1147
1185
  // A single slot can't hold the overlapping/concurrent slurs that piano writing
1148
1186
  // produces (measured up to 3 open at once per voice); a new start while one was
@@ -1248,9 +1286,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1248
1286
  const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
1249
1287
 
1250
1288
  // If there's a pending ottava, apply it BEFORE encoding the note
1251
- if (pendingOttava !== null && pendingOttava !== 0) {
1252
- currentOttavaShift = pendingOttava; // Apply the shift for this note
1253
- }
1289
+ applyPendingOttavaShift();
1254
1290
 
1255
1291
  // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
1256
1292
  const effectiveNoteEvent = currentStaff !== voice.staff
@@ -1260,9 +1296,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1260
1296
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
1261
1297
  xml += result.xml;
1262
1298
  lastNoteId = result.elementId;
1263
- if (currentOctave?.endToken) {
1264
- octaveEndReplacements[currentOctave.endToken] = result.elementId;
1265
- }
1299
+ extendOttavaEnd(result.elementId);
1266
1300
 
1267
1301
  // Flush any pending markups onto this note
1268
1302
  flushPendingMarkups(result.elementId);
@@ -1271,32 +1305,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1271
1305
  flushPendingDynamics(result.elementId);
1272
1306
 
1273
1307
  // If there's a pending ottava, start the span on this note
1274
- if (pendingOttava !== null && pendingOttava !== 0) {
1275
- const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
1276
- const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
1277
- // Close existing span first if it has a different value
1278
- if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
1279
- if (lastNoteId) {
1280
- octaves.push({
1281
- dis: currentOctave.dis,
1282
- disPlace: currentOctave.disPlace,
1283
- startId: currentOctave.startId,
1284
- endId: lastNoteId,
1285
- });
1286
- }
1287
- currentOctave = null;
1288
- }
1289
- // Start new span if we don't already have one with the same value
1290
- if (!currentOctave) {
1291
- currentOctave = { dis, disPlace, startId: result.elementId };
1292
- }
1293
- pendingOttava = null;
1294
- } else if (currentOctave?.continued) {
1295
- if (!currentOctave.endToken) {
1296
- currentOctave.startId = result.elementId;
1297
- }
1298
- currentOctave.continued = false;
1299
- }
1308
+ applyOttavaAnchor(result.elementId);
1300
1309
 
1301
1310
  // Update pending tie pitches
1302
1311
  if (result.hasTieStart) {
@@ -1394,23 +1403,38 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1394
1403
  case 'rest': {
1395
1404
  // For cross-staff notation: pass staff number if different from voice's home staff
1396
1405
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1406
+ // A pending ottava shift applies to a rest's gestural octave too.
1407
+ applyPendingOttavaShift();
1397
1408
  const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1398
1409
  xml += restResult.xml;
1410
+ lastNoteId = restResult.elementId;
1411
+ // An ottava span may open on, run through, or close at a rest (e.g. a
1412
+ // span that starts just before LH rests). Anchor it like a note.
1413
+ extendOttavaEnd(restResult.elementId);
1414
+ applyOttavaAnchor(restResult.elementId);
1399
1415
  // Fermata over a rest (held silence) — emit as a control event on the rest.
1400
1416
  if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
1401
1417
  // A leading dynamic/markup attaches to the next event, which may be this rest
1402
1418
  flushPendingMarkups(restResult.elementId);
1403
1419
  flushPendingDynamics(restResult.elementId);
1404
- lastNoteId = restResult.elementId;
1405
1420
  break;
1406
1421
  }
1407
1422
  case 'tuplet':
1408
1423
  case 'times': {
1409
1424
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1410
1425
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1426
+ // A pending ottava shift applies to the tuplet's notes' written octave.
1427
+ applyPendingOttavaShift();
1411
1428
  const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1412
1429
  xml += tupletResult.xml;
1413
1430
 
1431
+ // An ottava span can open on, run through, or close at a tuplet (common
1432
+ // in etudes where the 8va covers a whole tuplet run). Anchor the span on
1433
+ // the tuplet's FIRST note and let its LAST note close/extend it — without
1434
+ // this the entire span was dropped (start/close lived only in case 'note').
1435
+ if (tupletResult.firstNoteId) applyOttavaAnchor(tupletResult.firstNoteId);
1436
+ if (tupletResult.lastNoteId) extendOttavaEnd(tupletResult.lastNoteId);
1437
+
1414
1438
  // Propagate clef change from inside the tuplet to the parent tracker
1415
1439
  if (tupletResult.endingClef) {
1416
1440
  currentClef = tupletResult.endingClef as Clef;
@@ -1420,7 +1444,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1420
1444
  if (tupletResult.firstNoteId) {
1421
1445
  flushPendingMarkups(tupletResult.firstNoteId);
1422
1446
  flushPendingDynamics(tupletResult.firstNoteId);
1423
- lastNoteId = tupletResult.firstNoteId;
1447
+ lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
1424
1448
  }
1425
1449
 
1426
1450
  // Process slur ends first (to close open slurs, LIFO), then starts.
@@ -1482,9 +1506,22 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1482
1506
  }
1483
1507
  currentOctave = null;
1484
1508
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1509
+ } else if (currentOctave && currentOctave.emitted) {
1510
+ // Stop landed at measure START (no note yet in this layer) but the
1511
+ // span was carried+emitted in a PREVIOUS measure: it genuinely ends
1512
+ // here. Its deferred endToken was already resolved to the correct
1513
+ // last note by extendOttavaEnd as the span ran through prior measures
1514
+ // (or by the staff-level carry's endFallbackId), so DON'T rewrite it
1515
+ // here — just clear the slot and mark closed, so a later same-value
1516
+ // ottava opens a NEW span instead of folding into this one.
1517
+ // Without this, sequential cross-measure spans collapse into one
1518
+ // (No.57: 18 spans → 8).
1519
+ currentOctave = null;
1520
+ ottavaExplicitlyClosed = true;
1485
1521
  }
1486
- // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1487
- // It may be continued by a subsequent ottava command with the same value
1522
+ // Note: if no lastNoteId and not yet emitted (e.g., open+close at
1523
+ // measure start with no notes), keep currentOctave alive — it may be
1524
+ // continued by a subsequent ottava command with the same value.
1488
1525
  currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1489
1526
  } else {
1490
1527
  // Check if this continues an existing span (same value)
@@ -1866,8 +1903,9 @@ const BARLINE_TO_MEI: Record<string, string> = {
1866
1903
 
1867
1904
  // Encode a measure
1868
1905
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1869
- const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}): string => {
1906
+ const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}, measureIds?: string[]): string => {
1870
1907
  const measureId = generateId("measure");
1908
+ if (measureIds) measureIds.push(measureId);
1871
1909
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1872
1910
  const allHairpins: HairpinSpan[] = [];
1873
1911
  const allPedals: PedalMark[] = [];
@@ -2700,6 +2738,74 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2700
2738
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2701
2739
  mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2702
2740
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2741
+ // Splice point for an <expansion> element (must be the FIRST child of
2742
+ // <section>, but its plist needs the measure ids collected during the loop
2743
+ // below — so remember where to insert it and build it afterwards).
2744
+ const expansionInsertPos = mei.length;
2745
+ const measureIds: string[] = [];
2746
+
2747
+ // Volta playback + house brackets. A flat measure-level <expansion> conflicts
2748
+ // with <ending> wrappers in verovio (it then drops the volta measures). Verovio
2749
+ // instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
2750
+ // whose plist references those segment ids. So when the layout decomposes into
2751
+ // volta segments, emit that structure; otherwise keep the simpler flat path.
2752
+ // See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
2753
+ // Helper to check if a measure has any musical content
2754
+ const measureHasContent = (measure: Measure): boolean => {
2755
+ for (const part of measure.parts) {
2756
+ for (const voice of part.voices) {
2757
+ for (const event of voice.events) {
2758
+ // Check for actual musical content (not just context changes or pitch resets)
2759
+ if (event.type === 'note' || event.type === 'rest' ||
2760
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2761
+ return true;
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+ return false;
2767
+ };
2768
+
2769
+ // Filter out trailing empty measures (done before segment setup so the segment
2770
+ // guard below knows the true emitted measure count).
2771
+ let measures = doc.measures;
2772
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2773
+ measures = measures.slice(0, -1);
2774
+ }
2775
+
2776
+ let segDecomp: ReturnType<typeof decomposeToSegments> = null;
2777
+ const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
2778
+ const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
2779
+ const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
2780
+ if (doc.metadata?.measureLayout) {
2781
+ try {
2782
+ segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
2783
+ } catch { segDecomp = null; }
2784
+ }
2785
+ // A layout that references a measure beyond the emitted range (e.g. a volta
2786
+ // alternate whose bar was a trailing empty measure that got trimmed) cannot be
2787
+ // emitted as nested sections without a dangling <ending>/<section> id or an
2788
+ // unbalanced wrapper. Drop to the flat measure-level expansion path, which
2789
+ // already warns-and-skips out-of-range refs.
2790
+ if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
2791
+ segDecomp = null;
2792
+ }
2793
+ if (segDecomp) {
2794
+ const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2795
+ for (const seg of segDecomp.segments) {
2796
+ const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
2797
+ segIdById.set(seg.id, xmlId);
2798
+ const lo = seg.measures[0];
2799
+ const hi = seg.measures[seg.measures.length - 1];
2800
+ if (seg.kind === "ending") {
2801
+ const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
2802
+ segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
2803
+ } else {
2804
+ segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
2805
+ }
2806
+ segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
2807
+ }
2808
+ }
2703
2809
 
2704
2810
  // Track tie state across measures for cross-measure ties
2705
2811
  const tieState: TieState = {};
@@ -2731,28 +2837,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2731
2837
  }
2732
2838
  }
2733
2839
 
2734
- // Helper to check if a measure has any musical content
2735
- const measureHasContent = (measure: Measure): boolean => {
2736
- for (const part of measure.parts) {
2737
- for (const voice of part.voices) {
2738
- for (const event of voice.events) {
2739
- // Check for actual musical content (not just context changes or pitch resets)
2740
- if (event.type === 'note' || event.type === 'rest' ||
2741
- event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2742
- return true;
2743
- }
2744
- }
2745
- }
2746
- }
2747
- return false;
2748
- };
2749
-
2750
- // Filter out trailing empty measures
2751
- let measures = doc.measures;
2752
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
2753
- measures = measures.slice(0, -1);
2754
- }
2755
-
2756
2840
  // Encode measures
2757
2841
  measures.forEach((measure, mi) => {
2758
2842
  // Check for key signature change and output scoreDef if needed
@@ -2810,9 +2894,57 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2810
2894
  mei += `${sd}</scoreDef>\n`;
2811
2895
  }
2812
2896
  }
2813
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2897
+ const mIndex = mi + 1;
2898
+ const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2899
+ // When the layout decomposes into volta segments, wrap each measure run in
2900
+ // its <section>/<ending> container (opened/closed by measure index).
2901
+ const openTag = segOpenAt.get(mIndex);
2902
+ if (openTag) mei += openTag;
2903
+ const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2904
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2905
+ const closeTag = segCloseAt.get(mIndex);
2906
+ if (closeTag) mei += closeTag;
2814
2907
  });
2815
2908
 
2909
+ // Emit an <expansion> describing playback order from the measure-layout
2910
+ // directive ([measures "..."]). Verovio consumes its plist to unfold repeats
2911
+ // for MIDI/time-map output. Inserted as the first child of <section>.
2912
+ // - voltas present (segDecomp): plist references the SECTION/ENDING segment
2913
+ // ids in performed order (a body segment id repeats per pass). This is the
2914
+ // only form verovio plays correctly alongside <ending> house brackets.
2915
+ // - otherwise: the simpler flat measure-level plist (one ref per played bar).
2916
+ if (segDecomp) {
2917
+ const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
2918
+ if (refs.length > 0) {
2919
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2920
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2921
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2922
+ }
2923
+ }
2924
+ else if (doc.metadata?.measureLayout) {
2925
+ try {
2926
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
2927
+ const order = expandMeasureLayout(layout);
2928
+ const refs: string[] = [];
2929
+ for (const idx of order) {
2930
+ const id = measureIds[idx - 1]; // 1-based index into notated measures
2931
+ if (id === undefined) {
2932
+ console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
2933
+ continue;
2934
+ }
2935
+ refs.push(`#${id}`);
2936
+ }
2937
+ if (refs.length > 0) {
2938
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2939
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2940
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2941
+ }
2942
+ }
2943
+ catch (err) {
2944
+ console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${(err as Error).message}`);
2945
+ }
2946
+ }
2947
+
2816
2948
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
2817
2949
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
2818
2950
  mei += `${indent}${indent}${indent}</mdiv>\n`;