@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.
@@ -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(s)
159
- * Returns true if the event was added to at least one tuplet
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
- // Add to all active tuplets (in case of nested tuplets)
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
- // Set ratio from first event's duration.tuplet
167
- // convertDuration already stores Lilylet ratio semantics (normalNotes/actualNotes)
168
- if (!tuplet.ratio && event.duration.tuplet) {
169
- tuplet.ratio = { ...event.duration.tuplet };
170
- }
171
- // Store event without tuplet info in duration (it's handled at TupletEvent level)
172
- const cleanEvent = { ...event, duration: { ...event.duration } };
173
- delete cleanEvent.duration.tuplet;
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 any tuplet is active
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
- isActive(): boolean {
205
- return this.activeTuplets.size > 0;
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 fingering: number | undefined;
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 fingeringText = getElementText(technicalEl, 'fingering');
471
- if (fingeringText) {
472
- fingering = parseInt(fingeringText, 10);
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
- fingering,
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
- if (spannerTracker.stopSlur(slur.number)) {
804
- marks.push({ markType: 'slur', start: false });
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
- // Pending context changes (tempo, ottava) to insert before next note
1039
- const pendingContextChanges: ContextChange[] = [];
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
- for (const ctx of pendingContextChanges) {
1095
- voiceTracker.addEvent(voiceNum, ctx, 0, staffNum);
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; // Clear
1243
+ pendingContextChanges.length = 0;
1244
+ pendingContextChanges.push(...remaining);
1098
1245
  }
1099
1246
 
1100
- // Get pending marks for this voice
1101
- const marks: Mark[] = pendingMarks.get(voiceNum) || [];
1102
- pendingMarks.delete(voiceNum);
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 fingering
1137
- if (note.fingering !== undefined && note.fingering >= 1 && note.fingering <= 5) {
1138
- marks.push({ markType: 'fingering', finger: note.fingering });
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
- // Calculate total duration of tuplet for voiceTracker
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
- pendingContextChanges.push(contextChange);
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
- export const decode = (xmlString: string): LilyletDoc => {
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 content = await fs.readFile(filePath, 'utf-8');
1482
- return decode(content);
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
- fingering?: number; // 1-5
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
 
@@ -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 {