@k-l-lambda/lilylet 0.1.30

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.
@@ -0,0 +1,1311 @@
1
+ /**
2
+ * MusicXML to Lilylet Decoder
3
+ *
4
+ * Converts MusicXML files to Lilylet's internal LilyletDoc format.
5
+ * Improves upon musicxml2ly by properly tracking spanners (slurs, ties, wedges) by number attribute.
6
+ */
7
+
8
+ import { DOMParser } from '@xmldom/xmldom';
9
+
10
+ import {
11
+ LilyletDoc,
12
+ Measure,
13
+ Part,
14
+ Voice,
15
+ Event,
16
+ NoteEvent,
17
+ RestEvent,
18
+ ContextChange,
19
+ Pitch,
20
+ Duration,
21
+ Mark,
22
+ KeySignature,
23
+ Metadata,
24
+ Fraction,
25
+ HairpinType,
26
+ PedalType,
27
+ NavigationMarkType,
28
+ BarlineEvent,
29
+ HarmonyEvent,
30
+ } from './types';
31
+
32
+ import {
33
+ MusicXmlDocument,
34
+ MusicXmlPart,
35
+ MusicXmlMeasure,
36
+ MusicXmlMeasureContent,
37
+ MusicXmlNote,
38
+ MusicXmlAttributes,
39
+ MusicXmlDirection,
40
+ MusicXmlBarline,
41
+ MusicXmlHarmony,
42
+ MusicXmlNotations,
43
+ MusicXmlPitch,
44
+ } from './musicXmlTypes';
45
+
46
+ import {
47
+ getElementText,
48
+ getElementInt,
49
+ getElements,
50
+ getDirectChildren,
51
+ getChildElements,
52
+ getAttribute,
53
+ getAttributeNumber,
54
+ hasElement,
55
+ convertPitch,
56
+ convertDuration,
57
+ convertKeySignature,
58
+ convertClef,
59
+ convertStemDirection,
60
+ convertArticulation,
61
+ convertOrnament,
62
+ convertDynamic,
63
+ convertWedge,
64
+ convertPedal,
65
+ convertBarlineStyle,
66
+ convertHarmonyToText,
67
+ createFraction,
68
+ } from './musicXmlUtils';
69
+
70
+ // ============ Spanner Tracker ============
71
+
72
+ /**
73
+ * Track spanners (slurs, ties, wedges) by number attribute.
74
+ * This fixes the musicxml2ly bug where nested slurs aren't handled correctly.
75
+ */
76
+ class SpannerTracker {
77
+ private slurs: Map<number, boolean> = new Map(); // number → is active
78
+ private wedges: Map<number, 'crescendo' | 'diminuendo'> = new Map(); // number → type
79
+ private ties: Map<string, boolean> = new Map(); // pitch key → is active
80
+
81
+ // Slur tracking
82
+ startSlur(number: number = 1): void {
83
+ this.slurs.set(number, true);
84
+ }
85
+
86
+ stopSlur(number: number = 1): boolean {
87
+ const wasActive = this.slurs.has(number);
88
+ this.slurs.delete(number);
89
+ return wasActive;
90
+ }
91
+
92
+ isSlurActive(number: number = 1): boolean {
93
+ return this.slurs.has(number);
94
+ }
95
+
96
+ // Wedge (hairpin) tracking
97
+ startWedge(type: 'crescendo' | 'diminuendo', number: number = 1): void {
98
+ this.wedges.set(number, type);
99
+ }
100
+
101
+ stopWedge(number: number = 1): 'crescendo' | 'diminuendo' | undefined {
102
+ const type = this.wedges.get(number);
103
+ this.wedges.delete(number);
104
+ return type;
105
+ }
106
+
107
+ // Tie tracking (by pitch)
108
+ private pitchKey(pitch: Pitch): string {
109
+ return `${pitch.phonet}${pitch.accidental || ''}${pitch.octave}`;
110
+ }
111
+
112
+ startTie(pitch: Pitch): void {
113
+ this.ties.set(this.pitchKey(pitch), true);
114
+ }
115
+
116
+ stopTie(pitch: Pitch): boolean {
117
+ const key = this.pitchKey(pitch);
118
+ const wasActive = this.ties.has(key);
119
+ this.ties.delete(key);
120
+ return wasActive;
121
+ }
122
+
123
+ isTieActive(pitch: Pitch): boolean {
124
+ return this.ties.has(this.pitchKey(pitch));
125
+ }
126
+
127
+ // Reset all trackers (for new part)
128
+ reset(): void {
129
+ this.slurs.clear();
130
+ this.wedges.clear();
131
+ this.ties.clear();
132
+ }
133
+ }
134
+
135
+ // ============ Voice Position Tracker ============
136
+
137
+ /**
138
+ * Track position within each voice for proper event ordering.
139
+ * Handles backup/forward elements to manage multiple voices.
140
+ *
141
+ * MusicXML voice handling:
142
+ * - Each <note> has a <voice> element (1, 2, 3, etc.)
143
+ * - <backup> goes back in time to start a new voice
144
+ * - <forward> skips forward (for rests that aren't written)
145
+ */
146
+ interface VoiceState {
147
+ events: Event[];
148
+ staff: number;
149
+ lastEvent?: Event; // For chord merging
150
+ }
151
+
152
+ class VoiceTracker {
153
+ private voices: Map<number, VoiceState> = new Map();
154
+ private currentPosition: number = 0;
155
+ private divisions: number = 1;
156
+ private staves: number = 1;
157
+
158
+ setDivisions(div: number): void {
159
+ this.divisions = div;
160
+ }
161
+
162
+ getDivisions(): number {
163
+ return this.divisions;
164
+ }
165
+
166
+ setStaves(n: number): void {
167
+ this.staves = n;
168
+ }
169
+
170
+ getStaves(): number {
171
+ return this.staves;
172
+ }
173
+
174
+ getOrCreateVoice(voiceNum: number, staff: number = 1): VoiceState {
175
+ if (!this.voices.has(voiceNum)) {
176
+ this.voices.set(voiceNum, {
177
+ events: [],
178
+ staff,
179
+ });
180
+ }
181
+ const voice = this.voices.get(voiceNum)!;
182
+ // Update staff if specified
183
+ if (staff > 0) {
184
+ voice.staff = staff;
185
+ }
186
+ return voice;
187
+ }
188
+
189
+ addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
190
+ const voice = this.getOrCreateVoice(voiceNum, staff);
191
+ voice.events.push(event);
192
+ voice.lastEvent = event;
193
+ this.currentPosition += duration;
194
+ }
195
+
196
+ getLastEvent(voiceNum: number): Event | undefined {
197
+ const voice = this.voices.get(voiceNum);
198
+ return voice?.lastEvent;
199
+ }
200
+
201
+ backup(duration: number): void {
202
+ this.currentPosition -= duration;
203
+ // Note: Negative position is OK - it just means we're going back
204
+ // to write a different voice
205
+ }
206
+
207
+ forward(duration: number): void {
208
+ this.currentPosition += duration;
209
+ }
210
+
211
+ getCurrentPosition(): number {
212
+ return this.currentPosition;
213
+ }
214
+
215
+ getVoices(): Map<number, VoiceState> {
216
+ return this.voices;
217
+ }
218
+
219
+ getVoiceNumbers(): number[] {
220
+ return Array.from(this.voices.keys()).sort((a, b) => a - b);
221
+ }
222
+
223
+ reset(): void {
224
+ this.voices.clear();
225
+ this.currentPosition = 0;
226
+ }
227
+ }
228
+
229
+ // ============ XML Parsing Functions ============
230
+
231
+ /**
232
+ * Parse <pitch> element to MusicXmlPitch (raw data)
233
+ */
234
+ const parsePitchRaw = (pitchEl: Element): MusicXmlPitch | undefined => {
235
+ const step = getElementText(pitchEl, 'step');
236
+ const octave = getElementInt(pitchEl, 'octave');
237
+ const alter = getElementInt(pitchEl, 'alter');
238
+
239
+ if (!step || octave === undefined) {
240
+ return undefined;
241
+ }
242
+
243
+ return { step, alter, octave };
244
+ };
245
+
246
+ /**
247
+ * Convert MusicXmlPitch to Lilylet Pitch
248
+ */
249
+ const musicXmlPitchToLilylet = (xmlPitch: MusicXmlPitch): Pitch => {
250
+ return convertPitch(xmlPitch.step, xmlPitch.alter, xmlPitch.octave);
251
+ };
252
+
253
+ /**
254
+ * Parse <notations> element
255
+ */
256
+ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
257
+ const result: MusicXmlNotations = {};
258
+
259
+ // Ties
260
+ const tieEls = getElements(notationsEl, 'tied');
261
+ if (tieEls.length > 0) {
262
+ result.ties = tieEls.map(el => ({
263
+ type: getAttribute(el, 'type') as 'start' | 'stop',
264
+ }));
265
+ }
266
+
267
+ // Slurs
268
+ const slurEls = getElements(notationsEl, 'slur');
269
+ if (slurEls.length > 0) {
270
+ result.slurs = slurEls.map(el => ({
271
+ type: getAttribute(el, 'type') as 'start' | 'stop',
272
+ number: getAttributeNumber(el, 'number') || 1,
273
+ }));
274
+ }
275
+
276
+ // Articulations
277
+ const articulationsEl = notationsEl.getElementsByTagName('articulations')[0];
278
+ if (articulationsEl) {
279
+ const articulations: string[] = [];
280
+ for (const child of getChildElements(articulationsEl)) {
281
+ articulations.push(child.tagName);
282
+ }
283
+ if (articulations.length > 0) {
284
+ result.articulations = articulations;
285
+ }
286
+ }
287
+
288
+ // Ornaments
289
+ const ornamentsEl = notationsEl.getElementsByTagName('ornaments')[0];
290
+ if (ornamentsEl) {
291
+ const ornaments: string[] = [];
292
+ for (const child of getChildElements(ornamentsEl)) {
293
+ ornaments.push(child.tagName);
294
+ }
295
+ if (ornaments.length > 0) {
296
+ result.ornaments = ornaments;
297
+ }
298
+ }
299
+
300
+ // Fermata
301
+ if (hasElement(notationsEl, 'fermata')) {
302
+ result.fermata = true;
303
+ }
304
+
305
+ // Arpeggiate
306
+ if (hasElement(notationsEl, 'arpeggiate')) {
307
+ result.arpeggiate = true;
308
+ }
309
+
310
+ // Tremolo
311
+ const tremoloEl = notationsEl.getElementsByTagName('tremolo')[0];
312
+ if (tremoloEl) {
313
+ const tremoloType = getAttribute(tremoloEl, 'type') as 'single' | 'start' | 'stop' || 'single';
314
+ const tremoloValue = parseInt(tremoloEl.textContent || '3', 10);
315
+ result.tremolo = { type: tremoloType, value: tremoloValue };
316
+ }
317
+
318
+ // Tuplet
319
+ const tupletEl = notationsEl.getElementsByTagName('tuplet')[0];
320
+ if (tupletEl) {
321
+ result.tuplet = {
322
+ type: getAttribute(tupletEl, 'type') as 'start' | 'stop',
323
+ number: getAttributeNumber(tupletEl, 'number') || 1,
324
+ };
325
+ }
326
+
327
+ return result;
328
+ };
329
+
330
+ /**
331
+ * Parse <note> element
332
+ */
333
+ const parseNote = (noteEl: Element, divisions: number): MusicXmlNote => {
334
+ const isChord = hasElement(noteEl, 'chord');
335
+ const isRest = hasElement(noteEl, 'rest');
336
+ const isGrace = hasElement(noteEl, 'grace');
337
+
338
+ let pitch: MusicXmlPitch | undefined;
339
+ const pitchEl = noteEl.getElementsByTagName('pitch')[0];
340
+ if (pitchEl) {
341
+ pitch = parsePitchRaw(pitchEl);
342
+ }
343
+
344
+ // Duration
345
+ const durationVal = getElementInt(noteEl, 'duration') || 0;
346
+ const typeText = getElementText(noteEl, 'type');
347
+ const dotCount = getElements(noteEl, 'dot').length;
348
+
349
+ // Time modification (tuplets)
350
+ let timeModification: { actualNotes: number; normalNotes: number } | undefined;
351
+ const timeModEl = noteEl.getElementsByTagName('time-modification')[0];
352
+ if (timeModEl) {
353
+ const actual = getElementInt(timeModEl, 'actual-notes');
354
+ const normal = getElementInt(timeModEl, 'normal-notes');
355
+ if (actual && normal) {
356
+ timeModification = { actualNotes: actual, normalNotes: normal };
357
+ }
358
+ }
359
+
360
+ const duration = convertDuration(divisions, durationVal, typeText, dotCount, timeModification);
361
+
362
+ // Voice and staff
363
+ const voice = getElementInt(noteEl, 'voice') || 1;
364
+ const staff = getElementInt(noteEl, 'staff');
365
+
366
+ // Stem direction
367
+ const stemText = getElementText(noteEl, 'stem');
368
+ const stem = stemText ? convertStemDirection(stemText) : undefined;
369
+
370
+ // Notations
371
+ let notations: MusicXmlNotations | undefined;
372
+ const notationsEl = noteEl.getElementsByTagName('notations')[0];
373
+ if (notationsEl) {
374
+ notations = parseNotations(notationsEl);
375
+ }
376
+
377
+ // Fingering
378
+ let fingering: number | undefined;
379
+ const technicalEl = noteEl.getElementsByTagName('technical')[0];
380
+ if (technicalEl) {
381
+ const fingeringText = getElementText(technicalEl, 'fingering');
382
+ if (fingeringText) {
383
+ fingering = parseInt(fingeringText, 10);
384
+ }
385
+ }
386
+
387
+ // Beams - direct children of note, not in notations
388
+ // We only care about primary beam (number="1") for begin/end
389
+ let beams: { type: 'begin' | 'continue' | 'end'; number: number }[] | undefined;
390
+ const beamEls = getElements(noteEl, 'beam');
391
+ if (beamEls.length > 0) {
392
+ beams = beamEls.map(el => ({
393
+ type: (el.textContent?.trim() || 'continue') as 'begin' | 'continue' | 'end',
394
+ number: getAttributeNumber(el, 'number') || 1,
395
+ }));
396
+ }
397
+
398
+ return {
399
+ isChord,
400
+ isRest,
401
+ isGrace,
402
+ pitch,
403
+ duration: {
404
+ divisions: durationVal,
405
+ type: typeText,
406
+ dots: dotCount,
407
+ timeModification,
408
+ },
409
+ voice,
410
+ staff,
411
+ stem: stem as any,
412
+ notations,
413
+ fingering,
414
+ beams,
415
+ };
416
+ };
417
+
418
+ /**
419
+ * Parse <attributes> element
420
+ */
421
+ const parseAttributes = (attrEl: Element): MusicXmlAttributes => {
422
+ const result: MusicXmlAttributes = {};
423
+
424
+ // Divisions
425
+ const divisions = getElementInt(attrEl, 'divisions');
426
+ if (divisions !== undefined) {
427
+ result.divisions = divisions;
428
+ }
429
+
430
+ // Key
431
+ const keyEl = attrEl.getElementsByTagName('key')[0];
432
+ if (keyEl) {
433
+ const fifths = getElementInt(keyEl, 'fifths');
434
+ const mode = getElementText(keyEl, 'mode');
435
+ if (fifths !== undefined) {
436
+ result.key = { fifths, mode };
437
+ }
438
+ }
439
+
440
+ // Time
441
+ const timeEl = attrEl.getElementsByTagName('time')[0];
442
+ if (timeEl) {
443
+ const beats = getElementInt(timeEl, 'beats');
444
+ const beatType = getElementInt(timeEl, 'beat-type');
445
+ if (beats !== undefined && beatType !== undefined) {
446
+ result.time = { beats, beatType };
447
+ }
448
+ }
449
+
450
+ // Clefs - handle multiple clefs for different staves
451
+ const clefEls = getElements(attrEl, 'clef');
452
+ if (clefEls.length > 0) {
453
+ result.clefs = [];
454
+ for (const clefEl of clefEls) {
455
+ const sign = getElementText(clefEl, 'sign');
456
+ const line = getElementInt(clefEl, 'line');
457
+ const octaveChange = getElementInt(clefEl, 'clef-octave-change');
458
+ const staffNum = getAttributeNumber(clefEl, 'number') || 1;
459
+ if (sign) {
460
+ result.clefs.push({
461
+ staff: staffNum,
462
+ clef: { sign, line, clefOctaveChange: octaveChange },
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ // Staves
469
+ const staves = getElementInt(attrEl, 'staves');
470
+ if (staves !== undefined) {
471
+ result.staves = staves;
472
+ }
473
+
474
+ return result;
475
+ };
476
+
477
+ /**
478
+ * Parse <direction> element
479
+ */
480
+ const parseDirection = (dirEl: Element): MusicXmlDirection => {
481
+ const result: MusicXmlDirection = {};
482
+
483
+ result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
484
+ result.staff = getElementInt(dirEl, 'staff');
485
+
486
+ const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
487
+ if (!dirTypeEl) {
488
+ return result;
489
+ }
490
+
491
+ // Dynamics
492
+ const dynamicsEl = dirTypeEl.getElementsByTagName('dynamics')[0];
493
+ if (dynamicsEl) {
494
+ const dynamics: { type: string }[] = [];
495
+ for (const child of getChildElements(dynamicsEl)) {
496
+ dynamics.push({ type: child.tagName });
497
+ }
498
+ if (dynamics.length > 0) {
499
+ result.dynamics = dynamics;
500
+ }
501
+ }
502
+
503
+ // Wedge (hairpin)
504
+ const wedgeEl = dirTypeEl.getElementsByTagName('wedge')[0];
505
+ if (wedgeEl) {
506
+ const type = getAttribute(wedgeEl, 'type') as 'crescendo' | 'diminuendo' | 'stop';
507
+ const number = getAttributeNumber(wedgeEl, 'number');
508
+ if (type) {
509
+ result.wedge = { type, number };
510
+ }
511
+ }
512
+
513
+ // Pedal
514
+ const pedalEl = dirTypeEl.getElementsByTagName('pedal')[0];
515
+ if (pedalEl) {
516
+ const type = getAttribute(pedalEl, 'type') as 'start' | 'stop' | 'change';
517
+ const line = getAttribute(pedalEl, 'line') === 'yes';
518
+ if (type) {
519
+ result.pedal = { type, line };
520
+ }
521
+ }
522
+
523
+ // Metronome
524
+ const metronomeEl = dirTypeEl.getElementsByTagName('metronome')[0];
525
+ if (metronomeEl) {
526
+ const beatUnit = getElementText(metronomeEl, 'beat-unit');
527
+ const beatUnitDot = hasElement(metronomeEl, 'beat-unit-dot');
528
+ const perMinute = getElementInt(metronomeEl, 'per-minute');
529
+ if (beatUnit && perMinute !== undefined) {
530
+ result.metronome = { beatUnit, beatUnitDot, perMinute };
531
+ }
532
+ }
533
+
534
+ // Words
535
+ const wordsEls = getElements(dirTypeEl, 'words');
536
+ if (wordsEls.length > 0) {
537
+ result.words = wordsEls.map(el => ({
538
+ text: el.textContent || '',
539
+ fontStyle: getAttribute(el, 'font-style'),
540
+ fontWeight: getAttribute(el, 'font-weight'),
541
+ }));
542
+ }
543
+
544
+ // Octave shift
545
+ const octaveShiftEl = dirTypeEl.getElementsByTagName('octave-shift')[0];
546
+ if (octaveShiftEl) {
547
+ const type = getAttribute(octaveShiftEl, 'type') as 'up' | 'down' | 'stop';
548
+ const size = getAttributeNumber(octaveShiftEl, 'size');
549
+ if (type) {
550
+ result.octaveShift = { type, size };
551
+ }
552
+ }
553
+
554
+ // Coda and Segno
555
+ if (hasElement(dirTypeEl, 'coda')) {
556
+ result.coda = true;
557
+ }
558
+ if (hasElement(dirTypeEl, 'segno')) {
559
+ result.segno = true;
560
+ }
561
+
562
+ return result;
563
+ };
564
+
565
+ /**
566
+ * Parse <barline> element
567
+ */
568
+ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
569
+ const result: MusicXmlBarline = {};
570
+
571
+ result.location = getAttribute(barlineEl, 'location') as 'left' | 'right' | 'middle' | undefined;
572
+ result.barStyle = getElementText(barlineEl, 'bar-style');
573
+
574
+ const repeatEl = barlineEl.getElementsByTagName('repeat')[0];
575
+ if (repeatEl) {
576
+ const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
577
+ if (direction) {
578
+ result.repeat = { direction };
579
+ }
580
+ }
581
+
582
+ const endingEl = barlineEl.getElementsByTagName('ending')[0];
583
+ if (endingEl) {
584
+ const type = getAttribute(endingEl, 'type') as 'start' | 'stop' | 'discontinue';
585
+ const number = getAttribute(endingEl, 'number') || '1';
586
+ if (type) {
587
+ result.ending = { type, number };
588
+ }
589
+ }
590
+
591
+ return result;
592
+ };
593
+
594
+ /**
595
+ * Parse <harmony> element
596
+ */
597
+ const parseHarmony = (harmonyEl: Element): MusicXmlHarmony | undefined => {
598
+ const rootEl = harmonyEl.getElementsByTagName('root')[0];
599
+ if (!rootEl) {
600
+ return undefined;
601
+ }
602
+
603
+ const rootStep = getElementText(rootEl, 'root-step');
604
+ const rootAlter = getElementInt(rootEl, 'root-alter');
605
+ if (!rootStep) {
606
+ return undefined;
607
+ }
608
+
609
+ const kind = getElementText(harmonyEl, 'kind') || 'major';
610
+
611
+ const result: MusicXmlHarmony = {
612
+ root: { step: rootStep, alter: rootAlter },
613
+ kind,
614
+ };
615
+
616
+ const bassEl = harmonyEl.getElementsByTagName('bass')[0];
617
+ if (bassEl) {
618
+ const bassStep = getElementText(bassEl, 'bass-step');
619
+ const bassAlter = getElementInt(bassEl, 'bass-alter');
620
+ if (bassStep) {
621
+ result.bass = { step: bassStep, alter: bassAlter };
622
+ }
623
+ }
624
+
625
+ return result;
626
+ };
627
+
628
+ /**
629
+ * Parse metadata from score header
630
+ */
631
+ const parseMetadata = (doc: Document): Metadata => {
632
+ const metadata: Metadata = {};
633
+
634
+ // Work title
635
+ const workTitleEl = doc.getElementsByTagName('work-title')[0];
636
+ if (workTitleEl?.textContent) {
637
+ metadata.title = workTitleEl.textContent.trim();
638
+ }
639
+
640
+ // Movement title (fallback for title)
641
+ const movementTitleEl = doc.getElementsByTagName('movement-title')[0];
642
+ if (movementTitleEl?.textContent && !metadata.title) {
643
+ metadata.title = movementTitleEl.textContent.trim();
644
+ }
645
+
646
+ // Identification (composer, arranger, lyricist)
647
+ const identificationEl = doc.getElementsByTagName('identification')[0];
648
+ if (identificationEl) {
649
+ const creators = getElements(identificationEl, 'creator');
650
+ for (const creator of creators) {
651
+ const type = getAttribute(creator, 'type');
652
+ const text = creator.textContent?.trim();
653
+ if (text) {
654
+ if (type === 'composer') {
655
+ metadata.composer = text;
656
+ } else if (type === 'arranger') {
657
+ metadata.arranger = text;
658
+ } else if (type === 'lyricist' || type === 'poet') {
659
+ metadata.lyricist = text;
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ return Object.keys(metadata).length > 0 ? metadata : {};
666
+ };
667
+
668
+ // ============ Conversion Functions ============
669
+
670
+ /**
671
+ * Convert MusicXML notations to Lilylet marks
672
+ */
673
+ const notationsToMarks = (
674
+ notations: MusicXmlNotations | undefined,
675
+ spannerTracker: SpannerTracker,
676
+ pitches: Pitch[]
677
+ ): Mark[] => {
678
+ const marks: Mark[] = [];
679
+
680
+ if (!notations) {
681
+ return marks;
682
+ }
683
+
684
+ // Ties
685
+ if (notations.ties) {
686
+ for (const tie of notations.ties) {
687
+ if (tie.type === 'start') {
688
+ marks.push({ markType: 'tie', start: true });
689
+ // Track tie for each pitch
690
+ for (const p of pitches) {
691
+ spannerTracker.startTie(p);
692
+ }
693
+ }
694
+ // Note: tie stop doesn't need an explicit mark in Lilylet
695
+ }
696
+ }
697
+
698
+ // Slurs
699
+ if (notations.slurs) {
700
+ for (const slur of notations.slurs) {
701
+ if (slur.type === 'start') {
702
+ marks.push({ markType: 'slur', start: true });
703
+ spannerTracker.startSlur(slur.number);
704
+ } else if (slur.type === 'stop') {
705
+ if (spannerTracker.stopSlur(slur.number)) {
706
+ marks.push({ markType: 'slur', start: false });
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ // Articulations
713
+ if (notations.articulations) {
714
+ for (const artName of notations.articulations) {
715
+ const artType = convertArticulation(artName);
716
+ if (artType) {
717
+ marks.push({ markType: 'articulation', type: artType });
718
+ }
719
+ }
720
+ }
721
+
722
+ // Ornaments
723
+ if (notations.ornaments) {
724
+ for (const ornName of notations.ornaments) {
725
+ const ornType = convertOrnament(ornName);
726
+ if (ornType) {
727
+ marks.push({ markType: 'ornament', type: ornType });
728
+ }
729
+ }
730
+ }
731
+
732
+ // Fermata
733
+ if (notations.fermata) {
734
+ marks.push({ markType: 'ornament', type: 'fermata' as any });
735
+ }
736
+
737
+ // Arpeggiate
738
+ if (notations.arpeggiate) {
739
+ marks.push({ markType: 'ornament', type: 'arpeggio' as any });
740
+ }
741
+
742
+ return marks;
743
+ };
744
+
745
+ // MusicXML beat-unit to division mapping
746
+ const BEAT_UNIT_TO_DIVISION: Record<string, number> = {
747
+ 'maxima': 0.125,
748
+ 'long': 0.25,
749
+ 'breve': 0.5,
750
+ 'whole': 1,
751
+ 'half': 2,
752
+ 'quarter': 4,
753
+ 'eighth': 8,
754
+ '16th': 16,
755
+ '32nd': 32,
756
+ '64th': 64,
757
+ };
758
+
759
+ // Common tempo words that should be converted to \tempo
760
+ const TEMPO_WORDS = new Set([
761
+ // Very slow
762
+ 'largo', 'larghetto', 'grave', 'lento', 'adagio',
763
+ // Slow
764
+ 'andante', 'andantino',
765
+ // Moderate
766
+ 'moderato', 'allegretto',
767
+ // Fast
768
+ 'allegro', 'vivace', 'presto', 'prestissimo',
769
+ // Other tempo indications
770
+ 'tempo', 'a tempo', 'tempo i', 'tempo primo',
771
+ // With modifiers (partial matches)
772
+ ]);
773
+
774
+ /**
775
+ * Check if text is a tempo word
776
+ */
777
+ const isTempoWord = (text: string): boolean => {
778
+ const lower = text.toLowerCase().trim();
779
+ // Check exact match
780
+ if (TEMPO_WORDS.has(lower)) return true;
781
+ // Check if starts with tempo word (e.g., "Allegro moderato", "Andante con moto")
782
+ for (const word of TEMPO_WORDS) {
783
+ if (lower.startsWith(word)) return true;
784
+ }
785
+ return false;
786
+ };
787
+
788
+ /**
789
+ * Convert direction to context change (tempo, ottava)
790
+ */
791
+ const directionToContextChange = (
792
+ direction: MusicXmlDirection,
793
+ ottavaTracker: { current: number }
794
+ ): ContextChange | undefined => {
795
+ // Metronome → Tempo (may combine with words)
796
+ if (direction.metronome) {
797
+ const { beatUnit, beatUnitDot, perMinute } = direction.metronome;
798
+ const division = BEAT_UNIT_TO_DIVISION[beatUnit] || 4;
799
+
800
+ // Check if there's accompanying tempo text
801
+ let tempoText: string | undefined;
802
+ if (direction.words && direction.words.length > 0) {
803
+ const text = direction.words[0].text.trim();
804
+ if (isTempoWord(text)) {
805
+ tempoText = text;
806
+ }
807
+ }
808
+
809
+ return {
810
+ type: 'context',
811
+ tempo: {
812
+ text: tempoText,
813
+ beat: {
814
+ division,
815
+ dots: beatUnitDot ? 1 : 0,
816
+ },
817
+ bpm: perMinute,
818
+ },
819
+ };
820
+ }
821
+
822
+ // Words alone that are tempo indications → Tempo (text only)
823
+ if (direction.words && direction.words.length > 0 && !direction.metronome) {
824
+ const text = direction.words[0].text.trim();
825
+ if (isTempoWord(text)) {
826
+ return {
827
+ type: 'context',
828
+ tempo: {
829
+ text,
830
+ },
831
+ };
832
+ }
833
+ }
834
+
835
+ // Octave shift → Ottava
836
+ if (direction.octaveShift) {
837
+ const { type, size = 8 } = direction.octaveShift;
838
+ let ottava: number;
839
+ if (type === 'stop') {
840
+ ottava = 0;
841
+ ottavaTracker.current = 0;
842
+ } else if (type === 'up') {
843
+ // 8va = 1, 15ma = 2
844
+ ottava = size === 15 ? 2 : 1;
845
+ ottavaTracker.current = ottava;
846
+ } else if (type === 'down') {
847
+ // 8vb = -1, 15mb = -2
848
+ ottava = size === 15 ? -2 : -1;
849
+ ottavaTracker.current = ottava;
850
+ } else {
851
+ return undefined;
852
+ }
853
+ return {
854
+ type: 'context',
855
+ ottava,
856
+ };
857
+ }
858
+
859
+ return undefined;
860
+ };
861
+
862
+ /**
863
+ * Convert direction to marks
864
+ */
865
+ const directionToMarks = (
866
+ direction: MusicXmlDirection,
867
+ spannerTracker: SpannerTracker
868
+ ): Mark[] => {
869
+ const marks: Mark[] = [];
870
+
871
+ // Dynamics
872
+ if (direction.dynamics) {
873
+ for (const dyn of direction.dynamics) {
874
+ const dynType = convertDynamic(dyn.type);
875
+ if (dynType) {
876
+ marks.push({ markType: 'dynamic', type: dynType });
877
+ }
878
+ }
879
+ }
880
+
881
+ // Wedge (hairpin)
882
+ if (direction.wedge) {
883
+ const { type, number = 1 } = direction.wedge;
884
+ if (type === 'crescendo') {
885
+ marks.push({ markType: 'hairpin', type: HairpinType.crescendoStart });
886
+ spannerTracker.startWedge('crescendo', number);
887
+ } else if (type === 'diminuendo') {
888
+ marks.push({ markType: 'hairpin', type: HairpinType.diminuendoStart });
889
+ spannerTracker.startWedge('diminuendo', number);
890
+ } else if (type === 'stop') {
891
+ const wedgeType = spannerTracker.stopWedge(number);
892
+ if (wedgeType === 'crescendo') {
893
+ marks.push({ markType: 'hairpin', type: HairpinType.crescendoEnd });
894
+ } else if (wedgeType === 'diminuendo') {
895
+ marks.push({ markType: 'hairpin', type: HairpinType.diminuendoEnd });
896
+ } else {
897
+ // Unknown wedge type, default to crescendo end
898
+ marks.push({ markType: 'hairpin', type: HairpinType.crescendoEnd });
899
+ }
900
+ }
901
+ }
902
+
903
+ // Pedal
904
+ if (direction.pedal) {
905
+ const pedalType = convertPedal(direction.pedal.type);
906
+ if (pedalType) {
907
+ marks.push({ markType: 'pedal', type: pedalType });
908
+ }
909
+ }
910
+
911
+ // Coda
912
+ if (direction.coda) {
913
+ marks.push({ markType: 'navigation', type: NavigationMarkType.coda });
914
+ }
915
+
916
+ // Segno
917
+ if (direction.segno) {
918
+ marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
919
+ }
920
+
921
+ return marks;
922
+ };
923
+
924
+ /**
925
+ * Result of converting a measure - now includes voices grouped by voice number
926
+ */
927
+ interface MeasureConversionResult {
928
+ voiceMap: Map<number, { events: Event[]; staff: number }>;
929
+ key?: KeySignature;
930
+ timeSig?: Fraction;
931
+ barline?: BarlineEvent;
932
+ harmonies: HarmonyEvent[];
933
+ clefs: Map<number, ContextChange>; // staff number → clef context
934
+ }
935
+
936
+ /**
937
+ * Convert a MusicXML measure to Lilylet events, grouped by voice
938
+ */
939
+ const convertMeasure = (
940
+ measureEl: Element,
941
+ voiceTracker: VoiceTracker,
942
+ spannerTracker: SpannerTracker,
943
+ ottavaTracker: { current: number }
944
+ ): MeasureConversionResult => {
945
+ let key: KeySignature | undefined;
946
+ let timeSig: Fraction | undefined;
947
+ let barline: BarlineEvent | undefined;
948
+ const harmonies: HarmonyEvent[] = [];
949
+ const clefs: Map<number, ContextChange> = new Map();
950
+
951
+ // Pending marks from directions (to attach to next note), per voice
952
+ const pendingMarks: Map<number, Mark[]> = new Map();
953
+ // Pending context changes (tempo, ottava) to insert before next note
954
+ const pendingContextChanges: ContextChange[] = [];
955
+ let currentVoice = 1; // Track current voice for directions
956
+
957
+ // Process all children in order
958
+ for (const child of getChildElements(measureEl)) {
959
+ const tagName = child.tagName;
960
+
961
+ if (tagName === 'attributes') {
962
+ const attrs = parseAttributes(child);
963
+
964
+ if (attrs.divisions !== undefined) {
965
+ voiceTracker.setDivisions(attrs.divisions);
966
+ }
967
+
968
+ if (attrs.staves !== undefined) {
969
+ voiceTracker.setStaves(attrs.staves);
970
+ }
971
+
972
+ // Key signature
973
+ if (attrs.key) {
974
+ key = convertKeySignature(attrs.key.fifths, attrs.key.mode);
975
+ }
976
+
977
+ // Time signature
978
+ if (attrs.time) {
979
+ timeSig = createFraction(attrs.time.beats, attrs.time.beatType);
980
+ }
981
+
982
+ // Clefs - store by staff number
983
+ if (attrs.clefs) {
984
+ for (const clefEntry of attrs.clefs) {
985
+ const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
986
+ if (clef) {
987
+ clefs.set(clefEntry.staff, { type: 'context', clef });
988
+ }
989
+ }
990
+ }
991
+ } else if (tagName === 'note') {
992
+ const note = parseNote(child, voiceTracker.getDivisions());
993
+ const voiceNum = note.voice;
994
+ const staffNum = note.staff || 1;
995
+ currentVoice = voiceNum;
996
+
997
+ // Add any pending context changes before the note (tempo, ottava)
998
+ if (pendingContextChanges.length > 0) {
999
+ for (const ctx of pendingContextChanges) {
1000
+ voiceTracker.addEvent(voiceNum, ctx, 0, staffNum);
1001
+ }
1002
+ pendingContextChanges.length = 0; // Clear
1003
+ }
1004
+
1005
+ // Get pending marks for this voice
1006
+ const marks: Mark[] = pendingMarks.get(voiceNum) || [];
1007
+ pendingMarks.delete(voiceNum);
1008
+
1009
+ if (note.isRest) {
1010
+ // Rest event
1011
+ const duration = convertDuration(
1012
+ voiceTracker.getDivisions(),
1013
+ note.duration.divisions,
1014
+ note.duration.type,
1015
+ note.duration.dots,
1016
+ note.duration.timeModification
1017
+ );
1018
+
1019
+ const restEvent: RestEvent = {
1020
+ type: 'rest',
1021
+ duration,
1022
+ };
1023
+
1024
+ // Grace notes don't advance time
1025
+ const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
1026
+ voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
1027
+ } else if (note.pitch) {
1028
+ // Note or chord - convert MusicXmlPitch to Lilylet Pitch
1029
+ const lilyletPitch = musicXmlPitchToLilylet(note.pitch);
1030
+
1031
+ // Get marks from notations
1032
+ const notationMarks = notationsToMarks(note.notations, spannerTracker, [lilyletPitch]);
1033
+ marks.push(...notationMarks);
1034
+
1035
+ // Add fingering
1036
+ if (note.fingering !== undefined && note.fingering >= 1 && note.fingering <= 5) {
1037
+ marks.push({ markType: 'fingering', finger: note.fingering });
1038
+ }
1039
+
1040
+ // Handle chord: merge with previous note in same voice
1041
+ if (note.isChord) {
1042
+ const lastEvent = voiceTracker.getLastEvent(voiceNum);
1043
+ if (lastEvent && lastEvent.type === 'note') {
1044
+ lastEvent.pitches.push(lilyletPitch);
1045
+ // Merge marks
1046
+ if (marks.length > 0) {
1047
+ lastEvent.marks = [...(lastEvent.marks || []), ...marks];
1048
+ }
1049
+ continue; // Don't create a new event
1050
+ }
1051
+ }
1052
+
1053
+ const duration = convertDuration(
1054
+ voiceTracker.getDivisions(),
1055
+ note.duration.divisions,
1056
+ note.duration.type,
1057
+ note.duration.dots,
1058
+ note.duration.timeModification
1059
+ );
1060
+
1061
+ const noteEvent: NoteEvent = {
1062
+ type: 'note',
1063
+ pitches: [lilyletPitch],
1064
+ duration,
1065
+ grace: note.isGrace || undefined,
1066
+ staff: staffNum > 1 ? staffNum : undefined, // Only include if cross-staff
1067
+ stemDirection: note.stem ? convertStemDirection(note.stem) : undefined,
1068
+ };
1069
+
1070
+ // Add single tremolo
1071
+ if (note.notations?.tremolo?.type === 'single') {
1072
+ // Convert tremolo value (number of beams) to division
1073
+ // 1 beam = 8th, 2 beams = 16th, 3 beams = 32nd
1074
+ noteEvent.tremolo = Math.pow(2, note.notations.tremolo.value + 2);
1075
+ }
1076
+
1077
+ // Add beam marks - only care about primary beam (number=1)
1078
+ if (note.beams) {
1079
+ const primaryBeam = note.beams.find(b => b.number === 1);
1080
+ if (primaryBeam) {
1081
+ if (primaryBeam.type === 'begin') {
1082
+ marks.push({ markType: 'beam', start: true });
1083
+ } else if (primaryBeam.type === 'end') {
1084
+ marks.push({ markType: 'beam', start: false });
1085
+ }
1086
+ // 'continue' doesn't need a mark
1087
+ }
1088
+ }
1089
+
1090
+ if (marks.length > 0) {
1091
+ noteEvent.marks = marks;
1092
+ }
1093
+
1094
+ // Grace notes don't advance time
1095
+ const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
1096
+ voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
1097
+ }
1098
+ } else if (tagName === 'direction') {
1099
+ const direction = parseDirection(child);
1100
+
1101
+ // Handle context changes (tempo, ottava)
1102
+ const contextChange = directionToContextChange(direction, ottavaTracker);
1103
+ if (contextChange) {
1104
+ pendingContextChanges.push(contextChange);
1105
+ }
1106
+
1107
+ // Handle marks (dynamics, hairpins, etc.)
1108
+ const marks = directionToMarks(direction, spannerTracker);
1109
+ if (marks.length > 0) {
1110
+ // Store marks to attach to next note in current voice
1111
+ const existing = pendingMarks.get(currentVoice) || [];
1112
+ pendingMarks.set(currentVoice, [...existing, ...marks]);
1113
+ }
1114
+ } else if (tagName === 'backup') {
1115
+ const duration = getElementInt(child, 'duration') || 0;
1116
+ voiceTracker.backup(duration);
1117
+ } else if (tagName === 'forward') {
1118
+ const duration = getElementInt(child, 'duration') || 0;
1119
+ voiceTracker.forward(duration);
1120
+ } else if (tagName === 'barline') {
1121
+ const barlineData = parseBarline(child);
1122
+ const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
1123
+ if (style && style !== '|') {
1124
+ barline = { type: 'barline', style };
1125
+ }
1126
+ } else if (tagName === 'harmony') {
1127
+ const harmonyData = parseHarmony(child);
1128
+ if (harmonyData) {
1129
+ const text = convertHarmonyToText(
1130
+ harmonyData.root.step,
1131
+ harmonyData.root.alter,
1132
+ harmonyData.kind,
1133
+ harmonyData.bass?.step,
1134
+ harmonyData.bass?.alter
1135
+ );
1136
+ harmonies.push({ type: 'harmony', text });
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ // Build voice map from tracker
1142
+ const voiceMap = new Map<number, { events: Event[]; staff: number }>();
1143
+ for (const [voiceNum, voiceState] of voiceTracker.getVoices()) {
1144
+ voiceMap.set(voiceNum, {
1145
+ events: voiceState.events,
1146
+ staff: voiceState.staff,
1147
+ });
1148
+ }
1149
+
1150
+ return { voiceMap, key, timeSig, barline, harmonies, clefs };
1151
+ };
1152
+
1153
+ /**
1154
+ * Convert a MusicXML part to Lilylet measures
1155
+ */
1156
+ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } => {
1157
+ const measures: Measure[] = [];
1158
+ const voiceTracker = new VoiceTracker();
1159
+ const spannerTracker = new SpannerTracker();
1160
+ const ottavaTracker = { current: 0 };
1161
+
1162
+ let lastKey: KeySignature | undefined;
1163
+ let lastTimeSig: Fraction | undefined;
1164
+ let isFirstMeasure = true;
1165
+ const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
1166
+
1167
+ const measureEls = getDirectChildren(partEl, 'measure');
1168
+
1169
+ for (const measureEl of measureEls) {
1170
+ voiceTracker.reset();
1171
+ const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
1172
+
1173
+ // Update running key/time
1174
+ if (key) lastKey = key;
1175
+ if (timeSig) lastTimeSig = timeSig;
1176
+
1177
+ // Build voices from voice map, sorted by voice number
1178
+ const voiceNumbers = Array.from(voiceMap.keys()).sort((a, b) => a - b);
1179
+ const voices: Voice[] = [];
1180
+
1181
+ // Track which staves have had clef added (for this measure)
1182
+ const staffsWithClef = new Set<number>();
1183
+
1184
+ for (const voiceNum of voiceNumbers) {
1185
+ const voiceData = voiceMap.get(voiceNum)!;
1186
+ const events: Event[] = [];
1187
+
1188
+ // Add clef at start of first voice for each staff
1189
+ // For first measure: always add initial clef
1190
+ // For subsequent measures: add clef if there's a clef change
1191
+ if (!staffsWithClef.has(voiceData.staff)) {
1192
+ const clef = clefs.get(voiceData.staff);
1193
+ if (clef) {
1194
+ // Check if this is a clef change (not first measure) or initial clef (first measure)
1195
+ if (isFirstMeasure) {
1196
+ events.push(clef);
1197
+ lastClefs.set(voiceData.staff, clef);
1198
+ } else {
1199
+ // Only add if it's different from the last clef for this staff
1200
+ const lastClef = lastClefs.get(voiceData.staff);
1201
+ const isSameClef = lastClef &&
1202
+ (lastClef as ContextChange).clef === (clef as ContextChange).clef;
1203
+ if (!isSameClef) {
1204
+ events.push(clef);
1205
+ lastClefs.set(voiceData.staff, clef);
1206
+ }
1207
+ }
1208
+ }
1209
+ staffsWithClef.add(voiceData.staff);
1210
+ }
1211
+
1212
+ // Add voice events
1213
+ events.push(...voiceData.events);
1214
+
1215
+ // Add harmonies and barline to first voice only
1216
+ if (voiceNum === voiceNumbers[0]) {
1217
+ for (const h of harmonies) {
1218
+ events.push(h);
1219
+ }
1220
+ if (barline) {
1221
+ events.push(barline);
1222
+ }
1223
+ }
1224
+
1225
+ voices.push({
1226
+ staff: voiceData.staff,
1227
+ events,
1228
+ });
1229
+ }
1230
+
1231
+ // If no voices found, create an empty one
1232
+ if (voices.length === 0) {
1233
+ voices.push({ staff: 1, events: [] });
1234
+ }
1235
+
1236
+ const measure: Measure = {
1237
+ parts: [{
1238
+ voices,
1239
+ }],
1240
+ };
1241
+
1242
+ // Only include key/time if they changed
1243
+ if (key) measure.key = key;
1244
+ if (timeSig) measure.timeSig = timeSig;
1245
+
1246
+ measures.push(measure);
1247
+ isFirstMeasure = false;
1248
+ }
1249
+
1250
+ return { measures };
1251
+ };
1252
+
1253
+ // ============ Main Decoder Function ============
1254
+
1255
+ /**
1256
+ * Decode MusicXML string to LilyletDoc
1257
+ */
1258
+ export const decode = (xmlString: string): LilyletDoc => {
1259
+ const parser = new DOMParser();
1260
+ const doc = parser.parseFromString(xmlString, 'application/xml');
1261
+
1262
+ // Check for parsing errors
1263
+ const parseError = doc.getElementsByTagName('parsererror')[0];
1264
+ if (parseError) {
1265
+ throw new Error(`XML parsing error: ${parseError.textContent}`);
1266
+ }
1267
+
1268
+ // Get root element
1269
+ const root = doc.documentElement;
1270
+ if (!root || (root.tagName !== 'score-partwise' && root.tagName !== 'score-timewise')) {
1271
+ throw new Error(`Invalid MusicXML: expected score-partwise or score-timewise, got ${root?.tagName}`);
1272
+ }
1273
+
1274
+ // Parse metadata
1275
+ const metadata = parseMetadata(doc);
1276
+
1277
+ // Get parts
1278
+ const partEls = Array.from(doc.getElementsByTagName('part'));
1279
+ if (partEls.length === 0) {
1280
+ throw new Error('No parts found in MusicXML');
1281
+ }
1282
+
1283
+ // For now, convert only the first part
1284
+ // TODO: Handle multiple parts
1285
+ const firstPart = partEls[0];
1286
+ const { measures } = convertPart(firstPart);
1287
+
1288
+ const result: LilyletDoc = {
1289
+ measures,
1290
+ };
1291
+
1292
+ if (Object.keys(metadata).length > 0) {
1293
+ result.metadata = metadata;
1294
+ }
1295
+
1296
+ return result;
1297
+ };
1298
+
1299
+ /**
1300
+ * Decode MusicXML file to LilyletDoc
1301
+ */
1302
+ export const decodeFile = async (filePath: string): Promise<LilyletDoc> => {
1303
+ const fs = await import('fs/promises');
1304
+ const content = await fs.readFile(filePath, 'utf-8');
1305
+ return decode(content);
1306
+ };
1307
+
1308
+ export default {
1309
+ decode,
1310
+ decodeFile,
1311
+ };