@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.
@@ -1,6 +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
5
  // MEI key signatures: positive = sharps, negative = flats
5
6
  const KEY_SIGS = {
6
7
  0: "0",
@@ -641,6 +642,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
641
642
  const effectiveStaff = currentStaff ?? layerStaff;
642
643
  // Collect control event info from notes inside tuplet
643
644
  let firstNoteId = null;
645
+ let lastNoteId = null; // last note/rest id — anchors an ottava span end
644
646
  const slurStarts = [];
645
647
  const slurEnds = [];
646
648
  const dynamics = [];
@@ -675,6 +677,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
675
677
  xml += result.xml;
676
678
  if (!firstNoteId)
677
679
  firstNoteId = result.elementId;
680
+ lastNoteId = result.elementId;
678
681
  // Collect slur info
679
682
  if (result.slurStart)
680
683
  slurStarts.push(result.elementId);
@@ -714,6 +717,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
714
717
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
715
718
  const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
716
719
  xml += restResult.xml;
720
+ lastNoteId = restResult.elementId;
717
721
  if (restResult.fermata)
718
722
  fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
719
723
  }
@@ -736,7 +740,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
736
740
  xml += `${baseIndent}</beam>\n`;
737
741
  }
738
742
  xml += `${indent}</tuplet>\n`;
739
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
743
+ return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
740
744
  };
741
745
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
742
746
  const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
@@ -866,6 +870,43 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
866
870
  let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
867
871
  let lastNoteId = null; // Track last note id for ending ottava spans
868
872
  let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
873
+ // ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
874
+ // Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
875
+ // ottava span running over a tuplet run (ubiquitous in etudes) or over rests
876
+ // was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
877
+ // helpers let any event type anchor a span. Mirror the note-case ordering:
878
+ // the caller updates lastNoteId to the event's first emitted id, THEN calls
879
+ // these.
880
+ // Apply a pending ottava's pitch shift before the event's notes are encoded.
881
+ const applyPendingOttavaShift = () => {
882
+ if (pendingOttava !== null && pendingOttava !== 0)
883
+ currentOttavaShift = pendingOttava;
884
+ };
885
+ // Extend a carried (endToken) span's end to this event's last emitted id.
886
+ const extendOttavaEnd = (lastId) => {
887
+ if (currentOctave?.endToken)
888
+ octaveEndReplacements[currentOctave.endToken] = lastId;
889
+ };
890
+ // Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
891
+ // closing any open span of a different value first; or resolve a continued span.
892
+ const applyOttavaAnchor = (anchorId) => {
893
+ if (pendingOttava !== null && pendingOttava !== 0) {
894
+ const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
895
+ const disPlace = pendingOttava > 0 ? 'above' : 'below';
896
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
897
+ octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
898
+ currentOctave = null;
899
+ }
900
+ if (!currentOctave)
901
+ currentOctave = { dis, disPlace, startId: anchorId };
902
+ pendingOttava = null;
903
+ }
904
+ else if (currentOctave?.continued) {
905
+ if (!currentOctave.endToken)
906
+ currentOctave.startId = anchorId;
907
+ currentOctave.continued = false;
908
+ }
909
+ };
869
910
  // Track slur spans - slurs must be encoded as control events in MEI.
870
911
  // A single slot can't hold the overlapping/concurrent slurs that piano writing
871
912
  // produces (measured up to 3 open at once per voice); a new start while one was
@@ -957,9 +998,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
957
998
  // Check if this note should have tie="t" (matches pending tie)
958
999
  const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
959
1000
  // If there's a pending ottava, apply it BEFORE encoding the note
960
- if (pendingOttava !== null && pendingOttava !== 0) {
961
- currentOttavaShift = pendingOttava; // Apply the shift for this note
962
- }
1001
+ applyPendingOttavaShift();
963
1002
  // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
964
1003
  const effectiveNoteEvent = currentStaff !== voice.staff
