@k-l-lambda/lilylet 0.1.63 → 0.1.65

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.
Files changed (104) hide show
  1. package/lib/lilylet/grammar.jison.js +1 -10
  2. package/lib/lilylet/meiEncoder.js +58 -40
  3. package/lib/source/abc/abc.d.ts +102 -0
  4. package/lib/source/abc/abc.js +25 -0
  5. package/lib/source/abc/parser.d.ts +3 -0
  6. package/lib/source/abc/parser.js +6 -0
  7. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  8. package/lib/source/lilylet/abcDecoder.js +1035 -0
  9. package/lib/source/lilylet/index.d.ts +10 -0
  10. package/lib/source/lilylet/index.js +10 -0
  11. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  12. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  13. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  14. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  15. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  16. package/lib/source/lilylet/meiEncoder.js +1985 -0
  17. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  18. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  19. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  20. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  21. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  22. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  23. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  24. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  25. package/lib/source/lilylet/parser.d.ts +14 -0
  26. package/lib/source/lilylet/parser.js +161 -0
  27. package/lib/source/lilylet/serializer.d.ts +11 -0
  28. package/lib/source/lilylet/serializer.js +791 -0
  29. package/lib/source/lilylet/types.d.ts +253 -0
  30. package/lib/source/lilylet/types.js +100 -0
  31. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  32. package/lib/tests/abc-abcjs-parse.js +90 -0
  33. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  34. package/lib/tests/abc-abcjs-svg.js +143 -0
  35. package/lib/tests/abc-decoder.d.ts +1 -0
  36. package/lib/tests/abc-decoder.js +67 -0
  37. package/lib/tests/abc-mei-compare.d.ts +1 -0
  38. package/lib/tests/abc-mei-compare.js +525 -0
  39. package/lib/tests/auto-beam.d.ts +9 -0
  40. package/lib/tests/auto-beam.js +151 -0
  41. package/lib/tests/computeMeiHashes.d.ts +1 -0
  42. package/lib/tests/computeMeiHashes.js +87 -0
  43. package/lib/tests/encoder-mutation.d.ts +9 -0
  44. package/lib/tests/encoder-mutation.js +110 -0
  45. package/lib/tests/gpt-review-issues.d.ts +5 -0
  46. package/lib/tests/gpt-review-issues.js +255 -0
  47. package/lib/tests/json-to-lyl.d.ts +1 -0
  48. package/lib/tests/json-to-lyl.js +18 -0
  49. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  50. package/lib/tests/lilypond-roundtrip.js +558 -0
  51. package/lib/tests/lilypondDecoder.d.ts +6 -0
  52. package/lib/tests/lilypondDecoder.js +95 -0
  53. package/lib/tests/ly-to-lyl.d.ts +1 -0
  54. package/lib/tests/ly-to-lyl.js +12 -0
  55. package/lib/tests/mei.d.ts +1 -0
  56. package/lib/tests/mei.js +278 -0
  57. package/lib/tests/musicxml-decoder.d.ts +4 -0
  58. package/lib/tests/musicxml-decoder.js +61 -0
  59. package/lib/tests/musicxml-detail.d.ts +4 -0
  60. package/lib/tests/musicxml-detail.js +85 -0
  61. package/lib/tests/musicxml-fprod.d.ts +9 -0
  62. package/lib/tests/musicxml-fprod.js +153 -0
  63. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  64. package/lib/tests/musicxml-roundtrip.js +296 -0
  65. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  66. package/lib/tests/musicxml-to-mei.js +115 -0
  67. package/lib/tests/parser.d.ts +1 -0
  68. package/lib/tests/parser.js +17 -0
  69. package/lib/tests/render-k283.d.ts +1 -0
  70. package/lib/tests/render-k283.js +33 -0
  71. package/lib/tests/render-lyl.d.ts +1 -0
  72. package/lib/tests/render-lyl.js +35 -0
  73. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  74. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  75. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  76. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  77. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  78. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  79. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  80. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  81. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  82. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  83. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  84. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  85. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  86. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  87. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  88. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  89. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  90. package/lib/tests/unit/partialWarning.test.js +65 -0
  91. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  92. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  93. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  94. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  95. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  96. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  97. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  98. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  99. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  100. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  101. package/package.json +1 -1
  102. package/source/lilylet/grammar.jison.js +1 -10
  103. package/source/lilylet/lilylet.jison +1 -10
  104. package/source/lilylet/meiEncoder.ts +65 -40
