@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
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./types";
|
|
30
30
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
31
|
import { gmProgramOf } from "./gmInstruments";
|
|
32
|
+
import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -748,6 +749,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
748
749
|
interface TupletEventResult {
|
|
749
750
|
xml: string;
|
|
750
751
|
firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
|
|
752
|
+
lastNoteId: string | null; // ID of last note/rest in tuplet (for closing/extending ottava spans)
|
|
751
753
|
slurStarts: string[]; // Note IDs that start slurs
|
|
752
754
|
slurEnds: string[]; // Note IDs that end slurs
|
|
753
755
|
dynamics: DynamRef[];
|
|
@@ -794,6 +796,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
794
796
|
|
|
795
797
|
// Collect control event info from notes inside tuplet
|
|
796
798
|
let firstNoteId: string | null = null;
|
|
799
|
+
let lastNoteId: string | null = null; // last note/rest id — anchors an ottava span end
|
|
797
800
|
const slurStarts: string[] = [];
|
|
798
801
|
const slurEnds: string[] = [];
|
|
799
802
|
const dynamics: DynamRef[] = [];
|
|
@@ -833,6 +836,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
833
836
|
xml += result.xml;
|
|
834
837
|
|
|
835
838
|
if (!firstNoteId) firstNoteId = result.elementId;
|
|
839
|
+
lastNoteId = result.elementId;
|
|
836
840
|
|
|
837
841
|
// Collect slur info
|
|
838
842
|
if (result.slurStart) slurStarts.push(result.elementId);
|
|
@@ -863,6 +867,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
863
867
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
864
868
|
const restResult = restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
865
869
|
xml += restResult.xml;
|
|
870
|
+
lastNoteId = restResult.elementId;
|
|
866
871
|
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
867
872
|
} else if (e.type === 'context') {
|
|
868
873
|
const ctx = e as ContextChange;
|
|
@@ -885,7 +890,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
885
890
|
}
|
|
886
891
|
|
|
887
892
|
xml += `${indent}</tuplet>\n`;
|
|
888
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
893
|
+
return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
889
894
|
};
|
|
890
895
|
|
|
891
896
|
|
|
@@ -1143,6 +1148,39 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1143
1148
|
let lastNoteId: string | null = null; // Track last note id for ending ottava spans
|
|
1144
1149
|
let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
|
|
1145
1150
|
|
|
1151
|
+
// ── Ottava span anchoring (shared by note / rest / tuplet cases) ──
|
|
1152
|
+
// Originally the open/extend/close logic lived ONLY in `case 'note'`, so an
|
|
1153
|
+
// ottava span running over a tuplet run (ubiquitous in etudes) or over rests
|
|
1154
|
+
// was never opened — the whole span vanished (No.26 10→0, No.20 7→0). These
|
|
1155
|
+
// helpers let any event type anchor a span. Mirror the note-case ordering:
|
|
1156
|
+
// the caller updates lastNoteId to the event's first emitted id, THEN calls
|
|
1157
|
+
// these.
|
|
1158
|
+
// Apply a pending ottava's pitch shift before the event's notes are encoded.
|
|
1159
|
+
const applyPendingOttavaShift = (): void => {
|
|
1160
|
+
if (pendingOttava !== null && pendingOttava !== 0) currentOttavaShift = pendingOttava;
|
|
1161
|
+
};
|
|
1162
|
+
// Extend a carried (endToken) span's end to this event's last emitted id.
|
|
1163
|
+
const extendOttavaEnd = (lastId: string): void => {
|
|
1164
|
+
if (currentOctave?.endToken) octaveEndReplacements[currentOctave.endToken] = lastId;
|
|
1165
|
+
};
|
|
1166
|
+
// Open the pending ottava span on `anchorId` (the event's FIRST emitted id),
|
|
1167
|
+
// closing any open span of a different value first; or resolve a continued span.
|
|
1168
|
+
const applyOttavaAnchor = (anchorId: string): void => {
|
|
1169
|
+
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1170
|
+
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
1171
|
+
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
1172
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
1173
|
+
octaves.push({ dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, endId: anchorId });
|
|
1174
|
+
currentOctave = null;
|
|
1175
|
+
}
|
|
1176
|
+
if (!currentOctave) currentOctave = { dis, disPlace, startId: anchorId };
|
|
1177
|
+
pendingOttava = null;
|
|
1178
|
+
} else if (currentOctave?.continued) {
|
|
1179
|
+
if (!currentOctave.endToken) currentOctave.startId = anchorId;
|
|
1180
|
+
currentOctave.continued = false;
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1146
1184
|
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
1147
1185
|
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
1148
1186
|
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
@@ -1248,9 +1286,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1248
1286
|
const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
|
|
1249
1287
|
|
|
1250
1288
|
// If there's a pending ottava, apply it BEFORE encoding the note
|
|
1251
|
-
|
|
1252
|
-
currentOttavaShift = pendingOttava; // Apply the shift for this note
|
|
1253
|
-
}
|
|
1289
|
+
applyPendingOttavaShift();
|
|
1254
1290
|
|
|
1255
1291
|
// For cross-staff notation: set note's staff to currentStaff if different from voice.staff
|
|
1256
1292
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
@@ -1260,9 +1296,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1260
1296
|
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1261
1297
|
xml += result.xml;
|
|
1262
1298
|
lastNoteId = result.elementId;
|
|
1263
|
-
|
|
1264
|
-
octaveEndReplacements[currentOctave.endToken] = result.elementId;
|
|
1265
|
-
}
|
|
1299
|
+
extendOttavaEnd(result.elementId);
|
|
1266
1300
|
|
|
1267
1301
|
// Flush any pending markups onto this note
|
|
1268
1302
|
flushPendingMarkups(result.elementId);
|
|
@@ -1271,32 +1305,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1271
1305
|
flushPendingDynamics(result.elementId);
|
|
1272
1306
|
|
|
1273
1307
|
// If there's a pending ottava, start the span on this note
|
|
1274
|
-
|
|
1275
|
-
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
1276
|
-
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
1277
|
-
// Close existing span first if it has a different value
|
|
1278
|
-
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
1279
|
-
if (lastNoteId) {
|
|
1280
|
-
octaves.push({
|
|
1281
|
-
dis: currentOctave.dis,
|
|
1282
|
-
disPlace: currentOctave.disPlace,
|
|
1283
|
-
startId: currentOctave.startId,
|
|
1284
|
-
endId: lastNoteId,
|
|
1285
|
-
});
|
|
1286
|
-
}
|
|
1287
|
-
currentOctave = null;
|
|
1288
|
-
}
|
|
1289
|
-
// Start new span if we don't already have one with the same value
|
|
1290
|
-
if (!currentOctave) {
|
|
1291
|
-
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
1292
|
-
}
|
|
1293
|
-
pendingOttava = null;
|
|
1294
|
-
} else if (currentOctave?.continued) {
|
|
1295
|
-
if (!currentOctave.endToken) {
|
|
1296
|
-
currentOctave.startId = result.elementId;
|
|
1297
|
-
}
|
|
1298
|
-
currentOctave.continued = false;
|
|
1299
|
-
}
|
|
1308
|
+
applyOttavaAnchor(result.elementId);
|
|
1300
1309
|
|
|
1301
1310
|
// Update pending tie pitches
|
|
1302
1311
|
if (result.hasTieStart) {
|
|
@@ -1394,23 +1403,38 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1394
1403
|
case 'rest': {
|
|
1395
1404
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1396
1405
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1406
|
+
// A pending ottava shift applies to a rest's gestural octave too.
|
|
1407
|
+
applyPendingOttavaShift();
|
|
1397
1408
|
const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1398
1409
|
xml += restResult.xml;
|
|
1410
|
+
lastNoteId = restResult.elementId;
|
|
1411
|
+
// An ottava span may open on, run through, or close at a rest (e.g. a
|
|
1412
|
+
// span that starts just before LH rests). Anchor it like a note.
|
|
1413
|
+
extendOttavaEnd(restResult.elementId);
|
|
1414
|
+
applyOttavaAnchor(restResult.elementId);
|
|
1399
1415
|
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1400
1416
|
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1401
1417
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1402
1418
|
flushPendingMarkups(restResult.elementId);
|
|
1403
1419
|
flushPendingDynamics(restResult.elementId);
|
|
1404
|
-
lastNoteId = restResult.elementId;
|
|
1405
1420
|
break;
|
|
1406
1421
|
}
|
|
1407
1422
|
case 'tuplet':
|
|
1408
1423
|
case 'times': {
|
|
1409
1424
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1410
1425
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1426
|
+
// A pending ottava shift applies to the tuplet's notes' written octave.
|
|
1427
|
+
applyPendingOttavaShift();
|
|
1411
1428
|
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1412
1429
|
xml += tupletResult.xml;
|
|
1413
1430
|
|
|
1431
|
+
// An ottava span can open on, run through, or close at a tuplet (common
|
|
1432
|
+
// in etudes where the 8va covers a whole tuplet run). Anchor the span on
|
|
1433
|
+
// the tuplet's FIRST note and let its LAST note close/extend it — without
|
|
1434
|
+
// this the entire span was dropped (start/close lived only in case 'note').
|
|
1435
|
+
if (tupletResult.firstNoteId) applyOttavaAnchor(tupletResult.firstNoteId);
|
|
1436
|
+
if (tupletResult.lastNoteId) extendOttavaEnd(tupletResult.lastNoteId);
|
|
1437
|
+
|
|
1414
1438
|
// Propagate clef change from inside the tuplet to the parent tracker
|
|
1415
1439
|
if (tupletResult.endingClef) {
|
|
1416
1440
|
currentClef = tupletResult.endingClef as Clef;
|
|
@@ -1420,7 +1444,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1420
1444
|
if (tupletResult.firstNoteId) {
|
|
1421
1445
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1422
1446
|
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1423
|
-
lastNoteId = tupletResult.firstNoteId;
|
|
1447
|
+
lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
|
|
1424
1448
|
}
|
|
1425
1449
|
|
|
1426
1450
|
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
@@ -1482,9 +1506,22 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1482
1506
|
}
|
|
1483
1507
|
currentOctave = null;
|
|
1484
1508
|
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1509
|
+
} else if (currentOctave && currentOctave.emitted) {
|
|
1510
|
+
// Stop landed at measure START (no note yet in this layer) but the
|
|
1511
|
+
// span was carried+emitted in a PREVIOUS measure: it genuinely ends
|
|
1512
|
+
// here. Its deferred endToken was already resolved to the correct
|
|
1513
|
+
// last note by extendOttavaEnd as the span ran through prior measures
|
|
1514
|
+
// (or by the staff-level carry's endFallbackId), so DON'T rewrite it
|
|
1515
|
+
// here — just clear the slot and mark closed, so a later same-value
|
|
1516
|
+
// ottava opens a NEW span instead of folding into this one.
|
|
1517
|
+
// Without this, sequential cross-measure spans collapse into one
|
|
1518
|
+
// (No.57: 18 spans → 8).
|
|
1519
|
+
currentOctave = null;
|
|
1520
|
+
ottavaExplicitlyClosed = true;
|
|
1485
1521
|
}
|
|
1486
|
-
// Note: if no lastNoteId (e.g., at
|
|
1487
|
-
//
|
|
1522
|
+
// Note: if no lastNoteId and not yet emitted (e.g., open+close at
|
|
1523
|
+
// measure start with no notes), keep currentOctave alive — it may be
|
|
1524
|
+
// continued by a subsequent ottava command with the same value.
|
|
1488
1525
|
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1489
1526
|
} else {
|
|
1490
1527
|
// Check if this continues an existing span (same value)
|
|
@@ -1866,8 +1903,9 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1866
1903
|
|
|
1867
1904
|
// Encode a measure
|
|
1868
1905
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1869
|
-
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}): string => {
|
|
1906
|
+
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}, measureIds?: string[]): string => {
|
|
1870
1907
|
const measureId = generateId("measure");
|
|
1908
|
+
if (measureIds) measureIds.push(measureId);
|
|
1871
1909
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1872
1910
|
const allHairpins: HairpinSpan[] = [];
|
|
1873
1911
|
const allPedals: PedalMark[] = [];
|
|
@@ -2700,6 +2738,74 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2700
2738
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2701
2739
|
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2702
2740
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2741
|
+
// Splice point for an <expansion> element (must be the FIRST child of
|
|
2742
|
+
// <section>, but its plist needs the measure ids collected during the loop
|
|
2743
|
+
// below — so remember where to insert it and build it afterwards).
|
|
2744
|
+
const expansionInsertPos = mei.length;
|
|
2745
|
+
const measureIds: string[] = [];
|
|
2746
|
+
|
|
2747
|
+
// Volta playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2748
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2749
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2750
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2751
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2752
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2753
|
+
// Helper to check if a measure has any musical content
|
|
2754
|
+
const measureHasContent = (measure: Measure): boolean => {
|
|
2755
|
+
for (const part of measure.parts) {
|
|
2756
|
+
for (const voice of part.voices) {
|
|
2757
|
+
for (const event of voice.events) {
|
|
2758
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2759
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2760
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2761
|
+
return true;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
return false;
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2770
|
+
// guard below knows the true emitted measure count).
|
|
2771
|
+
let measures = doc.measures;
|
|
2772
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2773
|
+
measures = measures.slice(0, -1);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
let segDecomp: ReturnType<typeof decomposeToSegments> = null;
|
|
2777
|
+
const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
|
|
2778
|
+
const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
|
|
2779
|
+
const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
|
|
2780
|
+
if (doc.metadata?.measureLayout) {
|
|
2781
|
+
try {
|
|
2782
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2783
|
+
} catch { segDecomp = null; }
|
|
2784
|
+
}
|
|
2785
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2786
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2787
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2788
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2789
|
+
// already warns-and-skips out-of-range refs.
|
|
2790
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2791
|
+
segDecomp = null;
|
|
2792
|
+
}
|
|
2793
|
+
if (segDecomp) {
|
|
2794
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2795
|
+
for (const seg of segDecomp.segments) {
|
|
2796
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2797
|
+
segIdById.set(seg.id, xmlId);
|
|
2798
|
+
const lo = seg.measures[0];
|
|
2799
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2800
|
+
if (seg.kind === "ending") {
|
|
2801
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2802
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2803
|
+
} else {
|
|
2804
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2805
|
+
}
|
|
2806
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2703
2809
|
|
|
2704
2810
|
// Track tie state across measures for cross-measure ties
|
|
2705
2811
|
const tieState: TieState = {};
|
|
@@ -2731,28 +2837,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2731
2837
|
}
|
|
2732
2838
|
}
|
|
2733
2839
|
|
|
2734
|
-
// Helper to check if a measure has any musical content
|
|
2735
|
-
const measureHasContent = (measure: Measure): boolean => {
|
|
2736
|
-
for (const part of measure.parts) {
|
|
2737
|
-
for (const voice of part.voices) {
|
|
2738
|
-
for (const event of voice.events) {
|
|
2739
|
-
// Check for actual musical content (not just context changes or pitch resets)
|
|
2740
|
-
if (event.type === 'note' || event.type === 'rest' ||
|
|
2741
|
-
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2742
|
-
return true;
|
|
2743
|
-
}
|
|
2744
|
-
}
|
|
2745
|
-
}
|
|
2746
|
-
}
|
|
2747
|
-
return false;
|
|
2748
|
-
};
|
|
2749
|
-
|
|
2750
|
-
// Filter out trailing empty measures
|
|
2751
|
-
let measures = doc.measures;
|
|
2752
|
-
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2753
|
-
measures = measures.slice(0, -1);
|
|
2754
|
-
}
|
|
2755
|
-
|
|
2756
2840
|
// Encode measures
|
|
2757
2841
|
measures.forEach((measure, mi) => {
|
|
2758
2842
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2810,9 +2894,57 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2810
2894
|
mei += `${sd}</scoreDef>\n`;
|
|
2811
2895
|
}
|
|
2812
2896
|
}
|
|
2813
|
-
|
|
2897
|
+
const mIndex = mi + 1;
|
|
2898
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2899
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2900
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2901
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2902
|
+
if (openTag) mei += openTag;
|
|
2903
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2904
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2905
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2906
|
+
if (closeTag) mei += closeTag;
|
|
2814
2907
|
});
|
|
2815
2908
|
|
|
2909
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2910
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2911
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2912
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2913
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2914
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2915
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2916
|
+
if (segDecomp) {
|
|
2917
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
|
|
2918
|
+
if (refs.length > 0) {
|
|
2919
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2920
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2921
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
else if (doc.metadata?.measureLayout) {
|
|
2925
|
+
try {
|
|
2926
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2927
|
+
const order = expandMeasureLayout(layout);
|
|
2928
|
+
const refs: string[] = [];
|
|
2929
|
+
for (const idx of order) {
|
|
2930
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2931
|
+
if (id === undefined) {
|
|
2932
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2933
|
+
continue;
|
|
2934
|
+
}
|
|
2935
|
+
refs.push(`#${id}`);
|
|
2936
|
+
}
|
|
2937
|
+
if (refs.length > 0) {
|
|
2938
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2939
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2940
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
catch (err) {
|
|
2944
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${(err as Error).message}`);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2816
2948
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2817
2949
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
2818
2950
|
mei += `${indent}${indent}${indent}</mdiv>\n`;
|