965
1004
  ? { ...noteEvent, staff: currentStaff }
@@ -967,41 +1006,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
967
1006
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
968
1007
  xml += result.xml;
969
1008
  lastNoteId = result.elementId;
970
- if (currentOctave?.endToken) {
971
- octaveEndReplacements[currentOctave.endToken] = result.elementId;
972
- }
1009
+ extendOttavaEnd(result.elementId);
973
1010
  // Flush any pending markups onto this note
974
1011
  flushPendingMarkups(result.elementId);
975
1012
  // Flush any pending leading dynamics onto this note
976
1013
  flushPendingDynamics(result.elementId);
977
1014
  // If there's a pending ottava, start the span on this note
978
- if (pendingOttava !== null && pendingOttava !== 0) {
979
- const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
980
- const disPlace = pendingOttava > 0 ? 'above' : 'below';
981
- // Close existing span first if it has a different value
982
- if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
983
- if (lastNoteId) {
984
- octaves.push({
985
- dis: currentOctave.dis,
986
- disPlace: currentOctave.disPlace,
987
- startId: currentOctave.startId,
988
- endId: lastNoteId,
989
- });
990
- }
991
- currentOctave = null;
992
- }
993
- // Start new span if we don't already have one with the same value
994
- if (!currentOctave) {
995
- currentOctave = { dis, disPlace, startId: result.elementId };
996
- }
997
- pendingOttava = null;
998
- }
999
- else if (currentOctave?.continued) {
1000
- if (!currentOctave.endToken) {
1001
- currentOctave.startId = result.elementId;
1002
- }
1003
- currentOctave.continued = false;
1004
- }
1015
+ applyOttavaAnchor(result.elementId);
1005
1016
  // Update pending tie pitches
