@k-l-lambda/lilylet 0.1.75 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/abc/grammar.jison.js +85 -79
  2. package/lib/lilylet/abcDecoder.js +167 -0
  3. package/lib/lilylet/grammar.jison.js +143 -125
  4. package/lib/lilylet/highlight.js +3 -1
  5. package/lib/lilylet/lilypondEncoder.js +10 -7
  6. package/lib/lilylet/measureLayout.d.ts +11 -0
  7. package/lib/lilylet/measureLayout.js +97 -0
  8. package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
  9. package/lib/lilylet/measureLayoutFromXml.js +3 -1
  10. package/lib/lilylet/meiEncoder.js +146 -70
  11. package/lib/lilylet/musicXmlDecoder.js +119 -24
  12. package/lib/lilylet/musicXmlEncoder.js +85 -18
  13. package/lib/lilylet/musicXmlTypes.d.ts +3 -1
  14. package/lib/lilylet/musicXmlUtils.js +1 -1
  15. package/lib/lilylet/serializer.js +21 -2
  16. package/lib/lilylet/types.d.ts +6 -2
  17. package/lib/lilylet/types.js +1 -0
  18. package/package.json +3 -1
  19. package/source/abc/abc.jison +8 -4
  20. package/source/abc/grammar.jison.js +85 -79
  21. package/source/lilylet/abcDecoder.ts +189 -0
  22. package/source/lilylet/grammar.jison.js +143 -125
  23. package/source/lilylet/highlight.ts +3 -1
  24. package/source/lilylet/lilylet.jison +33 -17
  25. package/source/lilylet/lilypondEncoder.ts +11 -7
  26. package/source/lilylet/measureLayout.ts +92 -0
  27. package/source/lilylet/measureLayoutFromXml.ts +3 -1
  28. package/source/lilylet/meiEncoder.ts +164 -66
  29. package/source/lilylet/musicXmlDecoder.ts +117 -23
  30. package/source/lilylet/musicXmlEncoder.ts +89 -16
  31. package/source/lilylet/musicXmlTypes.ts +5 -1
  32. package/source/lilylet/musicXmlUtils.ts +1 -1
  33. package/source/lilylet/serializer.ts +21 -2
  34. package/source/lilylet/types.ts +10 -1
@@ -25,6 +25,7 @@ import {
25
25
  HairpinType,
26
26
  PedalType,
27
27
  NavigationMarkType,
28
+ OrnamentType,
28
29
  Placement,
29
30
  BarlineEvent,
30
31
  HarmonyEvent,
@@ -305,12 +306,13 @@ class VoiceTracker {
305
306
  });
306
307
  this.currentStaff.set(voiceNum, staff);
307
308
  }
308
- const voice = this.voices.get(voiceNum)!;
309
- // Update staff if specified
310
- if (staff > 0) {
311
- voice.staff = staff;
312
- }
313
- return voice;
309
+ // NOTE: do NOT overwrite voice.staff on later notes. voice.staff is the voice's
310
+ // HOME staff — the staff of its first note — matching the parser, which fixes
311
+ // voice.staff to the first leading \staff (so `\staff "1" … \staff "2" …` gives
312
+ // voice.staff=1, with the switch carried by an inline ctx(staff=2) event). The
313
+ // previous code clobbered it to every note's staff, so a voice that ended on a
314
+ // different staff round-tripped with the wrong home staff.
315
+ return this.voices.get(voiceNum)!;
314
316
  }
315
317
 
316
318
  addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
@@ -430,9 +432,11 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
430
432
  }
431
433
  }
432
434
 
433
- // Fermata
434
- if (hasElement(notationsEl, 'fermata')) {
435
- result.fermata = true;
435
+ // Fermata. The "angled" shape is LilyPond's \shortfermata; everything else
436
+ // (normal/square/unset) is the ordinary \fermata.
437
+ const fermataEl = getElements(notationsEl, 'fermata')[0];
438
+ if (fermataEl) {
439
+ result.fermata = (fermataEl.textContent || '').trim() === 'angled' ? 'short' : 'normal';
436
440
  }
437
441
 
438
442
  // Arpeggiate
@@ -457,6 +461,14 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
457
461
  };
