@k-l-lambda/lilylet 0.1.63 → 0.1.64

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 (101) hide show
  1. package/lib/lilylet/meiEncoder.js +58 -40
  2. package/lib/source/abc/abc.d.ts +102 -0
  3. package/lib/source/abc/abc.js +25 -0
  4. package/lib/source/abc/parser.d.ts +3 -0
  5. package/lib/source/abc/parser.js +6 -0
  6. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  7. package/lib/source/lilylet/abcDecoder.js +1035 -0
  8. package/lib/source/lilylet/index.d.ts +10 -0
  9. package/lib/source/lilylet/index.js +10 -0
  10. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  11. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  12. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  13. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  14. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  15. package/lib/source/lilylet/meiEncoder.js +1985 -0
  16. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  17. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  18. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  19. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  20. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  21. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  22. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  23. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  24. package/lib/source/lilylet/parser.d.ts +14 -0
  25. package/lib/source/lilylet/parser.js +161 -0
  26. package/lib/source/lilylet/serializer.d.ts +11 -0
  27. package/lib/source/lilylet/serializer.js +791 -0
  28. package/lib/source/lilylet/types.d.ts +253 -0
  29. package/lib/source/lilylet/types.js +100 -0
  30. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  31. package/lib/tests/abc-abcjs-parse.js +90 -0
  32. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  33. package/lib/tests/abc-abcjs-svg.js +143 -0
  34. package/lib/tests/abc-decoder.d.ts +1 -0
  35. package/lib/tests/abc-decoder.js +67 -0
  36. package/lib/tests/abc-mei-compare.d.ts +1 -0
  37. package/lib/tests/abc-mei-compare.js +525 -0
  38. package/lib/tests/auto-beam.d.ts +9 -0
  39. package/lib/tests/auto-beam.js +151 -0
  40. package/lib/tests/computeMeiHashes.d.ts +1 -0
  41. package/lib/tests/computeMeiHashes.js +87 -0
  42. package/lib/tests/encoder-mutation.d.ts +9 -0
  43. package/lib/tests/encoder-mutation.js +110 -0
  44. package/lib/tests/gpt-review-issues.d.ts +5 -0
  45. package/lib/tests/gpt-review-issues.js +255 -0
  46. package/lib/tests/json-to-lyl.d.ts +1 -0
  47. package/lib/tests/json-to-lyl.js +18 -0
  48. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  49. package/lib/tests/lilypond-roundtrip.js +558 -0
  50. package/lib/tests/lilypondDecoder.d.ts +6 -0
  51. package/lib/tests/lilypondDecoder.js +95 -0
  52. package/lib/tests/ly-to-lyl.d.ts +1 -0
  53. package/lib/tests/ly-to-lyl.js +12 -0
  54. package/lib/tests/mei.d.ts +1 -0
  55. package/lib/tests/mei.js +278 -0
  56. package/lib/tests/musicxml-decoder.d.ts +4 -0
  57. package/lib/tests/musicxml-decoder.js +61 -0
  58. package/lib/tests/musicxml-detail.d.ts +4 -0
  59. package/lib/tests/musicxml-detail.js +85 -0
  60. package/lib/tests/musicxml-fprod.d.ts +9 -0
  61. package/lib/tests/musicxml-fprod.js +153 -0
  62. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  63. package/lib/tests/musicxml-roundtrip.js +296 -0
  64. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  65. package/lib/tests/musicxml-to-mei.js +115 -0
  66. package/lib/tests/parser.d.ts +1 -0
  67. package/lib/tests/parser.js +17 -0
  68. package/lib/tests/render-k283.d.ts +1 -0
  69. package/lib/tests/render-k283.js +33 -0
  70. package/lib/tests/render-lyl.d.ts +1 -0
  71. package/lib/tests/render-lyl.js +35 -0
  72. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  73. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  74. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  75. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  76. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  77. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  78. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  79. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  80. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  81. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  82. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  83. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  84. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  85. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  86. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  87. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  88. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  89. package/lib/tests/unit/partialWarning.test.js +65 -0
  90. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  91. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  92. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  93. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  94. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  95. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  96. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  97. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  98. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  99. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  100. package/package.json +1 -1
  101. package/source/lilylet/meiEncoder.ts +65 -40
