@k-l-lambda/lilylet 0.1.49 → 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.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1808 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +653 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +170 -165
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +280 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +3 -0
- package/source/lilylet/types.ts +1 -0
package/lib/musicXmlDecoder.js
CHANGED
|
@@ -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";
|