@k-l-lambda/lilylet 0.1.35 → 0.1.37

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