@k-l-lambda/lilylet 0.1.74 → 0.1.76
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/lilylet/grammar.jison.js +200 -188
- package/lib/lilylet/highlight.js +1 -0
- package/lib/lilylet/index.d.ts +1 -0
- package/lib/lilylet/index.js +1 -0
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +79 -0
- package/lib/lilylet/measureLayout.js +509 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +201 -60
- package/lib/lilylet/musicXmlDecoder.js +172 -6
- package/lib/lilylet/musicXmlTypes.d.ts +9 -0
- package/lib/lilylet/serializer.js +131 -12
- package/lib/lilylet/types.d.ts +1 -0
- package/lib/measureLayout.d.ts +1 -0
- package/lib/measureLayout.js +1 -0
- package/lib/measureLayoutFromXml.d.ts +1 -0
- package/lib/measureLayoutFromXml.js +1 -0
- package/package.json +5 -2
- package/source/lilylet/grammar.jison.js +200 -188
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +25 -16
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +552 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +193 -61
- package/source/lilylet/musicXmlDecoder.ts +169 -6
- package/source/lilylet/musicXmlTypes.ts +12 -0
- package/source/lilylet/serializer.ts +125 -12
- package/source/lilylet/types.ts +4 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { DOMParser } from '@xmldom/xmldom';
|
|
8
8
|
import { HairpinType, NavigationMarkType, 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
|
+
import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
|
|
10
11
|
// ============ Spanner Tracker ============
|
|
11
12
|
/**
|
|
12
13
|
* Track spanners (slurs, ties, wedges) by number attribute.
|
|
@@ -476,6 +477,31 @@ const parseDirection = (dirEl) => {
|
|
|
476
477
|
const result = {};
|
|
477
478
|
result.placement = getAttribute(dirEl, 'placement');
|
|
478
479
|
result.staff = getElementInt(dirEl, 'staff');
|
|
480
|
+
// Navigation jumps live on the <sound> child of <direction> (a sibling of
|
|
481
|
+
// <direction-type>). Parse it first, before the no-direction-type early return,
|
|
482
|
+
// so a sound-only direction still contributes its jump semantics.
|
|
483
|
+
const soundEl = dirEl.getElementsByTagName('sound')[0];
|
|
484
|
+
if (soundEl) {
|
|
485
|
+
const sound = {};
|
|
486
|
+
if (getAttribute(soundEl, 'dacapo') === 'yes')
|
|
487
|
+
sound.dacapo = true;
|
|
488
|
+
if (getAttribute(soundEl, 'fine') === 'yes')
|
|
489
|
+
sound.fine = true;
|
|
490
|
+
const dalsegno = getAttribute(soundEl, 'dalsegno');
|
|
491
|
+
if (dalsegno)
|
|
492
|
+
sound.dalsegno = dalsegno;
|
|
493
|
+
const segno = getAttribute(soundEl, 'segno');
|
|
494
|
+
if (segno)
|
|
495
|
+
sound.segno = segno;
|
|
496
|
+
const coda = getAttribute(soundEl, 'coda');
|
|
497
|
+
if (coda)
|
|
498
|
+
sound.coda = coda;
|
|
499
|
+
const tocoda = getAttribute(soundEl, 'tocoda');
|
|
500
|
+
if (tocoda)
|
|
501
|
+
sound.tocoda = tocoda;
|
|
502
|
+
if (Object.keys(sound).length > 0)
|
|
503
|
+
result.sound = sound;
|
|
504
|
+
}
|
|
479
505
|
const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
|
|
480
506
|
if (!dirTypeEl) {
|
|
481
507
|
return result;
|
|
@@ -557,7 +583,8 @@ const parseBarline = (barlineEl) => {
|
|
|
557
583
|
if (repeatEl) {
|
|
558
584
|
const direction = getAttribute(repeatEl, 'direction');
|
|
559
585
|
if (direction) {
|
|
560
|
-
|
|
586
|
+
const times = getAttributeNumber(repeatEl, 'times');
|
|
587
|
+
result.repeat = times !== undefined ? { direction, times } : { direction };
|
|
561
588
|
}
|
|
562
589
|
}
|
|
563
590
|
const endingEl = barlineEl.getElementsByTagName('ending')[0];
|
|
@@ -864,7 +891,10 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
864
891
|
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
865
892
|
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
866
893
|
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
867
|
-
// directions are tempo too, never markup.
|
|
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.
|
|
897
|
+
let emittedWords = false;
|
|
868
898
|
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
869
899
|
const text = direction.words.map(w => w.text).join('').trim();
|
|
870
900
|
if (text && !isTempoWord(text)) {
|
|
@@ -872,6 +902,26 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
872
902
|
: direction.placement === 'below' ? Placement.below
|
|
873
903
|
: undefined;
|
|
874
904
|
marks.push({ markType: 'markup', content: text, placement });
|
|
905
|
+
emittedWords = true;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
|
|
909
|
+
// synthesize the conventional glyph text so the score still shows the marking.
|
|
910
|
+
// Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
|
|
911
|
+
// emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
|
|
912
|
+
// synthesizing, and only when the engraver left them implicit.
|
|
913
|
+
if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
|
|
914
|
+
const s = direction.sound;
|
|
915
|
+
const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
|
|
916
|
+
: s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
|
|
917
|
+
: s.tocoda ? 'To Coda'
|
|
918
|
+
: s.fine ? 'Fine'
|
|
919
|
+
: undefined;
|
|
920
|
+
if (label) {
|
|
921
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
922
|
+
: direction.placement === 'below' ? Placement.below
|
|
923
|
+
: undefined;
|
|
924
|
+
marks.push({ markType: 'markup', content: label, placement });
|
|
875
925
|
}
|
|
876
926
|
}
|
|
877
927
|
return marks;
|
|
@@ -928,6 +978,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
928
978
|
let key;
|
|
929
979
|
let timeSig;
|
|
930
980
|
let barline;
|
|
981
|
+
let leadingBarline;
|
|
931
982
|
const harmonies = [];
|
|
932
983
|
const clefs = new Map();
|
|
933
984
|
// Pending marks from directions (to attach to next note), per voice
|
|
@@ -944,6 +995,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
944
995
|
// layer (otherwise the encoder can't pair them and drops the span).
|
|
945
996
|
const pendingContextChanges = [];
|
|
946
997
|
let currentVoice = 1; // Track current voice for directions
|
|
998
|
+
// Mid-measure clef support: a staff can change clef partway through a measure
|
|
999
|
+
// (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
|
|
1000
|
+
// in an <attributes> block AFTER notes on that staff. We must attach it inline at
|
|
1001
|
+
// that point on the staff's currently-active voice, not collapse it into the
|
|
1002
|
+
// measure-start `clefs` map (which keeps only one clef per staff and emits it at
|
|
1003
|
+
// the bar start). Track the last voice that added a note on each staff, and which
|
|
1004
|
+
// staves already have notes this measure.
|
|
1005
|
+
const lastVoiceOnStaff = new Map();
|
|
1006
|
+
const staffHasNotes = new Set();
|
|
947
1007
|
// Process all children in order
|
|
948
1008
|
for (const child of getChildElements(measureEl)) {
|
|
949
1009
|
const tagName = child.tagName;
|
|
@@ -968,7 +1028,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
968
1028
|
for (const clefEntry of attrs.clefs) {
|
|
969
1029
|
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
970
1030
|
if (clef) {
|
|
971
|
-
|
|
1031
|
+
const staff = clefEntry.staff;
|
|
1032
|
+
if (staffHasNotes.has(staff)) {
|
|
1033
|
+
// Mid-measure clef change: this staff already has notes in this
|
|
1034
|
+
// measure, so the clef takes effect HERE, not at the bar start.
|
|
1035
|
+
// Append it inline on the staff's currently-active voice via
|
|
1036
|
+
// addEvent (which inserts a staff-switch context if that voice is
|
|
1037
|
+
// currently positioned on another staff — e.g. a cross-staff
|
|
1038
|
+
// voice — so the clef binds to `staff`, not the voice's current
|
|
1039
|
+
// staff). Zero duration: a clef does not advance time.
|
|
1040
|
+
const vNum = lastVoiceOnStaff.get(staff);
|
|
1041
|
+
if (vNum !== undefined) {
|
|
1042
|
+
// Tag the clef context with its staff explicitly. In a
|
|
1043
|
+
// cross-staff voice (where notes interleave staves) a bare
|
|
1044
|
+
// clef is ambiguous and is dropped/misplaced by the serializer;
|
|
1045
|
+
// the explicit staff makes it self-describing (serializer emits
|
|
1046
|
+
// `\staff "N" \clef ...`). addEvent still inserts a leading
|
|
1047
|
+
// staff switch when the voice is currently on another staff.
|
|
1048
|
+
voiceTracker.addEvent(vNum, { type: 'context', staff, clef }, 0, staff);
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1056
|
+
}
|
|
972
1057
|
}
|
|
973
1058
|
}
|
|
974
1059
|
}
|
|
@@ -978,6 +1063,10 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
978
1063
|
const voiceNum = note.voice;
|
|
979
1064
|
const staffNum = note.staff || 1;
|
|
980
1065
|
currentVoice = voiceNum;
|
|
1066
|
+
// Record that this staff now has notes this measure and which voice is
|
|
1067
|
+
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
1068
|
+
staffHasNotes.add(staffNum);
|
|
1069
|
+
lastVoiceOnStaff.set(staffNum, voiceNum);
|
|
981
1070
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
982
1071
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
983
1072
|
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
@@ -1159,7 +1248,15 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1159
1248
|
const barlineData = parseBarline(child);
|
|
1160
1249
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
1161
1250
|
if (style && style !== '|') {
|
|
1162
|
-
|
|
1251
|
+
// A `location="left"` barline (forward repeat at the measure front)
|
|
1252
|
+
// is carried back onto the previous measure's end bar by convertPart;
|
|
1253
|
+
// everything else is this measure's own trailing end bar.
|
|
1254
|
+
if (barlineData.location === 'left') {
|
|
1255
|
+
leadingBarline = { type: 'barline', style };
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
barline = { type: 'barline', style };
|
|
1259
|
+
}
|
|
1163
1260
|
}
|
|
1164
1261
|
}
|
|
1165
1262
|
else if (tagName === 'harmony') {
|
|
@@ -1252,7 +1349,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1252
1349
|
staff: voiceState.staff,
|
|
1253
1350
|
});
|
|
1254
1351
|
}
|
|
1255
|
-
return { voiceMap, key, timeSig, barline, harmonies, clefs };
|
|
1352
|
+
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
1256
1353
|
};
|
|
1257
1354
|
/**
|
|
1258
1355
|
* Convert a MusicXML part to Lilylet measures
|
|
@@ -1268,10 +1365,27 @@ const convertPart = (partEl) => {
|
|
|
1268
1365
|
let isFirstMeasure = true;
|
|
1269
1366
|
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1270
1367
|
const lastClefs = new Map(); // Track last clef per staff
|
|
1368
|
+
// Initial clef per staff declared in the FIRST <attributes> block. A staff whose
|
|
1369
|
+
// first voice does not appear until a later measure (e.g. an accompaniment staff
|
|
1370
|
+
// silent for the opening bars) would otherwise lose its initial clef — it is only
|
|
1371
|
+
// declared in measure 1's attributes, where that staff has no voice to attach it
|
|
1372
|
+
// to. We hold these and flush each onto the staff's FIRST appearing voice.
|
|
1373
|
+
const pendingInitialClefs = new Map();
|
|
1271
1374
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1272
1375
|
for (const measureEl of measureEls) {
|
|
1273
1376
|
voiceTracker.reset();
|
|
1274
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1377
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1378
|
+
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1379
|
+
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
1380
|
+
// Append it to the previous measure's first voice; if this is the first
|
|
1381
|
+
// measure there is no previous one, so a leading repeat at the very start of
|
|
1382
|
+
// the piece is conventionally dropped.
|
|
1383
|
+
if (leadingBarline && measures.length > 0) {
|
|
1384
|
+
const prevVoices = measures[measures.length - 1].parts[0]?.voices;
|
|
1385
|
+
if (prevVoices && prevVoices.length > 0) {
|
|
1386
|
+
prevVoices[0].events.push(leadingBarline);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1275
1389
|
// Update running key/time
|
|
1276
1390
|
if (key)
|
|
1277
1391
|
lastKey = key;
|
|
@@ -1306,11 +1420,45 @@ const convertPart = (partEl) => {
|
|
|
1306
1420
|
lastClefs.set(voiceData.staff, clef);
|
|
1307
1421
|
}
|
|
1308
1422
|
}
|
|
1423
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1424
|
+
}
|
|
1425
|
+
else if (pendingInitialClefs.has(voiceData.staff)) {
|
|
1426
|
+
// This staff's clef was declared in an earlier measure where the staff
|
|
1427
|
+
// had no voice to carry it (its first appearance is delayed, or it is
|
|
1428
|
+
// an intermittently-empty cross-staff passage). This measure declares
|
|
1429
|
+
// no clef of its own — emit the carried-forward pending clef if it
|
|
1430
|
+
// differs from the clef last in effect for this staff.
|
|
1431
|
+
const pending = pendingInitialClefs.get(voiceData.staff);
|
|
1432
|
+
const lastClef = lastClefs.get(voiceData.staff);
|
|
1433
|
+
const isSameClef = lastClef &&
|
|
1434
|
+
lastClef.clef === pending.clef;
|
|
1435
|
+
if (!isSameClef) {
|
|
1436
|
+
events.push(pending);
|
|
1437
|
+
lastClefs.set(voiceData.staff, pending);
|
|
1438
|
+
}
|
|
1439
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1309
1440
|
}
|
|
1310
1441
|
staffsWithClef.add(voiceData.staff);
|
|
1311
1442
|
}
|
|
1312
1443
|
// Add voice events
|
|
1313
1444
|
events.push(...voiceData.events);
|
|
1445
|
+
// A mid-measure clef change was appended inline to this voice's events (it
|
|
1446
|
+
// does not pass through the measure-start `clefs` map). Scan for the LAST
|
|
1447
|
+
// clef per staff in this voice and update lastClefs, so the next measure's
|
|
1448
|
+
// dedup compares against the clef actually in effect — otherwise a later
|
|
1449
|
+
// measure restating the pre-change clef would be wrongly suppressed.
|
|
1450
|
+
{
|
|
1451
|
+
let scanStaff = voiceData.staff;
|
|
1452
|
+
for (const ev of voiceData.events) {
|
|
1453
|
+
if (ev.type === 'context') {
|
|
1454
|
+
const c = ev;
|
|
1455
|
+
if (c.staff != null)
|
|
1456
|
+
scanStaff = c.staff;
|
|
1457
|
+
if (c.clef)
|
|
1458
|
+
lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1314
1462
|
// Add harmonies and barline to first voice only
|
|
1315
1463
|
if (voiceNum === voiceNumbers[0]) {
|
|
1316
1464
|
for (const h of harmonies) {
|
|
@@ -1332,6 +1480,16 @@ const convertPart = (partEl) => {
|
|
|
1332
1480
|
else {
|
|
1333
1481
|
lastVoiceStaff = voices[0].staff || 1;
|
|
1334
1482
|
}
|
|
1483
|
+
// Carry forward any clef declared this measure for a staff that had NO voice to
|
|
1484
|
+
// attach it to (an intermittently-empty staff — e.g. cross-staff passages where
|
|
1485
|
+
// the LH is written entirely on the upper staff for several bars). Remember it
|
|
1486
|
+
// as pending so it is emitted when that staff next reappears; otherwise the clef
|
|
1487
|
+
// (and any change it represents) is lost when this measure's `clefs` map is
|
|
1488
|
+
// discarded. Generalizes the first-measure seeding above to every measure.
|
|
1489
|
+
for (const [staff, clef] of clefs) {
|
|
1490
|
+
if (!staffsWithClef.has(staff))
|
|
1491
|
+
pendingInitialClefs.set(staff, clef);
|
|
1492
|
+
}
|
|
1335
1493
|
const measure = {
|
|
1336
1494
|
parts: [{
|
|
1337
1495
|
voices,
|
|
@@ -1450,6 +1608,14 @@ export const decode = (input) => {
|
|
|
1450
1608
|
const result = {
|
|
1451
1609
|
measures: mergedMeasures,
|
|
1452
1610
|
};
|
|
1611
|
+
// Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
|
|
1612
|
+
// from the first part's barline + <sound> navigation markup → measure-layout
|
|
1613
|
+
// string. The MEI encoder turns this into an <expansion> that verovio unfolds
|
|
1614
|
+
// for MIDI. Repeat markup is part-global; the first part carries it. Never
|
|
1615
|
+
// throws (returns undefined when there is nothing to unfold).
|
|
1616
|
+
const measureLayout = measureLayoutFromPart(partEls[0]);
|
|
1617
|
+
if (measureLayout)
|
|
1618
|
+
metadata.measureLayout = measureLayout;
|
|
1453
1619
|
if (Object.keys(metadata).length > 0) {
|
|
1454
1620
|
result.metadata = metadata;
|
|
1455
1621
|
}
|
|
@@ -120,12 +120,21 @@ export interface MusicXmlDirection {
|
|
|
120
120
|
octaveShift?: MusicXmlOctaveShift;
|
|
121
121
|
coda?: boolean;
|
|
122
122
|
segno?: boolean;
|
|
123
|
+
sound?: {
|
|
124
|
+
dacapo?: boolean;
|
|
125
|
+
dalsegno?: string;
|
|
126
|
+
segno?: string;
|
|
127
|
+
coda?: string;
|
|
128
|
+
tocoda?: string;
|
|
129
|
+
fine?: boolean;
|
|
130
|
+
};
|
|
123
131
|
}
|
|
124
132
|
export interface MusicXmlBarline {
|
|
125
133
|
location?: 'left' | 'right' | 'middle';
|
|
126
134
|
barStyle?: string;
|
|
127
135
|
repeat?: {
|
|
128
136
|
direction: 'forward' | 'backward';
|
|
137
|
+
times?: number;
|
|
129
138
|
};
|
|
130
139
|
ending?: {
|
|
131
140
|
type: 'start' | 'stop' | 'discontinue';
|
|
@@ -5,6 +5,44 @@
|
|
|
5
5
|
* Uses relative pitch mode matching the parser's behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { StemDirection, } from "./types.js";
|
|
8
|
+
import { parseMeasureLayout } from "./measureLayout.js";
|
|
9
|
+
// Walk a measure-layout AST, recording the FIRST measure index of each alternate
|
|
10
|
+
// ending (1st/2nd house) → its ending number, for the `% volta N` serializer
|
|
11
|
+
// comment convention. Only the alternates of a VoltaMLayout produce labels.
|
|
12
|
+
const collectVoltaLabels = (layout, out) => {
|
|
13
|
+
const firstIndexOf = (node) => {
|
|
14
|
+
switch (node.kind) {
|
|
15
|
+
case "single": return node.measure;
|
|
16
|
+
case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
|
|
17
|
+
case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
|
|
18
|
+
case "aba": return firstIndexOf(node.main);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const walk = (node) => {
|
|
22
|
+
switch (node.kind) {
|
|
23
|
+
case "block":
|
|
24
|
+
node.seq.forEach(walk);
|
|
25
|
+
break;
|
|
26
|
+
case "aba":
|
|
27
|
+
walk(node.main);
|
|
28
|
+
node.rest.forEach(walk);
|
|
29
|
+
break;
|
|
30
|
+
case "volta":
|
|
31
|
+
node.body.forEach(walk);
|
|
32
|
+
if (node.alternates) {
|
|
33
|
+
node.alternates.forEach((alt, ai) => {
|
|
34
|
+
const first = alt.length ? firstIndexOf(alt[0]) : undefined;
|
|
35
|
+
if (first !== undefined && !out.has(first))
|
|
36
|
+
out.set(first, ai + 1);
|
|
37
|
+
alt.forEach(walk);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
default: break;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(layout);
|
|
45
|
+
};
|
|
8
46
|
const PHONETS = "cdefgab";
|
|
9
47
|
/**
|
|
10
48
|
* Calculate the octave markers needed to serialize a pitch in relative mode.
|
|
@@ -369,10 +407,20 @@ const serializeTupletEvent = (event, env) => {
|
|
|
369
407
|
}
|
|
370
408
|
else if (e.type === 'context') {
|
|
371
409
|
const ctx = e;
|
|
410
|
+
// A context event inside a tuplet may carry several components at once
|
|
411
|
+
// (e.g. staff + clef). Emit each present one — previously only staff and
|
|
412
|
+
// stemDirection were handled, so a clef/ottava change inside a tuplet was
|
|
413
|
+
// silently dropped on serialization.
|
|
372
414
|
if (ctx.staff != null) {
|
|
373
415
|
parts.push(' \\staff "' + ctx.staff + '"');
|
|
374
416
|
}
|
|
375
|
-
|
|
417
|
+
if (ctx.clef != null) {
|
|
418
|
+
parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
|
|
419
|
+
}
|
|
420
|
+
if (ctx.ottava != null) {
|
|
421
|
+
parts.push(' \\ottava #' + ctx.ottava);
|
|
422
|
+
}
|
|
423
|
+
if (ctx.stemDirection != null) {
|
|
376
424
|
if (ctx.stemDirection === StemDirection.up)
|
|
377
425
|
parts.push(' \\stemUp');
|
|
378
426
|
else if (ctx.stemDirection === StemDirection.down)
|
|
@@ -459,7 +507,7 @@ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) =
|
|
|
459
507
|
case 'markup': {
|
|
460
508
|
const mk = event;
|
|
461
509
|
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
462
|
-
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
510
|
+
return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
|
|
463
511
|
}
|
|
464
512
|
case 'dynamic': {
|
|
465
513
|
const dynStr = DYNAMIC_MAP[event.dynamicType];
|
|
@@ -511,6 +559,32 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
511
559
|
// context{staff} events before this index are skipped in the main loop.
|
|
512
560
|
let effectiveInitialStaff = voice.staff;
|
|
513
561
|
let leadStaffScanEnd = 0;
|
|
562
|
+
// Staff of the first MUSICAL event (note/rest/...), honouring an explicit
|
|
563
|
+
// note.staff. If it differs from the home staff, the first inline staff switch
|
|
564
|
+
// would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
|
|
565
|
+
// binds home to the first `\staff` before any music), relocating the voice and
|
|
566
|
+
// any leading clef. We then force an explicit home-staff anchor up front. This
|
|
567
|
+
// scan is independent of leadStaffScanEnd because a leading clef/key stops that
|
|
568
|
+
// scan before the first note is reached.
|
|
569
|
+
let firstMusicalStaff = null;
|
|
570
|
+
{
|
|
571
|
+
let scanStaff = voice.staff;
|
|
572
|
+
for (const e of voice.events) {
|
|
573
|
+
if (e.type === 'context') {
|
|
574
|
+
const ctx = e;
|
|
575
|
+
if (ctx.staff != null)
|
|
576
|
+
scanStaff = ctx.staff;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (e.type === 'pitchReset')
|
|
580
|
+
continue;
|
|
581
|
+
if (e.type === 'note')
|
|
582
|
+
firstMusicalStaff = e.staff ?? scanStaff;
|
|
583
|
+
else
|
|
584
|
+
firstMusicalStaff = scanStaff;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
514
588
|
for (let i = 0; i < voice.events.length; i++) {
|
|
515
589
|
const e = voice.events[i];
|
|
516
590
|
if (e.type === 'pitchReset') {
|
|
@@ -538,10 +612,19 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
538
612
|
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
539
613
|
break;
|
|
540
614
|
}
|
|
541
|
-
// Output staff command if voice staff
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
615
|
+
// Output staff command if the voice's initial staff is not what the PARSER will
|
|
616
|
+
// assume. The parser (lilylet.jison voice()) assigns every voice with no leading
|
|
617
|
+
// `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
|
|
618
|
+
// voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
|
|
619
|
+
// is not 1, even if the cross-measure carry (currentStaff) happens to match it
|
|
620
|
+
// (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
|
|
621
|
+
// this, the round-trip would silently relocate that voice, and its leading clef,
|
|
622
|
+
// to staff 1). Also emit when it differs from the carry (resets after grand staff)
|
|
623
|
+
// or always in a grand-staff part for clarity. Finally, force an anchor when the
|
|
624
|
+
// first note switches staff via note.staff (otherwise that inline `\staff` would
|
|
625
|
+
// be read as the leading staff and rebind the voice's home).
|
|
626
|
+
const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
|
|
627
|
+
if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
|
|
545
628
|
parts.push('\\staff "' + effectiveInitialStaff + '"');
|
|
546
629
|
}
|
|
547
630
|
// Output key/time signatures after \staff (for first voice only)
|
|
@@ -568,7 +651,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
568
651
|
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
569
652
|
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
570
653
|
// hoisted here — it is emitted inline at its position.
|
|
571
|
-
|
|
654
|
+
//
|
|
655
|
+
// The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
|
|
656
|
+
// the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
|
|
657
|
+
// with no clef of its own must only use the fallback to ESTABLISH a staff that has
|
|
658
|
+
// not been emitted yet — never to "restate" it once any sibling voice has already
|
|
659
|
+
// emitted/changed this staff's clef this measure, because the fallback would then be
|
|
660
|
+
// stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
|
|
661
|
+
// treble, then v2 with no clef must not re-emit the carried-in bass).
|
|
662
|
+
const ownClef = findVoiceClef(voice);
|
|
663
|
+
const carryClef = allStaffClefs?.[voice.staff];
|
|
664
|
+
const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
|
|
572
665
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
573
666
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
574
667
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
@@ -591,7 +684,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
591
684
|
activeStaff = ctx.staff;
|
|
592
685
|
parts.push('\\staff "' + activeStaff + '"');
|
|
593
686
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
594
|
-
const ctxClef = ctx.clef ||
|
|
687
|
+
const ctxClef = ctx.clef ||
|
|
688
|
+
(emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
|
|
595
689
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
596
690
|
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
597
691
|
if (emittedClefs)
|
|
@@ -618,7 +712,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
618
712
|
activeStaff = effectiveStaff;
|
|
619
713
|
parts.push('\\staff "' + activeStaff + '"');
|
|
620
714
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
621
|
-
const targetClef = allStaffClefs?.[activeStaff];
|
|
715
|
+
const targetClef = emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
|
|
622
716
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
623
717
|
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
624
718
|
if (emittedClefs)
|
|
@@ -747,7 +841,12 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
747
841
|
};
|
|
748
842
|
// Escape string for serialization (quotes and backslashes)
|
|
749
843
|
const escapeString = (str) => {
|
|
750
|
-
return str
|
|
844
|
+
return str
|
|
845
|
+
.replace(/\\/g, '\\\\')
|
|
846
|
+
.replace(/"/g, '\\"')
|
|
847
|
+
.replace(/\n/g, '\\n')
|
|
848
|
+
.replace(/\r/g, '\\r')
|
|
849
|
+
.replace(/\t/g, '\\t');
|
|
751
850
|
};
|
|
752
851
|
// Serialize metadata
|
|
753
852
|
const serializeMetadata = (metadata) => {
|
|
@@ -776,6 +875,9 @@ const serializeMetadata = (metadata) => {
|
|
|
776
875
|
if (metadata.staves) {
|
|
777
876
|
lines.push('[staves "' + escapeString(metadata.staves) + '"]');
|
|
778
877
|
}
|
|
878
|
+
if (metadata.measureLayout) {
|
|
879
|
+
lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
|
|
880
|
+
}
|
|
779
881
|
if (metadata.instruments) {
|
|
780
882
|
for (const [key, instr] of Object.entries(metadata.instruments)) {
|
|
781
883
|
let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
|
|
@@ -856,9 +958,26 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
856
958
|
measureStrs.push(measureStr || 's1');
|
|
857
959
|
currentStaff = newStaff;
|
|
858
960
|
}
|
|
859
|
-
//
|
|
961
|
+
// Volta house-label comments (non-binding convention, like the `% n` measure
|
|
962
|
+
// index): if a measure-layout directive is present, mark the first measure of
|
|
963
|
+
// each alternate ending with a `% volta N` comment. Derived from the layout
|
|
964
|
+
// AST; purely cosmetic, never parsed back.
|
|
965
|
+
const voltaLabels = new Map(); // 1-based measure index → ending number
|
|
966
|
+
if (doc.metadata?.measureLayout) {
|
|
967
|
+
try {
|
|
968
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
969
|
+
collectVoltaLabels(layout, voltaLabels);
|
|
970
|
+
}
|
|
971
|
+
catch { /* ignore malformed layout */ }
|
|
972
|
+
}
|
|
973
|
+
// Join measures with bar, measure number comment, and double newline. A
|
|
974
|
+
// `% volta N` label (if any) is prepended on its own line before the measure.
|
|
860
975
|
const measuresOutput = measureStrs
|
|
861
|
-
.map((m, i) =>
|
|
976
|
+
.map((m, i) => {
|
|
977
|
+
const label = voltaLabels.get(i + 1);
|
|
978
|
+
const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
|
|
979
|
+
return prefix + m + ' | %' + (i + 1);
|
|
980
|
+
})
|
|
862
981
|
.join('\n\n');
|
|
863
982
|
parts.push(measuresOutput);
|
|
864
983
|
return parts.join('\n');
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayout.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayoutFromXml.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./lilylet/measureLayoutFromXml.js";
|
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.76",
|
|
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",
|
|
@@ -40,11 +40,14 @@
|
|
|
40
40
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
41
41
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
42
42
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
43
|
+
"test:lyl-roundtrip": "tsx ./tests/lyl-roundtrip.ts",
|
|
43
44
|
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
44
45
|
"train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
|
|
45
46
|
"build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
|
|
46
47
|
"build:browser-bundle": "esbuild tools/browserBundle.ts --bundle --format=iife --platform=browser --minify --outfile=dist/lilylet.bundle.js",
|
|
47
48
|
"test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
|
|
49
|
+
"test:measures": "tsx ./tests/unit/measureLayout.test.ts",
|
|
50
|
+
"test:measures-xml": "tsx ./tests/unit/measureLayoutFromXml.test.ts",
|
|
48
51
|
"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",
|
|
49
52
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
50
53
|
"ts": "tsx"
|
|
@@ -70,7 +73,7 @@
|
|
|
70
73
|
"ts-node": "^10.9.2",
|
|
71
74
|
"tsx": "^4.21.0",
|
|
72
75
|
"typescript": "^5.3.3",
|
|
73
|
-
"verovio": "^
|
|
76
|
+
"verovio": "^6.2.0",
|
|
74
77
|
"yargs": "^17.7.2"
|
|
75
78
|
},
|
|
76
79
|
"dependencies": {
|