@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
@@ -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()) {
@@ -4,7 +4,7 @@
4
4
  * Converts LilyletDoc to MusicXML format.
5
5
  * Produces valid MusicXML 4.0 partwise documents.
6
6
  */
7
- import { StemDirection, Accidental, HairpinType, PedalType, } from "./types.js";
7
+ import { StemDirection, Accidental, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
8
8
  import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
9
9
  import { parseStaffLayout } from "./staffLayout.js";
10
10
  // === Constants and Reverse Mappings ===
@@ -78,6 +78,7 @@ const DYNAMIC_TO_XML = {
78
78
  fff: 'fff',
79
79
  sfz: 'sfz',
80
80
  rfz: 'rfz',
81
+ fp: 'fp',
81
82
  };
82
83
  // Barline style to MusicXML
83
84
  const BARLINE_TO_XML = {
@@ -202,6 +203,7 @@ const encodeAttributes = (level, options) => {
202
203
  const encodeNotations = (marks, level) => {
203
204
  const articulations = [];
204
205
  const ornaments = [];
206
+ const technical = [];
205
207
  const otherNotations = [];
206
208
  for (const mark of marks) {
207
209
  switch (mark.markType) {
@@ -212,17 +214,25 @@ const encodeNotations = (marks, level) => {
212
214
  }
213
215
  break;
214
216
  case 'ornament':
215
- const ornXml = ORNAMENT_TO_XML[mark.type];
216
- if (ornXml) {
217
- if (mark.type === 'fermata') {
218
- otherNotations.push('<fermata/>');
219
- }
220
- else if (mark.type === 'arpeggio') {
221
- otherNotations.push('<arpeggiate/>');
222
- }
223
- else {
217
+ // fermata / arpeggio live at the <notations> level; breath-mark is an
218
+ // <articulations> child; the rest are true <ornaments>. (shortFermata is
219
+ // a fermata with an "angled" shape — decoder maps it back.)
220
+ if (mark.type === 'fermata') {
221
+ otherNotations.push('<fermata/>');
222
+ }
223
+ else if (mark.type === 'shortFermata') {
224
+ otherNotations.push('<fermata>angled</fermata>');
225
+ }
226
+ else if (mark.type === 'arpeggio') {
227
+ otherNotations.push('<arpeggiate/>');
228
+ }
229
+ else if (mark.type === 'breath') {
230
+ articulations.push('<breath-mark/>');
231
+ }
232
+ else {
233
+ const ornXml = ORNAMENT_TO_XML[mark.type];
234
+ if (ornXml)
224
235
  ornaments.push(`<${ornXml}/>`);
225
- }
226
236
  }
227
237
  break;
228
238
  case 'tie':
@@ -234,12 +244,18 @@ const encodeNotations = (marks, level) => {
234
244
  case 'tuplet':
235
245
  otherNotations.push(`<tuplet type="${mark.start ? 'start' : 'stop'}"/>`);
236
246
  break;
247
+ case 'glissando':
248
+ // Lilylet \glissando is start-only (the line auto-connects to the next
249
+ // note); emit just the start spanner so the decoder reads it back.
250
+ otherNotations.push('<glissando type="start" number="1"/>');
251
+ break;
237
252
  case 'fingering':
238
- // Fingering goes in technical
253
+ technical.push(`<fingering>${mark.finger}</fingering>`);
239
254
  break;
240
255
  }
241
256
  }
242
- if (articulations.length === 0 && ornaments.length === 0 && otherNotations.length === 0) {
257
+ if (articulations.length === 0 && ornaments.length === 0 &&
258
+ technical.length === 0 && otherNotations.length === 0) {
243
259
  return '';
244
260
  }
245
261
  let xml = `${indent(level)}<notations>\n`;
@@ -260,6 +276,13 @@ const encodeNotations = (marks, level) => {
260
276
  }
261
277
  xml += `${indent(level + 1)}</ornaments>\n`;
262
278
  }
279
+ if (technical.length > 0) {
280
+ xml += `${indent(level + 1)}<technical>\n`;
281
+ for (const tech of technical) {
282
+ xml += `${indent(level + 2)}${tech}\n`;
283
+ }
284
+ xml += `${indent(level + 1)}</technical>\n`;
285
+ }
263
286
  xml += `${indent(level)}</notations>\n`;
264
287
  return xml;
265
288
  };
@@ -369,8 +392,14 @@ const encodeRestWithTuplet = (event, voice, staff, level, isFirst, isLast) => {
369
392
  /**
370
393
  * Encode direction element (dynamics, tempo, etc.)
371
394
  */
372
- const encodeDirection = (marks, level) => {
395
+ const encodeDirection = (marks, level, staff = 0, emitStaff = false) => {
373
396
  let xml = '';
397
+ // In a grand-staff part, a <direction> must name its <staff> or the decoder
398
+ // can't route the mark back to the right staff (it would attach to the current
399
+ // voice/staff and drift — observed on chopin dynamics and cross-measure pedals).
400
+ const staffTag = (emitStaff && staff > 0)
401
+ ? `${indent(level + 1)}<staff>${staff}</staff>\n`
402
+ : '';
374
403
  for (const mark of marks) {
375
404
  if (mark.markType === 'dynamic') {
376
405
  const dynXml = DYNAMIC_TO_XML[mark.type];
@@ -381,6 +410,7 @@ const encodeDirection = (marks, level) => {
381
410
  xml += `${indent(level + 3)}<${dynXml}/>\n`;
382
411
  xml += `${indent(level + 2)}</dynamics>\n`;
383
412
  xml += `${indent(level + 1)}</direction-type>\n`;
413
+ xml += staffTag;
384
414
  xml += `${indent(level)}</direction>\n`;
385
415
  }
386
416
  }
@@ -400,6 +430,7 @@ const encodeDirection = (marks, level) => {
400
430
  xml += `${indent(level + 1)}<direction-type>\n`;
401
431
  xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
402
432
  xml += `${indent(level + 1)}</direction-type>\n`;
433
+ xml += staffTag;
403
434
  xml += `${indent(level)}</direction>\n`;
404
435
  }
405
436
  }
@@ -416,9 +447,31 @@ const encodeDirection = (marks, level) => {
416
447
  xml += `${indent(level + 1)}<direction-type>\n`;
417
448
  xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
418
449
  xml += `${indent(level + 1)}</direction-type>\n`;
450
+ xml += staffTag;
419
451
  xml += `${indent(level)}</direction>\n`;
420
452
  }
421
453
  }
454
+ else if (mark.markType === 'markup') {
455
+ // Text direction → <words>. The decoder reads any non-tempo <words> back
456
+ // as a markup mark (text-as-markup convention).
457
+ const placement = mark.placement ? ` placement="${mark.placement}"` : '';
458
+ xml += `${indent(level)}<direction${placement}>\n`;
459
+ xml += `${indent(level + 1)}<direction-type>\n`;
460
+ xml += `${indent(level + 2)}<words>${escapeXml(mark.content)}</words>\n`;
461
+ xml += `${indent(level + 1)}</direction-type>\n`;
462
+ xml += staffTag;
463
+ xml += `${indent(level)}</direction>\n`;
464
+ }
465
+ else if (mark.markType === 'navigation') {
466
+ // \coda / \segno glyph → <direction-type><coda|segno>.
467
+ const glyph = mark.type === NavigationMarkType.coda ? 'coda' : 'segno';
468
+ xml += `${indent(level)}<direction>\n`;
469
+ xml += `${indent(level + 1)}<direction-type>\n`;
470
+ xml += `${indent(level + 2)}<${glyph}/>\n`;
471
+ xml += `${indent(level + 1)}</direction-type>\n`;
472
+ xml += staffTag;
473
+ xml += `${indent(level)}</direction>\n`;
474
+ }
422
475
  }
423
476
  return xml;
424
477
  };
@@ -536,20 +589,26 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
536
589
  for (const event of voice.events) {
537
590
  switch (event.type) {
538
591
  case 'note': {
539
- // Check for direction marks (dynamics, hairpins, pedals)
540
- const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal') || [];
592
+ // Check for direction marks (dynamics, hairpins, pedals, text, nav)
593
+ const directionMarks = event.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
594
+ m.markType === 'pedal' || m.markType === 'markup' ||
595
+ m.markType === 'navigation') || [];
541
596
  if (directionMarks.length > 0) {
542
- xml += encodeDirection(directionMarks, level + 1);
597
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
543
598
  }
544
599
  // Encode main note
545
600
  xml += encodeNote(event, voiceNum, currentStaff, level + 1);
546
601
  const dur = calculateDuration(event.duration);
547
602
  voicePosition += dur;
548
- // Encode chord notes
603
+ // Encode chord notes. Marks live on the parent NoteEvent (the whole
604
+ // chord) and are emitted on the first note only — re-emitting them on
605
+ // each member would multiply them on decode (the decoder aggregates
606
+ // every member's marks back onto the one chord event).
549
607
  for (let i = 1; i < event.pitches.length; i++) {
550
608
  const chordEvent = {
551
609
  ...event,
552
610
  pitches: [event.pitches[i]],
611
+ marks: undefined,
553
612
  };
554
613
  xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
555
614
  }
@@ -582,6 +641,14 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
582
641
  const isFirst = ti === 0;
583
642
  const isLast = ti === tupletEvents.length - 1;
584
643
  if (subEvent.type === 'note') {
644
+ // Direction marks (dynamics, hairpins, pedals, text, nav) — same
645
+ // as the plain-note case; without this they vanish inside tuplets.
646
+ const directionMarks = subEvent.marks?.filter(m => m.markType === 'dynamic' || m.markType === 'hairpin' ||
647
+ m.markType === 'pedal' || m.markType === 'markup' ||
648
+ m.markType === 'navigation') || [];
649
+ if (directionMarks.length > 0) {
650
+ xml += encodeDirection(directionMarks, level + 1, currentStaff, maxStaff > 1);
651
+ }
585
652
  // Add tuplet notation marks
586
653
  const tupletMarks = [];
587
654
  if (isFirst)
@@ -56,7 +56,7 @@ export interface MusicXmlNotations {
56
56
  }[];
57
57
  articulations?: string[];
58
58
  ornaments?: string[];
59
- fermata?: boolean;
59
+ fermata?: 'normal' | 'short';
60
60
  arpeggiate?: boolean;
61
61
  tremolo?: {
62
62
  type: 'single' | 'start' | 'stop';
@@ -66,6 +66,7 @@ export interface MusicXmlNotations {
66
66
  type: 'start' | 'stop';
67
67
  number: number;
68
68
  };
69
+ glissando?: boolean;
69
70
  }
70
71
  export interface MusicXmlNote {
71
72
  isChord: boolean;
@@ -117,6 +118,7 @@ export interface MusicXmlDirection {
117
118
  pedal?: MusicXmlPedal;
118
119
  metronome?: MusicXmlMetronome;
119
120
  words?: MusicXmlWords[];
121
+ rehearsal?: string;
120
122
  octaveShift?: MusicXmlOctaveShift;
121
123
  coda?: boolean;
122
124
  segno?: boolean;
@@ -339,7 +339,7 @@ const DYNAMIC_MAP = {
339
339
  fz: DynamicType.sfz,
340
340
  sfp: DynamicType.sfz,
341
341
  sfpp: DynamicType.sfz,
342
- fp: DynamicType.f, // forte-piano, approximate
342
+ fp: DynamicType.fp, // forte-piano
343
343
  };
344
344
  export const convertDynamic = (name) => {
345
345
  return DYNAMIC_MAP[name.toLowerCase()];
@@ -114,6 +114,7 @@ const ORNAMENT_MAP = {
114
114
  fermata: '\\fermata',
115
115
  shortFermata: '\\shortfermata',
116
116
  arpeggio: '\\arpeggio',
117
+ breath: '\\breathe',
117
118
  };
118
119
  // Dynamic to Lilylet notation
119
120
  const DYNAMIC_MAP = {
@@ -232,6 +233,19 @@ const serializeMarks = (marks) => {
232
233
  case 'fingering':
233
234
  parts.push('-' + mark.finger);
234
235
  break;
236
+ case 'markup': {
237
+ // Text direction attached to a note (e.g. ^\markup "cantabile").
238
+ // Placement: above → ^, below → _, unspecified → - (default).
239
+ const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
240
+ parts.push(prefix + '\\markup "' + escapeString(mark.content) + '"');
241
+ break;
242
+ }
243
+ case 'navigation':
244
+ parts.push(mark.type === 'coda' ? '\\coda' : '\\segno');
245
+ break;
246
+ case 'glissando':
247
+ parts.push('\\glissando');
248
+ break;
235
249
  }
236
250
  }
237
251
  return parts.join('');
@@ -507,7 +521,7 @@ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) =
507
521
  case 'markup': {
508
522
  const mk = event;
509
523
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
510
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
524
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
511
525
  }
512
526
  case 'dynamic': {
513
527
  const dynStr = DYNAMIC_MAP[event.dynamicType];
@@ -841,7 +855,12 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
841
855
  };
842
856
  // Escape string for serialization (quotes and backslashes)
843
857
  const escapeString = (str) => {
844
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
858
+ return str
859
+ .replace(/\\/g, '\\\\')
860
+ .replace(/"/g, '\\"')
861
+ .replace(/\n/g, '\\n')
862
+ .replace(/\r/g, '\\r')
863
+ .replace(/\t/g, '\\t');
845
864
  };
846
865
  // Serialize metadata
847
866
  const serializeMetadata = (metadata) => {
@@ -39,7 +39,8 @@ export declare enum OrnamentType {
39
39
  prall = "prall",
40
40
  fermata = "fermata",
41
41
  shortFermata = "shortFermata",
42
- arpeggio = "arpeggio"
42
+ arpeggio = "arpeggio",
43
+ breath = "breath"
43
44
  }
44
45
  export declare enum DynamicType {
45
46
  ppp = "ppp",
@@ -150,7 +151,10 @@ export interface MarkupMark {
150
151
  content: string;
151
152
  placement?: Placement;
152
153
  }
153
- export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark;
154
+ export interface Glissando {
155
+ markType: 'glissando';
156
+ }
157
+ export type Mark = Articulation | Ornament | Dynamic | Hairpin | Tie | Slur | Beam | Pedal | Fingering | NavigationMark | MarkupMark | Glissando;
154
158
  export interface KeySignature {
155
159
  pitch: Phonet;
156
160
  accidental?: Accidental;
@@ -47,6 +47,7 @@ export var OrnamentType;
47
47
  OrnamentType["fermata"] = "fermata";
48
48
  OrnamentType["shortFermata"] = "shortFermata";
49
49
  OrnamentType["arpeggio"] = "arpeggio";
50
+ OrnamentType["breath"] = "breath";
50
51
  })(OrnamentType || (OrnamentType = {}));
51
52
  export var DynamicType;
52
53
  (function (DynamicType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.75",
3
+ "version": "0.1.78",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -41,6 +41,7 @@
41
41
  "test:abc": "tsx ./tests/abc-decoder.ts",
42
42
  "test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
43
43
  "test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
44
+ "test:xml-roundtrip": "tsx ./tests/musicxml-roundtrip.ts",
44
45
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
45
46
  "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
46
47
  "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
@@ -48,6 +49,7 @@
48
49
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
49
50
  "test:measures": "tsx ./tests/unit/measureLayout.test.ts",
50
51
  "test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
52
+ "test:abc-repeats": "tsx ./tests/unit/abcRepeatLayout.test.ts",
51
53
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
52
54
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
53
55
  "ts": "tsx"
@@ -542,15 +542,18 @@ bar
542
542
  | ':' '|' ']' -> ':|]'
543
543
  | '|' N -> '|' + $2
544
544
  | '|' N volta_rest -> '|' + $2 + $3
545
- | ':' '|' N -> ':|' + $2
546
- | ':' '|' N volta_rest -> ':|' + $2 + $3
545
+ | ':' '|' N -> ':|' + $3
546
+ | ':' '|' N volta_rest -> ':|' + $3 + $4
547
547
  | '&' -> '&'
548
548
  ;
549
549
 
550
- // Additional volta endings after the first number, comma-separated: "1,2", "1,2,3".
550
+ // Additional volta endings after the first number: comma-separated "1,2,3", or a
551
+ // hyphen range "1-3". Mirrors abc2xml, which normalizes "[1-2" to number="1,2".
551
552
  volta_rest
552
553
  : ',' N -> ',' + $2
554
+ | '-' N -> ',' + $2
553
555
  | volta_rest ',' N -> $1 + ',' + $3
556
+ | volta_rest '-' N -> $1 + ',' + $3
554
557
  ;
555
558
 
556
559
  music
@@ -566,7 +569,8 @@ music
566
569
  | music NAME -> $1
567
570
  | music '^' NAME -> $1
568
571
  | music '^' articulation_letter -> $1
569
- | music '[' N -> $1
572
+ | music '[' N -> $1 ? [...$1, {volta: String($3)}] : [{volta: String($3)}]
573
+ | music '[' N volta_rest -> $1 ? [...$1, {volta: String($3) + $4}] : [{volta: String($3) + $4}]
570
574
  ;
571
575
 
572
576
  // Articulation-class letters (P macro: HJLMOPRSTuv). After a stray '^' inside a