@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
|
@@ -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, collectVoltaSpans } 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,33 @@ 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 (1st/2nd-ending) house brackets. The measure-layout directive already
|
|
2748
|
+
// encodes which measures form each alternate; <expansion> replays them but does
|
|
2749
|
+
// NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
|
|
2750
|
+
// span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
|
|
2751
|
+
// own MusicXML import does the same). Keyed by the 1-based measure index where
|
|
2752
|
+
// an ending opens / closes.
|
|
2753
|
+
const endingOpenAt = new Map<number, number>(); // measure index → ending number
|
|
2754
|
+
const endingCloseAt = new Set<number>(); // measure index after which </ending> closes
|
|
2755
|
+
if (doc.metadata?.measureLayout) {
|
|
2756
|
+
try {
|
|
2757
|
+
const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2758
|
+
for (const s of spans) {
|
|
2759
|
+
// Only wrap a contiguous ascending run (a house is always adjacent bars).
|
|
2760
|
+
const lo = s.measures[0];
|
|
2761
|
+
const hi = s.measures[s.measures.length - 1];
|
|
2762
|
+
const contiguous = s.measures.every((m, k) => m === lo + k);
|
|
2763
|
+
if (contiguous) { endingOpenAt.set(lo, s.number); endingCloseAt.add(hi); }
|
|
2764
|
+
}
|
|
2765
|
+
} catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2766
|
+
}
|
|
2767
|
+
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2703
2768
|
|
|
2704
2769
|
// Track tie state across measures for cross-measure ties
|
|
2705
2770
|
const tieState: TieState = {};
|
|
@@ -2810,8 +2875,53 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2810
2875
|
mei += `${sd}</scoreDef>\n`;
|
|
2811
2876
|
}
|
|
2812
2877
|
}
|
|
2813
|
-
|
|
2878
|
+
const mIndex = mi + 1;
|
|
2879
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2880
|
+
// Open an <ending> house bracket if this measure starts an alternate.
|
|
2881
|
+
const openNum = endingOpenAt.get(mIndex);
|
|
2882
|
+
if (openNum !== undefined && !endingOpen) {
|
|
2883
|
+
const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
|
|
2884
|
+
mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
|
|
2885
|
+
endingOpen = true;
|
|
2886
|
+
}
|
|
2887
|
+
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2888
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2889
|
+
// Close the <ending> after the measure that ends this alternate.
|
|
2890
|
+
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2891
|
+
mei += `${mIndent}</ending>\n`;
|
|
2892
|
+
endingOpen = false;
|
|
2893
|
+
}
|
|
2814
2894
|
});
|
|
2895
|
+
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2896
|
+
if (endingOpen) { mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`; endingOpen = false; }
|
|
2897
|
+
|
|
2898
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2899
|
+
// directive ([measures "..."]). Verovio's expand="…" consumes its plist to
|
|
2900
|
+
// unfold repeats for MIDI/time-map output. Inserted as the first child of
|
|
2901
|
+
// <section>; ids were collected in the measure loop above.
|
|
2902
|
+
if (doc.metadata?.measureLayout) {
|
|
2903
|
+
try {
|
|
2904
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2905
|
+
const order = expandMeasureLayout(layout);
|
|
2906
|
+
const refs: string[] = [];
|
|
2907
|
+
for (const idx of order) {
|
|
2908
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2909
|
+
if (id === undefined) {
|
|
2910
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2911
|
+
continue;
|
|
2912
|
+
}
|
|
2913
|
+
refs.push(`#${id}`);
|
|
2914
|
+
}
|
|
2915
|
+
if (refs.length > 0) {
|
|
2916
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2917
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2918
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
catch (err) {
|
|
2922
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${(err as Error).message}`);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2815
2925
|
|
|
2816
2926
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2817
2927
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
createFraction,
|
|
70
70
|
TYPE_TO_DIVISION,
|
|
71
71
|
} from './musicXmlUtils';
|
|
72
|
+
import { measureLayoutFromPart } from './measureLayoutFromXml';
|
|
72
73
|
|
|
73
74
|
// ============ Spanner Tracker ============
|
|
74
75
|
|
|
@@ -630,6 +631,25 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
|
|
|
630
631
|
result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
|
|
631
632
|
result.staff = getElementInt(dirEl, 'staff');
|
|
632
633
|
|
|
634
|
+
// Navigation jumps live on the <sound> child of <direction> (a sibling of
|
|
635
|
+
// <direction-type>). Parse it first, before the no-direction-type early return,
|
|
636
|
+
// so a sound-only direction still contributes its jump semantics.
|
|
637
|
+
const soundEl = dirEl.getElementsByTagName('sound')[0];
|
|
638
|
+
if (soundEl) {
|
|
639
|
+
const sound: NonNullable<MusicXmlDirection['sound']> = {};
|
|
640
|
+
if (getAttribute(soundEl, 'dacapo') === 'yes') sound.dacapo = true;
|
|
641
|
+
if (getAttribute(soundEl, 'fine') === 'yes') sound.fine = true;
|
|
642
|
+
const dalsegno = getAttribute(soundEl, 'dalsegno');
|
|
643
|
+
if (dalsegno) sound.dalsegno = dalsegno;
|
|
644
|
+
const segno = getAttribute(soundEl, 'segno');
|
|
645
|
+
if (segno) sound.segno = segno;
|
|
646
|
+
const coda = getAttribute(soundEl, 'coda');
|
|
647
|
+
if (coda) sound.coda = coda;
|
|
648
|
+
const tocoda = getAttribute(soundEl, 'tocoda');
|
|
649
|
+
if (tocoda) sound.tocoda = tocoda;
|
|
650
|
+
if (Object.keys(sound).length > 0) result.sound = sound;
|
|
651
|
+
}
|
|
652
|
+
|
|
633
653
|
const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
|
|
634
654
|
if (!dirTypeEl) {
|
|
635
655
|
return result;
|
|
@@ -722,7 +742,8 @@ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
|
|
|
722
742
|
if (repeatEl) {
|
|
723
743
|
const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
|
|
724
744
|
if (direction) {
|
|
725
|
-
|
|
745
|
+
const times = getAttributeNumber(repeatEl, 'times');
|
|
746
|
+
result.repeat = times !== undefined ? { direction, times } : { direction };
|
|
726
747
|
}
|
|
727
748
|
}
|
|
728
749
|
|
|
@@ -1065,7 +1086,10 @@ const directionToMarks = (
|
|
|
1065
1086
|
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
1066
1087
|
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
1067
1088
|
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
1068
|
-
// directions are tempo too, never markup.
|
|
1089
|
+
// directions are tempo too, never markup. Navigation text ("D.C. al Fine",
|
|
1090
|
+
// "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
|
|
1091
|
+
// markup glyph — the jump SEMANTICS are captured separately as measure-layout.
|
|
1092
|
+
let emittedWords = false;
|
|
1069
1093
|
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
1070
1094
|
const text = direction.words.map(w => w.text).join('').trim();
|
|
1071
1095
|
if (text && !isTempoWord(text)) {
|
|
@@ -1073,6 +1097,27 @@ const directionToMarks = (
|
|
|
1073
1097
|
: direction.placement === 'below' ? Placement.below
|
|
1074
1098
|
: undefined;
|
|
1075
1099
|
marks.push({ markType: 'markup', content: text, placement });
|
|
1100
|
+
emittedWords = true;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
|
|
1105
|
+
// synthesize the conventional glyph text so the score still shows the marking.
|
|
1106
|
+
// Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
|
|
1107
|
+
// emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
|
|
1108
|
+
// synthesizing, and only when the engraver left them implicit.
|
|
1109
|
+
if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
|
|
1110
|
+
const s = direction.sound;
|
|
1111
|
+
const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
|
|
1112
|
+
: s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
|
|
1113
|
+
: s.tocoda ? 'To Coda'
|
|
1114
|
+
: s.fine ? 'Fine'
|
|
1115
|
+
: undefined;
|
|
1116
|
+
if (label) {
|
|
1117
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
1118
|
+
: direction.placement === 'below' ? Placement.below
|
|
1119
|
+
: undefined;
|
|
1120
|
+
marks.push({ markType: 'markup', content: label, placement });
|
|
1076
1121
|
}
|
|
1077
1122
|
}
|
|
1078
1123
|
|
|
@@ -1087,6 +1132,12 @@ interface MeasureConversionResult {
|
|
|
1087
1132
|
key?: KeySignature;
|
|
1088
1133
|
timeSig?: Fraction;
|
|
1089
1134
|
barline?: BarlineEvent;
|
|
1135
|
+
// A `location="left"` repeat (e.g. forward-repeat `.|:`) belongs at the FRONT
|
|
1136
|
+
// of this measure, but lilylet models a `\bar` as the END bar of its measure.
|
|
1137
|
+
// So it is carried back onto the PREVIOUS measure's trailing barline by
|
|
1138
|
+
// convertPart (dropped if this is the first measure — a leading `|:` at the
|
|
1139
|
+
// very start of the piece is conventionally omitted).
|
|
1140
|
+
leadingBarline?: BarlineEvent;
|
|
1090
1141
|
harmonies: HarmonyEvent[];
|
|
1091
1142
|
clefs: Map<number, ContextChange>; // staff number → clef context
|
|
1092
1143
|
}
|
|
@@ -1149,6 +1200,7 @@ const convertMeasure = (
|
|
|
1149
1200
|
let key: KeySignature | undefined;
|
|
1150
1201
|
let timeSig: Fraction | undefined;
|
|
1151
1202
|
let barline: BarlineEvent | undefined;
|
|
1203
|
+
let leadingBarline: BarlineEvent | undefined;
|
|
1152
1204
|
const harmonies: HarmonyEvent[] = [];
|
|
1153
1205
|
const clefs: Map<number, ContextChange> = new Map();
|
|
1154
1206
|
|
|
@@ -1166,6 +1218,15 @@ const convertMeasure = (
|
|
|
1166
1218
|
// layer (otherwise the encoder can't pair them and drops the span).
|
|
1167
1219
|
const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
|
|
1168
1220
|
let currentVoice = 1; // Track current voice for directions
|
|
1221
|
+
// Mid-measure clef support: a staff can change clef partway through a measure
|
|
1222
|
+
// (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
|
|
1223
|
+
// in an <attributes> block AFTER notes on that staff. We must attach it inline at
|
|
1224
|
+
// that point on the staff's currently-active voice, not collapse it into the
|
|
1225
|
+
// measure-start `clefs` map (which keeps only one clef per staff and emits it at
|
|
1226
|
+
// the bar start). Track the last voice that added a note on each staff, and which
|
|
1227
|
+
// staves already have notes this measure.
|
|
1228
|
+
const lastVoiceOnStaff: Map<number, number> = new Map();
|
|
1229
|
+
const staffHasNotes: Set<number> = new Set();
|
|
1169
1230
|
|
|
1170
1231
|
// Process all children in order
|
|
1171
1232
|
for (const child of getChildElements(measureEl)) {
|
|
@@ -1197,7 +1258,30 @@ const convertMeasure = (
|
|
|
1197
1258
|
for (const clefEntry of attrs.clefs) {
|
|
1198
1259
|
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
1199
1260
|
if (clef) {
|
|
1200
|
-
|
|
1261
|
+
const staff = clefEntry.staff;
|
|
1262
|
+
if (staffHasNotes.has(staff)) {
|
|
1263
|
+
// Mid-measure clef change: this staff already has notes in this
|
|
1264
|
+
// measure, so the clef takes effect HERE, not at the bar start.
|
|
1265
|
+
// Append it inline on the staff's currently-active voice via
|
|
1266
|
+
// addEvent (which inserts a staff-switch context if that voice is
|
|
1267
|
+
// currently positioned on another staff — e.g. a cross-staff
|
|
1268
|
+
// voice — so the clef binds to `staff`, not the voice's current
|
|
1269
|
+
// staff). Zero duration: a clef does not advance time.
|
|
1270
|
+
const vNum = lastVoiceOnStaff.get(staff);
|
|
1271
|
+
if (vNum !== undefined) {
|
|
1272
|
+
// Tag the clef context with its staff explicitly. In a
|
|
1273
|
+
// cross-staff voice (where notes interleave staves) a bare
|
|
1274
|
+
// clef is ambiguous and is dropped/misplaced by the serializer;
|
|
1275
|
+
// the explicit staff makes it self-describing (serializer emits
|
|
1276
|
+
// `\staff "N" \clef ...`). addEvent still inserts a leading
|
|
1277
|
+
// staff switch when the voice is currently on another staff.
|
|
1278
|
+
voiceTracker.addEvent(vNum, { type: 'context', staff, clef } as ContextChange, 0, staff);
|
|
1279
|
+
} else {
|
|
1280
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1281
|
+
}
|
|
1282
|
+
} else {
|
|
1283
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1284
|
+
}
|
|
1201
1285
|
}
|
|
1202
1286
|
}
|
|
1203
1287
|
}
|
|
@@ -1206,6 +1290,10 @@ const convertMeasure = (
|
|
|
1206
1290
|
const voiceNum = note.voice;
|
|
1207
1291
|
const staffNum = note.staff || 1;
|
|
1208
1292
|
currentVoice = voiceNum;
|
|
1293
|
+
// Record that this staff now has notes this measure and which voice is
|
|
1294
|
+
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
1295
|
+
staffHasNotes.add(staffNum);
|
|
1296
|
+
lastVoiceOnStaff.set(staffNum, voiceNum);
|
|
1209
1297
|
|
|
1210
1298
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
1211
1299
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
@@ -1412,7 +1500,14 @@ const convertMeasure = (
|
|
|
1412
1500
|
const barlineData = parseBarline(child);
|
|
1413
1501
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
1414
1502
|
if (style && style !== '|') {
|
|
1415
|
-
|
|
1503
|
+
// A `location="left"` barline (forward repeat at the measure front)
|
|
1504
|
+
// is carried back onto the previous measure's end bar by convertPart;
|
|
1505
|
+
// everything else is this measure's own trailing end bar.
|
|
1506
|
+
if (barlineData.location === 'left') {
|
|
1507
|
+
leadingBarline = { type: 'barline', style };
|
|
1508
|
+
} else {
|
|
1509
|
+
barline = { type: 'barline', style };
|
|
1510
|
+
}
|
|
1416
1511
|
}
|
|
1417
1512
|
} else if (tagName === 'harmony') {
|
|
1418
1513
|
const harmonyData = parseHarmony(child);
|
|
@@ -1506,7 +1601,7 @@ const convertMeasure = (
|
|
|
1506
1601
|
});
|
|
1507
1602
|
}
|
|
1508
1603
|
|
|
1509
|
-
return { voiceMap, key, timeSig, barline, harmonies, clefs };
|
|
1604
|
+
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
1510
1605
|
};
|
|
1511
1606
|
|
|
1512
1607
|
/**
|
|
@@ -1524,12 +1619,30 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1524
1619
|
let isFirstMeasure = true;
|
|
1525
1620
|
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1526
1621
|
const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
|
|
1622
|
+
// Initial clef per staff declared in the FIRST <attributes> block. A staff whose
|
|
1623
|
+
// first voice does not appear until a later measure (e.g. an accompaniment staff
|
|
1624
|
+
// silent for the opening bars) would otherwise lose its initial clef — it is only
|
|
1625
|
+
// declared in measure 1's attributes, where that staff has no voice to attach it
|
|
1626
|
+
// to. We hold these and flush each onto the staff's FIRST appearing voice.
|
|
1627
|
+
const pendingInitialClefs: Map<number, ContextChange> = new Map();
|
|
1527
1628
|
|
|
1528
1629
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1529
1630
|
|
|
1530
1631
|
for (const measureEl of measureEls) {
|
|
1531
1632
|
voiceTracker.reset();
|
|
1532
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1633
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1634
|
+
|
|
1635
|
+
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1636
|
+
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
1637
|
+
// Append it to the previous measure's first voice; if this is the first
|
|
1638
|
+
// measure there is no previous one, so a leading repeat at the very start of
|
|
1639
|
+
// the piece is conventionally dropped.
|
|
1640
|
+
if (leadingBarline && measures.length > 0) {
|
|
1641
|
+
const prevVoices = measures[measures.length - 1].parts[0]?.voices;
|
|
1642
|
+
if (prevVoices && prevVoices.length > 0) {
|
|
1643
|
+
prevVoices[0].events.push(leadingBarline);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1533
1646
|
|
|
1534
1647
|
// Update running key/time
|
|
1535
1648
|
if (key) lastKey = key;
|
|
@@ -1566,6 +1679,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1566
1679
|
lastClefs.set(voiceData.staff, clef);
|
|
1567
1680
|
}
|
|
1568
1681
|
}
|
|
1682
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1683
|
+
} else if (pendingInitialClefs.has(voiceData.staff)) {
|
|
1684
|
+
// This staff's clef was declared in an earlier measure where the staff
|
|
1685
|
+
// had no voice to carry it (its first appearance is delayed, or it is
|
|
1686
|
+
// an intermittently-empty cross-staff passage). This measure declares
|
|
1687
|
+
// no clef of its own — emit the carried-forward pending clef if it
|
|
1688
|
+
// differs from the clef last in effect for this staff.
|
|
1689
|
+
const pending = pendingInitialClefs.get(voiceData.staff)!;
|
|
1690
|
+
const lastClef = lastClefs.get(voiceData.staff);
|
|
1691
|
+
const isSameClef = lastClef &&
|
|
1692
|
+
(lastClef as ContextChange).clef === (pending as ContextChange).clef;
|
|
1693
|
+
if (!isSameClef) {
|
|
1694
|
+
events.push(pending);
|
|
1695
|
+
lastClefs.set(voiceData.staff, pending);
|
|
1696
|
+
}
|
|
1697
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1569
1698
|
}
|
|
1570
1699
|
staffsWithClef.add(voiceData.staff);
|
|
1571
1700
|
}
|
|
@@ -1573,6 +1702,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1573
1702
|
// Add voice events
|
|
1574
1703
|
events.push(...voiceData.events);
|
|
1575
1704
|
|
|
1705
|
+
// A mid-measure clef change was appended inline to this voice's events (it
|
|
1706
|
+
// does not pass through the measure-start `clefs` map). Scan for the LAST
|
|
1707
|
+
// clef per staff in this voice and update lastClefs, so the next measure's
|
|
1708
|
+
// dedup compares against the clef actually in effect — otherwise a later
|
|
1709
|
+
// measure restating the pre-change clef would be wrongly suppressed.
|
|
1710
|
+
{
|
|
1711
|
+
let scanStaff = voiceData.staff;
|
|
1712
|
+
for (const ev of voiceData.events) {
|
|
1713
|
+
if (ev.type === 'context') {
|
|
1714
|
+
const c = ev as ContextChange;
|
|
1715
|
+
if (c.staff != null) scanStaff = c.staff;
|
|
1716
|
+
if (c.clef) lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1576
1721
|
// Add harmonies and barline to first voice only
|
|
1577
1722
|
if (voiceNum === voiceNumbers[0]) {
|
|
1578
1723
|
for (const h of harmonies) {
|
|
@@ -1596,6 +1741,16 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1596
1741
|
lastVoiceStaff = voices[0].staff || 1;
|
|
1597
1742
|
}
|
|
1598
1743
|
|
|
1744
|
+
// Carry forward any clef declared this measure for a staff that had NO voice to
|
|
1745
|
+
// attach it to (an intermittently-empty staff — e.g. cross-staff passages where
|
|
1746
|
+
// the LH is written entirely on the upper staff for several bars). Remember it
|
|
1747
|
+
// as pending so it is emitted when that staff next reappears; otherwise the clef
|
|
1748
|
+
// (and any change it represents) is lost when this measure's `clefs` map is
|
|
1749
|
+
// discarded. Generalizes the first-measure seeding above to every measure.
|
|
1750
|
+
for (const [staff, clef] of clefs) {
|
|
1751
|
+
if (!staffsWithClef.has(staff)) pendingInitialClefs.set(staff, clef);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1599
1754
|
const measure: Measure = {
|
|
1600
1755
|
parts: [{
|
|
1601
1756
|
voices,
|
|
@@ -1730,6 +1885,14 @@ export const decode = (input: string | Uint8Array): LilyletDoc => {
|
|
|
1730
1885
|
measures: mergedMeasures,
|
|
1731
1886
|
};
|
|
1732
1887
|
|
|
1888
|
+
// Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
|
|
1889
|
+
// from the first part's barline + <sound> navigation markup → measure-layout
|
|
1890
|
+
// string. The MEI encoder turns this into an <expansion> that verovio unfolds
|
|
1891
|
+
// for MIDI. Repeat markup is part-global; the first part carries it. Never
|
|
1892
|
+
// throws (returns undefined when there is nothing to unfold).
|
|
1893
|
+
const measureLayout = measureLayoutFromPart(partEls[0]);
|
|
1894
|
+
if (measureLayout) metadata.measureLayout = measureLayout;
|
|
1895
|
+
|
|
1733
1896
|
if (Object.keys(metadata).length > 0) {
|
|
1734
1897
|
result.metadata = metadata;
|
|
1735
1898
|
}
|
|
@@ -124,6 +124,17 @@ export interface MusicXmlDirection {
|
|
|
124
124
|
octaveShift?: MusicXmlOctaveShift;
|
|
125
125
|
coda?: boolean;
|
|
126
126
|
segno?: boolean;
|
|
127
|
+
// Navigation jumps from the <sound> sibling inside <direction>. Present when
|
|
128
|
+
// the score carries D.C./D.S./Coda/Fine playback semantics; drives the
|
|
129
|
+
// performance-order (measure-layout) derivation, not a notational glyph.
|
|
130
|
+
sound?: {
|
|
131
|
+
dacapo?: boolean; // <sound dacapo="yes"> — da capo
|
|
132
|
+
dalsegno?: string; // <sound dalsegno="N"> — dal segno (target segno id)
|
|
133
|
+
segno?: string; // <sound segno="N"> — marks a segno point
|
|
134
|
+
coda?: string; // <sound coda="N"> — marks a coda point
|
|
135
|
+
tocoda?: string; // <sound tocoda="N"> — "to coda" jump
|
|
136
|
+
fine?: boolean; // <sound fine="yes"> — Fine (stop point on D.C./D.S.)
|
|
137
|
+
};
|
|
127
138
|
}
|
|
128
139
|
|
|
129
140
|
// ============ Barline Types ============
|
|
@@ -133,6 +144,7 @@ export interface MusicXmlBarline {
|
|
|
133
144
|
barStyle?: string; // regular, light-light, light-heavy, heavy-light, heavy-heavy, etc.
|
|
134
145
|
repeat?: {
|
|
135
146
|
direction: 'forward' | 'backward';
|
|
147
|
+
times?: number; // <repeat times="N"> — repeat count (default 2 for backward)
|
|
136
148
|
};
|
|
137
149
|
ending?: {
|
|
138
150
|
type: 'start' | 'stop' | 'discontinue';
|