@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
|
@@ -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,
|
|
@@ -141,38 +142,53 @@ class SpannerTracker {
|
|
|
141
142
|
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
142
143
|
*/
|
|
143
144
|
class TupletTracker {
|
|
144
|
-
// Map from tuplet number to collected events and ratio
|
|
145
|
+
// Map from tuplet number to collected events and ratio. Each tuplet is bound to
|
|
146
|
+
// the voice (and staff) it started in: a tuplet belongs to one voice, so events
|
|
147
|
+
// from OTHER voices must not be swallowed into it (multi-voice piano scores
|
|
148
|
+
// interleave voices, and a voice-1 tuplet would otherwise eat voice-2 notes).
|
|
145
149
|
private activeTuplets: Map<number, {
|
|
146
150
|
events: (NoteEvent | RestEvent)[];
|
|
147
151
|
ratio?: Fraction;
|
|
152
|
+
voice: number;
|
|
153
|
+
staff: number;
|
|
148
154
|
}> = new Map();
|
|
149
155
|
|
|
150
156
|
/**
|
|
151
|
-
* Start a new tuplet group
|
|
157
|
+
* Start a new tuplet group, bound to the voice/staff it starts in.
|
|
152
158
|
*/
|
|
153
|
-
startTuplet(number: number = 1): void {
|
|
154
|
-
this.activeTuplets.set(number, { events: [] });
|
|
159
|
+
startTuplet(number: number = 1, voice: number = 1, staff: number = 1): void {
|
|
160
|
+
this.activeTuplets.set(number, { events: [], voice, staff });
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
/**
|
|
158
|
-
* Add an event to active tuplet
|
|
159
|
-
* Returns true if the event was added
|
|
164
|
+
* Add an event to the innermost active tuplet of the SAME voice.
|
|
165
|
+
* Returns true if the event was added.
|
|
166
|
+
*
|
|
167
|
+
* Nested tuplets share the doc model's flat TupletEvent (which can't hold a
|
|
168
|
+
* nested TupletEvent), so an event must go to exactly ONE tuplet or it would be
|
|
169
|
+
* emitted twice — once per enclosing tuplet — inflating the pitch count. We pick
|
|
170
|
+
* the most-recently-started same-voice tuplet (the innermost): when the inner one
|
|
171
|
+
* closes, later events fall back to the still-open outer one.
|
|
160
172
|
*/
|
|
161
|
-
addEvent(event: NoteEvent | RestEvent): boolean {
|
|
173
|
+
addEvent(event: NoteEvent | RestEvent, voice: number): boolean {
|
|
162
174
|
if (this.activeTuplets.size === 0) return false;
|
|
163
175
|
|
|
164
|
-
//
|
|
176
|
+
// Innermost = last-inserted entry for this voice (Map preserves insertion order).
|
|
177
|
+
let target: { events: (NoteEvent | RestEvent)[]; ratio?: Fraction; voice: number; staff: number } | undefined;
|
|
165
178
|
for (const [, tuplet] of this.activeTuplets) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
tuplet.events.push(cleanEvent);
|
|
179
|
+
if (tuplet.voice === voice) target = tuplet;
|
|
180
|
+
}
|
|
181
|
+
if (!target) return false;
|
|
182
|
+
|
|
183
|
+
// Set ratio from first event's duration.tuplet
|
|
184
|
+
// convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
|
|
185
|
+
if (!target.ratio && event.duration.tuplet) {
|
|
186
|
+
target.ratio = { ...event.duration.tuplet };
|
|
175
187
|
}
|
|
188
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
189
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
190
|
+
delete cleanEvent.duration.tuplet;
|
|
191
|
+
target.events.push(cleanEvent);
|
|
176
192
|
return true;
|
|
177
193
|
}
|
|
178
194
|
|
|
@@ -199,10 +215,37 @@ class TupletTracker {
|
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
/**
|
|
202
|
-
* Check if
|
|
218
|
+
* Check if a tuplet is active for the given voice. A tuplet only swallows notes
|
|
219
|
+
* of its OWN voice, so the per-note "are we in a tuplet?" check must be scoped to
|
|
220
|
+
* the note's voice — otherwise a voice-1 tuplet would divert voice-2 notes.
|
|
221
|
+
*/
|
|
222
|
+
isActive(voice?: number): boolean {
|
|
223
|
+
if (voice === undefined) return this.activeTuplets.size > 0;
|
|
224
|
+
for (const [, t] of this.activeTuplets) if (t.voice === voice) return true;
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Force-close every still-open tuplet and return them with their owning
|
|
230
|
+
* voice/staff. Called at measure end: a tuplet is a within-measure time
|
|
231
|
+
* modification and MUST NOT leak across the bar line. A source file missing a
|
|
232
|
+
* <tuplet type="stop"> (corpus reality — e.g. 库劳 Op.20) would otherwise leave
|
|
233
|
+
* the tuplet open forever, swallowing every following note in that voice for the
|
|
234
|
+
* rest of the piece. Flushing here bounds the damage to the one measure.
|
|
203
235
|
*/
|
|
204
|
-
|
|
205
|
-
|
|
236
|
+
flushAll(): Array<{ event: TupletEvent; voice: number; staff: number }> {
|
|
237
|
+
const out: Array<{ event: TupletEvent; voice: number; staff: number }> = [];
|
|
238
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
239
|
+
if (tuplet.events.length === 0) continue;
|
|
240
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
241
|
+
out.push({
|
|
242
|
+
event: { type: 'tuplet', ratio, events: tuplet.events },
|
|
243
|
+
voice: tuplet.voice,
|
|
244
|
+
staff: tuplet.staff,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
this.activeTuplets.clear();
|
|
248
|
+
return out;
|
|
206
249
|
}
|
|
207
250
|
|
|
208
251
|
/**
|
|
@@ -423,6 +466,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
423
466
|
const isChord = hasElement(noteEl, 'chord');
|
|
424
467
|
const isRest = hasElement(noteEl, 'rest');
|
|
425
468
|
const isGrace = hasElement(noteEl, 'grace');
|
|
469
|
+
const restEl = isRest ? noteEl.getElementsByTagName('rest')[0] : undefined;
|
|
426
470
|
|
|
427
471
|
let pitch: MusicXmlPitch | undefined;
|
|
428
472
|
const pitchEl = noteEl.getElementsByTagName('pitch')[0];
|
|
@@ -435,6 +479,18 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
435
479
|
const typeText = getElementText(noteEl, 'type');
|
|
436
480
|
const dotCount = getElements(noteEl, 'dot').length;
|
|
437
481
|
|
|
482
|
+
// Whole-measure rest detection. Two forms in the wild:
|
|
483
|
+
// (a) <rest measure="yes"> — explicit.
|
|
484
|
+
// (b) a `type="whole"` rest whose <duration> is NOT a whole note (e.g. 72 ticks
|
|
485
|
+
// in 3/4 at divisions=24) — the conventional "centred whole rest = whole
|
|
486
|
+
// bar" notation. In both cases the rest fills the measure, so flag it and
|
|
487
|
+
// let encoders emit <mRest>/R instead of rounding the bare duration to a
|
|
488
|
+
// power-of-two division (which over/under-fills non-2^n meters).
|
|
489
|
+
const isMeasureRest = !!restEl && (
|
|
490
|
+
getAttribute(restEl, 'measure') === 'yes' ||
|
|
491
|
+
(typeText === 'whole' && durationVal > 0 && durationVal !== divisions * 4)
|
|
492
|
+
);
|
|
493
|
+
|
|
438
494
|
// Time modification (tuplets)
|
|
439
495
|
let timeModification: { actualNotes: number; normalNotes: number } | undefined;
|
|
440
496
|
const timeModEl = noteEl.getElementsByTagName('time-modification')[0];
|
|
@@ -463,14 +519,15 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
463
519
|
notations = parseNotations(notationsEl);
|
|
464
520
|
}
|
|
465
521
|
|
|
466
|
-
// Fingering
|
|
467
|
-
let
|
|
522
|
+
// Fingering — a note may carry several <fingering> (one per chord member).
|
|
523
|
+
let fingerings: number[] | undefined;
|
|
468
524
|
const technicalEl = noteEl.getElementsByTagName('technical')[0];
|
|
469
525
|
if (technicalEl) {
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
526
|
+
const fingeringEls = getElements(technicalEl, 'fingering');
|
|
527
|
+
const parsed = fingeringEls
|
|
528
|
+
.map(el => parseInt(el.textContent?.trim() || '', 10))
|
|
529
|
+
.filter(n => Number.isFinite(n));
|
|
530
|
+
if (parsed.length > 0) fingerings = parsed;
|
|
474
531
|
}
|
|
475
532
|
|
|
476
533
|
// Beams - direct children of note, not in notations
|
|
@@ -488,6 +545,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
488
545
|
isChord,
|
|
489
546
|
isRest,
|
|
490
547
|
isGrace,
|
|
548
|
+
isMeasureRest,
|
|
491
549
|
pitch,
|
|
492
550
|
duration: {
|
|
493
551
|
divisions: durationVal,
|
|
@@ -499,7 +557,7 @@ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
|
|
|
499
557
|
staff,
|
|
500
558
|
stem: stem as any,
|
|
501
559
|
notations,
|
|
502
|
-
|
|
560
|
+
fingerings,
|
|
503
561
|
beams,
|
|
504
562
|
};
|
|
505
563
|
};
|
|
@@ -797,12 +855,13 @@ const notationsToMarks = (
|
|
|
797
855
|
if (notations.slurs) {
|
|
798
856
|
for (const slur of notations.slurs) {
|
|
799
857
|
if (slur.type === 'start') {
|
|
800
|
-
marks.push({ markType: 'slur', start: true });
|
|
858
|
+
marks.push({ markType: 'slur', start: true, number: slur.number });
|
|
801
859
|
spannerTracker.startSlur(slur.number);
|
|
802
860
|
} else if (slur.type === 'stop') {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
861
|
+
// Carry the MusicXML number so the encoder can pair cross-voice slurs
|
|
862
|
+
// (start in one voice, stop in another — common in piano scores).
|
|
863
|
+
spannerTracker.stopSlur(slur.number);
|
|
864
|
+
marks.push({ markType: 'slur', start: false, number: slur.number });
|
|
806
865
|
}
|
|
807
866
|
}
|
|
808
867
|
}
|
|
@@ -1002,6 +1061,21 @@ const directionToMarks = (
|
|
|
1002
1061
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
1003
1062
|
}
|
|
1004
1063
|
|
|
1064
|
+
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
1065
|
+
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
1066
|
+
// ContextChange by directionToContextChange, so skip those here to avoid
|
|
1067
|
+
// double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
|
|
1068
|
+
// directions are tempo too, never markup.
|
|
1069
|
+
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
1070
|
+
const text = direction.words.map(w => w.text).join('').trim();
|
|
1071
|
+
if (text && !isTempoWord(text)) {
|
|
1072
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
1073
|
+
: direction.placement === 'below' ? Placement.below
|
|
1074
|
+
: undefined;
|
|
1075
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1005
1079
|
return marks;
|
|
1006
1080
|
};
|
|
1007
1081
|
|
|
@@ -1017,6 +1091,51 @@ interface MeasureConversionResult {
|
|
|
1017
1091
|
clefs: Map<number, ContextChange>; // staff number → clef context
|
|
1018
1092
|
}
|
|
1019
1093
|
|
|
1094
|
+
/**
|
|
1095
|
+
* Decompose a tick gap (from <forward>) into invisible spacer rests.
|
|
1096
|
+
*
|
|
1097
|
+
* lilylet's doc model is a flat per-voice event sequence with no absolute tick
|
|
1098
|
+
* anchor (currentPosition is unused for placement), so a <forward> that skips
|
|
1099
|
+
* time inside a voice must be materialised as filler or the following notes slide
|
|
1100
|
+
* earlier and the bar decodes short. Invisible rests (`s` / MEI <space>) are the
|
|
1101
|
+
* right carrier. The gap may not be a single note value (e.g. 1.5 quarters), so
|
|
1102
|
+
* emit a greedy sequence of power-of-two (optionally dotted) spacers.
|
|
1103
|
+
*/
|
|
1104
|
+
const forwardGapToRests = (gapTicks: number, divisions: number): RestEvent[] => {
|
|
1105
|
+
const rests: RestEvent[] = [];
|
|
1106
|
+
let remaining = gapTicks;
|
|
1107
|
+
const quarterTicks = divisions; // ticks per quarter note
|
|
1108
|
+
// Largest representable spacer first; division 1=whole..128. dotted adds half.
|
|
1109
|
+
const candidates: { division: number; dots: number; q: number }[] = [];
|
|
1110
|
+
for (const division of [1, 2, 4, 8, 16, 32, 64, 128]) {
|
|
1111
|
+
const baseQ = 4 / division; // quarter notes for this value
|
|
1112
|
+
candidates.push({ division, dots: 0, q: baseQ });
|
|
1113
|
+
candidates.push({ division, dots: 1, q: baseQ * 1.5 });
|
|
1114
|
+
}
|
|
1115
|
+
candidates.sort((a, b) => b.q - a.q);
|
|
1116
|
+
let guard = 0;
|
|
1117
|
+
while (remaining > 0.0001 && guard++ < 64) {
|
|
1118
|
+
const c = candidates.find(c => c.q * quarterTicks <= remaining + 0.0001);
|
|
1119
|
+
if (!c) break;
|
|
1120
|
+
rests.push({ type: 'rest', duration: { division: c.division, dots: c.dots }, invisible: true });
|
|
1121
|
+
remaining -= c.q * quarterTicks;
|
|
1122
|
+
}
|
|
1123
|
+
return rests;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Total time a TupletEvent advances the voice, in voiceTracker duration units.
|
|
1128
|
+
* Sum the inner note/rest values then apply the tuplet ratio (triplet etc.).
|
|
1129
|
+
*/
|
|
1130
|
+
const tupletAdvanceDuration = (tupletEvent: TupletEvent, divisions: number): number => {
|
|
1131
|
+
let total = 0;
|
|
1132
|
+
for (const evt of tupletEvent.events) {
|
|
1133
|
+
const d = (evt as NoteEvent | RestEvent).duration;
|
|
1134
|
+
if (d) total += (4 / d.division) * divisions;
|
|
1135
|
+
}
|
|
1136
|
+
return total * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1020
1139
|
/**
|
|
1021
1140
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
1022
1141
|
*/
|
|
@@ -1035,8 +1154,17 @@ const convertMeasure = (
|
|
|
1035
1154
|
|
|
1036
1155
|
// Pending marks from directions (to attach to next note), per voice
|
|
1037
1156
|
const pendingMarks: Map<number, Mark[]> = new Map();
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1157
|
+
// Accumulated <forward> ticks waiting for the next note, whose <voice> tells us
|
|
1158
|
+
// which voice the gap belongs to. Flushed as invisible rests before that note,
|
|
1159
|
+
// or onto currentVoice at a <backup>/measure end (a trailing gap in this voice).
|
|
1160
|
+
let pendingForward = 0;
|
|
1161
|
+
// Pending context changes (tempo, ottava) to insert before next note. Each may
|
|
1162
|
+
// carry a target staff: an ottava (<octave-shift>) start and its stop both name
|
|
1163
|
+
// the same <staff>, but in piano scores the stop direction often follows a
|
|
1164
|
+
// <backup> so the *next note* is on the other staff. Routing the change to a
|
|
1165
|
+
// note on its own staff keeps the 8va span's start and end in the same MEI
|
|
1166
|
+
// layer (otherwise the encoder can't pair them and drops the span).
|
|
1167
|
+
const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
|
|
1040
1168
|
let currentVoice = 1; // Track current voice for directions
|
|
1041
1169
|
|
|
1042
1170
|
// Process all children in order
|
|
@@ -1083,23 +1211,46 @@ const convertMeasure = (
|
|
|
1083
1211
|
// where notes go to tupletTracker but voice must be initialized for staff detection)
|
|
1084
1212
|
voiceTracker.getOrCreateVoice(voiceNum, staffNum);
|
|
1085
1213
|
|
|
1214
|
+
// Flush an accumulated <forward> gap as invisible rests into THIS note's
|
|
1215
|
+
// voice (the forward had no voice of its own; it belongs to the voice that
|
|
1216
|
+
// follows). Skip while inside a tuplet — a forward there is unusual and the
|
|
1217
|
+
// tuplet tracker owns timing.
|
|
1218
|
+
if (pendingForward > 0 && !tupletTracker.isActive(voiceNum)) {
|
|
1219
|
+
for (const r of forwardGapToRests(pendingForward, voiceTracker.getDivisions())) {
|
|
1220
|
+
voiceTracker.addEvent(voiceNum, r, 0, staffNum);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
pendingForward = 0;
|
|
1224
|
+
|
|
1086
1225
|
// Check for tuplet start BEFORE processing the note
|
|
1087
1226
|
const tupletNotation = note.notations?.tuplet;
|
|
1088
1227
|
if (tupletNotation?.type === 'start') {
|
|
1089
|
-
tupletTracker.startTuplet(tupletNotation.number);
|
|
1228
|
+
tupletTracker.startTuplet(tupletNotation.number, voiceNum, staffNum);
|
|
1090
1229
|
}
|
|
1091
1230
|
|
|
1092
|
-
// Add any pending context changes before the note (tempo, ottava)
|
|
1231
|
+
// Add any pending context changes before the note (tempo, ottava).
|
|
1232
|
+
// A staff-tagged change (ottava) only flushes onto a note on the SAME
|
|
1233
|
+
// staff so its 8va span stays in one layer; others (tempo) flush anywhere.
|
|
1093
1234
|
if (pendingContextChanges.length > 0) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1235
|
+
const remaining: { ctx: ContextChange; staff?: number }[] = [];
|
|
1236
|
+
for (const pc of pendingContextChanges) {
|
|
1237
|
+
if (pc.staff === undefined || pc.staff === staffNum) {
|
|
1238
|
+
voiceTracker.addEvent(voiceNum, pc.ctx, 0, staffNum);
|
|
1239
|
+
} else {
|
|
1240
|
+
remaining.push(pc); // wait for a note on the matching staff
|
|
1241
|
+
}
|
|
1096
1242
|
}
|
|
1097
|
-
pendingContextChanges.length = 0;
|
|
1243
|
+
pendingContextChanges.length = 0;
|
|
1244
|
+
pendingContextChanges.push(...remaining);
|
|
1098
1245
|
}
|
|
1099
1246
|
|
|
1100
|
-
// Get pending marks for this voice
|
|
1101
|
-
|
|
1102
|
-
|
|
1247
|
+
// Get pending marks for this voice. Rests can't hold marks (RestEvent has
|
|
1248
|
+
// no `marks` field), so do NOT consume them on a rest — leave them queued
|
|
1249
|
+
// for the next real note or the end-of-measure flush. Otherwise a pedal/
|
|
1250
|
+
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1251
|
+
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1252
|
+
const marks: Mark[] = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1253
|
+
if (!note.isRest) pendingMarks.delete(voiceNum);
|
|
1103
1254
|
|
|
1104
1255
|
if (note.isRest) {
|
|
1105
1256
|
// Rest event
|
|
@@ -1116,12 +1267,26 @@ const convertMeasure = (
|
|
|
1116
1267
|
duration,
|
|
1117
1268
|
};
|
|
1118
1269
|
|
|
1270
|
+
// Whole-measure rest: mark it so encoders emit <mRest>/R and downstream
|
|
1271
|
+
// duration math uses the measure length, not the power-of-two rounding
|
|
1272
|
+
// of the bare <duration> (which over/under-fills non-2^n meters like 3/4).
|
|
1273
|
+
if (note.isMeasureRest) {
|
|
1274
|
+
restEvent.fullMeasure = true;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// A rest can host a fermata (grand pause / held silence). Convert it
|
|
1278
|
+
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1279
|
+
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1280
|
+
if (note.notations?.fermata) {
|
|
1281
|
+
restEvent.marks = [{ markType: 'ornament', type: 'fermata' as any }];
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1119
1284
|
// Grace notes don't advance time
|
|
1120
1285
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1121
1286
|
|
|
1122
1287
|
// Check if we're in a tuplet
|
|
1123
|
-
if (tupletTracker.isActive()) {
|
|
1124
|
-
tupletTracker.addEvent(restEvent);
|
|
1288
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1289
|
+
tupletTracker.addEvent(restEvent, voiceNum);
|
|
1125
1290
|
} else {
|
|
1126
1291
|
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
1127
1292
|
}
|
|
@@ -1133,9 +1298,11 @@ const convertMeasure = (
|
|
|
1133
1298
|
const notationMarks = notationsToMarks(note.notations, spannerTracker, [lilyletPitch]);
|
|
1134
1299
|
marks.push(...notationMarks);
|
|
1135
1300
|
|
|
1136
|
-
// Add
|
|
1137
|
-
if (note.
|
|
1138
|
-
|
|
1301
|
+
// Add fingerings (one per chord member; MEI emits a <fing> each)
|
|
1302
|
+
if (note.fingerings) {
|
|
1303
|
+
for (const finger of note.fingerings) {
|
|
1304
|
+
if (finger >= 0 && finger <= 9) marks.push({ markType: 'fingering', finger });
|
|
1305
|
+
}
|
|
1139
1306
|
}
|
|
1140
1307
|
|
|
1141
1308
|
// Handle chord: merge with previous note in same voice
|
|
@@ -1196,8 +1363,8 @@ const convertMeasure = (
|
|
|
1196
1363
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1197
1364
|
|
|
1198
1365
|
// Check if we're in a tuplet
|
|
1199
|
-
if (tupletTracker.isActive()) {
|
|
1200
|
-
tupletTracker.addEvent(noteEvent);
|
|
1366
|
+
if (tupletTracker.isActive(voiceNum)) {
|
|
1367
|
+
tupletTracker.addEvent(noteEvent, voiceNum);
|
|
1201
1368
|
} else {
|
|
1202
1369
|
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
1203
1370
|
}
|
|
@@ -1207,16 +1374,7 @@ const convertMeasure = (
|
|
|
1207
1374
|
if (tupletNotation?.type === 'stop') {
|
|
1208
1375
|
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
1209
1376
|
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;
|
|
1377
|
+
const totalDuration = tupletAdvanceDuration(tupletEvent, voiceTracker.getDivisions());
|
|
1220
1378
|
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
1221
1379
|
}
|
|
1222
1380
|
}
|
|
@@ -1226,7 +1384,9 @@ const convertMeasure = (
|
|
|
1226
1384
|
// Handle context changes (tempo, ottava)
|
|
1227
1385
|
const contextChange = directionToContextChange(direction, ottavaTracker);
|
|
1228
1386
|
if (contextChange) {
|
|
1229
|
-
|
|
1387
|
+
// Tag ottava changes with their staff so they reach the right layer.
|
|
1388
|
+
const staff = contextChange.ottava !== undefined ? direction.staff : undefined;
|
|
1389
|
+
pendingContextChanges.push({ ctx: contextChange, staff });
|
|
1230
1390
|
}
|
|
1231
1391
|
|
|
1232
1392
|
// Handle marks (dynamics, hairpins, etc.)
|
|
@@ -1237,11 +1397,17 @@ const convertMeasure = (
|
|
|
1237
1397
|
pendingMarks.set(currentVoice, [...existing, ...marks]);
|
|
1238
1398
|
}
|
|
1239
1399
|
} else if (tagName === 'backup') {
|
|
1400
|
+
// A <forward> with no note after it (before this backup) is cursor
|
|
1401
|
+
// positioning, not a content gap — drop it rather than materialise filler
|
|
1402
|
+
// (the common `backup N / forward N` measure-end idiom would otherwise
|
|
1403
|
+
// double the bar). Only note→forward→note gaps become invisible rests.
|
|
1404
|
+
pendingForward = 0;
|
|
1240
1405
|
const duration = getElementInt(child, 'duration') || 0;
|
|
1241
1406
|
voiceTracker.backup(duration);
|
|
1242
1407
|
} else if (tagName === 'forward') {
|
|
1243
1408
|
const duration = getElementInt(child, 'duration') || 0;
|
|
1244
1409
|
voiceTracker.forward(duration);
|
|
1410
|
+
pendingForward += duration; // materialised as invisible rests only if a note follows in-voice
|
|
1245
1411
|
} else if (tagName === 'barline') {
|
|
1246
1412
|
const barlineData = parseBarline(child);
|
|
1247
1413
|
const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
|
|
@@ -1263,6 +1429,74 @@ const convertMeasure = (
|
|
|
1263
1429
|
}
|
|
1264
1430
|
}
|
|
1265
1431
|
|
|
1432
|
+
// A <forward> at the very end of the measure with no following note is cursor
|
|
1433
|
+
// positioning (e.g. the `backup N / forward N` idiom), not a content gap — drop
|
|
1434
|
+
// it. Only forwards consumed by a following note become invisible spacer rests.
|
|
1435
|
+
pendingForward = 0;
|
|
1436
|
+
|
|
1437
|
+
// Force-close any tuplet still open at the bar line. A tuplet is a within-measure
|
|
1438
|
+
// time modification and cannot span a bar; a source file missing its
|
|
1439
|
+
// <tuplet type="stop"> (corpus reality) would otherwise keep the tuplet open for
|
|
1440
|
+
// the rest of the piece, diverting every following note in that voice into the
|
|
1441
|
+
// zombie tuplet — the multi-voice block-drop bug (库劳 Op.20 m15→end empty).
|
|
1442
|
+
// Flush each leftover tuplet onto its owning voice so the damage is bounded to
|
|
1443
|
+
// this measure. Done before the pendingMarks flush so trailing marks still find
|
|
1444
|
+
// the (now-emitted) tuplet's notes as the voice's last events.
|
|
1445
|
+
for (const { event, voice, staff } of tupletTracker.flushAll()) {
|
|
1446
|
+
const totalDuration = tupletAdvanceDuration(event, voiceTracker.getDivisions());
|
|
1447
|
+
voiceTracker.addEvent(voice, event, totalDuration, staff);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Flush leftover pending marks. Post-positioned directions — hairpin/pedal
|
|
1451
|
+
// stops, and any direction after the last note of its voice (common after a
|
|
1452
|
+
// <backup> in piano scores) — never reached a following note in the loop above.
|
|
1453
|
+
// They belong on the note they trail, so attach them to the last NoteEvent of
|
|
1454
|
+
// the voice. pendingMarks is per-measure, so without this they would be lost
|
|
1455
|
+
// at the next measure (the pedal/hairpin "stop" loss). Rests carry no marks, so
|
|
1456
|
+
// search backward for the last actual note; fall back across voices if needed.
|
|
1457
|
+
if (pendingMarks.size > 0) {
|
|
1458
|
+
const allVoices = voiceTracker.getVoices();
|
|
1459
|
+
const findLastNote = (voiceNum: number): NoteEvent | undefined => {
|
|
1460
|
+
const vs = allVoices.get(voiceNum);
|
|
1461
|
+
if (!vs) return undefined;
|
|
1462
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1463
|
+
const ev = vs.events[i];
|
|
1464
|
+
if (ev.type === 'note') return ev as NoteEvent;
|
|
1465
|
+
}
|
|
1466
|
+
return undefined;
|
|
1467
|
+
};
|
|
1468
|
+
for (const [voiceNum, marks] of pendingMarks) {
|
|
1469
|
+
if (marks.length === 0) continue;
|
|
1470
|
+
let target = findLastNote(voiceNum);
|
|
1471
|
+
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1472
|
+
// last note of any voice so the marking is not silently dropped.
|
|
1473
|
+
if (!target) {
|
|
1474
|
+
for (const vn of allVoices.keys()) {
|
|
1475
|
+
target = findLastNote(vn);
|
|
1476
|
+
if (target) break;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (target) target.marks = [...(target.marks || []), ...marks];
|
|
1480
|
+
}
|
|
1481
|
+
pendingMarks.clear();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Flush leftover staff-tagged context changes (ottava) whose matching-staff note
|
|
1485
|
+
// never appeared this measure: append to a voice on the target staff so the span
|
|
1486
|
+
// continues into / closes in the right layer rather than being dropped.
|
|
1487
|
+
if (pendingContextChanges.length > 0) {
|
|
1488
|
+
const voices = voiceTracker.getVoices();
|
|
1489
|
+
for (const pc of pendingContextChanges) {
|
|
1490
|
+
let voiceNum: number | undefined;
|
|
1491
|
+
for (const [vn, vs] of voices) {
|
|
1492
|
+
if (vs.staff === pc.staff) { voiceNum = vn; break; }
|
|
1493
|
+
}
|
|
1494
|
+
if (voiceNum === undefined) voiceNum = voices.keys().next().value;
|
|
1495
|
+
if (voiceNum !== undefined) voiceTracker.addEvent(voiceNum, pc.ctx, 0, pc.staff);
|
|
1496
|
+
}
|
|
1497
|
+
pendingContextChanges.length = 0;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1266
1500
|
// Build voice map from tracker
|
|
1267
1501
|
const voiceMap = new Map<number, { events: Event[]; staff: number }>();
|
|
1268
1502
|
for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
|
|
@@ -1384,7 +1618,37 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1384
1618
|
/**
|
|
1385
1619
|
* Decode MusicXML string to LilyletDoc
|
|
1386
1620
|
*/
|
|
1387
|
-
|
|
1621
|
+
/**
|
|
1622
|
+
* Decode raw MusicXML bytes (or a string) into a clean UTF-8/UTF-16-correct
|
|
1623
|
+
* JS string. MuseScore/Finale/Sibelius frequently export `.xml` as UTF-16 LE
|
|
1624
|
+
* with a BOM; reading those as UTF-8 yields mojibake and a failed parse.
|
|
1625
|
+
*
|
|
1626
|
+
* Detection order: byte-order mark → declared `encoding="..."` in the XML
|
|
1627
|
+
* prolog → default UTF-8. A leading BOM is always stripped (xmldom chokes on a
|
|
1628
|
+
* U+FEFF before `<?xml`).
|
|
1629
|
+
*/
|
|
1630
|
+
export const readXmlString = (input: string | Uint8Array): string => {
|
|
1631
|
+
if (typeof input === 'string')
|
|
1632
|
+
return input.charCodeAt(0) === 0xFEFF ? input.slice(1) : input;
|
|
1633
|
+
|
|
1634
|
+
const bytes = input;
|
|
1635
|
+
if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE)
|
|
1636
|
+
return new TextDecoder('utf-16le').decode(bytes.subarray(2));
|
|
1637
|
+
if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF)
|
|
1638
|
+
return new TextDecoder('utf-16be').decode(bytes.subarray(2));
|
|
1639
|
+
if (bytes.length >= 3 && bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF)
|
|
1640
|
+
return new TextDecoder('utf-8').decode(bytes.subarray(3));
|
|
1641
|
+
|
|
1642
|
+
// No BOM: peek the prolog (as latin1 so every byte maps 1:1) for a declared encoding.
|
|
1643
|
+
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 256));
|
|
1644
|
+
const enc = /encoding\s*=\s*['"]([^'"]+)['"]/i.exec(head)?.[1]?.toLowerCase();
|
|
1645
|
+
if (enc && /utf-?16/.test(enc))
|
|
1646
|
+
return new TextDecoder('utf-16le').decode(bytes); // BOM-less UTF-16 → assume LE (Windows)
|
|
1647
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
export const decode = (input: string | Uint8Array): LilyletDoc => {
|
|
1651
|
+
const xmlString = readXmlString(input);
|
|
1388
1652
|
const parser = new DOMParser();
|
|
1389
1653
|
const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
1390
1654
|
|
|
@@ -1478,8 +1742,8 @@ export const decode = (xmlString: string): LilyletDoc => {
|
|
|
1478
1742
|
*/
|
|
1479
1743
|
export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
|
|
1480
1744
|
const fs = await import('fs/promises');
|
|
1481
|
-
const
|
|
1482
|
-
return decode(
|
|
1745
|
+
const buf = await fs.readFile(filePath); // raw bytes; readXmlString sniffs the encoding
|
|
1746
|
+
return decode(buf);
|
|
1483
1747
|
};
|
|
1484
1748
|
|
|
1485
1749
|
export default {
|
|
@@ -69,13 +69,14 @@ export interface MusicXmlNote {
|
|
|
69
69
|
isChord: boolean; // Has <chord/> tag
|
|
70
70
|
isRest: boolean; // Has <rest/> tag
|
|
71
71
|
isGrace: boolean; // Has <grace/> tag
|
|
72
|
+
isMeasureRest?: boolean; // <rest measure="yes"> — fills the whole measure
|
|
72
73
|
pitch?: MusicXmlPitch;
|
|
73
74
|
duration: MusicXmlDuration;
|
|
74
75
|
voice: number; // Voice number (1-based)
|
|
75
76
|
staff?: number; // Staff number (for cross-staff)
|
|
76
77
|
stem?: MusicXmlStemDirection;
|
|
77
78
|
notations?: MusicXmlNotations;
|
|
78
|
-
|
|
79
|
+
fingerings?: number[]; // one per chord member; a single note has at most one
|
|
79
80
|
beams?: { type: 'begin' | 'continue' | 'end'; number: number }[];
|
|
80
81
|
}
|
|
81
82
|
|
package/source/lilylet/types.ts
CHANGED
|
@@ -157,6 +157,7 @@ export interface Tie {
|
|
|
157
157
|
export interface Slur {
|
|
158
158
|
markType: 'slur';
|
|
159
159
|
start: boolean;
|
|
160
|
+
number?: number; // MusicXML slur number, for pairing cross-voice/overlapping slurs (encoder hint only)
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
export interface Beam {
|
|
@@ -223,6 +224,7 @@ export interface RestEvent {
|
|
|
223
224
|
invisible?: boolean; // space rest (s)
|
|
224
225
|
fullMeasure?: boolean; // full measure rest (R)
|
|
225
226
|
pitch?: Pitch; // positioned rest (e.g., g'\rest)
|
|
227
|
+
marks?: Mark[]; // control-event marks a rest can host (e.g. fermata over a rest / grand pause)
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
export interface ContextChange {
|