@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
|
@@ -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
|
-
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
893
|
-
// double-emitting;
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
253
|
+
technical.push(`<fingering>${mark.finger}</fingering>`);
|
|
239
254
|
break;
|
|
240
255
|
}
|
|
241
256
|
}
|
|
242
|
-
if (articulations.length === 0 && ornaments.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' ||
|
|
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?:
|
|
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.
|
|
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
|
|
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) => {
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/lib/lilylet/types.js
CHANGED
|
@@ -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.
|
|
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"
|
package/source/abc/abc.jison
CHANGED
|
@@ -542,15 +542,18 @@ bar
|
|
|
542
542
|
| ':' '|' ']' -> ':|]'
|
|
543
543
|
| '|' N -> '|' + $2
|
|
544
544
|
| '|' N volta_rest -> '|' + $2 + $3
|
|
545
|
-
| ':' '|' N -> ':|' + $
|
|
546
|
-
| ':' '|' N volta_rest -> ':|' + $
|
|
545
|
+
| ':' '|' N -> ':|' + $3
|
|
546
|
+
| ':' '|' N volta_rest -> ':|' + $3 + $4
|
|
547
547
|
| '&' -> '&'
|
|
548
548
|
;
|
|
549
549
|
|
|
550
|
-
// Additional volta endings after the first number
|
|
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
|