@k-l-lambda/lilylet 0.1.36 → 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,832 @@
1
+ /**
2
+ * Lilylet to LilyPond Encoder
3
+ *
4
+ * Converts LilyletDoc to LilyPond (.ly) format.
5
+ * Uses relative pitch mode matching LilyPond's default 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
+ 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
+ Placement,
38
+ } from "./types";
39
+
40
+
41
+ // === Constants and Mappings ===
42
+
43
+ const PHONETS = "cdefgab";
44
+
45
+ // Key signature to LilyPond notation (using English note names)
46
+ const KEY_MAP: Record<string, Record<string, string>> = {
47
+ c: { major: "c \\major", minor: "c \\minor" },
48
+ d: { major: "d \\major", minor: "d \\minor" },
49
+ e: { major: "e \\major", minor: "e \\minor" },
50
+ f: { major: "f \\major", minor: "f \\minor" },
51
+ g: { major: "g \\major", minor: "g \\minor" },
52
+ a: { major: "a \\major", minor: "a \\minor" },
53
+ b: { major: "b \\major", minor: "b \\minor" },
54
+ };
55
+
56
+ // Accidentals for key signatures
57
+ const KEY_ACCIDENTAL_MAP: Record<string, string> = {
58
+ sharp: "s",
59
+ flat: "f",
60
+ doubleSharp: "ss",
61
+ doubleFlat: "ff",
62
+ };
63
+
64
+ // Clef names
65
+ const CLEF_MAP: Record<string, string> = {
66
+ treble: "treble",
67
+ bass: "bass",
68
+ alto: "alto",
69
+ };
70
+
71
+ // Accidental to LilyPond notation
72
+ const ACCIDENTAL_MAP: Record<string, string> = {
73
+ natural: "!",
74
+ sharp: "s",
75
+ flat: "f",
76
+ doubleSharp: "ss",
77
+ doubleFlat: "ff",
78
+ };
79
+
80
+ // Articulation to LilyPond notation
81
+ const ARTICULATION_MAP: Record<string, string> = {
82
+ staccato: "-.",
83
+ staccatissimo: "-!",
84
+ tenuto: "--",
85
+ marcato: "-^",
86
+ accent: "->",
87
+ portato: "-_",
88
+ };
89
+
90
+ // Ornament to LilyPond notation
91
+ const ORNAMENT_MAP: Record<string, string> = {
92
+ trill: "\\trill",
93
+ turn: "\\turn",
94
+ mordent: "\\mordent",
95
+ prall: "\\prall",
96
+ fermata: "\\fermata",
97
+ shortFermata: "\\shortfermata",
98
+ arpeggio: "\\arpeggio",
99
+ };
100
+
101
+ // Dynamic to LilyPond notation
102
+ const DYNAMIC_MAP: Record<string, string> = {
103
+ ppp: "\\ppp",
104
+ pp: "\\pp",
105
+ p: "\\p",
106
+ mp: "\\mp",
107
+ mf: "\\mf",
108
+ f: "\\f",
109
+ ff: "\\ff",
110
+ fff: "\\fff",
111
+ sfz: "\\sfz",
112
+ rfz: "\\rfz",
113
+ };
114
+
115
+ // Hairpin to LilyPond notation
116
+ const HAIRPIN_MAP: Record<string, string> = {
117
+ crescendoStart: "\\<",
118
+ crescendoEnd: "\\!",
119
+ diminuendoStart: "\\>",
120
+ diminuendoEnd: "\\!",
121
+ };
122
+
123
+ // Pedal to LilyPond notation
124
+ const PEDAL_MAP: Record<string, string> = {
125
+ sustainOn: "\\sustainOn",
126
+ sustainOff: "\\sustainOff",
127
+ sostenutoOn: "\\sostenutoOn",
128
+ sostenutoOff: "\\sostenutoOff",
129
+ unaCordaOn: "\\unaCorda",
130
+ unaCordaOff: "\\treCorde",
131
+ };
132
+
133
+ // Stem direction
134
+ const STEM_MAP: Record<string, string> = {
135
+ up: "\\stemUp",
136
+ down: "\\stemDown",
137
+ auto: "\\stemNeutral",
138
+ };
139
+
140
+ // Barline styles
141
+ const BARLINE_MAP: Record<string, string> = {
142
+ "|": "|",
143
+ "||": "||",
144
+ "|.": "|.",
145
+ ".|:": ".|:",
146
+ ":|.": ":|.",
147
+ ":..:": ":..:",
148
+ ":..:|": ":..:|",
149
+ };
150
+
151
+
152
+ // === Pitch Environment for Relative Mode ===
153
+
154
+ interface PitchEnv {
155
+ step: number; // 0-6 for c-b
156
+ octave: number; // absolute octave (0 = middle C octave)
157
+ }
158
+
159
+
160
+ /**
161
+ * Calculate the octave markers needed to serialize a pitch in relative mode.
162
+ */
163
+ const getRelativeOctaveMarkers = (env: PitchEnv, pitch: Pitch): { markers: string; newEnv: PitchEnv } => {
164
+ const step = PHONETS.indexOf(pitch.phonet as string);
165
+ if (step === -1) {
166
+ return { markers: '', newEnv: env };
167
+ }
168
+
169
+ const interval = step - env.step;
170
+
171
+ // Parser's octave adjustment calculation
172
+ const octInc = Math.floor(Math.abs(interval) / 4) * -Math.sign(interval);
173
+
174
+ // Without any markers, parser would calculate:
175
+ const baseOctave = env.octave + octInc;
176
+
177
+ // We need markers to reach pitch.octave from baseOctave
178
+ const markerCount = pitch.octave - baseOctave;
179
+
180
+ let markers = '';
181
+ if (markerCount > 0) {
182
+ markers = "'".repeat(markerCount);
183
+ } else if (markerCount < 0) {
184
+ markers = ",".repeat(-markerCount);
185
+ }
186
+
187
+ // Update environment
188
+ const newEnv: PitchEnv = {
189
+ step: step,
190
+ octave: pitch.octave
191
+ };
192
+
193
+ return { markers, newEnv };
194
+ };
195
+
196
+
197
+ // === Render Options ===
198
+
199
+ interface RenderOptions {
200
+ paper?: {
201
+ width?: number | string;
202
+ height?: number | string;
203
+ };
204
+ fontSize?: number;
205
+ withMIDI?: boolean;
206
+ autoBeaming?: boolean;
207
+ }
208
+
209
+ const DEFAULT_OPTIONS: RenderOptions = {
210
+ paper: { width: 210, height: 297 }, // A4 size in mm
211
+ fontSize: 20,
212
+ withMIDI: false,
213
+ autoBeaming: false,
214
+ };
215
+
216
+
217
+ // === Encoding Functions ===
218
+
219
+ /**
220
+ * Encode key signature to LilyPond
221
+ */
222
+ const encodeKey = (key: KeySignature): string => {
223
+ let keyStr: string = key.pitch as string;
224
+ if (key.accidental) {
225
+ keyStr += KEY_ACCIDENTAL_MAP[key.accidental] || '';
226
+ }
227
+ return `\\key ${keyStr} \\${key.mode}`;
228
+ };
229
+
230
+
231
+ /**
232
+ * Encode time signature to LilyPond
233
+ */
234
+ const encodeTimeSig = (timeSig: { numerator: number; denominator: number; symbol?: string }): string => {
235
+ if (timeSig.symbol === 'common') {
236
+ return "\\time 4/4"; // LilyPond handles C automatically
237
+ }
238
+ if (timeSig.symbol === 'cut') {
239
+ return "\\time 2/2"; // LilyPond handles C| automatically
240
+ }
241
+ return `\\time ${timeSig.numerator}/${timeSig.denominator}`;
242
+ };
243
+
244
+
245
+ /**
246
+ * Encode clef to LilyPond
247
+ */
248
+ const encodeClef = (clef: Clef): string => {
249
+ return `\\clef ${CLEF_MAP[clef] || clef}`;
250
+ };
251
+
252
+
253
+ /**
254
+ * Encode tempo to LilyPond
255
+ */
256
+ const encodeTempo = (tempo: Tempo): string => {
257
+ let result = "\\tempo";
258
+ if (tempo.text) {
259
+ result += ` "${tempo.text}"`;
260
+ }
261
+ if (tempo.beat && tempo.bpm) {
262
+ const beatValue = tempo.beat.division;
263
+ let dots = "";
264
+ if (tempo.beat.dots) {
265
+ dots = ".".repeat(tempo.beat.dots);
266
+ }
267
+ result += ` ${beatValue}${dots} = ${tempo.bpm}`;
268
+ }
269
+ return result;
270
+ };
271
+
272
+
273
+ /**
274
+ * Encode a single pitch in relative mode
275
+ */
276
+ const encodePitch = (pitch: Pitch, env: PitchEnv): { str: string; newEnv: PitchEnv } => {
277
+ let result: string = pitch.phonet as string;
278
+
279
+ // Add accidental
280
+ if (pitch.accidental && pitch.accidental !== Accidental.natural) {
281
+ result += ACCIDENTAL_MAP[pitch.accidental] || '';
282
+ } else if (pitch.accidental === Accidental.natural) {
283
+ result += '!';
284
+ }
285
+
286
+ // Calculate relative octave markers
287
+ const { markers, newEnv } = getRelativeOctaveMarkers(env, pitch);
288
+ result += markers;
289
+
290
+ return { str: result, newEnv };
291
+ };
292
+
293
+
294
+ /**
295
+ * Encode duration to LilyPond
296
+ */
297
+ const encodeDuration = (duration: Duration): string => {
298
+ let result = String(duration.division);
299
+ if (duration.dots) {
300
+ result += ".".repeat(duration.dots);
301
+ }
302
+ return result;
303
+ };
304
+
305
+
306
+ /**
307
+ * Encode marks (articulations, dynamics, etc.) to LilyPond
308
+ */
309
+ const encodeMarks = (marks: Mark[]): string => {
310
+ let result = '';
311
+
312
+ for (const mark of marks) {
313
+ switch (mark.markType) {
314
+ case 'articulation':
315
+ result += ARTICULATION_MAP[mark.type] || '';
316
+ break;
317
+ case 'ornament':
318
+ result += ORNAMENT_MAP[mark.type] || '';
319
+ break;
320
+ case 'dynamic':
321
+ result += DYNAMIC_MAP[mark.type] || '';
322
+ break;
323
+ case 'hairpin':
324
+ result += HAIRPIN_MAP[mark.type] || '';
325
+ break;
326
+ case 'tie':
327
+ if (mark.start) {
328
+ result += '~';
329
+ }
330
+ break;
331
+ case 'slur':
332
+ result += mark.start ? '(' : ')';
333
+ break;
334
+ case 'beam':
335
+ result += mark.start ? '[' : ']';
336
+ break;
337
+ case 'pedal':
338
+ result += PEDAL_MAP[mark.type] || '';
339
+ break;
340
+ case 'fingering':
341
+ result += `-${mark.finger}`;
342
+ break;
343
+ case 'navigation':
344
+ if (mark.type === 'coda') {
345
+ result += '\\coda';
346
+ } else if (mark.type === 'segno') {
347
+ result += '\\segno';
348
+ }
349
+ break;
350
+ case 'markup':
351
+ const placement = mark.placement === 'below' ? '_' : '^';
352
+ result += `${placement}\\markup { ${mark.content} }`;
353
+ break;
354
+ }
355
+ }
356
+
357
+ return result;
358
+ };
359
+
360
+
361
+ /**
362
+ * Encode a note event
363
+ */
364
+ const encodeNoteEvent = (event: NoteEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration } => {
365
+ let result = '';
366
+ let newEnv = env;
367
+
368
+ // Grace note
369
+ if (event.grace) {
370
+ result += '\\grace ';
371
+ }
372
+
373
+ // Stem direction
374
+ if (event.stemDirection) {
375
+ result += STEM_MAP[event.stemDirection] + ' ';
376
+ }
377
+
378
+ // Pitches (chord or single note)
379
+ if (event.pitches.length > 1) {
380
+ result += '<';
381
+ const pitchStrs: string[] = [];
382
+ for (const pitch of event.pitches) {
383
+ const { str, newEnv: ne } = encodePitch(pitch, newEnv);
384
+ pitchStrs.push(str);
385
+ newEnv = ne;
386
+ }
387
+ result += pitchStrs.join(' ');
388
+ result += '>';
389
+ } else if (event.pitches.length === 1) {
390
+ const { str, newEnv: ne } = encodePitch(event.pitches[0], newEnv);
391
+ result += str;
392
+ newEnv = ne;
393
+ }
394
+
395
+ // Duration (only if different from last)
396
+ const needDuration = !lastDuration ||
397
+ lastDuration.division !== event.duration.division ||
398
+ lastDuration.dots !== event.duration.dots;
399
+
400
+ if (needDuration) {
401
+ result += encodeDuration(event.duration);
402
+ }
403
+
404
+ // Tremolo
405
+ if (event.tremolo) {
406
+ result += `:${event.tremolo}`;
407
+ }
408
+
409
+ // Marks
410
+ if (event.marks) {
411
+ result += encodeMarks(event.marks);
412
+ }
413
+
414
+ return { str: result, newEnv, newDuration: event.duration };
415
+ };
416
+
417
+
418
+ /**
419
+ * Encode a rest event
420
+ */
421
+ const encodeRestEvent = (event: RestEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration } => {
422
+ let result = '';
423
+
424
+ // Rest type
425
+ if (event.fullMeasure) {
426
+ result += 'R';
427
+ } else if (event.invisible) {
428
+ result += 's';
429
+ } else {
430
+ result += 'r';
431
+ }
432
+
433
+ // Duration
434
+ const needDuration = !lastDuration ||
435
+ lastDuration.division !== event.duration.division ||
436
+ lastDuration.dots !== event.duration.dots;
437
+
438
+ if (needDuration) {
439
+ result += encodeDuration(event.duration);
440
+ }
441
+
442
+ // Positioned rest
443
+ if (event.pitch && !event.fullMeasure && !event.invisible) {
444
+ const { str } = encodePitch(event.pitch, env);
445
+ result = str + result.slice(1); // Replace 'r' with pitch
446
+ result += '\\rest';
447
+ }
448
+
449
+ return { str: result, newEnv: env, newDuration: event.duration };
450
+ };
451
+
452
+
453
+ /**
454
+ * Encode a context change event
455
+ */
456
+ const encodeContextChange = (event: ContextChange): string => {
457
+ const parts: string[] = [];
458
+
459
+ if (event.key) {
460
+ parts.push(encodeKey(event.key));
461
+ }
462
+ if (event.time) {
463
+ parts.push(encodeTimeSig(event.time));
464
+ }
465
+ if (event.clef) {
466
+ parts.push(encodeClef(event.clef));
467
+ }
468
+ if (event.ottava !== undefined) {
469
+ parts.push(`\\ottava #${event.ottava}`);
470
+ }
471
+ if (event.stemDirection) {
472
+ parts.push(STEM_MAP[event.stemDirection]);
473
+ }
474
+ if (event.tempo) {
475
+ parts.push(encodeTempo(event.tempo));
476
+ }
477
+ if (event.staff) {
478
+ parts.push(`\\change Staff = "${event.staff}"`);
479
+ }
480
+
481
+ return parts.join(' ');
482
+ };
483
+
484
+
485
+ /**
486
+ * Encode a tuplet event
487
+ */
488
+ const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
489
+ let result = `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator} { `;
490
+ let newEnv = env;
491
+ let newDuration = lastDuration;
492
+
493
+ for (const subEvent of event.events) {
494
+ if (subEvent.type === 'note') {
495
+ const { str, newEnv: ne, newDuration: nd } = encodeNoteEvent(subEvent, newEnv, newDuration);
496
+ result += str + ' ';
497
+ newEnv = ne;
498
+ newDuration = nd;
499
+ } else if (subEvent.type === 'rest') {
500
+ const { str, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
501
+ result += str + ' ';
502
+ newDuration = nd;
503
+ }
504
+ }
505
+
506
+ result += '}';
507
+
508
+ return { str: result, newEnv, newDuration };
509
+ };
510
+
511
+
512
+ /**
513
+ * Encode a tremolo event
514
+ */
515
+ const encodeTremoloEvent = (event: TremoloEvent, env: PitchEnv): { str: string; newEnv: PitchEnv } => {
516
+ let newEnv = env;
517
+
518
+ // First chord/note
519
+ let pitchA = '';
520
+ if (event.pitchA.length > 1) {
521
+ pitchA += '<';
522
+ const pitchStrs: string[] = [];
523
+ for (const pitch of event.pitchA) {
524
+ const { str, newEnv: ne } = encodePitch(pitch, newEnv);
525
+ pitchStrs.push(str);
526
+ newEnv = ne;
527
+ }
528
+ pitchA += pitchStrs.join(' ');
529
+ pitchA += '>';
530
+ } else if (event.pitchA.length === 1) {
531
+ const { str, newEnv: ne } = encodePitch(event.pitchA[0], newEnv);
532
+ pitchA += str;
533
+ newEnv = ne;
534
+ }
535
+
536
+ // Second chord/note
537
+ let pitchB = '';
538
+ if (event.pitchB.length > 1) {
539
+ pitchB += '<';
540
+ const pitchStrs: string[] = [];
541
+ for (const pitch of event.pitchB) {
542
+ const { str, newEnv: ne } = encodePitch(pitch, newEnv);
543
+ pitchStrs.push(str);
544
+ newEnv = ne;
545
+ }
546
+ pitchB += pitchStrs.join(' ');
547
+ pitchB += '>';
548
+ } else if (event.pitchB.length === 1) {
549
+ const { str, newEnv: ne } = encodePitch(event.pitchB[0], newEnv);
550
+ pitchB += str;
551
+ newEnv = ne;
552
+ }
553
+
554
+ const result = `\\repeat tremolo ${event.count} { ${pitchA}${event.division} ${pitchB}${event.division} }`;
555
+
556
+ return { str: result, newEnv };
557
+ };
558
+
559
+
560
+ /**
561
+ * Encode a barline event
562
+ */
563
+ const encodeBarlineEvent = (event: BarlineEvent): string => {
564
+ const style = BARLINE_MAP[event.style] || event.style;
565
+ if (style === '|') {
566
+ return ''; // Default barline, no need to encode
567
+ }
568
+ return `\\bar "${style}"`;
569
+ };
570
+
571
+
572
+ /**
573
+ * Encode a harmony event (chord symbol)
574
+ */
575
+ const encodeHarmonyEvent = (event: HarmonyEvent): string => {
576
+ return `^\\markup { ${event.text} }`;
577
+ };
578
+
579
+
580
+ /**
581
+ * Encode a markup event
582
+ */
583
+ const encodeMarkupEvent = (event: MarkupEvent): string => {
584
+ const placement = event.placement === 'below' ? '_' : '^';
585
+ return `${placement}\\markup { ${event.content} }`;
586
+ };
587
+
588
+
589
+ /**
590
+ * Encode a voice to LilyPond
591
+ */
592
+ const encodeVoice = (
593
+ voice: Voice,
594
+ measureContext: { key?: KeySignature; timeSig?: any; isFirst: boolean },
595
+ voiceIndex: number
596
+ ): string => {
597
+ let result = '';
598
+ let env: PitchEnv = { step: 0, octave: 0 }; // Start at middle C
599
+ let lastDuration: Duration | null = null;
600
+
601
+ for (const event of voice.events) {
602
+ switch (event.type) {
603
+ case 'note': {
604
+ const { str, newEnv, newDuration } = encodeNoteEvent(event, env, lastDuration);
605
+ result += str + ' ';
606
+ env = newEnv;
607
+ lastDuration = newDuration;
608
+ break;
609
+ }
610
+ case 'rest': {
611
+ const { str, newDuration } = encodeRestEvent(event, env, lastDuration);
612
+ result += str + ' ';
613
+ lastDuration = newDuration;
614
+ break;
615
+ }
616
+ case 'context': {
617
+ result += encodeContextChange(event) + ' ';
618
+ break;
619
+ }
620
+ case 'tuplet': {
621
+ const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
622
+ result += str + ' ';
623
+ env = newEnv;
624
+ lastDuration = newDuration;
625
+ break;
626
+ }
627
+ case 'tremolo': {
628
+ const { str, newEnv } = encodeTremoloEvent(event, env);
629
+ result += str + ' ';
630
+ env = newEnv;
631
+ break;
632
+ }
633
+ case 'barline': {
634
+ const str = encodeBarlineEvent(event);
635
+ if (str) {
636
+ result += str + ' ';
637
+ }
638
+ break;
639
+ }
640
+ case 'harmony': {
641
+ result += encodeHarmonyEvent(event) + ' ';
642
+ break;
643
+ }
644
+ case 'markup': {
645
+ result += encodeMarkupEvent(event) + ' ';
646
+ break;
647
+ }
648
+ case 'pitchReset': {
649
+ env = { step: 0, octave: 0 };
650
+ break;
651
+ }
652
+ }
653
+ }
654
+
655
+ return result.trim();
656
+ };
657
+
658
+
659
+ /**
660
+ * Encode metadata to LilyPond header block
661
+ */
662
+ const encodeMetadata = (metadata: Metadata): string => {
663
+ const entries: string[] = [];
664
+
665
+ if (metadata.title) {
666
+ entries.push(` title = "${metadata.title}"`);
667
+ }
668
+ if (metadata.subtitle) {
669
+ entries.push(` subtitle = "${metadata.subtitle}"`);
670
+ }
671
+ if (metadata.composer) {
672
+ entries.push(` composer = "${metadata.composer}"`);
673
+ }
674
+ if (metadata.arranger) {
675
+ entries.push(` arranger = "${metadata.arranger}"`);
676
+ }
677
+ if (metadata.lyricist) {
678
+ entries.push(` poet = "${metadata.lyricist}"`);
679
+ }
680
+ if (metadata.opus) {
681
+ entries.push(` opus = "${metadata.opus}"`);
682
+ }
683
+ if (metadata.instrument) {
684
+ entries.push(` instrument = "${metadata.instrument}"`);
685
+ }
686
+
687
+ entries.push(' tagline = ##f');
688
+
689
+ return entries.join('\n');
690
+ };
691
+
692
+
693
+ /**
694
+ * Encode a complete LilyletDoc to LilyPond format
695
+ */
696
+ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string => {
697
+ const opts = { ...DEFAULT_OPTIONS, ...options };
698
+
699
+ // Collect all voices across measures, grouped by staff
700
+ const staffVoices: Map<number, string[][]> = new Map(); // staff -> measure -> voice content
701
+
702
+ let currentKey: KeySignature | undefined;
703
+ let currentTimeSig: any;
704
+
705
+ for (let mi = 0; mi < doc.measures.length; mi++) {
706
+ const measure = doc.measures[mi];
707
+
708
+ // Update context from measure
709
+ if (measure.key) currentKey = measure.key;
710
+ if (measure.timeSig) currentTimeSig = measure.timeSig;
711
+
712
+ // Process each part
713
+ for (const part of measure.parts) {
714
+ for (let vi = 0; vi < part.voices.length; vi++) {
715
+ const voice = part.voices[vi];
716
+ const staff = voice.staff || 1;
717
+
718
+ if (!staffVoices.has(staff)) {
719
+ staffVoices.set(staff, []);
720
+ }
721
+ const staffMeasures = staffVoices.get(staff)!;
722
+
723
+ // Ensure we have enough measure slots
724
+ while (staffMeasures.length <= mi) {
725
+ staffMeasures.push([]);
726
+ }
727
+
728
+ // Encode voice content
729
+ const voiceContent = encodeVoice(voice, {
730
+ key: currentKey,
731
+ timeSig: currentTimeSig,
732
+ isFirst: mi === 0
733
+ }, vi);
734
+
735
+ staffMeasures[mi].push(voiceContent);
736
+ }
737
+ }
738
+ }
739
+
740
+ // Build music content
741
+ const staffCount = Math.max(...Array.from(staffVoices.keys()));
742
+ const staffStrings: string[] = [];
743
+
744
+ for (let si = 1; si <= staffCount; si++) {
745
+ const measures = staffVoices.get(si) || [];
746
+
747
+ // Find max voices per measure for this staff
748
+ const maxVoices = Math.max(...measures.map(m => m.length), 1);
749
+
750
+ // Build voice lines
751
+ const voiceLines: string[] = [];
752
+ for (let vi = 0; vi < maxVoices; vi++) {
753
+ const measureContents = measures.map((m, mi) => {
754
+ const content = m[vi] || 's1'; // Space rest if no content
755
+ return ` ${content} | % ${mi + 1}`;
756
+ });
757
+ voiceLines.push(` \\new Voice \\relative c' {\n${measureContents.join('\n')}\n }`);
758
+ }
759
+
760
+ staffStrings.push(` \\new Staff = "${si}" <<\n${voiceLines.join('\n')}\n >>`);
761
+ }
762
+
763
+ const musicContent = staffStrings.join('\n');
764
+
765
+ // Build header
766
+ const headerContent = doc.metadata ? encodeMetadata(doc.metadata) : ' tagline = ##f';
767
+
768
+ // Build document
769
+ const paperWidth = typeof opts.paper?.width === 'number' ? `${opts.paper.width}\\mm` : opts.paper?.width || '210\\mm';
770
+ const paperHeight = typeof opts.paper?.height === 'number' ? `${opts.paper.height}\\mm` : opts.paper?.height || '297\\mm';
771
+
772
+ const lyDoc = `\\version "2.24.0"
773
+
774
+ \\language "english"
775
+
776
+ \\header {
777
+ ${headerContent}
778
+ }
779
+
780
+ #(set-global-staff-size ${opts.fontSize})
781
+
782
+ \\paper {
783
+ paper-width = ${paperWidth}
784
+ paper-height = ${paperHeight}
785
+ ragged-last = ##t
786
+ ragged-last-bottom = ##f
787
+ }
788
+
789
+ \\layout {
790
+ \\context {
791
+ \\Score
792
+ autoBeaming = ##${opts.autoBeaming ? 't' : 'f'}
793
+ }
794
+ }
795
+
796
+ \\score {
797
+ \\new GrandStaff <<
798
+ ${musicContent}
799
+ >>
800
+
801
+ \\layout { }${opts.withMIDI ? '\n \\midi { }' : ''}
802
+ }
803
+ `;
804
+
805
+ return lyDoc;
806
+ };
807
+
808
+
809
+ /**
810
+ * Encode LilyletDoc to minimal LilyPond (music content only, no headers)
811
+ */
812
+ export const encodeMinimal = (doc: LilyletDoc): string => {
813
+ const parts: string[] = [];
814
+
815
+ for (const measure of doc.measures) {
816
+ for (const part of measure.parts) {
817
+ for (const voice of part.voices) {
818
+ const content = encodeVoice(voice, { isFirst: false }, 0);
819
+ parts.push(content);
820
+ }
821
+ }
822
+ parts.push('|');
823
+ }
824
+
825
+ return parts.join(' ');
826
+ };
827
+
828
+
829
+ export default {
830
+ encode,
831
+ encodeMinimal,
832
+ };