@k-l-lambda/lilylet 0.1.74 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, collectVoltaSpans } 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,33 @@ 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 (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
2755
+ if (doc.metadata?.measureLayout) {
2756
+ 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); }
2764
+ }
2765
+ } catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2766
+ }
2767
+ let endingOpen = false; // whether an <ending> is currently open in the output
2703
2768
 
2704
2769
  // Track tie state across measures for cross-measure ties
2705
2770
  const tieState: TieState = {};
@@ -2810,8 +2875,53 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2810
2875
  mei += `${sd}</scoreDef>\n`;
2811
2876
  }
2812
2877
  }
2813
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2878
+ const mIndex = mi + 1;
2879
+ 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
+ }
2814
2894
  });
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
+
2898
+ // 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) {
2903
+ try {
2904
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
2905
+ const order = expandMeasureLayout(layout);
2906
+ const refs: string[] = [];
2907
+ for (const idx of order) {
2908
+ const id = measureIds[idx - 1]; // 1-based index into notated measures
2909
+ if (id === undefined) {
2910
+ console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
2911
+ continue;
2912
+ }
2913
+ refs.push(`#${id}`);
2914
+ }
2915
+ if (refs.length > 0) {
2916
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2917
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2918
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2919
+ }
2920
+ }
2921
+ catch (err) {
2922
+ console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${(err as Error).message}`);
2923
+ }
2924
+ }
2815
2925
 
2816
2926
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
2817
2927
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
@@ -69,6 +69,7 @@ import {
69
69
  createFraction,
70
70
  TYPE_TO_DIVISION,
71
71
  } from './musicXmlUtils';
72
+ import { measureLayoutFromPart } from './measureLayoutFromXml';
72
73
 
73
74
  // ============ Spanner Tracker ============
74
75
 
@@ -630,6 +631,25 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
630
631
  result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
631
632
  result.staff = getElementInt(dirEl, 'staff');
632
633
 
634
+ // Navigation jumps live on the <sound> child of <direction> (a sibling of
635
+ // <direction-type>). Parse it first, before the no-direction-type early return,
636
+ // so a sound-only direction still contributes its jump semantics.
637
+ const soundEl = dirEl.getElementsByTagName('sound')[0];
638
+ if (soundEl) {
639
+ const sound: NonNullable<MusicXmlDirection['sound']> = {};
640
+ if (getAttribute(soundEl, 'dacapo') === 'yes') sound.dacapo = true;
641
+ if (getAttribute(soundEl, 'fine') === 'yes') sound.fine = true;
642
+ const dalsegno = getAttribute(soundEl, 'dalsegno');
643
+ if (dalsegno) sound.dalsegno = dalsegno;
644
+ const segno = getAttribute(soundEl, 'segno');
645
+ if (segno) sound.segno = segno;
646
+ const coda = getAttribute(soundEl, 'coda');
647
+ if (coda) sound.coda = coda;
648
+ const tocoda = getAttribute(soundEl, 'tocoda');
649
+ if (tocoda) sound.tocoda = tocoda;
650
+ if (Object.keys(sound).length > 0) result.sound = sound;
651
+ }
652
+
633
653
  const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
634
654
  if (!dirTypeEl) {
635
655
  return result;
@@ -722,7 +742,8 @@ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
722
742
  if (repeatEl) {
723
743
  const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
724
744
  if (direction) {
725
- result.repeat = { direction };
745
+ const times = getAttributeNumber(repeatEl, 'times');
746
+ result.repeat = times !== undefined ? { direction, times } : { direction };
726
747
  }
727
748
  }
728
749
 
@@ -1065,7 +1086,10 @@ const directionToMarks = (
1065
1086
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
1066
1087
  // ContextChange by directionToContextChange, so skip those here to avoid
1067
1088
  // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
1068
- // directions are tempo too, never markup.
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.
1092
+ let emittedWords = false;
1069
1093
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
1070
1094
  const text = direction.words.map(w => w.text).join('').trim();
1071
1095
  if (text && !isTempoWord(text)) {
@@ -1073,6 +1097,27 @@ const directionToMarks = (
1073
1097
  : direction.placement === 'below' ? Placement.below
1074
1098
  : undefined;
1075
1099
  marks.push({ markType: 'markup', content: text, placement });
1100
+ emittedWords = true;
1101
+ }
1102
+ }
1103
+
1104
+ // A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
1105
+ // synthesize the conventional glyph text so the score still shows the marking.
1106
+ // Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
1107
+ // emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
1108
+ // synthesizing, and only when the engraver left them implicit.
1109
+ if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
1110
+ const s = direction.sound;
1111
+ const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
1112
+ : s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
1113
+ : s.tocoda ? 'To Coda'
1114
+ : s.fine ? 'Fine'
1115
+ : undefined;
1116
+ if (label) {
1117
+ const placement = direction.placement === 'above' ? Placement.above
1118
+ : direction.placement === 'below' ? Placement.below
1119
+ : undefined;
1120
+ marks.push({ markType: 'markup', content: label, placement });
1076
1121
  }
1077
1122
  }
1078
1123
 
@@ -1087,6 +1132,12 @@ interface MeasureConversionResult {
1087
1132
  key?: KeySignature;
1088
1133
  timeSig?: Fraction;
1089
1134
  barline?: BarlineEvent;
1135
+ // A `location="left"` repeat (e.g. forward-repeat `.|:`) belongs at the FRONT
1136
+ // of this measure, but lilylet models a `\bar` as the END bar of its measure.
1137
+ // So it is carried back onto the PREVIOUS measure's trailing barline by
1138
+ // convertPart (dropped if this is the first measure — a leading `|:` at the
1139
+ // very start of the piece is conventionally omitted).
1140
+ leadingBarline?: BarlineEvent;
1090
1141
  harmonies: HarmonyEvent[];
1091
1142
  clefs: Map<number, ContextChange>; // staff number → clef context
1092
1143
  }
@@ -1149,6 +1200,7 @@ const convertMeasure = (
1149
1200
  let key: KeySignature | undefined;
1150
1201
  let timeSig: Fraction | undefined;
1151
1202
  let barline: BarlineEvent | undefined;
1203
+ let leadingBarline: BarlineEvent | undefined;
1152
1204
  const harmonies: HarmonyEvent[] = [];
1153
1205
  const clefs: Map<number, ContextChange> = new Map();
1154
1206
 
@@ -1166,6 +1218,15 @@ const convertMeasure = (
1166
1218
  // layer (otherwise the encoder can't pair them and drops the span).
1167
1219
  const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
1168
1220
  let currentVoice = 1; // Track current voice for directions
1221
+ // Mid-measure clef support: a staff can change clef partway through a measure
1222
+ // (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
1223
+ // in an <attributes> block AFTER notes on that staff. We must attach it inline at
1224
+ // that point on the staff's currently-active voice, not collapse it into the
1225
+ // measure-start `clefs` map (which keeps only one clef per staff and emits it at
1226
+ // the bar start). Track the last voice that added a note on each staff, and which
1227
+ // staves already have notes this measure.
1228
+ const lastVoiceOnStaff: Map<number, number> = new Map();
1229
+ const staffHasNotes: Set<number> = new Set();
1169
1230
 
1170
1231
  // Process all children in order
1171
1232
  for (const child of getChildElements(measureEl)) {
@@ -1197,7 +1258,30 @@ const convertMeasure = (
1197
1258
  for (const clefEntry of attrs.clefs) {
1198
1259
  const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
1199
1260
  if (clef) {
1200
- clefs.set(clefEntry.staff, { type: 'context', clef });
1261
+ const staff = clefEntry.staff;
1262
+ if (staffHasNotes.has(staff)) {
1263
+ // Mid-measure clef change: this staff already has notes in this
1264
+ // measure, so the clef takes effect HERE, not at the bar start.
1265
+ // Append it inline on the staff's currently-active voice via
1266
+ // addEvent (which inserts a staff-switch context if that voice is
1267
+ // currently positioned on another staff — e.g. a cross-staff
1268
+ // voice — so the clef binds to `staff`, not the voice's current
1269
+ // staff). Zero duration: a clef does not advance time.
1270
+ const vNum = lastVoiceOnStaff.get(staff);
1271
+ if (vNum !== undefined) {
1272
+ // Tag the clef context with its staff explicitly. In a
1273
+ // cross-staff voice (where notes interleave staves) a bare
1274
+ // clef is ambiguous and is dropped/misplaced by the serializer;
1275
+ // the explicit staff makes it self-describing (serializer emits
1276
+ // `\staff "N" \clef ...`). addEvent still inserts a leading
1277
+ // staff switch when the voice is currently on another staff.
1278
+ voiceTracker.addEvent(vNum, { type: 'context', staff, clef } as ContextChange, 0, staff);
1279
+ } else {
1280
+ clefs.set(staff, { type: 'context', clef });
1281
+ }
1282
+ } else {
1283
+ clefs.set(staff, { type: 'context', clef });
1284
+ }
1201
1285
  }
1202
1286
  }
1203
1287
  }
@@ -1206,6 +1290,10 @@ const convertMeasure = (
1206
1290
  const voiceNum = note.voice;
1207
1291
  const staffNum = note.staff || 1;
1208
1292
  currentVoice = voiceNum;
1293
+ // Record that this staff now has notes this measure and which voice is
1294
+ // active on it, so a subsequent mid-measure <clef> attaches inline here.
1295
+ staffHasNotes.add(staffNum);
1296
+ lastVoiceOnStaff.set(staffNum, voiceNum);
1209
1297
 
1210
1298
  // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
1211
1299
  // where notes go to tupletTracker but voice must be initialized for staff detection)
@@ -1412,7 +1500,14 @@ const convertMeasure = (
1412
1500
  const barlineData = parseBarline(child);
1413
1501
  const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
1414
1502
  if (style && style !== '|') {
1415
- barline = { type: 'barline', style };
1503
+ // A `location="left"` barline (forward repeat at the measure front)
1504
+ // is carried back onto the previous measure's end bar by convertPart;
1505
+ // everything else is this measure's own trailing end bar.
1506
+ if (barlineData.location === 'left') {
1507
+ leadingBarline = { type: 'barline', style };
1508
+ } else {
1509
+ barline = { type: 'barline', style };
1510
+ }
1416
1511
  }
1417
1512
  } else if (tagName === 'harmony') {
1418
1513
  const harmonyData = parseHarmony(child);
@@ -1506,7 +1601,7 @@ const convertMeasure = (
1506
1601
  });
1507
1602
  }
1508
1603
 
1509
- return { voiceMap, key, timeSig, barline, harmonies, clefs };
1604
+ return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
1510
1605
  };
1511
1606
 
1512
1607
  /**
@@ -1524,12 +1619,30 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1524
1619
  let isFirstMeasure = true;
1525
1620
  let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1526
1621
  const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
1622
+ // Initial clef per staff declared in the FIRST <attributes> block. A staff whose
1623
+ // first voice does not appear until a later measure (e.g. an accompaniment staff
1624
+ // silent for the opening bars) would otherwise lose its initial clef — it is only
1625
+ // declared in measure 1's attributes, where that staff has no voice to attach it
1626
+ // to. We hold these and flush each onto the staff's FIRST appearing voice.
1627
+ const pendingInitialClefs: Map<number, ContextChange> = new Map();
1527
1628
 
1528
1629
  const measureEls = getDirectChildren(partEl, 'measure');
1529
1630
 
1530
1631
  for (const measureEl of measureEls) {
1531
1632
  voiceTracker.reset();
1532
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1633
+ const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1634
+
1635
+ // A forward-repeat (or any `location="left"`) barline belongs at the END of
1636
+ // the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
1637
+ // Append it to the previous measure's first voice; if this is the first
1638
+ // measure there is no previous one, so a leading repeat at the very start of
1639
+ // the piece is conventionally dropped.
1640
+ if (leadingBarline && measures.length > 0) {
1641
+ const prevVoices = measures[measures.length - 1].parts[0]?.voices;
1642
+ if (prevVoices && prevVoices.length > 0) {
1643
+ prevVoices[0].events.push(leadingBarline);
1644
+ }
1645
+ }
1533
1646
 
1534
1647
  // Update running key/time
1535
1648
  if (key) lastKey = key;
@@ -1566,6 +1679,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1566
1679
  lastClefs.set(voiceData.staff, clef);
1567
1680
  }
1568
1681
  }
1682
+ pendingInitialClefs.delete(voiceData.staff);
1683
+ } else if (pendingInitialClefs.has(voiceData.staff)) {
1684
+ // This staff's clef was declared in an earlier measure where the staff
1685
+ // had no voice to carry it (its first appearance is delayed, or it is
1686
+ // an intermittently-empty cross-staff passage). This measure declares
1687
+ // no clef of its own — emit the carried-forward pending clef if it
1688
+ // differs from the clef last in effect for this staff.
1689
+ const pending = pendingInitialClefs.get(voiceData.staff)!;
1690
+ const lastClef = lastClefs.get(voiceData.staff);
1691
+ const isSameClef = lastClef &&
1692
+ (lastClef as ContextChange).clef === (pending as ContextChange).clef;
1693
+ if (!isSameClef) {
1694
+ events.push(pending);
1695
+ lastClefs.set(voiceData.staff, pending);
1696
+ }
1697
+ pendingInitialClefs.delete(voiceData.staff);
1569
1698
  }
1570
1699
  staffsWithClef.add(voiceData.staff);
1571
1700
  }
@@ -1573,6 +1702,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1573
1702
  // Add voice events
1574
1703
  events.push(...voiceData.events);
1575
1704
 
1705
+ // A mid-measure clef change was appended inline to this voice's events (it
1706
+ // does not pass through the measure-start `clefs` map). Scan for the LAST
1707
+ // clef per staff in this voice and update lastClefs, so the next measure's
1708
+ // dedup compares against the clef actually in effect — otherwise a later
1709
+ // measure restating the pre-change clef would be wrongly suppressed.
1710
+ {
1711
+ let scanStaff = voiceData.staff;
1712
+ for (const ev of voiceData.events) {
1713
+ if (ev.type === 'context') {
1714
+ const c = ev as ContextChange;
1715
+ if (c.staff != null) scanStaff = c.staff;
1716
+ if (c.clef) lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
1717
+ }
1718
+ }
1719
+ }
1720
+
1576
1721
  // Add harmonies and barline to first voice only
1577
1722
  if (voiceNum === voiceNumbers[0]) {
1578
1723
  for (const h of harmonies) {
@@ -1596,6 +1741,16 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1596
1741
  lastVoiceStaff = voices[0].staff || 1;
1597
1742
  }
1598
1743
 
1744
+ // Carry forward any clef declared this measure for a staff that had NO voice to
1745
+ // attach it to (an intermittently-empty staff — e.g. cross-staff passages where
1746
+ // the LH is written entirely on the upper staff for several bars). Remember it
1747
+ // as pending so it is emitted when that staff next reappears; otherwise the clef
1748
+ // (and any change it represents) is lost when this measure's `clefs` map is
1749
+ // discarded. Generalizes the first-measure seeding above to every measure.
1750
+ for (const [staff, clef] of clefs) {
1751
+ if (!staffsWithClef.has(staff)) pendingInitialClefs.set(staff, clef);
1752
+ }
1753
+
1599
1754
  const measure: Measure = {
1600
1755
  parts: [{
1601
1756
  voices,
@@ -1730,6 +1885,14 @@ export const decode = (input: string | Uint8Array): LilyletDoc => {
1730
1885
  measures: mergedMeasures,
1731
1886
  };
1732
1887
 
1888
+ // Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
1889
+ // from the first part's barline + <sound> navigation markup → measure-layout
1890
+ // string. The MEI encoder turns this into an <expansion> that verovio unfolds
1891
+ // for MIDI. Repeat markup is part-global; the first part carries it. Never
1892
+ // throws (returns undefined when there is nothing to unfold).
1893
+ const measureLayout = measureLayoutFromPart(partEls[0]);
1894
+ if (measureLayout) metadata.measureLayout = measureLayout;
1895
+
1733
1896
  if (Object.keys(metadata).length > 0) {
1734
1897
  result.metadata = metadata;
1735
1898
  }
@@ -124,6 +124,17 @@ export interface MusicXmlDirection {
124
124
  octaveShift?: MusicXmlOctaveShift;
125
125
  coda?: boolean;
126
126
  segno?: boolean;
127
+ // Navigation jumps from the <sound> sibling inside <direction>. Present when
128
+ // the score carries D.C./D.S./Coda/Fine playback semantics; drives the
129
+ // performance-order (measure-layout) derivation, not a notational glyph.
130
+ sound?: {
131
+ dacapo?: boolean; // <sound dacapo="yes"> — da capo
132
+ dalsegno?: string; // <sound dalsegno="N"> — dal segno (target segno id)
133
+ segno?: string; // <sound segno="N"> — marks a segno point
134
+ coda?: string; // <sound coda="N"> — marks a coda point
135
+ tocoda?: string; // <sound tocoda="N"> — "to coda" jump
136
+ fine?: boolean; // <sound fine="yes"> — Fine (stop point on D.C./D.S.)
137
+ };
127
138
  }
128
139
 
129
140
  // ============ Barline Types ============
@@ -133,6 +144,7 @@ export interface MusicXmlBarline {
133
144
  barStyle?: string; // regular, light-light, light-heavy, heavy-light, heavy-heavy, etc.
134
145
  repeat?: {
135
146
  direction: 'forward' | 'backward';
147
+ times?: number; // <repeat times="N"> — repeat count (default 2 for backward)
136
148
  };
137
149
  ending?: {
138
150
  type: 'start' | 'stop' | 'discontinue';