458
462
  }
459
463
 
464
+ // Glissando / slide — both are note-to-note slide lines. Only the START note
465
+ // carries the mark (LilyPond \glissando auto-connects to the next note), so we
466
+ // record presence of any start; stops are ignored.
467
+ const slideEls = [...getElements(notationsEl, 'glissando'), ...getElements(notationsEl, 'slide')];
468
+ if (slideEls.some(el => getAttribute(el, 'type') === 'start')) {
469
+ result.glissando = true;
470
+ }
471
+
460
472
  return result;
461
473
  };
462
474
 
@@ -726,6 +738,17 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
726
738
  result.segno = true;
727
739
  }
728
740
 
741
+ // Rehearsal mark (<direction-type><rehearsal>A</rehearsal>). May be split into
742
+ // multiple <rehearsal> (e.g. "A" + a Chinese gloss "段"); join them. Collected
743
+ // from the whole direction so it is found regardless of which direction-type it
744
+ // sits in. Surfaced as text → markup by directionToMarks (per the text-as-markup
745
+ // convention; LilyPond's musicxml2ly emits \mark \markup { \box { … } }).
746
+ const rehearsalEls = Array.from(dirEl.getElementsByTagName('rehearsal'));
747
+ if (rehearsalEls.length > 0) {
748
+ const text = rehearsalEls.map(el => el.textContent?.trim() || '').filter(Boolean).join(' ');
749
+ if (text) result.rehearsal = text;
750
+ }
751
+
729
752
  return result;
730
753
  };
731
754
 
@@ -811,6 +834,21 @@ const parseMetadata = (doc: Document): Metadata => {
811
834
  metadata.title = movementTitleEl.textContent.trim();
812
835
  }
813
836
 
837
+ // Subtitle: MusicXML has no <subtitle> element — it lives in a page <credit>
838
+ // tagged <credit-type>subtitle</credit-type>, with the text in <credit-words>.
839
+ const creditEls = doc.getElementsByTagName('credit');
840
+ for (let i = 0; i < creditEls.length; i++) {
841
+ const credit = creditEls[i];
842
+ const typeEl = credit.getElementsByTagName('credit-type')[0];
843
+ if (typeEl?.textContent?.trim().toLowerCase() === 'subtitle') {
844
+ const words = Array.from(credit.getElementsByTagName('credit-words'))
845
+ .map(w => w.textContent?.trim() || '')
846
+ .filter(Boolean)
847
+ .join(' ');
848
+ if (words) { metadata.subtitle = words; break; }
849
+ }
850
+ }
851
+
814
852
  // Identification (composer, arranger, lyricist)
815
853
  const identificationEl = doc.getElementsByTagName('identification')[0];
