@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,748 @@
1
+ /**
2
+ * Lilylet Document Serializer
3
+ *
4
+ * Converts LilyletDoc to Lilylet (.lyl) string format.
5
+ * Uses relative pitch mode matching the parser's behavior.
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
+ Pitch,
20
+ Duration,
21
+ Mark,
22
+ KeySignature,
23
+ Clef,
24
+ StemDirection,
25
+ Accidental,
26
+ ArticulationType,
27
+ OrnamentType,
28
+ DynamicType,
29
+ HairpinType,
30
+ PedalType,
31
+ Tempo,
32
+ Placement,
33
+ } from "./types";
34
+
35
+
36
+ const PHONETS = "cdefgab";
37
+
38
+
39
+ // Pitch environment for relative pitch serialization
40
+ interface PitchEnv {
41
+ step: number; // 0-6 for c-b
42
+ octave: number; // absolute octave (0 = middle C octave)
43
+ }
44
+
45
+
46
+ /**
47
+ * Calculate the octave markers needed to serialize a pitch in relative mode.
48
+ *
49
+ * The parser logic:
50
+ * - Calculate interval from previous pitch
51
+ * - If |interval| > 3, adjust octave (go the "short way")
52
+ * - Add explicit ' and , markers from the pitch
53
+ *
54
+ * We need to reverse this: given the target absolute octave,
55
+ * calculate what markers are needed.
56
+ */
57
+ const getRelativeOctaveMarkers = (env: PitchEnv, pitch: Pitch): { markers: string; newEnv: PitchEnv } => {
58
+ const step = PHONETS.indexOf(pitch.phonet as string);
59
+ if (step === -1) {
60
+ return { markers: '', newEnv: env };
61
+ }
62
+
63
+ const interval = step - env.step;
64
+
65
+ // Parser's octave adjustment calculation
66
+ const octInc = Math.floor(Math.abs(interval) / 4) * -Math.sign(interval);
67
+
68
+ // Without any markers, parser would calculate:
69
+ // env.octave + 0 (marker) + octInc = base octave
70
+ const baseOctave = env.octave + octInc;
71
+
72
+ // We need markers to reach pitch.octave from baseOctave
73
+ const markerCount = pitch.octave - baseOctave;
74
+
75
+ let markers = '';
76
+ if (markerCount > 0) {
77
+ markers = "'".repeat(markerCount);
78
+ } else if (markerCount < 0) {
79
+ markers = ",".repeat(-markerCount);
80
+ }
81
+
82
+ // Update environment (mirrors parser behavior)
83
+ const newEnv: PitchEnv = {
84
+ step: step,
85
+ octave: pitch.octave
86
+ };
87
+
88
+ return { markers, newEnv };
89
+ };
90
+
91
+
92
+ // Accidental to Lilylet notation
93
+ const ACCIDENTAL_MAP: Record<string, string> = {
94
+ natural: '!',
95
+ sharp: 's',
96
+ flat: 'f',
97
+ doubleSharp: 'ss',
98
+ doubleFlat: 'ff',
99
+ };
100
+
101
+
102
+ // Clef to Lilylet notation
103
+ const CLEF_MAP: Record<string, string> = {
104
+ treble: 'treble',
105
+ bass: 'bass',
106
+ alto: 'alto',
107
+ };
108
+
109
+
110
+ // Articulation to Lilylet notation
111
+ const ARTICULATION_MAP: Record<string, string> = {
112
+ staccato: '.',
113
+ staccatissimo: '!',
114
+ tenuto: '_',
115
+ marcato: '^',
116
+ accent: '>',
117
+ portato: '_.',
118
+ };
119
+
120
+
121
+ // Ornament to Lilylet notation
122
+ const ORNAMENT_MAP: Record<string, string> = {
123
+ trill: '\\trill',
124
+ turn: '\\turn',
125
+ mordent: '\\mordent',
126
+ prall: '\\prall',
127
+ fermata: '\\fermata',
128
+ shortFermata: '\\shortfermata',
129
+ arpeggio: '\\arpeggio',
130
+ };
131
+
132
+
133
+ // Dynamic to Lilylet notation
134
+ const DYNAMIC_MAP: Record<string, string> = {
135
+ ppp: '\\ppp',
136
+ pp: '\\pp',
137
+ p: '\\p',
138
+ mp: '\\mp',
139
+ mf: '\\mf',
140
+ f: '\\f',
141
+ ff: '\\ff',
142
+ fff: '\\fff',
143
+ sfz: '\\sfz',
144
+ rfz: '\\rfz',
145
+ };
146
+
147
+
148
+ // Hairpin to Lilylet notation
149
+ const HAIRPIN_MAP: Record<string, string> = {
150
+ crescendoStart: '\\<',
151
+ crescendoEnd: '\\!',
152
+ diminuendoStart: '\\>',
153
+ diminuendoEnd: '\\!',
154
+ };
155
+
156
+
157
+ // Pedal to Lilylet notation
158
+ const PEDAL_MAP: Record<string, string> = {
159
+ sustainOn: '\\sustainOn',
160
+ sustainOff: '\\sustainOff',
161
+ sostenutoOn: '\\sostenutoOn',
162
+ sostenutoOff: '\\sostenutoOff',
163
+ unaCordaOn: '\\unaCorda',
164
+ unaCordaOff: '\\treCorde',
165
+ };
166
+
167
+
168
+ // Serialize a pitch to Lilylet notation (absolute mode - for contexts like key signature)
169
+ const serializePitchAbsolute = (pitch: Pitch): string => {
170
+ let result = String(pitch.phonet);
171
+
172
+ // Add accidental
173
+ if (pitch.accidental) {
174
+ result += ACCIDENTAL_MAP[pitch.accidental] || '';
175
+ }
176
+
177
+ // Add octave markers
178
+ if (pitch.octave > 0) {
179
+ result += "'".repeat(pitch.octave);
180
+ } else if (pitch.octave < 0) {
181
+ result += ",".repeat(-pitch.octave);
182
+ }
183
+
184
+ return result;
185
+ };
186
+
187
+
188
+ // Serialize a pitch in relative mode
189
+ const serializePitchRelative = (pitch: Pitch, env: PitchEnv): { str: string; newEnv: PitchEnv } => {
190
+ let result = String(pitch.phonet);
191
+
192
+ // Add accidental
193
+ if (pitch.accidental) {
194
+ result += ACCIDENTAL_MAP[pitch.accidental] || '';
195
+ }
196
+
197
+ // Calculate relative octave markers
198
+ const { markers, newEnv } = getRelativeOctaveMarkers(env, pitch);
199
+ result += markers;
200
+
201
+ return { str: result, newEnv };
202
+ };
203
+
204
+
205
+ // Serialize duration to Lilylet notation
206
+ const serializeDuration = (duration: Duration): string => {
207
+ let result = duration.division.toString();
208
+
209
+ // Add dots
210
+ if (duration.dots > 0) {
211
+ result += '.'.repeat(duration.dots);
212
+ }
213
+
214
+ return result;
215
+ };
216
+
217
+
218
+ // Serialize marks (articulations, ornaments, dynamics, etc.)
219
+ const serializeMarks = (marks: Mark[]): string => {
220
+ const parts: string[] = [];
221
+
222
+ for (const mark of marks) {
223
+ switch (mark.markType) {
224
+ case 'tie':
225
+ if (mark.start) parts.push('~');
226
+ break;
227
+ case 'slur':
228
+ parts.push(mark.start ? '(' : ')');
229
+ break;
230
+ case 'beam':
231
+ parts.push(mark.start ? '[' : ']');
232
+ break;
233
+ case 'articulation': {
234
+ const artStr = ARTICULATION_MAP[mark.type];
235
+ if (artStr) {
236
+ const prefix = mark.placement === 'above' ? '^' : mark.placement === 'below' ? '_' : '-';
237
+ parts.push(prefix + artStr);
238
+ }
239
+ break;
240
+ }
241
+ case 'ornament': {
242
+ const ornStr = ORNAMENT_MAP[mark.type];
243
+ if (ornStr) parts.push(ornStr);
244
+ break;
245
+ }
246
+ case 'dynamic': {
247
+ const dynStr = DYNAMIC_MAP[mark.type];
248
+ if (dynStr) parts.push(dynStr);
249
+ break;
250
+ }
251
+ case 'hairpin': {
252
+ const hairpinStr = HAIRPIN_MAP[mark.type];
253
+ if (hairpinStr) parts.push(hairpinStr);
254
+ break;
255
+ }
256
+ case 'pedal': {
257
+ const pedalStr = PEDAL_MAP[mark.type];
258
+ if (pedalStr) parts.push(pedalStr);
259
+ break;
260
+ }
261
+ }
262
+ }
263
+
264
+ return parts.join('');
265
+ };
266
+
267
+
268
+ // Serialize a note event with pitch environment tracking
269
+ const serializeNoteEvent = (
270
+ event: NoteEvent,
271
+ env: PitchEnv,
272
+ prevDuration?: Duration
273
+ ): { str: string; newEnv: PitchEnv } => {
274
+ const parts: string[] = [];
275
+ let currentEnv = env;
276
+
277
+ // Grace note prefix
278
+ if (event.grace) {
279
+ parts.push('\\grace ');
280
+ }
281
+
282
+ // Single note or chord
283
+ if (event.pitches.length === 1) {
284
+ const { str, newEnv } = serializePitchRelative(event.pitches[0], currentEnv);
285
+ parts.push(str);
286
+ currentEnv = newEnv;
287
+ } else if (event.pitches.length > 1) {
288
+ // Chord: <c e g>
289
+ // First pitch is relative to previous note, subsequent pitches relative to each other
290
+ const pitchStrs: string[] = [];
291
+ const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitches[0], currentEnv);
292
+ pitchStrs.push(firstStr);
293
+ currentEnv = firstEnv;
294
+
295
+ // Chord pitches are relative to each other within the chord
296
+ let chordEnv = { ...currentEnv };
297
+ for (let i = 1; i < event.pitches.length; i++) {
298
+ const { str, newEnv } = serializePitchRelative(event.pitches[i], chordEnv);
299
+ pitchStrs.push(str);
300
+ chordEnv = newEnv;
301
+ }
302
+
303
+ parts.push('<' + pitchStrs.join(' ') + '>');
304
+ }
305
+
306
+ // Duration (only if different from previous or first note)
307
+ const durStr = serializeDuration(event.duration);
308
+ if (!prevDuration ||
309
+ prevDuration.division !== event.duration.division ||
310
+ prevDuration.dots !== event.duration.dots) {
311
+ parts.push(durStr);
312
+ }
313
+
314
+ // Tremolo
315
+ if (event.tremolo) {
316
+ parts.push(':' + event.tremolo);
317
+ }
318
+
319
+ // Marks
320
+ if (event.marks && event.marks.length > 0) {
321
+ parts.push(serializeMarks(event.marks));
322
+ }
323
+
324
+ return { str: parts.join(''), newEnv: currentEnv };
325
+ };
326
+
327
+
328
+ // Serialize a rest event with pitch environment tracking
329
+ const serializeRestEvent = (
330
+ event: RestEvent,
331
+ env: PitchEnv,
332
+ prevDuration?: Duration
333
+ ): { str: string; newEnv: PitchEnv } => {
334
+ const parts: string[] = [];
335
+ let currentEnv = env;
336
+ let isPitchedRest = false;
337
+
338
+ // Full measure rest
339
+ if (event.fullMeasure) {
340
+ parts.push('R');
341
+ }
342
+ // Space rest (invisible)
343
+ else if (event.invisible) {
344
+ parts.push('s');
345
+ }
346
+ // Positioned rest: pitch + duration + \rest
347
+ else if (event.pitch) {
348
+ const { str, newEnv } = serializePitchRelative(event.pitch, currentEnv);
349
+ parts.push(str);
350
+ currentEnv = newEnv;
351
+ isPitchedRest = true;
352
+ } else {
353
+ parts.push('r');
354
+ }
355
+
356
+ // Duration
357
+ const durStr = serializeDuration(event.duration);
358
+ if (!prevDuration ||
359
+ prevDuration.division !== event.duration.division ||
360
+ prevDuration.dots !== event.duration.dots) {
361
+ parts.push(durStr);
362
+ }
363
+
364
+ // \rest mark comes after duration for positioned rests
365
+ if (isPitchedRest) {
366
+ parts.push('\\rest');
367
+ }
368
+
369
+ return { str: parts.join(''), newEnv: currentEnv };
370
+ };
371
+
372
+
373
+ // Serialize a context change
374
+ const serializeContextChange = (event: ContextChange): string => {
375
+ const parts: string[] = [];
376
+
377
+ // Clef
378
+ if (event.clef) {
379
+ parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
380
+ }
381
+
382
+ // Key signature
383
+ if (event.key) {
384
+ let keyStr = String(event.key.pitch);
385
+ if (event.key.accidental) {
386
+ keyStr += ACCIDENTAL_MAP[event.key.accidental] || '';
387
+ }
388
+ keyStr += ' \\' + event.key.mode;
389
+ parts.push('\\key ' + keyStr);
390
+ }
391
+
392
+ // Time signature
393
+ if (event.time) {
394
+ parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
395
+ }
396
+
397
+ // Ottava
398
+ if (event.ottava !== undefined) {
399
+ if (event.ottava === 0) {
400
+ parts.push('\\ottava #0');
401
+ } else {
402
+ parts.push('\\ottava #' + event.ottava);
403
+ }
404
+ }
405
+
406
+ // Stem direction
407
+ if (event.stemDirection) {
408
+ if (event.stemDirection === StemDirection.up) {
409
+ parts.push('\\stemUp');
410
+ } else if (event.stemDirection === StemDirection.down) {
411
+ parts.push('\\stemDown');
412
+ } else if (event.stemDirection === StemDirection.auto) {
413
+ parts.push('\\stemNeutral');
414
+ }
415
+ }
416
+
417
+ // Tempo
418
+ if (event.tempo) {
419
+ parts.push(serializeTempo(event.tempo));
420
+ }
421
+
422
+ return parts.join(' ');
423
+ };
424
+
425
+
426
+ // Serialize tempo
427
+ const serializeTempo = (tempo: Tempo): string => {
428
+ const parts: string[] = ['\\tempo'];
429
+
430
+ if (tempo.text) {
431
+ parts.push('"' + tempo.text + '"');
432
+ }
433
+
434
+ if (tempo.beat && tempo.bpm) {
435
+ parts.push(tempo.beat.division + '=' + tempo.bpm);
436
+ }
437
+
438
+ return parts.join(' ');
439
+ };
440
+
441
+
442
+ // Serialize a tuplet event with pitch environment tracking
443
+ const serializeTupletEvent = (
444
+ event: TupletEvent,
445
+ env: PitchEnv
446
+ ): { str: string; newEnv: PitchEnv } => {
447
+ const parts: string[] = [];
448
+ let currentEnv = env;
449
+
450
+ // \times numerator/denominator { ... }
451
+ parts.push('\\times ' + event.ratio.numerator + '/' + event.ratio.denominator + ' {');
452
+
453
+ let prevDuration: Duration | undefined;
454
+ for (const e of event.events) {
455
+ if (e.type === 'note') {
456
+ const { str, newEnv } = serializeNoteEvent(e as NoteEvent, currentEnv, prevDuration);
457
+ parts.push(' ' + str);
458
+ currentEnv = newEnv;
459
+ prevDuration = (e as NoteEvent).duration;
460
+ } else if (e.type === 'rest') {
461
+ const { str, newEnv } = serializeRestEvent(e as RestEvent, currentEnv, prevDuration);
462
+ parts.push(' ' + str);
463
+ currentEnv = newEnv;
464
+ prevDuration = (e as RestEvent).duration;
465
+ }
466
+ }
467
+
468
+ parts.push(' }');
469
+ return { str: parts.join(''), newEnv: currentEnv };
470
+ };
471
+
472
+
473
+ // Serialize a tremolo event with pitch environment tracking
474
+ const serializeTremoloEvent = (
475
+ event: TremoloEvent,
476
+ env: PitchEnv
477
+ ): { str: string; newEnv: PitchEnv } => {
478
+ const parts: string[] = [];
479
+ let currentEnv = env;
480
+
481
+ // \repeat tremolo count { noteA noteB }
482
+ parts.push('\\repeat tremolo ' + event.count + ' {');
483
+
484
+ // First pitch/chord
485
+ if (event.pitchA.length === 1) {
486
+ const { str, newEnv } = serializePitchRelative(event.pitchA[0], currentEnv);
487
+ parts.push(' ' + str + event.division);
488
+ currentEnv = newEnv;
489
+ } else {
490
+ const pitchStrs: string[] = [];
491
+ const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitchA[0], currentEnv);
492
+ pitchStrs.push(firstStr);
493
+ currentEnv = firstEnv;
494
+ let chordEnv = { ...currentEnv };
495
+ for (let i = 1; i < event.pitchA.length; i++) {
496
+ const { str, newEnv } = serializePitchRelative(event.pitchA[i], chordEnv);
497
+ pitchStrs.push(str);
498
+ chordEnv = newEnv;
499
+ }
500
+ parts.push(' <' + pitchStrs.join(' ') + '>' + event.division);
501
+ }
502
+
503
+ // Second pitch/chord
504
+ if (event.pitchB.length === 1) {
505
+ const { str, newEnv } = serializePitchRelative(event.pitchB[0], currentEnv);
506
+ parts.push(' ' + str + event.division);
507
+ currentEnv = newEnv;
508
+ } else {
509
+ const pitchStrs: string[] = [];
510
+ const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitchB[0], currentEnv);
511
+ pitchStrs.push(firstStr);
512
+ currentEnv = firstEnv;
513
+ let chordEnv = { ...currentEnv };
514
+ for (let i = 1; i < event.pitchB.length; i++) {
515
+ const { str, newEnv } = serializePitchRelative(event.pitchB[i], chordEnv);
516
+ pitchStrs.push(str);
517
+ chordEnv = newEnv;
518
+ }
519
+ parts.push(' <' + pitchStrs.join(' ') + '>' + event.division);
520
+ }
521
+
522
+ parts.push(' }');
523
+ return { str: parts.join(''), newEnv: currentEnv };
524
+ };
525
+
526
+
527
+ // Serialize a single event with pitch environment tracking
528
+ const serializeEvent = (
529
+ event: Event,
530
+ env: PitchEnv,
531
+ prevDuration?: Duration
532
+ ): { str: string; newEnv: PitchEnv } => {
533
+ switch (event.type) {
534
+ case 'note':
535
+ return serializeNoteEvent(event as NoteEvent, env, prevDuration);
536
+ case 'rest':
537
+ return serializeRestEvent(event as RestEvent, env, prevDuration);
538
+ case 'context':
539
+ return { str: serializeContextChange(event as ContextChange), newEnv: env };
540
+ case 'tuplet':
541
+ return serializeTupletEvent(event as TupletEvent, env);
542
+ case 'tremolo':
543
+ return serializeTremoloEvent(event as TremoloEvent, env);
544
+ default:
545
+ return { str: '', newEnv: env };
546
+ }
547
+ };
548
+
549
+
550
+ // Key/time signature info to inject into first voice
551
+ interface MeasureContext {
552
+ key?: KeySignature;
553
+ time?: { numerator: number; denominator: number };
554
+ }
555
+
556
+ // Serialize a voice with pitch environment tracking
557
+ // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
558
+ // If isGrandStaff is true, always output \staff command for clarity
559
+ // If measureContext is provided, output key/time after \staff (for first voice only)
560
+ const serializeVoice = (
561
+ voice: Voice,
562
+ currentStaff: number,
563
+ isGrandStaff: boolean = false,
564
+ measureContext?: MeasureContext
565
+ ): { str: string; newStaff: number } => {
566
+ const parts: string[] = [];
567
+ let prevDuration: Duration | undefined;
568
+ // Each voice starts fresh from middle C (step=0, octave=0)
569
+ let pitchEnv: PitchEnv = { step: 0, octave: 0 };
570
+
571
+ // Output staff command if voice staff differs from current parser staff,
572
+ // or always output if it's a grand staff score for clarity
573
+ if (isGrandStaff || voice.staff !== currentStaff) {
574
+ parts.push('\\staff "' + voice.staff + '"');
575
+ }
576
+
577
+ // Output key/time signatures after \staff (for first voice of first measure)
578
+ if (measureContext) {
579
+ if (measureContext.key) {
580
+ let keyStr = String(measureContext.key.pitch);
581
+ if (measureContext.key.accidental) {
582
+ keyStr += ACCIDENTAL_MAP[measureContext.key.accidental] || '';
583
+ }
584
+ keyStr += ' \\' + measureContext.key.mode;
585
+ parts.push('\\key ' + keyStr);
586
+ }
587
+ if (measureContext.time) {
588
+ parts.push('\\time ' + measureContext.time.numerator + '/' + measureContext.time.denominator);
589
+ }
590
+ }
591
+
592
+ for (const event of voice.events) {
593
+ const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
594
+ pitchEnv = newEnv;
595
+
596
+ if (eventStr) {
597
+ parts.push(eventStr);
598
+ }
599
+
600
+ // Track duration for note/rest events
601
+ if (event.type === 'note') {
602
+ prevDuration = (event as NoteEvent).duration;
603
+ } else if (event.type === 'rest') {
604
+ prevDuration = (event as RestEvent).duration;
605
+ }
606
+ }
607
+
608
+ return { str: parts.join(' '), newStaff: voice.staff };
609
+ };
610
+
611
+
612
+ // Serialize a part, tracking staff state across voices
613
+ // If measureContext is provided, pass it to the first voice only
614
+ const serializePart = (
615
+ part: Part,
616
+ currentStaff: number,
617
+ isGrandStaff: boolean = false,
618
+ measureContext?: MeasureContext
619
+ ): { str: string; newStaff: number } => {
620
+ if (part.voices.length === 0) {
621
+ return { str: '', newStaff: currentStaff };
622
+ }
623
+
624
+ const voiceStrs: string[] = [];
625
+ let staff = currentStaff;
626
+
627
+ for (let i = 0; i < part.voices.length; i++) {
628
+ const voice = part.voices[i];
629
+ // Only pass measureContext to first voice
630
+ const ctx = i === 0 ? measureContext : undefined;
631
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, ctx);
632
+ voiceStrs.push(str);
633
+ staff = newStaff;
634
+ }
635
+
636
+ // Multiple voices: separated by \\ with newline
637
+ return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
638
+ };
639
+
640
+
641
+ // Serialize a measure, tracking staff state across parts
642
+ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: number, isGrandStaff: boolean = false): { str: string; newStaff: number } => {
643
+ const parts: string[] = [];
644
+
645
+ // Build measure context for first voice (key/time signatures)
646
+ const measureContext: MeasureContext | undefined = isFirst ? {
647
+ key: measure.key,
648
+ time: measure.timeSig,
649
+ } : undefined;
650
+
651
+ // Parts
652
+ let staff = currentStaff;
653
+ if (measure.parts.length === 1) {
654
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
655
+ if (partStr) {
656
+ parts.push(partStr);
657
+ }
658
+ staff = newStaff;
659
+ } else if (measure.parts.length > 1) {
660
+ // Multiple parts: separated by \\\ with newline
661
+ const partStrs: string[] = [];
662
+ for (let i = 0; i < measure.parts.length; i++) {
663
+ const part = measure.parts[i];
664
+ // Only pass measureContext to first part
665
+ const ctx = i === 0 ? measureContext : undefined;
666
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
667
+ if (str) {
668
+ partStrs.push(str);
669
+ }
670
+ staff = newStaff;
671
+ }
672
+ parts.push(partStrs.join(' \\\\\\\\\n'));
673
+ }
674
+
675
+ return { str: parts.join(' '), newStaff: staff };
676
+ };
677
+
678
+
679
+ // Escape string for serialization (quotes and backslashes)
680
+ const escapeString = (str: string): string => {
681
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
682
+ };
683
+
684
+ // Serialize metadata
685
+ const serializeMetadata = (metadata: any): string => {
686
+ const lines: string[] = [];
687
+
688
+ if (metadata.title) {
689
+ lines.push('[title "' + escapeString(metadata.title) + '"]');
690
+ }
691
+ if (metadata.subtitle) {
692
+ lines.push('[subtitle "' + escapeString(metadata.subtitle) + '"]');
693
+ }
694
+ if (metadata.composer) {
695
+ lines.push('[composer "' + escapeString(metadata.composer) + '"]');
696
+ }
697
+ if (metadata.arranger) {
698
+ lines.push('[arranger "' + escapeString(metadata.arranger) + '"]');
699
+ }
700
+ if (metadata.lyricist) {
701
+ lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
702
+ }
703
+
704
+ return lines.join('\n');
705
+ };
706
+
707
+
708
+ /**
709
+ * Serialize a LilyletDoc to Lilylet (.lyl) string format
710
+ */
711
+ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
712
+ const parts: string[] = [];
713
+
714
+ // Metadata
715
+ if (doc.metadata) {
716
+ const metaStr = serializeMetadata(doc.metadata);
717
+ if (metaStr) {
718
+ parts.push(metaStr);
719
+ parts.push('');
720
+ }
721
+ }
722
+
723
+ // Detect grand staff: check if any voice has staff > 1
724
+ const isGrandStaff = doc.measures.some(m =>
725
+ m.parts.some(p =>
726
+ p.voices.some(v => v.staff > 1)
727
+ )
728
+ );
729
+
730
+ // Measures with bar lines, measure numbers, and double newlines
731
+ // Track staff state across measures (parser remembers staff across bar lines)
732
+ const measureStrs: string[] = [];
733
+ let currentStaff = 1; // Parser starts at staff 1
734
+ for (let i = 0; i < doc.measures.length; i++) {
735
+ const { str: measureStr, newStaff } = serializeMeasure(doc.measures[i], i === 0, currentStaff, isGrandStaff);
736
+ // Always include measure, even if empty (use space rest for empty measures)
737
+ measureStrs.push(measureStr || 's1');
738
+ currentStaff = newStaff;
739
+ }
740
+
741
+ // Join measures with bar, measure number comment, and double newline
742
+ const measuresOutput = measureStrs
743
+ .map((m, i) => m + ' | %' + (i + 1))
744
+ .join('\n\n');
745
+ parts.push(measuresOutput);
746
+
747
+ return parts.join('\n');
748
+ };