@k-l-lambda/lilylet 0.1.48 → 0.1.50

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