@k-l-lambda/lilylet 0.1.71 → 0.1.73

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