@k-l-lambda/lilylet 0.1.74 → 0.1.75
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 +180 -175
- 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/measureLayout.d.ts +68 -0
- package/lib/lilylet/measureLayout.js +412 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +48 -0
- package/lib/lilylet/measureLayoutFromXml.js +423 -0
- package/lib/lilylet/meiEncoder.js +160 -40
- package/lib/lilylet/musicXmlDecoder.js +172 -6
- package/lib/lilylet/musicXmlTypes.d.ts +9 -0
- package/lib/lilylet/serializer.js +124 -10
- 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 +180 -175
- package/source/lilylet/highlight.ts +1 -0
- package/source/lilylet/index.ts +1 -0
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/measureLayout.ts +460 -0
- package/source/lilylet/measureLayoutFromXml.ts +417 -0
- package/source/lilylet/meiEncoder.ts +149 -39
- package/source/lilylet/musicXmlDecoder.ts +169 -6
- package/source/lilylet/musicXmlTypes.ts +12 -0
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +4 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
2
|
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
3
3
|
import { gmProgramOf } from "./gmInstruments.js";
|
|
4
|
+
import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout.js";
|
|
4
5
|
// MEI key signatures: positive = sharps, negative = flats
|
|
5
6
|
const KEY_SIGS = {
|
|
6
7
|
0: "0",
|
|
@@ -641,6 +642,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
641
642
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
642
643
|
// Collect control event info from notes inside tuplet
|
|
643
644
|
let firstNoteId = null;
|
|
645
|
+
let lastNoteId = null; // last note/rest id — anchors an ottava span end
|
|
644
646
|
const slurStarts = [];
|
|
645
647
|
const slurEnds = [];
|
|
646
648
|
const dynamics = [];
|
|
@@ -675,6 +677,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
675
677
|
xml += result.xml;
|
|
676
678
|
if (!firstNoteId)
|
|
677
679
|
firstNoteId = result.elementId;
|
|
680
|
+
lastNoteId = result.elementId;
|
|
678
681
|
// Collect slur info
|
|
679
682
|
if (result.slurStart)
|
|
680
683
|
slurStarts.push(result.elementId);
|
|
@@ -714,6 +717,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
714
717
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
715
718
|
const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
716
719
|
xml += restResult.xml;
|
|
720
|
+
lastNoteId = restResult.elementId;
|
|
717
721
|
if (restResult.fermata)
|
|
718
722
|
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
719
723
|
}
|
|
@@ -736,7 +740,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
736
740
|
xml += `${baseIndent}</beam>\n`;
|
|
737
741
|
}
|
|
738
742
|
xml += `${indent}</tuplet>\n`;
|
|
739
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
743
|
+
return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
740
744
|
};
|
|
741
745
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
742
746
|
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
@@ -866,6 +870,43 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
866
870
|
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
867
871
|
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
868
872
|
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
873
|
+
// ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
|
|
874
|
+
// Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
|
|
875
|
+
// ottava span running over a tuplet run (ubiquitous in etudes) or over rests
|
|
876
|
+
// was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
|
|
877
|
+
// helpers let any event type anchor a span. Mirror the note-case ordering:
|
|
878
|
+
// the caller updates lastNoteId to the event's first emitted id, THEN calls
|
|
879
|
+
// these.
|
|
880
|
+
// Apply a pending ottava's pitch shift before the event's notes are encoded.
|
|
881
|
+
const applyPendingOttavaShift = () => {
|
|
882
|
+
if (pendingOttava !== null && pendingOttava !== 0)
|
|
883
|
+
currentOttavaShift = pendingOttava;
|
|
884
|
+
};
|
|
885
|
+
// Extend a carried (endToken) span's end to this event's last emitted id.
|
|
886
|
+
const extendOttavaEnd = (lastId) => {
|
|
887
|
+
if (currentOctave?.endToken)
|
|
888
|
+
octaveEndReplacements[currentOctave.endToken] = lastId;
|
|
889
|
+
};
|
|
890
|
+
// Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
|
|
891
|
+
// closing any open span of a different value first; or resolve a continued span.
|
|
892
|
+
const applyOttavaAnchor = (anchorId) => {
|
|
893
|
+
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
894
|
+
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
895
|
+
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
896
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
897
|
+
octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
|
|
898
|
+
currentOctave = null;
|
|
899
|
+
}
|
|
900
|
+
if (!currentOctave)
|
|
901
|
+
currentOctave = { dis, disPlace, startId: anchorId };
|
|
902
|
+
pendingOttava = null;
|
|
903
|
+
}
|
|
904
|
+
else if (currentOctave?.continued) {
|
|
905
|
+
if (!currentOctave.endToken)
|
|
906
|
+
currentOctave.startId = anchorId;
|
|
907
|
+
currentOctave.continued = false;
|
|
908
|
+
}
|
|
909
|
+
};
|
|
869
910
|
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
870
911
|
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
871
912
|
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
@@ -957,9 +998,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
957
998
|
// Check if this note should have tie="t" (matches pending tie)
|
|
958
999
|
const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
|
|
959
1000
|
// If there's a pending ottava, apply it BEFORE encoding the note
|
|
960
|
-
|
|
961
|
-
currentOttavaShift = pendingOttava; // Apply the shift for this note
|
|
962
|
-
}
|
|
1001
|
+
applyPendingOttavaShift();
|
|
963
1002
|
// For cross-staff notation: set note's staff to currentStaff if different from voice.staff
|
|
964
1003
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
965
1004
|
? { ...noteEvent, staff: currentStaff }
|
|
@@ -967,41 +1006,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
967
1006
|
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
968
1007
|
xml += result.xml;
|
|
969
1008
|
lastNoteId = result.elementId;
|
|
970
|
-
|
|
971
|
-
octaveEndReplacements[currentOctave.endToken] = result.elementId;
|
|
972
|
-
}
|
|
1009
|
+
extendOttavaEnd(result.elementId);
|
|
973
1010
|
// Flush any pending markups onto this note
|
|
974
1011
|
flushPendingMarkups(result.elementId);
|
|
975
1012
|
// Flush any pending leading dynamics onto this note
|
|
976
1013
|
flushPendingDynamics(result.elementId);
|
|
977
1014
|
// If there's a pending ottava, start the span on this note
|
|
978
|
-
|
|
979
|
-
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
980
|
-
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
981
|
-
// Close existing span first if it has a different value
|
|
982
|
-
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
983
|
-
if (lastNoteId) {
|
|
984
|
-
octaves.push({
|
|
985
|
-
dis: currentOctave.dis,
|
|
986
|
-
disPlace: currentOctave.disPlace,
|
|
987
|
-
startId: currentOctave.startId,
|
|
988
|
-
endId: lastNoteId,
|
|
989
|
-
});
|
|
990
|
-
}
|
|
991
|
-
currentOctave = null;
|
|
992
|
-
}
|
|
993
|
-
// Start new span if we don't already have one with the same value
|
|
994
|
-
if (!currentOctave) {
|
|
995
|
-
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
996
|
-
}
|
|
997
|
-
pendingOttava = null;
|
|
998
|
-
}
|
|
999
|
-
else if (currentOctave?.continued) {
|
|
1000
|
-
if (!currentOctave.endToken) {
|
|
1001
|
-
currentOctave.startId = result.elementId;
|
|
1002
|
-
}
|
|
1003
|
-
currentOctave.continued = false;
|
|
1004
|
-
}
|
|
1015
|
+
applyOttavaAnchor(result.elementId);
|
|
1005
1016
|
// Update pending tie pitches
|
|
1006
1017
|
if (result.hasTieStart) {
|
|
1007
1018
|
pendingTiePitches = result.pitches;
|
|
@@ -1101,23 +1112,39 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1101
1112
|
case 'rest': {
|
|
1102
1113
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1103
1114
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1115
|
+
// A pending ottava shift applies to a rest's gestural octave too.
|
|
1116
|
+
applyPendingOttavaShift();
|
|
1104
1117
|
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1105
1118
|
xml += restResult.xml;
|
|
1119
|
+
lastNoteId = restResult.elementId;
|
|
1120
|
+
// An ottava span may open on, run through, or close at a rest (e.g. a
|
|
1121
|
+
// span that starts just before LH rests). Anchor it like a note.
|
|
1122
|
+
extendOttavaEnd(restResult.elementId);
|
|
1123
|
+
applyOttavaAnchor(restResult.elementId);
|
|
1106
1124
|
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1107
1125
|
if (restResult.fermata)
|
|
1108
1126
|
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1109
1127
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1110
1128
|
flushPendingMarkups(restResult.elementId);
|
|
1111
1129
|
flushPendingDynamics(restResult.elementId);
|
|
1112
|
-
lastNoteId = restResult.elementId;
|
|
1113
1130
|
break;
|
|
1114
1131
|
}
|
|
1115
1132
|
case 'tuplet':
|
|
1116
1133
|
case 'times': {
|
|
1117
1134
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1118
1135
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1136
|
+
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1137
|
+
applyPendingOttavaShift();
|
|
1119
1138
|
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1120
1139
|
xml += tupletResult.xml;
|
|
1140
|
+
// An ottava span can open on, run through, or close at a tuplet (common
|
|
1141
|
+
// in etudes where the 8va covers a whole tuplet run). Anchor the span on
|
|
1142
|
+
// the tuplet's FIRST note and let its LAST note close/extend it — without
|
|
1143
|
+
// this the entire span was dropped (start/close lived only in case 'note').
|
|
1144
|
+
if (tupletResult.firstNoteId)
|
|
1145
|
+
applyOttavaAnchor(tupletResult.firstNoteId);
|
|
1146
|
+
if (tupletResult.lastNoteId)
|
|
1147
|
+
extendOttavaEnd(tupletResult.lastNoteId);
|
|
1121
1148
|
// Propagate clef change from inside the tuplet to the parent tracker
|
|
1122
1149
|
if (tupletResult.endingClef) {
|
|
1123
1150
|
currentClef = tupletResult.endingClef;
|
|
@@ -1126,7 +1153,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1126
1153
|
if (tupletResult.firstNoteId) {
|
|
1127
1154
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1128
1155
|
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1129
|
-
lastNoteId = tupletResult.firstNoteId;
|
|
1156
|
+
lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
|
|
1130
1157
|
}
|
|
1131
1158
|
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
1132
1159
|
for (const endId of tupletResult.slurEnds) {
|
|
@@ -1185,8 +1212,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1185
1212
|
currentOctave = null;
|
|
1186
1213
|
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1187
1214
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1215
|
+
else if (currentOctave && currentOctave.emitted) {
|
|
1216
|
+
// Stop landed at measure START (no note yet in this layer) but the
|
|
1217
|
+
// span was carried+emitted in a PREVIOUS measure: it genuinely ends
|
|
1218
|
+
// here. Its deferred endToken was already resolved to the correct
|
|
1219
|
+
// last note by extendOttavaEnd as the span ran through prior measures
|
|
1220
|
+
// (or by the staff-level carry's endFallbackId), so DON'T rewrite it
|
|
1221
|
+
// here — just clear the slot and mark closed, so a later same-value
|
|
1222
|
+
// ottava opens a NEW span instead of folding into this one.
|
|
1223
|
+
// Without this, sequential cross-measure spans collapse into one
|
|
1224
|
+
// (No.57: 18 spans → 8).
|
|
1225
|
+
currentOctave = null;
|
|
1226
|
+
ottavaExplicitlyClosed = true;
|
|
1227
|
+
}
|
|
1228
|
+
// Note: if no lastNoteId and not yet emitted (e.g., open+close at
|
|
1229
|
+
// measure start with no notes), keep currentOctave alive — it may be
|
|
1230
|
+
// continued by a subsequent ottava command with the same value.
|
|
1190
1231
|
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1191
1232
|
}
|
|
1192
1233
|
else {
|
|
@@ -1525,8 +1566,10 @@ const BARLINE_TO_MEI = {
|
|
|
1525
1566
|
};
|
|
1526
1567
|
// Encode a measure
|
|
1527
1568
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1528
|
-
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}) => {
|
|
1569
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}, measureIds) => {
|
|
1529
1570
|
const measureId = generateId("measure");
|
|
1571
|
+
if (measureIds)
|
|
1572
|
+
measureIds.push(measureId);
|
|
1530
1573
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1531
1574
|
const allHairpins = [];
|
|
1532
1575
|
const allPedals = [];
|
|
@@ -2246,6 +2289,36 @@ const encode = (doc, options = {}) => {
|
|
|
2246
2289
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2247
2290
|
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2248
2291
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2292
|
+
// Splice point for an <expansion> element (must be the FIRST child of
|
|
2293
|
+
// <section>, but its plist needs the measure ids collected during the loop
|
|
2294
|
+
// below — so remember where to insert it and build it afterwards).
|
|
2295
|
+
const expansionInsertPos = mei.length;
|
|
2296
|
+
const measureIds = [];
|
|
2297
|
+
// Volta (1st/2nd-ending) house brackets. The measure-layout directive already
|
|
2298
|
+
// encodes which measures form each alternate; <expansion> replays them but does
|
|
2299
|
+
// NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
|
|
2300
|
+
// span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
|
|
2301
|
+
// own MusicXML import does the same). Keyed by the 1-based measure index where
|
|
2302
|
+
// an ending opens / closes.
|
|
2303
|
+
const endingOpenAt = new Map(); // measure index → ending number
|
|
2304
|
+
const endingCloseAt = new Set(); // measure index after which </ending> closes
|
|
2305
|
+
if (doc.metadata?.measureLayout) {
|
|
2306
|
+
try {
|
|
2307
|
+
const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2308
|
+
for (const s of spans) {
|
|
2309
|
+
// Only wrap a contiguous ascending run (a house is always adjacent bars).
|
|
2310
|
+
const lo = s.measures[0];
|
|
2311
|
+
const hi = s.measures[s.measures.length - 1];
|
|
2312
|
+
const contiguous = s.measures.every((m, k) => m === lo + k);
|
|
2313
|
+
if (contiguous) {
|
|
2314
|
+
endingOpenAt.set(lo, s.number);
|
|
2315
|
+
endingCloseAt.add(hi);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2320
|
+
}
|
|
2321
|
+
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2249
2322
|
// Track tie state across measures for cross-measure ties
|
|
2250
2323
|
const tieState = {};
|
|
2251
2324
|
// Track slur state across measures for cross-measure slurs
|
|
@@ -2352,8 +2425,55 @@ const encode = (doc, options = {}) => {
|
|
|
2352
2425
|
mei += `${sd}</scoreDef>\n`;
|
|
2353
2426
|
}
|
|
2354
2427
|
}
|
|
2355
|
-
|
|
2428
|
+
const mIndex = mi + 1;
|
|
2429
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2430
|
+
// Open an <ending> house bracket if this measure starts an alternate.
|
|
2431
|
+
const openNum = endingOpenAt.get(mIndex);
|
|
2432
|
+
if (openNum !== undefined && !endingOpen) {
|
|
2433
|
+
const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
|
|
2434
|
+
mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
|
|
2435
|
+
endingOpen = true;
|
|
2436
|
+
}
|
|
2437
|
+
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2438
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2439
|
+
// Close the <ending> after the measure that ends this alternate.
|
|
2440
|
+
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2441
|
+
mei += `${mIndent}</ending>\n`;
|
|
2442
|
+
endingOpen = false;
|
|
2443
|
+
}
|
|
2356
2444
|
});
|
|
2445
|
+
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2446
|
+
if (endingOpen) {
|
|
2447
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`;
|
|
2448
|
+
endingOpen = false;
|
|
2449
|
+
}
|
|
2450
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2451
|
+
// directive ([measures "..."]). Verovio's expand="…" consumes its plist to
|
|
2452
|
+
// unfold repeats for MIDI/time-map output. Inserted as the first child of
|
|
2453
|
+
// <section>; ids were collected in the measure loop above.
|
|
2454
|
+
if (doc.metadata?.measureLayout) {
|
|
2455
|
+
try {
|
|
2456
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2457
|
+
const order = expandMeasureLayout(layout);
|
|
2458
|
+
const refs = [];
|
|
2459
|
+
for (const idx of order) {
|
|
2460
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2461
|
+
if (id === undefined) {
|
|
2462
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
refs.push(`#${id}`);
|
|
2466
|
+
}
|
|
2467
|
+
if (refs.length > 0) {
|
|
2468
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2469
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2470
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
catch (err) {
|
|
2474
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2357
2477
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2358
2478
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
2359
2479
|
mei += `${indent}${indent}${indent}</mdiv>\n`;
|
|
@@ -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';
|