@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
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
HairpinType,
|
|
26
26
|
PedalType,
|
|
27
27
|
NavigationMarkType,
|
|
28
|
+
Placement,
|
|
28
29
|
BarlineEvent,
|
|
29
30
|
HarmonyEvent,
|
|
30
31
|
TupletEvent,
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
createFraction,
|
|
69
70
|
TYPE_TO_DIVISION,
|
|
70
71
|
} from './musicXmlUtils';
|
|
72
|
+
import { measureLayoutFromPart } from './measureLayoutFromXml';
|
|
71
73
|
|
|
72
74
|
// ============ Spanner Tracker ============
|
|
73
75
|
|
|
@@ -141,38 +143,53 @@ class SpannerTracker {
|
|
|
141
143
|
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
142
144
|
*/
|
|
143
145
|
class TupletTracker {
|
|
144
|
-
// Map from tuplet number to collected events and ratio
|
|
146
|
+
// Map from tuplet number to collected events and ratio. Each tuplet is bound to
|
|
147
|
+
// the voice (and staff) it started in: a tuplet belongs to one voice, so events
|
|
148
|
+
// from OTHER voices must not be swallowed into it (multi-voice piano scores
|
|
149
|
+
// interleave voices, and a voice-1 tuplet would otherwise eat voice-2 notes).
|
|
145
150
|
private activeTuplets: Map<number, {
|
|
146
151
|
events: (NoteEvent | RestEvent)[];
|
|
147
152
|
ratio?: Fraction;
|
|
153
|
+
voice: number;
|
|
154
|
+
staff: number;
|
|
148
155
|
}> = new Map();
|
|
149
156
|
|
|
150
157
|
/**
|
|
151
|
-
* Start a new tuplet group
|
|
158
|
+
* Start a new tuplet group, bound to the voice/staff it starts in.
|
|
152
159
|
*/
|
|
153
|
-
startTuplet(number: number = 1): void {
|
|
154
|
-
this.activeTuplets.set(number, { events: [] });
|
|
160
|
+
startTuplet(number: number = 1, voice: number = 1, staff: number = 1): void {
|
|
161
|
+
this.activeTuplets.set(number, { events: [], voice, staff });
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
/**
|
|
158
|
-
* Add an event to active tuplet
|
|
159
|
-
* Returns true if the event was added
|
|
165
|
+
* Add an event to the innermost active tuplet of the SAME voice.
|
|
166
|
+
* Returns true if the event was added.
|
|
167
|
+
*
|
|
168
|
+
* Nested tuplets share the doc model's flat TupletEvent (which can't hold a
|
|
169
|
+
* nested TupletEvent), so an event must go to exactly ONE tuplet or it would be
|
|
170
|
+
* emitted twice — once per enclosing tuplet — inflating the pitch count. We pick
|
|
171
|
+
* the most-recently-started same-voice tuplet (the innermost): when the inner one
|
|
172
|
+
* closes, later events fall back to the still-open outer one.
|
|
160
173
|
*/
|
|
161
|
-
addEvent(event: NoteEvent | RestEvent): boolean {
|
|
174
|
+
addEvent(event: NoteEvent | RestEvent, voice: number): boolean {
|
|
162
175
|
if (this.activeTuplets.size === 0) return false;
|
|
163
176
|
|
|
164
|
-
//
|
|
177
|
+
// Innermost = last-inserted entry for this voice (Map preserves insertion order).
|
|
178
|
+
let target: { events: (NoteEvent | RestEvent)[]; ratio?: Fraction; voice: number; staff: number } | undefined;
|
|
165
179
|
for (const [, tuplet] of this.activeTuplets) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
tuplet.events.push(cleanEvent);
|
|
180
|
+
if (tuplet.voice === voice) target = tuplet;
|
|
181
|
+
}
|
|
182
|
+
if (!target) return false;
|
|
183
|
+
|
|
184
|
+
// Set ratio from first event's duration.tuplet
|
|
185
|
+
// convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
|
|
186
|
+
if (!target.ratio && event.duration.tuplet) {
|
|
187
|
+
target.ratio = { ...event.duration.tuplet };
|
|
175
188
|
}
|
|
189
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
190
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
191
|
+
delete cleanEvent.duration.tuplet;
|
|
192
|
+
target.events.push(cleanEvent);
|
|
176
193
|
return true;
|
|
177
194
|
}
|
|
178
195
|
|
|
@@ -199,10 +216,37 @@ class TupletTracker {
|
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
/**
|
|
202
|
-
* Check if
|
|
219
|
+
* Check if a tuplet is active for the given voice. A tuplet only swallows notes
|
|
220
|
+
* of its OWN voice, so the per-note "are we in a tuplet?" check must be scoped to
|
|
221
|
+
* the note's voice — otherwise a voice-1 tuplet would divert voice-2 notes.
|
|
203
222
|
*/
|
|
204
|
-
isActive(): boolean {
|
|
205
|
-
return this.activeTuplets.size > 0;
|
|
223
|
+
isActive(voice?: number): boolean {
|
|
224
|
+
if (voice === undefined) return this.activeTuplets.size > 0;
|
|
225
|
+
for (const [, t] of this.activeTuplets) if (t.voice === voice) return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Force-close every still-open tuplet and return them with their owning
|
|
231
|
+
* voice/staff. Called at measure end: a tuplet is a within-measure time
|
|
232
|
+
* modification and MUST NOT leak across the bar line. A source file missing a
|
|
233
|
+
* <tuplet type="stop"> (corpus reality — e.g. 库劳 Op.20) would otherwise leave
|
|
234
|
+
* the tuplet open forever, swallowing every following note in that voice for the
|
|
235
|
+
* rest of the piece. Flushing here bounds the damage to the one measure.
|
|
236
|
+
*/
|
|
237
|
+
flushAll(): Array<{ event: TupletEvent; voice: number; staff: number }> {
|
|
238
|
+
const out: Array<{ event: TupletEvent; voice: number; staff: number }> = [];
|
|
239
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
240
|
+
if (tuplet.events.length === 0) continue;
|
|
241
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
242
|
+
out.push({
|
|
243
|
+
event: { type: 'tuplet', ratio, events: tuplet.events },
|
|
244
|
+
voice: tuplet.voice,
|
|
245
|
+
staff: tuplet.staff,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
this.activeTuplets.clear();
|
|
249
|
+
return out;
|
|
206
250
|
}
|
|
207
251
|
|
|
208
252
|
/**
|
|
@@ -423,6 +467,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
423
467
|
const isChord = hasElement(noteEl, 'chord');
|
|
424
468
|
const isRest = hasElement(noteEl, 'rest');
|
|
425
469
|
const isGrace = hasElement(noteEl, 'grace');
|
|
470
|
+
const restEl = isRest ? noteEl.getElementsByTagName('rest')[0] : undefined;
|
|
426
471
|
|
|
427
472
|
let pitch: MusicXmlPitch | undefined;
|
|
428
473
|
const pitchEl = noteEl.getElementsByTagName('pitch')[0];
|
|
@@ -435,6 +480,18 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
435
480
|
const typeText = getElementText(noteEl, 'type');
|
|
436
481
|
const dotCount = getElements(noteEl, 'dot').length;
|
|
437
482
|
|
|
483
|
+
// Whole-measure rest detection. Two forms in the wild:
|
|
484
|
+
// (a) <rest measure="yes"> — explicit.
|
|
485
|
+
// (b) a `type="whole"` rest whose <duration> is NOT a whole note (e.g. 72 ticks
|
|
486
|
+
// in 3/4 at divisions=24) — the conventional "centred whole rest = whole
|
|
487
|
+
// bar" notation. In both cases the rest fills the measure, so flag it and
|
|
488
|
+
// let encoders emit <mRest>/R instead of rounding the bare duration to a
|
|
489
|
+
// power-of-two division (which over/under-fills non-2^n meters).
|
|
490
|
+
const isMeasureRest = !!restEl && (
|
|
491
|
+
getAttribute(restEl, 'measure') === 'yes' ||
|
|
492
|
+
(typeText === 'whole' && durationVal > 0 && durationVal !== divisions * 4)
|
|
493
|
+
);
|
|
494
|
+
|
|
438
495
|
// Time modification (tuplets)
|
|
439
496
|
let timeModification: { actualNotes: number; normalNotes: number } | undefined;
|
|
440
497
|
const timeModEl = noteEl.getElementsByTagName('time-modification')[0];
|
|
@@ -463,14 +520,15 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
463
520
|
notations = parseNotations(notationsEl);
|
|
464
521
|
}
|
|
465
522
|
|
|
466
|
-
// Fingering
|
|
467
|
-
let
|
|
523
|
+
// Fingering — a note may carry several <fingering> (one per chord member).
|
|
524
|
+
let fingerings: number[] | undefined;
|
|
468
525
|
const technicalEl = noteEl.getElementsByTagName('technical')[0];
|
|
469
526
|
if (technicalEl) {
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
527
|
+
const fingeringEls = getElements(technicalEl, 'fingering');
|
|
528
|
+
const parsed = fingeringEls
|
|
529
|
+
.map(el => parseInt(el.textContent?.trim() || '', 10))
|
|
530
|
+
.filter(n => Number.isFinite(n));
|
|
531
|
+
if (parsed.length > 0) fingerings = parsed;
|
|
474
532
|
}
|
|
475
533
|
|
|
476
534
|
// Beams - direct children of note, not in notations
|
|
@@ -488,6 +546,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
488
546
|
isChord,
|
|
489
547
|
isRest,
|
|
490
548
|
isGrace,
|
|
549
|
+
isMeasureRest,
|
|
491
550
|
pitch,
|
|
492
551
|
duration: {
|
|
493
552
|
divisions: durationVal,
|
|
@@ -499,7 +558,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
499
558
|
staff,
|
|
500
559
|
stem: stem as any,
|
|
501
560
|
notations,
|
|
502
|
-
|
|
561
|
+
fingerings,
|
|
503
562
|
beams,
|
|
504
563
|
};
|
|
505
564
|
};
|
|
@@ -572,6 +631,25 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
|
|
|
572
631
|
result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
|
|
573
632
|
result.staff = getElementInt(dirEl, 'staff');
|
|
574
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
|
+
|
|
575
653
|
const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
|
|
576
654
|
if (!dirTypeEl) {
|
|
577
655
|
return result;
|
|
@@ -664,7 +742,8 @@ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
|
|
|
664
742
|
if (repeatEl) {
|
|
665
743
|
const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
|
|
666
744
|
if (direction) {
|
|
667
|
-
|
|
745
|
+
const times = getAttributeNumber(repeatEl, 'times');
|
|
746
|
+
result.repeat = times !== undefined ? { direction, times } : { direction };
|
|
668
747
|
}
|
|
669
748
|
}
|
|
670
749
|
|
|
@@ -797,12 +876,13 @@ const notationsToMarks = (
|
|
|
797
876
|
if (notations.slurs) {
|
|
798
877
|
for (const slur of notations.slurs) {
|
|
799
878
|
if (slur.type === 'start') {
|
|
800
|
-
marks.push({ markType: 'slur', start: true });
|
|
879
|
+
marks.push({ markType: 'slur', start: true, number: slur.number });
|
|
801
880
|
spannerTracker.startSlur(slur.number);
|
|
802
881
|
} else if (slur.type === 'stop') {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
882
|
+
// Carry the MusicXML number so the encoder can pair cross-voice slurs
|
|
883
|
+
// (start in one voice, stop in another — common in piano scores).
|
|
884
|
+
spannerTracker.stopSlur(slur.number);
|
|
885
|
+
marks.push({ markType: 'slur', start: false, number: slur.number });
|
|
806
886
|
}
|
|
807
887
|
}
|
|
808
888
|
}
|
|
@@ -1002,6 +1082,45 @@ const directionToMarks = (
|
|
|
1002
1082
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
1003
1083
|
}
|
|
1004
1084
|
|
|
1085
|
+
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
1086
|
+
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
1087
|
+
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
1088
|
+
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
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;
|
|
1093
|
+
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
1094
|
+
const text = direction.words.map(w => w.text).join('').trim();
|
|
1095
|
+
if (text && !isTempoWord(text)) {
|
|
1096
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
1097
|
+
: direction.placement === 'below' ? Placement.below
|
|
1098
|
+
: undefined;
|
|
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 });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1005
1124
|
return marks;
|
|
1006
1125
|
};
|
|
1007
1126
|
|
|
@@ -1013,10 +1132,61 @@ interface MeasureConversionResult {
|
|
|
1013
1132
|
key?: KeySignature;
|
|
1014
1133
|
timeSig?: Fraction;
|
|
1015
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;
|
|
1016
1141
|
harmonies: HarmonyEvent[];
|
|
1017
1142
|
clefs: Map<number, ContextChange>; // staff number → clef context
|
|
1018
1143
|
}
|
|
1019
1144
|
|
|
1145
|
+
/**
|
|
1146
|
+
* Decompose a tick gap (from <forward>) into invisible spacer rests.
|
|
1147
|
+
*
|
|
1148
|
+
* lilylet's doc model is a flat per-voice event sequence with no absolute tick
|
|
1149
|
+
* anchor (currentPosition is unused for placement), so a <forward> that skips
|
|
1150
|
+
* time inside a voice must be materialised as filler or the following notes slide
|
|
1151
|
+
* earlier and the bar decodes short. Invisible rests (`s` / MEI <space>) are the
|
|
1152
|
+
* right carrier. The gap may not be a single note value (e.g. 1.5 quarters), so
|
|
1153
|
+
* emit a greedy sequence of power-of-two (optionally dotted) spacers.
|
|
1154
|
+
*/
|
|
1155
|
+
const forwardGapToRests = (gapTicks: number, divisions: number): RestEvent[] => {
|
|
1156
|
+
const rests: RestEvent[] = [];
|
|
1157
|
+
let remaining = gapTicks;
|
|
1158
|
+
const quarterTicks = divisions; // ticks per quarter note
|
|
1159
|
+
// Largest representable spacer first; division 1=whole..128. dotted adds half.
|
|
1160
|
+
const candidates: { division: number; dots: number; q: number }[] = [];
|
|
1161
|
+
for (const division of [1, 2, 4, 8, 16, 32, 64, 128]) {
|
|
1162
|
+
const baseQ = 4 / division; // quarter notes for this value
|
|
1163
|
+
candidates.push({ division, dots: 0, q: baseQ });
|
|
1164
|
+
candidates.push({ division, dots: 1, q: baseQ * 1.5 });
|
|
1165
|
+
}
|
|
1166
|
+
candidates.sort((a, b) => b.q - a.q);
|
|
1167
|
+
let guard = 0;
|
|
1168
|
+
while (remaining > 0.0001 && guard++ < 64) {
|
|
1169
|
+
const c = candidates.find(c => c.q * quarterTicks <= remaining + 0.0001);
|
|
1170
|
+
if (!c) break;
|
|
1171
|
+
rests.push({ type: 'rest', duration: { division: c.division, dots: c.dots }, invisible: true });
|
|
1172
|
+
remaining -= c.q * quarterTicks;
|
|
1173
|
+
}
|
|
1174
|
+
return rests;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
1179
|
+
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
1180
|
+
*/
|
|
1181
|
+
const tupletAdvanceDuration = (tupletEvent: TupletEvent, divisions: number): number => {
|
|
1182
|
+
let total = 0;
|
|
1183
|
+
for (const evt of tupletEvent.events) {
|
|
1184
|
+
const d = (evt as NoteEvent | RestEvent).duration;
|
|
1185
|
+
if (d) total += (4 / d.division) * divisions;
|
|
1186
|
+
}
|
|
1187
|
+
return total * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1020
1190
|
/**
|
|
1021
1191
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
1022
1192
|
*/
|
|
@@ -1030,14 +1200,33 @@ const convertMeasure = (
|
|
|
1030
1200
|
let key: KeySignature | undefined;
|
|
1031
1201
|
let timeSig: Fraction | undefined;
|
|
1032
1202
|
let barline: BarlineEvent | undefined;
|
|
1203
|
+
let leadingBarline: BarlineEvent | undefined;
|
|
1033
1204
|
const harmonies: HarmonyEvent[] = [];
|
|
1034
1205
|
const clefs: Map<number, ContextChange> = new Map();
|
|
1035
1206
|
|
|
1036
1207
|
// Pending marks from directions (to attach to next note), per voice
|
|
1037
1208
|
const pendingMarks: Map<number, Mark[]> = new Map();
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1209
|
+
// Accumulated <forward> ticks waiting for the next note, whose <voice> tells us
|
|
1210
|
+
// which voice the gap belongs to. Flushed as invisible rests before that note,
|
|
1211
|
+
// or onto currentVoice at a <backup>/measure end (a trailing gap in this voice).
|
|
1212
|
+
let pendingForward = 0;
|
|
1213
|
+
// Pending context changes (tempo, ottava) to insert before next note. Each may
|
|
1214
|
+
// carry a target staff: an ottava (<octave-shift>) start and its stop both name
|
|
1215
|
+
// the same <staff>, but in piano scores the stop direction often follows a
|
|
1216
|
+
// <backup> so the *next note* is on the other staff. Routing the change to a
|
|
1217
|
+
// note on its own staff keeps the 8va span's start and end in the same MEI
|
|
1218
|
+
// layer (otherwise the encoder can't pair them and drops the span).
|
|
1219
|
+
const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
|
|
1040
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();
|
|
1041
1230
|
|
|
1042
1231
|
// Process all children in order
|
|
1043
1232
|
for (const child of getChildElements(measureEl)) {
|
|
@@ -1069,7 +1258,30 @@ const convertMeasure = (
|
|
|
1069
1258
|
for (const clefEntry of attrs.clefs) {
|
|
1070
1259
|
const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
|
|
1071
1260
|
if (clef) {
|
|
1072
|
-
|
|
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
|
+
}
|
|
1073
1285
|
}
|
|
1074
1286
|
}
|
|
1075
1287
|
}
|
|
@@ -1078,28 +1290,55 @@ const convertMeasure = (
|
|
|
1078
1290
|
const voiceNum = note.voice;
|
|
1079
1291
|
const staffNum = note.staff || 1;
|
|
1080
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);
|
|
1081
1297
|
|
|
1082
1298
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
1083
1299
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
1084
1300
|
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
1085
1301
|
|
|
1302
|
+
// Flush an accumulated <forward> gap as invisible rests into THIS note's
|
|
1303
|
+
// voice (the forward had no voice of its own; it belongs to the voice that
|
|
1304
|
+
// follows). Skip while inside a tuplet — a forward there is unusual and the
|
|
1305
|
+
// tuplet tracker owns timing.
|
|
1306
|
+
if (pendingForward > 0 && !tupletTracker.isActive(voiceNum)) {
|
|
1307
|
+
for (const r of forwardGapToRests(pendingForward, voiceTracker.getDivisions())) {
|
|
1308
|
+
voiceTracker.addEvent(voiceNum, r, 0, staffNum);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
pendingForward = 0;
|
|
1312
|
+
|
|
1086
1313
|
// Check for tuplet start BEFORE processing the note
|
|
1087
1314
|
const tupletNotation = note.notations?.tuplet;
|
|
1088
1315
|
if (tupletNotation?.type === 'start') {
|
|
1089
|
-
tupletTracker.startTuplet(tupletNotation.number);
|
|
1316
|
+
tupletTracker.startTuplet(tupletNotation.number, voiceNum, staffNum);
|
|
1090
1317
|
}
|
|
1091
1318
|
|
|
1092
|
-
// Add any pending context changes before the note (tempo, ottava)
|
|
1319
|
+
// Add any pending context changes before the note (tempo, ottava).
|
|
1320
|
+
// A staff-tagged change (ottava) only flushes onto a note on the SAME
|
|
1321
|
+
// staff so its 8va span stays in one layer; others (tempo) flush anywhere.
|
|
1093
1322
|
if (pendingContextChanges.length > 0) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1323
|
+
const remaining: { ctx: ContextChange; staff?: number }[] = [];
|
|
1324
|
+
for (const pc of pendingContextChanges) {
|
|
1325
|
+
if (pc.staff === undefined || pc.staff === staffNum) {
|
|
1326
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, staffNum);
|
|
1327
|
+
} else {
|
|
1328
|
+
remaining.push(pc); // wait for a note on the matching staff
|
|
1329
|
+
}
|
|
1096
1330
|
}
|
|
1097
|
-
pendingContextChanges.length = 0;
|
|
1331
|
+
pendingContextChanges.length = 0;
|
|
1332
|
+
pendingContextChanges.push(...remaining);
|
|
1098
1333
|
}
|
|
1099
1334
|
|
|
1100
|
-
// Get pending marks for this voice
|
|
1101
|
-
|
|
1102
|
-
|
|
1335
|
+
// Get pending marks for this voice. Rests can't hold marks (RestEvent has
|
|
1336
|
+
// no `marks` field), so do NOT consume them on a rest — leave them queued
|
|
1337
|
+
// for the next real note or the end-of-measure flush. Otherwise a pedal/
|
|
1338
|
+
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1339
|
+
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1340
|
+
const marks: Mark[] = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1341
|
+
if (!note.isRest) pendingMarks.delete(voiceNum);
|
|
1103
1342
|
|
|
1104
1343
|
if (note.isRest) {
|
|
1105
1344
|
// Rest event
|
|
@@ -1116,12 +1355,26 @@ const convertMeasure = (
|
|
|
1116
1355
|
duration,
|
|
1117
1356
|
};
|
|
1118
1357
|
|
|
1358
|
+
// Whole-measure rest: mark it so encoders emit <mRest>/R and downstream
|
|
1359
|
+
// duration math uses the measure length, not the power-of-two rounding
|
|
1360
|
+
// of the bare <duration> (which over/under-fills non-2^n meters like 3/4).
|
|
1361
|
+
if (note.isMeasureRest) {
|
|
1362
|
+
restEvent.fullMeasure = true;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// A rest can host a fermata (grand pause / held silence). Convert it
|
|
1366
|
+
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1367
|
+
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1368
|
+
if (note.notations?.fermata) {
|
|
1369
|
+
restEvent.marks = [{ markType: 'ornament', type: 'fermata' as any }];
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1119
1372
|
// Grace notes don't advance time
|
|
1120
1373
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1121
1374
|
|
|
1122
1375
|
// Check if we're in a tuplet
|
|
1123
|
-
if (tupletTracker.isActive()) {
|
|
1124
|
-
tupletTracker.addEvent(restEvent);
|
|
1376
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1377
|
+
tupletTracker.addEvent(restEvent, voiceNum);
|
|
1125
1378
|
} else {
|
|
1126
1379
|
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
1127
1380
|
}
|
|
@@ -1133,9 +1386,11 @@ const convertMeasure = (
|
|
|
1133
1386
|
const notationMarks = notationsToMarks(note.notations, spannerTracker, [lilyletPitch]);
|
|
1134
1387
|
marks.push(...notationMarks);
|
|
1135
1388
|
|
|
1136
|
-
// Add
|
|
1137
|
-
if (note.
|
|
1138
|
-
|
|
1389
|
+
// Add fingerings (one per chord member; MEI emits a <fing> each)
|
|
1390
|
+
if (note.fingerings) {
|
|
1391
|
+
for (const finger of note.fingerings) {
|
|
1392
|
+
if (finger >= 0 && finger <= 9) marks.push({ markType: 'fingering', finger });
|
|
1393
|
+
}
|
|
1139
1394
|
}
|
|
1140
1395
|
|
|
1141
1396
|
// Handle chord: merge with previous note in same voice
|
|
@@ -1196,8 +1451,8 @@ const convertMeasure = (
|
|
|
1196
1451
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1197
1452
|
|
|
1198
1453
|
// Check if we're in a tuplet
|
|
1199
|
-
if (tupletTracker.isActive()) {
|
|
1200
|
-
tupletTracker.addEvent(noteEvent);
|
|
1454
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1455
|
+
tupletTracker.addEvent(noteEvent, voiceNum);
|
|
1201
1456
|
} else {
|
|
1202
1457
|
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
1203
1458
|
}
|
|
@@ -1207,16 +1462,7 @@ const convertMeasure = (
|
|
|
1207
1462
|
if (tupletNotation?.type === 'stop') {
|
|
1208
1463
|
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
1209
1464
|
if (tupletEvent) {
|
|
1210
|
-
|
|
1211
|
-
let totalDuration = 0;
|
|
1212
|
-
for (const evt of tupletEvent.events) {
|
|
1213
|
-
if ((evt as NoteEvent | RestEvent).duration) {
|
|
1214
|
-
// Convert division to duration units (quarter = 1)
|
|
1215
|
-
totalDuration += (4 / (evt as NoteEvent | RestEvent).duration.division) * voiceTracker.getDivisions();
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
// Apply tuplet ratio to get actual duration
|
|
1219
|
-
totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
1465
|
+
const totalDuration = tupletAdvanceDuration(tupletEvent, voiceTracker.getDivisions());
|
|
1220
1466
|
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
1221
1467
|
}
|
|
1222
1468
|
}
|
|
@@ -1226,7 +1472,9 @@ const convertMeasure = (
|
|
|
1226
1472
|
// Handle context changes (tempo, ottava)
|
|
1227
1473
|
const contextChange = directionToContextChange(direction, ottavaTracker);
|
|
1228
1474
|
if (contextChange) {
|
|
1229
|
-
|
|
1475
|
+
// Tag ottava changes with their staff so they reach the right layer.
|
|
1476
|
+
const staff = contextChange.ottava !== undefined ? direction.staff : undefined;
|
|
1477
|
+
pendingContextChanges.push({ ctx: contextChange, staff });
|
|
1230
1478
|
}
|
|
1231
1479
|
|
|
1232
1480
|
// Handle marks (dynamics, hairpins, etc.)
|
|
@@ -1237,16 +1485,29 @@ const convertMeasure = (
|
|
|
1237
1485
|
pendingMarks.set(currentVoice, [...existing, ...marks]);
|
|
1238
1486
|
}
|
|
1239
1487
|
} else if (tagName === 'backup') {
|
|
1488
|
+
// A <forward> with no note after it (before this backup) is cursor
|
|
1489
|
+
// positioning, not a content gap — drop it rather than materialise filler
|
|
1490
|
+
// (the common `backup N / forward N` measure-end idiom would otherwise
|
|
1491
|
+
// double the bar). Only note→forward→note gaps become invisible rests.
|
|
1492
|
+
pendingForward = 0;
|
|
1240
1493
|
const duration = getElementInt(child, 'duration') || 0;
|
|
1241
1494
|
voiceTracker.backup(duration);
|
|
1242
1495
|
} else if (tagName === 'forward') {
|
|
1243
1496
|
const duration = getElementInt(child, 'duration') || 0;
|
|
1244
1497
|
voiceTracker.forward(duration);
|
|
1498
|
+
pendingForward += duration; // materialised as invisible rests only if a note follows in-voice
|
|
1245
1499
|
} else if (tagName === 'barline') {
|
|
1246
1500
|
const barlineData = parseBarline(child);
|
|
1247
1501
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
1248
1502
|
if (style && style !== '|') {
|
|
1249
|
-
|
|
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
|
+
}
|
|
1250
1511
|
}
|
|
1251
1512
|
} else if (tagName === 'harmony') {
|
|
1252
1513
|
const harmonyData = parseHarmony(child);
|
|
@@ -1263,6 +1524,74 @@ const convertMeasure = (
|
|
|
1263
1524
|
}
|
|
1264
1525
|
}
|
|
1265
1526
|
|
|
1527
|
+
// A <forward> at the very end of the measure with no following note is cursor
|
|
1528
|
+
// positioning (e.g. the `backup N / forward N` idiom), not a content gap — drop
|
|
1529
|
+
// it. Only forwards consumed by a following note become invisible spacer rests.
|
|
1530
|
+
pendingForward = 0;
|
|
1531
|
+
|
|
1532
|
+
// Force-close any tuplet still open at the bar line. A tuplet is a within-measure
|
|
1533
|
+
// time modification and cannot span a bar; a source file missing its
|
|
1534
|
+
// <tuplet type="stop"> (corpus reality) would otherwise keep the tuplet open for
|
|
1535
|
+
// the rest of the piece, diverting every following note in that voice into the
|
|
1536
|
+
// zombie tuplet — the multi-voice block-drop bug (库劳 Op.20 m15→end empty).
|
|
1537
|
+
// Flush each leftover tuplet onto its owning voice so the damage is bounded to
|
|
1538
|
+
// this measure. Done before the pendingMarks flush so trailing marks still find
|
|
1539
|
+
// the (now-emitted) tuplet's notes as the voice's last events.
|
|
1540
|
+
for (const { event, voice, staff } of tupletTracker.flushAll()) {
|
|
1541
|
+
const totalDuration = tupletAdvanceDuration(event, voiceTracker.getDivisions());
|
|
1542
|
+
voiceTracker.addEvent(voice, event, totalDuration, staff);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Flush leftover pending marks. Post-positioned directions — hairpin/pedal
|
|
1546
|
+
// stops, and any direction after the last note of its voice (common after a
|
|
1547
|
+
// <backup> in piano scores) — never reached a following note in the loop above.
|
|
1548
|
+
// They belong on the note they trail, so attach them to the last NoteEvent of
|
|
1549
|
+
// the voice. pendingMarks is per-measure, so without this they would be lost
|
|
1550
|
+
// at the next measure (the pedal/hairpin "stop" loss). Rests carry no marks, so
|
|
1551
|
+
// search backward for the last actual note; fall back across voices if needed.
|
|
1552
|
+
if (pendingMarks.size > 0) {
|
|
1553
|
+
const allVoices = voiceTracker.getVoices();
|
|
1554
|
+
const findLastNote = (voiceNum: number): NoteEvent | undefined => {
|
|
1555
|
+
const vs = allVoices.get(voiceNum);
|
|
1556
|
+
if (!vs) return undefined;
|
|
1557
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1558
|
+
const ev = vs.events[i];
|
|
1559
|
+
if (ev.type === 'note') return ev as NoteEvent;
|
|
1560
|
+
}
|
|
1561
|
+
return undefined;
|
|
1562
|
+
};
|
|
1563
|
+
for (const [voiceNum, marks] of pendingMarks) {
|
|
1564
|
+
if (marks.length === 0) continue;
|
|
1565
|
+
let target = findLastNote(voiceNum);
|
|
1566
|
+
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1567
|
+
// last note of any voice so the marking is not silently dropped.
|
|
1568
|
+
if (!target) {
|
|
1569
|
+
for (const vn of allVoices.keys()) {
|
|
1570
|
+
target = findLastNote(vn);
|
|
1571
|
+
if (target) break;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (target) target.marks = [...(target.marks || []), ...marks];
|
|
1575
|
+
}
|
|
1576
|
+
pendingMarks.clear();
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Flush leftover staff-tagged context changes (ottava) whose matching-staff note
|
|
1580
|
+
// never appeared this measure: append to a voice on the target staff so the span
|
|
1581
|
+
// continues into / closes in the right layer rather than being dropped.
|
|
1582
|
+
if (pendingContextChanges.length > 0) {
|
|
1583
|
+
const voices = voiceTracker.getVoices();
|
|
1584
|
+
for (const pc of pendingContextChanges) {
|
|
1585
|
+
let voiceNum: number | undefined;
|
|
1586
|
+
for (const [vn, vs] of voices) {
|
|
1587
|
+
if (vs.staff === pc.staff) { voiceNum = vn; break; }
|
|
1588
|
+
}
|
|
1589
|
+
if (voiceNum === undefined) voiceNum = voices.keys().next().value;
|
|
1590
|
+
if (voiceNum !== undefined) voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff);
|
|
1591
|
+
}
|
|
1592
|
+
pendingContextChanges.length = 0;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1266
1595
|
// Build voice map from tracker
|
|
1267
1596
|
const voiceMap = new Map<number, { events: Event[]; staff: number }>();
|
|
1268
1597
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
@@ -1272,7 +1601,7 @@ const convertMeasure = (
|
|
|
1272
1601
|
});
|
|
1273
1602
|
}
|
|
1274
1603
|
|
|
1275
|
-
return { voiceMap, key, timeSig, barline, harmonies, clefs };
|
|
1604
|
+
return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
|
|
1276
1605
|
};
|
|
1277
1606
|
|
|
1278
1607
|
/**
|
|
@@ -1290,12 +1619,30 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1290
1619
|
let isFirstMeasure = true;
|
|
1291
1620
|
let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
|
|
1292
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();
|
|
1293
1628
|
|
|
1294
1629
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
1295
1630
|
|
|
1296
1631
|
for (const measureEl of measureEls) {
|
|
1297
1632
|
voiceTracker.reset();
|
|
1298
|
-
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
|
+
}
|
|
1299
1646
|
|
|
1300
1647
|
// Update running key/time
|
|
1301
1648
|
if (key) lastKey = key;
|
|
@@ -1332,6 +1679,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1332
1679
|
lastClefs.set(voiceData.staff, clef);
|
|
1333
1680
|
}
|
|
1334
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);
|
|
1335
1698
|
}
|
|
1336
1699
|
staffsWithClef.add(voiceData.staff);
|
|
1337
1700
|
}
|
|
@@ -1339,6 +1702,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1339
1702
|
// Add voice events
|
|
1340
1703
|
events.push(...voiceData.events);
|
|
1341
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
|
+
|
|
1342
1721
|
// Add harmonies and barline to first voice only
|
|
1343
1722
|
if (voiceNum === voiceNumbers[0]) {
|
|
1344
1723
|
for (const h of harmonies) {
|
|
@@ -1362,6 +1741,16 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1362
1741
|
lastVoiceStaff = voices[0].staff || 1;
|
|
1363
1742
|
}
|
|
1364
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
|
+
|
|
1365
1754
|
const measure: Measure = {
|
|
1366
1755
|
parts: [{
|
|
1367
1756
|
voices,
|
|
@@ -1384,7 +1773,37 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1384
1773
|
/**
|
|
1385
1774
|
* Decode MusicXML string to LilyletDoc
|
|
1386
1775
|
*/
|
|
1387
|
-
|
|
1776
|
+
/**
|
|
1777
|
+
* Decode raw MusicXML bytes (or a string) into a clean UTF-8/UTF-16-correct
|
|
1778
|
+
* JS string. MuseScore/Finale/Sibelius frequently export `.xml` as UTF-16 LE
|
|
1779
|
+
* with a BOM; reading those as UTF-8 yields mojibake and a failed parse.
|
|
1780
|
+
*
|
|
1781
|
+
* Detection order: byte-order mark → declared `encoding="..."` in the XML
|
|
1782
|
+
* prolog → default UTF-8. A leading BOM is always stripped (xmldom chokes on a
|
|
1783
|
+
* U+FEFF before `<?xml`).
|
|
1784
|
+
*/
|
|
1785
|
+
export const readXmlString = (input: string | Uint8Array): string => {
|
|
1786
|
+
if (typeof input === 'string')
|
|
1787
|
+
return input.charCodeAt(0) === 0xFEFF ? input.slice(1) : input;
|
|
1788
|
+
|
|
1789
|
+
const bytes = input;
|
|
1790
|
+
if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE)
|
|
1791
|
+
return new TextDecoder('utf-16le').decode(bytes.subarray(2));
|
|
1792
|
+
if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF)
|
|
1793
|
+
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
|
|
1794
|
+
if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF)
|
|
1795
|
+
return new TextDecoder('utf-8').decode(bytes.subarray(3));
|
|
1796
|
+
|
|
1797
|
+
// No BOM: peek the prolog (as latin1 so every byte maps 1:1) for a declared encoding.
|
|
1798
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 256));
|
|
1799
|
+
const enc = /encoding\s*=\s*['"]([^'"]+)['"]/i.exec(head)?.[1]?.toLowerCase();
|
|
1800
|
+
if (enc && /utf-?16/.test(enc))
|
|
1801
|
+
return new TextDecoder('utf-16le').decode(bytes); // BOM-less UTF-16 → assume LE (Windows)
|
|
1802
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
export const decode = (input: string | Uint8Array): LilyletDoc => {
|
|
1806
|
+
const xmlString = readXmlString(input);
|
|
1388
1807
|
const parser = new DOMParser();
|
|
1389
1808
|
const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
1390
1809
|
|
|
@@ -1466,6 +1885,14 @@ export const decode = (xmlString: string): LilyletDoc => {
|
|
|
1466
1885
|
measures: mergedMeasures,
|
|
1467
1886
|
};
|
|
1468
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
|
+
|
|
1469
1896
|
if (Object.keys(metadata).length > 0) {
|
|
1470
1897
|
result.metadata = metadata;
|
|
1471
1898
|
}
|
|
@@ -1478,8 +1905,8 @@ export const decode = (xmlString: string): LilyletDoc => {
|
|
|
1478
1905
|
*/
|
|
1479
1906
|
export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
|
|
1480
1907
|
const fs = await import('fs/promises');
|
|
1481
|
-
const
|
|
1482
|
-
return decode(
|
|
1908
|
+
const buf = await fs.readFile(filePath); // raw bytes; readXmlString sniffs the encoding
|
|
1909
|
+
return decode(buf);
|
|
1483
1910
|
};
|
|
1484
1911
|
|
|
1485
1912
|
export default {
|