@k-l-lambda/lilylet 0.1.37 → 0.1.38

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,858 @@
1
+ /**
2
+ * Lilylet to MusicXML Encoder
3
+ *
4
+ * Converts LilyletDoc to MusicXML format.
5
+ * Produces valid MusicXML 4.0 partwise documents.
6
+ */
7
+
8
+ import {
9
+ LilyletDoc,
10
+ Measure,
11
+ Part,
12
+ Voice,
13
+ Event,
14
+ NoteEvent,
15
+ RestEvent,
16
+ ContextChange,
17
+ TupletEvent,
18
+ TremoloEvent,
19
+ BarlineEvent,
20
+ HarmonyEvent,
21
+ MarkupEvent,
22
+ Pitch,
23
+ Duration,
24
+ Mark,
25
+ KeySignature,
26
+ Clef,
27
+ StemDirection,
28
+ Accidental,
29
+ Phonet,
30
+ ArticulationType,
31
+ OrnamentType,
32
+ DynamicType,
33
+ HairpinType,
34
+ PedalType,
35
+ Tempo,
36
+ Metadata,
37
+ Fraction,
38
+ } from "./types";
39
+
40
+
41
+ // === Constants and Reverse Mappings ===
42
+
43
+ // Standard divisions per quarter note
44
+ const DIVISIONS = 4;
45
+
46
+ // Phonet to MusicXML step
47
+ const PHONET_TO_STEP: Record<string, string> = {
48
+ c: 'C',
49
+ d: 'D',
50
+ e: 'E',
51
+ f: 'F',
52
+ g: 'G',
53
+ a: 'A',
54
+ b: 'B',
55
+ };
56
+
57
+ // Accidental to MusicXML alter
58
+ const ACCIDENTAL_TO_ALTER: Record<string, number> = {
59
+ sharp: 1,
60
+ flat: -1,
61
+ doubleSharp: 2,
62
+ doubleFlat: -2,
63
+ natural: 0,
64
+ };
65
+
66
+ // Division to MusicXML note type
67
+ const DIVISION_TO_TYPE: Record<number, string> = {
68
+ 0.5: 'breve',
69
+ 1: 'whole',
70
+ 2: 'half',
71
+ 4: 'quarter',
72
+ 8: 'eighth',
73
+ 16: '16th',
74
+ 32: '32nd',
75
+ 64: '64th',
76
+ 128: '128th',
77
+ };
78
+
79
+ // Key signature to fifths (major keys)
80
+ const KEY_TO_FIFTHS: Record<string, number> = {
81
+ 'c': 0,
82
+ 'g': 1,
83
+ 'd': 2,
84
+ 'a': 3,
85
+ 'e': 4,
86
+ 'b': 5,
87
+ 'f#': 6, 'fs': 6,
88
+ 'c#': 7, 'cs': 7,
89
+ 'f': -1,
90
+ 'bb': -2, 'bf': -2,
91
+ 'eb': -3, 'ef': -3,
92
+ 'ab': -4, 'af': -4,
93
+ 'db': -5, 'df': -5,
94
+ 'gb': -6, 'gf': -6,
95
+ 'cb': -7, 'cf': -7,
96
+ };
97
+
98
+ // Clef to MusicXML sign
99
+ const CLEF_TO_SIGN: Record<string, { sign: string; line: number }> = {
100
+ treble: { sign: 'G', line: 2 },
101
+ bass: { sign: 'F', line: 4 },
102
+ alto: { sign: 'C', line: 3 },
103
+ };
104
+
105
+ // Articulation to MusicXML element name
106
+ const ARTICULATION_TO_XML: Record<string, string> = {
107
+ staccato: 'staccato',
108
+ staccatissimo: 'staccatissimo',
109
+ tenuto: 'tenuto',
110
+ accent: 'accent',
111
+ marcato: 'strong-accent',
112
+ portato: 'detached-legato',
113
+ };
114
+
115
+ // Ornament to MusicXML element name
116
+ const ORNAMENT_TO_XML: Record<string, string> = {
117
+ trill: 'trill-mark',
118
+ turn: 'turn',
119
+ mordent: 'mordent',
120
+ prall: 'inverted-mordent',
121
+ };
122
+
123
+ // Dynamic to MusicXML element name
124
+ const DYNAMIC_TO_XML: Record<string, string> = {
125
+ ppp: 'ppp',
126
+ pp: 'pp',
127
+ p: 'p',
128
+ mp: 'mp',
129
+ mf: 'mf',
130
+ f: 'f',
131
+ ff: 'ff',
132
+ fff: 'fff',
133
+ sfz: 'sfz',
134
+ rfz: 'rfz',
135
+ };
136
+
137
+ // Barline style to MusicXML
138
+ const BARLINE_TO_XML: Record<string, { barStyle: string; repeat?: string }> = {
139
+ '|': { barStyle: 'regular' },
140
+ '||': { barStyle: 'light-light' },
141
+ '|.': { barStyle: 'light-heavy' },
142
+ '.|:': { barStyle: 'heavy-light', repeat: 'forward' },
143
+ ':|.': { barStyle: 'light-heavy', repeat: 'backward' },
144
+ ':..:': { barStyle: 'light-heavy', repeat: 'backward' }, // Will need special handling
145
+ };
146
+
147
+
148
+ // === XML Helper Functions ===
149
+
150
+ const escapeXml = (text: string): string => {
151
+ return text
152
+ .replace(/&/g, '&amp;')
153
+ .replace(/</g, '&lt;')
154
+ .replace(/>/g, '&gt;')
155
+ .replace(/"/g, '&quot;')
156
+ .replace(/'/g, '&apos;');
157
+ };
158
+
159
+ const indent = (level: number): string => ' '.repeat(level);
160
+
161
+
162
+ // === Encoding Functions ===
163
+
164
+ /**
165
+ * Calculate duration in MusicXML divisions
166
+ */
167
+ const calculateDuration = (duration: Duration): number => {
168
+ // Base duration: DIVISIONS * (4 / division)
169
+ // e.g., quarter (4) = DIVISIONS * 1 = 4
170
+ // half (2) = DIVISIONS * 2 = 8
171
+ // eighth (8) = DIVISIONS * 0.5 = 2
172
+ let dur = DIVISIONS * (4 / duration.division);
173
+
174
+ // Apply dots
175
+ if (duration.dots) {
176
+ let dotValue = dur / 2;
177
+ for (let i = 0; i < duration.dots; i++) {
178
+ dur += dotValue;
179
+ dotValue /= 2;
180
+ }
181
+ }
182
+
183
+ // Apply tuplet ratio
184
+ if (duration.tuplet) {
185
+ dur = dur * duration.tuplet.denominator / duration.tuplet.numerator;
186
+ }
187
+
188
+ return Math.round(dur);
189
+ };
190
+
191
+
192
+ /**
193
+ * Encode pitch to MusicXML
194
+ */
195
+ const encodePitch = (pitch: Pitch, level: number): string => {
196
+ const step = PHONET_TO_STEP[pitch.phonet] || 'C';
197
+ const octave = pitch.octave + 4; // Lilylet octave 0 = MusicXML octave 4
198
+ const alter = pitch.accidental ? ACCIDENTAL_TO_ALTER[pitch.accidental] : undefined;
199
+
200
+ let xml = `${indent(level)}<pitch>\n`;
201
+ xml += `${indent(level + 1)}<step>${step}</step>\n`;
202
+ if (alter !== undefined && alter !== 0) {
203
+ xml += `${indent(level + 1)}<alter>${alter}</alter>\n`;
204
+ }
205
+ xml += `${indent(level + 1)}<octave>${octave}</octave>\n`;
206
+ xml += `${indent(level)}</pitch>\n`;
207
+
208
+ return xml;
209
+ };
210
+
211
+
212
+ /**
213
+ * Encode duration elements
214
+ */
215
+ const encodeDuration = (duration: Duration, level: number): string => {
216
+ const dur = calculateDuration(duration);
217
+ const type = DIVISION_TO_TYPE[duration.division] || 'quarter';
218
+
219
+ let xml = `${indent(level)}<duration>${dur}</duration>\n`;
220
+ xml += `${indent(level)}<type>${type}</type>\n`;
221
+
222
+ for (let i = 0; i < duration.dots; i++) {
223
+ xml += `${indent(level)}<dot/>\n`;
224
+ }
225
+
226
+ if (duration.tuplet) {
227
+ xml += `${indent(level)}<time-modification>\n`;
228
+ xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.numerator}</actual-notes>\n`;
229
+ xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.denominator}</normal-notes>\n`;
230
+ xml += `${indent(level)}</time-modification>\n`;
231
+ }
232
+
233
+ return xml;
234
+ };
235
+
236
+
237
+ /**
238
+ * Encode key signature to fifths
239
+ */
240
+ const getKeyFifths = (key: KeySignature): number => {
241
+ let keyStr = key.pitch as string;
242
+ if (key.accidental === Accidental.sharp) {
243
+ keyStr += 's';
244
+ } else if (key.accidental === Accidental.flat) {
245
+ keyStr += 'f';
246
+ }
247
+
248
+ let fifths = KEY_TO_FIFTHS[keyStr.toLowerCase()] ?? 0;
249
+
250
+ // Adjust for minor mode (relative minor is 3 fifths down)
251
+ if (key.mode === 'minor') {
252
+ // Minor keys have same fifths as their relative major
253
+ // e.g., A minor = C major = 0 fifths
254
+ }
255
+
256
+ return fifths;
257
+ };
258
+
259
+
260
+ /**
261
+ * Encode attributes element (key, time, clef, divisions)
262
+ */
263
+ const encodeAttributes = (
264
+ level: number,
265
+ options: {
266
+ divisions?: boolean;
267
+ key?: KeySignature;
268
+ time?: Fraction;
269
+ clef?: Clef;
270
+ staves?: number;
271
+ }
272
+ ): string => {
273
+ let xml = `${indent(level)}<attributes>\n`;
274
+
275
+ if (options.divisions) {
276
+ xml += `${indent(level + 1)}<divisions>${DIVISIONS}</divisions>\n`;
277
+ }
278
+
279
+ if (options.key) {
280
+ const fifths = getKeyFifths(options.key);
281
+ xml += `${indent(level + 1)}<key>\n`;
282
+ xml += `${indent(level + 2)}<fifths>${fifths}</fifths>\n`;
283
+ xml += `${indent(level + 2)}<mode>${options.key.mode}</mode>\n`;
284
+ xml += `${indent(level + 1)}</key>\n`;
285
+ }
286
+
287
+ if (options.time) {
288
+ xml += `${indent(level + 1)}<time>\n`;
289
+ xml += `${indent(level + 2)}<beats>${options.time.numerator}</beats>\n`;
290
+ xml += `${indent(level + 2)}<beat-type>${options.time.denominator}</beat-type>\n`;
291
+ xml += `${indent(level + 1)}</time>\n`;
292
+ }
293
+
294
+ if (options.staves && options.staves > 1) {
295
+ xml += `${indent(level + 1)}<staves>${options.staves}</staves>\n`;
296
+ }
297
+
298
+ if (options.clef) {
299
+ const clefInfo = CLEF_TO_SIGN[options.clef];
300
+ if (clefInfo) {
301
+ xml += `${indent(level + 1)}<clef>\n`;
302
+ xml += `${indent(level + 2)}<sign>${clefInfo.sign}</sign>\n`;
303
+ xml += `${indent(level + 2)}<line>${clefInfo.line}</line>\n`;
304
+ xml += `${indent(level + 1)}</clef>\n`;
305
+ }
306
+ }
307
+
308
+ xml += `${indent(level)}</attributes>\n`;
309
+
310
+ return xml;
311
+ };
312
+
313
+
314
+ /**
315
+ * Encode notations (articulations, ornaments, ties, slurs, etc.)
316
+ */
317
+ const encodeNotations = (marks: Mark[], level: number): string => {
318
+ const articulations: string[] = [];
319
+ const ornaments: string[] = [];
320
+ const otherNotations: string[] = [];
321
+
322
+ for (const mark of marks) {
323
+ switch (mark.markType) {
324
+ case 'articulation':
325
+ const artXml = ARTICULATION_TO_XML[mark.type];
326
+ if (artXml) {
327
+ articulations.push(`<${artXml}/>`);
328
+ }
329
+ break;
330
+
331
+ case 'ornament':
332
+ const ornXml = ORNAMENT_TO_XML[mark.type];
333
+ if (ornXml) {
334
+ if (mark.type === 'fermata') {
335
+ otherNotations.push('<fermata/>');
336
+ } else if (mark.type === 'arpeggio') {
337
+ otherNotations.push('<arpeggiate/>');
338
+ } else {
339
+ ornaments.push(`<${ornXml}/>`);
340
+ }
341
+ }
342
+ break;
343
+
344
+ case 'tie':
345
+ otherNotations.push(`<tied type="${mark.start ? 'start' : 'stop'}"/>`);
346
+ break;
347
+
348
+ case 'slur':
349
+ otherNotations.push(`<slur type="${mark.start ? 'start' : 'stop'}" number="1"/>`);
350
+ break;
351
+
352
+ case 'fingering':
353
+ // Fingering goes in technical
354
+ break;
355
+ }
356
+ }
357
+
358
+ if (articulations.length === 0 && ornaments.length === 0 && otherNotations.length === 0) {
359
+ return '';
360
+ }
361
+
362
+ let xml = `${indent(level)}<notations>\n`;
363
+
364
+ for (const notation of otherNotations) {
365
+ xml += `${indent(level + 1)}${notation}\n`;
366
+ }
367
+
368
+ if (articulations.length > 0) {
369
+ xml += `${indent(level + 1)}<articulations>\n`;
370
+ for (const art of articulations) {
371
+ xml += `${indent(level + 2)}${art}\n`;
372
+ }
373
+ xml += `${indent(level + 1)}</articulations>\n`;
374
+ }
375
+
376
+ if (ornaments.length > 0) {
377
+ xml += `${indent(level + 1)}<ornaments>\n`;
378
+ for (const orn of ornaments) {
379
+ xml += `${indent(level + 2)}${orn}\n`;
380
+ }
381
+ xml += `${indent(level + 1)}</ornaments>\n`;
382
+ }
383
+
384
+ xml += `${indent(level)}</notations>\n`;
385
+
386
+ return xml;
387
+ };
388
+
389
+
390
+ /**
391
+ * Encode a note event
392
+ */
393
+ const encodeNote = (
394
+ event: NoteEvent,
395
+ voice: number,
396
+ staff: number,
397
+ level: number,
398
+ isChord: boolean = false
399
+ ): string => {
400
+ let xml = `${indent(level)}<note>\n`;
401
+
402
+ if (isChord) {
403
+ xml += `${indent(level + 1)}<chord/>\n`;
404
+ }
405
+
406
+ if (event.grace) {
407
+ xml += `${indent(level + 1)}<grace/>\n`;
408
+ }
409
+
410
+ // Pitch (use first pitch, additional pitches become chord notes)
411
+ const pitch = isChord ? event.pitches[0] : event.pitches[0];
412
+ xml += encodePitch(pitch, level + 1);
413
+
414
+ // Duration (not for grace notes)
415
+ if (!event.grace) {
416
+ xml += encodeDuration(event.duration, level + 1);
417
+ } else {
418
+ // Grace notes still need type
419
+ const type = DIVISION_TO_TYPE[event.duration.division] || 'eighth';
420
+ xml += `${indent(level + 1)}<type>${type}</type>\n`;
421
+ }
422
+
423
+ // Tie notation in note element
424
+ const hasTieStart = event.marks?.some(m => m.markType === 'tie' && m.start);
425
+ const hasTieStop = event.marks?.some(m => m.markType === 'tie' && !m.start);
426
+ if (hasTieStart) {
427
+ xml += `${indent(level + 1)}<tie type="start"/>\n`;
428
+ }
429
+ if (hasTieStop) {
430
+ xml += `${indent(level + 1)}<tie type="stop"/>\n`;
431
+ }
432
+
433
+ // Voice
434
+ xml += `${indent(level + 1)}<voice>${voice}</voice>\n`;
435
+
436
+ // Staff (for grand staff)
437
+ if (staff > 0) {
438
+ xml += `${indent(level + 1)}<staff>${staff}</staff>\n`;
439
+ }
440
+
441
+ // Stem direction
442
+ if (event.stemDirection && event.stemDirection !== StemDirection.auto) {
443
+ xml += `${indent(level + 1)}<stem>${event.stemDirection}</stem>\n`;
444
+ }
445
+
446
+ // Beam marks
447
+ const beamStart = event.marks?.find(m => m.markType === 'beam' && m.start);
448
+ const beamEnd = event.marks?.find(m => m.markType === 'beam' && !m.start);
449
+ if (beamStart) {
450
+ xml += `${indent(level + 1)}<beam number="1">begin</beam>\n`;
451
+ } else if (beamEnd) {
452
+ xml += `${indent(level + 1)}<beam number="1">end</beam>\n`;
453
+ }
454
+
455
+ // Notations
456
+ if (event.marks && event.marks.length > 0) {
457
+ xml += encodeNotations(event.marks, level + 1);
458
+ }
459
+
460
+ xml += `${indent(level)}</note>\n`;
461
+
462
+ return xml;
463
+ };
464
+
465
+
466
+ /**
467
+ * Encode a rest event
468
+ */
469
+ const encodeRest = (
470
+ event: RestEvent,
471
+ voice: number,
472
+ staff: number,
473
+ level: number
474
+ ): string => {
475
+ let xml = `${indent(level)}<note>\n`;
476
+
477
+ xml += `${indent(level + 1)}<rest`;
478
+ if (event.fullMeasure) {
479
+ xml += ' measure="yes"';
480
+ }
481
+ xml += '/>\n';
482
+
483
+ xml += encodeDuration(event.duration, level + 1);
484
+
485
+ xml += `${indent(level + 1)}<voice>${voice}</voice>\n`;
486
+
487
+ if (staff > 0) {
488
+ xml += `${indent(level + 1)}<staff>${staff}</staff>\n`;
489
+ }
490
+
491
+ xml += `${indent(level)}</note>\n`;
492
+
493
+ return xml;
494
+ };
495
+
496
+
497
+ /**
498
+ * Encode direction element (dynamics, tempo, etc.)
499
+ */
500
+ const encodeDirection = (
501
+ marks: Mark[],
502
+ level: number
503
+ ): string => {
504
+ let xml = '';
505
+
506
+ for (const mark of marks) {
507
+ if (mark.markType === 'dynamic') {
508
+ const dynXml = DYNAMIC_TO_XML[mark.type];
509
+ if (dynXml) {
510
+ xml += `${indent(level)}<direction placement="below">\n`;
511
+ xml += `${indent(level + 1)}<direction-type>\n`;
512
+ xml += `${indent(level + 2)}<dynamics>\n`;
513
+ xml += `${indent(level + 3)}<${dynXml}/>\n`;
514
+ xml += `${indent(level + 2)}</dynamics>\n`;
515
+ xml += `${indent(level + 1)}</direction-type>\n`;
516
+ xml += `${indent(level)}</direction>\n`;
517
+ }
518
+ } else if (mark.markType === 'hairpin') {
519
+ let wedgeType = '';
520
+ if (mark.type === HairpinType.crescendoStart) {
521
+ wedgeType = 'crescendo';
522
+ } else if (mark.type === HairpinType.diminuendoStart) {
523
+ wedgeType = 'diminuendo';
524
+ } else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
525
+ wedgeType = 'stop';
526
+ }
527
+ if (wedgeType) {
528
+ xml += `${indent(level)}<direction>\n`;
529
+ xml += `${indent(level + 1)}<direction-type>\n`;
530
+ xml += `${indent(level + 2)}<wedge type="${wedgeType}"/>\n`;
531
+ xml += `${indent(level + 1)}</direction-type>\n`;
532
+ xml += `${indent(level)}</direction>\n`;
533
+ }
534
+ } else if (mark.markType === 'pedal') {
535
+ let pedalType = '';
536
+ if (mark.type === PedalType.sustainOn) {
537
+ pedalType = 'start';
538
+ } else if (mark.type === PedalType.sustainOff) {
539
+ pedalType = 'stop';
540
+ }
541
+ if (pedalType) {
542
+ xml += `${indent(level)}<direction>\n`;
543
+ xml += `${indent(level + 1)}<direction-type>\n`;
544
+ xml += `${indent(level + 2)}<pedal type="${pedalType}"/>\n`;
545
+ xml += `${indent(level + 1)}</direction-type>\n`;
546
+ xml += `${indent(level)}</direction>\n`;
547
+ }
548
+ }
549
+ }
550
+
551
+ return xml;
552
+ };
553
+
554
+
555
+ /**
556
+ * Encode tempo marking
557
+ */
558
+ const encodeTempo = (tempo: Tempo, level: number): string => {
559
+ let xml = `${indent(level)}<direction placement="above">\n`;
560
+ xml += `${indent(level + 1)}<direction-type>\n`;
561
+
562
+ if (tempo.beat && tempo.bpm) {
563
+ xml += `${indent(level + 2)}<metronome>\n`;
564
+ const beatUnit = DIVISION_TO_TYPE[tempo.beat.division] || 'quarter';
565
+ xml += `${indent(level + 3)}<beat-unit>${beatUnit}</beat-unit>\n`;
566
+ if (tempo.beat.dots) {
567
+ for (let i = 0; i < tempo.beat.dots; i++) {
568
+ xml += `${indent(level + 3)}<beat-unit-dot/>\n`;
569
+ }
570
+ }
571
+ xml += `${indent(level + 3)}<per-minute>${tempo.bpm}</per-minute>\n`;
572
+ xml += `${indent(level + 2)}</metronome>\n`;
573
+ }
574
+
575
+ if (tempo.text) {
576
+ xml += `${indent(level + 2)}<words>${escapeXml(tempo.text)}</words>\n`;
577
+ }
578
+
579
+ xml += `${indent(level + 1)}</direction-type>\n`;
580
+
581
+ if (tempo.bpm) {
582
+ xml += `${indent(level + 1)}<sound tempo="${tempo.bpm}"/>\n`;
583
+ }
584
+
585
+ xml += `${indent(level)}</direction>\n`;
586
+
587
+ return xml;
588
+ };
589
+
590
+
591
+ /**
592
+ * Encode barline
593
+ */
594
+ const encodeBarline = (event: BarlineEvent, level: number): string => {
595
+ const barInfo = BARLINE_TO_XML[event.style];
596
+ if (!barInfo || event.style === '|') {
597
+ return ''; // Regular barline, no need to encode
598
+ }
599
+
600
+ let xml = `${indent(level)}<barline location="right">\n`;
601
+ xml += `${indent(level + 1)}<bar-style>${barInfo.barStyle}</bar-style>\n`;
602
+
603
+ if (barInfo.repeat) {
604
+ xml += `${indent(level + 1)}<repeat direction="${barInfo.repeat}"/>\n`;
605
+ }
606
+
607
+ xml += `${indent(level)}</barline>\n`;
608
+
609
+ return xml;
610
+ };
611
+
612
+
613
+ /**
614
+ * Encode harmony (chord symbol)
615
+ */
616
+ const encodeHarmony = (event: HarmonyEvent, level: number): string => {
617
+ // Simple text-based harmony for now
618
+ let xml = `${indent(level)}<harmony>\n`;
619
+ xml += `${indent(level + 1)}<root>\n`;
620
+ xml += `${indent(level + 2)}<root-step>C</root-step>\n`; // Placeholder
621
+ xml += `${indent(level + 1)}</root>\n`;
622
+ xml += `${indent(level + 1)}<kind text="${escapeXml(event.text)}">major</kind>\n`;
623
+ xml += `${indent(level)}</harmony>\n`;
624
+
625
+ return xml;
626
+ };
627
+
628
+
629
+ /**
630
+ * Encode a complete measure
631
+ */
632
+ const encodeMeasure = (
633
+ measure: Measure,
634
+ measureNumber: number,
635
+ isFirst: boolean,
636
+ prevKey: KeySignature | undefined,
637
+ prevTime: Fraction | undefined,
638
+ level: number
639
+ ): string => {
640
+ let xml = `${indent(level)}<measure number="${measureNumber}">\n`;
641
+
642
+ // Determine if we need attributes
643
+ const needAttributes = isFirst ||
644
+ (measure.key && JSON.stringify(measure.key) !== JSON.stringify(prevKey)) ||
645
+ (measure.timeSig && JSON.stringify(measure.timeSig) !== JSON.stringify(prevTime));
646
+
647
+ // Find max staff number
648
+ let maxStaff = 1;
649
+ for (const part of measure.parts) {
650
+ for (const voice of part.voices) {
651
+ maxStaff = Math.max(maxStaff, voice.staff || 1);
652
+ }
653
+ }
654
+
655
+ // Encode attributes if needed
656
+ if (needAttributes) {
657
+ // Find clef from first voice
658
+ let clef: Clef | undefined;
659
+ for (const part of measure.parts) {
660
+ for (const voice of part.voices) {
661
+ for (const event of voice.events) {
662
+ if (event.type === 'context' && event.clef) {
663
+ clef = event.clef;
664
+ break;
665
+ }
666
+ }
667
+ if (clef) break;
668
+ }
669
+ if (clef) break;
670
+ }
671
+
672
+ xml += encodeAttributes(level + 1, {
673
+ divisions: isFirst,
674
+ key: measure.key || prevKey,
675
+ time: measure.timeSig || prevTime,
676
+ clef: clef,
677
+ staves: maxStaff > 1 ? maxStaff : undefined,
678
+ });
679
+ }
680
+
681
+ // Encode voices
682
+ let voiceNum = 1;
683
+ let currentPosition = 0;
684
+
685
+ for (const part of measure.parts) {
686
+ for (const voice of part.voices) {
687
+ const staff = voice.staff || 1;
688
+ let voicePosition = 0;
689
+
690
+ // Backup if needed
691
+ if (currentPosition > 0 && voiceNum > 1) {
692
+ xml += `${indent(level + 1)}<backup>\n`;
693
+ xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
694
+ xml += `${indent(level + 1)}</backup>\n`;
695
+ voicePosition = 0;
696
+ }
697
+
698
+ for (const event of voice.events) {
699
+ switch (event.type) {
700
+ case 'note': {
701
+ // Check for direction marks (dynamics, hairpins, pedals)
702
+ const directionMarks = event.marks?.filter(m =>
703
+ m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
704
+ ) || [];
705
+ if (directionMarks.length > 0) {
706
+ xml += encodeDirection(directionMarks, level + 1);
707
+ }
708
+
709
+ // Encode main note
710
+ xml += encodeNote(event, voiceNum, staff, level + 1);
711
+ const dur = calculateDuration(event.duration);
712
+ voicePosition += dur;
713
+
714
+ // Encode chord notes
715
+ for (let i = 1; i < event.pitches.length; i++) {
716
+ const chordEvent: NoteEvent = {
717
+ ...event,
718
+ pitches: [event.pitches[i]],
719
+ };
720
+ xml += encodeNote(chordEvent, voiceNum, staff, level + 1, true);
721
+ }
722
+ break;
723
+ }
724
+
725
+ case 'rest': {
726
+ xml += encodeRest(event, voiceNum, staff, level + 1);
727
+ const dur = calculateDuration(event.duration);
728
+ voicePosition += dur;
729
+ break;
730
+ }
731
+
732
+ case 'context': {
733
+ if (event.tempo) {
734
+ xml += encodeTempo(event.tempo, level + 1);
735
+ }
736
+ // Other context changes are handled in attributes
737
+ break;
738
+ }
739
+
740
+ case 'tuplet': {
741
+ for (const subEvent of event.events) {
742
+ if (subEvent.type === 'note') {
743
+ xml += encodeNote(subEvent, voiceNum, staff, level + 1);
744
+ const dur = calculateDuration(subEvent.duration);
745
+ voicePosition += dur;
746
+ } else if (subEvent.type === 'rest') {
747
+ xml += encodeRest(subEvent, voiceNum, staff, level + 1);
748
+ const dur = calculateDuration(subEvent.duration);
749
+ voicePosition += dur;
750
+ }
751
+ }
752
+ break;
753
+ }
754
+
755
+ case 'barline': {
756
+ xml += encodeBarline(event, level + 1);
757
+ break;
758
+ }
759
+
760
+ case 'harmony': {
761
+ xml += encodeHarmony(event, level + 1);
762
+ break;
763
+ }
764
+ }
765
+ }
766
+
767
+ currentPosition = Math.max(currentPosition, voicePosition);
768
+ voiceNum++;
769
+ }
770
+ }
771
+
772
+ xml += `${indent(level)}</measure>\n`;
773
+
774
+ return xml;
775
+ };
776
+
777
+
778
+ /**
779
+ * Encode metadata to MusicXML elements
780
+ */
781
+ const encodeMetadata = (metadata: Metadata, level: number): string => {
782
+ let xml = '';
783
+
784
+ if (metadata.title) {
785
+ xml += `${indent(level)}<work>\n`;
786
+ xml += `${indent(level + 1)}<work-title>${escapeXml(metadata.title)}</work-title>\n`;
787
+ xml += `${indent(level)}</work>\n`;
788
+ }
789
+
790
+ xml += `${indent(level)}<identification>\n`;
791
+
792
+ if (metadata.composer) {
793
+ xml += `${indent(level + 1)}<creator type="composer">${escapeXml(metadata.composer)}</creator>\n`;
794
+ }
795
+ if (metadata.arranger) {
796
+ xml += `${indent(level + 1)}<creator type="arranger">${escapeXml(metadata.arranger)}</creator>\n`;
797
+ }
798
+ if (metadata.lyricist) {
799
+ xml += `${indent(level + 1)}<creator type="lyricist">${escapeXml(metadata.lyricist)}</creator>\n`;
800
+ }
801
+
802
+ xml += `${indent(level + 1)}<encoding>\n`;
803
+ xml += `${indent(level + 2)}<software>Lilylet</software>\n`;
804
+ xml += `${indent(level + 2)}<encoding-date>${new Date().toISOString().split('T')[0]}</encoding-date>\n`;
805
+ xml += `${indent(level + 1)}</encoding>\n`;
806
+
807
+ xml += `${indent(level)}</identification>\n`;
808
+
809
+ return xml;
810
+ };
811
+
812
+
813
+ /**
814
+ * Encode complete LilyletDoc to MusicXML
815
+ */
816
+ export const encode = (doc: LilyletDoc): string => {
817
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
818
+ xml += '<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">\n';
819
+ xml += '<score-partwise version="4.0">\n';
820
+
821
+ // Metadata
822
+ if (doc.metadata) {
823
+ xml += encodeMetadata(doc.metadata, 1);
824
+ }
825
+
826
+ // Part list (single part for now)
827
+ xml += `${indent(1)}<part-list>\n`;
828
+ xml += `${indent(2)}<score-part id="P1">\n`;
829
+ xml += `${indent(3)}<part-name>${doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music'}</part-name>\n`;
830
+ xml += `${indent(2)}</score-part>\n`;
831
+ xml += `${indent(1)}</part-list>\n`;
832
+
833
+ // Part content
834
+ xml += `${indent(1)}<part id="P1">\n`;
835
+
836
+ let prevKey: KeySignature | undefined;
837
+ let prevTime: Fraction | undefined;
838
+
839
+ for (let i = 0; i < doc.measures.length; i++) {
840
+ const measure = doc.measures[i];
841
+ const isFirst = i === 0;
842
+
843
+ xml += encodeMeasure(measure, i + 1, isFirst, prevKey, prevTime, 2);
844
+
845
+ if (measure.key) prevKey = measure.key;
846
+ if (measure.timeSig) prevTime = measure.timeSig;
847
+ }
848
+
849
+ xml += `${indent(1)}</part>\n`;
850
+ xml += '</score-partwise>\n';
851
+
852
+ return xml;
853
+ };
854
+
855
+
856
+ export default {
857
+ encode,
858
+ };