@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
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* Improves upon musicxml2ly by properly tracking spanners (slurs, ties, wedges) by number attribute.
|
|
6
6
|
*/
|
|
7
7
|
import { DOMParser } from '@xmldom/xmldom';
|
|
8
|
-
import { HairpinType, NavigationMarkType, } from "./types.js";
|
|
8
|
+
import { HairpinType, NavigationMarkType, Placement, } from "./types.js";
|
|
9
9
|
import { getElementText, getElementInt, getElements, getDirectChildren, getChildElements, getAttribute, getAttributeNumber, hasElement, convertPitch, convertDuration, convertKeySignature, convertClef, convertStemDirection, convertArticulation, convertOrnament, convertDynamic, convertPedal, convertBarlineStyle, convertHarmonyToText, createFraction, TYPE_TO_DIVISION, } from "./musicXmlUtils.js";
|
|
10
|
+
import { measureLayoutFromPart } from "./measureLayoutFromXml.js";
|
|
10
11
|
// ============ Spanner Tracker ============
|
|
11
12
|
/**
|
|
12
13
|
* Track spanners (slurs, ties, wedges) by number attribute.
|
|
@@ -66,33 +67,47 @@ class SpannerTracker {
|
|
|
66
67
|
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
67
68
|
*/
|
|
68
69
|
class TupletTracker {
|
|
69
|
-
// Map from tuplet number to collected events and ratio
|
|
70
|
+
// Map from tuplet number to collected events and ratio. Each tuplet is bound to
|
|
71
|
+
// the voice (and staff) it started in: a tuplet belongs to one voice, so events
|
|
72
|
+
// from OTHER voices must not be swallowed into it (multi-voice piano scores
|
|
73
|
+
// interleave voices, and a voice-1 tuplet would otherwise eat voice-2 notes).
|
|
70
74
|
activeTuplets = new Map();
|
|
71
75
|
/**
|
|
72
|
-
* Start a new tuplet group
|
|
76
|
+
* Start a new tuplet group, bound to the voice/staff it starts in.
|
|
73
77
|
*/
|
|
74
|
-
startTuplet(number = 1) {
|
|
75
|
-
this.activeTuplets.set(number, { events: [] });
|
|
78
|
+
startTuplet(number = 1, voice = 1, staff = 1) {
|
|
79
|
+
this.activeTuplets.set(number, { events: [], voice, staff });
|
|
76
80
|
}
|
|
77
81
|
/**
|
|
78
|
-
* Add an event to active tuplet
|
|
79
|
-
* Returns true if the event was added
|
|
82
|
+
* Add an event to the innermost active tuplet of the SAME voice.
|
|
83
|
+
* Returns true if the event was added.
|
|
84
|
+
*
|
|
85
|
+
* Nested tuplets share the doc model's flat TupletEvent (which can't hold a
|
|
86
|
+
* nested TupletEvent), so an event must go to exactly ONE tuplet or it would be
|
|
87
|
+
* emitted twice — once per enclosing tuplet — inflating the pitch count. We pick
|
|
88
|
+
* the most-recently-started same-voice tuplet (the innermost): when the inner one
|
|
89
|
+
* closes, later events fall back to the still-open outer one.
|
|
80
90
|
*/
|
|
81
|
-
addEvent(event) {
|
|
91
|
+
addEvent(event, voice) {
|
|
82
92
|
if (this.activeTuplets.size === 0)
|
|
83
93
|
return false;
|
|
84
|
-
//
|
|
94
|
+
// Innermost = last-inserted entry for this voice (Map preserves insertion order).
|
|
95
|
+
let target;
|
|
85
96
|
for (const [, tuplet] of this.activeTuplets) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
if (tuplet.voice === voice)
|
|
98
|
+
target = tuplet;
|
|
99
|
+
}
|
|
100
|
+
if (!target)
|
|
101
|
+
return false;
|
|
102
|
+
// Set ratio from first event's duration.tuplet
|
|
103
|
+
// convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
|
|
104
|
+
if (!target.ratio && event.duration.tuplet) {
|
|
105
|
+
target.ratio = { ...event.duration.tuplet };
|
|
95
106
|
}
|
|
107
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
108
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
109
|
+
delete cleanEvent.duration.tuplet;
|
|
110
|
+
target.events.push(cleanEvent);
|
|
96
111
|
return true;
|
|
97
112
|
}
|
|
98
113
|
/**
|
|
@@ -114,10 +129,40 @@ class TupletTracker {
|
|
|
114
129
|
};
|
|
115
130
|
}
|
|
116
131
|
/**
|
|
117
|
-
* Check if
|
|
132
|
+
* Check if a tuplet is active for the given voice. A tuplet only swallows notes
|
|
133
|
+
* of its OWN voice, so the per-note "are we in a tuplet?" check must be scoped to
|
|
134
|
+
* the note's voice — otherwise a voice-1 tuplet would divert voice-2 notes.
|
|
135
|
+
*/
|
|
136
|
+
isActive(voice) {
|
|
137
|
+
if (voice === undefined)
|
|
138
|
+
return this.activeTuplets.size > 0;
|
|
139
|
+
for (const [, t] of this.activeTuplets)
|
|
140
|
+
if (t.voice === voice)
|
|
141
|
+
return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Force-close every still-open tuplet and return them with their owning
|
|
146
|
+
* voice/staff. Called at measure end: a tuplet is a within-measure time
|
|
147
|
+
* modification and MUST NOT leak across the bar line. A source file missing a
|
|
148
|
+
* <tuplet type="stop"> (corpus reality — e.g. 库劳 Op.20) would otherwise leave
|
|
149
|
+
* the tuplet open forever, swallowing every following note in that voice for the
|
|
150
|
+
* rest of the piece. Flushing here bounds the damage to the one measure.
|
|
118
151
|
*/
|
|
119
|
-
|
|
120
|
-
|
|
152
|
+
flushAll() {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
155
|
+
if (tuplet.events.length === 0)
|
|
156
|
+
continue;
|
|
157
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
158
|
+
out.push({
|
|
159
|
+
event: { type: 'tuplet', ratio, events: tuplet.events },
|
|
160
|
+
voice: tuplet.voice,
|
|
161
|
+
staff: tuplet.staff,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
this.activeTuplets.clear();
|
|
165
|
+
return out;
|
|
121
166
|
}
|
|
122
167
|
/**
|
|
123
168
|
* Reset tracker
|
|
@@ -290,6 +335,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
290
335
|
const isChord = hasElement(noteEl, 'chord');
|
|
291
336
|
const isRest = hasElement(noteEl, 'rest');
|
|
292
337
|
const isGrace = hasElement(noteEl, 'grace');
|
|
338
|
+
const restEl = isRest ? noteEl.getElementsByTagName('rest')[0] : undefined;
|
|
293
339
|
let pitch;
|
|
294
340
|
const pitchEl = noteEl.getElementsByTagName('pitch')[0];
|
|
295
341
|
if (pitchEl) {
|
|
@@ -299,6 +345,15 @@ const parseNote = (noteEl, divisions) => {
|
|
|
299
345
|
const durationVal = getElementInt(noteEl, 'duration') || 0;
|
|
300
346
|
const typeText = getElementText(noteEl, 'type');
|
|
301
347
|
const dotCount = getElements(noteEl, 'dot').length;
|
|
348
|
+
// Whole-measure rest detection. Two forms in the wild:
|
|
349
|
+
// (a) <rest measure="yes"> — explicit.
|
|
350
|
+
// (b) a `type="whole"` rest whose <duration> is NOT a whole note (e.g. 72 ticks
|
|
351
|
+
// in 3/4 at divisions=24) — the conventional "centred whole rest = whole
|
|
352
|
+
// bar" notation. In both cases the rest fills the measure, so flag it and
|
|
353
|
+
// let encoders emit <mRest>/R instead of rounding the bare duration to a
|
|
354
|
+
// power-of-two division (which over/under-fills non-2^n meters).
|
|
355
|
+
const isMeasureRest = !!restEl && (getAttribute(restEl, 'measure') === 'yes' ||
|
|
356
|
+
(typeText === 'whole' && durationVal > 0 && durationVal !== divisions * 4));
|
|
302
357
|
// Time modification (tuplets)
|
|
303
358
|
let timeModification;
|
|
304
359
|
const timeModEl = noteEl.getElementsByTagName('time-modification')[0];
|
|
@@ -322,14 +377,16 @@ const parseNote = (noteEl, divisions) => {
|
|
|
322
377
|
if (notationsEl) {
|
|
323
378
|
notations = parseNotations(notationsEl);
|
|
324
379
|
}
|
|
325
|
-
// Fingering
|
|
326
|
-
let
|
|
380
|
+
// Fingering — a note may carry several <fingering> (one per chord member).
|
|
381
|
+
let fingerings;
|
|
327
382
|
const technicalEl = noteEl.getElementsByTagName('technical')[0];
|
|
328
383
|
if (technicalEl) {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
384
|
+
const fingeringEls = getElements(technicalEl, 'fingering');
|
|
385
|
+
const parsed = fingeringEls
|
|
386
|
+
.map(el => parseInt(el.textContent?.trim() || '', 10))
|
|
387
|
+
.filter(n => Number.isFinite(n));
|
|
388
|
+
if (parsed.length > 0)
|
|
389
|
+
fingerings = parsed;
|
|
333
390
|
}
|
|
334
391
|
// Beams - direct children of note, not in notations
|
|
335
392
|
// We only care about primary beam (number="1") for begin/end
|
|
@@ -345,6 +402,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
345
402
|
isChord,
|
|
346
403
|
isRest,
|
|
347
404
|
isGrace,
|
|
405
|
+
isMeasureRest,
|
|
348
406
|
pitch,
|
|
349
407
|
duration: {
|
|
350
408
|
divisions: durationVal,
|
|
@@ -356,7 +414,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
356
414
|
staff,
|
|
357
415
|
stem: stem,
|
|
358
416
|
notations,
|
|
359
|
-
|
|
417
|
+
fingerings,
|
|
360
418
|
beams,
|
|
361
419
|
};
|
|
362
420
|
};
|
|
@@ -419,6 +477,31 @@ const parseDirection = (dirEl) => {
|
|
|
419
477
|
const result = {};
|
|
420
478
|
result.placement = getAttribute(dirEl, 'placement');
|
|
421
479
|
result.staff = getElementInt(dirEl, 'staff');
|
|
480
|
+
// Navigation jumps live on the <sound> child of <direction> (a sibling of
|
|
481
|
+
// <direction-type>). Parse it first, before the no-direction-type early return,
|
|
482
|
+
// so a sound-only direction still contributes its jump semantics.
|
|
483
|
+
const soundEl = dirEl.getElementsByTagName('sound')[0];
|
|
484
|
+
if (soundEl) {
|
|
485
|
+
const sound = {};
|
|
486
|
+
if (getAttribute(soundEl, 'dacapo') === 'yes')
|
|
487
|
+
sound.dacapo = true;
|
|
488
|
+
if (getAttribute(soundEl, 'fine') === 'yes')
|
|
489
|
+
sound.fine = true;
|
|
490
|
+
const dalsegno = getAttribute(soundEl, 'dalsegno');
|
|
491
|
+
if (dalsegno)
|
|
492
|
+
sound.dalsegno = dalsegno;
|
|
493
|
+
const segno = getAttribute(soundEl, 'segno');
|
|
494
|
+
if (segno)
|
|
495
|
+
sound.segno = segno;
|
|
496
|
+
const coda = getAttribute(soundEl, 'coda');
|
|
497
|
+
if (coda)
|
|
498
|
+
sound.coda = coda;
|
|
499
|
+
const tocoda = getAttribute(soundEl, 'tocoda');
|
|
500
|
+
if (tocoda)
|
|
501
|
+
sound.tocoda = tocoda;
|
|
502
|
+
if (Object.keys(sound).length > 0)
|
|
503
|
+
result.sound = sound;
|
|
504
|
+
}
|
|
422
505
|
const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
|
|
423
506
|
if (!dirTypeEl) {
|
|
424
507
|
return result;
|
|
@@ -500,7 +583,8 @@ const parseBarline = (barlineEl) => {
|
|
|
500
583
|
if (repeatEl) {
|
|
501
584
|
const direction = getAttribute(repeatEl, 'direction');
|
|
502
585
|
if (direction) {
|
|
503
|
-
|
|
586
|
+
const times = getAttributeNumber(repeatEl, 'times');
|
|
587
|
+
result.repeat = times !== undefined ? { direction, times } : { direction };
|
|
504
588
|
}
|
|
505
589
|
}
|
|
506
590
|
const endingEl = barlineEl.getElementsByTagName('ending')[0];
|
|
@@ -613,13 +697,14 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
|
|
|
613
697
|
if (notations.slurs) {
|
|
614
698
|
for (const slur of notations.slurs) {
|
|
615
699
|
if (slur.type === 'start') {
|
|
616
|
-
marks.push({ markType: 'slur', start: true });
|
|
700
|
+
marks.push({ markType: 'slur', start: true, number: slur.number });
|
|
617
701
|
spannerTracker.startSlur(slur.number);
|
|
618
702
|
}
|
|
619
703
|
else if (slur.type === 'stop') {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
704
|
+
// Carry the MusicXML number so the encoder can pair cross-voice slurs
|
|
705
|
+
// (start in one voice, stop in another — common in piano scores).
|
|
706
|
+
spannerTracker.stopSlur(slur.number);
|
|
707
|
+
marks.push({ markType: 'slur', start: false, number: slur.number });
|
|
623
708
|
}
|
|
624
709
|
}
|
|
625
710
|
}
|
|
@@ -802,8 +887,90 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
802
887
|
if (direction.segno) {
|
|
803
888
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
804
889
|
}
|
|
890
|
+
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
891
|
+
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
892
|
+
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
893
|
+
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
894
|
+
// directions are tempo too, never markup. Navigation text ("D.C. al Fine",
|
|
895
|
+
// "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
|
|
896
|
+
// markup glyph — the jump SEMANTICS are captured separately as measure-layout.
|
|
897
|
+
let emittedWords = false;
|
|
898
|
+
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
899
|
+
const text = direction.words.map(w => w.text).join('').trim();
|
|
900
|
+
if (text && !isTempoWord(text)) {
|
|
901
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
902
|
+
: direction.placement === 'below' ? Placement.below
|
|
903
|
+
: undefined;
|
|
904
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
905
|
+
emittedWords = true;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
|
|
909
|
+
// synthesize the conventional glyph text so the score still shows the marking.
|
|
910
|
+
// Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
|
|
911
|
+
// emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
|
|
912
|
+
// synthesizing, and only when the engraver left them implicit.
|
|
913
|
+
if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
|
|
914
|
+
const s = direction.sound;
|
|
915
|
+
const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
|
|
916
|
+
: s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
|
|
917
|
+
: s.tocoda ? 'To Coda'
|
|
918
|
+
: s.fine ? 'Fine'
|
|
919
|
+
: undefined;
|
|
920
|
+
if (label) {
|
|
921
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
922
|
+
: direction.placement === 'below' ? Placement.below
|
|
923
|
+
: undefined;
|
|
924
|
+
marks.push({ markType: 'markup', content: label, placement });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
805
927
|
return marks;
|
|
806
928
|
};
|
|
929
|
+
/**
|
|
930
|
+
* Decompose a tick gap (from <forward>) into invisible spacer rests.
|
|
931
|
+
*
|
|
932
|
+
* lilylet's doc model is a flat per-voice event sequence with no absolute tick
|
|
933
|
+
* anchor (currentPosition is unused for placement), so a <forward> that skips
|
|
934
|
+
* time inside a voice must be materialised as filler or the following notes slide
|
|
935
|
+
* earlier and the bar decodes short. Invisible rests (`s` / MEI <space>) are the
|
|
936
|
+
* right carrier. The gap may not be a single note value (e.g. 1.5 quarters), so
|
|
937
|
+
* emit a greedy sequence of power-of-two (optionally dotted) spacers.
|
|
938
|
+
*/
|
|
939
|
+
const forwardGapToRests = (gapTicks, divisions) => {
|
|
940
|
+
const rests = [];
|
|
941
|
+
let remaining = gapTicks;
|
|
942
|
+
const quarterTicks = divisions; // ticks per quarter note
|
|
943
|
+
// Largest representable spacer first; division 1=whole..128. dotted adds half.
|
|
944
|
+
const candidates = [];
|
|
945
|
+
for (const division of [1, 2, 4, 8, 16, 32, 64, 128]) {
|
|
946
|
+
const baseQ = 4 / division; // quarter notes for this value
|
|
947
|
+
candidates.push({ division, dots: 0, q: baseQ });
|
|
948
|
+
candidates.push({ division, dots: 1, q: baseQ * 1.5 });
|
|
949
|
+
}
|
|
950
|
+
candidates.sort((a, b) => b.q - a.q);
|
|
951
|
+
let guard = 0;
|
|
952
|
+
while (remaining > 0.0001 && guard++ < 64) {
|
|
953
|
+
const c = candidates.find(c => c.q * quarterTicks <= remaining + 0.0001);
|
|
954
|
+
if (!c)
|
|
955
|
+
break;
|
|
956
|
+
rests.push({ type: 'rest', duration: { division: c.division, dots: c.dots }, invisible: true });
|
|
957
|
+
remaining -= c.q * quarterTicks;
|
|
958
|
+
}
|
|
959
|
+
return rests;
|
|
960
|
+
};
|
|
961
|
+
/**
|
|
962
|
+
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
963
|
+
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
964
|
+
*/
|
|
965
|
+
const tupletAdvanceDuration = (tupletEvent, divisions) => {
|
|
966
|
+
let total = 0;
|
|
967
|
+
for (const evt of tupletEvent.events) {
|
|
968
|
+
const d = evt.duration;
|
|
969
|
+
if (d)
|
|
970
|
+
total += (4 / d.division) * divisions;
|
|
971
|
+
}
|
|
972
|
+
return total * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
973
|
+
};
|
|
807
974
|
/**
|
|
808
975
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
809
976
|
*/
|
|
@@ -811,13 +978,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
811
978
|
let key;
|
|
812
979
|
let timeSig;
|
|
813
980
|
let barline;
|
|
981
|
+
let leadingBarline;
|
|
814
982
|
const harmonies = [];
|
|
815
983
|
const clefs = new Map();
|
|
816
984
|
// Pending marks from directions (to attach to next note), per voice
|
|
817
985
|
const pendingMarks = new Map();
|
|
818
|
-
//
|
|
986
|
+
// Accumulated <forward> ticks waiting for the next note, whose <voice> tells us
|
|
987
|
+
// which voice the gap belongs to. Flushed as invisible rests before that note,
|
|
988
|
+
// or onto currentVoice at a <backup>/measure end (a trailing gap in this voice).
|
|
989
|
+
let pendingForward = 0;
|
|
990
|
+
// Pending context changes (tempo, ottava) to insert before next note. Each may
|
|
991
|
+
// carry a target staff: an ottava (<octave-shift>) start and its stop both name
|
|
992
|
+
// the same <staff>, but in piano scores the stop direction often follows a
|
|
993
|
+
// <backup> so the *next note* is on the other staff. Routing the change to a
|
|
994
|
+
// note on its own staff keeps the 8va span's start and end in the same MEI
|
|
995
|
+
// layer (otherwise the encoder can't pair them and drops the span).
|
|
819
996
|
const pendingContextChanges = [];
|
|
820
997
|
let currentVoice = 1; // Track current voice for directions
|
|
998
|
+
// Mid-measure clef support: a staff can change clef partway through a measure
|
|
999
|
+
// (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
|
|
1000
|
+
// in an <attributes> block AFTER notes on that staff. We must attach it inline at
|
|
1001
|
+
// that point on the staff's currently-active voice, not collapse it into the
|
|
1002
|
+
// measure-start `clefs` map (which keeps only one clef per staff and emits it at
|
|
1003
|
+
// the bar start). Track the last voice that added a note on each staff, and which
|
|
1004
|
+
// staves already have notes this measure.
|
|
1005
|
+
const lastVoiceOnStaff = new Map();
|
|
1006
|
+
const staffHasNotes = new Set();
|
|
821
1007
|
// Process all children in order
|
|
822
1008
|
for (const child of getChildElements(measureEl)) {
|
|
823
1009
|
const tagName = child.tagName;
|
|
@@ -842,7 +1028,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
842
1028
|
for (const clefEntry of attrs.clefs) {
|
|
843
1029
|
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
844
1030
|
if (clef) {
|
|
845
|
-
|
|
1031
|
+
const staff = clefEntry.staff;
|
|
1032
|
+
if (staffHasNotes.has(staff)) {
|
|
1033
|
+
// Mid-measure clef change: this staff already has notes in this
|
|
1034
|
+
// measure, so the clef takes effect HERE, not at the bar start.
|
|
1035
|
+
// Append it inline on the staff's currently-active voice via
|
|
1036
|
+
// addEvent (which inserts a staff-switch context if that voice is
|
|
1037
|
+
// currently positioned on another staff — e.g. a cross-staff
|
|
1038
|
+
// voice — so the clef binds to `staff`, not the voice's current
|
|
1039
|
+
// staff). Zero duration: a clef does not advance time.
|
|
1040
|
+
const vNum = lastVoiceOnStaff.get(staff);
|
|
1041
|
+
if (vNum !== undefined) {
|
|
1042
|
+
// Tag the clef context with its staff explicitly. In a
|
|
1043
|
+
// cross-staff voice (where notes interleave staves) a bare
|
|
1044
|
+
// clef is ambiguous and is dropped/misplaced by the serializer;
|
|
1045
|
+
// the explicit staff makes it self-describing (serializer emits
|
|
1046
|
+
// `\staff "N" \clef ...`). addEvent still inserts a leading
|
|
1047
|
+
// staff switch when the voice is currently on another staff.
|
|
1048
|
+
voiceTracker.addEvent(vNum, { type: 'context', staff, clef }, 0, staff);
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
clefs.set(staff, { type: 'context', clef });
|
|
1056
|
+
}
|
|
846
1057
|
}
|
|
847
1058
|
}
|
|
848
1059
|
}
|
|
@@ -852,24 +1063,52 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
852
1063
|
const voiceNum = note.voice;
|
|
853
1064
|
const staffNum = note.staff || 1;
|
|
854
1065
|
currentVoice = voiceNum;
|
|
1066
|
+
// Record that this staff now has notes this measure and which voice is
|
|
1067
|
+
// active on it, so a subsequent mid-measure <clef> attaches inline here.
|
|
1068
|
+
staffHasNotes.add(staffNum);
|
|
1069
|
+
lastVoiceOnStaff.set(staffNum, voiceNum);
|
|
855
1070
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
856
1071
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
857
1072
|
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
1073
|
+
// Flush an accumulated <forward> gap as invisible rests into THIS note's
|
|
1074
|
+
// voice (the forward had no voice of its own; it belongs to the voice that
|
|
1075
|
+
// follows). Skip while inside a tuplet — a forward there is unusual and the
|
|
1076
|
+
// tuplet tracker owns timing.
|
|
1077
|
+
if (pendingForward > 0 && !tupletTracker.isActive(voiceNum)) {
|
|
1078
|
+
for (const r of forwardGapToRests(pendingForward, voiceTracker.getDivisions())) {
|
|
1079
|
+
voiceTracker.addEvent(voiceNum, r, 0, staffNum);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
pendingForward = 0;
|
|
858
1083
|
// Check for tuplet start BEFORE processing the note
|
|
859
1084
|
const tupletNotation = note.notations?.tuplet;
|
|
860
1085
|
if (tupletNotation?.type === 'start') {
|
|
861
|
-
tupletTracker.startTuplet(tupletNotation.number);
|
|
1086
|
+
tupletTracker.startTuplet(tupletNotation.number, voiceNum, staffNum);
|
|
862
1087
|
}
|
|
863
|
-
// Add any pending context changes before the note (tempo, ottava)
|
|
1088
|
+
// Add any pending context changes before the note (tempo, ottava).
|
|
1089
|
+
// A staff-tagged change (ottava) only flushes onto a note on the SAME
|
|
1090
|
+
// staff so its 8va span stays in one layer; others (tempo) flush anywhere.
|
|
864
1091
|
if (pendingContextChanges.length > 0) {
|
|
865
|
-
|
|
866
|
-
|
|
1092
|
+
const remaining = [];
|
|
1093
|
+
for (const pc of pendingContextChanges) {
|
|
1094
|
+
if (pc.staff === undefined || pc.staff === staffNum) {
|
|
1095
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, staffNum);
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
remaining.push(pc); // wait for a note on the matching staff
|
|
1099
|
+
}
|
|
867
1100
|
}
|
|
868
|
-
pendingContextChanges.length = 0;
|
|
1101
|
+
pendingContextChanges.length = 0;
|
|
1102
|
+
pendingContextChanges.push(...remaining);
|
|
869
1103
|
}
|
|
870
|
-
// Get pending marks for this voice
|
|
871
|
-
|
|
872
|
-
|
|
1104
|
+
// Get pending marks for this voice. Rests can't hold marks (RestEvent has
|
|
1105
|
+
// no `marks` field), so do NOT consume them on a rest — leave them queued
|
|
1106
|
+
// for the next real note or the end-of-measure flush. Otherwise a pedal/
|
|
1107
|
+
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1108
|
+
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1109
|
+
const marks = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1110
|
+
if (!note.isRest)
|
|
1111
|
+
pendingMarks.delete(voiceNum);
|
|
873
1112
|
if (note.isRest) {
|
|
874
1113
|
// Rest event
|
|
875
1114
|
const duration = convertDuration(voiceTracker.getDivisions(), note.duration.divisions, note.duration.type, note.duration.dots, note.duration.timeModification);
|
|
@@ -877,11 +1116,23 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
877
1116
|
type: 'rest',
|
|
878
1117
|
duration,
|
|
879
1118
|
};
|
|
1119
|
+
// Whole-measure rest: mark it so encoders emit <mRest>/R and downstream
|
|
1120
|
+
// duration math uses the measure length, not the power-of-two rounding
|
|
1121
|
+
// of the bare <duration> (which over/under-fills non-2^n meters like 3/4).
|
|
1122
|
+
if (note.isMeasureRest) {
|
|
1123
|
+
restEvent.fullMeasure = true;
|
|
1124
|
+
}
|
|
1125
|
+
// A rest can host a fermata (grand pause / held silence). Convert it
|
|
1126
|
+
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1127
|
+
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1128
|
+
if (note.notations?.fermata) {
|
|
1129
|
+
restEvent.marks = [{ markType: 'ornament', type: 'fermata' }];
|
|
1130
|
+
}
|
|
880
1131
|
// Grace notes don't advance time
|
|
881
1132
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
882
1133
|
// Check if we're in a tuplet
|
|
883
|
-
if (tupletTracker.isActive()) {
|
|
884
|
-
tupletTracker.addEvent(restEvent);
|
|
1134
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1135
|
+
tupletTracker.addEvent(restEvent, voiceNum);
|
|
885
1136
|
}
|
|
886
1137
|
else {
|
|
887
1138
|
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
@@ -893,9 +1144,12 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
893
1144
|
// Get marks from notations
|
|
894
1145
|
const notationMarks = notationsToMarks(note.notations, spannerTracker, [lilyletPitch]);
|
|
895
1146
|
marks.push(...notationMarks);
|
|
896
|
-
// Add
|
|
897
|
-
if (note.
|
|
898
|
-
|
|
1147
|
+
// Add fingerings (one per chord member; MEI emits a <fing> each)
|
|
1148
|
+
if (note.fingerings) {
|
|
1149
|
+
for (const finger of note.fingerings) {
|
|
1150
|
+
if (finger >= 0 && finger <= 9)
|
|
1151
|
+
marks.push({ markType: 'fingering', finger });
|
|
1152
|
+
}
|
|
899
1153
|
}
|
|
900
1154
|
// Handle chord: merge with previous note in same voice
|
|
901
1155
|
if (note.isChord) {
|
|
@@ -943,8 +1197,8 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
943
1197
|
// Grace notes don't advance time
|
|
944
1198
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
945
1199
|
// Check if we're in a tuplet
|
|
946
|
-
if (tupletTracker.isActive()) {
|
|
947
|
-
tupletTracker.addEvent(noteEvent);
|
|
1200
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1201
|
+
tupletTracker.addEvent(noteEvent, voiceNum);
|
|
948
1202
|
}
|
|
949
1203
|
else {
|
|
950
1204
|
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
@@ -954,16 +1208,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
954
1208
|
if (tupletNotation?.type === 'stop') {
|
|
955
1209
|
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
956
1210
|
if (tupletEvent) {
|
|
957
|
-
|
|
958
|
-
let totalDuration = 0;
|
|
959
|
-
for (const evt of tupletEvent.events) {
|
|
960
|
-
if (evt.duration) {
|
|
961
|
-
// Convert division to duration units (quarter = 1)
|
|
962
|
-
totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
// Apply tuplet ratio to get actual duration
|
|
966
|
-
totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
1211
|
+
const totalDuration = tupletAdvanceDuration(tupletEvent, voiceTracker.getDivisions());
|
|
967
1212
|
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
968
1213
|
}
|
|
969
1214
|
}
|
|
@@ -973,7 +1218,9 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
973
1218
|
// Handle context changes (tempo, ottava)
|
|
974
1219
|
const contextChange = directionToContextChange(direction, ottavaTracker);
|
|
975
1220
|
if (contextChange) {
|
|
976
|
-
|
|
1221
|
+
// Tag ottava changes with their staff so they reach the right layer.
|
|
1222
|
+
const staff = contextChange.ottava !== undefined ? direction.staff : undefined;
|
|
1223
|
+
pendingContextChanges.push({ ctx: contextChange, staff });
|
|
977
1224
|
}
|
|
978
1225
|
// Handle marks (dynamics, hairpins, etc.)
|
|
979
1226
|
const marks = directionToMarks(direction, spannerTracker);
|
|
@@ -984,18 +1231,32 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
984
1231
|
}
|
|
985
1232
|
}
|
|
986
1233
|
else if (tagName === 'backup') {
|
|
1234
|
+
// A <forward> with no note after it (before this backup) is cursor
|
|
1235
|
+
// positioning, not a content gap — drop it rather than materialise filler
|
|
1236
|
+
// (the common `backup N / forward N` measure-end idiom would otherwise
|
|
1237
|
+
// double the bar). Only note→forward→note gaps become invisible rests.
|
|
1238
|
+
pendingForward = 0;
|
|
987
1239
|
const duration = getElementInt(child, 'duration') || 0;
|
|
988
1240
|
voiceTracker.backup(duration);
|
|
989
1241
|
}
|
|
990
1242
|
else if (tagName === 'forward') {
|
|
991
1243
|
const duration = getElementInt(child, 'duration') || 0;
|
|
992
1244
|
voiceTracker.forward(duration);
|
|
1245
|
+
pendingForward += duration; // materialised as invisible rests only if a note follows in-voice
|
|
993
1246
|
}
|
|
994
1247
|
else if (tagName === 'barline') {
|
|
995
1248
|
const barlineData = parseBarline(child);
|
|
996
1249
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
997
1250
|
if (style && style !== '|') {
|
|
998
|
-
|
|
1251
|
+
// A `location="left"` barline (forward repeat at the measure front)
|
|
1252
|
+
// is carried back onto the previous measure's end bar by convertPart;
|
|
1253
|
+
// everything else is this measure's own trailing end bar.
|
|
1254
|
+
if (barlineData.location === 'left') {
|
|
1255
|
+
leadingBarline = { type: 'barline', style };
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
barline = { type: 'barline', style };
|
|
1259
|
+
}
|
|
999
1260
|
}
|
|
1000
1261
|
}
|
|
1001
1262
|
else if (tagName === 'harmony') {
|
|
@@ -1006,6 +1267,80 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1006
1267
|
}
|
|
1007
1268
|
}
|
|
1008
1269
|
}
|
|
1270
|
+
// A <forward> at the very end of the measure with no following note is cursor
|
|
1271
|
+
// positioning (e.g. the `backup N / forward N` idiom), not a content gap — drop
|
|
1272
|
+
// it. Only forwards consumed by a following note become invisible spacer rests.
|
|
1273
|
+
pendingForward = 0;
|
|
1274
|
+
// Force-close any tuplet still open at the bar line. A tuplet is a within-measure
|
|
1275
|
+
// time modification and cannot span a bar; a source file missing its
|
|
1276
|
+
// <tuplet type="stop"> (corpus reality) would otherwise keep the tuplet open for
|
|
1277
|
+
// the rest of the piece, diverting every following note in that voice into the
|
|
1278
|
+
// zombie tuplet — the multi-voice block-drop bug (库劳 Op.20 m15→end empty).
|
|
1279
|
+
// Flush each leftover tuplet onto its owning voice so the damage is bounded to
|
|
1280
|
+
// this measure. Done before the pendingMarks flush so trailing marks still find
|
|
1281
|
+
// the (now-emitted) tuplet's notes as the voice's last events.
|
|
1282
|
+
for (const { event, voice, staff } of tupletTracker.flushAll()) {
|
|
1283
|
+
const totalDuration = tupletAdvanceDuration(event, voiceTracker.getDivisions());
|
|
1284
|
+
voiceTracker.addEvent(voice, event, totalDuration, staff);
|
|
1285
|
+
}
|
|
1286
|
+
// Flush leftover pending marks. Post-positioned directions — hairpin/pedal
|
|
1287
|
+
// stops, and any direction after the last note of its voice (common after a
|
|
1288
|
+
// <backup> in piano scores) — never reached a following note in the loop above.
|
|
1289
|
+
// They belong on the note they trail, so attach them to the last NoteEvent of
|
|
1290
|
+
// the voice. pendingMarks is per-measure, so without this they would be lost
|
|
1291
|
+
// at the next measure (the pedal/hairpin "stop" loss). Rests carry no marks, so
|
|
1292
|
+
// search backward for the last actual note; fall back across voices if needed.
|
|
1293
|
+
if (pendingMarks.size > 0) {
|
|
1294
|
+
const allVoices = voiceTracker.getVoices();
|
|
1295
|
+
const findLastNote = (voiceNum) => {
|
|
1296
|
+
const vs = allVoices.get(voiceNum);
|
|
1297
|
+
if (!vs)
|
|
1298
|
+
return undefined;
|
|
1299
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1300
|
+
const ev = vs.events[i];
|
|
1301
|
+
if (ev.type === 'note')
|
|
1302
|
+
return ev;
|
|
1303
|
+
}
|
|
1304
|
+
return undefined;
|
|
1305
|
+
};
|
|
1306
|
+
for (const [voiceNum, marks] of pendingMarks) {
|
|
1307
|
+
if (marks.length === 0)
|
|
1308
|
+
continue;
|
|
1309
|
+
let target = findLastNote(voiceNum);
|
|
1310
|
+
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1311
|
+
// last note of any voice so the marking is not silently dropped.
|
|
1312
|
+
if (!target) {
|
|
1313
|
+
for (const vn of allVoices.keys()) {
|
|
1314
|
+
target = findLastNote(vn);
|
|
1315
|
+
if (target)
|
|
1316
|
+
break;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (target)
|
|
1320
|
+
target.marks = [...(target.marks || []), ...marks];
|
|
1321
|
+
}
|
|
1322
|
+
pendingMarks.clear();
|
|
1323
|
+
}
|
|
1324
|
+
// Flush leftover staff-tagged context changes (ottava) whose matching-staff note
|
|
1325
|
+
// never appeared this measure: append to a voice on the target staff so the span
|
|
1326
|
+
// continues into / closes in the right layer rather than being dropped.
|
|
1327
|
+
if (pendingContextChanges.length > 0) {
|
|
1328
|
+
const voices = voiceTracker.getVoices();
|
|
1329
|
+
for (const pc of pendingContextChanges) {
|
|
1330
|
+
let voiceNum;
|
|
1331
|
+
for (const [vn, vs] of voices) {
|
|
1332
|
+
if (vs.staff === pc.staff) {
|
|
1333
|
+
voiceNum = vn;
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (voiceNum === undefined)
|
|
1338
|
+
voiceNum = voices.keys().next().value;
|
|
1339
|
+
if (voiceNum !== undefined)
|
|
1340
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff);
|
|
1341
|
+
}
|
|
1342
|
+
pendingContextChanges.length = 0;
|
|
1343
|
+
}
|
|
1009
1344
|
// Build voice map from tracker
|
|
1010
1345
|
const voiceMap = new Map();
|
|
1011
1346
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
@@ -1014,7 +1349,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1014
1349
|
staff: voiceState.staff,
|
|
1015
1350
|
});
|
|
1016
1351
|
}
|
|
1017
|
-
return { voiceMap, key, timeSig, barline, harmonies, clefs };
|
|
1352
|
+
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
1018
1353
|
};
|
|
1019
1354
|
/**
|
|
1020
1355
|
* Convert a MusicXML part to Lilylet measures
|
|
@@ -1030,10 +1365,27 @@ const convertPart = (partEl) => {
|
|
|
1030
1365
|
let isFirstMeasure = true;
|
|
1031
1366
|
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1032
1367
|
const lastClefs = new Map(); // Track last clef per staff
|
|
1368
|
+
// Initial clef per staff declared in the FIRST <attributes> block. A staff whose
|
|
1369
|
+
// first voice does not appear until a later measure (e.g. an accompaniment staff
|
|
1370
|
+
// silent for the opening bars) would otherwise lose its initial clef — it is only
|
|
1371
|
+
// declared in measure 1's attributes, where that staff has no voice to attach it
|
|
1372
|
+
// to. We hold these and flush each onto the staff's FIRST appearing voice.
|
|
1373
|
+
const pendingInitialClefs = new Map();
|
|
1033
1374
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1034
1375
|
for (const measureEl of measureEls) {
|
|
1035
1376
|
voiceTracker.reset();
|
|
1036
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1377
|
+
const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1378
|
+
// A forward-repeat (or any `location="left"`) barline belongs at the END of
|
|
1379
|
+
// the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
|
|
1380
|
+
// Append it to the previous measure's first voice; if this is the first
|
|
1381
|
+
// measure there is no previous one, so a leading repeat at the very start of
|
|
1382
|
+
// the piece is conventionally dropped.
|
|
1383
|
+
if (leadingBarline && measures.length > 0) {
|
|
1384
|
+
const prevVoices = measures[measures.length - 1].parts[0]?.voices;
|
|
1385
|
+
if (prevVoices && prevVoices.length > 0) {
|
|
1386
|
+
prevVoices[0].events.push(leadingBarline);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1037
1389
|
// Update running key/time
|
|
1038
1390
|
if (key)
|
|
1039
1391
|
lastKey = key;
|
|
@@ -1068,11 +1420,45 @@ const convertPart = (partEl) => {
|
|
|
1068
1420
|
lastClefs.set(voiceData.staff, clef);
|
|
1069
1421
|
}
|
|
1070
1422
|
}
|
|
1423
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1424
|
+
}
|
|
1425
|
+
else if (pendingInitialClefs.has(voiceData.staff)) {
|
|
1426
|
+
// This staff's clef was declared in an earlier measure where the staff
|
|
1427
|
+
// had no voice to carry it (its first appearance is delayed, or it is
|
|
1428
|
+
// an intermittently-empty cross-staff passage). This measure declares
|
|
1429
|
+
// no clef of its own — emit the carried-forward pending clef if it
|
|
1430
|
+
// differs from the clef last in effect for this staff.
|
|
1431
|
+
const pending = pendingInitialClefs.get(voiceData.staff);
|
|
1432
|
+
const lastClef = lastClefs.get(voiceData.staff);
|
|
1433
|
+
const isSameClef = lastClef &&
|
|
1434
|
+
lastClef.clef === pending.clef;
|
|
1435
|
+
if (!isSameClef) {
|
|
1436
|
+
events.push(pending);
|
|
1437
|
+
lastClefs.set(voiceData.staff, pending);
|
|
1438
|
+
}
|
|
1439
|
+
pendingInitialClefs.delete(voiceData.staff);
|
|
1071
1440
|
}
|
|
1072
1441
|
staffsWithClef.add(voiceData.staff);
|
|
1073
1442
|
}
|
|
1074
1443
|
// Add voice events
|
|
1075
1444
|
events.push(...voiceData.events);
|
|
1445
|
+
// A mid-measure clef change was appended inline to this voice's events (it
|
|
1446
|
+
// does not pass through the measure-start `clefs` map). Scan for the LAST
|
|
1447
|
+
// clef per staff in this voice and update lastClefs, so the next measure's
|
|
1448
|
+
// dedup compares against the clef actually in effect — otherwise a later
|
|
1449
|
+
// measure restating the pre-change clef would be wrongly suppressed.
|
|
1450
|
+
{
|
|
1451
|
+
let scanStaff = voiceData.staff;
|
|
1452
|
+
for (const ev of voiceData.events) {
|
|
1453
|
+
if (ev.type === 'context') {
|
|
1454
|
+
const c = ev;
|
|
1455
|
+
if (c.staff != null)
|
|
1456
|
+
scanStaff = c.staff;
|
|
1457
|
+
if (c.clef)
|
|
1458
|
+
lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1076
1462
|
// Add harmonies and barline to first voice only
|
|
1077
1463
|
if (voiceNum === voiceNumbers[0]) {
|
|
1078
1464
|
for (const h of harmonies) {
|
|
@@ -1094,6 +1480,16 @@ const convertPart = (partEl) => {
|
|
|
1094
1480
|
else {
|
|
1095
1481
|
lastVoiceStaff = voices[0].staff || 1;
|
|
1096
1482
|
}
|
|
1483
|
+
// Carry forward any clef declared this measure for a staff that had NO voice to
|
|
1484
|
+
// attach it to (an intermittently-empty staff — e.g. cross-staff passages where
|
|
1485
|
+
// the LH is written entirely on the upper staff for several bars). Remember it
|
|
1486
|
+
// as pending so it is emitted when that staff next reappears; otherwise the clef
|
|
1487
|
+
// (and any change it represents) is lost when this measure's `clefs` map is
|
|
1488
|
+
// discarded. Generalizes the first-measure seeding above to every measure.
|
|
1489
|
+
for (const [staff, clef] of clefs) {
|
|
1490
|
+
if (!staffsWithClef.has(staff))
|
|
1491
|
+
pendingInitialClefs.set(staff, clef);
|
|
1492
|
+
}
|
|
1097
1493
|
const measure = {
|
|
1098
1494
|
parts: [{
|
|
1099
1495
|
voices,
|
|
@@ -1113,7 +1509,34 @@ const convertPart = (partEl) => {
|
|
|
1113
1509
|
/**
|
|
1114
1510
|
* Decode MusicXML string to LilyletDoc
|
|
1115
1511
|
*/
|
|
1116
|
-
|
|
1512
|
+
/**
|
|
1513
|
+
* Decode raw MusicXML bytes (or a string) into a clean UTF-8/UTF-16-correct
|
|
1514
|
+
* JS string. MuseScore/Finale/Sibelius frequently export `.xml` as UTF-16 LE
|
|
1515
|
+
* with a BOM; reading those as UTF-8 yields mojibake and a failed parse.
|
|
1516
|
+
*
|
|
1517
|
+
* Detection order: byte-order mark → declared `encoding="..."` in the XML
|
|
1518
|
+
* prolog → default UTF-8. A leading BOM is always stripped (xmldom chokes on a
|
|
1519
|
+
* U+FEFF before `<?xml`).
|
|
1520
|
+
*/
|
|
1521
|
+
export const readXmlString = (input) => {
|
|
1522
|
+
if (typeof input === 'string')
|
|
1523
|
+
return input.charCodeAt(0) === 0xFEFF ? input.slice(1) : input;
|
|
1524
|
+
const bytes = input;
|
|
1525
|
+
if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE)
|
|
1526
|
+
return new TextDecoder('utf-16le').decode(bytes.subarray(2));
|
|
1527
|
+
if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF)
|
|
1528
|
+
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
|
|
1529
|
+
if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF)
|
|
1530
|
+
return new TextDecoder('utf-8').decode(bytes.subarray(3));
|
|
1531
|
+
// No BOM: peek the prolog (as latin1 so every byte maps 1:1) for a declared encoding.
|
|
1532
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 256));
|
|
1533
|
+
const enc = /encoding\s*=\s*['"]([^'"]+)['"]/i.exec(head)?.[1]?.toLowerCase();
|
|
1534
|
+
if (enc && /utf-?16/.test(enc))
|
|
1535
|
+
return new TextDecoder('utf-16le').decode(bytes); // BOM-less UTF-16 → assume LE (Windows)
|
|
1536
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1537
|
+
};
|
|
1538
|
+
export const decode = (input) => {
|
|
1539
|
+
const xmlString = readXmlString(input);
|
|
1117
1540
|
const parser = new DOMParser();
|
|
1118
1541
|
const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
1119
1542
|
// Check for parsing errors
|
|
@@ -1185,6 +1608,14 @@ export const decode = (xmlString) => {
|
|
|
1185
1608
|
const result = {
|
|
1186
1609
|
measures: mergedMeasures,
|
|
1187
1610
|
};
|
|
1611
|
+
// Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
|
|
1612
|
+
// from the first part's barline + <sound> navigation markup → measure-layout
|
|
1613
|
+
// string. The MEI encoder turns this into an <expansion> that verovio unfolds
|
|
1614
|
+
// for MIDI. Repeat markup is part-global; the first part carries it. Never
|
|
1615
|
+
// throws (returns undefined when there is nothing to unfold).
|
|
1616
|
+
const measureLayout = measureLayoutFromPart(partEls[0]);
|
|
1617
|
+
if (measureLayout)
|
|
1618
|
+
metadata.measureLayout = measureLayout;
|
|
1188
1619
|
if (Object.keys(metadata).length > 0) {
|
|
1189
1620
|
result.metadata = metadata;
|
|
1190
1621
|
}
|
|
@@ -1195,8 +1626,8 @@ export const decode = (xmlString) => {
|
|
|
1195
1626
|
*/
|
|
1196
1627
|
export const decodeFile = async (filePath) => {
|
|
1197
1628
|
const fs = await import('fs/promises');
|
|
1198
|
-
const
|
|
1199
|
-
return decode(
|
|
1629
|
+
const buf = await fs.readFile(filePath); // raw bytes; readXmlString sniffs the encoding
|
|
1630
|
+
return decode(buf);
|
|
1200
1631
|
};
|
|
1201
1632
|
export default {
|
|
1202
1633
|
decode,
|