@@ -0,0 +1,893 @@
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
+ // === Helper Functions ===
106
+ /**
107
+ * Generate a spacer rest that fills a measure based on time signature.
108
+ * Uses multiplication syntax: s{denominator}*{numerator}
109
+ * @param timeSig - Time signature { numerator, denominator }
110
+ * @returns LilyPond spacer rest string (e.g., "s4*3" for 3/4, "s8*6" for 6/8)
111
+ */
112
+ const getSpacerRest = (timeSig) => {
113
+ if (!timeSig)
114
+ return 's1';
115
+ const { numerator, denominator } = timeSig;
116
+ return `s${denominator}*${numerator}`;
117
+ };
118
+ // === Partial Measure Helpers ===
119
+ const TPQN = 480;
120
+ const voiceDurationTicks = (voice) => {
121
+ let ticks = 0;
122
+ const addEvent = (e, factor = 1) => {
123
+ if (e.type === 'note' || e.type === 'rest') {
124
+ const ev = e;
125
+ if (ev.grace)
126
+ return;
127
+ let t = (TPQN * 4) / ev.duration.division;
128
+ let dot = t / 2;
129
+ for (let i = 0; i < ev.duration.dots; i++) {
130
+ t += dot;
131
+ dot /= 2;
132
+ }
133
+ ticks += Math.round(t * factor);
134
+ }
135
+ else if (e.type === 'tuplet' || e.type === 'times') {
136
+ const te = e;
137
+ const f = factor * te.ratio.numerator / te.ratio.denominator;
138
+ for (const inner of te.events)
139
+ addEvent(inner, f);
140
+ }
141
+ };
142
+ for (const e of voice.events)
143
+ addEvent(e);
144
+ return ticks;
145
+ };
146
+ const ticksToLyDuration = (ticks) => {
147
+ const whole = TPQN * 4;
148
+ for (const div of [1, 2, 4, 8, 16, 32, 64]) {
149
+ const t = whole / div;
150
+ if (Math.abs(ticks - t) < 1)
151
+ return String(div);
152
+ if (Math.abs(ticks - Math.round(t * 1.5)) < 1)
153
+ return `${div}.`;
154
+ if (Math.abs(ticks - Math.round(t * 1.75)) < 1)
155
+ return `${div}..`;
156
+ }
157
+ return '4';
158
+ };
159
+ // Return a spacer-rest suffix to pad a voice to the full measure duration.
160
+ // Uses s16*N to avoid complexity with dotted/compound values.
161
+ const padVoiceToMeasure = (voiceTicks, measureTicks) => {
162
+ const remaining = Math.round(measureTicks - voiceTicks);
163
+ if (remaining <= 0)
164
+ return '';
165
+ const sixteenth = Math.round(TPQN / 4); // 120 ticks
166
+ const numSixteenths = Math.round(remaining / sixteenth);
167
+ if (numSixteenths <= 0)
168
+ return '';
169
+ return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
170
+ };
171
+ /**
172
+ * Calculate the octave markers needed to serialize a pitch in relative mode.
173
+ */
174
+ const getRelativeOctaveMarkers = (env, pitch) => {
175
+ const step = PHONETS.indexOf(pitch.phonet);
176
+ if (step === -1) {
177
+ return { markers: '', newEnv: env };
178
+ }
179
+ const interval = step - env.step;
180
+ // Parser's octave adjustment calculation
181
+ const octInc = Math.floor(Math.abs(interval) / 4) * -Math.sign(interval);
182
+ // Without any markers, parser would calculate:
183
+ const baseOctave = env.octave + octInc;
184
+ // We need markers to reach pitch.octave from baseOctave
185
+ const markerCount = pitch.octave - baseOctave;
186
+ let markers = '';
187
+ if (markerCount > 0) {
188
+ markers = "'".repeat(markerCount);
189
+ }
190
+ else if (markerCount < 0) {
191
+ markers = ",".repeat(-markerCount);
192
+ }
193
+ // Update environment
194
+ const newEnv = {
195
+ step: step,
196
+ octave: pitch.octave
197
+ };
198
+ return { markers, newEnv };
199
+ };
200
+ const DEFAULT_OPTIONS = {
201
+ paper: { width: 210, height: 297 }, // A4 size in mm
202
+ fontSize: 20,
203
+ withMIDI: false,
204
+ autoBeaming: false,
205
+ };
206
+ // === Encoding Functions ===
207
+ /**
208
+ * Encode key signature to LilyPond
209
+ */
210
+ const encodeKey = (key) => {
211
+ let keyStr = key.pitch;
212
+ if (key.accidental) {
213
+ keyStr += KEY_ACCIDENTAL_MAP[key.accidental] || '';
214
+ }
215
+ return `\\key ${keyStr} \\${key.mode}`;
216
+ };
217
+ /**
218
+ * Encode time signature to LilyPond
219
+ */
220
+ const encodeTimeSig = (timeSig) => {
221
+ if (timeSig.symbol === 'common') {
222
+ return "\\time 4/4"; // LilyPond handles C automatically
223
+ }
224
+ if (timeSig.symbol === 'cut') {
225
+ return "\\time 2/2"; // LilyPond handles C| automatically
226
+ }
227
+ return `\\time ${timeSig.numerator}/${timeSig.denominator}`;
228
+ };
229
+ /**
230
+ * Encode clef to LilyPond
231
+ */
232
+ const encodeClef = (clef) => {
233
+ return `\\clef ${CLEF_MAP[clef] || clef}`;
234
+ };
235
+ /**
236
+ * Encode tempo to LilyPond
237
+ */
238
+ const encodeTempo = (tempo) => {
239
+ let result = "\\tempo";
240
+ if (tempo.text) {
241
+ result += ` "${tempo.text}"`;
242
+ }
243
+ if (tempo.beat && tempo.bpm) {
244
+ const beatValue = tempo.beat.division;
245
+ let dots = "";
246
+ if (tempo.beat.dots) {
247
+ dots = ".".repeat(tempo.beat.dots);
248
+ }
249
+ result += ` ${beatValue}${dots} = ${tempo.bpm}`;
250
+ }
251
+ return result;
252
+ };
253
+ /**
254
+ * Encode a single pitch in relative mode
255
+ */
256
+ const encodePitch = (pitch, env) => {
257
+ let result = pitch.phonet;
258
+ // Add accidental
259
+ if (pitch.accidental && pitch.accidental !== Accidental.natural) {
260
+ result += ACCIDENTAL_MAP[pitch.accidental] || '';
261
+ }
262
+ else if (pitch.accidental === Accidental.natural) {
263
+ result += '!';
264
+ }
265
+ // Calculate relative octave markers
266
+ const { markers, newEnv } = getRelativeOctaveMarkers(env, pitch);
267
+ result += markers;
268
+ return { str: result, newEnv };
269
+ };
270
+ /**
271
+ * Encode duration to LilyPond
272
+ */
273
+ const encodeDuration = (duration) => {
274
+ let result = String(duration.division);
275
+ if (duration.dots) {
276
+ result += ".".repeat(duration.dots);
277
+ }
278
+ return result;
279
+ };
280
+ /**
281
+ * Encode marks (articulations, dynamics, etc.) to LilyPond
282
+ */
283
+ const encodeMarks = (marks) => {
284
+ let result = '';
285
+ for (const mark of marks) {
286
+ switch (mark.markType) {
287
+ case 'articulation':
288
+ result += ARTICULATION_MAP[mark.type] || '';
289
+ break;
290
+ case 'ornament':
291
+ result += ORNAMENT_MAP[mark.type] || '';
292
+ break;
293
+ case 'dynamic':
294
+ result += DYNAMIC_MAP[mark.type] || '';
295
+ break;
296
+ case 'hairpin':
297
+ result += HAIRPIN_MAP[mark.type] || '';
298
+ break;
299
+ case 'tie':
300
+ if (mark.start) {
301
+ result += '~';
302
+ }
303
+ break;
304
+ case 'slur':
305
+ result += mark.start ? '(' : ')';
306
+ break;
307
+ case 'beam':
308
+ result += mark.start ? '[' : ']';
309
+ break;
310
+ case 'pedal':
311
+ result += PEDAL_MAP[mark.type] || '';
312
+ break;
313
+ case 'fingering':
314
+ result += `-${mark.finger}`;
315
+ break;
316
+ case 'navigation':
317
+ if (mark.type === 'coda') {
318
+ result += '\\coda';
319
+ }
320
+ else if (mark.type === 'segno') {
321
+ result += '\\segno';
322
+ }
323
+ break;
324
+ case 'markup':
325
+ const placement = mark.placement === 'below' ? '_' : '^';
326
+ result += `${placement}\\markup { ${mark.content} }`;
327
+ break;
328
+ }
329
+ }
330
+ return result;
331
+ };
332
+ /**
333
+ * Encode a note event
334
+ */
335
+ const encodeNoteEvent = (event, env, lastDuration) => {
336
+ let result = '';
337
+ let newEnv = env;
338
+ // Grace note
339
+ if (event.grace) {
340
+ result += '\\grace ';
341
+ }
342
+ // Stem direction
343
+ if (event.stemDirection) {
344
+ result += STEM_MAP[event.stemDirection] + ' ';
345
+ }
346
+ // Pitches (chord or single note)
347
+ if (event.pitches.length > 1) {
348
+ result += '<';
349
+ const pitchStrs = [];
350
+ let firstPitchEnv;
351
+ for (let i = 0; i < event.pitches.length; i++) {
352
+ // In LilyPond relative mode, each pitch in a chord is relative
353
+ // to the previous pitch in the chord (cascading).
354
+ // After the chord, env becomes the first pitch.
355
+ const { str, newEnv: ne } = encodePitch(event.pitches[i], newEnv);
356
+ pitchStrs.push(str);
357
+ newEnv = ne;
358
+ if (i === 0)
359
+ firstPitchEnv = ne;
360
+ }
361
+ // After chord, reference resets to first pitch
362
+ newEnv = firstPitchEnv;
363
+ result += pitchStrs.join(' ');
364
+ result += '>';
365
+ }
366
+ else if (event.pitches.length === 1) {
367
+ const { str, newEnv: ne } = encodePitch(event.pitches[0], newEnv);
368
+ result += str;
369
+ newEnv = ne;
370
+ }
371
+ // Duration (only if different from last)
372
+ const needDuration = !lastDuration ||
373
+ lastDuration.division !== event.duration.division ||
374
+ lastDuration.dots !== event.duration.dots;
375
+ if (needDuration) {
376
+ result += encodeDuration(event.duration);
377
+ }
378
+ // Tremolo
379
+ if (event.tremolo) {
380
+ result += `:${event.tremolo}`;
381
+ }
382
+ // Marks
383
+ if (event.marks) {
384
+ result += encodeMarks(event.marks);
385
+ }
386
+ return { str: result, newEnv, newDuration: event.duration };
387
+ };
388
+ /**
389
+ * Encode a rest event
390
+ */
391
+ const encodeRestEvent = (event, env, lastDuration) => {
392
+ let result = '';
393
+ // Rest type
394
+ if (event.fullMeasure) {
395
+ result += 'R';
396
+ }
397
+ else if (event.invisible) {
398
+ result += 's';
399
+ }
400
+ else {
401
+ result += 'r';
402
+ }
403
+ // Duration
404
+ const needDuration = !lastDuration ||
405
+ lastDuration.division !== event.duration.division ||
406
+ lastDuration.dots !== event.duration.dots;
407
+ if (needDuration) {
408
+ result += encodeDuration(event.duration);
409
+ }
410
+ // Positioned rest
411
+ let newEnv = env;
412
+ if (event.pitch && !event.fullMeasure && !event.invisible) {
413
+ const { str, newEnv: ne } = encodePitch(event.pitch, env);
414
+ result = str + result.slice(1); // Replace 'r' with pitch
415
+ result += '\\rest';
416
+ newEnv = ne;
417
+ }
418
+ return { str: result, newEnv, newDuration: event.duration };
419
+ };
420
+ /**
421
+ * Encode a context change event
422
+ */
423
+ const encodeContextChange = (event) => {
424
+ const parts = [];
425
+ if (event.key) {
426
+ parts.push(encodeKey(event.key));
427
+ }
428
+ if (event.time) {
429
+ parts.push(encodeTimeSig(event.time));
430
+ }
431
+ if (event.clef) {
432
+ parts.push(encodeClef(event.clef));
433
+ }
434
+ if (event.ottava !== undefined) {
435
+ parts.push(`\\ottava #${event.ottava}`);
436
+ }
437
+ if (event.stemDirection) {
438
+ parts.push(STEM_MAP[event.stemDirection]);
439
+ }
440
+ if (event.tempo) {
441
+ parts.push(encodeTempo(event.tempo));
442
+ }
443
+ if (event.staff) {
444
+ parts.push(`\\change Staff = "${event.staff}"`);
445
+ }
446
+ return parts.join(' ');
447
+ };
448
+ /**
449
+ * Encode a tuplet event
450
+ */
451
+ const encodeTupletEvent = (event, env, lastDuration) => {
452
+ // \times preserves type:"times"; \tuplet is denominator/numerator
453
+ const header = event.type === 'times'
454
+ ? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
455
+ : `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
456
+ let result = `${header} { `;
457
+ let newEnv = env;
458
+ let newDuration = lastDuration;
459
+ for (const subEvent of event.events) {
460
+ if (subEvent.type === 'note') {
461
+ const { str, newEnv: ne, newDuration: nd } = encodeNoteEvent(subEvent, newEnv, newDuration);
462
+ result += str + ' ';
463
+ newEnv = ne;
464
+ newDuration = nd;
465
+ }
466
+ else if (subEvent.type === 'rest') {
467
+ const { str, newEnv: ne, newDuration: nd } = encodeRestEvent(subEvent, newEnv, newDuration);
468
+ result += str + ' ';
469
+ newEnv = ne;
470
+ newDuration = nd;
471
+ }
472
+ else if (subEvent.type === 'context') {
473
+ result += encodeContextChange(subEvent) + ' ';
474
+ }
475
+ }
476
+ result += '}';
477
+ return { str: result, newEnv, newDuration };
478
+ };
479
+ /**
480
+ * Encode a tremolo event
481
+ */
482
+ const encodeTremoloEvent = (event, env) => {
483
+ let newEnv = env;
484
+ // First chord/note
485
+ let pitchA = '';
486
+ if (event.pitchA.length > 1) {
487
+ pitchA += '<';
488
+ const pitchStrs = [];
489
+ let firstPitchEnv;
490
+ for (let i = 0; i < event.pitchA.length; i++) {
491
+ const { str, newEnv: ne } = encodePitch(event.pitchA[i], newEnv);
492
+ pitchStrs.push(str);
493
+ newEnv = ne;
494
+ if (i === 0)
495
+ firstPitchEnv = ne;
496
+ }
497
+ newEnv = firstPitchEnv;
498
+ pitchA += pitchStrs.join(' ');
499
+ pitchA += '>';
500
+ }
501
+ else if (event.pitchA.length === 1) {
502
+ const { str, newEnv: ne } = encodePitch(event.pitchA[0], newEnv);
503
+ pitchA += str;
504
+ newEnv = ne;
505
+ }
506
+ // Second chord/note
507
+ let pitchB = '';
508
+ if (event.pitchB.length > 1) {
509
+ pitchB += '<';
510
+ const pitchStrs = [];
511
+ let firstPitchEnv;
512
+ for (let i = 0; i < event.pitchB.length; i++) {
513
+ const { str, newEnv: ne } = encodePitch(event.pitchB[i], newEnv);
514
+ pitchStrs.push(str);
515
+ newEnv = ne;
516
+ if (i === 0)
517
+ firstPitchEnv = ne;
518
+ }
519
+ newEnv = firstPitchEnv;
520
+ pitchB += pitchStrs.join(' ');
521
+ pitchB += '>';
522
+ }
523
+ else if (event.pitchB.length === 1) {
524
+ const { str, newEnv: ne } = encodePitch(event.pitchB[0], newEnv);
525
+ pitchB += str;
526
+ newEnv = ne;
527
+ }
528
+ const result = `\\repeat tremolo ${event.count} { ${pitchA}${event.division} ${pitchB}${event.division} }`;
529
+ return { str: result, newEnv };
530
+ };
531
+ /**
532
+ * Encode a barline event
533
+ */
534
+ const encodeBarlineEvent = (event) => {
535
+ const style = BARLINE_MAP[event.style] || event.style;
536
+ if (style === '|') {
537
+ return ''; // Default barline, no need to encode
538
+ }
539
+ return `\\bar "${style}"`;
540
+ };
541
+ /**
542
+ * Encode a harmony event (chord symbol)
543
+ * Uses the lilylet extension: \chords "text" (parsed by modified lotus grammar)
544
+ */
545
+ const encodeHarmonyEvent = (event) => {
546
+ return `\\chords "${event.text}"`;
547
+ };
548
+ /**
549
+ * Encode a markup event
550
+ */
551
+ const encodeMarkupEvent = (event) => {
552
+ const placement = event.placement === 'below' ? '_' : '^';
553
+ return `${placement}\\markup { ${event.content} }`;
554
+ };
555
+ /**
556
+ * Encode a voice to LilyPond
557
+ */
558
+ const encodeVoice = (voice, measureContext, voiceIndex) => {
559
+ let result = '';
560
+ let env = { step: 0, octave: 0 }; // Start at middle C
561
+ let lastDuration = null;
562
+ for (const event of voice.events) {
563
+ switch (event.type) {
564
+ case 'note': {
565
+ const { str, newEnv, newDuration } = encodeNoteEvent(event, env, lastDuration);
566
+ result += str + ' ';
567
+ env = newEnv;
568
+ lastDuration = newDuration;
569
+ break;
570
+ }
571
+ case 'rest': {
572
+ const { str, newEnv, newDuration } = encodeRestEvent(event, env, lastDuration);
573
+ result += str + ' ';
574
+ env = newEnv;
575
+ lastDuration = newDuration;
576
+ break;
577
+ }
578
+ case 'context': {
579
+ result += encodeContextChange(event) + ' ';
580
+ break;
581
+ }
582
+ case 'tuplet':
583
+ case 'times': {
584
+ const { str, newEnv, newDuration } = encodeTupletEvent(event, env, lastDuration);
585
+ result += str + ' ';
586
+ env = newEnv;
587
+ lastDuration = newDuration;
588
+ break;
589
+ }
590
+ case 'tremolo': {
591
+ const { str, newEnv } = encodeTremoloEvent(event, env);
592
+ result += str + ' ';
593
+ env = newEnv;
594
+ break;
595
+ }
596
+ case 'barline': {
597
+ const str = encodeBarlineEvent(event);
598
+ if (str) {
599
+ result += str + ' ';
600
+ }
601
+ break;
602
+ }
603
+ case 'harmony': {
604
+ result += encodeHarmonyEvent(event) + ' ';
605
+ break;
606
+ }
607
+ case 'markup': {
608
+ result += encodeMarkupEvent(event) + ' ';
609
+ break;
610
+ }
611
+ case 'pitchReset': {
612
+ // Ignore: each measure already gets its own \relative c' block,
613
+ // and within a measure the LilyPond reference pitch is not reset.
614
+ break;
615
+ }
616
+ }
617
+ }
618
+ return result.trim();
619
+ };
620
+ /**
621
+ * Encode metadata to LilyPond header block
622
+ */
623
+ const encodeMetadata = (metadata) => {
624
+ const entries = [];
625
+ if (metadata.title) {
626
+ entries.push(` title = "${metadata.title}"`);
627
+ }
628
+ if (metadata.subtitle) {
629
+ entries.push(` subtitle = "${metadata.subtitle}"`);
630
+ }
631
+ if (metadata.composer) {
632
+ entries.push(` composer = "${metadata.composer}"`);
633
+ }
634
+ if (metadata.arranger) {
635
+ entries.push(` arranger = "${metadata.arranger}"`);
636
+ }
637
+ if (metadata.lyricist) {
638
+ entries.push(` poet = "${metadata.lyricist}"`);
639
+ }
640
+ if (metadata.opus) {
641
+ entries.push(` opus = "${metadata.opus}"`);
642
+ }
643
+ if (metadata.instrument) {
644
+ entries.push(` instrument = "${metadata.instrument}"`);
645
+ }
646
+ entries.push(' tagline = ##f');
647
+ return entries.join('\n');
648
+ };
649
+ /**
650
+ * Encode a complete LilyletDoc to LilyPond format
651
+ *
652
+ * Structure:
653
+ * - Multiple parts → outer <<>>
654
+ * - Part with multiple staves → GrandStaff
655
+ * - Part with single staff → standalone Staff
656
+ */
657
+ export const encode = (doc, options = {}) => {
658
+ const opts = { ...DEFAULT_OPTIONS, ...options };
659
+ // Filter out trailing empty measures (measures with no musical content)
660
+ const hasMusicContent = (measure) => {
661
+ for (const part of measure.parts) {
662
+ for (const voice of part.voices) {
663
+ for (const event of voice.events) {
664
+ if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
665
+ return true;
666
+ }
667
+ }
668
+ }
669
+ }
670
+ return false;
671
+ };
672
+ // Trim trailing empty measures
673
+ let measureCount = doc.measures.length;
674
+ while (measureCount > 0 && !hasMusicContent(doc.measures[measureCount - 1])) {
675
+ measureCount--;
676
+ }
677
+ const measures = doc.measures.slice(0, measureCount);
678
+ // Determine number of parts from the document
679
+ const partCount = Math.max(...measures.map(m => m.parts.length), 1);
680
+ const partVoices = [];
681
+ for (let pi = 0; pi < partCount; pi++) {
682
+ partVoices.push(new Map());
683
+ }
684
+ // Track time signature for each measure (for spacer rests)
685
+ const measureTimeSigs = [];
686
+ // Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
687
+ const measurePartialDurs = [];
688
+ let currentKey;
689
+ let currentTimeSig;
690
+ for (let mi = 0; mi < measures.length; mi++) {
691
+ const measure = measures[mi];
692
+ // Update context from measure
693
+ if (measure.key)
694
+ currentKey = measure.key;
695
+ if (measure.timeSig)
696
+ currentTimeSig = measure.timeSig;
697
+ // Store time signature for this measure
698
+ measureTimeSigs[mi] = currentTimeSig;
699
+ // Detect partial (pickup) measures and compute their duration.
700
+ // Only the first measure (mi===0) can be an implicit partial;
701
+ // subsequent incomplete measures are NOT treated as partial.
702
+ const isExplicitPartial = measure.partial === true;
703
+ if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
704
+ let maxVoiceTicks = 0;
705
+ for (const part of measure.parts) {
706
+ for (const v of part.voices) {
707
+ maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
708
+ }
709
+ }
710
+ const expectedTicks = currentTimeSig
711
+ ? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
712
+ : TPQN * 4;
713
+ if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
714
+ measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
715
+ }
716
+ else {
717
+ measurePartialDurs[mi] = undefined;
718
+ }
719
+ }
720
+ else {
721
+ measurePartialDurs[mi] = undefined;
722
+ }
723
+ // Process each part
724
+ for (let pi = 0; pi < measure.parts.length; pi++) {
725
+ const part = measure.parts[pi];
726
+ const staffMap = partVoices[pi];
727
+ for (let vi = 0; vi < part.voices.length; vi++) {
728
+ const voice = part.voices[vi];
729
+ const staff = voice.staff || 1;
730
+ if (!staffMap.has(staff)) {
731
+ staffMap.set(staff, []);
732
+ }
733
+ const staffMeasures = staffMap.get(staff);
734
+ // Ensure we have enough measure slots
735
+ while (staffMeasures.length <= mi) {
736
+ staffMeasures.push([]);
737
+ }
738
+ // Encode voice content
739
+ let voiceContent = encodeVoice(voice, {
740
+ key: currentKey,
741
+ timeSig: currentTimeSig,
742
+ isFirst: mi === 0
743
+ }, vi);
744
+ // For non-partial measures, pad incomplete voices to fill the full
745
+ // measure duration. lotus doesn't auto-advance on barlines, so
746
+ // under-full voices cause measure boundary miscounting.
747
+ if (!measurePartialDurs[mi] && currentTimeSig) {
748
+ const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
749
+ const voiceTicks = voiceDurationTicks(voice);
750
+ voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
751
+ }
752
+ staffMeasures[mi].push(voiceContent);
753
+ }
754
+ }
755
+ }
756
+ // Build a staff string (used for both GrandStaff children and standalone Staff)
757
+ // Staff ID format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1") for multi-part decoding
758
+ const buildStaffString = (measures, staffId, indent) => {
759
+ // Find max voices per measure for this staff
760
+ const maxVoices = Math.max(...measures.map(m => m.length), 1);
761
+ // Build voice lines
762
+ const voiceLines = [];
763
+ for (let vi = 0; vi < maxVoices; vi++) {
764
+ let prevTimeSigStr;
765
+ const measureContents = measures.map((m, mi) => {
766
+ // For partial (pickup) measures, use a partial-duration spacer
767
+ const partialDur = measurePartialDurs[mi];
768
+ const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
769
+ let content = m[vi] || spacer;
770
+ // Inject \time if the content lacks it and the time sig changed.
771
+ // lotus processes each voice independently — without \time it
772
+ // defaults to 4/4, miscounting measure boundaries for other meters.
773
+ const ts = measureTimeSigs[mi];
774
+ if (ts) {
775
+ const tsStr = `${ts.numerator}/${ts.denominator}`;
776
+ if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
777
+ content = `${encodeTimeSig(ts)} ${content}`;
778
+ }
779
+ prevTimeSigStr = tsStr;
780
+ }
781
+ // For partial measures, prepend \partial DUR before all other commands
782
+ // so the lotus interpreter correctly tracks the pickup measure boundary.
783
+ if (partialDur && !content.includes('\\partial')) {
784
+ content = `\\partial ${partialDur} ${content}`;
785
+ }
786
+ // Wrap each measure in its own \relative c' to reset pitch context
787
+ return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
788
+ });
789
+ voiceLines.push(`${indent} \\new Voice {\n${measureContents.join('\n')}\n${indent} }`);
790
+ }
791
+ return `${indent}\\new Staff = "${staffId}" <<\n${voiceLines.join('\n')}\n${indent}>>`;
792
+ };
793
+ // Build music content for each part
794
+ const partStrings = [];
795
+ for (let pi = 0; pi < partCount; pi++) {
796
+ const staffMap = partVoices[pi];
797
+ const staffNums = Array.from(staffMap.keys()).sort((a, b) => a - b);
798
+ if (staffNums.length === 0) {
799
+ // Empty part, skip
800
+ continue;
801
+ }
802
+ const partIndex = pi + 1; // 1-based part index
803
+ if (staffNums.length === 1) {
804
+ // Single staff part → standalone Staff
805
+ const staffNum = staffNums[0];
806
+ const measures = staffMap.get(staffNum);
807
+ const staffId = `${partIndex}_${staffNum}`;
808
+ const staffStr = buildStaffString(measures, staffId, ' ');
809
+ partStrings.push(staffStr);
810
+ }
811
+ else {
812
+ // Multiple staves → GrandStaff
813
+ const staffStrings = [];
814
+ for (const staffNum of staffNums) {
815
+ const measures = staffMap.get(staffNum);
816
+ const staffId = `${partIndex}_${staffNum}`;
817
+ const staffStr = buildStaffString(measures, staffId, ' ');
818
+ staffStrings.push(staffStr);
819
+ }
820
+ partStrings.push(` \\new GrandStaff <<\n${staffStrings.join('\n')}\n >>`);
821
+ }
822
+ }
823
+ const musicContent = partStrings.join('\n');
824
+ // Determine outer wrapper
825
+ // - Single part with single staff → just Staff (no outer <<>>)
826
+ // - Single part with multiple staves → GrandStaff (no extra outer <<>>)
827
+ // - Multiple parts → outer <<>>
828
+ let scoreContent;
829
+ if (partCount === 1 && partStrings.length === 1) {
830
+ // Single part - use as-is (already has Staff or GrandStaff)
831
+ scoreContent = musicContent;
832
+ }
833
+ else {
834
+ // Multiple parts - wrap in <<>>
835
+ scoreContent = ` <<\n${musicContent}\n >>`;
836
+ }
837
+ // Build header
838
+ const headerContent = doc.metadata ? encodeMetadata(doc.metadata) : ' tagline = ##f';
839
+ // Build document
840
+ const paperWidth = typeof opts.paper?.width === 'number' ? `${opts.paper.width}\\mm` : opts.paper?.width || '210\\mm';
841
+ const paperHeight = typeof opts.paper?.height === 'number' ? `${opts.paper.height}\\mm` : opts.paper?.height || '297\\mm';
842
+ const lyDoc = `\\version "2.22.0"
843
+
844
+ \\language "english"
845
+
846
+ \\header {
847
+ ${headerContent}
848
+ }
849
+
850
+ #(set-global-staff-size ${opts.fontSize})
851
+
852
+ \\paper {
853
+ paper-width = ${paperWidth}
854
+ paper-height = ${paperHeight}
855
+ ragged-last = ##t
856
+ ragged-last-bottom = ##f
857
+ }
858
+
859
+ \\layout {
860
+ \\context {
861
+ \\Score
862
+ autoBeaming = ##${opts.autoBeaming ? 't' : 'f'}
863
+ }
864
+ }
865
+
866
+ \\score {
867
+ ${scoreContent}
868
+
869
+ \\layout { }${opts.withMIDI ? '\n \\midi { }' : ''}
870
+ }
871
+ `;
872
+ return lyDoc;
873
+ };
874
+ /**
875
+ * Encode LilyletDoc to minimal LilyPond (music content only, no headers)
876
+ */
877
+ export const encodeMinimal = (doc) => {
878
+ const parts = [];
879
+ for (const measure of doc.measures) {
880
+ for (const part of measure.parts) {
881
+ for (const voice of part.voices) {
882
+ const content = encodeVoice(voice, { isFirst: false }, 0);
883
+ parts.push(content);
884
+ }
885
+ }
886
+ parts.push('|');
887
+ }
888
+ return parts.join(' ');
889
+ };
890
+ export default {
891
+ encode,
892
+ encodeMinimal,
893
+ };