@k-l-lambda/lilylet 0.1.30

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