@k-l-lambda/lilylet 0.1.73 → 0.1.74
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/meiEncoder.js +133 -47
- package/lib/lilylet/musicXmlDecoder.d.ts +12 -2
- package/lib/lilylet/musicXmlDecoder.js +327 -62
- package/lib/lilylet/musicXmlTypes.d.ts +2 -1
- package/lib/lilylet/types.d.ts +2 -0
- package/package.json +2 -1
- package/source/lilylet/meiEncoder.ts +132 -49
- package/source/lilylet/musicXmlDecoder.ts +326 -62
- package/source/lilylet/musicXmlTypes.ts +2 -1
- package/source/lilylet/types.ts +2 -0
|
@@ -5,7 +5,7 @@
|
|
|
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
10
|
// ============ Spanner Tracker ============
|
|
11
11
|
/**
|
|
@@ -66,33 +66,47 @@ class SpannerTracker {
|
|
|
66
66
|
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
67
67
|
*/
|
|
68
68
|
class TupletTracker {
|
|
69
|
-
// Map from tuplet number to collected events and ratio
|
|
69
|
+
// Map from tuplet number to collected events and ratio. Each tuplet is bound to
|
|
70
|
+
// the voice (and staff) it started in: a tuplet belongs to one voice, so events
|
|
71
|
+
// from OTHER voices must not be swallowed into it (multi-voice piano scores
|
|
72
|
+
// interleave voices, and a voice-1 tuplet would otherwise eat voice-2 notes).
|
|
70
73
|
activeTuplets = new Map();
|
|
71
74
|
/**
|
|
72
|
-
* Start a new tuplet group
|
|
75
|
+
* Start a new tuplet group, bound to the voice/staff it starts in.
|
|
73
76
|
*/
|
|
74
|
-
startTuplet(number = 1) {
|
|
75
|
-
this.activeTuplets.set(number, { events: [] });
|
|
77
|
+
startTuplet(number = 1, voice = 1, staff = 1) {
|
|
78
|
+
this.activeTuplets.set(number, { events: [], voice, staff });
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
|
-
* Add an event to active tuplet
|
|
79
|
-
* Returns true if the event was added
|
|
81
|
+
* Add an event to the innermost active tuplet of the SAME voice.
|
|
82
|
+
* Returns true if the event was added.
|
|
83
|
+
*
|
|
84
|
+
* Nested tuplets share the doc model's flat TupletEvent (which can't hold a
|
|
85
|
+
* nested TupletEvent), so an event must go to exactly ONE tuplet or it would be
|
|
86
|
+
* emitted twice — once per enclosing tuplet — inflating the pitch count. We pick
|
|
87
|
+
* the most-recently-started same-voice tuplet (the innermost): when the inner one
|
|
88
|
+
* closes, later events fall back to the still-open outer one.
|
|
80
89
|
*/
|
|
81
|
-
addEvent(event) {
|
|
90
|
+
addEvent(event, voice) {
|
|
82
91
|
if (this.activeTuplets.size === 0)
|
|
83
92
|
return false;
|
|
84
|
-
//
|
|
93
|
+
// Innermost = last-inserted entry for this voice (Map preserves insertion order).
|
|
94
|
+
let target;
|
|
85
95
|
for (const [, tuplet] of this.activeTuplets) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
if (tuplet.voice === voice)
|
|
97
|
+
target = tuplet;
|
|
98
|
+
}
|
|
99
|
+
if (!target)
|
|
100
|
+
return false;
|
|
101
|
+
// Set ratio from first event's duration.tuplet
|
|
102
|
+
// convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
|
|
103
|
+
if (!target.ratio && event.duration.tuplet) {
|
|
104
|
+
target.ratio = { ...event.duration.tuplet };
|
|
95
105
|
}
|
|
106
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
107
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
108
|
+
delete cleanEvent.duration.tuplet;
|
|
109
|
+
target.events.push(cleanEvent);
|
|
96
110
|
return true;
|
|
97
111
|
}
|
|
98
112
|
/**
|
|
@@ -114,10 +128,40 @@ class TupletTracker {
|
|
|
114
128
|
};
|
|
115
129
|
}
|
|
116
130
|
/**
|
|
117
|
-
* Check if
|
|
131
|
+
* Check if a tuplet is active for the given voice. A tuplet only swallows notes
|
|
132
|
+
* of its OWN voice, so the per-note "are we in a tuplet?" check must be scoped to
|
|
133
|
+
* the note's voice — otherwise a voice-1 tuplet would divert voice-2 notes.
|
|
134
|
+
*/
|
|
135
|
+
isActive(voice) {
|
|
136
|
+
if (voice === undefined)
|
|
137
|
+
return this.activeTuplets.size > 0;
|
|
138
|
+
for (const [, t] of this.activeTuplets)
|
|
139
|
+
if (t.voice === voice)
|
|
140
|
+
return true;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Force-close every still-open tuplet and return them with their owning
|
|
145
|
+
* voice/staff. Called at measure end: a tuplet is a within-measure time
|
|
146
|
+
* modification and MUST NOT leak across the bar line. A source file missing a
|
|
147
|
+
* <tuplet type="stop"> (corpus reality — e.g. 库劳 Op.20) would otherwise leave
|
|
148
|
+
* the tuplet open forever, swallowing every following note in that voice for the
|
|
149
|
+
* rest of the piece. Flushing here bounds the damage to the one measure.
|
|
118
150
|
*/
|
|
119
|
-
|
|
120
|
-
|
|
151
|
+
flushAll() {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
154
|
+
if (tuplet.events.length === 0)
|
|
155
|
+
continue;
|
|
156
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
157
|
+
out.push({
|
|
158
|
+
event: { type: 'tuplet', ratio, events: tuplet.events },
|
|
159
|
+
voice: tuplet.voice,
|
|
160
|
+
staff: tuplet.staff,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
this.activeTuplets.clear();
|
|
164
|
+
return out;
|
|
121
165
|
}
|
|
122
166
|
/**
|
|
123
167
|
* Reset tracker
|
|
@@ -290,6 +334,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
290
334
|
const isChord = hasElement(noteEl, 'chord');
|
|
291
335
|
const isRest = hasElement(noteEl, 'rest');
|
|
292
336
|
const isGrace = hasElement(noteEl, 'grace');
|
|
337
|
+
const restEl = isRest ? noteEl.getElementsByTagName('rest')[0] : undefined;
|
|
293
338
|
let pitch;
|
|
294
339
|
const pitchEl = noteEl.getElementsByTagName('pitch')[0];
|
|
295
340
|
if (pitchEl) {
|
|
@@ -299,6 +344,15 @@ const parseNote = (noteEl, divisions) => {
|
|
|
299
344
|
const durationVal = getElementInt(noteEl, 'duration') || 0;
|
|
300
345
|
const typeText = getElementText(noteEl, 'type');
|
|
301
346
|
const dotCount = getElements(noteEl, 'dot').length;
|
|
347
|
+
// Whole-measure rest detection. Two forms in the wild:
|
|
348
|
+
// (a) <rest measure="yes"> — explicit.
|
|
349
|
+
// (b) a `type="whole"` rest whose <duration> is NOT a whole note (e.g. 72 ticks
|
|
350
|
+
// in 3/4 at divisions=24) — the conventional "centred whole rest = whole
|
|
351
|
+
// bar" notation. In both cases the rest fills the measure, so flag it and
|
|
352
|
+
// let encoders emit <mRest>/R instead of rounding the bare duration to a
|
|
353
|
+
// power-of-two division (which over/under-fills non-2^n meters).
|
|
354
|
+
const isMeasureRest = !!restEl && (getAttribute(restEl, 'measure') === 'yes' ||
|
|
355
|
+
(typeText === 'whole' && durationVal > 0 && durationVal !== divisions * 4));
|
|
302
356
|
// Time modification (tuplets)
|
|
303
357
|
let timeModification;
|
|
304
358
|
const timeModEl = noteEl.getElementsByTagName('time-modification')[0];
|
|
@@ -322,14 +376,16 @@ const parseNote = (noteEl, divisions) => {
|
|
|
322
376
|
if (notationsEl) {
|
|
323
377
|
notations = parseNotations(notationsEl);
|
|
324
378
|
}
|
|
325
|
-
// Fingering
|
|
326
|
-
let
|
|
379
|
+
// Fingering — a note may carry several <fingering> (one per chord member).
|
|
380
|
+
let fingerings;
|
|
327
381
|
const technicalEl = noteEl.getElementsByTagName('technical')[0];
|
|
328
382
|
if (technicalEl) {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
383
|
+
const fingeringEls = getElements(technicalEl, 'fingering');
|
|
384
|
+
const parsed = fingeringEls
|
|
385
|
+
.map(el => parseInt(el.textContent?.trim() || '', 10))
|
|
386
|
+
.filter(n => Number.isFinite(n));
|
|
387
|
+
if (parsed.length > 0)
|
|
388
|
+
fingerings = parsed;
|
|
333
389
|
}
|
|
334
390
|
// Beams - direct children of note, not in notations
|
|
335
391
|
// We only care about primary beam (number="1") for begin/end
|
|
@@ -345,6 +401,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
345
401
|
isChord,
|
|
346
402
|
isRest,
|
|
347
403
|
isGrace,
|
|
404
|
+
isMeasureRest,
|
|
348
405
|
pitch,
|
|
349
406
|
duration: {
|
|
350
407
|
divisions: durationVal,
|
|
@@ -356,7 +413,7 @@ const parseNote = (noteEl, divisions) => {
|
|
|
356
413
|
staff,
|
|
357
414
|
stem: stem,
|
|
358
415
|
notations,
|
|
359
|
-
|
|
416
|
+
fingerings,
|
|
360
417
|
beams,
|
|
361
418
|
};
|
|
362
419
|
};
|
|
@@ -613,13 +670,14 @@ const notationsToMarks = (notations, spannerTracker, pitches) => {
|
|
|
613
670
|
if (notations.slurs) {
|
|
614
671
|
for (const slur of notations.slurs) {
|
|
615
672
|
if (slur.type === 'start') {
|
|
616
|
-
marks.push({ markType: 'slur', start: true });
|
|
673
|
+
marks.push({ markType: 'slur', start: true, number: slur.number });
|
|
617
674
|
spannerTracker.startSlur(slur.number);
|
|
618
675
|
}
|
|
619
676
|
else if (slur.type === 'stop') {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
677
|
+
// Carry the MusicXML number so the encoder can pair cross-voice slurs
|
|
678
|
+
// (start in one voice, stop in another — common in piano scores).
|
|
679
|
+
spannerTracker.stopSlur(slur.number);
|
|
680
|
+
marks.push({ markType: 'slur', start: false, number: slur.number });
|
|
623
681
|
}
|
|
624
682
|
}
|
|
625
683
|
}
|
|
@@ -802,8 +860,67 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
802
860
|
if (direction.segno) {
|
|
803
861
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
804
862
|
}
|
|
863
|
+
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
864
|
+
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
865
|
+
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
866
|
+
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
867
|
+
// directions are tempo too, never markup.
|
|
868
|
+
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
869
|
+
const text = direction.words.map(w => w.text).join('').trim();
|
|
870
|
+
if (text && !isTempoWord(text)) {
|
|
871
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
872
|
+
: direction.placement === 'below' ? Placement.below
|
|
873
|
+
: undefined;
|
|
874
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
805
877
|
return marks;
|
|
806
878
|
};
|
|
879
|
+
/**
|
|
880
|
+
* Decompose a tick gap (from <forward>) into invisible spacer rests.
|
|
881
|
+
*
|
|
882
|
+
* lilylet's doc model is a flat per-voice event sequence with no absolute tick
|
|
883
|
+
* anchor (currentPosition is unused for placement), so a <forward> that skips
|
|
884
|
+
* time inside a voice must be materialised as filler or the following notes slide
|
|
885
|
+
* earlier and the bar decodes short. Invisible rests (`s` / MEI <space>) are the
|
|
886
|
+
* right carrier. The gap may not be a single note value (e.g. 1.5 quarters), so
|
|
887
|
+
* emit a greedy sequence of power-of-two (optionally dotted) spacers.
|
|
888
|
+
*/
|
|
889
|
+
const forwardGapToRests = (gapTicks, divisions) => {
|
|
890
|
+
const rests = [];
|
|
891
|
+
let remaining = gapTicks;
|
|
892
|
+
const quarterTicks = divisions; // ticks per quarter note
|
|
893
|
+
// Largest representable spacer first; division 1=whole..128. dotted adds half.
|
|
894
|
+
const candidates = [];
|
|
895
|
+
for (const division of [1, 2, 4, 8, 16, 32, 64, 128]) {
|
|
896
|
+
const baseQ = 4 / division; // quarter notes for this value
|
|
897
|
+
candidates.push({ division, dots: 0, q: baseQ });
|
|
898
|
+
candidates.push({ division, dots: 1, q: baseQ * 1.5 });
|
|
899
|
+
}
|
|
900
|
+
candidates.sort((a, b) => b.q - a.q);
|
|
901
|
+
let guard = 0;
|
|
902
|
+
while (remaining > 0.0001 && guard++ < 64) {
|
|
903
|
+
const c = candidates.find(c => c.q * quarterTicks <= remaining + 0.0001);
|
|
904
|
+
if (!c)
|
|
905
|
+
break;
|
|
906
|
+
rests.push({ type: 'rest', duration: { division: c.division, dots: c.dots }, invisible: true });
|
|
907
|
+
remaining -= c.q * quarterTicks;
|
|
908
|
+
}
|
|
909
|
+
return rests;
|
|
910
|
+
};
|
|
911
|
+
/**
|
|
912
|
+
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
913
|
+
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
914
|
+
*/
|
|
915
|
+
const tupletAdvanceDuration = (tupletEvent, divisions) => {
|
|
916
|
+
let total = 0;
|
|
917
|
+
for (const evt of tupletEvent.events) {
|
|
918
|
+
const d = evt.duration;
|
|
919
|
+
if (d)
|
|
920
|
+
total += (4 / d.division) * divisions;
|
|
921
|
+
}
|
|
922
|
+
return total * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
923
|
+
};
|
|
807
924
|
/**
|
|
808
925
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
809
926
|
*/
|
|
@@ -815,7 +932,16 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
815
932
|
const clefs = new Map();
|
|
816
933
|
// Pending marks from directions (to attach to next note), per voice
|
|
817
934
|
const pendingMarks = new Map();
|
|
818
|
-
//
|
|
935
|
+
// Accumulated <forward> ticks waiting for the next note, whose <voice> tells us
|
|
936
|
+
// which voice the gap belongs to. Flushed as invisible rests before that note,
|
|
937
|
+
// or onto currentVoice at a <backup>/measure end (a trailing gap in this voice).
|
|
938
|
+
let pendingForward = 0;
|
|
939
|
+
// Pending context changes (tempo, ottava) to insert before next note. Each may
|
|
940
|
+
// carry a target staff: an ottava (<octave-shift>) start and its stop both name
|
|
941
|
+
// the same <staff>, but in piano scores the stop direction often follows a
|
|
942
|
+
// <backup> so the *next note* is on the other staff. Routing the change to a
|
|
943
|
+
// note on its own staff keeps the 8va span's start and end in the same MEI
|
|
944
|
+
// layer (otherwise the encoder can't pair them and drops the span).
|
|
819
945
|
const pendingContextChanges = [];
|
|
820
946
|
let currentVoice = 1; // Track current voice for directions
|
|
821
947
|
// Process all children in order
|
|
@@ -855,21 +981,45 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
855
981
|
// Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
|
|
856
982
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
857
983
|
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
984
|
+
// Flush an accumulated <forward> gap as invisible rests into THIS note's
|
|
985
|
+
// voice (the forward had no voice of its own; it belongs to the voice that
|
|
986
|
+
// follows). Skip while inside a tuplet — a forward there is unusual and the
|
|
987
|
+
// tuplet tracker owns timing.
|
|
988
|
+
if (pendingForward > 0 && !tupletTracker.isActive(voiceNum)) {
|
|
989
|
+
for (const r of forwardGapToRests(pendingForward, voiceTracker.getDivisions())) {
|
|
990
|
+
voiceTracker.addEvent(voiceNum, r, 0, staffNum);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
pendingForward = 0;
|
|
858
994
|
// Check for tuplet start BEFORE processing the note
|
|
859
995
|
const tupletNotation = note.notations?.tuplet;
|
|
860
996
|
if (tupletNotation?.type === 'start') {
|
|
861
|
-
tupletTracker.startTuplet(tupletNotation.number);
|
|
997
|
+
tupletTracker.startTuplet(tupletNotation.number, voiceNum, staffNum);
|
|
862
998
|
}
|
|
863
|
-
// Add any pending context changes before the note (tempo, ottava)
|
|
999
|
+
// Add any pending context changes before the note (tempo, ottava).
|
|
1000
|
+
// A staff-tagged change (ottava) only flushes onto a note on the SAME
|
|
1001
|
+
// staff so its 8va span stays in one layer; others (tempo) flush anywhere.
|
|
864
1002
|
if (pendingContextChanges.length > 0) {
|
|
865
|
-
|
|
866
|
-
|
|
1003
|
+
const remaining = [];
|
|
1004
|
+
for (const pc of pendingContextChanges) {
|
|
1005
|
+
if (pc.staff === undefined || pc.staff === staffNum) {
|
|
1006
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, staffNum);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
remaining.push(pc); // wait for a note on the matching staff
|
|
1010
|
+
}
|
|
867
1011
|
}
|
|
868
|
-
pendingContextChanges.length = 0;
|
|
1012
|
+
pendingContextChanges.length = 0;
|
|
1013
|
+
pendingContextChanges.push(...remaining);
|
|
869
1014
|
}
|
|
870
|
-
// Get pending marks for this voice
|
|
871
|
-
|
|
872
|
-
|
|
1015
|
+
// Get pending marks for this voice. Rests can't hold marks (RestEvent has
|
|
1016
|
+
// no `marks` field), so do NOT consume them on a rest — leave them queued
|
|
1017
|
+
// for the next real note or the end-of-measure flush. Otherwise a pedal/
|
|
1018
|
+
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1019
|
+
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1020
|
+
const marks = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1021
|
+
if (!note.isRest)
|
|
1022
|
+
pendingMarks.delete(voiceNum);
|
|
873
1023
|
if (note.isRest) {
|
|
874
1024
|
// Rest event
|
|
875
1025
|
const duration = convertDuration(voiceTracker.getDivisions(), note.duration.divisions, note.duration.type, note.duration.dots, note.duration.timeModification);
|
|
@@ -877,11 +1027,23 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
877
1027
|
type: 'rest',
|
|
878
1028
|
duration,
|
|
879
1029
|
};
|
|
1030
|
+
// Whole-measure rest: mark it so encoders emit <mRest>/R and downstream
|
|
1031
|
+
// duration math uses the measure length, not the power-of-two rounding
|
|
1032
|
+
// of the bare <duration> (which over/under-fills non-2^n meters like 3/4).
|
|
1033
|
+
if (note.isMeasureRest) {
|
|
1034
|
+
restEvent.fullMeasure = true;
|
|
1035
|
+
}
|
|
1036
|
+
// A rest can host a fermata (grand pause / held silence). Convert it
|
|
1037
|
+
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1038
|
+
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1039
|
+
if (note.notations?.fermata) {
|
|
1040
|
+
restEvent.marks = [{ markType: 'ornament', type: 'fermata' }];
|
|
1041
|
+
}
|
|
880
1042
|
// Grace notes don't advance time
|
|
881
1043
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
882
1044
|
// Check if we're in a tuplet
|
|
883
|
-
if (tupletTracker.isActive()) {
|
|
884
|
-
tupletTracker.addEvent(restEvent);
|
|
1045
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1046
|
+
tupletTracker.addEvent(restEvent, voiceNum);
|
|
885
1047
|
}
|
|
886
1048
|
else {
|
|
887
1049
|
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
@@ -893,9 +1055,12 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
893
1055
|
// Get marks from notations
|
|
894
1056
|
const notationMarks = notationsToMarks(note.notations, spannerTracker, [lilyletPitch]);
|
|
895
1057
|
marks.push(...notationMarks);
|
|
896
|
-
// Add
|
|
897
|
-
if (note.
|
|
898
|
-
|
|
1058
|
+
// Add fingerings (one per chord member; MEI emits a <fing> each)
|
|
1059
|
+
if (note.fingerings) {
|
|
1060
|
+
for (const finger of note.fingerings) {
|
|
1061
|
+
if (finger >= 0 && finger <= 9)
|
|
1062
|
+
marks.push({ markType: 'fingering', finger });
|
|
1063
|
+
}
|
|
899
1064
|
}
|
|
900
1065
|
// Handle chord: merge with previous note in same voice
|
|
901
1066
|
if (note.isChord) {
|
|
@@ -943,8 +1108,8 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
943
1108
|
// Grace notes don't advance time
|
|
944
1109
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
945
1110
|
// Check if we're in a tuplet
|
|
946
|
-
if (tupletTracker.isActive()) {
|
|
947
|
-
tupletTracker.addEvent(noteEvent);
|
|
1111
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1112
|
+
tupletTracker.addEvent(noteEvent, voiceNum);
|
|
948
1113
|
}
|
|
949
1114
|
else {
|
|
950
1115
|
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
@@ -954,16 +1119,7 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
954
1119
|
if (tupletNotation?.type === 'stop') {
|
|
955
1120
|
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
956
1121
|
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;
|
|
1122
|
+
const totalDuration = tupletAdvanceDuration(tupletEvent, voiceTracker.getDivisions());
|
|
967
1123
|
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
968
1124
|
}
|
|
969
1125
|
}
|
|
@@ -973,7 +1129,9 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
973
1129
|
// Handle context changes (tempo, ottava)
|
|
974
1130
|
const contextChange = directionToContextChange(direction, ottavaTracker);
|
|
975
1131
|
if (contextChange) {
|
|
976
|
-
|
|
1132
|
+
// Tag ottava changes with their staff so they reach the right layer.
|
|
1133
|
+
const staff = contextChange.ottava !== undefined ? direction.staff : undefined;
|
|
1134
|
+
pendingContextChanges.push({ ctx: contextChange, staff });
|
|
977
1135
|
}
|
|
978
1136
|
// Handle marks (dynamics, hairpins, etc.)
|
|
979
1137
|
const marks = directionToMarks(direction, spannerTracker);
|
|
@@ -984,12 +1142,18 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
984
1142
|
}
|
|
985
1143
|
}
|
|
986
1144
|
else if (tagName === 'backup') {
|
|
1145
|
+
// A <forward> with no note after it (before this backup) is cursor
|
|
1146
|
+
// positioning, not a content gap — drop it rather than materialise filler
|
|
1147
|
+
// (the common `backup N / forward N` measure-end idiom would otherwise
|
|
1148
|
+
// double the bar). Only note→forward→note gaps become invisible rests.
|
|
1149
|
+
pendingForward = 0;
|
|
987
1150
|
const duration = getElementInt(child, 'duration') || 0;
|
|
988
1151
|
voiceTracker.backup(duration);
|
|
989
1152
|
}
|
|
990
1153
|
else if (tagName === 'forward') {
|
|
991
1154
|
const duration = getElementInt(child, 'duration') || 0;
|
|
992
1155
|
voiceTracker.forward(duration);
|
|
1156
|
+
pendingForward += duration; // materialised as invisible rests only if a note follows in-voice
|
|
993
1157
|
}
|
|
994
1158
|
else if (tagName === 'barline') {
|
|
995
1159
|
const barlineData = parseBarline(child);
|
|
@@ -1006,6 +1170,80 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker,
|
|
|
1006
1170
|
}
|
|
1007
1171
|
}
|
|
1008
1172
|
}
|
|
1173
|
+
// A <forward> at the very end of the measure with no following note is cursor
|
|
1174
|
+
// positioning (e.g. the `backup N / forward N` idiom), not a content gap — drop
|
|
1175
|
+
// it. Only forwards consumed by a following note become invisible spacer rests.
|
|
1176
|
+
pendingForward = 0;
|
|
1177
|
+
// Force-close any tuplet still open at the bar line. A tuplet is a within-measure
|
|
1178
|
+
// time modification and cannot span a bar; a source file missing its
|
|
1179
|
+
// <tuplet type="stop"> (corpus reality) would otherwise keep the tuplet open for
|
|
1180
|
+
// the rest of the piece, diverting every following note in that voice into the
|
|
1181
|
+
// zombie tuplet — the multi-voice block-drop bug (库劳 Op.20 m15→end empty).
|
|
1182
|
+
// Flush each leftover tuplet onto its owning voice so the damage is bounded to
|
|
1183
|
+
// this measure. Done before the pendingMarks flush so trailing marks still find
|
|
1184
|
+
// the (now-emitted) tuplet's notes as the voice's last events.
|
|
1185
|
+
for (const { event, voice, staff } of tupletTracker.flushAll()) {
|
|
1186
|
+
const totalDuration = tupletAdvanceDuration(event, voiceTracker.getDivisions());
|
|
1187
|
+
voiceTracker.addEvent(voice, event, totalDuration, staff);
|
|
1188
|
+
}
|
|
1189
|
+
// Flush leftover pending marks. Post-positioned directions — hairpin/pedal
|
|
1190
|
+
// stops, and any direction after the last note of its voice (common after a
|
|
1191
|
+
// <backup> in piano scores) — never reached a following note in the loop above.
|
|
1192
|
+
// They belong on the note they trail, so attach them to the last NoteEvent of
|
|
1193
|
+
// the voice. pendingMarks is per-measure, so without this they would be lost
|
|
1194
|
+
// at the next measure (the pedal/hairpin "stop" loss). Rests carry no marks, so
|
|
1195
|
+
// search backward for the last actual note; fall back across voices if needed.
|
|
1196
|
+
if (pendingMarks.size > 0) {
|
|
1197
|
+
const allVoices = voiceTracker.getVoices();
|
|
1198
|
+
const findLastNote = (voiceNum) => {
|
|
1199
|
+
const vs = allVoices.get(voiceNum);
|
|
1200
|
+
if (!vs)
|
|
1201
|
+
return undefined;
|
|
1202
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1203
|
+
const ev = vs.events[i];
|
|
1204
|
+
if (ev.type === 'note')
|
|
1205
|
+
return ev;
|
|
1206
|
+
}
|
|
1207
|
+
return undefined;
|
|
1208
|
+
};
|
|
1209
|
+
for (const [voiceNum, marks] of pendingMarks) {
|
|
1210
|
+
if (marks.length === 0)
|
|
1211
|
+
continue;
|
|
1212
|
+
let target = findLastNote(voiceNum);
|
|
1213
|
+
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1214
|
+
// last note of any voice so the marking is not silently dropped.
|
|
1215
|
+
if (!target) {
|
|
1216
|
+
for (const vn of allVoices.keys()) {
|
|
1217
|
+
target = findLastNote(vn);
|
|
1218
|
+
if (target)
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (target)
|
|
1223
|
+
target.marks = [...(target.marks || []), ...marks];
|
|
1224
|
+
}
|
|
1225
|
+
pendingMarks.clear();
|
|
1226
|
+
}
|
|
1227
|
+
// Flush leftover staff-tagged context changes (ottava) whose matching-staff note
|
|
1228
|
+
// never appeared this measure: append to a voice on the target staff so the span
|
|
1229
|
+
// continues into / closes in the right layer rather than being dropped.
|
|
1230
|
+
if (pendingContextChanges.length > 0) {
|
|
1231
|
+
const voices = voiceTracker.getVoices();
|
|
1232
|
+
for (const pc of pendingContextChanges) {
|
|
1233
|
+
let voiceNum;
|
|
1234
|
+
for (const [vn, vs] of voices) {
|
|
1235
|
+
if (vs.staff === pc.staff) {
|
|
1236
|
+
voiceNum = vn;
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (voiceNum === undefined)
|
|
1241
|
+
voiceNum = voices.keys().next().value;
|
|
1242
|
+
if (voiceNum !== undefined)
|
|
1243
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff);
|
|
1244
|
+
}
|
|
1245
|
+
pendingContextChanges.length = 0;
|
|
1246
|
+
}
|
|
1009
1247
|
// Build voice map from tracker
|
|
1010
1248
|
const voiceMap = new Map();
|
|
1011
1249
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
@@ -1113,7 +1351,34 @@ const convertPart = (partEl) => {
|
|
|
1113
1351
|
/**
|
|
1114
1352
|
* Decode MusicXML string to LilyletDoc
|
|
1115
1353
|
*/
|
|
1116
|
-
|
|
1354
|
+
/**
|
|
1355
|
+
* Decode raw MusicXML bytes (or a string) into a clean UTF-8/UTF-16-correct
|
|
1356
|
+
* JS string. MuseScore/Finale/Sibelius frequently export `.xml` as UTF-16 LE
|
|
1357
|
+
* with a BOM; reading those as UTF-8 yields mojibake and a failed parse.
|
|
1358
|
+
*
|
|
1359
|
+
* Detection order: byte-order mark → declared `encoding="..."` in the XML
|
|
1360
|
+
* prolog → default UTF-8. A leading BOM is always stripped (xmldom chokes on a
|
|
1361
|
+
* U+FEFF before `<?xml`).
|
|
1362
|
+
*/
|
|
1363
|
+
export const readXmlString = (input) => {
|
|
1364
|
+
if (typeof input === 'string')
|
|
1365
|
+
return input.charCodeAt(0) === 0xFEFF ? input.slice(1) : input;
|
|
1366
|
+
const bytes = input;
|
|
1367
|
+
if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE)
|
|
1368
|
+
return new TextDecoder('utf-16le').decode(bytes.subarray(2));
|
|
1369
|
+
if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF)
|
|
1370
|
+
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
|
|
1371
|
+
if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF)
|
|
1372
|
+
return new TextDecoder('utf-8').decode(bytes.subarray(3));
|
|
1373
|
+
// No BOM: peek the prolog (as latin1 so every byte maps 1:1) for a declared encoding.
|
|
1374
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 256));
|
|
1375
|
+
const enc = /encoding\s*=\s*['"]([^'"]+)['"]/i.exec(head)?.[1]?.toLowerCase();
|
|
1376
|
+
if (enc && /utf-?16/.test(enc))
|
|
1377
|
+
return new TextDecoder('utf-16le').decode(bytes); // BOM-less UTF-16 → assume LE (Windows)
|
|
1378
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1379
|
+
};
|
|
1380
|
+
export const decode = (input) => {
|
|
1381
|
+
const xmlString = readXmlString(input);
|
|
1117
1382
|
const parser = new DOMParser();
|
|
1118
1383
|
const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
1119
1384
|
// Check for parsing errors
|
|
@@ -1195,8 +1460,8 @@ export const decode = (xmlString) => {
|
|
|
1195
1460
|
*/
|
|
1196
1461
|
export const decodeFile = async (filePath) => {
|
|
1197
1462
|
const fs = await import('fs/promises');
|
|
1198
|
-
const
|
|
1199
|
-
return decode(
|
|
1463
|
+
const buf = await fs.readFile(filePath); // raw bytes; readXmlString sniffs the encoding
|
|
1464
|
+
return decode(buf);
|
|
1200
1465
|
};
|
|
1201
1466
|
export default {
|
|
1202
1467
|
decode,
|
|
@@ -71,13 +71,14 @@ export interface MusicXmlNote {
|
|
|
71
71
|
isChord: boolean;
|
|
72
72
|
isRest: boolean;
|
|
73
73
|
isGrace: boolean;
|
|
74
|
+
isMeasureRest?: boolean;
|
|
74
75
|
pitch?: MusicXmlPitch;
|
|
75
76
|
duration: MusicXmlDuration;
|
|
76
77
|
voice: number;
|
|
77
78
|
staff?: number;
|
|
78
79
|
stem?: MusicXmlStemDirection;
|
|
79
80
|
notations?: MusicXmlNotations;
|
|
80
|
-
|
|
81
|
+
fingerings?: number[];
|
|
81
82
|
beams?: {
|
|
82
83
|
type: 'begin' | 'continue' | 'end';
|
|
83
84
|
number: number;
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ export interface Tie {
|
|
|
126
126
|
export interface Slur {
|
|
127
127
|
markType: 'slur';
|
|
128
128
|
start: boolean;
|
|
129
|
+
number?: number;
|
|
129
130
|
}
|
|
130
131
|
export interface Beam {
|
|
131
132
|
markType: 'beam';
|
|
@@ -176,6 +177,7 @@ export interface RestEvent {
|
|
|
176
177
|
invisible?: boolean;
|
|
177
178
|
fullMeasure?: boolean;
|
|
178
179
|
pitch?: Pitch;
|
|
180
|
+
marks?: Mark[];
|
|
179
181
|
}
|
|
180
182
|
export interface ContextChange {
|
|
181
183
|
type: 'context';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.74",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"prepublishOnly": "npm run build:grammar && npm run build",
|
|
35
35
|
"test": "tsx ./tests/parser.ts",
|
|
36
36
|
"test:mei": "tsx ./tests/mei.ts",
|
|
37
|
+
"test:mei-diff": "tsx ./tests/musicxml-mei-diff.ts",
|
|
37
38
|
"test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
|
|
38
39
|
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
39
40
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|