@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
|
@@ -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, decomposeToSegments } 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,75 @@ 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 playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2298
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2299
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2300
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2301
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2302
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2303
|
+
// Helper to check if a measure has any musical content
|
|
2304
|
+
const measureHasContent = (measure) => {
|
|
2305
|
+
for (const part of measure.parts) {
|
|
2306
|
+
for (const voice of part.voices) {
|
|
2307
|
+
for (const event of voice.events) {
|
|
2308
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2309
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2310
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
return false;
|
|
2317
|
+
};
|
|
2318
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2319
|
+
// guard below knows the true emitted measure count).
|
|
2320
|
+
let measures = doc.measures;
|
|
2321
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2322
|
+
measures = measures.slice(0, -1);
|
|
2323
|
+
}
|
|
2324
|
+
let segDecomp = null;
|
|
2325
|
+
const segOpenAt = new Map(); // measure index → opening tag for its segment
|
|
2326
|
+
const segCloseAt = new Map(); // measure index → closing tag after it
|
|
2327
|
+
const segIdById = new Map(); // layout segment id → emitted xml:id
|
|
2328
|
+
if (doc.metadata?.measureLayout) {
|
|
2329
|
+
try {
|
|
2330
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
segDecomp = null;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2337
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2338
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2339
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2340
|
+
// already warns-and-skips out-of-range refs.
|
|
2341
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2342
|
+
segDecomp = null;
|
|
2343
|
+
}
|
|
2344
|
+
if (segDecomp) {
|
|
2345
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2346
|
+
for (const seg of segDecomp.segments) {
|
|
2347
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2348
|
+
segIdById.set(seg.id, xmlId);
|
|
2349
|
+
const lo = seg.measures[0];
|
|
2350
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2351
|
+
if (seg.kind === "ending") {
|
|
2352
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2353
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2354
|
+
}
|
|
2355
|
+
else {
|
|
2356
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2357
|
+
}
|
|
2358
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2249
2361
|
// Track tie state across measures for cross-measure ties
|
|
2250
2362
|
const tieState = {};
|
|
2251
2363
|
// Track slur state across measures for cross-measure slurs
|
|
@@ -2271,26 +2383,6 @@ const encode = (doc, options = {}) => {
|
|
|
2271
2383
|
transState[globalStaff] = `${t.diat},${t.semi}`;
|
|
2272
2384
|
}
|
|
2273
2385
|
}
|
|
2274
|
-
// Helper to check if a measure has any musical content
|
|
2275
|
-
const measureHasContent = (measure) => {
|
|
2276
|
-
for (const part of measure.parts) {
|
|
2277
|
-
for (const voice of part.voices) {
|
|
2278
|
-
for (const event of voice.events) {
|
|
2279
|
-
// Check for actual musical content (not just context changes or pitch resets)
|
|
2280
|
-
if (event.type === 'note' || event.type === 'rest' ||
|
|
2281
|
-
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2282
|
-
return true;
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
return false;
|
|
2288
|
-
};
|
|
2289
|
-
// Filter out trailing empty measures
|
|
2290
|
-
let measures = doc.measures;
|
|
2291
|
-
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2292
|
-
measures = measures.slice(0, -1);
|
|
2293
|
-
}
|
|
2294
2386
|
// Encode measures
|
|
2295
2387
|
measures.forEach((measure, mi) => {
|
|
2296
2388
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2352,8 +2444,57 @@ const encode = (doc, options = {}) => {
|
|
|
2352
2444
|
mei += `${sd}</scoreDef>\n`;
|
|
2353
2445
|
}
|
|
2354
2446
|
}
|
|
2355
|
-
|
|
2447
|
+
const mIndex = mi + 1;
|
|
2448
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2449
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2450
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2451
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2452
|
+
if (openTag)
|
|
2453
|
+
mei += openTag;
|
|
2454
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2455
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2456
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2457
|
+
if (closeTag)
|
|
2458
|
+
mei += closeTag;
|
|
2356
2459
|
});
|
|
2460
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2461
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2462
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2463
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2464
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2465
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2466
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2467
|
+
if (segDecomp) {
|
|
2468
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x) => x !== undefined).map(id => `#${id}`);
|
|
2469
|
+
if (refs.length > 0) {
|
|
2470
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2471
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2472
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
else if (doc.metadata?.measureLayout) {
|
|
2476
|
+
try {
|
|
2477
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2478
|
+
const order = expandMeasureLayout(layout);
|
|
2479
|
+
const refs = [];
|
|
2480
|
+
for (const idx of order) {
|
|
2481
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2482
|
+
if (id === undefined) {
|
|
2483
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2484
|
+
continue;
|
|
2485
|
+
}
|
|
2486
|
+
refs.push(`#${id}`);
|
|
2487
|
+
}
|
|
2488
|
+
if (refs.length > 0) {
|
|
2489
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2490
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2491
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
catch (err) {
|
|
2495
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2357
2498
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2358
2499
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
2359
2500
|
mei += `${indent}${indent}${indent}</mdiv>\n`;
|