@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.
- package/lib/abc/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +143 -125
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +11 -0
- package/lib/lilylet/measureLayout.js +97 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +146 -70
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +21 -2
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +143 -125
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +33 -17
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +92 -0
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +164 -66
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +21 -2
- 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
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
1088
|
-
// double-emitting;
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
343
|
+
technical.push(`<fingering>${mark.finger}</fingering>`);
|
|
330
344
|
break;
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
347
|
|
|
334
|
-
if (
|
|
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' ||
|
|
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?:
|
|
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.
|
|
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
|
|
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
|
package/source/lilylet/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|