@@ -0,0 +1,1985 @@
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
+ fp: "fp",
89
+ };
90
+ // ID generation state - uses session prefix to prevent collisions in concurrent encoding
91
+ let idCounter = 0;
92
+ let sessionPrefix = '';
93
+ const generateId = (prefix) => {
94
+ return `${prefix}-${sessionPrefix}${String(++idCounter).padStart(10, "0")}`;
95
+ };
96
+ const resetIdCounter = () => {
97
+ idCounter = 0;
98
+ // Generate a unique 4-char hex session prefix for this encode call
99
+ sessionPrefix = Math.random().toString(16).substring(2, 6);
100
+ };
101
+ // Sharp and flat order for key signatures (circle of fifths)
102
+ const SHARP_ORDER = ['f', 'c', 'g', 'd', 'a', 'e', 'b'];
103
+ const FLAT_ORDER = ['b', 'e', 'a', 'd', 'g', 'c', 'f'];
104
+ // Get the accidentals implied by a key signature
105
+ // fifths > 0 = sharps, fifths < 0 = flats
106
+ const getKeyAccidentals = (fifths) => {
107
+ const result = {};
108
+ if (fifths > 0) {
109
+ // Sharps
110
+ for (let i = 0; i < Math.min(fifths, 7); i++) {
111
+ result[SHARP_ORDER[i]] = 's'; // sharp
112
+ }
113
+ }
114
+ else if (fifths < 0) {
115
+ // Flats
116
+ for (let i = 0; i < Math.min(-fifths, 7); i++) {
117
+ result[FLAT_ORDER[i]] = 'f'; // flat
118
+ }
119
+ }
120
+ return result;
121
+ };
122
+ // Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
123
+ // ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
124
+ // The written pitch should be adjusted by subtracting the ottava shift
125
+ // measureAccidentals: tracks accidentals used earlier in the same measure (keyed by "pname-oct")
126
+ // - mutated to record new accidentals; used to add cancellation naturals
127
+ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
128
+ // Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
129
+ // When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
130
+ // For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
131
+ const oct = 4 + pitch.octave - ottavaShift;
132
+ // Get the accidental implied by the key signature for this note
133
+ const keyAccidentals = getKeyAccidentals(keyFifths);
134
+ const keyAccid = keyAccidentals[pitch.phonet];
135
+ // Determine accid (written/displayed) and accid.ges (gestural/sounding)
136
+ let accid;
137
+ let accidGes;
138
+ const pitchKey = `${pitch.phonet}-${oct}`;
139
+ // Check what was previously established for this pitch in this measure
140
+ const prevMeasureAccid = measureAccidentals?.get(pitchKey);
141
+ if (pitch.accidental) {
142
+ const noteAccid = ACCIDENTALS[pitch.accidental];
143
+ if (pitch.courtesy) {
144
+ // Courtesy accidental (!) - always display
145
+ accid = noteAccid;
146
+ }
147
+ else if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
148
+ // Previous note in this measure had a different accidental - must re-assert
149
+ accid = noteAccid;
150
+ }
151
+ else if (noteAccid !== keyAccid) {
152
+ // Accidental differs from key signature - display it
153
+ accid = noteAccid;
154
+ }
155
+ // Always set gestural accidental for MIDI generation
156
+ accidGes = noteAccid;
157
+ // Record this accidental for in-measure tracking
158
+ if (measureAccidentals)
159
+ measureAccidentals.set(pitchKey, noteAccid);
160
+ }
161
+ else if (keyAccid) {
162
+ // Note has no accidental but key implies one - output natural
163
+ if (pitch.courtesy) {
164
+ // Courtesy accidental (!) - always display
165
+ accid = 'n';
166
+ }
167
+ else if (prevMeasureAccid === 'n') {
168
+ // Already cancelled earlier in this measure - no need to show again
169
+ }
170
+ else {
171
+ accid = 'n';
172
+ }
173
+ accidGes = 'n';
174
+ if (measureAccidentals)
175
+ measureAccidentals.set(pitchKey, 'n');
176
+ }
177
+ else if (pitch.courtesy && prevMeasureAccid && prevMeasureAccid !== 'n') {
178
+ // Courtesy accidental after an in-measure accidental - force natural display
179
+ accid = 'n';
180
+ accidGes = 'n';
181
+ if (measureAccidentals)
182
+ measureAccidentals.set(pitchKey, 'n');
183
+ }
184
+ else if (measureAccidentals) {
185
+ // No explicit accidental, no key accidental - check if earlier note in measure had one
186
+ if (prevMeasureAccid && prevMeasureAccid !== 'n') {
187
+ // Previous note had an accidental - add cancellation natural
188
+ accid = 'n';
189
+ measureAccidentals.set(pitchKey, 'n');
190
+ }
191
+ }
192
+ return { pname: pitch.phonet, oct, accid, accidGes };
193
+ };
194
+ // Convert tremolo division to stem.mod value
195
+ const tremoloToStemMod = (division) => {
196
+ // 8 = 1slash (eighth note strokes), 16 = 2slash, 32 = 3slash, etc.
197
+ const slashes = Math.log2(division) - 2; // 8->1, 16->2, 32->3
198
+ if (slashes >= 1 && slashes <= 6) {
199
+ return `${slashes}slash`;
200
+ }
201
+ return undefined;
202
+ };
203
+ // Build note element
204
+ const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteId) => {
205
+ const id = noteId || generateId('note');
206
+ let attrs = `xml:id="${id}" pname="${pitch.pname}" oct="${pitch.oct}"`;
207
+ if (!inChord) {
208
+ attrs += ` dur="${dur}"`;
209
+ }
210
+ if (pitch.accid)
211
+ attrs += ` accid="${pitch.accid}"`;
212
+ if (pitch.accidGes)
213
+ attrs += ` accid.ges="${pitch.accidGes}"`;
214
+ if (!inChord && dots > 0)
215
+ attrs += ` dots="${dots}"`;
216
+ if (!inChord && options.grace)
217
+ attrs += ` grace="unacc"`;
218
+ if (!inChord && options.tie)
219
+ attrs += ` tie="${options.tie}"`;
220
+ if (!inChord && options.stemDir)
221
+ attrs += ` stem.dir="${options.stemDir}"`;
222
+ if (!inChord && options.layerStaff && options.staff && options.staff !== options.layerStaff) {
223
+ attrs += ` staff="${options.staff}"`;
224
+ }
225
+ if (!inChord && options.tremolo) {
226
+ const stemMod = tremoloToStemMod(options.tremolo);
227
+ if (stemMod)
228
+ attrs += ` stem.mod="${stemMod}"`;
229
+ }
230
+ // Only artics remain as child elements; ornaments are control events
231
+ const hasChildren = !inChord && options.artics && options.artics.length > 0;
232
+ if (!hasChildren) {
233
+ return `${indent}<note ${attrs} />\n`;
234
+ }
235
+ let result = `${indent}<note ${attrs}>\n`;
236
+ if (options.artics && options.artics.length > 0) {
237
+ // Group artics by placement
238
+ const aboveArtics = options.artics.filter(a => a.placement === 'above').map(a => a.type);
239
+ const belowArtics = options.artics.filter(a => a.placement === 'below').map(a => a.type);
240
+ const defaultArtics = options.artics.filter(a => !a.placement).map(a => a.type);
241
+ if (aboveArtics.length > 0) {
242
+ result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
243
+ }
244
+ if (belowArtics.length > 0) {
245
+ result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
246
+ }
247
+ if (defaultArtics.length > 0) {
248
+ result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
249
+ }
250
+ }
251
+ result += `${indent}</note>\n`;
252
+ return result;
253
+ };
254
+ // Extract mark properties from note event
255
+ const extractMarkOptions = (marks) => {
256
+ const result = {
257
+ artics: [],
258
+ fermata: false,
259
+ trill: false,
260
+ arpeggio: false,
261
+ turn: false,
262
+ mordent: false, // lower = mordent, upper = prall
263
+ slurStart: false,
264
+ slurEnd: false,
265
+ tieStart: false,
266
+ beamStart: false,
267
+ beamEnd: false,
268
+ dynamic: undefined,
269
+ hairpin: undefined,
270
+ pedal: undefined,
271
+ tremolo: undefined,
272
+ fingerings: [],
273
+ navigation: undefined,
274
+ markups: [],
275
+ };
276
+ if (!marks)
277
+ return result;
278
+ for (const mark of marks) {
279
+ switch (mark.markType) {
280
+ case 'articulation': {
281
+ const articType = ARTIC_MAP[mark.type];
282
+ if (articType) {
283
+ result.artics.push({
284
+ type: articType,
285
+ placement: mark.placement,
286
+ });
287
+ }
288
+ break;
289
+ }
290
+ case 'ornament':
291
+ if (mark.type === OrnamentType.fermata) {
292
+ result.fermata = 'normal';
293
+ }
294
+ else if (mark.type === OrnamentType.shortFermata) {
295
+ result.fermata = 'short';
296
+ }
297
+ else if (mark.type === OrnamentType.trill) {
298
+ result.trill = true;
299
+ }
300
+ else if (mark.type === OrnamentType.arpeggio) {
301
+ result.arpeggio = true;
302
+ }
303
+ else if (mark.type === OrnamentType.turn) {
304
+ result.turn = true;
305
+ }
306
+ else if (mark.type === OrnamentType.mordent) {
307
+ result.mordent = 'lower';
308
+ }
309
+ else if (mark.type === OrnamentType.prall) {
310
+ result.mordent = 'upper';
311
+ }
312
+ break;
313
+ case 'dynamic': {
314
+ const dynStr = DYNAMIC_MAP[mark.type];
315
+ if (dynStr) {
316
+ result.dynamic = dynStr;
317
+ }
318
+ break;
319
+ }
320
+ case 'hairpin':
321
+ if (mark.type === HairpinType.crescendoStart) {
322
+ result.hairpin = 'crescStart';
323
+ }
324
+ else if (mark.type === HairpinType.diminuendoStart) {
325
+ result.hairpin = 'dimStart';
326
+ }
327
+ else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
328
+ result.hairpin = 'end';
329
+ }
330
+ break;
331
+ case 'pedal':
332
+ if (mark.type === PedalType.sustainOn) {
333
+ result.pedal = 'down';
334
+ }
335
+ else if (mark.type === PedalType.sustainOff) {
336
+ result.pedal = 'up';
337
+ }
338
+ break;
339
+ case 'tie':
340
+ if (mark.start) {
341
+ result.tieStart = true;
342
+ }
343
+ break;
344
+ case 'slur':
345
+ if (mark.start) {
346
+ result.slurStart = true;
347
+ }
348
+ else {
349
+ result.slurEnd = true;
350
+ }
351
+ break;
352
+ case 'beam':
353
+ if (mark.start) {
354
+ result.beamStart = true;
355
+ }
356
+ else {
357
+ result.beamEnd = true;
358
+ }
359
+ break;
360
+ case 'fingering':
361
+ result.fingerings.push({
362
+ finger: mark.finger,
363
+ placement: mark.placement,
364
+ });
365
+ break;
366
+ case 'navigation':
367
+ result.navigation = mark.type;
368
+ break;
369
+ case 'markup':
370
+ result.markups.push({
371
+ content: mark.content,
372
+ placement: mark.placement,
373
+ });
374
+ break;
375
+ }
376
+ // Tremolo (special case - from parser internal mark)
377
+ if ('tremolo' in mark && typeof mark.tremolo === 'number') {
378
+ result.tremolo = mark.tremolo;
379
+ }
380
+ }
381
+ return result;
382
+ };
383
+ // Convert NoteEvent to MEI
384
+ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
385
+ const dur = DURATIONS[event.duration.division] || "4";
386
+ const dots = event.duration.dots || 0;
387
+ const markOptions = extractMarkOptions(event.marks);
388
+ // Stem direction - use event's own or context's
389
+ const effectiveStemDir = event.stemDirection ?? contextStemDir;
390
+ let stemDir;
391
+ if (effectiveStemDir === StemDirection.up)
392
+ stemDir = 'up';
393
+ else if (effectiveStemDir === StemDirection.down)
394
+ stemDir = 'down';
395
+ // Determine tie attribute: 'i' = initial, 'm' = medial, 't' = terminal
396
+ let tie;
397
+ if (markOptions.tieStart && tieEnd) {
398
+ tie = 'm'; // Both start and end = medial
399
+ }
400
+ else if (markOptions.tieStart) {
401
+ tie = 'i'; // Start only = initial
402
+ }
403
+ else if (tieEnd) {
404
+ tie = 't'; // End only = terminal
405
+ }
406
+ // Note options - ornaments are now control events, not inline
407
+ const noteOptions = {
408
+ grace: event.grace,
409
+ tie,
410
+ stemDir,
411
+ staff: event.staff,
412
+ layerStaff,
413
+ artics: markOptions.artics,
414
+ tremolo: markOptions.tremolo,
415
+ };
416
+ // Single note
417
+ if (event.pitches.length === 1) {
418
+ const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
419
+ const noteId = generateId('note');
420
+ return {
421
+ xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
422
+ elementId: noteId,
423
+ hairpin: markOptions.hairpin,
424
+ pedal: markOptions.pedal,
425
+ hasTieStart: markOptions.tieStart,
426
+ pitches: event.pitches,
427
+ arpeggio: markOptions.arpeggio,
428
+ fermata: markOptions.fermata,
429
+ trill: markOptions.trill,
430
+ mordent: markOptions.mordent,
431
+ turn: markOptions.turn,
432
+ dynamic: markOptions.dynamic,
433
+ slurStart: markOptions.slurStart,
434
+ slurEnd: markOptions.slurEnd,
435
+ fingerings: markOptions.fingerings,
436
+ navigation: markOptions.navigation,
437
+ markups: markOptions.markups,
438
+ };
439
+ }
440
+ // Chord
441
+ const chordId = generateId('chord');
442
+ let chordAttrs = `xml:id="${chordId}" dur="${dur}"`;
443
+ if (dots > 0)
444
+ chordAttrs += ` dots="${dots}"`;
445
+ if (noteOptions.grace)
446
+ chordAttrs += ` grace="unacc"`;
447
+ if (noteOptions.tie)
448
+ chordAttrs += ` tie="${noteOptions.tie}"`;
449
+ if (noteOptions.stemDir)
450
+ chordAttrs += ` stem.dir="${noteOptions.stemDir}"`;
451
+ if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
452
+ chordAttrs += ` staff="${noteOptions.staff}"`;
453
+ }
454
+ if (noteOptions.tremolo) {
455
+ const stemMod = tremoloToStemMod(noteOptions.tremolo);
456
+ if (stemMod)
457
+ chordAttrs += ` stem.mod="${stemMod}"`;
458
+ }
459
+ let result = `${indent}<chord ${chordAttrs}>\n`;
460
+ for (const p of event.pitches) {
461
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
462
+ result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
463
+ }
464
+ // Artics for chord - group by placement
465
+ if (noteOptions.artics.length > 0) {
466
+ const aboveArtics = noteOptions.artics.filter(a => a.placement === 'above').map(a => a.type);
467
+ const belowArtics = noteOptions.artics.filter(a => a.placement === 'below').map(a => a.type);
468
+ const defaultArtics = noteOptions.artics.filter(a => !a.placement).map(a => a.type);
469
+ if (aboveArtics.length > 0) {
470
+ result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
471
+ }
472
+ if (belowArtics.length > 0) {
473
+ result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
474
+ }
475
+ if (defaultArtics.length > 0) {
476
+ result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
477
+ }
478
+ }
479
+ result += `${indent}</chord>\n`;
480
+ return {
481
+ xml: result,
482
+ elementId: chordId,
483
+ hairpin: markOptions.hairpin,
484
+ pedal: markOptions.pedal,
485
+ hasTieStart: markOptions.tieStart,
486
+ pitches: event.pitches,
487
+ arpeggio: markOptions.arpeggio,
488
+ fermata: markOptions.fermata,
489
+ trill: markOptions.trill,
490
+ mordent: markOptions.mordent,
491
+ turn: markOptions.turn,
492
+ dynamic: markOptions.dynamic,
493
+ slurStart: markOptions.slurStart,
494
+ slurEnd: markOptions.slurEnd,
495
+ fingerings: markOptions.fingerings,
496
+ navigation: markOptions.navigation,
497
+ markups: markOptions.markups,
498
+ };
499
+ };
500
+ // Convert RestEvent to MEI
501
+ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
502
+ const dur = DURATIONS[event.duration.division] || "4";
503
+ let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
504
+ if (event.duration.dots > 0)
505
+ attrs += ` dots="${event.duration.dots}"`;
506
+ // Cross-staff attribute
507
+ if (crossStaff)
508
+ attrs += ` staff="${crossStaff}"`;
509
+ // Pitched rest (positioned at specific pitch)
510
+ if (event.pitch) {
511
+ const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
512
+ attrs += ` ploc="${pitch.pname}" oloc="${pitch.oct}"`;
513
+ }
514
+ // Space rest (invisible)
515
+ if (event.invisible) {
516
+ return `${indent}<space ${attrs} />\n`;
517
+ }
518
+ // Full measure rest
519
+ if (event.fullMeasure) {
520
+ return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
521
+ }
522
+ return `${indent}<rest ${attrs} />\n`;
523
+ };
524
+ // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
525
+ // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
526
+ const tupletHasInternalBeams = (event) => {
527
+ let starts = 0;
528
+ let ends = 0;
529
+ for (const e of event.events) {
530
+ if (e.type === 'note') {
531
+ const markOptions = extractMarkOptions(e.marks);
532
+ if (markOptions.beamStart)
533
+ starts++;
534
+ if (markOptions.beamEnd)
535
+ ends++;
536
+ }
537
+ }
538
+ return starts > 0 && starts === ends;
539
+ };
540
+ // Convert TupletEvent to MEI
541
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
542
+ // LilyPond \times 2/3 means "multiply duration by 2/3"
543
+ // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
544
+ // MEI: num = number of notes written, numbase = normal equivalent
545
+ const num = event.ratio.denominator; // denominator = actual note count
546
+ const numbase = event.ratio.numerator; // numerator = time equivalent
547
+ let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
548
+ const baseIndent = indent + ' ';
549
+ // Effective staff for cross-staff notation
550
+ const effectiveStaff = currentStaff ?? layerStaff;
551
+ // Collect control event info from notes inside tuplet
552
+ let firstNoteId = null;
553
+ const slurStarts = [];
554
+ const slurEnds = [];
555
+ const dynamics = [];
556
+ const fermatas = [];
557
+ const trills = [];
558
+ const mordents = [];
559
+ const turns = [];
560
+ const arpeggios = [];
561
+ // Handle internal beam groups: if notes have manual beam marks, respect them
562
+ const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
563
+ let beamOpen = false;
564
+ let activeClef = currentClef;
565
+ let endingClef;
566
+ for (const e of event.events) {
567
+ if (e.type === 'note') {
568
+ const noteEvent = e;
569
+ const markOptions = extractMarkOptions(noteEvent.marks);
570
+ // Open beam if this note starts a beam group
571
+ if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
572
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
573
+ beamOpen = true;
574
+ }
575
+ const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
576
+ // For cross-staff notation: set note's staff if different from layerStaff
577
+ const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
578
+ ? { ...noteEvent, staff: effectiveStaff }
579
+ : noteEvent;
580
+ const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
581
+ xml += result.xml;
582
+ if (!firstNoteId)
583
+ firstNoteId = result.elementId;
584
+ // Collect slur info
585
+ if (result.slurStart)
586
+ slurStarts.push(result.elementId);
587
+ if (result.slurEnd)
588
+ slurEnds.push(result.elementId);
589
+ // Collect other control events
590
+ if (result.dynamic)
591
+ dynamics.push({ startid: result.elementId, label: result.dynamic });
592
+ if (result.fermata)
593
+ fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
594
+ if (result.trill)
595
+ trills.push({ startid: result.elementId });
596
+ if (result.mordent)
597
+ mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
598
+ if (result.turn)
599
+ turns.push({ startid: result.elementId });
600
+ if (result.arpeggio)
601
+ arpeggios.push({ plist: result.elementId });
602
+ // Close beam if this note ends a beam group
603
+ if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
604
+ xml += `${baseIndent}</beam>\n`;
605
+ beamOpen = false;
606
+ }
607
+ }
608
+ else if (e.type === 'rest') {
609
+ const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
610
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
611
+ }
612
+ else if (e.type === 'context') {
613
+ const ctx = e;
614
+ if (ctx.clef && ctx.clef !== activeClef) {
615
+ const layerStaffNum = layerStaff || 1;
616
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
617
+ if (effectiveStaffNum === layerStaffNum) {
618
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
619
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
620
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
621
+ }
622
+ activeClef = ctx.clef;
623
+ endingClef = ctx.clef;
624
+ }
625
+ }
626
+ }
627
+ // Close any unclosed beam
628
+ if (beamOpen) {
629
+ xml += `${baseIndent}</beam>\n`;
630
+ }
631
+ xml += `${indent}</tuplet>\n`;
632
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
633
+ };
634
+ // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
635
+ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
636
+ const ftremId = generateId('fTrem');
637
+ // For \repeat tremolo 4 { c16 d16 }:
638
+ // - count = 4 (repetitions)
639
+ // - division = 16 (note value)
640
+ // - Total duration = 4 × 2 × 16th = 8 × 16th = half note
641
+ // - Each visible note = half of total = quarter note
642
+ // Calculate beams (tremolo strokes) based on division
643
+ // 8th = 1 beam, 16th = 2 beams, 32nd = 3 beams
644
+ const beams = Math.max(1, Math.log2(event.division / 8) + 1);
645
+ // Calculate visual duration for each note
646
+ // For \repeat tremolo 4 { c16 d16 }:
647
+ // - Total strokes = 4 × 2 = 8 sixteenth notes = 1/2 whole note
648
+ // - Each visible note = 1/4 whole note = quarter note (dur="4")
649
+ // Formula: dur = division / count (e.g., 16 / 4 = 4 for quarter note)
650
+ const noteDur = Math.round(event.division / event.count) || 4; // Default to quarter if calculation fails
651
+ let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
652
+ // First note (or chord)
653
+ if (event.pitchA.length === 1) {
654
+ const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
655
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
656
+ if (pitch.accid)
657
+ attrs += ` accid="${pitch.accid}"`;
658
+ if (pitch.accidGes)
659
+ attrs += ` accid.ges="${pitch.accidGes}"`;
660
+ result += `${indent} <note ${attrs} />\n`;
661
+ }
662
+ else if (event.pitchA.length > 1) {
663
+ result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
664
+ for (const p of event.pitchA) {
665
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
666
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
667
+ if (pitch.accid)
668
+ attrs += ` accid="${pitch.accid}"`;
669
+ if (pitch.accidGes)
670
+ attrs += ` accid.ges="${pitch.accidGes}"`;
671
+ result += `${indent} <note ${attrs} />\n`;
672
+ }
673
+ result += `${indent} </chord>\n`;
674
+ }
675
+ // Second note (or chord)
676
+ if (event.pitchB.length === 1) {
677
+ const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
678
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
679
+ if (pitch.accid)
680
+ attrs += ` accid="${pitch.accid}"`;
681
+ if (pitch.accidGes)
682
+ attrs += ` accid.ges="${pitch.accidGes}"`;
683
+ result += `${indent} <note ${attrs} />\n`;
684
+ }
685
+ else if (event.pitchB.length > 1) {
686
+ result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
687
+ for (const p of event.pitchB) {
688
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
689
+ let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
690
+ if (pitch.accid)
691
+ attrs += ` accid="${pitch.accid}"`;
692
+ if (pitch.accidGes)
693
+ attrs += ` accid.ges="${pitch.accidGes}"`;
694
+ result += `${indent} <note ${attrs} />\n`;
695
+ }
696
+ result += `${indent} </chord>\n`;
697
+ }
698
+ result += `${indent}</fTrem>\n`;
699
+ return result;
700
+ };
701
+ // Helper: check if an event (or any note inside a tuplet) has beam start/end
702
+ const getEventBeamMarks = (event) => {
703
+ if (event.type === 'note') {
704
+ const markOptions = extractMarkOptions(event.marks);
705
+ return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
706
+ }
707
+ if (event.type === 'tuplet' || event.type === 'times') {
708
+ const tuplet = event;
709
+ // If the tuplet has internal beam groups, don't report beam marks to the parent
710
+ // so the parent won't wrap the tuplet in an external <beam>
711
+ if (tupletHasInternalBeams(tuplet)) {
712
+ return { beamStart: false, beamEnd: false };
713
+ }
714
+ let beamStart = false;
715
+ let beamEnd = false;
716
+ for (const e of tuplet.events) {
717
+ if (e.type === 'note') {
718
+ const markOptions = extractMarkOptions(e.marks);
719
+ if (markOptions.beamStart)
720
+ beamStart = true;
721
+ if (markOptions.beamEnd)
722
+ beamEnd = true;
723
+ }
724
+ }
725
+ return { beamStart, beamEnd };
726
+ }
727
+ return { beamStart: false, beamEnd: false };
728
+ };
729
+ // Encode a layer (voice)
730
+ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
731
+ const layerId = generateId("layer");
732
+ let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
733
+ let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
734
+ const baseIndent = indent + ' ';
735
+ // Track current clef to only emit changes
736
+ let currentClef = initialClef;
737
+ // Track hairpin spans
738
+ const hairpins = [];
739
+ let currentHairpin = initialHairpin;
740
+ // Track pedal marks (each is independent, not paired spans)
741
+ const pedals = [];
742
+ // Track octave spans - initialize from previous measure if continuing
743
+ const octaves = [];
744
+ let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId, continued: initialOctave.continued } : null;
745
+ let pendingOttava = null; // Track ottava to apply to next note
746
+ let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
747
+ let lastNoteId = null; // Track last note id for ending ottava spans
748
+ let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
749
+ // Track slur spans - slurs must be encoded as control events in MEI
750
+ const slurs = [];
751
+ let currentSlur = initialSlur ? { startId: initialSlur } : null;
752
+ // Track arpeggio refs
753
+ const arpeggios = [];
754
+ // Track ornament refs
755
+ const fermatas = [];
756
+ const trills = [];
757
+ const mordents = [];
758
+ const turns = [];
759
+ const dynamics = [];
760
+ const fingerings = [];
761
+ const navigations = [];
762
+ const harmonies = [];
763
+ const barlines = [];
764
+ const markups = [];
765
+ const pendingMarkups = [];
766
+ // Track current stem direction from context changes
767
+ let currentStemDirection = undefined;
768
+ // Track current staff for cross-staff notation
769
+ let currentStaff = voice.staff || 1;
770
+ // Track in-measure accidentals for cancellation naturals
771
+ const measureAccidentals = new Map();
772
+ // Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
773
+ let pendingTiePitches = [...initialTiePitches];
774
+ // Helper to flush pending markups onto a note ID
775
+ const flushPendingMarkups = (noteId) => {
776
+ for (const mkup of pendingMarkups) {
777
+ markups.push({ startid: noteId, content: mkup.content, placement: mkup.placement });
778
+ }
779
+ pendingMarkups.length = 0;
780
+ };
781
+ // Helper to check if pitches match for tie continuation
782
+ const pitchesMatch = (p1, p2) => {
783
+ if (p1.length !== p2.length)
784
+ return false;
785
+ for (let i = 0; i < p1.length; i++) {
786
+ if (p1[i].phonet !== p2[i].phonet || p1[i].octave !== p2[i].octave)
787
+ return false;
788
+ }
789
+ return true;
790
+ };
791
+ let graceBeamOpen = false; // Whether a grace note <beam> is open (independent of main beam)
792
+ for (const event of voice.events) {
793
+ // Check for beam start/end in this event (including inside tuplets)
794
+ const { beamStart, beamEnd } = getEventBeamMarks(event);
795
+ // Grace notes have independent beam groups - don't interfere with main beam
796
+ const isGraceNote = event.type === 'note' && event.grace;
797
+ if (isGraceNote) {
798
+ // Grace beam: open/close independently, nested inside parent beam if open
799
+ const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
800
+ if (beamStart && !graceBeamOpen) {
801
+ xml += `${graceBaseIndent}<beam xml:id="${generateId('beam')}">\n`;
802
+ graceBeamOpen = true;
803
+ }
804
+ }
805
+ else {
806
+ // Close any open grace beam before processing a non-grace event
807
+ if (graceBeamOpen) {
808
+ const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
809
+ xml += `${graceBaseIndent}</beam>\n`;
810
+ graceBeamOpen = false;
811
+ }
812
+ // Open main beam element if beam starts
813
+ if (beamStart && !beamElementOpen) {
814
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
815
+ beamElementOpen = true;
816
+ }
817
+ }
818
+ const graceIndentBase = beamElementOpen ? baseIndent + ' ' : baseIndent;
819
+ const currentIndent = graceBeamOpen ? graceIndentBase + ' ' : (beamElementOpen ? baseIndent + ' ' : baseIndent);
820
+ switch (event.type) {
821
+ case 'note': {
822
+ const noteEvent = event;
823
+ // Check if this note should have tie="t" (matches pending tie)
824
+ const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
825
+ // If there's a pending ottava, apply it BEFORE encoding the note
826
+ if (pendingOttava !== null && pendingOttava !== 0) {
827
+ currentOttavaShift = pendingOttava; // Apply the shift for this note
828
+ }
829
+ // For cross-staff notation: set note's staff to currentStaff if different from voice.staff
830
+ const effectiveNoteEvent = currentStaff !== voice.staff
831
+ ? { ...noteEvent, staff: currentStaff }
832
+ : noteEvent;
833
+ const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
834
+ xml += result.xml;
835
+ lastNoteId = result.elementId;
836
+ // Flush any pending markups onto this note
837
+ flushPendingMarkups(result.elementId);
838
+ // If there's a pending ottava, start the span on this note
839
+ if (pendingOttava !== null && pendingOttava !== 0) {
840
+ const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
841
+ const disPlace = pendingOttava > 0 ? 'above' : 'below';
842
+ // Close existing span first if it has a different value
843
+ if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
844
+ if (lastNoteId) {
845
+ octaves.push({
846
+ dis: currentOctave.dis,
847
+ disPlace: currentOctave.disPlace,
848
+ startId: currentOctave.startId,
849
+ endId: lastNoteId,
850
+ });
851
+ }
852
+ currentOctave = null;
853
+ }
854
+ // Start new span if we don't already have one with the same value
855
+ if (!currentOctave) {
856
+ currentOctave = { dis, disPlace, startId: result.elementId };
857
+ }
858
+ pendingOttava = null;
859
+ }
860
+ else if (currentOctave?.continued) {
861
+ currentOctave.startId = result.elementId;
862
+ currentOctave.continued = false;
863
+ }
864
+ // Update pending tie pitches
865
+ if (result.hasTieStart) {
866
+ pendingTiePitches = result.pitches;
867
+ }
868
+ else if (tieEnd) {
869
+ pendingTiePitches = [];
870
+ }
871
+ // Track hairpin spans
872
+ if (result.hairpin === 'crescStart') {
873
+ currentHairpin = { form: 'cres', startId: result.elementId };
874
+ }
875
+ else if (result.hairpin === 'dimStart') {
876
+ currentHairpin = { form: 'dim', startId: result.elementId };
877
+ }
878
+ else if (result.hairpin === 'end' && currentHairpin) {
879
+ hairpins.push({
880
+ form: currentHairpin.form,
881
+ startId: currentHairpin.startId,
882
+ endId: result.elementId,
883
+ });
884
+ currentHairpin = null;
885
+ }
886
+ // Track pedal marks (each is independent)
887
+ if (result.pedal === 'down' || result.pedal === 'up') {
888
+ pedals.push({
889
+ startId: result.elementId,
890
+ dir: result.pedal,
891
+ });
892
+ }
893
+ // Track slur spans - end must be processed before start
894
+ // in case a note ends one slur and starts another
895
+ if (result.slurEnd && currentSlur) {
896
+ slurs.push({
897
+ startId: currentSlur.startId,
898
+ endId: result.elementId,
899
+ });
900
+ currentSlur = null;
901
+ }
902
+ if (result.slurStart) {
903
+ currentSlur = { startId: result.elementId };
904
+ }
905
+ // Track arpeggio refs
906
+ if (result.arpeggio) {
907
+ arpeggios.push({ plist: result.elementId });
908
+ }
909
+ // Track ornament refs (fermata, trill, mordent, turn)
910
+ if (result.fermata) {
911
+ fermatas.push({
912
+ startid: result.elementId,
913
+ shape: result.fermata === 'short' ? 'angular' : undefined,
914
+ });
915
+ }
916
+ if (result.trill) {
917
+ trills.push({ startid: result.elementId });
918
+ }
919
+ if (result.mordent) {
920
+ mordents.push({
921
+ startid: result.elementId,
922
+ form: result.mordent === 'upper' ? 'upper' : undefined,
923
+ });
924
+ }
925
+ if (result.turn) {
926
+ turns.push({ startid: result.elementId });
927
+ }
928
+ if (result.dynamic) {
929
+ dynamics.push({ startid: result.elementId, label: result.dynamic });
930
+ }
931
+ // Track fingerings
932
+ for (const fing of result.fingerings) {
933
+ fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
934
+ }
935
+ // Track markups from note marks
936
+ for (const mkup of result.markups) {
937
+ markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
938
+ }
939
+ // Track navigation marks
940
+ if (result.navigation) {
941
+ navigations.push({ type: result.navigation });
942
+ }
943
+ break;
944
+ }
945
+ case 'rest': {
946
+ // For cross-staff notation: pass staff number if different from voice's home staff
947
+ const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
948
+ xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
949
+ break;
950
+ }
951
+ case 'tuplet':
952
+ case 'times': {
953
+ // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
954
+ // Pass beamElementOpen to tuplet so it knows not to create its own beam
955
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
956
+ xml += tupletResult.xml;
957
+ // Propagate clef change from inside the tuplet to the parent tracker
958
+ if (tupletResult.endingClef) {
959
+ currentClef = tupletResult.endingClef;
960
+ }
961
+ // Flush any pending markups onto the first note of the tuplet
962
+ if (tupletResult.firstNoteId) {
963
+ flushPendingMarkups(tupletResult.firstNoteId);
964
+ lastNoteId = tupletResult.firstNoteId;
965
+ }
966
+ // Process slur ends first (to close any pending slurs from before this tuplet)
967
+ for (const endId of tupletResult.slurEnds) {
968
+ if (currentSlur) {
969
+ slurs.push({
970
+ startId: currentSlur.startId,
971
+ endId: endId,
972
+ });
973
+ currentSlur = null;
974
+ }
975
+ }
976
+ // Then process slur starts (to open new slurs)
977
+ for (const startId of tupletResult.slurStarts) {
978
+ currentSlur = { startId };
979
+ }
980
+ // Collect other control events from tuplet
981
+ dynamics.push(...tupletResult.dynamics);
982
+ fermatas.push(...tupletResult.fermatas);
983
+ trills.push(...tupletResult.trills);
984
+ mordents.push(...tupletResult.mordents);
985
+ turns.push(...tupletResult.turns);
986
+ arpeggios.push(...tupletResult.arpeggios);
987
+ break;
988
+ }
989
+ case 'tremolo':
990
+ xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
991
+ break;
992
+ case 'context': {
993
+ const ctx = event;
994
+ // Check for clef changes - emit <clef> element only if different from current
995
+ // and only when on the home staff (don't emit cross-staff clefs into this layer)
996
+ if (ctx.clef && ctx.clef !== currentClef) {
997
+ const layerStaff = voice.staff || 1;
998
+ if (currentStaff === layerStaff) {
999
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1000
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1001
+ }
1002
+ currentClef = ctx.clef;
1003
+ }
1004
+ // Check for ottava changes
1005
+ if (ctx.ottava !== undefined) {
1006
+ if (ctx.ottava === 0) {
1007
+ // End current ottava span
1008
+ if (currentOctave && lastNoteId) {
1009
+ octaves.push({
1010
+ dis: currentOctave.dis,
1011
+ disPlace: currentOctave.disPlace,
1012
+ startId: currentOctave.startId,
1013
+ endId: lastNoteId,
1014
+ });
1015
+ currentOctave = null;
1016
+ ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1017
+ }
1018
+ // Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
1019
+ // It may be continued by a subsequent ottava command with the same value
1020
+ currentOttavaShift = 0; // Reset the shift (will be restored if continued)
1021
+ }
1022
+ else {
1023
+ // Check if this continues an existing span (same value)
1024
+ const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
1025
+ const disPlace = ctx.ottava > 0 ? 'above' : 'below';
1026
+ if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
1027
+ // Continuation - restore the shift but don't change the span
1028
+ currentOttavaShift = ctx.ottava;
1029
+ }
1030
+ else {
1031
+ // Different value - start new ottava span (will be applied to next note)
1032
+ // If there's an existing span with different value, it will be closed when the note is processed
1033
+ pendingOttava = ctx.ottava;
1034
+ }
1035
+ }
1036
+ }
1037
+ // Check for stem direction changes
1038
+ if (ctx.stemDirection !== undefined) {
1039
+ currentStemDirection = ctx.stemDirection;
1040
+ }
1041
+ // Check for staff changes (cross-staff notation)
1042
+ if (ctx.staff !== undefined) {
1043
+ currentStaff = ctx.staff;
1044
+ }
1045
+ // Other context changes are handled at measure level
1046
+ break;
1047
+ }
1048
+ case 'pitchReset':
1049
+ // Pitch reset events are only used during pitch resolution in the parser.
1050
+ // They don't produce any MEI output - just skip them.
1051
+ break;
1052
+ case 'barline':
1053
+ barlines.push({ style: event.style });
1054
+ break;
1055
+ case 'harmony':
1056
+ // Harmony needs a note ID to attach to - use the last note if available
1057
+ if (lastNoteId) {
1058
+ harmonies.push({ startid: lastNoteId, text: event.text });
1059
+ }
1060
+ break;
1061
+ case 'markup':
1062
+ {
1063
+ // Markup needs a note ID to attach to
1064
+ const mkupEvent = event;
1065
+ if (lastNoteId) {
1066
+ markups.push({
1067
+ startid: lastNoteId,
1068
+ content: mkupEvent.content,
1069
+ placement: mkupEvent.placement,
1070
+ });
1071
+ }
1072
+ else {
1073
+ // No note yet - save as pending, will attach to next note
1074
+ pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
1075
+ }
1076
+ }
1077
+ break;
1078
+ }
1079
+ // Close beam element if beam ends
1080
+ if (beamEnd) {
1081
+ if (isGraceNote && graceBeamOpen) {
1082
+ // Close grace beam
1083
+ const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
1084
+ xml += `${graceBaseIndent}</beam>\n`;
1085
+ graceBeamOpen = false;
1086
+ }
1087
+ else if (!isGraceNote && beamElementOpen) {
1088
+ // Close main beam
1089
+ xml += `${baseIndent}</beam>\n`;
1090
+ beamElementOpen = false;
1091
+ }
1092
+ }
1093
+ }
1094
+ // Close any unclosed grace beam
1095
+ if (graceBeamOpen) {
1096
+ const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
1097
+ xml += `${graceBaseIndent}</beam>\n`;
1098
+ }
1099
+ // Close any unclosed beam
1100
+ if (beamElementOpen) {
1101
+ xml += `${baseIndent}</beam>\n`;
1102
+ }
1103
+ // Emit a measure-local octave span if the ottava continues past this measure.
1104
+ // The returned pending state starts a fresh control element in the next measure.
1105
+ if (currentOctave && lastNoteId) {
1106
+ octaves.push({
1107
+ dis: currentOctave.dis,
1108
+ disPlace: currentOctave.disPlace,
1109
+ startId: currentOctave.startId,
1110
+ endId: lastNoteId,
1111
+ });
1112
+ }
1113
+ const pendingOctave = currentOctave
1114
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true }
1115
+ : null;
1116
+ xml += `${indent}</layer>\n`;
1117
+ 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 };
1118
+ };
1119
+ // Encode a staff
1120
+ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
1121
+ const staffId = generateId("staff");
1122
+ let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
1123
+ const allHairpins = [];
1124
+ const allPedals = [];
1125
+ const allOctaves = [];
1126
+ const allSlurs = [];
1127
+ const allArpeggios = [];
1128
+ const allFermatas = [];
1129
+ const allTrills = [];
1130
+ const allMordents = [];
1131
+ const allTurns = [];
1132
+ const allDynamics = [];
1133
+ const allFingerings = [];
1134
+ const allNavigations = [];
1135
+ const allHarmonies = [];
1136
+ const allBarlines = [];
1137
+ const allMarkups = [];
1138
+ const pendingTies = {};
1139
+ const pendingSlurs = {};
1140
+ const pendingHairpins = {};
1141
+ const pendingOctaves = {};
1142
+ const ottavaExplicitlyClosed = {};
1143
+ const lastNoteIds = {};
1144
+ let endingClef = initialClef;
1145
+ if (voices.length === 0) {
1146
+ xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
1147
+ }
1148
+ else {
1149
+ voices.forEach((voice, vi) => {
1150
+ const layerN = vi + 1;
1151
+ const tieKey = `${staffN}-${layerN}`;
1152
+ const initialTies = tieState[tieKey] || [];
1153
+ const initialSlur = slurState[tieKey] || null;
1154
+ const initialHairpin = hairpinState[tieKey] || null;
1155
+ const initialOctave = ottavaState[tieKey] || null;
1156
+ const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
1157
+ xml += result.xml;
1158
+ allHairpins.push(...result.hairpins);
1159
+ allPedals.push(...result.pedals);
1160
+ allOctaves.push(...result.octaves);
1161
+ allSlurs.push(...result.slurs);
1162
+ allArpeggios.push(...result.arpeggios);
1163
+ allFermatas.push(...result.fermatas);
1164
+ allTrills.push(...result.trills);
1165
+ allMordents.push(...result.mordents);
1166
+ allTurns.push(...result.turns);
1167
+ allDynamics.push(...result.dynamics);
1168
+ allFingerings.push(...result.fingerings);
1169
+ allNavigations.push(...result.navigations);
1170
+ allHarmonies.push(...result.harmonies);
1171
+ allBarlines.push(...result.barlines);
1172
+ allMarkups.push(...result.markups);
1173
+ // Track pending ties for this layer
1174
+ if (result.pendingTiePitches.length > 0) {
1175
+ pendingTies[tieKey] = result.pendingTiePitches;
1176
+ }
1177
+ // Track pending slurs for this layer
1178
+ if (result.pendingSlur) {
1179
+ pendingSlurs[tieKey] = result.pendingSlur;
1180
+ }
1181
+ // Track pending hairpins for this layer
1182
+ if (result.pendingHairpin) {
1183
+ pendingHairpins[tieKey] = result.pendingHairpin;
1184
+ }
1185
+ // Track pending ottava spans for this layer
1186
+ if (result.pendingOctave) {
1187
+ pendingOctaves[tieKey] = result.pendingOctave;
1188
+ }
1189
+ // Track if ottava was explicitly closed in this layer
1190
+ if (result.ottavaExplicitlyClosed) {
1191
+ ottavaExplicitlyClosed[tieKey] = true;
1192
+ }
1193
+ // Track last note IDs for this layer (for closing ottava spans)
1194
+ lastNoteIds[tieKey] = result.lastNoteId;
1195
+ // Track ending clef for cross-measure tracking
1196
+ if (result.endingClef) {
1197
+ endingClef = result.endingClef;
1198
+ }
1199
+ });
1200
+ }
1201
+ xml += `${indent}</staff>\n`;
1202
+ return {
1203
+ xml,
1204
+ hairpins: allHairpins,
1205
+ pedals: allPedals,
1206
+ octaves: allOctaves,
1207
+ slurs: allSlurs,
1208
+ arpeggios: allArpeggios,
1209
+ fermatas: allFermatas,
1210
+ trills: allTrills,
1211
+ mordents: allMordents,
1212
+ turns: allTurns,
1213
+ dynamics: allDynamics,
1214
+ fingerings: allFingerings,
1215
+ navigations: allNavigations,
1216
+ harmonies: allHarmonies,
1217
+ barlines: allBarlines,
1218
+ markups: allMarkups,
1219
+ pendingTies,
1220
+ pendingSlurs,
1221
+ pendingHairpins,
1222
+ pendingOctaves,
1223
+ ottavaExplicitlyClosed,
1224
+ lastNoteIds,
1225
+ endingClef,
1226
+ };
1227
+ };
1228
+ // Tempo text to BPM mapping (approximate values based on musical convention)
1229
+ const TEMPO_TEXT_TO_BPM = {
1230
+ // Very slow
1231
+ 'grave': 35,
1232
+ 'largo': 50,
1233
+ 'larghetto': 63,
1234
+ 'lento': 52,
1235
+ 'adagio': 70,
1236
+ // Slow to moderate
1237
+ 'andante': 92,
1238
+ 'andantino': 96,
1239
+ 'moderato': 114,
1240
+ // Fast
1241
+ 'allegretto': 116,
1242
+ 'allegro': 138,
1243
+ 'vivace': 166,
1244
+ 'presto': 184,
1245
+ 'prestissimo': 208,
1246
+ };
1247
+ // Infer BPM from tempo text
1248
+ const inferBpmFromText = (text) => {
1249
+ const lowerText = text.toLowerCase();
1250
+ for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
1251
+ if (lowerText.includes(keyword)) {
1252
+ return bpm;
1253
+ }
1254
+ }
1255
+ return undefined;
1256
+ };
1257
+ // Generate tempo element
1258
+ const generateTempoElement = (tempo, indent, staff = 1) => {
1259
+ let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
1260
+ // Determine BPM: use explicit value or infer from text
1261
+ let bpm = tempo.bpm;
1262
+ if (!bpm && tempo.text) {
1263
+ bpm = inferBpmFromText(tempo.text);
1264
+ }
1265
+ // Add BPM if available
1266
+ if (bpm) {
1267
+ attrs += ` midi.bpm="${bpm}"`;
1268
+ if (tempo.beat) {
1269
+ attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
1270
+ }
1271
+ }
1272
+ // Generate content
1273
+ let content = '';
1274
+ if (tempo.text) {
1275
+ content = escapeXml(tempo.text);
1276
+ }
1277
+ if (tempo.beat && tempo.bpm) {
1278
+ const beatSymbol = tempo.beat.division === 4 ? '♩' : tempo.beat.division === 2 ? '𝅗𝅥' : '♪';
1279
+ if (content)
1280
+ content += ' ';
1281
+ content += `${beatSymbol} = ${tempo.bpm}`;
1282
+ }
1283
+ if (content) {
1284
+ return `${indent}<tempo ${attrs}>${content}</tempo>\n`;
1285
+ }
1286
+ return `${indent}<tempo ${attrs} />\n`;
1287
+ };
1288
+ // Barline style to MEI @right attribute mapping
1289
+ const BARLINE_TO_MEI = {
1290
+ '|': 'single',
1291
+ '||': 'dbl',
1292
+ '|.': 'end',
1293
+ '.|:': 'rptstart',
1294
+ ':|.': 'rptend',
1295
+ ':..:|': 'rptboth',
1296
+ ':..:': 'rptboth',
1297
+ };
1298
+ // Encode a measure
1299
+ // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1300
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
1301
+ const measureId = generateId("measure");
1302
+ let staffContent = ''; // Build staff content first, then add measure tag with barline
1303
+ const allHairpins = [];
1304
+ const allPedals = [];
1305
+ const allOctaves = [];
1306
+ const allSlurs = [];
1307
+ const allArpeggios = [];
1308
+ const allFermatas = [];
1309
+ const allTrills = [];
1310
+ const allMordents = [];
1311
+ const allTurns = [];
1312
+ const allDynamics = [];
1313
+ const allFingerings = [];
1314
+ const allNavigations = [];
1315
+ const allHarmonies = [];
1316
+ const allBarlines = [];
1317
+ const allMarkups = [];
1318
+ // Extract tempo from context changes (track which staff it came from)
1319
+ let measureTempo;
1320
+ let tempoStaff = 1;
1321
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1322
+ const part = measure.parts[pi];
1323
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1324
+ for (const voice of part.voices) {
1325
+ const localStaff = voice.staff || 1;
1326
+ const globalStaff = partOffset + localStaff;
1327
+ for (const event of voice.events) {
1328
+ if (event.type === 'context') {
1329
+ const ctx = event;
1330
+ if (ctx.tempo) {
1331
+ measureTempo = ctx.tempo;
1332
+ tempoStaff = globalStaff;
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+ }
1338
+ // Group voices by global staff (local staff + part offset)
1339
+ const voicesByStaff = {};
1340
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1341
+ const part = measure.parts[pi];
1342
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1343
+ for (const voice of part.voices) {
1344
+ const localStaff = voice.staff || 1;
1345
+ const globalStaff = partOffset + localStaff;
1346
+ if (!voicesByStaff[globalStaff]) {
1347
+ voicesByStaff[globalStaff] = [];
1348
+ }
1349
+ voicesByStaff[globalStaff].push(voice);
1350
+ }
1351
+ }
1352
+ // Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
1353
+ for (let si = 1; si <= totalStaves; si++) {
1354
+ const voices = voicesByStaff[si] || [];
1355
+ const initialClef = clefState[si];
1356
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1357
+ staffContent += result.xml;
1358
+ allHairpins.push(...result.hairpins);
1359
+ allPedals.push(...result.pedals);
1360
+ allOctaves.push(...result.octaves);
1361
+ allSlurs.push(...result.slurs);
1362
+ allArpeggios.push(...result.arpeggios);
1363
+ allFermatas.push(...result.fermatas);
1364
+ allTrills.push(...result.trills);
1365
+ allMordents.push(...result.mordents);
1366
+ allTurns.push(...result.turns);
1367
+ allDynamics.push(...result.dynamics);
1368
+ allFingerings.push(...result.fingerings);
1369
+ allNavigations.push(...result.navigations);
1370
+ allHarmonies.push(...result.harmonies);
1371
+ allBarlines.push(...result.barlines);
1372
+ allMarkups.push(...result.markups);
1373
+ // Update tie state with pending ties from this staff
1374
+ Object.assign(tieState, result.pendingTies);
1375
+ // Update slur state with pending slurs from this staff
1376
+ Object.assign(slurState, result.pendingSlurs);
1377
+ // Update hairpin state with pending hairpins from this staff
1378
+ Object.assign(hairpinState, result.pendingHairpins);
1379
+ // Update ottava state with pending octaves from this staff.
1380
+ // encodeLayer already emits measure-local octave spans, so keep the next measure's start independent.
1381
+ const currentStaffPrefix = `${si}-`;
1382
+ for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1383
+ if (pending) {
1384
+ ottavaState[key] = pending;
1385
+ }
1386
+ }
1387
+ // For layers in this staff that had pending octaves but didn't in this measure, close the spans
1388
+ for (const [key, pending] of Object.entries(ottavaState)) {
1389
+ // Only process keys for the current staff
1390
+ if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
1391
+ // Check if the span was already explicitly closed in encodeLayer
1392
+ // If so, don't generate another span (it was already pushed to octaves in encodeLayer)
1393
+ if (!result.ottavaExplicitlyClosed[key]) {
1394
+ // Ottava ended without explicit close - generate the closing span
1395
+ const lastNoteId = result.lastNoteIds[key];
1396
+ if (lastNoteId) {
1397
+ allOctaves.push({
1398
+ dis: pending.dis,
1399
+ disPlace: pending.disPlace,
1400
+ startId: pending.startId,
1401
+ endId: lastNoteId,
1402
+ });
1403
+ }
1404
+ }
1405
+ delete ottavaState[key];
1406
+ }
1407
+ }
1408
+ // Update clef state with ending clef from this staff
1409
+ if (result.endingClef) {
1410
+ clefState[si] = result.endingClef;
1411
+ }
1412
+ }
1413
+ // Generate tempo element if present
1414
+ if (measureTempo) {
1415
+ staffContent += generateTempoElement(measureTempo, indent + ' ', tempoStaff);
1416
+ }
1417
+ // Generate hairpin control events
1418
+ for (const hp of allHairpins) {
1419
+ staffContent += `${indent} <hairpin xml:id="${generateId('hairpin')}" form="${hp.form}" startid="#${hp.startId}" endid="#${hp.endId}" />\n`;
1420
+ }
1421
+ // Generate pedal control events (each mark is independent)
1422
+ for (const ped of allPedals) {
1423
+ staffContent += `${indent} <pedal xml:id="${generateId('pedal')}" dir="${ped.dir}" startid="#${ped.startId}" />\n`;
1424
+ }
1425
+ // Generate octave control events
1426
+ for (const oct of allOctaves) {
1427
+ staffContent += `${indent} <octave xml:id="${generateId('octave')}" dis="${oct.dis}" dis.place="${oct.disPlace}" startid="#${oct.startId}" endid="#${oct.endId}" />\n`;
1428
+ }
1429
+ // Generate slur control events
1430
+ for (const sl of allSlurs) {
1431
+ staffContent += `${indent} <slur xml:id="${generateId('slur')}" startid="#${sl.startId}" endid="#${sl.endId}" />\n`;
1432
+ }
1433
+ // Generate arpeggio control events
1434
+ for (const arp of allArpeggios) {
1435
+ staffContent += `${indent} <arpeg xml:id="${generateId('arpeg')}" plist="#${arp.plist}" />\n`;
1436
+ }
1437
+ // Generate fermata control events
1438
+ for (const ferm of allFermatas) {
1439
+ const shapeAttr = ferm.shape ? ` shape="${ferm.shape}"` : '';
1440
+ staffContent += `${indent} <fermata xml:id="${generateId('fermata')}" startid="#${ferm.startid}"${shapeAttr} />\n`;
1441
+ }
1442
+ // Generate trill control events
1443
+ for (const tr of allTrills) {
1444
+ staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
1445
+ }
1446
+ // Generate mordent control events
1447
+ for (const mord of allMordents) {
1448
+ const formAttr = mord.form ? ` form="${mord.form}"` : '';
1449
+ staffContent += `${indent} <mordent xml:id="${generateId('mordent')}" startid="#${mord.startid}"${formAttr} />\n`;
1450
+ }
1451
+ // Generate turn control events
1452
+ for (const tu of allTurns) {
1453
+ staffContent += `${indent} <turn xml:id="${generateId('turn')}" startid="#${tu.startid}" />\n`;
1454
+ }
1455
+ // Generate dynamic control events
1456
+ for (const dyn of allDynamics) {
1457
+ staffContent += `${indent} <dynam xml:id="${generateId('dynam')}" startid="#${dyn.startid}">${dyn.label}</dynam>\n`;
1458
+ }
1459
+ // Generate fingering control events
1460
+ for (const fing of allFingerings) {
1461
+ const placeAttr = fing.placement ? ` place="${fing.placement}"` : '';
1462
+ staffContent += `${indent} <fing xml:id="${generateId('fing')}" startid="#${fing.startid}"${placeAttr}>${fing.finger}</fing>\n`;
1463
+ }
1464
+ // Generate dir elements for navigation marks (coda, segno)
1465
+ for (const nav of allNavigations) {
1466
+ // Use <dir> element with appropriate glyph
1467
+ const glyph = nav.type === 'coda' ? '𝄌' : '𝄋'; // Unicode coda/segno symbols
1468
+ staffContent += `${indent} <dir xml:id="${generateId('dir')}" tstamp="1">${glyph}</dir>\n`;
1469
+ }
1470
+ // Generate harm elements for chord symbols
1471
+ for (const harm of allHarmonies) {
1472
+ staffContent += `${indent} <harm xml:id="${generateId('harm')}" startid="#${harm.startid}">${escapeXml(harm.text)}</harm>\n`;
1473
+ }
1474
+ // Generate dir elements for markups
1475
+ for (const mkup of allMarkups) {
1476
+ const placeAttr = mkup.placement ? ` place="${mkup.placement}"` : '';
1477
+ staffContent += `${indent} <dir xml:id="${generateId('dir')}" startid="#${mkup.startid}"${placeAttr}>${escapeXml(mkup.content)}</dir>\n`;
1478
+ }
1479
+ // Determine barline attribute from collected barlines
1480
+ let barlineAttr = '';
1481
+ if (allBarlines.length > 0) {
1482
+ const lastBarline = allBarlines[allBarlines.length - 1];
1483
+ const meiBarline = BARLINE_TO_MEI[lastBarline.style];
1484
+ if (meiBarline && meiBarline !== 'single') {
1485
+ barlineAttr = ` right="${meiBarline}"`;
1486
+ }
1487
+ }
1488
+ // Build final XML with measure tag including barline
1489
+ let xml = `${indent}<measure xml:id="${measureId}" n="${measureN}"${barlineAttr}>\n`;
1490
+ xml += staffContent;
1491
+ xml += `${indent}</measure>\n`;
1492
+ return xml;
1493
+ };
1494
+ // Analyze document to get part structure
1495
+ const analyzePartStructure = (doc) => {
1496
+ // Find maximum number of parts in any measure
1497
+ let maxParts = 0;
1498
+ for (const measure of doc.measures) {
1499
+ maxParts = Math.max(maxParts, measure.parts.length);
1500
+ }
1501
+ // Initialize part info
1502
+ const partInfos = [];
1503
+ for (let i = 0; i < maxParts; i++) {
1504
+ partInfos.push({ maxStaff: 1, staffOffset: 0, clefs: {} });
1505
+ }
1506
+ // Analyze each measure to find max staff per part and clefs
1507
+ for (const measure of doc.measures) {
1508
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1509
+ const part = measure.parts[pi];
1510
+ for (const voice of part.voices) {
1511
+ let currentStaff = voice.staff || 1;
1512
+ partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, currentStaff);
1513
+ // Scan context changes for staff switches and clefs
1514
+ for (const event of voice.events) {
1515
+ if (event.type === 'context') {
1516
+ const ctx = event;
1517
+ if (ctx.staff !== undefined) {
1518
+ currentStaff = ctx.staff;
1519
+ partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, currentStaff);
1520
+ }
1521
+ if (ctx.clef && !partInfos[pi].clefs[currentStaff]) {
1522
+ // Only set if not already set - take the FIRST clef per staff
1523
+ partInfos[pi].clefs[currentStaff] = ctx.clef;
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1528
+ }
1529
+ }
1530
+ // Calculate staff offsets
1531
+ let offset = 0;
1532
+ for (const info of partInfos) {
1533
+ info.staffOffset = offset;
1534
+ offset += info.maxStaff;
1535
+ }
1536
+ return partInfos;
1537
+ };
1538
+ // Encode scoreDef with part groups
1539
+ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1540
+ const scoreDefId = generateId("scoredef");
1541
+ // Build meter attributes
1542
+ const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1543
+ let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1544
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1545
+ for (let pi = 0; pi < partInfos.length; pi++) {
1546
+ const info = partInfos[pi];
1547
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace
1548
+ if (info.maxStaff > 1) {
1549
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1550
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1551
+ const globalStaff = info.staffOffset + ls;
1552
+ const clef = info.clefs[ls] || Clef.treble;
1553
+ const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1554
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1555
+ }
1556
+ xml += `${indent} </staffGrp>\n`;
1557
+ }
1558
+ else {
1559
+ // Single staff part
1560
+ const globalStaff = info.staffOffset + 1;
1561
+ const clef = info.clefs[1] || Clef.treble;
1562
+ const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1563
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
1564
+ }
1565
+ }
1566
+ xml += `${indent} </staffGrp>\n`;
1567
+ xml += `${indent}</scoreDef>\n`;
1568
+ return xml;
1569
+ };
1570
+ // === Auto-beam logic ===
1571
+ // Check if any NoteEvent in the document has a beam mark
1572
+ const docHasBeamMarks = (doc) => {
1573
+ for (const measure of doc.measures) {
1574
+ for (const part of measure.parts) {
1575
+ for (const voice of part.voices) {
1576
+ for (const event of voice.events) {
1577
+ if (event.type === 'note') {
1578
+ const note = event;
1579
+ if (note.marks) {
1580
+ for (const m of note.marks) {
1581
+ if (m.markType === 'beam')
1582
+ return true;
1583
+ }
1584
+ }
1585
+ }
1586
+ else if (event.type === 'tuplet' || event.type === 'times') {
1587
+ const tuplet = event;
1588
+ for (const e of tuplet.events) {
1589
+ if (e.type === 'note') {
1590
+ const note = e;
1591
+ if (note.marks) {
1592
+ for (const m of note.marks) {
1593
+ if (m.markType === 'beam')
1594
+ return true;
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ }
1603
+ }
1604
+ return false;
1605
+ };
1606
+ // Resolve whether auto-beam should be applied
1607
+ const resolveAutoBeam = (doc) => {
1608
+ if (doc.metadata?.autoBeam === 'off')
1609
+ return false;
1610
+ if (doc.metadata?.autoBeam === 'on')
1611
+ return true;
1612
+ // 'auto' or undefined: auto-beam if no manual beam marks exist
1613
+ return !docHasBeamMarks(doc);
1614
+ };
1615
+ // Compute beam group sizes in eighth-note units for a given time signature
1616
+ const getBeamGroups = (timeNum, timeDen) => {
1617
+ // Compound meters (n/8 where n is divisible by 3, and n > 3)
1618
+ if (timeDen === 8 && timeNum % 3 === 0 && timeNum > 3) {
1619
+ const groupCount = timeNum / 3;
1620
+ return Array(groupCount).fill(3);
1621
+ }
1622
+ // Specific common time signatures (LilyPond defaults)
1623
+ if (timeDen === 8 && timeNum === 3)
1624
+ return [3];
1625
+ if (timeDen === 4 && timeNum === 2)
1626
+ return [2, 2];
1627
+ if (timeDen === 4 && timeNum === 3)
1628
+ return [3, 3];
1629
+ if (timeDen === 4 && timeNum === 4)
1630
+ return [4, 4];
1631
+ if (timeDen === 2 && timeNum === 2)
1632
+ return [4, 4];
1633
+ // Generic simple meters: each beat = 8/den eighths
1634
+ const eighthsPerBeat = 8 / timeDen;
1635
+ if (eighthsPerBeat >= 1) {
1636
+ return Array(timeNum).fill(eighthsPerBeat);
1637
+ }
1638
+ // Fallback: one group for the whole measure
1639
+ const totalEighths = timeNum * 8 / timeDen;
1640
+ return [totalEighths];
1641
+ };
1642
+ // Calculate duration in eighth-note units
1643
+ const durationInEighths = (division, dots, tupletRatio) => {
1644
+ // Base duration in eighths: 8 / division
1645
+ let dur = 8 / division;
1646
+ // Dot multiplier: 1 + 1/2 + 1/4 + ... = 2 - 1/2^dots
1647
+ if (dots > 0) {
1648
+ dur *= (2 - Math.pow(0.5, dots));
1649
+ }
1650
+ // Tuplet ratio: multiply by num/den (LilyPond semantics)
1651
+ if (tupletRatio) {
1652
+ dur *= tupletRatio.numerator / tupletRatio.denominator;
1653
+ }
1654
+ return dur;
1655
+ };
1656
+ // Apply auto-beam to the document, mutating events' marks arrays in-place
1657
+ const applyAutoBeam = (doc) => {
1658
+ // Track time signature across measures
1659
+ let timeNum = 4;
1660
+ let timeDen = 4;
1661
+ // Get initial time signature
1662
+ if (doc.measures.length > 0 && doc.measures[0].timeSig) {
1663
+ timeNum = doc.measures[0].timeSig.numerator;
1664
+ timeDen = doc.measures[0].timeSig.denominator;
1665
+ }
1666
+ for (const measure of doc.measures) {
1667
+ // Update time signature if changed
1668
+ if (measure.timeSig) {
1669
+ timeNum = measure.timeSig.numerator;
1670
+ timeDen = measure.timeSig.denominator;
1671
+ }
1672
+ const beamGroups = getBeamGroups(timeNum, timeDen);
1673
+ for (const part of measure.parts) {
1674
+ for (const voice of part.voices) {
1675
+ applyAutoBeamToVoice(voice.events, beamGroups);
1676
+ }
1677
+ }
1678
+ }
1679
+ };
1680
+ // Apply auto-beam to a single voice's events
1681
+ const applyAutoBeamToVoice = (events, beamGroups) => {
1682
+ // Compute group boundary positions in eighths
1683
+ const groupBoundaries = [];
1684
+ let boundary = 0;
1685
+ for (const size of beamGroups) {
1686
+ boundary += size;
1687
+ groupBoundaries.push(boundary);
1688
+ }
1689
+ const totalMeasureEighths = boundary;
1690
+ // Collect beamable notes with their positions
1691
+ let position = 0;
1692
+ const beamableRuns = [];
1693
+ let currentRun = [];
1694
+ // Helper: find which group index a position belongs to
1695
+ const getGroupIndex = (pos) => {
1696
+ for (let i = 0; i < groupBoundaries.length; i++) {
1697
+ if (pos < groupBoundaries[i])
1698
+ return i;
1699
+ }
1700
+ return groupBoundaries.length - 1;
1701
+ };
1702
+ // Helper: flush current run into beamableRuns
1703
+ const flushRun = () => {
1704
+ if (currentRun.length >= 2) {
1705
+ beamableRuns.push(currentRun);
1706
+ }
1707
+ currentRun = [];
1708
+ };
1709
+ for (const event of events) {
1710
+ if (event.type === 'note') {
1711
+ const note = event;
1712
+ if (note.grace)
1713
+ continue; // skip grace notes
1714
+ const dur = durationInEighths(note.duration.division, note.duration.dots);
1715
+ if (note.duration.division >= 8) {
1716
+ // Beamable note
1717
+ const groupIdx = getGroupIndex(position);
1718
+ const noteEndPos = position + dur;
1719
+ const endGroupIdx = getGroupIndex(Math.min(noteEndPos - 0.001, totalMeasureEighths - 0.001));
1720
+ // Note must start and end within the same group
1721
+ if (groupIdx === endGroupIdx) {
1722
+ // Check if current run is in the same group
1723
+ if (currentRun.length > 0) {
1724
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1725
+ if (lastGroupIdx !== groupIdx) {
1726
+ flushRun();
1727
+ }
1728
+ }
1729
+ currentRun.push({ note, position });
1730
+ }
1731
+ else {
1732
+ // Note spans group boundary — break
1733
+ flushRun();
1734
+ }
1735
+ }
1736
+ else {
1737
+ // Non-beamable note (quarter or longer) — break
1738
+ flushRun();
1739
+ }
1740
+ position += dur;
1741
+ }
1742
+ else if (event.type === 'rest') {
1743
+ const rest = event;
1744
+ const dur = durationInEighths(rest.duration.division, rest.duration.dots);
1745
+ // Rests break beam groups
1746
+ flushRun();
1747
+ position += dur;
1748
+ }
1749
+ else if (event.type === 'tuplet' || event.type === 'times') {
1750
+ const tuplet = event;
1751
+ const ratio = tuplet.ratio; // LilyPond ratio: num/den
1752
+ // Check if all inner notes are beamable (division >= 8)
1753
+ const innerNotes = [];
1754
+ let allBeamable = true;
1755
+ let tupletDur = 0;
1756
+ for (const e of tuplet.events) {
1757
+ if (e.type === 'note') {
1758
+ const note = e;
1759
+ if (note.grace)
1760
+ continue;
1761
+ const dur = durationInEighths(note.duration.division, note.duration.dots, ratio);
1762
+ innerNotes.push({ note, dur });
1763
+ tupletDur += dur;
1764
+ if (note.duration.division < 8) {
1765
+ allBeamable = false;
1766
+ }
1767
+ }
1768
+ else if (e.type === 'rest') {
1769
+ allBeamable = false;
1770
+ const dur = durationInEighths(e.duration.division, e.duration.dots, ratio);
1771
+ tupletDur += dur;
1772
+ }
1773
+ }
1774
+ if (allBeamable && innerNotes.length > 0) {
1775
+ const groupIdx = getGroupIndex(position);
1776
+ const endGroupIdx = getGroupIndex(Math.min(position + tupletDur - 0.001, totalMeasureEighths - 0.001));
1777
+ if (groupIdx === endGroupIdx) {
1778
+ // Tuplet fits within one group — add inner notes to current run
1779
+ if (currentRun.length > 0) {
1780
+ const lastGroupIdx = getGroupIndex(currentRun[0].position);
1781
+ if (lastGroupIdx !== groupIdx) {
1782
+ flushRun();
1783
+ }
1784
+ }
1785
+ let innerPos = position;
1786
+ for (const { note, dur } of innerNotes) {
1787
+ currentRun.push({ note, position: innerPos });
1788
+ innerPos += dur;
1789
+ }
1790
+ }
1791
+ else {
1792
+ flushRun();
1793
+ }
1794
+ }
1795
+ else {
1796
+ flushRun();
1797
+ }
1798
+ position += tupletDur;
1799
+ }
1800
+ else if (event.type === 'context' || event.type === 'pitchReset' || event.type === 'barline' || event.type === 'harmony' || event.type === 'markup') {
1801
+ // Non-musical events: don't advance position, don't break beams
1802
+ continue;
1803
+ }
1804
+ else if (event.type === 'tremolo') {
1805
+ // Tremolo breaks beams
1806
+ const trem = event;
1807
+ // Total duration = count * 2 * (1/division) in whole notes
1808
+ // In eighths: count * 2 * (8/division)
1809
+ const dur = trem.count * 2 * (8 / trem.division);
1810
+ flushRun();
1811
+ position += dur;
1812
+ }
1813
+ }
1814
+ // Flush any remaining run
1815
+ flushRun();
1816
+ // Apply beam marks to collected runs
1817
+ for (const run of beamableRuns) {
1818
+ const first = run[0].note;
1819
+ const last = run[run.length - 1].note;
1820
+ if (!first.marks)
1821
+ first.marks = [];
1822
+ first.marks.push({ markType: 'beam', start: true });
1823
+ if (!last.marks)
1824
+ last.marks = [];
1825
+ last.marks.push({ markType: 'beam', start: false });
1826
+ }
1827
+ };
1828
+ // Main encode function
1829
+ const encode = (doc, options = {}) => {
1830
+ const indent = options.indent || " ";
1831
+ resetIdCounter();
1832
+ if (!doc.measures || doc.measures.length === 0) {
1833
+ return "";
1834
+ }
1835
+ // Analyze part structure to get staff offsets
1836
+ const partInfos = analyzePartStructure(doc);
1837
+ // Calculate total staff count
1838
+ const totalStaves = partInfos.reduce((sum, info) => sum + info.maxStaff, 0);
1839
+ // Collect initial key/time from first measure
1840
+ let currentKey = 0;
1841
+ let currentTimeNum = 4;
1842
+ let currentTimeDen = 4;
1843
+ let currentMeterSymbol = undefined;
1844
+ const firstMeasure = doc.measures[0];
1845
+ if (firstMeasure.key) {
1846
+ currentKey = keyToFifths(firstMeasure.key);
1847
+ }
1848
+ if (firstMeasure.timeSig) {
1849
+ currentTimeNum = firstMeasure.timeSig.numerator;
1850
+ currentTimeDen = firstMeasure.timeSig.denominator;
1851
+ currentMeterSymbol = firstMeasure.timeSig.symbol;
1852
+ }
1853
+ const keySig = KEY_SIGS[currentKey] || "0";
1854
+ // Apply auto-beam if needed (before encoding so beam marks are picked up by encodeLayer)
1855
+ const shouldAutoBeam = resolveAutoBeam(doc);
1856
+ if (shouldAutoBeam) {
1857
+ applyAutoBeam(doc);
1858
+ }
1859
+ // Build MEI document
1860
+ const xmlDecl = options.xmlDeclaration !== false
1861
+ ? '<?xml version="1.0" encoding="UTF-8"?>\n'
1862
+ : "";
1863
+ let mei = xmlDecl;
1864
+ mei += '<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">\n';
1865
+ mei += `${indent}<meiHead>\n`;
1866
+ mei += `${indent}${indent}<fileDesc>\n`;
1867
+ mei += `${indent}${indent}${indent}<titleStmt>\n`;
1868
+ // Add title from metadata if available
1869
+ if (doc.metadata?.title) {
1870
+ mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.title)}</title>\n`;
1871
+ }
1872
+ // Add subtitle as second title (verovio reads subsequent titles as subtitle)
1873
+ if (doc.metadata?.subtitle) {
1874
+ mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.subtitle)}</title>\n`;
1875
+ }
1876
+ // Add composer (right-aligned in verovio)
1877
+ if (doc.metadata?.composer) {
1878
+ mei += `${indent}${indent}${indent}${indent}<composer>${escapeXml(doc.metadata.composer)}</composer>\n`;
1879
+ }
1880
+ // Add arranger (right-aligned in verovio)
1881
+ if (doc.metadata?.arranger) {
1882
+ mei += `${indent}${indent}${indent}${indent}<arranger>${escapeXml(doc.metadata.arranger)}</arranger>\n`;
1883
+ }
1884
+ // Add lyricist (left-aligned in verovio)
1885
+ if (doc.metadata?.lyricist) {
1886
+ mei += `${indent}${indent}${indent}${indent}<lyricist>${escapeXml(doc.metadata.lyricist)}</lyricist>\n`;
1887
+ }
1888
+ mei += `${indent}${indent}${indent}</titleStmt>\n`;
1889
+ mei += `${indent}${indent}${indent}<pubStmt />\n`;
1890
+ mei += `${indent}${indent}</fileDesc>\n`;
1891
+ mei += `${indent}${indent}<encodingDesc>\n`;
1892
+ mei += `${indent}${indent}${indent}<projectDesc>\n`;
1893
+ mei += `${indent}${indent}${indent}${indent}<p>Encoded with Lilylet MEIEncoder</p>\n`;
1894
+ mei += `${indent}${indent}${indent}</projectDesc>\n`;
1895
+ mei += `${indent}${indent}</encodingDesc>\n`;
1896
+ mei += `${indent}</meiHead>\n`;
1897
+ mei += `${indent}<music>\n`;
1898
+ mei += `${indent}${indent}<body>\n`;
1899
+ mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1900
+ mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1901
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
1902
+ mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1903
+ // Track tie state across measures for cross-measure ties
1904
+ const tieState = {};
1905
+ // Track slur state across measures for cross-measure slurs
1906
+ const slurState = {};
1907
+ // Track hairpin state across measures for cross-measure hairpins
1908
+ const hairpinState = {};
1909
+ // Track ottava state across measures for cross-measure ottava spans
1910
+ const ottavaState = {};
1911
+ // Initialize clef state from partInfos (convert local staff to global staff)
1912
+ const clefState = {};
1913
+ for (let pi = 0; pi < partInfos.length; pi++) {
1914
+ const partInfo = partInfos[pi];
1915
+ for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
1916
+ const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
1917
+ clefState[globalStaff] = clef;
1918
+ }
1919
+ }
1920
+ // Helper to check if a measure has any musical content
1921
+ const measureHasContent = (measure) => {
1922
+ for (const part of measure.parts) {
1923
+ for (const voice of part.voices) {
1924
+ for (const event of voice.events) {
1925
+ // Check for actual musical content (not just context changes or pitch resets)
1926
+ if (event.type === 'note' || event.type === 'rest' ||
1927
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
1928
+ return true;
1929
+ }
1930
+ }
1931
+ }
1932
+ }
1933
+ return false;
1934
+ };
1935
+ // Filter out trailing empty measures
1936
+ let measures = doc.measures;
1937
+ while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
1938
+ measures = measures.slice(0, -1);
1939
+ }
1940
+ // Encode measures
1941
+ measures.forEach((measure, mi) => {
1942
+ // Check for key signature change and output scoreDef if needed
1943
+ if (measure.key) {
1944
+ const newKey = keyToFifths(measure.key);
1945
+ if (newKey !== currentKey) {
1946
+ currentKey = newKey;
1947
+ const newKeySig = KEY_SIGS[currentKey] || "0";
1948
+ // Output a scoreDef with the new key signature
1949
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
1950
+ }
1951
+ }
1952
+ // Check for time signature change and output scoreDef if needed
1953
+ if (measure.timeSig && mi > 0) {
1954
+ const newTimeNum = measure.timeSig.numerator;
1955
+ const newTimeDen = measure.timeSig.denominator;
1956
+ const newMeterSymbol = measure.timeSig.symbol;
1957
+ if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
1958
+ currentTimeNum = newTimeNum;
1959
+ currentTimeDen = newTimeDen;
1960
+ currentMeterSymbol = newMeterSymbol;
1961
+ // Output a scoreDef with the new time signature
1962
+ const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
1963
+ mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1964
+ }
1965
+ }
1966
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1967
+ });
1968
+ mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1969
+ mei += `${indent}${indent}${indent}${indent}</score>\n`;
1970
+ mei += `${indent}${indent}${indent}</mdiv>\n`;
1971
+ mei += `${indent}${indent}</body>\n`;
1972
+ mei += `${indent}</music>\n`;
1973
+ mei += '</mei>\n';
1974
+ return mei;
1975
+ };
1976
+ // Escape XML special characters
1977
+ const escapeXml = (text) => {
1978
+ return text
1979
+ .replace(/&/g, '&amp;')
1980
+ .replace(/</g, '&lt;')
1981
+ .replace(/>/g, '&gt;')
1982
+ .replace(/"/g, '&quot;')
1983
+ .replace(/'/g, '&apos;');
1984
+ };
1985
+ export { encode, resetIdCounter, };