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