@k-l-lambda/lilylet 0.1.49 → 0.1.51

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 +1813 -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 +702 -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 +195 -190
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +10 -3
  65. package/source/lilylet/lilypondDecoder.ts +91 -41
  66. package/source/lilylet/meiEncoder.ts +284 -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 +75 -21
  71. package/source/lilylet/types.ts +1 -0
@@ -0,0 +1,1813 @@
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
+ if (noteOptions.tremolo) {
412
+ const stemMod = tremoloToStemMod(noteOptions.tremolo);
413
+ if (stemMod)
414
+ chordAttrs += ` stem.mod="${stemMod}"`;
415
+ }
416
+ let result = `${indent}<chord ${chordAttrs}>\n`;
417
+ for (const p of event.pitches) {
418
+ const pitch = encodePitch(p, keyFifths, ottavaShift);
419
+ result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
420
+ }
421
+ // Artics for chord - group by placement
422
+ if (noteOptions.artics.length > 0) {
423
+ const aboveArtics = noteOptions.artics.filter(a => a.placement === 'above').map(a => a.type);
424
+ const belowArtics = noteOptions.artics.filter(a => a.placement === 'below').map(a => a.type);
425
+ const defaultArtics = noteOptions.artics.filter(a => !a.placement).map(a => a.type);
426
+ if (aboveArtics.length > 0) {
427
+ result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
428
+ }
429
+ if (belowArtics.length > 0) {
430
+ result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
431
+ }
432
+ if (defaultArtics.length > 0) {
433
+ result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
434
+ }
435
+ }
436
+ result += `${indent}</chord>\n`;
437
+ return {
438
+ xml: result,
439
+ elementId: chordId,
440
+ hairpin: markOptions.hairpin,
441
+ pedal: markOptions.pedal,
442
+ hasTieStart: markOptions.tieStart,
443
+ pitches: event.pitches,
444
+ arpeggio: markOptions.arpeggio,
445
+ fermata: markOptions.fermata,
446
+ trill: markOptions.trill,
447
+ mordent: markOptions.mordent,
448
+ turn: markOptions.turn,
449
+ dynamic: markOptions.dynamic,
450
+ slurStart: markOptions.slurStart,
451
+ slurEnd: markOptions.slurEnd,
452
+ fingerings: markOptions.fingerings,
453
+ navigation: markOptions.navigation,
454
+ markups: markOptions.markups,
455
+ };
456
+ };
457
+ // Convert RestEvent to MEI
458
+ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
459
+ const dur = DURATIONS[event.duration.division] || "4";
460
+ let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
461
+ if (event.duration.dots > 0)
462
+ attrs += ` dots="${event.duration.dots}"`;
463
+ // Pitched rest (positioned at specific pitch)
464
+ if (event.pitch) {
465
+ const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
466
+ attrs += ` ploc="${pitch.pname}" oloc="${pitch.oct}"`;
467
+ }
468
+ // Space rest (invisible)
469
+ if (event.invisible) {
470
+ return `${indent}<space ${attrs} />\n`;
471
+ }
472
+ // Full measure rest
473
+ if (event.fullMeasure) {
474
+ return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
475
+ }
476
+ return `${indent}<rest ${attrs} />\n`;
477
+ };
478
+ // Convert TupletEvent to MEI
479
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
480
+ // LilyPond \times 2/3 means "multiply duration by 2/3"
481
+ // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
482
+ // MEI: num = number of notes written, numbase = normal equivalent
483
+ const num = event.ratio.denominator; // denominator = actual note count
484
+ const numbase = event.ratio.numerator; // numerator = time equivalent
485
+ let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
486
+ const baseIndent = indent + ' ';
487
+ // Effective staff for cross-staff notation
488
+ const effectiveStaff = currentStaff ?? layerStaff;
489
+ // Collect control event info from notes inside tuplet
490
+ const slurStarts = [];
491
+ const slurEnds = [];
492
+ const dynamics = [];
493
+ const fermatas = [];
494
+ const trills = [];
495
+ const mordents = [];
496
+ const turns = [];
497
+ const arpeggios = [];
498
+ // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
499
+ // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
500
+ // Beam state is managed by encodeLayer, not here.
501
+ for (const e of event.events) {
502
+ if (e.type === 'note') {
503
+ // For cross-staff notation: set note's staff if different from layerStaff
504
+ const noteEvent = e;
505
+ const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
506
+ ? { ...noteEvent, staff: effectiveStaff }
507
+ : noteEvent;
508
+ const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
509
+ xml += result.xml;
510
+ // Collect slur info
511
+ if (result.slurStart)
512
+ slurStarts.push(result.elementId);
513
+ if (result.slurEnd)
514
+ slurEnds.push(result.elementId);
515
+ // Collect other control events
516
+ if (result.dynamic)
517
+ dynamics.push({ startid: result.elementId, label: result.dynamic });
518
+ if (result.fermata)
519
+ fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
520
+ if (result.trill)
521
+ trills.push({ startid: result.elementId });
522
+ if (result.mordent)
523
+ mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
524
+ if (result.turn)
525
+ turns.push({ startid: result.elementId });
526
+ if (result.arpeggio)
527
+ arpeggios.push({ plist: result.elementId });
528
+ }
529
+ else if (e.type === 'rest') {
530
+ xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
531
+ }
532
+ }
533
+ xml += `${indent}</tuplet>\n`;
534
+ return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
535
+ };
536
+ // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
537
+ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
538
+ const ftremId = generateId('fTrem');
539
+ // For \repeat tremolo 4 { c16 d16 }:
540
+ // - count = 4 (repetitions)
541
+ // - division = 16 (note value)
542
+ // - Total duration = 4 × 2 × 16th = 8 × 16th = half note
543
+ // - Each visible note = half of total = quarter note
544
+ // Calculate beams (tremolo strokes) based on division
545
+ // 8th = 1 beam, 16th = 2 beams, 32nd = 3 beams
546
+ const beams = Math.max(1, Math.log2(event.division / 8) + 1);
547
+ // Calculate visual duration for each note
548
+ // For \repeat tremolo 4 { c16 d16 }:
549
+ // - Total strokes = 4 × 2 = 8 sixteenth notes = 1/2 whole note
550
+ // - Each visible note = 1/4 whole note = quarter note (dur="4")
551
+ // Formula: dur = division / count (e.g., 16 / 4 = 4 for quarter note)
552
+ const noteDur = Math.round(event.division / event.count) || 4; // Default to quarter if calculation fails
553
+ let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
554
+ // First note (or chord)
555
+ if (event.pitchA.length === 1) {
556
+ const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
557
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
558
+ if (pitch.accid)
559
+ attrs += ` accid="${pitch.accid}"`;
560
+ if (pitch.accidGes)
561
+ attrs += ` accid.ges="${pitch.accidGes}"`;
562
+ result += `${indent} <note ${attrs} />\n`;
563
+ }
564
+ else if (event.pitchA.length > 1) {
565
+ result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
566
+ for (const p of event.pitchA) {
567
+ const pitch = encodePitch(p, keyFifths, ottavaShift);
568
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
569
+ if (pitch.accid)
570
+ attrs += ` accid="${pitch.accid}"`;
571
+ if (pitch.accidGes)
572
+ attrs += ` accid.ges="${pitch.accidGes}"`;
573
+ result += `${indent} <note ${attrs} />\n`;
574
+ }
575
+ result += `${indent} </chord>\n`;
576
+ }
577
+ // Second note (or chord)
578
+ if (event.pitchB.length === 1) {
579
+ const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
580
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
581
+ if (pitch.accid)
582
+ attrs += ` accid="${pitch.accid}"`;
583
+ if (pitch.accidGes)
584
+ attrs += ` accid.ges="${pitch.accidGes}"`;
585
+ result += `${indent} <note ${attrs} />\n`;
586
+ }
587
+ else if (event.pitchB.length > 1) {
588
+ result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
589
+ for (const p of event.pitchB) {
590
+ const pitch = encodePitch(p, keyFifths, ottavaShift);
591
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
592
+ if (pitch.accid)
593
+ attrs += ` accid="${pitch.accid}"`;
594
+ if (pitch.accidGes)
595
+ attrs += ` accid.ges="${pitch.accidGes}"`;
596
+ result += `${indent} <note ${attrs} />\n`;
597
+ }
598
+ result += `${indent} </chord>\n`;
599
+ }
600
+ result += `${indent}</fTrem>\n`;
601
+ return result;
602
+ };
603
+ // Helper: check if an event (or any note inside a tuplet) has beam start/end
604
+ const getEventBeamMarks = (event) => {
605
+ if (event.type === 'note') {
606
+ const markOptions = extractMarkOptions(event.marks);
607
+ return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
608
+ }
609
+ if (event.type === 'tuplet') {
610
+ const tuplet = event;
611
+ let beamStart = false;
612
+ let beamEnd = false;
613
+ for (const e of tuplet.events) {
614
+ if (e.type === 'note') {
615
+ const markOptions = extractMarkOptions(e.marks);
616
+ if (markOptions.beamStart)
617
+ beamStart = true;
618
+ if (markOptions.beamEnd)
619
+ beamEnd = true;
620
+ }
621
+ }
622
+ return { beamStart, beamEnd };
623
+ }
624
+ return { beamStart: false, beamEnd: false };
625
+ };
626
+ // Encode a layer (voice)
627
+ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
628
+ const layerId = generateId("layer");
629
+ let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
630
+ let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
631
+ const baseIndent = indent + ' ';
632
+ // Track current clef to only emit changes
633
+ let currentClef = initialClef;
634
+ // Track hairpin spans
635
+ const hairpins = [];
636
+ let currentHairpin = initialHairpin;
637
+ // Track pedal marks (each is independent, not paired spans)
638
+ const pedals = [];
639
+ // Track octave spans - initialize from previous measure if continuing
640
+ const octaves = [];
641
+ let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
642
+ let pendingOttava = null; // Track ottava to apply to next note
643
+ let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
644
+ let lastNoteId = null; // Track last note id for ending ottava spans
645
+ let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
646
+ // Track slur spans - slurs must be encoded as control events in MEI
647
+ const slurs = [];
648
+ let currentSlur = initialSlur ? { startId: initialSlur } : null;
649
+ // Track arpeggio refs
650
+ const arpeggios = [];
651
+ // Track ornament refs
652
+ const fermatas = [];
653
+ const trills = [];
654
+ const mordents = [];
655
+ const turns = [];
656
+ const dynamics = [];
657
+ const fingerings = [];
658
+ const navigations = [];
659
+ const harmonies = [];
660
+ const barlines = [];
661
+ const markups = [];
662
+ // Track current stem direction from context changes
663
+ let currentStemDirection = undefined;
664
+ // Track current staff for cross-staff notation
665
+ let currentStaff = voice.staff || 1;
666
+ // Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
667
+ let pendingTiePitches = [...initialTiePitches];
668
+ // Helper to check if pitches match for tie continuation
669
+ const pitchesMatch = (p1, p2) => {
670
+ if (p1.length !== p2.length)
671
+ return false;
672
+ for (let i = 0; i < p1.length; i++) {
673
+ if (p1[i].phonet !== p2[i].phonet || p1[i].octave !== p2[i].octave)
674
+ return false;
675
+ }
676
+ return true;
677
+ };
678
+ for (const event of voice.events) {
679
+ // Check for beam start/end in this event (including inside tuplets)
680
+ const { beamStart, beamEnd } = getEventBeamMarks(event);
681
+ // Open beam element if beam starts
682
+ if (beamStart && !beamElementOpen) {
683
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
684
+ beamElementOpen = true;
685
+ }
686
+ const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
687
+ switch (event.type) {
688
+ case 'note': {
689
+ const noteEvent = event;
690
+ // Check if this note should have tie="t" (matches pending tie)
691
+ const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
692
+ // If there's a pending ottava, apply it BEFORE encoding the note
693
+ if (pendingOttava !== null && pendingOttava !== 0) {
694
+ currentOttavaShift = pendingOttava; // Apply the shift for this note
695
+ }
696
+ // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
697
+ const effectiveNoteEvent = currentStaff !== voice.staff
698
+ ? { ...noteEvent, staff: currentStaff }
699
+ : noteEvent;
700
+ const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
701
+ xml += result.xml;
702
+ lastNoteId = result.elementId;
703
+ // If there's a pending ottava, start the span on this note
704
+ if (pendingOttava !== null && pendingOttava !== 0) {
705
+ const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
706
+ const disPlace = pendingOttava > 0 ? 'above' : 'below';
707
+ // Close existing span first if it has a different value
708
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
709
+ // Different value - close the old span
710
+ // Use the lastNoteId from before this note (which we saved before processing)
711
+ // Note: The span from previous measure will be closed by encodeMeasure
712
+ currentOctave = null;
713
+ }
714
+ // Start new span if we don't already have one with the same value
715
+ if (!currentOctave) {
716
+ currentOctave = { dis, disPlace, startId: result.elementId };
717
+ }
718
+ pendingOttava = null;
719
+ }
720
+ // Update pending tie pitches
721
+ if (result.hasTieStart) {
722
+ pendingTiePitches = result.pitches;
723
+ }
724
+ else if (tieEnd) {
725
+ pendingTiePitches = [];
726
+ }
727
+ // Track hairpin spans
728
+ if (result.hairpin === 'crescStart') {
729
+ currentHairpin = { form: 'cres', startId: result.elementId };
730
+ }
731
+ else if (result.hairpin === 'dimStart') {
732
+ currentHairpin = { form: 'dim', startId: result.elementId };
733
+ }
734
+ else if (result.hairpin === 'end' && currentHairpin) {
735
+ hairpins.push({
736
+ form: currentHairpin.form,
737
+ startId: currentHairpin.startId,
738
+ endId: result.elementId,
739
+ });
740
+ currentHairpin = null;
741
+ }
742
+ // Track pedal marks (each is independent)
743
+ if (result.pedal === 'down' || result.pedal === 'up') {
744
+ pedals.push({
745
+ startId: result.elementId,
746
+ dir: result.pedal,
747
+ });
748
+ }
749
+ // Track slur spans - end must be processed before start
750
+ // in case a note ends one slur and starts another
751
+ if (result.slurEnd && currentSlur) {
752
+ slurs.push({
753
+ startId: currentSlur.startId,
754
+ endId: result.elementId,
755
+ });
756
+ currentSlur = null;
757
+ }
758
+ if (result.slurStart) {
759
+ currentSlur = { startId: result.elementId };
760
+ }
761
+ // Track arpeggio refs
762
+ if (result.arpeggio) {
763
+ arpeggios.push({ plist: result.elementId });
764
+ }
765
+ // Track ornament refs (fermata, trill, mordent, turn)
766
+ if (result.fermata) {
767
+ fermatas.push({
768
+ startid: result.elementId,
769
+ shape: result.fermata === 'short' ? 'angular' : undefined,
770
+ });
771
+ }
772
+ if (result.trill) {
773
+ trills.push({ startid: result.elementId });
774
+ }
775
+ if (result.mordent) {
776
+ mordents.push({
777
+ startid: result.elementId,
778
+ form: result.mordent === 'upper' ? 'upper' : undefined,
779
+ });
780
+ }
781
+ if (result.turn) {
782
+ turns.push({ startid: result.elementId });
783
+ }
784
+ if (result.dynamic) {
785
+ dynamics.push({ startid: result.elementId, label: result.dynamic });
786
+ }
787
+ // Track fingerings
788
+ for (const fing of result.fingerings) {
789
+ fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
790
+ }
791
+ // Track markups from note marks
792
+ for (const mkup of result.markups) {
793
+ markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
794
+ }
795
+ // Track navigation marks
796
+ if (result.navigation) {
797
+ navigations.push({ type: result.navigation });
798
+ }
799
+ break;
800
+ }
801
+ case 'rest':
802
+ xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
803
+ break;
804
+ case 'tuplet': {
805
+ // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
806
+ // Pass beamElementOpen to tuplet so it knows not to create its own beam
807
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
808
+ xml += tupletResult.xml;
809
+ // Process slur ends first (to close any pending slurs from before this tuplet)
810
+ for (const endId of tupletResult.slurEnds) {
811
+ if (currentSlur) {
812
+ slurs.push({
813
+ startId: currentSlur.startId,
814
+ endId: endId,
815
+ });
816
+ currentSlur = null;
817
+ }
818
+ }
819
+ // Then process slur starts (to open new slurs)
820
+ for (const startId of tupletResult.slurStarts) {
821
+ currentSlur = { startId };
822
+ }
823
+ // Collect other control events from tuplet
824
+ dynamics.push(...tupletResult.dynamics);
825
+ fermatas.push(...tupletResult.fermatas);
826
+ trills.push(...tupletResult.trills);
827
+ mordents.push(...tupletResult.mordents);
828
+ turns.push(...tupletResult.turns);
829
+ arpeggios.push(...tupletResult.arpeggios);
830
+ break;
831
+ }
832
+ case 'tremolo':
833
+ xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
834
+ break;
835
+ case 'context': {
836
+ const ctx = event;
837
+ // Check for clef changes - emit <clef> element only if different from current
838
+ if (ctx.clef && ctx.clef !== currentClef) {
839
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
840
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
841
+ currentClef = ctx.clef;
842
+ }
843
+ // Check for ottava changes
844
+ if (ctx.ottava !== undefined) {
845
+ if (ctx.ottava === 0) {
846
+ // End current ottava span
847
+ if (currentOctave && lastNoteId) {
848
+ octaves.push({
849
+ dis: currentOctave.dis,
850
+ disPlace: currentOctave.disPlace,
851
+ startId: currentOctave.startId,
852
+ endId: lastNoteId,
853
+ });
854
+ currentOctave = null;
855
+ ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
856
+ }
857
+ // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
858
+ // It may be continued by a subsequent ottava command with the same value
859
+ currentOttavaShift = 0; // Reset the shift (will be restored if continued)
860
+ }
861
+ else {
862
+ // Check if this continues an existing span (same value)
863
+ const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
864
+ const disPlace = ctx.ottava > 0 ? 'above' : 'below';
865
+ if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
866
+ // Continuation - restore the shift but don't change the span
867
+ currentOttavaShift = ctx.ottava;
868
+ }
869
+ else {
870
+ // Different value - start new ottava span (will be applied to next note)
871
+ // If there's an existing span with different value, it will be closed when the note is processed
872
+ pendingOttava = ctx.ottava;
873
+ }
874
+ }
875
+ }
876
+ // Check for stem direction changes
877
+ if (ctx.stemDirection !== undefined) {
878
+ currentStemDirection = ctx.stemDirection;
879
+ }
880
+ // Check for staff changes (cross-staff notation)
881
+ if (ctx.staff !== undefined) {
882
+ currentStaff = ctx.staff;
883
+ }
884
+ // Other context changes are handled at measure level
885
+ break;
886
+ }
887
+ case 'pitchReset':
888
+ // Pitch reset events are only used during pitch resolution in the parser.
889
+ // They don't produce any MEI output - just skip them.
890
+ break;
891
+ case 'barline':
892
+ barlines.push({ style: event.style });
893
+ break;
894
+ case 'harmony':
895
+ // Harmony needs a note ID to attach to - use the last note if available
896
+ if (lastNoteId) {
897
+ harmonies.push({ startid: lastNoteId, text: event.text });
898
+ }
899
+ break;
900
+ case 'markup':
901
+ // Markup needs a note ID to attach to - use the last note if available
902
+ if (lastNoteId) {
903
+ const mkupEvent = event;
904
+ markups.push({
905
+ startid: lastNoteId,
906
+ content: mkupEvent.content,
907
+ placement: mkupEvent.placement,
908
+ });
909
+ }
910
+ break;
911
+ }
912
+ // Close beam element if beam ends
913
+ if (beamEnd && beamElementOpen) {
914
+ xml += `${baseIndent}</beam>\n`;
915
+ beamElementOpen = false;
916
+ }
917
+ }
918
+ // Close any unclosed beam
919
+ if (beamElementOpen) {
920
+ xml += `${baseIndent}</beam>\n`;
921
+ }
922
+ // Don't close ottava span at measure end - it may continue in the next measure
923
+ // Build pending octave state to return
924
+ const pendingOctave = currentOctave
925
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
926
+ : null;
927
+ xml += `${indent}</layer>\n`;
928
+ 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 };
929
+ };
930
+ // Encode a staff
931
+ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
932
+ const staffId = generateId("staff");
933
+ let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
934
+ const allHairpins = [];
935
+ const allPedals = [];
936
+ const allOctaves = [];
937
+ const allSlurs = [];
938
+ const allArpeggios = [];
939
+ const allFermatas = [];
940
+ const allTrills = [];
941
+ const allMordents = [];
942
+ const allTurns = [];
943
+ const allDynamics = [];
944
+ const allFingerings = [];
945
+ const allNavigations = [];
946
+ const allHarmonies = [];
947
+ const allBarlines = [];
948
+ const allMarkups = [];
949
+ const pendingTies = {};
950
+ const pendingSlurs = {};
951
+ const pendingHairpins = {};
952
+ const pendingOctaves = {};
953
+ const ottavaExplicitlyClosed = {};
954
+ const lastNoteIds = {};
955
+ let endingClef = initialClef;
956
+ if (voices.length === 0) {
957
+ xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
958
+ }
959
+ else {
960
+ voices.forEach((voice, vi) => {
961
+ const layerN = vi + 1;
962
+ const tieKey = `${staffN}-${layerN}`;
963
+ const initialTies = tieState[tieKey] || [];
964
+ const initialSlur = slurState[tieKey] || null;
965
+ const initialHairpin = hairpinState[tieKey] || null;
966
+ const initialOctave = ottavaState[tieKey] || null;
967
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
968
+ xml += result.xml;
969
+ allHairpins.push(...result.hairpins);
970
+ allPedals.push(...result.pedals);
971
+ allOctaves.push(...result.octaves);
972
+ allSlurs.push(...result.slurs);
973
+ allArpeggios.push(...result.arpeggios);
974
+ allFermatas.push(...result.fermatas);
975
+ allTrills.push(...result.trills);
976
+ allMordents.push(...result.mordents);
977
+ allTurns.push(...result.turns);
978
+ allDynamics.push(...result.dynamics);
979
+ allFingerings.push(...result.fingerings);
980
+ allNavigations.push(...result.navigations);
981
+ allHarmonies.push(...result.harmonies);
982
+ allBarlines.push(...result.barlines);
983
+ allMarkups.push(...result.markups);
984
+ // Track pending ties for this layer
985
+ if (result.pendingTiePitches.length > 0) {
986
+ pendingTies[tieKey] = result.pendingTiePitches;
987
+ }
988
+ // Track pending slurs for this layer
989
+ if (result.pendingSlur) {
990
+ pendingSlurs[tieKey] = result.pendingSlur;
991
+ }
992
+ // Track pending hairpins for this layer
993
+ if (result.pendingHairpin) {
994
+ pendingHairpins[tieKey] = result.pendingHairpin;
995
+ }
996
+ // Track pending ottava spans for this layer
997
+ if (result.pendingOctave) {
998
+ pendingOctaves[tieKey] = result.pendingOctave;
999
+ }
1000
+ // Track if ottava was explicitly closed in this layer
1001
+ if (result.ottavaExplicitlyClosed) {
1002
+ ottavaExplicitlyClosed[tieKey] = true;
1003
+ }
1004
+ // Track last note IDs for this layer (for closing ottava spans)
1005
+ lastNoteIds[tieKey] = result.lastNoteId;
1006
+ // Track ending clef for cross-measure tracking
1007
+ if (result.endingClef) {
1008
+ endingClef = result.endingClef;
1009
+ }
1010
+ });
1011
+ }
1012
+ xml += `${indent}</staff>\n`;
1013
+ return {
1014
+ xml,
1015
+ hairpins: allHairpins,
1016
+ pedals: allPedals,
1017
+ octaves: allOctaves,
1018
+ slurs: allSlurs,
1019
+ arpeggios: allArpeggios,
1020
+ fermatas: allFermatas,
1021
+ trills: allTrills,
1022
+ mordents: allMordents,
1023
+ turns: allTurns,
1024
+ dynamics: allDynamics,
1025
+ fingerings: allFingerings,
1026
+ navigations: allNavigations,
1027
+ harmonies: allHarmonies,
1028
+ barlines: allBarlines,
1029
+ markups: allMarkups,
1030
+ pendingTies,
1031
+ pendingSlurs,
1032
+ pendingHairpins,
1033
+ pendingOctaves,
1034
+ ottavaExplicitlyClosed,
1035
+ lastNoteIds,
1036
+ endingClef,
1037
+ };
1038
+ };
1039
+ // Tempo text to BPM mapping (approximate values based on musical convention)
1040
+ const TEMPO_TEXT_TO_BPM = {
1041
+ // Very slow
1042
+ 'grave': 35,
1043
+ 'largo': 50,
1044
+ 'larghetto': 63,
1045
+ 'lento': 52,
1046
+ 'adagio': 70,
1047
+ // Slow to moderate
1048
+ 'andante': 92,
1049
+ 'andantino': 96,
1050
+ 'moderato': 114,
1051
+ // Fast
1052
+ 'allegretto': 116,
1053
+ 'allegro': 138,
1054
+ 'vivace': 166,
1055
+ 'presto': 184,
1056
+ 'prestissimo': 208,
1057
+ };
1058
+ // Infer BPM from tempo text
1059
+ const inferBpmFromText = (text) => {
1060
+ const lowerText = text.toLowerCase();
1061
+ for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
1062
+ if (lowerText.includes(keyword)) {
1063
+ return bpm;
1064
+ }
1065
+ }
1066
+ return undefined;
1067
+ };
1068
+ // Generate tempo element
1069
+ const generateTempoElement = (tempo, indent, staff = 1) => {
1070
+ let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
1071
+ // Determine BPM: use explicit value or infer from text
1072
+ let bpm = tempo.bpm;
1073
+ if (!bpm && tempo.text) {
1074
+ bpm = inferBpmFromText(tempo.text);
1075
+ }
1076
+ // Add BPM if available
1077
+ if (bpm) {
1078
+ attrs += ` midi.bpm="${bpm}"`;
1079
+ if (tempo.beat) {
1080
+ attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
1081
+ }
1082
+ }
1083
+ // Generate content
1084
+ let content = '';
1085
+ if (tempo.text) {
1086
+ content = escapeXml(tempo.text);
1087
+ }
1088
+ if (tempo.beat && tempo.bpm) {
1089
+ const beatSymbol = tempo.beat.division === 4 ? '♩' : tempo.beat.division === 2 ? '𝅗𝅥' : '♪';
1090
+ if (content)
1091
+ content += ' ';
1092
+ content += `${beatSymbol} = ${tempo.bpm}`;
1093
+ }
1094
+ if (content) {
1095
+ return `${indent}<tempo ${attrs}>${content}</tempo>\n`;
1096
+ }
1097
+ return `${indent}<tempo ${attrs} />\n`;
1098
+ };
1099
+ // Barline style to MEI @right attribute mapping
1100
+ const BARLINE_TO_MEI = {
1101
+ '|': 'single',
1102
+ '||': 'dbl',
1103
+ '|.': 'end',
1104
+ '.|:': 'rptstart',
1105
+ ':|.': 'rptend',
1106
+ ':..:|': 'rptboth',
1107
+ ':..:': 'rptboth',
1108
+ };
1109
+ // Encode a measure
1110
+ // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1111
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
1112
+ const measureId = generateId("measure");
1113
+ let staffContent = ''; // Build staff content first, then add measure tag with barline
1114
+ const allHairpins = [];
1115
+ const allPedals = [];
1116
+ const allOctaves = [];
1117
+ const allSlurs = [];
1118
+ const allArpeggios = [];
1119
+ const allFermatas = [];
1120
+ const allTrills = [];
1121
+ const allMordents = [];
1122
+ const allTurns = [];
1123
+ const allDynamics = [];
1124
+ const allFingerings = [];
1125
+ const allNavigations = [];
1126
+ const allHarmonies = [];
1127
+ const allBarlines = [];
1128
+ const allMarkups = [];
1129
+ // Extract tempo from context changes (track which staff it came from)
1130
+ let measureTempo;
1131
+ let tempoStaff = 1;
1132
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1133
+ const part = measure.parts[pi];
1134
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1135
+ for (const voice of part.voices) {
1136
+ const localStaff = voice.staff || 1;
1137
+ const globalStaff = partOffset + localStaff;
1138
+ for (const event of voice.events) {
1139
+ if (event.type === 'context') {
1140
+ const ctx = event;
1141
+ if (ctx.tempo) {
1142
+ measureTempo = ctx.tempo;
1143
+ tempoStaff = globalStaff;
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ // Group voices by global staff (local staff + part offset)
1150
+ const voicesByStaff = {};
1151
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1152
+ const part = measure.parts[pi];
1153
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1154
+ for (const voice of part.voices) {
1155
+ const localStaff = voice.staff || 1;
1156
+ const globalStaff = partOffset + localStaff;
1157
+ if (!voicesByStaff[globalStaff]) {
1158
+ voicesByStaff[globalStaff] = [];
1159
+ }
1160
+ voicesByStaff[globalStaff].push(voice);
1161
+ }
1162
+ }
1163
+ // Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
1164
+ for (let si = 1; si <= totalStaves; si++) {
1165
+ const voices = voicesByStaff[si] || [];
1166
+ const initialClef = clefState[si];
1167
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1168
+ staffContent += result.xml;
1169
+ allHairpins.push(...result.hairpins);
1170
+ allPedals.push(...result.pedals);
1171
+ allOctaves.push(...result.octaves);
1172
+ allSlurs.push(...result.slurs);
1173
+ allArpeggios.push(...result.arpeggios);
1174
+ allFermatas.push(...result.fermatas);
1175
+ allTrills.push(...result.trills);
1176
+ allMordents.push(...result.mordents);
1177
+ allTurns.push(...result.turns);
1178
+ allDynamics.push(...result.dynamics);
1179
+ allFingerings.push(...result.fingerings);
1180
+ allNavigations.push(...result.navigations);
1181
+ allHarmonies.push(...result.harmonies);
1182
+ allBarlines.push(...result.barlines);
1183
+ allMarkups.push(...result.markups);
1184
+ // Update tie state with pending ties from this staff
1185
+ Object.assign(tieState, result.pendingTies);
1186
+ // Update slur state with pending slurs from this staff
1187
+ Object.assign(slurState, result.pendingSlurs);
1188
+ // Update hairpin state with pending hairpins from this staff
1189
+ Object.assign(hairpinState, result.pendingHairpins);
1190
+ // Update ottava state with pending octaves from this staff
1191
+ // Also handle closing spans when ottava ends
1192
+ const currentStaffPrefix = `${si}-`;
1193
+ for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1194
+ if (pending) {
1195
+ // Check if this is a continuation or a new span
1196
+ const prevPending = ottavaState[key];
1197
+ if (prevPending && prevPending.shift === pending.shift) {
1198
+ // Same ottava value continues - keep the original startId
1199
+ ottavaState[key] = { ...pending, startId: prevPending.startId };
1200
+ }
1201
+ else {
1202
+ // Different ottava value - close the old span first if exists
1203
+ if (prevPending) {
1204
+ const lastNoteId = result.lastNoteIds[key];
1205
+ if (lastNoteId) {
1206
+ allOctaves.push({
1207
+ dis: prevPending.dis,
1208
+ disPlace: prevPending.disPlace,
1209
+ startId: prevPending.startId,
1210
+ endId: lastNoteId,
1211
+ });
1212
+ }
1213
+ }
1214
+ // Start new span
1215
+ ottavaState[key] = pending;
1216
+ }
1217
+ }
1218
+ }
1219
+ // For layers in this staff that had pending octaves but didn't in this measure, close the spans
1220
+ for (const [key, pending] of Object.entries(ottavaState)) {
1221
+ // Only process keys for the current staff
1222
+ if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
1223
+ // Check if the span was already explicitly closed in encodeLayer
1224
+ // If so, don't generate another span (it was already pushed to octaves in encodeLayer)
1225
+ if (!result.ottavaExplicitlyClosed[key]) {
1226
+ // Ottava ended without explicit close - generate the closing span
1227
+ const lastNoteId = result.lastNoteIds[key];
1228
+ if (lastNoteId) {
1229
+ allOctaves.push({
1230
+ dis: pending.dis,
1231
+ disPlace: pending.disPlace,
1232
+ startId: pending.startId,
1233
+ endId: lastNoteId,
1234
+ });
1235
+ }
1236
+ }
1237
+ delete ottavaState[key];
1238
+ }
1239
+ }
1240
+ // Update clef state with ending clef from this staff
1241
+ if (result.endingClef) {
1242
+ clefState[si] = result.endingClef;
1243
+ }
1244
+ }
1245
+ // Generate tempo element if present
1246
+ if (measureTempo) {
1247
+ staffContent += generateTempoElement(measureTempo, indent + ' ', tempoStaff);
1248
+ }
1249
+ // Generate hairpin control events
1250
+ for (const hp of allHairpins) {
1251
+ staffContent += `${indent} <hairpin xml:id="${generateId('hairpin')}" form="${hp.form}" startid="#${hp.startId}" endid="#${hp.endId}" />\n`;
1252
+ }
1253
+ // Generate pedal control events (each mark is independent)
1254
+ for (const ped of allPedals) {
1255
+ staffContent += `${indent} <pedal xml:id="${generateId('pedal')}" dir="${ped.dir}" startid="#${ped.startId}" />\n`;
1256
+ }
1257
+ // Generate octave control events
1258
+ for (const oct of allOctaves) {
1259
+ staffContent += `${indent} <octave xml:id="${generateId('octave')}" dis="${oct.dis}" dis.place="${oct.disPlace}" startid="#${oct.startId}" endid="#${oct.endId}" />\n`;
1260
+ }
1261
+ // Generate slur control events
1262
+ for (const sl of allSlurs) {
1263
+ staffContent += `${indent} <slur xml:id="${generateId('slur')}" startid="#${sl.startId}" endid="#${sl.endId}" />\n`;
1264
+ }
1265
+ // Generate arpeggio control events
1266
+ for (const arp of allArpeggios) {
1267
+ staffContent += `${indent} <arpeg xml:id="${generateId('arpeg')}" plist="#${arp.plist}" />\n`;
1268
+ }
1269
+ // Generate fermata control events
1270
+ for (const ferm of allFermatas) {
1271
+ const shapeAttr = ferm.shape ? ` shape="${ferm.shape}"` : '';
1272
+ staffContent += `${indent} <fermata xml:id="${generateId('fermata')}" startid="#${ferm.startid}"${shapeAttr} />\n`;
1273
+ }
1274
+ // Generate trill control events
1275
+ for (const tr of allTrills) {
1276
+ staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
1277
+ }
1278
+ // Generate mordent control events
1279
+ for (const mord of allMordents) {
1280
+ const formAttr = mord.form ? ` form="${mord.form}"` : '';
1281
+ staffContent += `${indent} <mordent xml:id="${generateId('mordent')}" startid="#${mord.startid}"${formAttr} />\n`;
1282
+ }
1283
+ // Generate turn control events
1284
+ for (const tu of allTurns) {
1285
+ staffContent += `${indent} <turn xml:id="${generateId('turn')}" startid="#${tu.startid}" />\n`;
1286
+ }
1287
+ // Generate dynamic control events
1288
+ for (const dyn of allDynamics) {
1289
+ staffContent += `${indent} <dynam xml:id="${generateId('dynam')}" startid="#${dyn.startid}">${dyn.label}</dynam>\n`;
1290
+ }
1291
+ // Generate fingering control events
1292
+ for (const fing of allFingerings) {
1293
+ const placeAttr = fing.placement ? ` place="${fing.placement}"` : '';
1294
+ staffContent += `${indent} <fing xml:id="${generateId('fing')}" startid="#${fing.startid}"${placeAttr}>${fing.finger}</fing>\n`;
1295
+ }
1296
+ // Generate dir elements for navigation marks (coda, segno)
1297
+ for (const nav of allNavigations) {
1298
+ // Use <dir> element with appropriate glyph
1299
+ const glyph = nav.type === 'coda' ? '𝄌' : '𝄋'; // Unicode coda/segno symbols
1300
+ staffContent += `${indent} <dir xml:id="${generateId('dir')}" tstamp="1">${glyph}</dir>\n`;
1301
+ }
1302
+ // Generate harm elements for chord symbols
1303
+ for (const harm of allHarmonies) {
1304
+ staffContent += `${indent} <harm xml:id="${generateId('harm')}" startid="#${harm.startid}">${escapeXml(harm.text)}</harm>\n`;
1305
+ }
1306
+ // Generate dir elements for markups
1307
+ for (const mkup of allMarkups) {
1308
+ const placeAttr = mkup.placement ? ` place="${mkup.placement}"` : '';
1309
+ staffContent += `${indent} <dir xml:id="${generateId('dir')}" startid="#${mkup.startid}"${placeAttr}>${escapeXml(mkup.content)}</dir>\n`;
1310
+ }
1311
+ // Determine barline attribute from collected barlines
1312
+ let barlineAttr = '';
1313
+ if (allBarlines.length > 0) {
1314
+ const lastBarline = allBarlines[allBarlines.length - 1];
1315
+ const meiBarline = BARLINE_TO_MEI[lastBarline.style];
1316
+ if (meiBarline && meiBarline !== 'single') {
1317
+ barlineAttr = ` right="${meiBarline}"`;
1318
+ }
1319
+ }
1320
+ // Build final XML with measure tag including barline
1321
+ let xml = `${indent}<measure xml:id="${measureId}" n="${measureN}"${barlineAttr}>\n`;
1322
+ xml += staffContent;
1323
+ xml += `${indent}</measure>\n`;
1324
+ return xml;
1325
+ };
1326
+ // Analyze document to get part structure
1327
+ const analyzePartStructure = (doc) => {
1328
+ // Find maximum number of parts in any measure
1329
+ let maxParts = 0;
1330
+ for (const measure of doc.measures) {
1331
+ maxParts = Math.max(maxParts, measure.parts.length);
1332
+ }
1333
+ // Initialize part info
1334
+ const partInfos = [];
1335
+ for (let i = 0; i < maxParts; i++) {
1336
+ partInfos.push({ maxStaff: 1, staffOffset: 0, clefs: {} });
1337
+ }
1338
+ // Analyze each measure to find max staff per part and clefs
1339
+ for (const measure of doc.measures) {
1340
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1341
+ const part = measure.parts[pi];
1342
+ for (const voice of part.voices) {
1343
+ const localStaff = voice.staff || 1;
1344
+ partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, localStaff);
1345
+ // Get FIRST clef from context changes (for initial staffDef)
1346
+ for (const event of voice.events) {
1347
+ if (event.type === 'context') {
1348
+ const ctx = event;
1349
+ if (ctx.clef && !partInfos[pi].clefs[localStaff]) {
1350
+ // Only set if not already set - take the FIRST clef
1351
+ partInfos[pi].clefs[localStaff] = ctx.clef;
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ }
1358
+ // Calculate staff offsets
1359
+ let offset = 0;
1360
+ for (const info of partInfos) {
1361
+ info.staffOffset = offset;
1362
+ offset += info.maxStaff;
1363
+ }
1364
+ return partInfos;
1365
+ };
1366
+ // Encode scoreDef with part groups
1367
+ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1368
+ const scoreDefId = generateId("scoredef");
1369
+ // Build meter attributes
1370
+ const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1371
+ let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1372
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1373
+ for (let pi = 0; pi < partInfos.length; pi++) {
1374
+ const info = partInfos[pi];
1375
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace
1376
+ if (info.maxStaff > 1) {
1377
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1378
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1379
+ const globalStaff = info.staffOffset + ls;
1380
+ const clef = info.clefs[ls] || Clef.treble;
1381
+ const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1382
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1383
+ }
1384
+ xml += `${indent} </staffGrp>\n`;
1385
+ }
1386
+ else {
1387
+ // Single staff part
1388
+ const globalStaff = info.staffOffset + 1;
1389
+ const clef = info.clefs[1] || Clef.treble;
1390
+ const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1391
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1392
+ }
1393
+ }
1394
+ xml += `${indent} </staffGrp>\n`;
1395
+ xml += `${indent}</scoreDef>\n`;
1396
+ return xml;
1397
+ };
1398
+ // === Auto-beam logic ===
1399
+ // Check if any NoteEvent in the document has a beam mark
1400
+ const docHasBeamMarks = (doc) => {
1401
+ for (const measure of doc.measures) {
1402
+ for (const part of measure.parts) {
1403
+ for (const voice of part.voices) {
1404
+ for (const event of voice.events) {
1405
+ if (event.type === 'note') {
1406
+ const note = event;
1407
+ if (note.marks) {
1408
+ for (const m of note.marks) {
1409
+ if (m.markType === 'beam')
1410
+ return true;
1411
+ }
1412
+ }
1413
+ }
1414
+ else if (event.type === 'tuplet') {
1415
+ const tuplet = event;
1416
+ for (const e of tuplet.events) {
1417
+ if (e.type === 'note') {
1418
+ const note = e;
1419
+ if (note.marks) {
1420
+ for (const m of note.marks) {
1421
+ if (m.markType === 'beam')
1422
+ return true;
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+ }
1428
+ }
1429
+ }
1430
+ }
1431
+ }
1432
+ return false;
1433
+ };
1434
+ // Resolve whether auto-beam should be applied
1435
+ const resolveAutoBeam = (doc) => {
1436
+ if (doc.metadata?.autoBeam === 'off')
1437
+ return false;
1438
+ if (doc.metadata?.autoBeam === 'on')
1439
+ return true;
1440
+ // 'auto' or undefined: auto-beam if no manual beam marks exist
1441
+ return !docHasBeamMarks(doc);
1442
+ };
1443
+ // Compute beam group sizes in eighth-note units for a given time signature
1444
+ const getBeamGroups = (timeNum, timeDen) => {
1445
+ // Compound meters (n/8 where n is divisible by 3, and n > 3)
1446
+ if (timeDen === 8 && timeNum % 3 === 0 && timeNum > 3) {
1447
+ const groupCount = timeNum / 3;
1448
+ return Array(groupCount).fill(3);
1449
+ }
1450
+ // Specific common time signatures (LilyPond defaults)
1451
+ if (timeDen === 8 && timeNum === 3)
1452
+ return [3];
1453
+ if (timeDen === 4 && timeNum === 2)
1454
+ return [2, 2];
1455
+ if (timeDen === 4 && timeNum === 3)
1456
+ return [3, 3];
1457
+ if (timeDen === 4 && timeNum === 4)
1458
+ return [4, 4];
1459
+ if (timeDen === 2 && timeNum === 2)
1460
+ return [4, 4];
1461
+ // Generic simple meters: each beat = 8/den eighths
1462
+ const eighthsPerBeat = 8 / timeDen;
1463
+ if (eighthsPerBeat >= 1) {
1464
+ return Array(timeNum).fill(eighthsPerBeat);
1465
+ }
1466
+ // Fallback: one group for the whole measure
1467
+ const totalEighths = timeNum * 8 / timeDen;
1468
+ return [totalEighths];
1469
+ };
1470
+ // Calculate duration in eighth-note units
1471
+ const durationInEighths = (division, dots, tupletRatio) => {
1472
+ // Base duration in eighths: 8 / division
1473
+ let dur = 8 / division;
1474
+ // Dot multiplier: 1 + 1/2 + 1/4 + ... = 2 - 1/2^dots
1475
+ if (dots > 0) {
1476
+ dur *= (2 - Math.pow(0.5, dots));
1477
+ }
1478
+ // Tuplet ratio: multiply by num/den (LilyPond semantics)
1479
+ if (tupletRatio) {
1480
+ dur *= tupletRatio.numerator / tupletRatio.denominator;
1481
+ }
1482
+ return dur;
1483
+ };
1484
+ // Apply auto-beam to the document, mutating events' marks arrays in-place
1485
+ const applyAutoBeam = (doc) => {
1486
+ // Track time signature across measures
1487
+ let timeNum = 4;
1488
+ let timeDen = 4;
1489
+ // Get initial time signature
1490
+ if (doc.measures.length > 0 && doc.measures[0].timeSig) {
1491
+ timeNum = doc.measures[0].timeSig.numerator;
1492
+ timeDen = doc.measures[0].timeSig.denominator;
1493
+ }
1494
+ for (const measure of doc.measures) {
1495
+ // Update time signature if changed
1496
+ if (measure.timeSig) {
1497
+ timeNum = measure.timeSig.numerator;
1498
+ timeDen = measure.timeSig.denominator;
1499
+ }
1500
+ const beamGroups = getBeamGroups(timeNum, timeDen);
1501
+ for (const part of measure.parts) {
1502
+ for (const voice of part.voices) {
1503
+ applyAutoBeamToVoice(voice.events, beamGroups);
1504
+ }
1505
+ }
1506
+ }
1507
+ };
1508
+ // Apply auto-beam to a single voice's events
1509
+ const applyAutoBeamToVoice = (events, beamGroups) => {
1510
+ // Compute group boundary positions in eighths
1511
+ const groupBoundaries = [];
1512
+ let boundary = 0;
1513
+ for (const size of beamGroups) {
1514
+ boundary += size;
1515
+ groupBoundaries.push(boundary);
1516
+ }
1517
+ const totalMeasureEighths = boundary;
1518
+ // Collect beamable notes with their positions
1519
+ let position = 0;
1520
+ const beamableRuns = [];
1521
+ let currentRun = [];
1522
+ // Helper: find which group index a position belongs to
1523
+ const getGroupIndex = (pos) => {
1524
+ for (let i = 0; i < groupBoundaries.length; i++) {
1525
+ if (pos < groupBoundaries[i])
1526
+ return i;
1527
+ }
1528
+ return groupBoundaries.length - 1;
1529
+ };
1530
+ // Helper: flush current run into beamableRuns
1531
+ const flushRun = () => {
1532
+ if (currentRun.length >= 2) {
1533
+ beamableRuns.push(currentRun);
1534
+ }
1535
+ currentRun = [];
1536
+ };
1537
+ for (const event of events) {
1538
+ if (event.type === 'note') {
1539
+ const note = event;
1540
+ if (note.grace)
1541
+ continue; // skip grace notes
1542
+ const dur = durationInEighths(note.duration.division, note.duration.dots);
1543
+ if (note.duration.division >= 8) {
1544
+ // Beamable note
1545
+ const groupIdx = getGroupIndex(position);
1546
+ const noteEndPos = position + dur;
1547
+ const endGroupIdx = getGroupIndex(Math.min(noteEndPos - 0.001, totalMeasureEighths - 0.001));
1548
+ // Note must start and end within the same group
1549
+ if (groupIdx === endGroupIdx) {
1550
+ // Check if current run is in the same group
1551
+ if (currentRun.length > 0) {
1552
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1553
+ if (lastGroupIdx !== groupIdx) {
1554
+ flushRun();
1555
+ }
1556
+ }
1557
+ currentRun.push({ note, position });
1558
+ }
1559
+ else {
1560
+ // Note spans group boundary — break
1561
+ flushRun();
1562
+ }
1563
+ }
1564
+ else {
1565
+ // Non-beamable note (quarter or longer) — break
1566
+ flushRun();
1567
+ }
1568
+ position += dur;
1569
+ }
1570
+ else if (event.type === 'rest') {
1571
+ const rest = event;
1572
+ const dur = durationInEighths(rest.duration.division, rest.duration.dots);
1573
+ // Rests break beam groups
1574
+ flushRun();
1575
+ position += dur;
1576
+ }
1577
+ else if (event.type === 'tuplet') {
1578
+ const tuplet = event;
1579
+ const ratio = tuplet.ratio; // LilyPond ratio: num/den
1580
+ // Check if all inner notes are beamable (division >= 8)
1581
+ const innerNotes = [];
1582
+ let allBeamable = true;
1583
+ let tupletDur = 0;
1584
+ for (const e of tuplet.events) {
1585
+ if (e.type === 'note') {
1586
+ const note = e;
1587
+ if (note.grace)
1588
+ continue;
1589
+ const dur = durationInEighths(note.duration.division, note.duration.dots, ratio);
1590
+ innerNotes.push({ note, dur });
1591
+ tupletDur += dur;
1592
+ if (note.duration.division < 8) {
1593
+ allBeamable = false;
1594
+ }
1595
+ }
1596
+ else if (e.type === 'rest') {
1597
+ allBeamable = false;
1598
+ const dur = durationInEighths(e.duration.division, e.duration.dots, ratio);
1599
+ tupletDur += dur;
1600
+ }
1601
+ }
1602
+ if (allBeamable && innerNotes.length > 0) {
1603
+ const groupIdx = getGroupIndex(position);
1604
+ const endGroupIdx = getGroupIndex(Math.min(position + tupletDur - 0.001, totalMeasureEighths - 0.001));
1605
+ if (groupIdx === endGroupIdx) {
1606
+ // Tuplet fits within one group — add inner notes to current run
1607
+ if (currentRun.length > 0) {
1608
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1609
+ if (lastGroupIdx !== groupIdx) {
1610
+ flushRun();
1611
+ }
1612
+ }
1613
+ let innerPos = position;
1614
+ for (const { note, dur } of innerNotes) {
1615
+ currentRun.push({ note, position: innerPos });
1616
+ innerPos += dur;
1617
+ }
1618
+ }
1619
+ else {
1620
+ flushRun();
1621
+ }
1622
+ }
1623
+ else {
1624
+ flushRun();
1625
+ }
1626
+ position += tupletDur;
1627
+ }
1628
+ else if (event.type === 'context' || event.type === 'pitchReset' || event.type === 'barline' || event.type === 'harmony' || event.type === 'markup') {
1629
+ // Non-musical events: don't advance position, don't break beams
1630
+ continue;
1631
+ }
1632
+ else if (event.type === 'tremolo') {
1633
+ // Tremolo breaks beams
1634
+ const trem = event;
1635
+ // Total duration = count * 2 * (1/division) in whole notes
1636
+ // In eighths: count * 2 * (8/division)
1637
+ const dur = trem.count * 2 * (8 / trem.division);
1638
+ flushRun();
1639
+ position += dur;
1640
+ }
1641
+ }
1642
+ // Flush any remaining run
1643
+ flushRun();
1644
+ // Apply beam marks to collected runs
1645
+ for (const run of beamableRuns) {
1646
+ const first = run[0].note;
1647
+ const last = run[run.length - 1].note;
1648
+ if (!first.marks)
1649
+ first.marks = [];
1650
+ first.marks.push({ markType: 'beam', start: true });
1651
+ if (!last.marks)
1652
+ last.marks = [];
1653
+ last.marks.push({ markType: 'beam', start: false });
1654
+ }
1655
+ };
1656
+ // Main encode function
1657
+ const encode = (doc, options = {}) => {
1658
+ const indent = options.indent || " ";
1659
+ resetIdCounter();
1660
+ if (!doc.measures || doc.measures.length === 0) {
1661
+ return "";
1662
+ }
1663
+ // Analyze part structure to get staff offsets
1664
+ const partInfos = analyzePartStructure(doc);
1665
+ // Calculate total staff count
1666
+ const totalStaves = partInfos.reduce((sum, info) => sum + info.maxStaff, 0);
1667
+ // Collect initial key/time from first measure
1668
+ let currentKey = 0;
1669
+ let currentTimeNum = 4;
1670
+ let currentTimeDen = 4;
1671
+ let currentMeterSymbol = undefined;
1672
+ const firstMeasure = doc.measures[0];
1673
+ if (firstMeasure.key) {
1674
+ currentKey = keyToFifths(firstMeasure.key);
1675
+ }
1676
+ if (firstMeasure.timeSig) {
1677
+ currentTimeNum = firstMeasure.timeSig.numerator;
1678
+ currentTimeDen = firstMeasure.timeSig.denominator;
1679
+ currentMeterSymbol = firstMeasure.timeSig.symbol;
1680
+ }
1681
+ const keySig = KEY_SIGS[currentKey] || "0";
1682
+ // Apply auto-beam if needed (before encoding so beam marks are picked up by encodeLayer)
1683
+ const shouldAutoBeam = resolveAutoBeam(doc);
1684
+ if (shouldAutoBeam) {
1685
+ applyAutoBeam(doc);
1686
+ }
1687
+ // Build MEI document
1688
+ const xmlDecl = options.xmlDeclaration !== false
1689
+ ? '<?xml version="1.0" encoding="UTF-8"?>\n'
1690
+ : "";
1691
+ let mei = xmlDecl;
1692
+ mei += '<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">\n';
1693
+ mei += `${indent}<meiHead>\n`;
1694
+ mei += `${indent}${indent}<fileDesc>\n`;
1695
+ mei += `${indent}${indent}${indent}<titleStmt>\n`;
1696
+ // Add title from metadata if available
1697
+ if (doc.metadata?.title) {
1698
+ mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.title)}</title>\n`;
1699
+ }
1700
+ // Add subtitle as second title (verovio reads subsequent titles as subtitle)
1701
+ if (doc.metadata?.subtitle) {
1702
+ mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.subtitle)}</title>\n`;
1703
+ }
1704
+ // Add composer (right-aligned in verovio)
1705
+ if (doc.metadata?.composer) {
1706
+ mei += `${indent}${indent}${indent}${indent}<composer>${escapeXml(doc.metadata.composer)}</composer>\n`;
1707
+ }
1708
+ // Add arranger (right-aligned in verovio)
1709
+ if (doc.metadata?.arranger) {
1710
+ mei += `${indent}${indent}${indent}${indent}<arranger>${escapeXml(doc.metadata.arranger)}</arranger>\n`;
1711
+ }
1712
+ // Add lyricist (left-aligned in verovio)
1713
+ if (doc.metadata?.lyricist) {
1714
+ mei += `${indent}${indent}${indent}${indent}<lyricist>${escapeXml(doc.metadata.lyricist)}</lyricist>\n`;
1715
+ }
1716
+ mei += `${indent}${indent}${indent}</titleStmt>\n`;
1717
+ mei += `${indent}${indent}${indent}<pubStmt />\n`;
1718
+ mei += `${indent}${indent}</fileDesc>\n`;
1719
+ mei += `${indent}${indent}<encodingDesc>\n`;
1720
+ mei += `${indent}${indent}${indent}<projectDesc>\n`;
1721
+ mei += `${indent}${indent}${indent}${indent}<p>Encoded with Lilylet MEIEncoder</p>\n`;
1722
+ mei += `${indent}${indent}${indent}</projectDesc>\n`;
1723
+ mei += `${indent}${indent}</encodingDesc>\n`;
1724
+ mei += `${indent}</meiHead>\n`;
1725
+ mei += `${indent}<music>\n`;
1726
+ mei += `${indent}${indent}<body>\n`;
1727
+ mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1728
+ mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1729
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
1730
+ mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1731
+ // Track tie state across measures for cross-measure ties
1732
+ const tieState = {};
1733
+ // Track slur state across measures for cross-measure slurs
1734
+ const slurState = {};
1735
+ // Track hairpin state across measures for cross-measure hairpins
1736
+ const hairpinState = {};
1737
+ // Track ottava state across measures for cross-measure ottava spans
1738
+ const ottavaState = {};
1739
+ // Initialize clef state from partInfos (convert local staff to global staff)
1740
+ const clefState = {};
1741
+ for (let pi = 0; pi < partInfos.length; pi++) {
1742
+ const partInfo = partInfos[pi];
1743
+ for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
1744
+ const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
1745
+ clefState[globalStaff] = clef;
1746
+ }
1747
+ }
1748
+ // Helper to check if a measure has any musical content
1749
+ const measureHasContent = (measure) => {
1750
+ for (const part of measure.parts) {
1751
+ for (const voice of part.voices) {
1752
+ for (const event of voice.events) {
1753
+ // Check for actual musical content (not just context changes or pitch resets)
1754
+ if (event.type === 'note' || event.type === 'rest' ||
1755
+ event.type === 'tuplet' || event.type === 'tremolo') {
1756
+ return true;
1757
+ }
1758
+ }
1759
+ }
1760
+ }
1761
+ return false;
1762
+ };
1763
+ // Filter out trailing empty measures
1764
+ let measures = doc.measures;
1765
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
1766
+ measures = measures.slice(0, -1);
1767
+ }
1768
+ // Encode measures
1769
+ measures.forEach((measure, mi) => {
1770
+ // Check for key signature change and output scoreDef if needed
1771
+ if (measure.key) {
1772
+ const newKey = keyToFifths(measure.key);
1773
+ if (newKey !== currentKey) {
1774
+ currentKey = newKey;
1775
+ const newKeySig = KEY_SIGS[currentKey] || "0";
1776
+ // Output a scoreDef with the new key signature
1777
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
1778
+ }
1779
+ }
1780
+ // Check for time signature change and output scoreDef if needed
1781
+ if (measure.timeSig && mi > 0) {
1782
+ const newTimeNum = measure.timeSig.numerator;
1783
+ const newTimeDen = measure.timeSig.denominator;
1784
+ const newMeterSymbol = measure.timeSig.symbol;
1785
+ if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
1786
+ currentTimeNum = newTimeNum;
1787
+ currentTimeDen = newTimeDen;
1788
+ currentMeterSymbol = newMeterSymbol;
1789
+ // Output a scoreDef with the new time signature
1790
+ const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
1791
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1792
+ }
1793
+ }
1794
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1795
+ });
1796
+ mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1797
+ mei += `${indent}${indent}${indent}${indent}</score>\n`;
1798
+ mei += `${indent}${indent}${indent}</mdiv>\n`;
1799
+ mei += `${indent}${indent}</body>\n`;
1800
+ mei += `${indent}</music>\n`;
1801
+ mei += '</mei>\n';
1802
+ return mei;
1803
+ };
1804
+ // Escape XML special characters
1805
+ const escapeXml = (text) => {
1806
+ return text
1807
+ .replace(/&/g, '&amp;')
1808
+ .replace(/</g, '&lt;')
1809
+ .replace(/>/g, '&gt;')
1810
+ .replace(/"/g, '&quot;')
1811
+ .replace(/'/g, '&apos;');
1812
+ };
1813
+ export { encode, resetIdCounter, };