1006
1017
  if (result.hasTieStart) {
1007
1018
  pendingTiePitches = result.pitches;
@@ -1101,23 +1112,39 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1101
1112
  case 'rest': {
1102
1113
  // For cross-staff notation: pass staff number if different from voice's home staff
1103
1114
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1115
+ // A pending ottava shift applies to a rest's gestural octave too.
1116
+ applyPendingOttavaShift();
1104
1117
  const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1105
1118
  xml += restResult.xml;
1119
+ lastNoteId = restResult.elementId;
1120
+ // An ottava span may open on, run through, or close at a rest (e.g. a
1121
+ // span that starts just before LH rests). Anchor it like a note.
1122
+ extendOttavaEnd(restResult.elementId);
1123
+ applyOttavaAnchor(restResult.elementId);
1106
1124
  // Fermata over a rest (held silence) — emit as a control event on the rest.
1107
1125
  if (restResult.fermata)
1108
1126
  fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
1109
1127
  // A leading dynamic/markup attaches to the next event, which may be this rest
1110
1128
  flushPendingMarkups(restResult.elementId);
1111
1129
  flushPendingDynamics(restResult.elementId);
1112
- lastNoteId = restResult.elementId;
1113
1130
  break;
1114
1131
  }
1115
1132
  case 'tuplet':
1116
1133
  case 'times': {
1117
1134
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1118
1135
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1136
+ // A pending ottava shift applies to the tuplet's notes' written octave.
1137
+ applyPendingOttavaShift();
1119
1138
  const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1120
1139
  xml += tupletResult.xml;
1140
+ // An ottava span can open on, run through, or close at a tuplet (common
1141
+ // in etudes where the 8va covers a whole tuplet run). Anchor the span on
1142
+ // the tuplet's FIRST note and let its LAST note close/extend it — without
1143
+ // this the entire span was dropped (start/close lived only in case 'note').
1144
+ if (tupletResult.firstNoteId)
1145
+ applyOttavaAnchor(tupletResult.firstNoteId);
1146
+ if (tupletResult.lastNoteId)
1147
+ extendOttavaEnd(tupletResult.lastNoteId);
1121
1148
  // Propagate clef change from inside the tuplet to the parent tracker
1122
1149
  if (tupletResult.endingClef) {
1123
1150
  currentClef = tupletResult.endingClef;
@@ -1126,7 +1153,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1126
1153
  if (tupletResult.firstNoteId) {
1127
1154
  flushPendingMarkups(tupletResult.firstNoteId);
1128
1155
  flushPendingDynamics(tupletResult.firstNoteId);
1129
- lastNoteId = tupletResult.firstNoteId;
1156
+ lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
1130
1157
  }
1131
1158
  // Process slur ends first (to close open slurs, LIFO), then starts.
1132
1159
  for (const endId of tupletResult.slurEnds) {
@@ -1185,8 +1212,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1185
1212
  currentOctave = null;
1186
1213
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1187
1214
  }
1188
- // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1189
- // It may be continued by a subsequent ottava command with the same value
1215
+ else if (currentOctave && currentOctave.emitted) {
1216
+ // Stop landed at measure START (no note yet in this layer) but the
1217
+ // span was carried+emitted in a PREVIOUS measure: it genuinely ends
1218
+ // here. Its deferred endToken was already resolved to the correct
1219
+ // last note by extendOttavaEnd as the span ran through prior measures
1220
+ // (or by the staff-level carry's endFallbackId), so DON'T rewrite it
1221
+ // here — just clear the slot and mark closed, so a later same-value
1222
+ // ottava opens a NEW span instead of folding into this one.
1223
+ // Without this, sequential cross-measure spans collapse into one
1224
+ // (No.57: 18 spans → 8).
1225
+ currentOctave = null;
1226
+ ottavaExplicitlyClosed = true;
1227
+ }
1228
+ // Note: if no lastNoteId and not yet emitted (e.g., open+close at
1229
+ // measure start with no notes), keep currentOctave alive — it may be
1230
+ // continued by a subsequent ottava command with the same value.
1190
1231
  currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1191
1232
  }
1192
1233
  else {
@@ -1525,8 +1566,10 @@ const BARLINE_TO_MEI = {
1525
1566
  };
1526
1567
  // Encode a measure
1527
1568
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1528
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}) => {
1569
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
1529
1570
  const measureId = generateId("measure");
1571
+ if (measureIds)
1572
+ measureIds.push(measureId);
1530
1573
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1531
1574
  const allHairpins = [];
1532
1575
  const allPedals = [];
@@ -2246,6 +2289,36 @@ const encode = (doc, options = {}) => {
2246
2289
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2247
2290
  mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2248
2291
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2292
+ // Splice point for an <expansion> element (must be the FIRST child of
2293
+ // <section>, but its plist needs the measure ids collected during the loop
2294
+ // below — so remember where to insert it and build it afterwards).
2295
+ const expansionInsertPos = mei.length;
2296
+ 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
2305
+ if (doc.metadata?.measureLayout) {
2306
+ 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
+ }
2317
+ }
2318
+ }
2319
+ catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
2320
+ }
2321
+ let endingOpen = false; // whether an <ending> is currently open in the output
2249
2322
  // Track tie state across measures for cross-measure ties
2250
2323
  const tieState = {};
2251
2324
  // Track slur state across measures for cross-measure slurs
@@ -2352,8 +2425,55 @@ const encode = (doc, options = {}) => {
2352
2425
  mei += `${sd}</scoreDef>\n`;
2353
2426
  }
2354
2427
  }
2355
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2428
+ const mIndex = mi + 1;
2429
+ 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
+ }
2356
2444
  });
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
+ // 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) {
2455
+ try {
2456
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
2457
+ const order = expandMeasureLayout(layout);
2458
+ const refs = [];
2459
+ for (const idx of order) {
2460
+ const id = measureIds[idx - 1]; // 1-based index into notated measures
2461
+ if (id === undefined) {
2462
+ console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
2463
+ continue;
2464
+ }
2465
+ refs.push(`#${id}`);
2466
+ }
2467
+ if (refs.length > 0) {
2468
+ const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2469
+ const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
2470
+ mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
2471
+ }
2472
+ }
2473
+ catch (err) {
2474
+ console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
2475
+ }
2476
+ }
2357
2477
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
2358
2478
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
2359
2479
  mei += `${indent}${indent}${indent}</mdiv>\n`;
