@k-l-lambda/lilylet 0.1.49 → 0.1.51

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