@k-l-lambda/lilylet 0.1.76 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -342,6 +342,8 @@ const extractMarkOptions = (marks) => {
342
342
  fingerings: [],
343
343
  navigation: undefined,
344
344
  markups: [],
345
+ breath: false,
346
+ glissando: false,
345
347
  };
346
348
  if (!marks)
347
349
  return result;
@@ -379,6 +381,9 @@ const extractMarkOptions = (marks) => {
379
381
  else if (mark.type === OrnamentType.prall) {
380
382
  result.mordent = 'upper';
381
383
  }
384
+ else if (mark.type === OrnamentType.breath) {
385
+ result.breath = true;
386
+ }
382
387
  break;
383
388
  case 'dynamic': {
384
389
  const dynStr = DYNAMIC_MAP[mark.type];
@@ -449,6 +454,9 @@ const extractMarkOptions = (marks) => {
449
454
  placement: mark.placement,
450
455
  });
451
456
  break;
457
+ case 'glissando':
458
+ result.glissando = true;
459
+ break;
452
460
  }
453
461
  // Tremolo (special case - from parser internal mark)
454
462
  if ('tremolo' in mark && typeof mark.tremolo === 'number') {
@@ -514,6 +522,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
514
522
  fingerings: markOptions.fingerings,
515
523
  navigation: markOptions.navigation,
516
524
  markups: markOptions.markups,
525
+ breath: markOptions.breath,
526
+ glissando: markOptions.glissando,
517
527
  };
518
528
  }
519
529
  // Chord
@@ -576,6 +586,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
576
586
  fingerings: markOptions.fingerings,
577
587
  navigation: markOptions.navigation,
578
588
  markups: markOptions.markups,
589
+ breath: markOptions.breath,
590
+ glissando: markOptions.glissando,
579
591
  };
580
592
  };
581
593
  // Convert RestEvent to MEI
@@ -846,7 +858,7 @@ const getEventBeamMarks = (event) => {
846
858
  return { beamStart: false, beamEnd: false };
847
859
  };
848
860
  // Encode a layer (voice)
849
- const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
861
+ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null, initialGliss = null) => {
850
862
  const layerId = generateId("layer");
851
863
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
852
864
  let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
@@ -921,6 +933,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
921
933
  // Track ornament refs
922
934
  const fermatas = [];
923
935
  const trills = [];
936
+ const breaths = [];
937
+ // Glissando: a note-to-note slide. Record the start note id and close it on the
938
+ // NEXT encoded note (endid), mirroring slur start→end pairing. A trailing
939
+ // glissando with no following note in the layer is dropped (no target).
940
+ const glissandos = [];
941
+ let pendingGlissStart = initialGliss;
924
942
  const mordents = [];
925
943
  const turns = [];
926
944
  const dynamics = [];
@@ -1082,6 +1100,19 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1082
1100
  if (result.trill) {
1083
1101
  trills.push({ startid: result.elementId });
1084
1102
  }
1103
+ // Breath mark follows this note.
1104
+ if (result.breath) {
1105
+ breaths.push({ startid: result.elementId });
1106
+ }
1107
+ // Glissando: close a pending start onto this note, then open a new one
1108
+ // if this note itself carries a glissando.
1109
+ if (pendingGlissStart) {
1110
+ glissandos.push({ startid: pendingGlissStart, endid: result.elementId });
1111
+ pendingGlissStart = null;
1112
+ }
1113
+ if (result.glissando) {
1114
+ pendingGlissStart = result.elementId;
1115
+ }
1085
1116
  if (result.mordent) {
1086
1117
  mordents.push({
1087
1118
  startid: result.elementId,
@@ -1350,10 +1381,10 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1350
1381
  }
1351
1382
  }
1352
1383
  xml += `${indent}</layer>\n`;
1353
- return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1384
+ return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, breaths, glissandos, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingGliss: pendingGlissStart, pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1354
1385
  };
1355
1386
  // Encode a staff
1356
- const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
1387
+ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef, glissState = {}) => {
1357
1388
  const staffId = generateId("staff");
1358
1389
  let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
1359
1390
  const allHairpins = [];
@@ -1363,6 +1394,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1363
1394
  const allArpeggios = [];
1364
1395
  const allFermatas = [];
1365
1396
  const allTrills = [];
1397
+ const allBreaths = [];
1398
+ const allGlissandos = [];
1366
1399
  const allMordents = [];
1367
1400
  const allTurns = [];
1368
1401
  const allDynamics = [];
@@ -1373,6 +1406,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1373
1406
  const allMarkups = [];
1374
1407
  const pendingTies = {};
1375
1408
  const pendingSlurs = {};
1409
+ const pendingGlisses = {};
1376
1410
  const pendingHairpins = {};
1377
1411
  const pendingOctaves = {};
1378
1412
  const ottavaExplicitlyClosed = {};
@@ -1390,7 +1424,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1390
1424
  const initialSlur = slurState[tieKey] || [];
1391
1425
  const initialHairpin = hairpinState[tieKey] || null;
1392
1426
  const initialOctave = ottavaState[tieKey] || null;
1393
- const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
1427
+ const initialGliss = glissState[tieKey] || null;
1428
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave, initialGliss);
1394
1429
  xml += result.xml;