@@ -7,6 +7,7 @@
7
7
  import { DOMParser } from '@xmldom/xmldom';
8
8
  import { HairpinType, NavigationMarkType, Placement, } from "./types.js";
9
9
  import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
10
+ import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
10
11
  // ============ Spanner Tracker ============
11
12
  /**
12
13
  * Track spanners (slurs, ties, wedges) by number attribute.
@@ -476,6 +477,31 @@ const parseDirection = (dirEl) => {
476
477
  const result = {};
477
478
  result.placement = getAttribute(dirEl, 'placement');
478
479
  result.staff = getElementInt(dirEl, 'staff');
480
+ // Navigation jumps live on the <sound> child of <direction> (a sibling of
481
+ // <direction-type>). Parse it first, before the no-direction-type early return,
482
+ // so a sound-only direction still contributes its jump semantics.
483
+ const soundEl = dirEl.getElementsByTagName('sound')[0];
484
+ if (soundEl) {
485
+ const sound = {};
486
+ if (getAttribute(soundEl, 'dacapo') === 'yes')
487
+ sound.dacapo = true;
488
+ if (getAttribute(soundEl, 'fine') === 'yes')
489
+ sound.fine = true;
490
+ const dalsegno = getAttribute(soundEl, 'dalsegno');
491
+ if (dalsegno)
492
+ sound.dalsegno = dalsegno;
493
+ const segno = getAttribute(soundEl, 'segno');
494
+ if (segno)
495
+ sound.segno = segno;
496
+ const coda = getAttribute(soundEl, 'coda');
497
+ if (coda)
498
+ sound.coda = coda;
499
+ const tocoda = getAttribute(soundEl, 'tocoda');
500
+ if (tocoda)
501
+ sound.tocoda = tocoda;
502
+ if (Object.keys(sound).length > 0)
503
+ result.sound = sound;
504
+ }
479
505
  const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
480
506
  if (!dirTypeEl) {
481
507
  return result;
@@ -557,7 +583,8 @@ const parseBarline = (barlineEl) => {
557
583
  if (repeatEl) {
558
584
  const direction = getAttribute(repeatEl, 'direction');
559
585
  if (direction) {
560
- result.repeat = { direction };
586
+ const times = getAttributeNumber(repeatEl, 'times');
587
+ result.repeat = times !== undefined ? { direction, times } : { direction };
561
588
  }
562
589
  }
563
590
  const endingEl = barlineEl.getElementsByTagName('ending')[0];
@@ -864,7 +891,10 @@ const directionToMarks = (direction, spannerTracker) => {
864
891
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
865
892
  // ContextChange by directionToContextChange, so skip those here to avoid
866
893
  // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
867
- // directions are tempo too, never markup.
894
+ // directions are tempo too, never markup. Navigation text ("D.C. al Fine",
895
+ // "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
896
+ // markup glyph — the jump SEMANTICS are captured separately as measure-layout.
897
+ let emittedWords = false;
868
898
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
869
899
  const text = direction.words.map(w => w.text).join('').trim();
870
900
  if (text && !isTempoWord(text)) {
@@ -872,6 +902,26 @@ const directionToMarks = (direction, spannerTracker) => {
872
902
  : direction.placement === 'below' ? Placement.below
873
903
  : undefined;
874
904
  marks.push({ markType: 'markup', content: text, placement });
905
+ emittedWords = true;
906
+ }
907
+ }
908
+ // A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
909
+ // synthesize the conventional glyph text so the score still shows the marking.
910
+ // Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
911
+ // emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
912
+ // synthesizing, and only when the engraver left them implicit.
913
+ if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
914
+ const s = direction.sound;
915
+ const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
916
+ : s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
917
+ : s.tocoda ? 'To Coda'
918
+ : s.fine ? 'Fine'
919
+ : undefined;
920
+ if (label) {
921
+ const placement = direction.placement === 'above' ? Placement.above
922
+ : direction.placement === 'below' ? Placement.below
923
+ : undefined;
924
+ marks.push({ markType: 'markup', content: label, placement });
875
925
  }
876
926
  }
877
927
  return marks;
@@ -928,6 +978,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
928
978
  let key;
929
979
  let timeSig;
930
980
  let barline;
981
+ let leadingBarline;
931
982
  const harmonies = [];
932
983
  const clefs = new Map();
933
984
  // Pending marks from directions (to attach to next note), per voice
@@ -944,6 +995,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
944
995
  // layer (otherwise the encoder can't pair them and drops the span).
945
996
  const pendingContextChanges = [];
946
997
  let currentVoice = 1; // Track current voice for directions
998
+ // Mid-measure clef support: a staff can change clef partway through a measure
999
+ // (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
1000
+ // in an <attributes> block AFTER notes on that staff. We must attach it inline at
1001
+ // that point on the staff's currently-active voice, not collapse it into the
1002
+ // measure-start `clefs` map (which keeps only one clef per staff and emits it at
1003
+ // the bar start). Track the last voice that added a note on each staff, and which
1004
+ // staves already have notes this measure.
1005
+ const lastVoiceOnStaff = new Map();
1006
+ const staffHasNotes = new Set();
947
1007
  // Process all children in order
948
1008
  for (const child of getChildElements(measureEl)) {
949
1009
  const tagName = child.tagName;
@@ -968,7 +1028,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
968
1028
  for (const clefEntry of attrs.clefs) {
969
1029
  const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
970
1030
  if (clef) {
971
- clefs.set(clefEntry.staff, { type: 'context', clef });
1031
+ const staff = clefEntry.staff;
1032
+ if (staffHasNotes.has(staff)) {
1033
+ // Mid-measure clef change: this staff already has notes in this
1034
+ // measure, so the clef takes effect HERE, not at the bar start.
1035
+ // Append it inline on the staff's currently-active voice via
1036
+ // addEvent (which inserts a staff-switch context if that voice is
1037
+ // currently positioned on another staff — e.g. a cross-staff
1038
+ // voice — so the clef binds to `staff`, not the voice's current
1039
+ // staff). Zero duration: a clef does not advance time.
1040
+ const vNum = lastVoiceOnStaff.get(staff);
1041
+ if (vNum !== undefined) {
1042
+ // Tag the clef context with its staff explicitly. In a
1043
+ // cross-staff voice (where notes interleave staves) a bare
1044
+ // clef is ambiguous and is dropped/misplaced by the serializer;
1045
+ // the explicit staff makes it self-describing (serializer emits
1046
+ // `\staff "N" \clef ...`). addEvent still inserts a leading
1047
+ // staff switch when the voice is currently on another staff.
1048
+ voiceTracker.addEvent(vNum, { type: 'context', staff, clef }, 0, staff);
1049
+ }
1050
+ else {
1051
+ clefs.set(staff, { type: 'context', clef });
1052
+ }
1053
+ }
1054
+ else {
1055
+ clefs.set(staff, { type: 'context', clef });
1056
+ }
972
1057
  }
973
1058
  }
974
1059
  }
@@ -978,6 +1063,10 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
978
1063
  const voiceNum = note.voice;
979
1064
  const staffNum = note.staff || 1;
980
1065
  currentVoice = voiceNum;
1066
+ // Record that this staff now has notes this measure and which voice is
1067
+ // active on it, so a subsequent mid-measure <clef> attaches inline here.
1068
+ staffHasNotes.add(staffNum);
1069
+ lastVoiceOnStaff.set(staffNum, voiceNum);
981
1070
  // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
982
1071
  // where notes go to tupletTracker but voice must be initialized for staff detection)
983
1072
  voiceTracker.getOrCreateVoice(voiceNum, staffNum);
@@ -1159,7 +1248,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1159
1248
  const barlineData = parseBarline(child);
1160
1249
  const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
1161
1250
  if (style && style !== '|') {
1162
- barline = { type: 'barline', style };
1251
+ // A `location="left"` barline (forward repeat at the measure front)
1252
+ // is carried back onto the previous measure's end bar by convertPart;
1253
+ // everything else is this measure's own trailing end bar.
1254
+ if (barlineData.location === 'left') {
1255
+ leadingBarline = { type: 'barline', style };
1256
+ }
1257
+ else {
1258
+ barline = { type: 'barline', style };
1259
+ }
1163
1260
  }
1164
1261
  }
1165
1262
  else if (tagName === 'harmony') {
@@ -1252,7 +1349,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1252
1349
  staff: voiceState.staff,
1253
1350
  });
1254
1351
  }
1255
- return { voiceMap, key, timeSig, barline, harmonies, clefs };
1352
+ return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
1256
1353
  };
1257
1354
  /**
1258
1355
  * Convert a MusicXML part to Lilylet measures
@@ -1268,10 +1365,27 @@ const convertPart = (partEl) => {
1268
1365
  let isFirstMeasure = true;
1269
1366
  let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1270
1367
  const lastClefs = new Map(); // Track last clef per staff
1368
+ // Initial clef per staff declared in the FIRST <attributes> block. A staff whose
1369
+ // first voice does not appear until a later measure (e.g. an accompaniment staff
1370
+ // silent for the opening bars) would otherwise lose its initial clef — it is only
1371
+ // declared in measure 1's attributes, where that staff has no voice to attach it
1372
+ // to. We hold these and flush each onto the staff's FIRST appearing voice.
1373
+ const pendingInitialClefs = new Map();
1271
1374
  const measureEls = getDirectChildren(partEl, 'measure');
1272
1375
  for (const measureEl of measureEls) {
1273
1376
  voiceTracker.reset();
1274
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1377
+ const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1378
+ // A forward-repeat (or any `location="left"`) barline belongs at the END of
1379
+ // the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
1380
+ // Append it to the previous measure's first voice; if this is the first
1381
+ // measure there is no previous one, so a leading repeat at the very start of
1382
+ // the piece is conventionally dropped.
1383
+ if (leadingBarline && measures.length > 0) {
1384
+ const prevVoices = measures[measures.length - 1].parts[0]?.voices;
1385
+ if (prevVoices && prevVoices.length > 0) {
1386
+ prevVoices[0].events.push(leadingBarline);
1387
+ }
1388
+ }
1275
1389
  // Update running key/time
1276
1390
  if (key)
1277
1391
  lastKey = key;
@@ -1306,11 +1420,45 @@ const convertPart = (partEl) => {
1306
1420
  lastClefs.set(voiceData.staff, clef);
1307
1421
  }
1308
1422
  }
1423
+ pendingInitialClefs.delete(voiceData.staff);
1424
+ }
1425
+ else if (pendingInitialClefs.has(voiceData.staff)) {
1426
+ // This staff's clef was declared in an earlier measure where the staff
1427
+ // had no voice to carry it (its first appearance is delayed, or it is
1428
+ // an intermittently-empty cross-staff passage). This measure declares
1429
+ // no clef of its own — emit the carried-forward pending clef if it
1430
+ // differs from the clef last in effect for this staff.
1431
+ const pending = pendingInitialClefs.get(voiceData.staff);
1432
+ const lastClef = lastClefs.get(voiceData.staff);
1433
+ const isSameClef = lastClef &&
1434
+ lastClef.clef === pending.clef;
1435
+ if (!isSameClef) {
1436
+ events.push(pending);
1437
+ lastClefs.set(voiceData.staff, pending);
1438
+ }
1439
+ pendingInitialClefs.delete(voiceData.staff);
1309
1440
  }
1310
1441
  staffsWithClef.add(voiceData.staff);
1311
1442
  }
1312
1443
  // Add voice events
1313
1444
  events.push(...voiceData.events);
1445
+ // A mid-measure clef change was appended inline to this voice's events (it
1446
+ // does not pass through the measure-start `clefs` map). Scan for the LAST
1447
+ // clef per staff in this voice and update lastClefs, so the next measure's
1448
+ // dedup compares against the clef actually in effect — otherwise a later
1449
+ // measure restating the pre-change clef would be wrongly suppressed.
1450
+ {
1451
+ let scanStaff = voiceData.staff;
1452
+ for (const ev of voiceData.events) {
1453
+ if (ev.type === 'context') {
1454
+ const c = ev;
1455
+ if (c.staff != null)
1456
+ scanStaff = c.staff;
1457
+ if (c.clef)
1458
+ lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
1459
+ }
1460
+ }
1461
+ }
1314
1462
  // Add harmonies and barline to first voice only
1315
1463
  if (voiceNum === voiceNumbers[0]) {
1316
1464
  for (const h of harmonies) {
@@ -1332,6 +1480,16 @@ const convertPart = (partEl) => {
1332
1480
  else {
1333
1481
  lastVoiceStaff = voices[0].staff || 1;
1334
1482
  }
1483
+ // Carry forward any clef declared this measure for a staff that had NO voice to
1484
+ // attach it to (an intermittently-empty staff — e.g. cross-staff passages where
1485
+ // the LH is written entirely on the upper staff for several bars). Remember it
1486
+ // as pending so it is emitted when that staff next reappears; otherwise the clef
1487
+ // (and any change it represents) is lost when this measure's `clefs` map is
1488
+ // discarded. Generalizes the first-measure seeding above to every measure.
1489
+ for (const [staff, clef] of clefs) {
1490
+ if (!staffsWithClef.has(staff))
1491
+ pendingInitialClefs.set(staff, clef);
1492
+ }
1335
1493
  const measure = {
1336
1494
  parts: [{
1337
1495
  voices,
@@ -1450,6 +1608,14 @@ export const decode = (input) => {
1450
1608
  const result = {
1451
1609
  measures: mergedMeasures,
1452
1610
  };
1611
+ // Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
1612
+ // from the first part's barline + <sound> navigation markup → measure-layout
1613
+ // string. The MEI encoder turns this into an <expansion> that verovio unfolds
1614
+ // for MIDI. Repeat markup is part-global; the first part carries it. Never
1615
+ // throws (returns undefined when there is nothing to unfold).
1616
+ const measureLayout = measureLayoutFromPart(partEls[0]);
1617
+ if (measureLayout)
1618
+ metadata.measureLayout = measureLayout;
1453
1619
  if (Object.keys(metadata).length > 0) {
1454
1620
  result.metadata = metadata;
1455
1621
  }
@@ -120,12 +120,21 @@ export interface MusicXmlDirection {
120
120
  octaveShift?: MusicXmlOctaveShift;
121
121
  coda?: boolean;
122
122
  segno?: boolean;
123
+ sound?: {
124
+ dacapo?: boolean;
125
+ dalsegno?: string;
126
+ segno?: string;
127
+ coda?: string;
128
+ tocoda?: string;
129
+ fine?: boolean;
130
+ };
123
131
  }
124
132
  export interface MusicXmlBarline {
125
133
  location?: 'left' | 'right' | 'middle';
126
134
  barStyle?: string;
127
135
  repeat?: {
128
136
  direction: 'forward' | 'backward';
137
+ times?: number;
129
138
  };
130
139
  ending?: {
131
140
  type: 'start' | 'stop' | 'discontinue';