@k-l-lambda/lilylet 0.1.73 → 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 +292 -86
- package/lib/lilylet/musicXmlDecoder.d.ts +12 -2
- package/lib/lilylet/musicXmlDecoder.js +498 -67
- package/lib/lilylet/musicXmlTypes.d.ts +11 -1
- package/lib/lilylet/serializer.js +124 -10
- package/lib/lilylet/types.d.ts +3 -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 +6 -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 +280 -87
- package/source/lilylet/musicXmlDecoder.ts +494 -67
- package/source/lilylet/musicXmlTypes.ts +14 -1
- package/source/lilylet/serializer.ts +118 -10
- package/source/lilylet/types.ts +6 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
2
|
import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
|
|
3
3
|
import { gmProgramOf } from "./gmInstruments.js";
|
|
4
|
+
import { parseMeasureLayout, expandMeasureLayout, collectVoltaSpans } from "./measureLayout.js";
|
|
4
5
|
// MEI key signatures: positive = sharps, negative = flats
|
|
5
6
|
const KEY_SIGS = {
|
|
6
7
|
0: "0",
|
|
@@ -333,8 +334,10 @@ const extractMarkOptions = (marks) => {
|
|
|
333
334
|
beamStart: false,
|
|
334
335
|
beamEnd: false,
|
|
335
336
|
dynamic: undefined,
|
|
337
|
+
dynamics: [],
|
|
336
338
|
hairpin: undefined,
|
|
337
339
|
pedal: undefined,
|
|
340
|
+
pedals: [],
|
|
338
341
|
tremolo: undefined,
|
|
339
342
|
fingerings: [],
|
|
340
343
|
navigation: undefined,
|
|
@@ -380,7 +383,8 @@ const extractMarkOptions = (marks) => {
|
|
|
380
383
|
case 'dynamic': {
|
|
381
384
|
const dynStr = DYNAMIC_MAP[mark.type];
|
|
382
385
|
if (dynStr) {
|
|
383
|
-
result.dynamic = dynStr;
|
|
386
|
+
result.dynamic = dynStr; // kept for back-compat (first dynamic)
|
|
387
|
+
result.dynamics.push(dynStr);
|
|
384
388
|
}
|
|
385
389
|
break;
|
|
386
390
|
}
|
|
@@ -391,16 +395,22 @@ const extractMarkOptions = (marks) => {
|
|
|
391
395
|
else if (mark.type === HairpinType.diminuendoStart) {
|
|
392
396
|
result.hairpin = 'dimStart';
|
|
393
397
|
}
|
|
394
|
-
else if (mark.type === HairpinType.crescendoEnd
|
|
395
|
-
result.hairpin = '
|
|
398
|
+
else if (mark.type === HairpinType.crescendoEnd) {
|
|
399
|
+
result.hairpin = 'crescEnd';
|
|
400
|
+
}
|
|
401
|
+
else if (mark.type === HairpinType.diminuendoEnd) {
|
|
402
|
+
result.hairpin = 'dimEnd';
|
|
396
403
|
}
|
|
397
404
|
break;
|
|
398
405
|
case 'pedal':
|
|
406
|
+
// A note can carry more than one pedal mark (a pedal "bounce": an up
|
|
407
|
+
// to release the previous pedal and an immediate down to re-pedal on
|
|
408
|
+
// the same note), so collect ALL of them rather than keep one scalar.
|
|
399
409
|
if (mark.type === PedalType.sustainOn) {
|
|
400
|
-
result.
|
|
410
|
+
result.pedals.push('down');
|
|
401
411
|
}
|
|
402
412
|
else if (mark.type === PedalType.sustainOff) {
|
|
403
|
-
result.
|
|
413
|
+
result.pedals.push('up');
|
|
404
414
|
}
|
|
405
415
|
break;
|
|
406
416
|
case 'tie':
|
|
@@ -489,6 +499,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
489
499
|
elementId: noteId,
|
|
490
500
|
hairpin: markOptions.hairpin,
|
|
491
501
|
pedal: markOptions.pedal,
|
|
502
|
+
pedals: markOptions.pedals,
|
|
492
503
|
hasTieStart: markOptions.tieStart,
|
|
493
504
|
pitches: event.pitches,
|
|
494
505
|
arpeggio: markOptions.arpeggio,
|
|
@@ -497,6 +508,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
497
508
|
mordent: markOptions.mordent,
|
|
498
509
|
turn: markOptions.turn,
|
|
499
510
|
dynamic: markOptions.dynamic,
|
|
511
|
+
dynamics: markOptions.dynamics,
|
|
500
512
|
slurStart: markOptions.slurStart,
|
|
501
513
|
slurEnd: markOptions.slurEnd,
|
|
502
514
|
fingerings: markOptions.fingerings,
|
|
@@ -549,6 +561,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
549
561
|
elementId: chordId,
|
|
550
562
|
hairpin: markOptions.hairpin,
|
|
551
563
|
pedal: markOptions.pedal,
|
|
564
|
+
pedals: markOptions.pedals,
|
|
552
565
|
hasTieStart: markOptions.tieStart,
|
|
553
566
|
pitches: event.pitches,
|
|
554
567
|
arpeggio: markOptions.arpeggio,
|
|
@@ -557,6 +570,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
557
570
|
mordent: markOptions.mordent,
|
|
558
571
|
turn: markOptions.turn,
|
|
559
572
|
dynamic: markOptions.dynamic,
|
|
573
|
+
dynamics: markOptions.dynamics,
|
|
560
574
|
slurStart: markOptions.slurStart,
|
|
561
575
|
slurEnd: markOptions.slurEnd,
|
|
562
576
|
fingerings: markOptions.fingerings,
|
|
@@ -574,6 +588,15 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
574
588
|
// Cross-staff attribute
|
|
575
589
|
if (crossStaff)
|
|
576
590
|
attrs += ` staff="${crossStaff}"`;
|
|
591
|
+
// A rest may carry a fermata (held silence). Surface it so the layer loop can
|
|
592
|
+
// emit a <fermata> control event referencing this rest's id.
|
|
593
|
+
let fermata;
|
|
594
|
+
if (event.marks) {
|
|
595
|
+
for (const mk of event.marks) {
|
|
596
|
+
if (mk.markType === 'ornament' && mk.type === 'fermata')
|
|
597
|
+
fermata = 'normal';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
577
600
|
// Pitched rest (positioned at specific pitch)
|
|
578
601
|
if (event.pitch) {
|
|
579
602
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -581,14 +604,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
581
604
|
}
|
|
582
605
|
// Space rest (invisible)
|
|
583
606
|
if (event.invisible) {
|
|
584
|
-
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
607
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
|
|
585
608
|
}
|
|
586
609
|
// Full measure rest
|
|
587
610
|
if (event.fullMeasure) {
|
|
588
611
|
const mRestId = generateId('mrest');
|
|
589
|
-
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
612
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
|
|
590
613
|
}
|
|
591
|
-
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
614
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
|
|
592
615
|
};
|
|
593
616
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
594
617
|
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
@@ -619,6 +642,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
619
642
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
620
643
|
// Collect control event info from notes inside tuplet
|
|
621
644
|
let firstNoteId = null;
|
|
645
|
+
let lastNoteId = null; // last note/rest id — anchors an ottava span end
|
|
622
646
|
const slurStarts = [];
|
|
623
647
|
const slurEnds = [];
|
|
624
648
|
const dynamics = [];
|
|
@@ -627,6 +651,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
627
651
|
const mordents = [];
|
|
628
652
|
const turns = [];
|
|
629
653
|
const arpeggios = [];
|
|
654
|
+
const pedals = [];
|
|
655
|
+
const fingerings = [];
|
|
656
|
+
const markups = [];
|
|
630
657
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
631
658
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
632
659
|
let beamOpen = false;
|
|
@@ -650,14 +677,16 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
650
677
|
xml += result.xml;
|
|
651
678
|
if (!firstNoteId)
|
|
652
679
|
firstNoteId = result.elementId;
|
|
680
|
+
lastNoteId = result.elementId;
|
|
653
681
|
// Collect slur info
|
|
654
682
|
if (result.slurStart)
|
|
655
683
|
slurStarts.push(result.elementId);
|
|
656
684
|
if (result.slurEnd)
|
|
657
685
|
slurEnds.push(result.elementId);
|
|
658
686
|
// Collect other control events
|
|
659
|
-
if (result.
|
|
660
|
-
|
|
687
|
+
if (result.dynamics)
|
|
688
|
+
for (const label of result.dynamics)
|
|
689
|
+
dynamics.push({ startid: result.elementId, label });
|
|
661
690
|
if (result.fermata)
|
|
662
691
|
fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
|
|
663
692
|
if (result.trill)
|
|
@@ -668,6 +697,16 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
668
697
|
turns.push({ startid: result.elementId });
|
|
669
698
|
if (result.arpeggio)
|
|
670
699
|
arpeggios.push({ plist: result.elementId });
|
|
700
|
+
if (result.pedals)
|
|
701
|
+
for (const dir of result.pedals)
|
|
702
|
+
pedals.push({ startId: result.elementId, dir });
|
|
703
|
+
// Fingerings and text directions (markup) on inner notes are control
|
|
704
|
+
// events that attach by id — collect them so the layer loop can emit
|
|
705
|
+
// <fing>/<dir> for them (previously silently dropped inside tuplets).
|
|
706
|
+
for (const fing of result.fingerings)
|
|
707
|
+
fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
|
|
708
|
+
for (const mkup of result.markups)
|
|
709
|
+
markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
|
|
671
710
|
// Close beam if this note ends a beam group
|
|
672
711
|
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
673
712
|
xml += `${baseIndent}</beam>\n`;
|
|
@@ -676,7 +715,11 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
676
715
|
}
|
|
677
716
|
else if (e.type === 'rest') {
|
|
678
717
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
679
|
-
|
|
718
|
+
const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
719
|
+
xml += restResult.xml;
|
|
720
|
+
lastNoteId = restResult.elementId;
|
|
721
|
+
if (restResult.fermata)
|
|
722
|
+
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
680
723
|
}
|
|
681
724
|
else if (e.type === 'context') {
|
|
682
725
|
const ctx = e;
|
|
@@ -697,7 +740,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
697
740
|
xml += `${baseIndent}</beam>\n`;
|
|
698
741
|
}
|
|
699
742
|
xml += `${indent}</tuplet>\n`;
|
|
700
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
743
|
+
return { xml, firstNoteId, lastNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
701
744
|
};
|
|
702
745
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
703
746
|
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
@@ -803,16 +846,20 @@ const getEventBeamMarks = (event) => {
|
|
|
803
846
|
return { beamStart: false, beamEnd: false };
|
|
804
847
|
};
|
|
805
848
|
// Encode a layer (voice)
|
|
806
|
-
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef,
|
|
849
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
|
|
807
850
|
const layerId = generateId("layer");
|
|
808
851
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
809
852
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
810
853
|
const baseIndent = indent + ' ';
|
|
811
854
|
// Track current clef to only emit changes
|
|
812
855
|
let currentClef = initialClef;
|
|
813
|
-
// Track hairpin spans
|
|
856
|
+
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
857
|
+
// flattened layer stream can interleave overlapping/cross-staff hairpins
|
|
858
|
+
// (e.g. a crescendo starting before the previous one ended), and a single slot
|
|
859
|
+
// would silently overwrite the earlier one. Seed with any hairpin still open
|
|
860
|
+
// from the previous measure (cross-measure carry).
|
|
814
861
|
const hairpins = [];
|
|
815
|
-
|
|
862
|
+
const openHairpins = initialHairpin ? [initialHairpin] : [];
|
|
816
863
|
// Track pedal marks (each is independent, not paired spans)
|
|
817
864
|
const pedals = [];
|
|
818
865
|
// Track octave spans - initialize from previous measure if continuing
|
|
@@ -823,9 +870,52 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
823
870
|
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
824
871
|
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
825
872
|
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
826
|
-
//
|
|
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
|
+
};
|
|
910
|
+
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
911
|
+
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
912
|
+
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
913
|
+
// open overwrote it and the span was lost. Use a STACK and pair each end to the
|
|
914
|
+
// most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
|
|
915
|
+
// as a view of the stack top for the existing cross-measure carry plumbing.
|
|
827
916
|
const slurs = [];
|
|
828
|
-
|
|
917
|
+
const openSlurs = initialSlurs.map(startId => ({ startId }));
|
|
918
|
+
let currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
829
919
|
// Track arpeggio refs
|
|
830
920
|
const arpeggios = [];
|
|
831
921
|
// Track ornament refs
|
|
@@ -908,9 +998,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
908
998
|
// Check if this note should have tie="t" (matches pending tie)
|
|
909
999
|
const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
|
|
910
1000
|
// If there's a pending ottava, apply it BEFORE encoding the note
|
|
911
|
-
|
|
912
|
-
currentOttavaShift = pendingOttava; // Apply the shift for this note
|
|
913
|
-
}
|
|
1001
|
+
applyPendingOttavaShift();
|
|
914
1002
|
// For cross-staff notation: set note's staff to currentStaff if different from voice.staff
|
|
915
1003
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
916
1004
|
? { ...noteEvent, staff: currentStaff }
|
|
@@ -918,41 +1006,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
918
1006
|
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
919
1007
|
xml += result.xml;
|
|
920
1008
|
lastNoteId = result.elementId;
|
|
921
|
-
|
|
922
|
-
octaveEndReplacements[currentOctave.endToken] = result.elementId;
|
|
923
|
-
}
|
|
1009
|
+
extendOttavaEnd(result.elementId);
|
|
924
1010
|
// Flush any pending markups onto this note
|
|
925
1011
|
flushPendingMarkups(result.elementId);
|
|
926
1012
|
// Flush any pending leading dynamics onto this note
|
|
927
1013
|
flushPendingDynamics(result.elementId);
|
|
928
1014
|
// If there's a pending ottava, start the span on this note
|
|
929
|
-
|
|
930
|
-
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
931
|
-
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
932
|
-
// Close existing span first if it has a different value
|
|
933
|
-
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
934
|
-
if (lastNoteId) {
|
|
935
|
-
octaves.push({
|
|
936
|
-
dis: currentOctave.dis,
|
|
937
|
-
disPlace: currentOctave.disPlace,
|
|
938
|
-
startId: currentOctave.startId,
|
|
939
|
-
endId: lastNoteId,
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
currentOctave = null;
|
|
943
|
-
}
|
|
944
|
-
// Start new span if we don't already have one with the same value
|
|
945
|
-
if (!currentOctave) {
|
|
946
|
-
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
947
|
-
}
|
|
948
|
-
pendingOttava = null;
|
|
949
|
-
}
|
|
950
|
-
else if (currentOctave?.continued) {
|
|
951
|
-
if (!currentOctave.endToken) {
|
|
952
|
-
currentOctave.startId = result.elementId;
|
|
953
|
-
}
|
|
954
|
-
currentOctave.continued = false;
|
|
955
|
-
}
|
|
1015
|
+
applyOttavaAnchor(result.elementId);
|
|
956
1016
|
// Update pending tie pitches
|
|
957
1017
|
if (result.hasTieStart) {
|
|
958
1018
|
pendingTiePitches = result.pitches;
|
|
@@ -962,38 +1022,52 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
962
1022
|
}
|
|
963
1023
|
// Track hairpin spans
|
|
964
1024
|
if (result.hairpin === 'crescStart') {
|
|
965
|
-
|
|
1025
|
+
openHairpins.push({ form: 'cres', startId: result.elementId });
|
|
966
1026
|
}
|
|
967
1027
|
else if (result.hairpin === 'dimStart') {
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
else if (result.hairpin === '
|
|
1028
|
+
openHairpins.push({ form: 'dim', startId: result.elementId });
|
|
1029
|
+
}
|
|
1030
|
+
else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
|
|
1031
|
+
const endForm = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
|
|
1032
|
+
// Close the most-recent open hairpin of the matching form; if none
|
|
1033
|
+
// matches (interleaved/malformed input), fall back to the newest open.
|
|
1034
|
+
let idx = -1;
|
|
1035
|
+
for (let i = openHairpins.length - 1; i >= 0; i--) {
|
|
1036
|
+
if (openHairpins[i].form === endForm) {
|
|
1037
|
+
idx = i;
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (idx < 0)
|
|
1042
|
+
idx = openHairpins.length - 1;
|
|
1043
|
+
const open = openHairpins.splice(idx, 1)[0];
|
|
971
1044
|
hairpins.push({
|
|
972
|
-
form:
|
|
973
|
-
startId:
|
|
1045
|
+
form: open.form,
|
|
1046
|
+
startId: open.startId,
|
|
974
1047
|
endId: result.elementId,
|
|
975
1048
|
});
|
|
976
|
-
currentHairpin = null;
|
|
977
1049
|
}
|
|
978
|
-
// Track pedal marks (each is independent
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
1050
|
+
// Track pedal marks (each is independent; a note may carry several,
|
|
1051
|
+
// e.g. an up+down pedal bounce at the same beat).
|
|
1052
|
+
if (result.pedals) {
|
|
1053
|
+
for (const dir of result.pedals) {
|
|
1054
|
+
pedals.push({ startId: result.elementId, dir });
|
|
1055
|
+
}
|
|
984
1056
|
}
|
|
985
1057
|
// Track slur spans - end must be processed before start
|
|
986
|
-
// in case a note ends one slur and starts another
|
|
987
|
-
|
|
1058
|
+
// in case a note ends one slur and starts another.
|
|
1059
|
+
// Pair an end to the most-recent open slur (LIFO).
|
|
1060
|
+
if (result.slurEnd && openSlurs.length > 0) {
|
|
1061
|
+
const open = openSlurs.pop();
|
|
988
1062
|
slurs.push({
|
|
989
|
-
startId:
|
|
1063
|
+
startId: open.startId,
|
|
990
1064
|
endId: result.elementId,
|
|
991
1065
|
});
|
|
992
|
-
currentSlur = null;
|
|
993
1066
|
}
|
|
994
1067
|
if (result.slurStart) {
|
|
995
|
-
|
|
1068
|
+
openSlurs.push({ startId: result.elementId });
|
|
996
1069
|
}
|
|
1070
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
997
1071
|
// Track arpeggio refs
|
|
998
1072
|
if (result.arpeggio) {
|
|
999
1073
|
arpeggios.push({ plist: result.elementId });
|
|
@@ -1017,8 +1091,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1017
1091
|
if (result.turn) {
|
|
1018
1092
|
turns.push({ startid: result.elementId });
|
|
1019
1093
|
}
|
|
1020
|
-
if (result.
|
|
1021
|
-
|
|
1094
|
+
if (result.dynamics) {
|
|
1095
|
+
for (const label of result.dynamics)
|
|
1096
|
+
dynamics.push({ startid: result.elementId, label });
|
|
1022
1097
|
}
|
|
1023
1098
|
// Track fingerings
|
|
1024
1099
|
for (const fing of result.fingerings) {
|
|
@@ -1037,20 +1112,39 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1037
1112
|
case 'rest': {
|
|
1038
1113
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1039
1114
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1115
|
+
// A pending ottava shift applies to a rest's gestural octave too.
|
|
1116
|
+
applyPendingOttavaShift();
|
|
1040
1117
|
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1041
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);
|
|
1124
|
+
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1125
|
+
if (restResult.fermata)
|
|
1126
|
+
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1042
1127
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1043
1128
|
flushPendingMarkups(restResult.elementId);
|
|
1044
1129
|
flushPendingDynamics(restResult.elementId);
|
|
1045
|
-
lastNoteId = restResult.elementId;
|
|
1046
1130
|
break;
|
|
1047
1131
|
}
|
|
1048
1132
|
case 'tuplet':
|
|
1049
1133
|
case 'times': {
|
|
1050
1134
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1051
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();
|
|
1052
1138
|
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1053
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);
|
|
1054
1148
|
// Propagate clef change from inside the tuplet to the parent tracker
|
|
1055
1149
|
if (tupletResult.endingClef) {
|
|
1056
1150
|
currentClef = tupletResult.endingClef;
|
|
@@ -1059,22 +1153,23 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1059
1153
|
if (tupletResult.firstNoteId) {
|
|
1060
1154
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1061
1155
|
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1062
|
-
lastNoteId = tupletResult.firstNoteId;
|
|
1156
|
+
lastNoteId = tupletResult.lastNoteId ?? tupletResult.firstNoteId;
|
|
1063
1157
|
}
|
|
1064
|
-
// Process slur ends first (to close
|
|
1158
|
+
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
1065
1159
|
for (const endId of tupletResult.slurEnds) {
|
|
1066
|
-
if (
|
|
1160
|
+
if (openSlurs.length > 0) {
|
|
1161
|
+
const open = openSlurs.pop();
|
|
1067
1162
|
slurs.push({
|
|
1068
|
-
startId:
|
|
1163
|
+
startId: open.startId,
|
|
1069
1164
|
endId: endId,
|
|
1070
1165
|
});
|
|
1071
|
-
currentSlur = null;
|
|
1072
1166
|
}
|
|
1073
1167
|
}
|
|
1074
1168
|
// Then process slur starts (to open new slurs)
|
|
1075
1169
|
for (const startId of tupletResult.slurStarts) {
|
|
1076
|
-
|
|
1170
|
+
openSlurs.push({ startId });
|
|
1077
1171
|
}
|
|
1172
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1078
1173
|
// Collect other control events from tuplet
|
|
1079
1174
|
dynamics.push(...tupletResult.dynamics);
|
|
1080
1175
|
fermatas.push(...tupletResult.fermatas);
|
|
@@ -1082,6 +1177,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1082
1177
|
mordents.push(...tupletResult.mordents);
|
|
1083
1178
|
turns.push(...tupletResult.turns);
|
|
1084
1179
|
arpeggios.push(...tupletResult.arpeggios);
|
|
1180
|
+
pedals.push(...tupletResult.pedals);
|
|
1181
|
+
fingerings.push(...tupletResult.fingerings);
|
|
1182
|
+
markups.push(...tupletResult.markups);
|
|
1085
1183
|
break;
|
|
1086
1184
|
}
|
|
1087
1185
|
case 'tremolo':
|
|
@@ -1114,8 +1212,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1114
1212
|
currentOctave = null;
|
|
1115
1213
|
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1116
1214
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
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.
|
|
1119
1231
|
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1120
1232
|
}
|
|
1121
1233
|
else {
|
|
@@ -1222,8 +1334,23 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1222
1334
|
const pendingOctave = currentOctave
|
|
1223
1335
|
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
|
|
1224
1336
|
: null;
|
|
1337
|
+
// Resolve hairpins still open at layer end. The cross-measure carry supports a
|
|
1338
|
+
// single pending hairpin, so keep the OLDEST open span (bottom of stack — most
|
|
1339
|
+
// likely to legitimately continue into the next measure) as pendingHairpin, and
|
|
1340
|
+
// flush the rest here, ending them at the last note so they aren't dropped
|
|
1341
|
+
// (MusicXML files routinely leave wedges unclosed). Without a last note id there
|
|
1342
|
+
// is nothing to attach to, so those are unavoidably dropped.
|
|
1343
|
+
let pendingHairpin = null;
|
|
1344
|
+
if (openHairpins.length > 0) {
|
|
1345
|
+
pendingHairpin = openHairpins[0];
|
|
1346
|
+
for (let i = 1; i < openHairpins.length; i++) {
|
|
1347
|
+
if (lastNoteId) {
|
|
1348
|
+
hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1225
1352
|
xml += `${indent}</layer>\n`;
|
|
1226
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur:
|
|
1353
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1227
1354
|
};
|
|
1228
1355
|
// Encode a staff
|
|
1229
1356
|
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
@@ -1260,7 +1387,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1260
1387
|
const layerN = vi + 1;
|
|
1261
1388
|
const tieKey = `${staffN}-${layerN}`;
|
|
1262
1389
|
const initialTies = tieState[tieKey] || [];
|
|
1263
|
-
const initialSlur = slurState[tieKey] ||
|
|
1390
|
+
const initialSlur = slurState[tieKey] || [];
|
|
1264
1391
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1265
1392
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1266
1393
|
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
@@ -1286,9 +1413,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1286
1413
|
pendingTies[tieKey] = result.pendingTiePitches;
|
|
1287
1414
|
}
|
|
1288
1415
|
// Track pending slurs for this layer
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1416
|
+
// Always record (even empty) so a measure that closed all its slurs
|
|
1417
|
+
// clears the carried stack rather than leaving a stale open slur.
|
|
1418
|
+
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1292
1419
|
// Track pending hairpins for this layer
|
|
1293
1420
|
if (result.pendingHairpin) {
|
|
1294
1421
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
@@ -1439,8 +1566,10 @@ const BARLINE_TO_MEI = {
|
|
|
1439
1566
|
};
|
|
1440
1567
|
// Encode a measure
|
|
1441
1568
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1442
|
-
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) => {
|
|
1443
1570
|
const measureId = generateId("measure");
|
|
1571
|
+
if (measureIds)
|
|
1572
|
+
measureIds.push(measureId);
|
|
1444
1573
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1445
1574
|
const allHairpins = [];
|
|
1446
1575
|
const allPedals = [];
|
|
@@ -2160,6 +2289,36 @@ const encode = (doc, options = {}) => {
|
|
|
2160
2289
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
2161
2290
|
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
|
|
2162
2291
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
2292
|
+
// Splice point for an <expansion> element (must be the FIRST child of
|
|
2293
|
+
// <section>, but its plist needs the measure ids collected during the loop
|
|
2294
|
+
// below — so remember where to insert it and build it afterwards).
|
|
2295
|
+
const expansionInsertPos = mei.length;
|
|
2296
|
+
const measureIds = [];
|
|
2297
|
+
// Volta (1st/2nd-ending) house brackets. The measure-layout directive already
|
|
2298
|
+
// encodes which measures form each alternate; <expansion> replays them but does
|
|
2299
|
+
// NOT draw the 1./2. brackets. So derive each alternate's contiguous measure
|
|
2300
|
+
// span and wrap that run of <measure>s in an <ending n="N"> container (verovio's
|
|
2301
|
+
// own MusicXML import does the same). Keyed by the 1-based measure index where
|
|
2302
|
+
// an ending opens / closes.
|
|
2303
|
+
const endingOpenAt = new Map(); // measure index → ending number
|
|
2304
|
+
const endingCloseAt = new Set(); // measure index after which </ending> closes
|
|
2305
|
+
if (doc.metadata?.measureLayout) {
|
|
2306
|
+
try {
|
|
2307
|
+
const spans = collectVoltaSpans(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2308
|
+
for (const s of spans) {
|
|
2309
|
+
// Only wrap a contiguous ascending run (a house is always adjacent bars).
|
|
2310
|
+
const lo = s.measures[0];
|
|
2311
|
+
const hi = s.measures[s.measures.length - 1];
|
|
2312
|
+
const contiguous = s.measures.every((m, k) => m === lo + k);
|
|
2313
|
+
if (contiguous) {
|
|
2314
|
+
endingOpenAt.set(lo, s.number);
|
|
2315
|
+
endingCloseAt.add(hi);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
catch { /* malformed layout → no house brackets, expansion path handles the rest */ }
|
|
2320
|
+
}
|
|
2321
|
+
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2163
2322
|
// Track tie state across measures for cross-measure ties
|
|
2164
2323
|
const tieState = {};
|
|
2165
2324
|
// Track slur state across measures for cross-measure slurs
|
|
@@ -2266,8 +2425,55 @@ const encode = (doc, options = {}) => {
|
|
|
2266
2425
|
mei += `${sd}</scoreDef>\n`;
|
|
2267
2426
|
}
|
|
2268
2427
|
}
|
|
2269
|
-
|
|
2428
|
+
const mIndex = mi + 1;
|
|
2429
|
+
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2430
|
+
// Open an <ending> house bracket if this measure starts an alternate.
|
|
2431
|
+
const openNum = endingOpenAt.get(mIndex);
|
|
2432
|
+
if (openNum !== undefined && !endingOpen) {
|
|
2433
|
+
const lendsym = openNum === 1 ? ' lendsym="angledown"' : '';
|
|
2434
|
+
mei += `${mIndent}<ending xml:id="${generateId('ending')}" n="${openNum}"${lendsym}>\n`;
|
|
2435
|
+
endingOpen = true;
|
|
2436
|
+
}
|
|
2437
|
+
const measureIndent = endingOpen ? `${mIndent}${indent}` : mIndent;
|
|
2438
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2439
|
+
// Close the <ending> after the measure that ends this alternate.
|
|
2440
|
+
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2441
|
+
mei += `${mIndent}</ending>\n`;
|
|
2442
|
+
endingOpen = false;
|
|
2443
|
+
}
|
|
2270
2444
|
});
|
|
2445
|
+
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2446
|
+
if (endingOpen) {
|
|
2447
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`;
|
|
2448
|
+
endingOpen = false;
|
|
2449
|
+
}
|
|
2450
|
+
// Emit an <expansion> describing playback order from the measure-layout
|
|
2451
|
+
// directive ([measures "..."]). Verovio's expand="…" consumes its plist to
|
|
2452
|
+
// unfold repeats for MIDI/time-map output. Inserted as the first child of
|
|
2453
|
+
// <section>; ids were collected in the measure loop above.
|
|
2454
|
+
if (doc.metadata?.measureLayout) {
|
|
2455
|
+
try {
|
|
2456
|
+
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2457
|
+
const order = expandMeasureLayout(layout);
|
|
2458
|
+
const refs = [];
|
|
2459
|
+
for (const idx of order) {
|
|
2460
|
+
const id = measureIds[idx - 1]; // 1-based index into notated measures
|
|
2461
|
+
if (id === undefined) {
|
|
2462
|
+
console.warn(`[measures] index ${idx} out of range (only ${measureIds.length} measures); skipping`);
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
refs.push(`#${id}`);
|
|
2466
|
+
}
|
|
2467
|
+
if (refs.length > 0) {
|
|
2468
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2469
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2470
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
catch (err) {
|
|
2474
|
+
console.warn(`[measures] failed to parse "${doc.metadata.measureLayout}": ${err.message}`);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2271
2477
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
2272
2478
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
2273
2479
|
mei += `${indent}${indent}${indent}</mdiv>\n`;
|