@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.
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 +91 -41
  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
package/lib/meiEncoder.js CHANGED
@@ -1,1545 +1 @@
1
- import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
- // MEI key signatures: positive = sharps, negative = flats
3
- const KEY_SIGS = {
4
- 0: "0",
5
- 1: "1s",
6
- 2: "2s",
7
- 3: "3s",
8
- 4: "4s",
9
- 5: "5s",
10
- 6: "6s",
11
- 7: "7s",
12
- [-1]: "1f",
13
- [-2]: "2f",
14
- [-3]: "3f",
15
- [-4]: "4f",
16
- [-5]: "5f",
17
- [-6]: "6f",
18
- [-7]: "7f",
19
- };
20
- // Key signature to fifths number
21
- const keyToFifths = (key) => {
22
- if (!key)
23
- return 0;
24
- // Major keys
25
- const majorKeys = {
26
- 'c': 0, 'd': 2, 'e': 4, 'f': -1, 'g': 1, 'a': 3, 'b': 5,
27
- };
28
- let fifths = majorKeys[key.pitch] || 0;
29
- if (key.accidental === Accidental.sharp)
30
- fifths += 7;
31
- else if (key.accidental === Accidental.flat)
32
- fifths -= 7;
33
- if (key.mode === 'minor')
34
- fifths -= 3;
35
- // Clamp to valid range [-7, 7] since standard notation doesn't support more than 7 sharps/flats
36
- return Math.max(-7, Math.min(7, fifths));
37
- };
38
- const CLEF_SHAPES = {
39
- treble: { shape: "G", line: 2 },
40
- bass: { shape: "F", line: 4 },
41
- alto: { shape: "C", line: 3 },
42
- // Also support uppercase letter clef names
43
- G: { shape: "G", line: 2 },
44
- F: { shape: "F", line: 4 },
45
- C: { shape: "C", line: 3 },
46
- };
47
- // Lilylet duration division to MEI dur
48
- // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
49
- const DURATIONS = {
50
- 1: "1", // whole
51
- 2: "2", // half
52
- 4: "4", // quarter
53
- 8: "8", // eighth
54
- 16: "16",
55
- 32: "32",
56
- 64: "64",
57
- 128: "128",
58
- };
59
- // Accidental mapping
60
- const ACCIDENTALS = {
61
- natural: "n",
62
- sharp: "s",
63
- flat: "f",
64
- doubleSharp: "x",
65
- doubleFlat: "ff",
66
- };
67
- // Articulation to MEI artic
68
- const ARTIC_MAP = {
69
- staccato: "stacc",
70
- staccatissimo: "stacciss",
71
- tenuto: "ten",
72
- marcato: "marc",
73
- accent: "acc",
74
- portato: "stacc ten", // Both staccato and tenuto (portato)
75
- };
76
- // Dynamic to MEI
77
- const DYNAMIC_MAP = {
78
- ppp: "ppp",
79
- pp: "pp",
80
- p: "p",
81
- mp: "mp",
82
- mf: "mf",
83
- f: "f",
84
- ff: "ff",
85
- fff: "fff",
86
- sfz: "sfz",
87
- rfz: "rfz",
88
- };
89
- // ID generation state - uses session prefix to prevent collisions in concurrent encoding
90
- let idCounter = 0;
91
- let sessionPrefix = '';
92
- const generateId = (prefix) => {
93
- return `${prefix}-${sessionPrefix}${String(++idCounter).padStart(10, "0")}`;
94
- };
95
- const resetIdCounter = () => {
96
- idCounter = 0;
97
- // Generate a unique 4-char hex session prefix for this encode call
98
- sessionPrefix = Math.random().toString(16).substring(2, 6);
99
- };
100
- // Sharp and flat order for key signatures (circle of fifths)
101
- const SHARP_ORDER = ['f', 'c', 'g', 'd', 'a', 'e', 'b'];
102
- const FLAT_ORDER = ['b', 'e', 'a', 'd', 'g', 'c', 'f'];
103
- // Get the accidentals implied by a key signature
104
- // fifths > 0 = sharps, fifths < 0 = flats
105
- const getKeyAccidentals = (fifths) => {
106
- const result = {};
107
- if (fifths > 0) {
108
- // Sharps
109
- for (let i = 0; i < Math.min(fifths, 7); i++) {
110
- result[SHARP_ORDER[i]] = 's'; // sharp
111
- }
112
- }
113
- else if (fifths < 0) {
114
- // Flats
115
- for (let i = 0; i < Math.min(-fifths, 7); i++) {
116
- result[FLAT_ORDER[i]] = 'f'; // flat
117
- }
118
- }
119
- return result;
120
- };
121
- // Convert Pitch to MEI attributes, checking against key signature
122
- // ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
123
- // The written pitch should be adjusted by subtracting the ottava shift
124
- const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0) => {
125
- // Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
126
- // When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
127
- // For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
128
- const oct = 4 + pitch.octave - ottavaShift;
129
- // Get the accidental implied by the key signature for this note
130
- const keyAccidentals = getKeyAccidentals(keyFifths);
131
- const keyAccid = keyAccidentals[pitch.phonet];
132
- // Determine accid (written/displayed) and accid.ges (gestural/sounding)
133
- let accid;
134
- let accidGes;
135
- if (pitch.accidental) {
136
- const noteAccid = ACCIDENTALS[pitch.accidental];
137
- if (noteAccid !== keyAccid) {
138
- // Accidental differs from key signature - display it
139
- accid = noteAccid;
140
- }
141
- // Always set gestural accidental for MIDI generation
142
- accidGes = noteAccid;
143
- }
144
- else if (keyAccid) {
145
- // Note has no accidental but key implies one - output natural
146
- accid = 'n';
147
- accidGes = 'n';
148
- }
149
- return { pname: pitch.phonet, oct, accid, accidGes };
150
- };
151
- // Convert tremolo division to stem.mod value
152
- const tremoloToStemMod = (division) => {
153
- // 8 = 1slash (eighth note strokes), 16 = 2slash, 32 = 3slash, etc.
154
- const slashes = Math.log2(division) - 2; // 8->1, 16->2, 32->3
155
- if (slashes >= 1 && slashes <= 6) {
156
- return `${slashes}slash`;
157
- }
158
- return undefined;
159
- };
160
- // Build note element
161
- const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteId) => {
162
- const id = noteId || generateId('note');
163
- let attrs = `xml:id="${id}" pname="${pitch.pname}" oct="${pitch.oct}"`;
164
- if (!inChord) {
165
- attrs += ` dur="${dur}"`;
166
- }
167
- if (pitch.accid)
168
- attrs += ` accid="${pitch.accid}"`;
169
- if (pitch.accidGes)
170
- attrs += ` accid.ges="${pitch.accidGes}"`;
171
- if (!inChord && dots > 0)
172
- attrs += ` dots="${dots}"`;
173
- if (!inChord && options.grace)
174
- attrs += ` grace="unacc"`;
175
- if (!inChord && options.tie)
176
- attrs += ` tie="${options.tie}"`;
177
- if (!inChord && options.stemDir)
178
- attrs += ` stem.dir="${options.stemDir}"`;
179
- if (!inChord && options.layerStaff && options.staff && options.staff !== options.layerStaff) {
180
- attrs += ` staff="${options.staff}"`;
181
- }
182
- if (!inChord && options.tremolo) {
183
- const stemMod = tremoloToStemMod(options.tremolo);
184
- if (stemMod)
185
- attrs += ` stem.mod="${stemMod}"`;
186
- }
187
- // Only artics remain as child elements; ornaments are control events
188
- const hasChildren = !inChord && options.artics && options.artics.length > 0;
189
- if (!hasChildren) {
190
- return `${indent}<note ${attrs} />\n`;
191
- }
192
- let result = `${indent}<note ${attrs}>\n`;
193
- if (options.artics && options.artics.length > 0) {
194
- // Group artics by placement
195
- const aboveArtics = options.artics.filter(a => a.placement === 'above').map(a => a.type);
196
- const belowArtics = options.artics.filter(a => a.placement === 'below').map(a => a.type);
197
- const defaultArtics = options.artics.filter(a => !a.placement).map(a => a.type);
198
- if (aboveArtics.length > 0) {
199
- result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
200
- }
201
- if (belowArtics.length > 0) {
202
- result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
203
- }
204
- if (defaultArtics.length > 0) {
205
- result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
206
- }
207
- }
208
- result += `${indent}</note>\n`;
209
- return result;
210
- };
211
- // Extract mark properties from note event
212
- const extractMarkOptions = (marks) => {
213
- const result = {
214
- artics: [],
215
- fermata: false,
216
- trill: false,
217
- arpeggio: false,
218
- turn: false,
219
- mordent: false, // lower = mordent, upper = prall
220
- slurStart: false,
221
- slurEnd: false,
222
- tieStart: false,
223
- beamStart: false,
224
- beamEnd: false,
225
- dynamic: undefined,
226
- hairpin: undefined,
227
- pedal: undefined,
228
- tremolo: undefined,
229
- fingerings: [],
230
- navigation: undefined,
231
- markups: [],
232
- };
233
- if (!marks)
234
- return result;
235
- for (const mark of marks) {
236
- switch (mark.markType) {
237
- case 'articulation': {
238
- const articType = ARTIC_MAP[mark.type];
239
- if (articType) {
240
- result.artics.push({
241
- type: articType,
242
- placement: mark.placement,
243
- });
244
- }
245
- break;
246
- }
247
- case 'ornament':
248
- if (mark.type === OrnamentType.fermata) {
249
- result.fermata = 'normal';
250
- }
251
- else if (mark.type === OrnamentType.shortFermata) {
252
- result.fermata = 'short';
253
- }
254
- else if (mark.type === OrnamentType.trill) {
255
- result.trill = true;
256
- }
257
- else if (mark.type === OrnamentType.arpeggio) {
258
- result.arpeggio = true;
259
- }
260
- else if (mark.type === OrnamentType.turn) {
261
- result.turn = true;
262
- }
263
- else if (mark.type === OrnamentType.mordent) {
264
- result.mordent = 'lower';
265
- }
266
- else if (mark.type === OrnamentType.prall) {
267
- result.mordent = 'upper';
268
- }
269
- break;
270
- case 'dynamic': {
271
- const dynStr = DYNAMIC_MAP[mark.type];
272
- if (dynStr) {
273
- result.dynamic = dynStr;
274
- }
275
- break;
276
- }
277
- case 'hairpin':
278
- if (mark.type === HairpinType.crescendoStart) {
279
- result.hairpin = 'crescStart';
280
- }
281
- else if (mark.type === HairpinType.diminuendoStart) {
282
- result.hairpin = 'dimStart';
283
- }
284
- else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
285
- result.hairpin = 'end';
286
- }
287
- break;
288
- case 'pedal':
289
- if (mark.type === PedalType.sustainOn) {
290
- result.pedal = 'down';
291
- }
292
- else if (mark.type === PedalType.sustainOff) {
293
- result.pedal = 'up';
294
- }
295
- break;
296
- case 'tie':
297
- if (mark.start) {
298
- result.tieStart = true;
299
- }
300
- break;
301
- case 'slur':
302
- if (mark.start) {
303
- result.slurStart = true;
304
- }
305
- else {
306
- result.slurEnd = true;
307
- }
308
- break;
309
- case 'beam':
310
- if (mark.start) {
311
- result.beamStart = true;
312
- }
313
- else {
314
- result.beamEnd = true;
315
- }
316
- break;
317
- case 'fingering':
318
- result.fingerings.push({
319
- finger: mark.finger,
320
- placement: mark.placement,
321
- });
322
- break;
323
- case 'navigation':
324
- result.navigation = mark.type;
325
- break;
326
- case 'markup':
327
- result.markups.push({
328
- content: mark.content,
329
- placement: mark.placement,
330
- });
331
- break;
332
- }
333
- // Tremolo (special case - from parser internal mark)
334
- if ('tremolo' in mark && typeof mark.tremolo === 'number') {
335
- result.tremolo = mark.tremolo;
336
- }
337
- }
338
- return result;
339
- };
340
- // Convert NoteEvent to MEI
341
- const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0) => {
342
- const dur = DURATIONS[event.duration.division] || "4";
343
- const dots = event.duration.dots || 0;
344
- const markOptions = extractMarkOptions(event.marks);
345
- // Stem direction - use event's own or context's
346
- const effectiveStemDir = event.stemDirection ?? contextStemDir;
347
- let stemDir;
348
- if (effectiveStemDir === StemDirection.up)
349
- stemDir = 'up';
350
- else if (effectiveStemDir === StemDirection.down)
351
- stemDir = 'down';
352
- // Determine tie attribute: 'i' = initial, 'm' = medial, 't' = terminal
353
- let tie;
354
- if (markOptions.tieStart && tieEnd) {
355
- tie = 'm'; // Both start and end = medial
356
- }
357
- else if (markOptions.tieStart) {
358
- tie = 'i'; // Start only = initial
359
- }
360
- else if (tieEnd) {
361
- tie = 't'; // End only = terminal
362
- }
363
- // Note options - ornaments are now control events, not inline
364
- const noteOptions = {
365
- grace: event.grace,
366
- tie,
367
- stemDir,
368
- staff: event.staff,
369
- layerStaff,
370
- artics: markOptions.artics,
371
- tremolo: markOptions.tremolo,
372
- };
373
- // Single note
374
- if (event.pitches.length === 1) {
375
- const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
376
- const noteId = generateId('note');
377
- return {
378
- xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
379
- elementId: noteId,
380
- hairpin: markOptions.hairpin,
381
- pedal: markOptions.pedal,
382
- hasTieStart: markOptions.tieStart,
383
- pitches: event.pitches,
384
- arpeggio: markOptions.arpeggio,
385
- fermata: markOptions.fermata,
386
- trill: markOptions.trill,
387
- mordent: markOptions.mordent,
388
- turn: markOptions.turn,
389
- dynamic: markOptions.dynamic,
390
- slurStart: markOptions.slurStart,
391
- slurEnd: markOptions.slurEnd,
392
- fingerings: markOptions.fingerings,
393
- navigation: markOptions.navigation,
394
- markups: markOptions.markups,
395
- };
396
- }
397
- // Chord
398
- const chordId = generateId('chord');
399
- let chordAttrs = `xml:id="${chordId}" dur="${dur}"`;
400
- if (dots > 0)
401
- chordAttrs += ` dots="${dots}"`;
402
- if (noteOptions.grace)
403
- chordAttrs += ` grace="unacc"`;
404
- if (noteOptions.tie)
405
- chordAttrs += ` tie="${noteOptions.tie}"`;
406
- if (noteOptions.stemDir)
407
- chordAttrs += ` stem.dir="${noteOptions.stemDir}"`;
408
- if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
409
- chordAttrs += ` staff="${noteOptions.staff}"`;
410
- }
411
- let result = `${indent}<chord ${chordAttrs}>\n`;
412
- for (const p of event.pitches) {
413
- const pitch = encodePitch(p, keyFifths, ottavaShift);
414
- result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
415
- }
416
- // Artics for chord - group by placement
417
- if (noteOptions.artics.length > 0) {
418
- const aboveArtics = noteOptions.artics.filter(a => a.placement === 'above').map(a => a.type);
419
- const belowArtics = noteOptions.artics.filter(a => a.placement === 'below').map(a => a.type);
420
- const defaultArtics = noteOptions.artics.filter(a => !a.placement).map(a => a.type);
421
- if (aboveArtics.length > 0) {
422
- result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
423
- }
424
- if (belowArtics.length > 0) {
425
- result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
426
- }
427
- if (defaultArtics.length > 0) {
428
- result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
429
- }
430
- }
431
- result += `${indent}</chord>\n`;
432
- return {
433
- xml: result,
434
- elementId: chordId,
435
- hairpin: markOptions.hairpin,
436
- pedal: markOptions.pedal,
437
- hasTieStart: markOptions.tieStart,
438
- pitches: event.pitches,
439
- arpeggio: markOptions.arpeggio,
440
- fermata: markOptions.fermata,
441
- trill: markOptions.trill,
442
- mordent: markOptions.mordent,
443
- turn: markOptions.turn,
444
- dynamic: markOptions.dynamic,
445
- slurStart: markOptions.slurStart,
446
- slurEnd: markOptions.slurEnd,
447
- fingerings: markOptions.fingerings,
448
- navigation: markOptions.navigation,
449
- markups: markOptions.markups,
450
- };
451
- };
452
- // Convert RestEvent to MEI
453
- const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
454
- const dur = DURATIONS[event.duration.division] || "4";
455
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
456
- if (event.duration.dots > 0)
457
- attrs += ` dots="${event.duration.dots}"`;
458
- // Pitched rest (positioned at specific pitch)
459
- if (event.pitch) {
460
- const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
461
- attrs += ` ploc="${pitch.pname}" oloc="${pitch.oct}"`;
462
- }
463
- // Space rest (invisible)
464
- if (event.invisible) {
465
- return `${indent}<space ${attrs} />\n`;
466
- }
467
- // Full measure rest
468
- if (event.fullMeasure) {
469
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
470
- }
471
- return `${indent}<rest ${attrs} />\n`;
472
- };
473
- // Convert TupletEvent to MEI
474
- const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
475
- // LilyPond \times 2/3 means "multiply duration by 2/3"
476
- // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
477
- // MEI: num = number of notes written, numbase = normal equivalent
478
- const num = event.ratio.denominator; // denominator = actual note count
479
- const numbase = event.ratio.numerator; // numerator = time equivalent
480
- let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
481
- const baseIndent = indent + ' ';
482
- // Effective staff for cross-staff notation
483
- const effectiveStaff = currentStaff ?? layerStaff;
484
- // Collect control event info from notes inside tuplet
485
- const slurStarts = [];
486
- const slurEnds = [];
487
- const dynamics = [];
488
- const fermatas = [];
489
- const trills = [];
490
- const mordents = [];
491
- const turns = [];
492
- const arpeggios = [];
493
- // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
494
- // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
495
- // Beam state is managed by encodeLayer, not here.
496
- for (const e of event.events) {
497
- if (e.type === 'note') {
498
- // For cross-staff notation: set note's staff if different from layerStaff
499
- const noteEvent = e;
500
- const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
501
- ? { ...noteEvent, staff: effectiveStaff }
502
- : noteEvent;
503
- const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
504
- xml += result.xml;
505
- // Collect slur info
506
- if (result.slurStart)
507
- slurStarts.push(result.elementId);
508
- if (result.slurEnd)
509
- slurEnds.push(result.elementId);
510
- // Collect other control events
511
- if (result.dynamic)
512
- dynamics.push({ startid: result.elementId, label: result.dynamic });
513
- if (result.fermata)
514
- fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
515
- if (result.trill)
516
- trills.push({ startid: result.elementId });
517
- if (result.mordent)
518
- mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
519
- if (result.turn)
520
- turns.push({ startid: result.elementId });
521
- if (result.arpeggio)
522
- arpeggios.push({ plist: result.elementId });
523
- }
524
- else if (e.type === 'rest') {
525
- xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
526
- }
527
- }
528
- xml += `${indent}</tuplet>\n`;
529
- return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
530
- };
531
- // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
532
- const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
533
- const ftremId = generateId('fTrem');
534
- // For \repeat tremolo 4 { c16 d16 }:
535
- // - count = 4 (repetitions)
536
- // - division = 16 (note value)
537
- // - Total duration = 4 × 2 × 16th = 8 × 16th = half note
538
- // - Each visible note = half of total = quarter note
539
- // Calculate beams (tremolo strokes) based on division
540
- // 8th = 1 beam, 16th = 2 beams, 32nd = 3 beams
541
- const beams = Math.max(1, Math.log2(event.division / 8) + 1);
542
- // Calculate visual duration for each note
543
- // For \repeat tremolo 4 { c16 d16 }:
544
- // - Total strokes = 4 × 2 = 8 sixteenth notes = 1/2 whole note
545
- // - Each visible note = 1/4 whole note = quarter note (dur="4")
546
- // Formula: dur = division / count (e.g., 16 / 4 = 4 for quarter note)
547
- const noteDur = Math.round(event.division / event.count) || 4; // Default to quarter if calculation fails
548
- let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
549
- // First note (or chord)
550
- if (event.pitchA.length === 1) {
551
- const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
552
- let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
553
- if (pitch.accid)
554
- attrs += ` accid="${pitch.accid}"`;
555
- if (pitch.accidGes)
556
- attrs += ` accid.ges="${pitch.accidGes}"`;
557
- result += `${indent} <note ${attrs} />\n`;
558
- }
559
- else if (event.pitchA.length > 1) {
560
- result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
561
- for (const p of event.pitchA) {
562
- const pitch = encodePitch(p, keyFifths, ottavaShift);
563
- let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
564
- if (pitch.accid)
565
- attrs += ` accid="${pitch.accid}"`;
566
- if (pitch.accidGes)
567
- attrs += ` accid.ges="${pitch.accidGes}"`;
568
- result += `${indent} <note ${attrs} />\n`;
569
- }
570
- result += `${indent} </chord>\n`;
571
- }
572
- // Second note (or chord)
573
- if (event.pitchB.length === 1) {
574
- const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
575
- let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
576
- if (pitch.accid)
577
- attrs += ` accid="${pitch.accid}"`;
578
- if (pitch.accidGes)
579
- attrs += ` accid.ges="${pitch.accidGes}"`;
580
- result += `${indent} <note ${attrs} />\n`;
581
- }
582
- else if (event.pitchB.length > 1) {
583
- result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
584
- for (const p of event.pitchB) {
585
- const pitch = encodePitch(p, keyFifths, ottavaShift);
586
- let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
587
- if (pitch.accid)
588
- attrs += ` accid="${pitch.accid}"`;
589
- if (pitch.accidGes)
590
- attrs += ` accid.ges="${pitch.accidGes}"`;
591
- result += `${indent} <note ${attrs} />\n`;
592
- }
593
- result += `${indent} </chord>\n`;
594
- }
595
- result += `${indent}</fTrem>\n`;
596
- return result;
597
- };
598
- // Helper: check if an event (or any note inside a tuplet) has beam start/end
599
- const getEventBeamMarks = (event) => {
600
- if (event.type === 'note') {
601
- const markOptions = extractMarkOptions(event.marks);
602
- return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
603
- }
604
- if (event.type === 'tuplet') {
605
- const tuplet = event;
606
- let beamStart = false;
607
- let beamEnd = false;
608
- for (const e of tuplet.events) {
609
- if (e.type === 'note') {
610
- const markOptions = extractMarkOptions(e.marks);
611
- if (markOptions.beamStart)
612
- beamStart = true;
613
- if (markOptions.beamEnd)
614
- beamEnd = true;
615
- }
616
- }
617
- return { beamStart, beamEnd };
618
- }
619
- return { beamStart: false, beamEnd: false };
620
- };
621
- // Encode a layer (voice)
622
- const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
623
- const layerId = generateId("layer");
624
- let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
625
- let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
626
- const baseIndent = indent + ' ';
627
- // Track current clef to only emit changes
628
- let currentClef = initialClef;
629
- // Track hairpin spans
630
- const hairpins = [];
631
- let currentHairpin = initialHairpin;
632
- // Track pedal marks (each is independent, not paired spans)
633
- const pedals = [];
634
- // Track octave spans - initialize from previous measure if continuing
635
- const octaves = [];
636
- let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
637
- let pendingOttava = null; // Track ottava to apply to next note
638
- let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
639
- let lastNoteId = null; // Track last note id for ending ottava spans
640
- let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
641
- // Track slur spans - slurs must be encoded as control events in MEI
642
- const slurs = [];
643
- let currentSlur = initialSlur ? { startId: initialSlur } : null;
644
- // Track arpeggio refs
645
- const arpeggios = [];
646
- // Track ornament refs
647
- const fermatas = [];
648
- const trills = [];
649
- const mordents = [];
650
- const turns = [];
651
- const dynamics = [];
652
- const fingerings = [];
653
- const navigations = [];
654
- const harmonies = [];
655
- const barlines = [];
656
- const markups = [];
657
- // Track current stem direction from context changes
658
- let currentStemDirection = undefined;
659
- // Track current staff for cross-staff notation
660
- let currentStaff = voice.staff || 1;
661
- // Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
662
- let pendingTiePitches = [...initialTiePitches];
663
- // Helper to check if pitches match for tie continuation
664
- const pitchesMatch = (p1, p2) => {
665
- if (p1.length !== p2.length)
666
- return false;
667
- for (let i = 0; i < p1.length; i++) {
668
- if (p1[i].phonet !== p2[i].phonet || p1[i].octave !== p2[i].octave)
669
- return false;
670
- }
671
- return true;
672
- };
673
- for (const event of voice.events) {
674
- // Check for beam start/end in this event (including inside tuplets)
675
- const { beamStart, beamEnd } = getEventBeamMarks(event);
676
- // Open beam element if beam starts
677
- if (beamStart && !beamElementOpen) {
678
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
679
- beamElementOpen = true;
680
- }
681
- const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
682
- switch (event.type) {
683
- case 'note': {
684
- const noteEvent = event;
685
- // Check if this note should have tie="t" (matches pending tie)
686
- const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
687
- // If there's a pending ottava, apply it BEFORE encoding the note
688
- if (pendingOttava !== null && pendingOttava !== 0) {
689
- currentOttavaShift = pendingOttava; // Apply the shift for this note
690
- }
691
- // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
692
- const effectiveNoteEvent = currentStaff !== voice.staff
693
- ? { ...noteEvent, staff: currentStaff }
694
- : noteEvent;
695
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
696
- xml += result.xml;
697
- lastNoteId = result.elementId;
698
- // If there's a pending ottava, start the span on this note
699
- if (pendingOttava !== null && pendingOttava !== 0) {
700
- const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
701
- const disPlace = pendingOttava > 0 ? 'above' : 'below';
702
- // Close existing span first if it has a different value
703
- if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
704
- // Different value - close the old span
705
- // Use the lastNoteId from before this note (which we saved before processing)
706
- // Note: The span from previous measure will be closed by encodeMeasure
707
- currentOctave = null;
708
- }
709
- // Start new span if we don't already have one with the same value
710
- if (!currentOctave) {
711
- currentOctave = { dis, disPlace, startId: result.elementId };
712
- }
713
- pendingOttava = null;
714
- }
715
- // Update pending tie pitches
716
- if (result.hasTieStart) {
717
- pendingTiePitches = result.pitches;
718
- }
719
- else if (tieEnd) {
720
- pendingTiePitches = [];
721
- }
722
- // Track hairpin spans
723
- if (result.hairpin === 'crescStart') {
724
- currentHairpin = { form: 'cres', startId: result.elementId };
725
- }
726
- else if (result.hairpin === 'dimStart') {
727
- currentHairpin = { form: 'dim', startId: result.elementId };
728
- }
729
- else if (result.hairpin === 'end' && currentHairpin) {
730
- hairpins.push({
731
- form: currentHairpin.form,
732
- startId: currentHairpin.startId,
733
- endId: result.elementId,
734
- });
735
- currentHairpin = null;
736
- }
737
- // Track pedal marks (each is independent)
738
- if (result.pedal === 'down' || result.pedal === 'up') {
739
- pedals.push({
740
- startId: result.elementId,
741
- dir: result.pedal,
742
- });
743
- }
744
- // Track slur spans - end must be processed before start
745
- // in case a note ends one slur and starts another
746
- if (result.slurEnd && currentSlur) {
747
- slurs.push({
748
- startId: currentSlur.startId,
749
- endId: result.elementId,
750
- });
751
- currentSlur = null;
752
- }
753
- if (result.slurStart) {
754
- currentSlur = { startId: result.elementId };
755
- }
756
- // Track arpeggio refs
757
- if (result.arpeggio) {
758
- arpeggios.push({ plist: result.elementId });
759
- }
760
- // Track ornament refs (fermata, trill, mordent, turn)
761
- if (result.fermata) {
762
- fermatas.push({
763
- startid: result.elementId,
764
- shape: result.fermata === 'short' ? 'angular' : undefined,
765
- });
766
- }
767
- if (result.trill) {
768
- trills.push({ startid: result.elementId });
769
- }
770
- if (result.mordent) {
771
- mordents.push({
772
- startid: result.elementId,
773
- form: result.mordent === 'upper' ? 'upper' : undefined,
774
- });
775
- }
776
- if (result.turn) {
777
- turns.push({ startid: result.elementId });
778
- }
779
- if (result.dynamic) {
780
- dynamics.push({ startid: result.elementId, label: result.dynamic });
781
- }
782
- // Track fingerings
783
- for (const fing of result.fingerings) {
784
- fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
785
- }
786
- // Track markups from note marks
787
- for (const mkup of result.markups) {
788
- markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
789
- }
790
- // Track navigation marks
791
- if (result.navigation) {
792
- navigations.push({ type: result.navigation });
793
- }
794
- break;
795
- }
796
- case 'rest':
797
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
798
- break;
799
- case 'tuplet': {
800
- // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
801
- // Pass beamElementOpen to tuplet so it knows not to create its own beam
802
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
803
- xml += tupletResult.xml;
804
- // Process slur ends first (to close any pending slurs from before this tuplet)
805
- for (const endId of tupletResult.slurEnds) {
806
- if (currentSlur) {
807
- slurs.push({
808
- startId: currentSlur.startId,
809
- endId: endId,
810
- });
811
- currentSlur = null;
812
- }
813
- }
814
- // Then process slur starts (to open new slurs)
815
- for (const startId of tupletResult.slurStarts) {
816
- currentSlur = { startId };
817
- }
818
- // Collect other control events from tuplet
819
- dynamics.push(...tupletResult.dynamics);
820
- fermatas.push(...tupletResult.fermatas);
821
- trills.push(...tupletResult.trills);
822
- mordents.push(...tupletResult.mordents);
823
- turns.push(...tupletResult.turns);
824
- arpeggios.push(...tupletResult.arpeggios);
825
- break;
826
- }
827
- case 'tremolo':
828
- xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
829
- break;
830
- case 'context': {
831
- const ctx = event;
832
- // Check for clef changes - emit <clef> element only if different from current
833
- if (ctx.clef && ctx.clef !== currentClef) {
834
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
835
- xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
836
- currentClef = ctx.clef;
837
- }
838
- // Check for ottava changes
839
- if (ctx.ottava !== undefined) {
840
- if (ctx.ottava === 0) {
841
- // End current ottava span
842
- if (currentOctave && lastNoteId) {
843
- octaves.push({
844
- dis: currentOctave.dis,
845
- disPlace: currentOctave.disPlace,
846
- startId: currentOctave.startId,
847
- endId: lastNoteId,
848
- });
849
- currentOctave = null;
850
- ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
851
- }
852
- // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
853
- // It may be continued by a subsequent ottava command with the same value
854
- currentOttavaShift = 0; // Reset the shift (will be restored if continued)
855
- }
856
- else {
857
- // Check if this continues an existing span (same value)
858
- const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
859
- const disPlace = ctx.ottava > 0 ? 'above' : 'below';
860
- if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
861
- // Continuation - restore the shift but don't change the span
862
- currentOttavaShift = ctx.ottava;
863
- }
864
- else {
865
- // Different value - start new ottava span (will be applied to next note)
866
- // If there's an existing span with different value, it will be closed when the note is processed
867
- pendingOttava = ctx.ottava;
868
- }
869
- }
870
- }
871
- // Check for stem direction changes
872
- if (ctx.stemDirection !== undefined) {
873
- currentStemDirection = ctx.stemDirection;
874
- }
875
- // Check for staff changes (cross-staff notation)
876
- if (ctx.staff !== undefined) {
877
- currentStaff = ctx.staff;
878
- }
879
- // Other context changes are handled at measure level
880
- break;
881
- }
882
- case 'pitchReset':
883
- // Pitch reset events are only used during pitch resolution in the parser.
884
- // They don't produce any MEI output - just skip them.
885
- break;
886
- case 'barline':
887
- barlines.push({ style: event.style });
888
- break;
889
- case 'harmony':
890
- // Harmony needs a note ID to attach to - use the last note if available
891
- if (lastNoteId) {
892
- harmonies.push({ startid: lastNoteId, text: event.text });
893
- }
894
- break;
895
- case 'markup':
896
- // Markup needs a note ID to attach to - use the last note if available
897
- if (lastNoteId) {
898
- const mkupEvent = event;
899
- markups.push({
900
- startid: lastNoteId,
901
- content: mkupEvent.content,
902
- placement: mkupEvent.placement,
903
- });
904
- }
905
- break;
906
- }
907
- // Close beam element if beam ends
908
- if (beamEnd && beamElementOpen) {
909
- xml += `${baseIndent}</beam>\n`;
910
- beamElementOpen = false;
911
- }
912
- }
913
- // Close any unclosed beam
914
- if (beamElementOpen) {
915
- xml += `${baseIndent}</beam>\n`;
916
- }
917
- // Don't close ottava span at measure end - it may continue in the next measure
918
- // Build pending octave state to return
919
- const pendingOctave = currentOctave
920
- ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
921
- : null;
922
- xml += `${indent}</layer>\n`;
923
- return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
924
- };
925
- // Encode a staff
926
- const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
927
- const staffId = generateId("staff");
928
- let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
929
- const allHairpins = [];
930
- const allPedals = [];
931
- const allOctaves = [];
932
- const allSlurs = [];
933
- const allArpeggios = [];
934
- const allFermatas = [];
935
- const allTrills = [];
936
- const allMordents = [];
937
- const allTurns = [];
938
- const allDynamics = [];
939
- const allFingerings = [];
940
- const allNavigations = [];
941
- const allHarmonies = [];
942
- const allBarlines = [];
943
- const allMarkups = [];
944
- const pendingTies = {};
945
- const pendingSlurs = {};
946
- const pendingHairpins = {};
947
- const pendingOctaves = {};
948
- const ottavaExplicitlyClosed = {};
949
- const lastNoteIds = {};
950
- let endingClef = initialClef;
951
- if (voices.length === 0) {
952
- xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
953
- }
954
- else {
955
- voices.forEach((voice, vi) => {
956
- const layerN = vi + 1;
957
- const tieKey = `${staffN}-${layerN}`;
958
- const initialTies = tieState[tieKey] || [];
959
- const initialSlur = slurState[tieKey] || null;
960
- const initialHairpin = hairpinState[tieKey] || null;
961
- const initialOctave = ottavaState[tieKey] || null;
962
- const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
963
- xml += result.xml;
964
- allHairpins.push(...result.hairpins);
965
- allPedals.push(...result.pedals);
966
- allOctaves.push(...result.octaves);
967
- allSlurs.push(...result.slurs);
968
- allArpeggios.push(...result.arpeggios);
969
- allFermatas.push(...result.fermatas);
970
- allTrills.push(...result.trills);
971
- allMordents.push(...result.mordents);
972
- allTurns.push(...result.turns);
973
- allDynamics.push(...result.dynamics);
974
- allFingerings.push(...result.fingerings);
975
- allNavigations.push(...result.navigations);
976
- allHarmonies.push(...result.harmonies);
977
- allBarlines.push(...result.barlines);
978
- allMarkups.push(...result.markups);
979
- // Track pending ties for this layer
980
- if (result.pendingTiePitches.length > 0) {
981
- pendingTies[tieKey] = result.pendingTiePitches;
982
- }
983
- // Track pending slurs for this layer
984
- if (result.pendingSlur) {
985
- pendingSlurs[tieKey] = result.pendingSlur;
986
- }
987
- // Track pending hairpins for this layer
988
- if (result.pendingHairpin) {
989
- pendingHairpins[tieKey] = result.pendingHairpin;
990
- }
991
- // Track pending ottava spans for this layer
992
- if (result.pendingOctave) {
993
- pendingOctaves[tieKey] = result.pendingOctave;
994
- }
995
- // Track if ottava was explicitly closed in this layer
996
- if (result.ottavaExplicitlyClosed) {
997
- ottavaExplicitlyClosed[tieKey] = true;
998
- }
999
- // Track last note IDs for this layer (for closing ottava spans)
1000
- lastNoteIds[tieKey] = result.lastNoteId;
1001
- // Track ending clef for cross-measure tracking
1002
- if (result.endingClef) {
1003
- endingClef = result.endingClef;
1004
- }
1005
- });
1006
- }
1007
- xml += `${indent}</staff>\n`;
1008
- return {
1009
- xml,
1010
- hairpins: allHairpins,
1011
- pedals: allPedals,
1012
- octaves: allOctaves,
1013
- slurs: allSlurs,
1014
- arpeggios: allArpeggios,
1015
- fermatas: allFermatas,
1016
- trills: allTrills,
1017
- mordents: allMordents,
1018
- turns: allTurns,
1019
- dynamics: allDynamics,
1020
- fingerings: allFingerings,
1021
- navigations: allNavigations,
1022
- harmonies: allHarmonies,
1023
- barlines: allBarlines,
1024
- markups: allMarkups,
1025
- pendingTies,
1026
- pendingSlurs,
1027
- pendingHairpins,
1028
- pendingOctaves,
1029
- ottavaExplicitlyClosed,
1030
- lastNoteIds,
1031
- endingClef,
1032
- };
1033
- };
1034
- // Tempo text to BPM mapping (approximate values based on musical convention)
1035
- const TEMPO_TEXT_TO_BPM = {
1036
- // Very slow
1037
- 'grave': 35,
1038
- 'largo': 50,
1039
- 'larghetto': 63,
1040
- 'lento': 52,
1041
- 'adagio': 70,
1042
- // Slow to moderate
1043
- 'andante': 92,
1044
- 'andantino': 96,
1045
- 'moderato': 114,
1046
- // Fast
1047
- 'allegretto': 116,
1048
- 'allegro': 138,
1049
- 'vivace': 166,
1050
- 'presto': 184,
1051
- 'prestissimo': 208,
1052
- };
1053
- // Infer BPM from tempo text
1054
- const inferBpmFromText = (text) => {
1055
- const lowerText = text.toLowerCase();
1056
- for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
1057
- if (lowerText.includes(keyword)) {
1058
- return bpm;
1059
- }
1060
- }
1061
- return undefined;
1062
- };
1063
- // Generate tempo element
1064
- const generateTempoElement = (tempo, indent, staff = 1) => {
1065
- let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
1066
- // Determine BPM: use explicit value or infer from text
1067
- let bpm = tempo.bpm;
1068
- if (!bpm && tempo.text) {
1069
- bpm = inferBpmFromText(tempo.text);
1070
- }
1071
- // Add BPM if available
1072
- if (bpm) {
1073
- attrs += ` midi.bpm="${bpm}"`;
1074
- if (tempo.beat) {
1075
- attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
1076
- }
1077
- }
1078
- // Generate content
1079
- let content = '';
1080
- if (tempo.text) {
1081
- content = escapeXml(tempo.text);
1082
- }
1083
- if (tempo.beat && tempo.bpm) {
1084
- const beatSymbol = tempo.beat.division === 4 ? '♩' : tempo.beat.division === 2 ? '𝅗𝅥' : '♪';
1085
- if (content)
1086
- content += ' ';
1087
- content += `${beatSymbol} = ${tempo.bpm}`;
1088
- }
1089
- if (content) {
1090
- return `${indent}<tempo ${attrs}>${content}</tempo>\n`;
1091
- }
1092
- return `${indent}<tempo ${attrs} />\n`;
1093
- };
1094
- // Barline style to MEI @right attribute mapping
1095
- const BARLINE_TO_MEI = {
1096
- '|': 'single',
1097
- '||': 'dbl',
1098
- '|.': 'end',
1099
- '.|:': 'rptstart',
1100
- ':|.': 'rptend',
1101
- ':..:|': 'rptboth',
1102
- ':..:': 'rptboth',
1103
- };
1104
- // Encode a measure
1105
- // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1106
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
1107
- const measureId = generateId("measure");
1108
- let staffContent = ''; // Build staff content first, then add measure tag with barline
1109
- const allHairpins = [];
1110
- const allPedals = [];
1111
- const allOctaves = [];
1112
- const allSlurs = [];
1113
- const allArpeggios = [];
1114
- const allFermatas = [];
1115
- const allTrills = [];
1116
- const allMordents = [];
1117
- const allTurns = [];
1118
- const allDynamics = [];
1119
- const allFingerings = [];
1120
- const allNavigations = [];
1121
- const allHarmonies = [];
1122
- const allBarlines = [];
1123
- const allMarkups = [];
1124
- // Extract tempo from context changes (track which staff it came from)
1125
- let measureTempo;
1126
- let tempoStaff = 1;
1127
- for (let pi = 0; pi < measure.parts.length; pi++) {
1128
- const part = measure.parts[pi];
1129
- const partOffset = partInfos[pi]?.staffOffset || 0;
1130
- for (const voice of part.voices) {
1131
- const localStaff = voice.staff || 1;
1132
- const globalStaff = partOffset + localStaff;
1133
- for (const event of voice.events) {
1134
- if (event.type === 'context') {
1135
- const ctx = event;
1136
- if (ctx.tempo) {
1137
- measureTempo = ctx.tempo;
1138
- tempoStaff = globalStaff;
1139
- }
1140
- }
1141
- }
1142
- }
1143
- }
1144
- // Group voices by global staff (local staff + part offset)
1145
- const voicesByStaff = {};
1146
- for (let pi = 0; pi < measure.parts.length; pi++) {
1147
- const part = measure.parts[pi];
1148
- const partOffset = partInfos[pi]?.staffOffset || 0;
1149
- for (const voice of part.voices) {
1150
- const localStaff = voice.staff || 1;
1151
- const globalStaff = partOffset + localStaff;
1152
- if (!voicesByStaff[globalStaff]) {
1153
- voicesByStaff[globalStaff] = [];
1154
- }
1155
- voicesByStaff[globalStaff].push(voice);
1156
- }
1157
- }
1158
- // Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
1159
- for (let si = 1; si <= totalStaves; si++) {
1160
- const voices = voicesByStaff[si] || [];
1161
- const initialClef = clefState[si];
1162
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1163
- staffContent += result.xml;
1164
- allHairpins.push(...result.hairpins);
1165
- allPedals.push(...result.pedals);
1166
- allOctaves.push(...result.octaves);
1167
- allSlurs.push(...result.slurs);
1168
- allArpeggios.push(...result.arpeggios);
1169
- allFermatas.push(...result.fermatas);
1170
- allTrills.push(...result.trills);
1171
- allMordents.push(...result.mordents);
1172
- allTurns.push(...result.turns);
1173
- allDynamics.push(...result.dynamics);
1174
- allFingerings.push(...result.fingerings);
1175
- allNavigations.push(...result.navigations);
1176
- allHarmonies.push(...result.harmonies);
1177
- allBarlines.push(...result.barlines);
1178
- allMarkups.push(...result.markups);
1179
- // Update tie state with pending ties from this staff
1180
- Object.assign(tieState, result.pendingTies);
1181
- // Update slur state with pending slurs from this staff
1182
- Object.assign(slurState, result.pendingSlurs);
1183
- // Update hairpin state with pending hairpins from this staff
1184
- Object.assign(hairpinState, result.pendingHairpins);
1185
- // Update ottava state with pending octaves from this staff
1186
- // Also handle closing spans when ottava ends
1187
- const currentStaffPrefix = `${si}-`;
1188
- for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1189
- if (pending) {
1190
- // Check if this is a continuation or a new span
1191
- const prevPending = ottavaState[key];
1192
- if (prevPending && prevPending.shift === pending.shift) {
1193
- // Same ottava value continues - keep the original startId
1194
- ottavaState[key] = { ...pending, startId: prevPending.startId };
1195
- }
1196
- else {
1197
- // Different ottava value - close the old span first if exists
1198
- if (prevPending) {
1199
- const lastNoteId = result.lastNoteIds[key];
1200
- if (lastNoteId) {
1201
- allOctaves.push({
1202
- dis: prevPending.dis,
1203
- disPlace: prevPending.disPlace,
1204
- startId: prevPending.startId,
1205
- endId: lastNoteId,
1206
- });
1207
- }
1208
- }
1209
- // Start new span
1210
- ottavaState[key] = pending;
1211
- }
1212
- }
1213
- }
1214
- // For layers in this staff that had pending octaves but didn't in this measure, close the spans
1215
- for (const [key, pending] of Object.entries(ottavaState)) {
1216
- // Only process keys for the current staff
1217
- if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
1218
- // Check if the span was already explicitly closed in encodeLayer
1219
- // If so, don't generate another span (it was already pushed to octaves in encodeLayer)
1220
- if (!result.ottavaExplicitlyClosed[key]) {
1221
- // Ottava ended without explicit close - generate the closing span
1222
- const lastNoteId = result.lastNoteIds[key];
1223
- if (lastNoteId) {
1224
- allOctaves.push({
1225
- dis: pending.dis,
1226
- disPlace: pending.disPlace,
1227
- startId: pending.startId,
1228
- endId: lastNoteId,
1229
- });
1230
- }
1231
- }
1232
- delete ottavaState[key];
1233
- }
1234
- }
1235
- // Update clef state with ending clef from this staff
1236
- if (result.endingClef) {
1237
- clefState[si] = result.endingClef;
1238
- }
1239
- }
1240
- // Generate tempo element if present
1241
- if (measureTempo) {
1242
- staffContent += generateTempoElement(measureTempo, indent + ' ', tempoStaff);
1243
- }
1244
- // Generate hairpin control events
1245
- for (const hp of allHairpins) {
1246
- staffContent += `${indent} <hairpin xml:id="${generateId('hairpin')}" form="${hp.form}" startid="#${hp.startId}" endid="#${hp.endId}" />\n`;
1247
- }
1248
- // Generate pedal control events (each mark is independent)
1249
- for (const ped of allPedals) {
1250
- staffContent += `${indent} <pedal xml:id="${generateId('pedal')}" dir="${ped.dir}" startid="#${ped.startId}" />\n`;
1251
- }
1252
- // Generate octave control events
1253
- for (const oct of allOctaves) {
1254
- staffContent += `${indent} <octave xml:id="${generateId('octave')}" dis="${oct.dis}" dis.place="${oct.disPlace}" startid="#${oct.startId}" endid="#${oct.endId}" />\n`;
1255
- }
1256
- // Generate slur control events
1257
- for (const sl of allSlurs) {
1258
- staffContent += `${indent} <slur xml:id="${generateId('slur')}" startid="#${sl.startId}" endid="#${sl.endId}" />\n`;
1259
- }
1260
- // Generate arpeggio control events
1261
- for (const arp of allArpeggios) {
1262
- staffContent += `${indent} <arpeg xml:id="${generateId('arpeg')}" plist="#${arp.plist}" />\n`;
1263
- }
1264
- // Generate fermata control events
1265
- for (const ferm of allFermatas) {
1266
- const shapeAttr = ferm.shape ? ` shape="${ferm.shape}"` : '';
1267
- staffContent += `${indent} <fermata xml:id="${generateId('fermata')}" startid="#${ferm.startid}"${shapeAttr} />\n`;
1268
- }
1269
- // Generate trill control events
1270
- for (const tr of allTrills) {
1271
- staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
1272
- }
1273
- // Generate mordent control events
1274
- for (const mord of allMordents) {
1275
- const formAttr = mord.form ? ` form="${mord.form}"` : '';
1276
- staffContent += `${indent} <mordent xml:id="${generateId('mordent')}" startid="#${mord.startid}"${formAttr} />\n`;
1277
- }
1278
- // Generate turn control events
1279
- for (const tu of allTurns) {
1280
- staffContent += `${indent} <turn xml:id="${generateId('turn')}" startid="#${tu.startid}" />\n`;
1281
- }
1282
- // Generate dynamic control events
1283
- for (const dyn of allDynamics) {
1284
- staffContent += `${indent} <dynam xml:id="${generateId('dynam')}" startid="#${dyn.startid}">${dyn.label}</dynam>\n`;
1285
- }
1286
- // Generate fingering control events
1287
- for (const fing of allFingerings) {
1288
- const placeAttr = fing.placement ? ` place="${fing.placement}"` : '';
1289
- staffContent += `${indent} <fing xml:id="${generateId('fing')}" startid="#${fing.startid}"${placeAttr}>${fing.finger}</fing>\n`;
1290
- }
1291
- // Generate dir elements for navigation marks (coda, segno)
1292
- for (const nav of allNavigations) {
1293
- // Use <dir> element with appropriate glyph
1294
- const glyph = nav.type === 'coda' ? '𝄌' : '𝄋'; // Unicode coda/segno symbols
1295
- staffContent += `${indent} <dir xml:id="${generateId('dir')}" tstamp="1">${glyph}</dir>\n`;
1296
- }
1297
- // Generate harm elements for chord symbols
1298
- for (const harm of allHarmonies) {
1299
- staffContent += `${indent} <harm xml:id="${generateId('harm')}" startid="#${harm.startid}">${escapeXml(harm.text)}</harm>\n`;
1300
- }
1301
- // Generate dir elements for markups
1302
- for (const mkup of allMarkups) {
1303
- const placeAttr = mkup.placement ? ` place="${mkup.placement}"` : '';
1304
- staffContent += `${indent} <dir xml:id="${generateId('dir')}" startid="#${mkup.startid}"${placeAttr}>${escapeXml(mkup.content)}</dir>\n`;
1305
- }
1306
- // Determine barline attribute from collected barlines
1307
- let barlineAttr = '';
1308
- if (allBarlines.length > 0) {
1309
- const lastBarline = allBarlines[allBarlines.length - 1];
1310
- const meiBarline = BARLINE_TO_MEI[lastBarline.style];
1311
- if (meiBarline && meiBarline !== 'single') {
1312
- barlineAttr = ` right="${meiBarline}"`;
1313
- }
1314
- }
1315
- // Build final XML with measure tag including barline
1316
- let xml = `${indent}<measure xml:id="${measureId}" n="${measureN}"${barlineAttr}>\n`;
1317
- xml += staffContent;
1318
- xml += `${indent}</measure>\n`;
1319
- return xml;
1320
- };
1321
- // Analyze document to get part structure
1322
- const analyzePartStructure = (doc) => {
1323
- // Find maximum number of parts in any measure
1324
- let maxParts = 0;
1325
- for (const measure of doc.measures) {
1326
- maxParts = Math.max(maxParts, measure.parts.length);
1327
- }
1328
- // Initialize part info
1329
- const partInfos = [];
1330
- for (let i = 0; i < maxParts; i++) {
1331
- partInfos.push({ maxStaff: 1, staffOffset: 0, clefs: {} });
1332
- }
1333
- // Analyze each measure to find max staff per part and clefs
1334
- for (const measure of doc.measures) {
1335
- for (let pi = 0; pi < measure.parts.length; pi++) {
1336
- const part = measure.parts[pi];
1337
- for (const voice of part.voices) {
1338
- const localStaff = voice.staff || 1;
1339
- partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, localStaff);
1340
- // Get FIRST clef from context changes (for initial staffDef)
1341
- for (const event of voice.events) {
1342
- if (event.type === 'context') {
1343
- const ctx = event;
1344
- if (ctx.clef && !partInfos[pi].clefs[localStaff]) {
1345
- // Only set if not already set - take the FIRST clef
1346
- partInfos[pi].clefs[localStaff] = ctx.clef;
1347
- }
1348
- }
1349
- }
1350
- }
1351
- }
1352
- }
1353
- // Calculate staff offsets
1354
- let offset = 0;
1355
- for (const info of partInfos) {
1356
- info.staffOffset = offset;
1357
- offset += info.maxStaff;
1358
- }
1359
- return partInfos;
1360
- };
1361
- // Encode scoreDef with part groups
1362
- const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1363
- const scoreDefId = generateId("scoredef");
1364
- // Build meter attributes
1365
- const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1366
- let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1367
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1368
- for (let pi = 0; pi < partInfos.length; pi++) {
1369
- const info = partInfos[pi];
1370
- // If part has multiple staves (grand staff), wrap in staffGrp with brace
1371
- if (info.maxStaff > 1) {
1372
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1373
- for (let ls = 1; ls <= info.maxStaff; ls++) {
1374
- const globalStaff = info.staffOffset + ls;
1375
- const clef = info.clefs[ls] || Clef.treble;
1376
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1377
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1378
- }
1379
- xml += `${indent} </staffGrp>\n`;
1380
- }
1381
- else {
1382
- // Single staff part
1383
- const globalStaff = info.staffOffset + 1;
1384
- const clef = info.clefs[1] || Clef.treble;
1385
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1386
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1387
- }
1388
- }
1389
- xml += `${indent} </staffGrp>\n`;
1390
- xml += `${indent}</scoreDef>\n`;
1391
- return xml;
1392
- };
1393
- // Main encode function
1394
- const encode = (doc, options = {}) => {
1395
- const indent = options.indent || " ";
1396
- resetIdCounter();
1397
- if (!doc.measures || doc.measures.length === 0) {
1398
- return "";
1399
- }
1400
- // Analyze part structure to get staff offsets
1401
- const partInfos = analyzePartStructure(doc);
1402
- // Calculate total staff count
1403
- const totalStaves = partInfos.reduce((sum, info) => sum + info.maxStaff, 0);
1404
- // Collect initial key/time from first measure
1405
- let currentKey = 0;
1406
- let currentTimeNum = 4;
1407
- let currentTimeDen = 4;
1408
- let currentMeterSymbol = undefined;
1409
- const firstMeasure = doc.measures[0];
1410
- if (firstMeasure.key) {
1411
- currentKey = keyToFifths(firstMeasure.key);
1412
- }
1413
- if (firstMeasure.timeSig) {
1414
- currentTimeNum = firstMeasure.timeSig.numerator;
1415
- currentTimeDen = firstMeasure.timeSig.denominator;
1416
- currentMeterSymbol = firstMeasure.timeSig.symbol;
1417
- }
1418
- const keySig = KEY_SIGS[currentKey] || "0";
1419
- // Build MEI document
1420
- const xmlDecl = options.xmlDeclaration !== false
1421
- ? '<?xml version="1.0" encoding="UTF-8"?>\n'
1422
- : "";
1423
- let mei = xmlDecl;
1424
- mei += '<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">\n';
1425
- mei += `${indent}<meiHead>\n`;
1426
- mei += `${indent}${indent}<fileDesc>\n`;
1427
- mei += `${indent}${indent}${indent}<titleStmt>\n`;
1428
- // Add title from metadata if available
1429
- if (doc.metadata?.title) {
1430
- mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.title)}</title>\n`;
1431
- }
1432
- // Add subtitle as second title (verovio reads subsequent titles as subtitle)
1433
- if (doc.metadata?.subtitle) {
1434
- mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.subtitle)}</title>\n`;
1435
- }
1436
- // Add composer (right-aligned in verovio)
1437
- if (doc.metadata?.composer) {
1438
- mei += `${indent}${indent}${indent}${indent}<composer>${escapeXml(doc.metadata.composer)}</composer>\n`;
1439
- }
1440
- // Add arranger (right-aligned in verovio)
1441
- if (doc.metadata?.arranger) {
1442
- mei += `${indent}${indent}${indent}${indent}<arranger>${escapeXml(doc.metadata.arranger)}</arranger>\n`;
1443
- }
1444
- // Add lyricist (left-aligned in verovio)
1445
- if (doc.metadata?.lyricist) {
1446
- mei += `${indent}${indent}${indent}${indent}<lyricist>${escapeXml(doc.metadata.lyricist)}</lyricist>\n`;
1447
- }
1448
- mei += `${indent}${indent}${indent}</titleStmt>\n`;
1449
- mei += `${indent}${indent}${indent}<pubStmt />\n`;
1450
- mei += `${indent}${indent}</fileDesc>\n`;
1451
- mei += `${indent}${indent}<encodingDesc>\n`;
1452
- mei += `${indent}${indent}${indent}<projectDesc>\n`;
1453
- mei += `${indent}${indent}${indent}${indent}<p>Encoded with Lilylet MEIEncoder</p>\n`;
1454
- mei += `${indent}${indent}${indent}</projectDesc>\n`;
1455
- mei += `${indent}${indent}</encodingDesc>\n`;
1456
- mei += `${indent}</meiHead>\n`;
1457
- mei += `${indent}<music>\n`;
1458
- mei += `${indent}${indent}<body>\n`;
1459
- mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1460
- mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1461
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
1462
- mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1463
- // Track tie state across measures for cross-measure ties
1464
- const tieState = {};
1465
- // Track slur state across measures for cross-measure slurs
1466
- const slurState = {};
1467
- // Track hairpin state across measures for cross-measure hairpins
1468
- const hairpinState = {};
1469
- // Track ottava state across measures for cross-measure ottava spans
1470
- const ottavaState = {};
1471
- // Initialize clef state from partInfos (convert local staff to global staff)
1472
- const clefState = {};
1473
- for (let pi = 0; pi < partInfos.length; pi++) {
1474
- const partInfo = partInfos[pi];
1475
- for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
1476
- const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
1477
- clefState[globalStaff] = clef;
1478
- }
1479
- }
1480
- // Helper to check if a measure has any musical content
1481
- const measureHasContent = (measure) => {
1482
- for (const part of measure.parts) {
1483
- for (const voice of part.voices) {
1484
- for (const event of voice.events) {
1485
- // Check for actual musical content (not just context changes or pitch resets)
1486
- if (event.type === 'note' || event.type === 'rest' ||
1487
- event.type === 'tuplet' || event.type === 'tremolo') {
1488
- return true;
1489
- }
1490
- }
1491
- }
1492
- }
1493
- return false;
1494
- };
1495
- // Filter out trailing empty measures
1496
- let measures = doc.measures;
1497
- while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
1498
- measures = measures.slice(0, -1);
1499
- }
1500
- // Encode measures
1501
- measures.forEach((measure, mi) => {
1502
- // Check for key signature change and output scoreDef if needed
1503
- if (measure.key) {
1504
- const newKey = keyToFifths(measure.key);
1505
- if (newKey !== currentKey) {
1506
- currentKey = newKey;
1507
- const newKeySig = KEY_SIGS[currentKey] || "0";
1508
- // Output a scoreDef with the new key signature
1509
- mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
1510
- }
1511
- }
1512
- // Check for time signature change and output scoreDef if needed
1513
- if (measure.timeSig && mi > 0) {
1514
- const newTimeNum = measure.timeSig.numerator;
1515
- const newTimeDen = measure.timeSig.denominator;
1516
- const newMeterSymbol = measure.timeSig.symbol;
1517
- if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
1518
- currentTimeNum = newTimeNum;
1519
- currentTimeDen = newTimeDen;
1520
- currentMeterSymbol = newMeterSymbol;
1521
- // Output a scoreDef with the new time signature
1522
- const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
1523
- mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1524
- }
1525
- }
1526
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1527
- });
1528
- mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1529
- mei += `${indent}${indent}${indent}${indent}</score>\n`;
1530
- mei += `${indent}${indent}${indent}</mdiv>\n`;
1531
- mei += `${indent}${indent}</body>\n`;
1532
- mei += `${indent}</music>\n`;
1533
- mei += '</mei>\n';
1534
- return mei;
1535
- };
1536
- // Escape XML special characters
1537
- const escapeXml = (text) => {
1538
- return text
1539
- .replace(/&/g, '&amp;')
1540
- .replace(/</g, '&lt;')
1541
- .replace(/>/g, '&gt;')
1542
- .replace(/"/g, '&quot;')
1543
- .replace(/'/g, '&apos;');
1544
- };
1545
- export { encode, resetIdCounter, };
1
+ export * from "./lilylet/meiEncoder.js";