1395
1430
  allHairpins.push(...result.hairpins);
1396
1431
  allPedals.push(...result.pedals);
@@ -1399,6 +1434,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1399
1434
  allArpeggios.push(...result.arpeggios);
1400
1435
  allFermatas.push(...result.fermatas);
1401
1436
  allTrills.push(...result.trills);
1437
+ allBreaths.push(...result.breaths);
1438
+ allGlissandos.push(...result.glissandos);
1402
1439
  allMordents.push(...result.mordents);
1403
1440
  allTurns.push(...result.turns);
1404
1441
  allDynamics.push(...result.dynamics);
@@ -1416,6 +1453,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1416
1453
  // Always record (even empty) so a measure that closed all its slurs
1417
1454
  // clears the carried stack rather than leaving a stale open slur.
1418
1455
  pendingSlurs[tieKey] = result.pendingSlur;
1456
+ pendingGlisses[tieKey] = result.pendingGliss;
1419
1457
  // Track pending hairpins for this layer
1420
1458
  if (result.pendingHairpin) {
1421
1459
  pendingHairpins[tieKey] = result.pendingHairpin;
@@ -1446,6 +1484,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1446
1484
  arpeggios: allArpeggios,
1447
1485
  fermatas: allFermatas,
1448
1486
  trills: allTrills,
1487
+ breaths: allBreaths,
1488
+ glissandos: allGlissandos,
1449
1489
  mordents: allMordents,
1450
1490
  turns: allTurns,
1451
1491
  dynamics: allDynamics,
@@ -1456,6 +1496,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1456
1496
  markups: allMarkups,
1457
1497
  pendingTies,
1458
1498
  pendingSlurs,
1499
+ pendingGlisses,
1459
1500
  pendingHairpins,
1460
1501
  pendingOctaves,
1461
1502
  ottavaExplicitlyClosed,
@@ -1566,7 +1607,7 @@ const BARLINE_TO_MEI = {
1566
1607
  };
1567
1608
  // Encode a measure
1568
1609
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1569
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
1610
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds, glissState = {}) => {
1570
1611
  const measureId = generateId("measure");
1571
1612
  if (measureIds)
1572
1613
  measureIds.push(measureId);
@@ -1578,6 +1619,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1578
1619
  const allArpeggios = [];
1579
1620
  const allFermatas = [];
1580
1621
  const allTrills = [];
1622
+ const allBreaths = [];
1623
+ const allGlissandos = [];
1581
1624
  const allMordents = [];
1582
1625
  const allTurns = [];
1583
1626
  const allDynamics = [];
@@ -1624,7 +1667,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1624
1667
  for (let si = 1; si <= totalStaves; si++) {
1625
1668
  const voices = voicesByStaff[si] || [];
1626
1669
  const initialClef = clefState[si];
1627
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1670
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef, glissState);
1628
1671
  staffContent += result.xml;
1629
1672
  allHairpins.push(...result.hairpins);
1630
1673
  allPedals.push(...result.pedals);
@@ -1633,6 +1676,8 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1633
1676
  allArpeggios.push(...result.arpeggios);
1634
1677
  allFermatas.push(...result.fermatas);
1635
1678
  allTrills.push(...result.trills);
1679
+ allBreaths.push(...result.breaths);
1680
+ allGlissandos.push(...result.glissandos);
1636
1681
  allMordents.push(...result.mordents);
1637
1682
  allTurns.push(...result.turns);
1638
1683
  allDynamics.push(...result.dynamics);
@@ -1646,6 +1691,7 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1646
1691
  Object.assign(tieState, result.pendingTies);
1647
1692
  // Update slur state with pending slurs from this staff
1648
1693
  Object.assign(slurState, result.pendingSlurs);
1694
+ Object.assign(glissState, result.pendingGlisses);
1649
1695
  // Update hairpin state with pending hairpins from this staff
1650
1696
  Object.assign(hairpinState, result.pendingHairpins);
1651
1697
  // Update ottava state with pending octaves from this staff.
@@ -1718,6 +1764,14 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1718
1764
  for (const tr of allTrills) {
1719
1765
  staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
1720
1766
  }
1767
+ // Generate breath marks (the breath follows its startid note)
1768
+ for (const br of allBreaths) {
1769
+ staffContent += `${indent} <breath xml:id="${generateId('breath')}" startid="#${br.startid}" />\n`;
1770
+ }
1771
+ // Generate glissando spans (note-to-note slide)
1772
+ for (const gl of allGlissandos) {
1773
+ staffContent += `${indent} <gliss xml:id="${generateId('gliss')}" startid="#${gl.startid}" endid="#${gl.endid}" />\n`;
1774
+ }
1721
1775
  // Generate mordent control events
1722
1776
  for (const mord of allMordents) {
1723
1777
  const formAttr = mord.form ? ` form="${mord.form}"` : '';
@@ -2362,6 +2416,7 @@ const encode = (doc, options = {}) => {
2362
2416
  const tieState = {};
2363
2417
  // Track slur state across measures for cross-measure slurs
2364
2418
  const slurState = {};
2419
+ const glissState = {};
2365
2420
  // Track hairpin state across measures for cross-measure hairpins
2366
2421
  const hairpinState = {};
2367
2422
  // Track ottava state across measures for cross-measure ottava spans
@@ -2452,7 +2507,7 @@ const encode = (doc, options = {}) => {
2452
2507
  if (openTag)
2453
2508
  mei += openTag;
2454
2509
  const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
2455
- mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
2510
+ mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
2456
2511
  const closeTag = segCloseAt.get(mIndex);
2457
2512
  if (closeTag)
2458
2513
  mei += closeTag;
@@ -5,7 +5,7 @@
5
5
  * Improves upon musicxml2ly by properly tracking spanners (slurs, ties, wedges) by number attribute.
6
6
  */
7
7
  import { DOMParser } from '@xmldom/xmldom';
8
- import { HairpinType, NavigationMarkType, Placement, } from "./types.js";
8
+ import { HairpinType, NavigationMarkType, OrnamentType, 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
10
  import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
11
11
  // ============ Spanner Tracker ============
@@ -197,12 +197,13 @@ class VoiceTracker {
197
197
  });
198
198
  this.currentStaff.set(voiceNum, staff);
199
199
  }
200
- const voice = this.voices.get(voiceNum);
201
- // Update staff if specified
202
- if (staff > 0) {
203
- voice.staff = staff;
204
- }
205
- return voice;
200
+ // NOTE: do NOT overwrite voice.staff on later notes. voice.staff is the voice's
201
+ // HOME staff — the staff of its first note — matching the parser, which fixes
202
+ // voice.staff to the first leading \staff (so `\staff "1" … \staff "2" …` gives
203
+ // voice.staff=1, with the switch carried by an inline ctx(staff=2) event). The
204
+ // previous code clobbered it to every note's staff, so a voice that ended on a
205
+ // different staff round-tripped with the wrong home staff.
206
+ return this.voices.get(voiceNum);
206
207
  }
207
208
  addEvent(voiceNum, event, duration, staff = 1) {
208
209
  const voice = this.getOrCreateVoice(voiceNum, staff);
@@ -303,9 +304,11 @@ const parseNotations = (notationsEl) => {
303
304
  result.ornaments = ornaments;
304
305
  }
305
306
  }
306
- // Fermata
307
- if (hasElement(notationsEl, 'fermata')) {
308
- result.fermata = true;
307
+ // Fermata. The "angled" shape is LilyPond's \shortfermata; everything else
308
+ // (normal/square/unset) is the ordinary \fermata.
309
+ const fermataEl = getElements(notationsEl, 'fermata')[0];
310
+ if (fermataEl) {
311
+ result.fermata = (fermataEl.textContent || '').trim() === 'angled' ? 'short' : 'normal';
309
312
  }
310
313
  // Arpeggiate
311
314
  if (hasElement(notationsEl, 'arpeggiate')) {
@@ -326,6 +329,13 @@ const parseNotations = (notationsEl) => {
326
329
  number: getAttributeNumber(tupletEl, 'number') || 1,
327
330
  };
328
331
  }
332
+ // Glissando / slide — both are note-to-note slide lines. Only the START note
333
+ // carries the mark (LilyPond \glissando auto-connects to the next note), so we
334
+ // record presence of any start; stops are ignored.
335
+ const slideEls = [...getElements(notationsEl, 'glissando'), ...getElements(notationsEl, 'slide')];
336
+ if (slideEls.some(el => getAttribute(el, 'type') === 'start')) {
337
+ result.glissando = true;
338
+ }
329
339
  return result;
330
340
  };
331
341
  /**
@@ -570,6 +580,17 @@ const parseDirection = (dirEl) => {
570
580
  if (hasElement(dirTypeEl, 'segno')) {
571
581
  result.segno = true;
572
582
  }
583
+ // Rehearsal mark (<direction-type><rehearsal>A</rehearsal>). May be split into
584
+ // multiple <rehearsal> (e.g. "A" + a Chinese gloss "段"); join them. Collected
585
+ // from the whole direction so it is found regardless of which direction-type it
586
+ // sits in. Surfaced as text → markup by directionToMarks (per the text-as-markup
587
+ // convention; LilyPond's musicxml2ly emits \mark \markup { \box { … } }).
588
+ const rehearsalEls = Array.from(dirEl.getElementsByTagName('rehearsal'));
589
+ if (rehearsalEls.length > 0) {
590
+ const text = rehearsalEls.map(el => el.textContent?.trim() || '').filter(Boolean).join(' ');
591
+ if (text)
592
+ result.rehearsal = text;
593
+ }
573
594
  return result;
574
595
  };
575
596
  /**
@@ -640,6 +661,23 @@ const parseMetadata = (doc) => {
640
661
  if (movementTitleEl?.textContent && !metadata.title) {
641
662
  metadata.title = movementTitleEl.textContent.trim();
642
663
  }
664
+ // Subtitle: MusicXML has no <subtitle> element — it lives in a page <credit>
665
+ // tagged <credit-type>subtitle</credit-type>, with the text in <credit-words>.
666
+ const creditEls = doc.getElementsByTagName('credit');
667
+ for (let i = 0; i < creditEls.length; i++) {
668
+ const credit = creditEls[i];
669
+ const typeEl = credit.getElementsByTagName('credit-type')[0];
670
+ if (typeEl?.textContent?.trim().toLowerCase() === 'subtitle') {
671
+ const words = Array.from(credit.getElementsByTagName('credit-words'))
672
+ .map(w => w.textContent?.trim() || '')
673
+ .filter(Boolean)
674
+ .join(' ');
675
+ if (words) {
676
+ metadata.subtitle = words;
677
+ break;
678
+ }
679
+ }
680
+ }
643
681
  // Identification (composer, arranger, lyricist)
644
682
  const identificationEl = doc.getElementsByTagName('identification')[0];
645
683
  if (identificationEl) {
@@ -711,6 +749,12 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
711
749
  // Articulations
712
750
  if (notations.articulations) {
713
751
  for (const artName of notations.articulations) {
752
+ // breath-mark is a NoDirection articulation in MusicXML but maps to
753
+ // LilyPond's \breathe — model it as the breath ornament.
754
+ if (artName === 'breath-mark') {
755
+ marks.push({ markType: 'ornament', type: OrnamentType.breath });
756
+ continue;
757
+ }
714
758
  const artType = convertArticulation(artName);
715
759
  if (artType) {
716
760
  marks.push({ markType: 'articulation', type: artType });
@@ -728,12 +772,16 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
728
772
  }
729
773
  // Fermata
730
774
  if (notations.fermata) {
731
- marks.push({ markType: 'ornament', type: 'fermata' });
775
+ marks.push({ markType: 'ornament', type: (notations.fermata === 'short' ? 'shortFermata' : 'fermata') });
732
776
  }
733
777
  // Arpeggiate
734
778
  if (notations.arpeggiate) {
735
779
  marks.push({ markType: 'ornament', type: 'arpeggio' });
736
780
  }
781
+ // Glissando / slide (start note only)
782
+ if (notations.glissando) {
783
+ marks.push({ markType: 'glissando' });
784
+ }
737
785
  return marks;
738
786
  };
739
787
  // Common tempo words that should be converted to \tempo
@@ -887,16 +935,30 @@ const directionToMarks = (direction, spannerTracker) => {
887
935
  if (direction.segno) {
888
936
  marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
889
937
  }
938
+ // Rehearsal mark → markup (text-as-markup convention; LilyPond uses \mark).
939
+ if (direction.rehearsal) {
940
+ const placement = direction.placement === 'above' ? Placement.above
941
+ : direction.placement === 'below' ? Placement.below
942
+ : undefined;
943
+ marks.push({ markType: 'markup', content: direction.rehearsal, placement });
944
+ }
890
945
  // Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
891
946
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
892
- // ContextChange by directionToContextChange, so skip those here to avoid
893
- // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
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.
947
+ // ContextChange by directionToContextChange (which takes words[0]), so skip that
948
+ // one here to avoid double-emitting; the REST become a markup mark → MEI <dir>.
949
+ // A single <direction> often carries a tempo word AND an expressive/translation
950
+ // word together (e.g. <words>Moderato</words><words>中板</words>) — only the tempo
951
+ // word is consumed; the rest must still surface as markup. Metronome directions
952
+ // are tempo too, never markup. Navigation text ("D.C. al Fine", "To Coda",
953
+ // "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a markup glyph
954
+ // — the jump SEMANTICS are captured separately as measure-layout.
897
955
  let emittedWords = false;
898
956
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
899
- const text = direction.words.map(w => w.text).join('').trim();
957
+ // If words[0] was consumed as the tempo text, drop it and keep the remainder;
958
+ // otherwise the whole run is one expressive phrase, joined as today.
959
+ const firstIsTempo = isTempoWord(direction.words[0].text.trim());
960
+ const rest = firstIsTempo ? direction.words.slice(1) : direction.words;
961
+ const text = rest.map(w => w.text).join('').trim();
900
962
  if (text && !isTempoWord(text)) {
901
963
  const placement = direction.placement === 'above' ? Placement.above
902
964
  : direction.placement === 'below' ? Placement.below
@@ -1107,8 +1169,16 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1107
1169
  // hairpin stop that lands just before a rest (common in piano scores:
1108
1170
  // `<pedal stop/>` followed by rests filling the voice) is silently dropped.
1109
1171
  const marks = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
1110
- if (!note.isRest)
1172
+ if (!note.isRest) {
1111
1173
  pendingMarks.delete(voiceNum);
1174
+ // Drain directions that named this note's staff but had no known voice
1175
+ // yet (queued under the -staff placeholder key by the <direction> handler).
1176
+ const staffPending = pendingMarks.get(-staffNum);
1177
+ if (staffPending && staffPending.length > 0) {
1178
+ marks.push(...staffPending);
1179
+ pendingMarks.delete(-staffNum);
1180
+ }
1181
+ }
1112
1182
  if (note.isRest) {
1113
1183
  // Rest event
1114
1184
  const duration = convertDuration(voiceTracker.getDivisions(), note.duration.divisions, note.duration.type, note.duration.dots, note.duration.timeModification);
@@ -1126,7 +1196,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1126
1196
  // so the encoder can emit <fermata startid="#rest">; without this the
1127
1197
  // 3-of-4 fermatas that sit on rests in typical piano scores are lost.
1128
1198
  if (note.notations?.fermata) {
1129
- restEvent.marks = [{ markType: 'ornament', type: 'fermata' }];
1199
+ restEvent.marks = [{ markType: 'ornament', type: (note.notations.fermata === 'short' ? 'shortFermata' : 'fermata') }];
1130
1200
  }
1131
1201
  // Grace notes don't advance time
1132
1202
  const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
@@ -1225,9 +1295,17 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1225
1295
  // Handle marks (dynamics, hairpins, etc.)
1226
1296
  const marks = directionToMarks(direction, spannerTracker);
1227
1297
  if (marks.length > 0) {
1228
- // Store marks to attach to next note in current voice
1229
- const existing = pendingMarks.get(currentVoice) || [];
1230
- pendingMarks.set(currentVoice, [...existing, ...marks]);
1298
+ // Route to the voice the marks belong to. A <direction> in a grand-staff
1299
+ // part names its <staff>; the matching note is the NEXT note on that staff
1300
+ // — NOT currentVoice, which after a <backup> still points at the voice that
1301
+ // just ended (so a staff-2 direction emitted after the backup would wrongly
1302
+ // land on staff 1). Queue under a -staff placeholder key; the next note on
1303
+ // that staff drains it, and the end-of-measure flush falls back to that
1304
+ // staff's last note if no further note appears. Without a staff (single-
1305
+ // staff parts), fall back to currentVoice as before.
1306
+ const targetKey = direction.staff ? -direction.staff : currentVoice;
1307
+ const existing = pendingMarks.get(targetKey) || [];
1308
+ pendingMarks.set(targetKey, [...existing, ...marks]);
1231
1309
  }
1232
1310
  }
1233
1311
  else if (tagName === 'backup') {
@@ -1303,11 +1381,28 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
1303
1381
  }
1304
1382
  return undefined;
1305
1383
  };
1384
+ // A staff-keyed leftover (negative key = -staffNum, queued for a "next note on
1385
+ // this staff" that never came): resolve to the last note on any voice that lives
1386
+ // on that staff.
1387
+ const findLastNoteOnStaff = (staff) => {
1388
+ let best;
1389
+ for (const [, vs] of allVoices) {
1390
+ if ((vs.staff || 1) !== staff)
1391
+ continue;
1392
+ for (let i = vs.events.length - 1; i >= 0; i--) {
1393
+ if (vs.events[i].type === 'note') {
1394
+ best = vs.events[i];
1395
+ break;
1396
+ }
1397
+ }
1398
+ }
1399
+ return best;
1400
+ };
1306
1401
  for (const [voiceNum, marks] of pendingMarks) {
1307
1402
  if (marks.length === 0)
1308
1403
  continue;
1309
- let target = findLastNote(voiceNum);
1310
- // Voice had no note (e.g. direction-only or rest-only): attach to the
1404
+ let target = voiceNum < 0 ? findLastNoteOnStaff(-voiceNum) : findLastNote(voiceNum);
1405
+ // Voice/staff had no note (e.g. direction-only or rest-only): attach to the
1311
1406
  // last note of any voice so the marking is not silently dropped.
1312
1407
  if (!target) {
1313
1408
  for (const vn of allVoices.keys()) {