@k-l-lambda/lilylet 0.1.70 → 0.1.72

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