816
854
  if (identificationEl) {
@@ -890,6 +928,12 @@ const notationsToMarks = (
890
928
  // Articulations
891
929
  if (notations.articulations) {
892
930
  for (const artName of notations.articulations) {
931
+ // breath-mark is a NoDirection articulation in MusicXML but maps to
932
+ // LilyPond's \breathe — model it as the breath ornament.
933
+ if (artName === 'breath-mark') {
934
+ marks.push({ markType: 'ornament', type: OrnamentType.breath });
935
+ continue;
936
+ }
893
937
  const artType = convertArticulation(artName);
894
938
  if (artType) {
895
939
  marks.push({ markType: 'articulation', type: artType });
@@ -909,7 +953,7 @@ const notationsToMarks = (
909
953
 
910
954
  // Fermata
911
955
  if (notations.fermata) {
912
- marks.push({ markType: 'ornament', type: 'fermata' as any });
956
+ marks.push({ markType: 'ornament', type: (notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any });
913
957
  }
914
958
 
915
959
  // Arpeggiate
@@ -917,6 +961,11 @@ const notationsToMarks = (
917
961
  marks.push({ markType: 'ornament', type: 'arpeggio' as any });
918
962
  }
919
963
 
964
+ // Glissando / slide (start note only)
965
+ if (notations.glissando) {
966
+ marks.push({ markType: 'glissando' });
967
+ }
968
+
920
969
  return marks;
921
970
  };
922
971
 
@@ -1082,16 +1131,31 @@ const directionToMarks = (
1082
1131
  marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
1083
1132
  }
1084
1133
 
1134
+ // Rehearsal mark → markup (text-as-markup convention; LilyPond uses \mark).
1135
+ if (direction.rehearsal) {
1136
+ const placement = direction.placement === 'above' ? Placement.above
1137
+ : direction.placement === 'below' ? Placement.below
1138
+ : undefined;
1139
+ marks.push({ markType: 'markup', content: direction.rehearsal, placement });
1140
+ }
1141
+
1085
1142
  // Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
1086
1143
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
1087
- // ContextChange by directionToContextChange, so skip those here to avoid
1088
- // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
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.
1144
+ // ContextChange by directionToContextChange (which takes words[0]), so skip that
1145
+ // one here to avoid double-emitting; the REST become a markup mark → MEI <dir>.
1146
+ // A single <direction> often carries a tempo word AND an expressive/translation
1147
+ // word together (e.g. <words>Moderato</words><words>中板</words>) — only the tempo
1148
+ // word is consumed; the rest must still surface as markup. Metronome directions
1149
+ // are tempo too, never markup. Navigation text ("D.C. al Fine", "To Coda",
1150
+ // "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a markup glyph
1151
+ // — the jump SEMANTICS are captured separately as measure-layout.
1092
1152
  let emittedWords = false;
1093
1153
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
1094
- const text = direction.words.map(w => w.text).join('').trim();
1154
+ // If words[0] was consumed as the tempo text, drop it and keep the remainder;
1155
+ // otherwise the whole run is one expressive phrase, joined as today.
1156
+ const firstIsTempo = isTempoWord(direction.words[0].text.trim());
1157
+ const rest = firstIsTempo ? direction.words.slice(1) : direction.words;
1158
+ const text = rest.map(w => w.text).join('').trim();
1095
1159
  if (text && !isTempoWord(text)) {
1096
1160
  const placement = direction.placement === 'above' ? Placement.above
1097
1161
  : direction.placement === 'below' ? Placement.below
@@ -1338,7 +1402,16 @@ const convertMeasure = (
1338
1402
  // hairpin stop that lands just before a rest (common in piano scores:
1339
1403
  // `<pedal stop/>` followed by rests filling the voice) is silently dropped.
1340
1404
  const marks: Mark[] = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
1341
- if (!note.isRest) pendingMarks.delete(voiceNum);
1405
+ if (!note.isRest) {
1406
+ pendingMarks.delete(voiceNum);
1407
+ // Drain directions that named this note's staff but had no known voice
1408
+ // yet (queued under the -staff placeholder key by the <direction> handler).
1409
+ const staffPending = pendingMarks.get(-staffNum);
1410
+ if (staffPending && staffPending.length > 0) {
1411
+ marks.push(...staffPending);
1412
+ pendingMarks.delete(-staffNum);
1413
+ }
1414
+ }
1342
1415
 
1343
1416
  if (note.isRest) {
1344
1417
  // Rest event
@@ -1366,7 +1439,7 @@ const convertMeasure = (
1366
1439
  // so the encoder can emit <fermata startid="#rest">; without this the
1367
1440
  // 3-of-4 fermatas that sit on rests in typical piano scores are lost.
1368
1441
  if (note.notations?.fermata) {
1369
- restEvent.marks = [{ markType: 'ornament', type: 'fermata' as any }];
1442
+ restEvent.marks = [{ markType: 'ornament', type: (note.notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any }];
1370
1443
  }
1371
1444
 
1372
1445
  // Grace notes don't advance time
@@ -1480,9 +1553,17 @@ const convertMeasure = (
1480
1553
  // Handle marks (dynamics, hairpins, etc.)
1481
1554
  const marks = directionToMarks(direction, spannerTracker);
1482
1555
  if (marks.length > 0) {
1483
- // Store marks to attach to next note in current voice
1484
- const existing = pendingMarks.get(currentVoice) || [];
1485
- pendingMarks.set(currentVoice, [...existing, ...marks]);
1556
+ // Route to the voice the marks belong to. A <direction> in a grand-staff
1557
+ // part names its <staff>; the matching note is the NEXT note on that staff
1558
+ // — NOT currentVoice, which after a <backup> still points at the voice that
1559
+ // just ended (so a staff-2 direction emitted after the backup would wrongly
1560
+ // land on staff 1). Queue under a -staff placeholder key; the next note on
1561
+ // that staff drains it, and the end-of-measure flush falls back to that
1562
+ // staff's last note if no further note appears. Without a staff (single-
1563
+ // staff parts), fall back to currentVoice as before.
1564
+ const targetKey = direction.staff ? -direction.staff : currentVoice;
1565
+ const existing = pendingMarks.get(targetKey) || [];
1566
+ pendingMarks.set(targetKey, [...existing, ...marks]);
1486
1567
  }
1487
1568
  } else if (tagName === 'backup') {
1488
1569
  // A <forward> with no note after it (before this backup) is cursor
@@ -1560,10 +1641,23 @@ const convertMeasure = (
1560
1641
  }
1561
1642
  return undefined;
1562
1643
  };
1644
+ // A staff-keyed leftover (negative key = -staffNum, queued for a "next note on
1645
+ // this staff" that never came): resolve to the last note on any voice that lives
1646
+ // on that staff.
1647
+ const findLastNoteOnStaff = (staff: number): NoteEvent | undefined => {
1648
+ let best: NoteEvent | undefined;
1649
+ for (const [, vs] of allVoices) {
1650
+ if ((vs.staff || 1) !== staff) continue;
1651
+ for (let i = vs.events.length - 1; i >= 0; i--) {
1652
+ if (vs.events[i].type === 'note') { best = vs.events[i] as NoteEvent; break; }
1653
+ }
1654
+ }
1655
+ return best;
1656
+ };
1563
1657
  for (const [voiceNum, marks] of pendingMarks) {
1564
1658
  if (marks.length === 0) continue;
1565
- let target = findLastNote(voiceNum);
1566
- // Voice had no note (e.g. direction-only or rest-only): attach to the
1659
+ let target = voiceNum < 0 ? findLastNoteOnStaff(-voiceNum) : findLastNote(voiceNum);
1660
+ // Voice/staff had no note (e.g. direction-only or rest-only): attach to the
1567
1661
  // last note of any voice so the marking is not silently dropped.
1568
1662
  if (!target) {
1569
1663
  for (const vn of allVoices.keys()) {
@@ -32,6 +32,7 @@ import {
32
32
  DynamicType,
33
33
  HairpinType,
34
34
  PedalType,
35
+ NavigationMarkType,
35
36
  Tempo,
36
37
  Metadata,
37
38
  Fraction,
@@ -124,6 +125,7 @@ const DYNAMIC_TO_XML: Record<string, string> = {
124
125
  fff: 'fff',
125
126
  sfz: 'sfz',
126
127
  rfz: 'rfz',
128
+ fp: 'fp',
127
129
  };
128
130
 
129
131
  // Barline style to MusicXML
@@ -289,6 +291,7 @@ const encodeAttributes = (
289
291
  const encodeNotations = (marks: Mark[], level: number): string => {
290
292
  const articulations: string[] = [];
291
293
  const ornaments: string[] = [];
294
+ const technical: string[] = [];
292
295
  const otherNotations: string[] = [];
293
296
 
294
297
  for (const mark of marks) {
@@ -301,15 +304,20 @@ const encodeNotations = (marks: Mark[], level: number): string => {
301
304
  break;
302
305
 
303
306
  case 'ornament':
304
- const ornXml = ORNAMENT_TO_XML[mark.type];
305
- if (ornXml) {
306
- if (mark.type === 'fermata') {
307
- otherNotations.push('<fermata/>');
308
- } else if (mark.type === 'arpeggio') {
309
- otherNotations.push('<arpeggiate/>');
310
- } else {
311
- ornaments.push(`<${ornXml}/>`);
312
- }
307
+ // fermata / arpeggio live at the <notations> level; breath-mark is an
308
+ // <articulations> child; the rest are true <ornaments>. (shortFermata is
309
+ // a fermata with an "angled" shape — decoder maps it back.)
310
+ if (mark.type === 'fermata') {
311
+ otherNotations.push('<fermata/>');
312
+ } else if (mark.type === 'shortFermata') {
313
+ otherNotations.push('<fermata>angled</fermata>');
314
+ } else if (mark.type === 'arpeggio') {
315
+ otherNotations.push('<arpeggiate/>');
316
+ } else if (mark.type === 'breath') {
317
+ articulations.push('<breath-mark/>');
318
+ } else {
319
+ const ornXml = ORNAMENT_TO_XML[mark.type];
320
+ if (ornXml) ornaments.push(`<${ornXml}/>`);
313
321
  }
314
322
  break;
315
323
 
@@ -325,13 +333,22 @@ const encodeNotations = (marks: Mark[], level: number): string => {
325
333
  otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
326
334
  break;
327
335
 
336
+ case 'glissando':
337
+ // Lilylet \glissando is start-only (the line auto-connects to the next
338
+ // note); emit just the start spanner so the decoder reads it back.
339
+ otherNotations.push('<glissando type="start" number="1"/>');
340
+ break;
341
+
328
342
  case 'fingering':
329
- // Fingering goes in technical
343
+ technical.push(`<fingering>${mark.finger}</fingering>`);
330
344
  break;
331
345
  }
332
346
  }
333
347
 
334
- if (articulations.length === 0 && ornaments.length === 0 && otherNotations.length === 0) {
348
+ if (
349
+ articulations.length === 0 && ornaments.length === 0 &&
350
+ technical.length === 0 && otherNotations.length === 0
351
+ ) {
335
352
  return '';
336
353
  }
337
354
 
@@ -357,6 +374,14 @@ const encodeNotations = (marks: Mark[], level: number): string => {
357
374
  xml += `${indent(level + 1)}</ornaments>\n`;
358
375
  }
359
376
 
377
+ if (technical.length > 0) {
378
+ xml += `${indent(level + 1)}<technical>\n`;
379
+ for (const tech of technical) {
380
+ xml += `${indent(level + 2)}${tech}\n`;
381
+ }
382
+ xml += `${indent(level + 1)}</technical>\n`;
383
+ }
384
+
360
385
  xml += `${indent(level)}</notations>\n`;
361
386
 
362
387
  return xml;
@@ -518,10 +543,19 @@ const encodeRestWithTuplet = (
518
543
  */
519
544
  const encodeDirection = (
520
545
  marks: Mark[],
521
- level: number
546
+ level: number,
547
+ staff: number = 0,
548
+ emitStaff: boolean = false
522
549
  ): string => {
523
550
  let xml = '';
524
551
 
552
+ // In a grand-staff part, a <direction> must name its <staff> or the decoder
553
+ // can't route the mark back to the right staff (it would attach to the current
554
+ // voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
555
+ const staffTag = (emitStaff && staff > 0)
556
+ ? `${indent(level + 1)}<staff>${staff}</staff>\n`
557
+ : '';
558
+
525
559
  for (const mark of marks) {
526
560
  if (mark.markType === 'dynamic') {
527
561
  const dynXml = DYNAMIC_TO_XML[mark.type];
@@ -532,6 +566,7 @@ const encodeDirection = (
532
566
  xml += `${indent(level + 3)}<${dynXml}/>\n`;
533
567
  xml += `${indent(level + 2)}</dynamics>\n`;
534
568
  xml += `${indent(level + 1)}</direction-type>\n`;
569
+ xml += staffTag;
535
570
  xml += `${indent(level)}</direction>\n`;
536
571
  }
537
572
  } else if (mark.markType === 'hairpin') {
@@ -548,6 +583,7 @@ const encodeDirection = (
548
583
  xml += `${indent(level + 1)}<direction-type>\n`;
549
584
  xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
550
585
  xml += `${indent(level + 1)}</direction-type>\n`;
586
+ xml += staffTag;
551
587
  xml += `${indent(level)}</direction>\n`;
552
588
  }
553
589
  } else if (mark.markType === 'pedal') {
@@ -562,8 +598,28 @@ const encodeDirection = (
562
598
  xml += `${indent(level + 1)}<direction-type>\n`;
563
599
  xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
564
600
  xml += `${indent(level + 1)}</direction-type>\n`;
601
+ xml += staffTag;
565
602
  xml += `${indent(level)}</direction>\n`;
566
603
  }
604
+ } else if (mark.markType === 'markup') {
605
+ // Text direction → <words>. The decoder reads any non-tempo <words> back
606
+ // as a markup mark (text-as-markup convention).
607
+ const placement = mark.placement ? ` placement="${mark.placement}"` : '';
608
+ xml += `${indent(level)}<direction${placement}>\n`;
609
+ xml += `${indent(level + 1)}<direction-type>\n`;
610
+ xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
611
+ xml += `${indent(level + 1)}</direction-type>\n`;
612
+ xml += staffTag;
613
+ xml += `${indent(level)}</direction>\n`;
614
+ } else if (mark.markType === 'navigation') {
615
+ // \coda / \segno glyph → <direction-type><coda|segno>.
616
+ const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
617
+ xml += `${indent(level)}<direction>\n`;
618
+ xml += `${indent(level + 1)}<direction-type>\n`;
619
+ xml += `${indent(level + 2)}<${glyph}/>\n`;
620
+ xml += `${indent(level + 1)}</direction-type>\n`;
621
+ xml += staffTag;
622
+ xml += `${indent(level)}</direction>\n`;
567
623
  }
568
624
  }
569
625
 
@@ -718,12 +774,14 @@ const encodeMeasure = (
718
774
  for (const event of voice.events) {
719
775
  switch (event.type) {
720
776
  case 'note': {
721
- // Check for direction marks (dynamics, hairpins, pedals)
777
+ // Check for direction marks (dynamics, hairpins, pedals, text, nav)
722
778
  const directionMarks = event.marks?.filter(m =>
723
- m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
779
+ m.markType === 'dynamic' || m.markType === 'hairpin' ||
780
+ m.markType === 'pedal' || m.markType === 'markup' ||
781
+ m.markType === 'navigation'
724
782
  ) || [];
725
783
  if (directionMarks.length > 0) {
726
- xml += encodeDirection(directionMarks, level + 1);
784
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
727
785
  }
728
786
 
729
787
  // Encode main note
@@ -731,11 +789,15 @@ const encodeMeasure = (
731
789
  const dur = calculateDuration(event.duration);
732
790
  voicePosition += dur;
733
791
 
734
- // Encode chord notes
792
+ // Encode chord notes. Marks live on the parent NoteEvent (the whole
793
+ // chord) and are emitted on the first note only — re-emitting them on
794
+ // each member would multiply them on decode (the decoder aggregates
795
+ // every member's marks back onto the one chord event).
735
796
  for (let i = 1; i < event.pitches.length; i++) {
736
797
  const chordEvent: NoteEvent = {
737
798
  ...event,
738
799
  pitches: [event.pitches[i]],
800
+ marks: undefined,
739
801
  };
740
802
  xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
741
803
  }
@@ -773,6 +835,17 @@ const encodeMeasure = (
773
835
  const isLast = ti === tupletEvents.length - 1;
774
836
 
775
837
  if (subEvent.type === 'note') {
838
+ // Direction marks (dynamics, hairpins, pedals, text, nav) — same
839
+ // as the plain-note case; without this they vanish inside tuplets.
840
+ const directionMarks = subEvent.marks?.filter(m =>
841
+ m.markType === 'dynamic' || m.markType === 'hairpin' ||
842
+ m.markType === 'pedal' || m.markType === 'markup' ||
843
+ m.markType === 'navigation'
844
+ ) || [];
845
+ if (directionMarks.length > 0) {
846
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
847
+ }
848
+
776
849
  // Add tuplet notation marks
777
850
  const tupletMarks: Mark[] = [];
778
851
  if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
@@ -59,10 +59,13 @@ export interface MusicXmlNotations {
59
59
  beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
60
60
  articulations?: string[]; // staccato, accent, tenuto, etc.
61
61
  ornaments?: string[]; // trill, turn, mordent, etc.
62
- fermata?: boolean;
62
+ fermata?: 'normal' | 'short'; // 'short' = angled shape (LilyPond \shortfermata)
63
63
  arpeggiate?: boolean;
64
64
  tremolo?: { type: 'single' | 'start' | 'stop'; value: number };
65
65
  tuplet?: { type: 'start' | 'stop'; number: number };
66
+ // Glissando / slide spanner: only the start matters for lilylet (LilyPond emits
67
+ // \glissando on the start note; the line auto-connects to the next note).
68
+ glissando?: boolean;
66
69
  }
67
70
 
68
71
  export interface MusicXmlNote {
@@ -121,6 +124,7 @@ export interface MusicXmlDirection {
121
124
  pedal?: MusicXmlPedal;
122
125
  metronome?: MusicXmlMetronome;
123
126
  words?: MusicXmlWords[];
127
+ rehearsal?: string; // <direction-type><rehearsal> text (e.g. "A", "段")
124
128
  octaveShift?: MusicXmlOctaveShift;
125
129
  coda?: boolean;
126
130
  segno?: boolean;
@@ -416,7 +416,7 @@ const DYNAMIC_MAP: Record<string, DynamicType> = {
416
416
  fz: DynamicType.sfz,
417
417
  sfp: DynamicType.sfz,
418
418
  sfpp: DynamicType.sfz,
419
- fp: DynamicType.f, // forte-piano, approximate
419
+ fp: DynamicType.fp, // forte-piano
420
420
  };
421
421
 
422
422
  export const convertDynamic = (name: string): DynamicType | undefined => {
@@ -166,6 +166,7 @@ const ORNAMENT_MAP: Record<string, string> = {
166
166
  fermata: '\\fermata',
167
167
  shortFermata: '\\shortfermata',
168
168
  arpeggio: '\\arpeggio',
169
+ breath: '\\breathe',
169
170
  };
170
171
 
171
172
 
@@ -301,6 +302,19 @@ const serializeMarks = (marks: Mark[]): string => {
301
302
  case 'fingering':
302
303
  parts.push('-' + mark.finger);
303
304
  break;
305
+ case 'markup': {
306
+ // Text direction attached to a note (e.g. ^\markup "cantabile").
307
+ // Placement: above → ^, below → _, unspecified → - (default).
308
+ const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
309
+ parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
310
+ break;
311
+ }
312
+ case 'navigation':
313
+ parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
314
+ break;
315
+ case 'glissando':
316
+ parts.push('\\glissando');
317
+ break;
304
318
  }
305
319
  }
306
320
 
@@ -631,7 +645,7 @@ const serializeEvent = (
631
645
  case 'markup': {
632
646
  const mk = event as MarkupEvent;
633
647
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
634
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
648
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
635
649
  }
636
650
  case 'dynamic': {
637
651
  const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
@@ -1011,7 +1025,12 @@ const serializeMeasure = (
1011
1025
 
1012
1026
  // Escape string for serialization (quotes and backslashes)
1013
1027
  const escapeString = (str: string): string => {
1014
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1028
+ return str
1029
+ .replace(/\\/g, '\\\\')
1030
+ .replace(/"/g, '\\"')
1031
+ .replace(/\n/g, '\\n')
1032
+ .replace(/\r/g, '\\r')
1033
+ .replace(/\t/g, '\\t');
1015
1034
  };
1016
1035
 
1017
1036
  // Serialize metadata
@@ -47,6 +47,7 @@ export enum OrnamentType {
47
47
  fermata = 'fermata',
48
48
  shortFermata = 'shortFermata',
49
49
  arpeggio = 'arpeggio',
50
+ breath = 'breath', // breath mark (\breathe) — MusicXML <breath-mark>
50
51
  }
51
52
 
52
53
  export enum DynamicType {
@@ -187,7 +188,15 @@ export interface MarkupMark {
187
188
  placement?: Placement;
188
189
  }
189
190
 
190
- export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark;
191
+ // Glissando / slide: a note-to-note slide line. Mirrors LilyPond's \glissando,
192
+ // which is emitted only on the START note (the line auto-connects to the next
193
+ // note), so this is a single mark — no start/stop pairing. MusicXML <glissando>
194
+ // and <slide> both decode to this.
195
+ export interface Glissando {
196
+ markType: 'glissando';
197
+ }
198
+
199
+ export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
191
200
 
192
201
  // === Key Signature ===
193
202