@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.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1813 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +702 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +195 -190
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +10 -3
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +284 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +75 -21
- package/source/lilylet/types.ts +1 -0
package/lib/serializer.js
CHANGED
|
@@ -1,650 +1 @@
|
|
|
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
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return parts.join('');
|
|
196
|
-
};
|
|
197
|
-
// Serialize a note event with pitch environment tracking
|
|
198
|
-
const serializeNoteEvent = (event, env, prevDuration) => {
|
|
199
|
-
const parts = [];
|
|
200
|
-
let currentEnv = env;
|
|
201
|
-
// Grace note prefix
|
|
202
|
-
if (event.grace) {
|
|
203
|
-
parts.push('\\grace ');
|
|
204
|
-
}
|
|
205
|
-
// Single note or chord
|
|
206
|
-
if (event.pitches.length === 1) {
|
|
207
|
-
const { str, newEnv } = serializePitchRelative(event.pitches[0], currentEnv);
|
|
208
|
-
parts.push(str);
|
|
209
|
-
currentEnv = newEnv;
|
|
210
|
-
}
|
|
211
|
-
else if (event.pitches.length > 1) {
|
|
212
|
-
// Chord: <c e g>
|
|
213
|
-
// First pitch is relative to previous note, subsequent pitches relative to each other
|
|
214
|
-
const pitchStrs = [];
|
|
215
|
-
const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitches[0], currentEnv);
|
|
216
|
-
pitchStrs.push(firstStr);
|
|
217
|
-
currentEnv = firstEnv;
|
|
218
|
-
// Chord pitches are relative to each other within the chord
|
|
219
|
-
let chordEnv = { ...currentEnv };
|
|
220
|
-
for (let i = 1; i < event.pitches.length; i++) {
|
|
221
|
-
const { str, newEnv } = serializePitchRelative(event.pitches[i], chordEnv);
|
|
222
|
-
pitchStrs.push(str);
|
|
223
|
-
chordEnv = newEnv;
|
|
224
|
-
}
|
|
225
|
-
parts.push('<' + pitchStrs.join(' ') + '>');
|
|
226
|
-
}
|
|
227
|
-
// Duration (only if different from previous or first note)
|
|
228
|
-
const durStr = serializeDuration(event.duration);
|
|
229
|
-
if (!prevDuration ||
|
|
230
|
-
prevDuration.division !== event.duration.division ||
|
|
231
|
-
prevDuration.dots !== event.duration.dots) {
|
|
232
|
-
parts.push(durStr);
|
|
233
|
-
}
|
|
234
|
-
// Tremolo
|
|
235
|
-
if (event.tremolo) {
|
|
236
|
-
parts.push(':' + event.tremolo);
|
|
237
|
-
}
|
|
238
|
-
// Marks
|
|
239
|
-
if (event.marks && event.marks.length > 0) {
|
|
240
|
-
parts.push(serializeMarks(event.marks));
|
|
241
|
-
}
|
|
242
|
-
return { str: parts.join(''), newEnv: currentEnv };
|
|
243
|
-
};
|
|
244
|
-
// Serialize a rest event with pitch environment tracking
|
|
245
|
-
const serializeRestEvent = (event, env, prevDuration) => {
|
|
246
|
-
const parts = [];
|
|
247
|
-
let currentEnv = env;
|
|
248
|
-
let isPitchedRest = false;
|
|
249
|
-
// Full measure rest
|
|
250
|
-
if (event.fullMeasure) {
|
|
251
|
-
parts.push('R');
|
|
252
|
-
}
|
|
253
|
-
// Space rest (invisible)
|
|
254
|
-
else if (event.invisible) {
|
|
255
|
-
parts.push('s');
|
|
256
|
-
}
|
|
257
|
-
// Positioned rest: pitch + duration + \rest
|
|
258
|
-
else if (event.pitch) {
|
|
259
|
-
const { str, newEnv } = serializePitchRelative(event.pitch, currentEnv);
|
|
260
|
-
parts.push(str);
|
|
261
|
-
currentEnv = newEnv;
|
|
262
|
-
isPitchedRest = true;
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
parts.push('r');
|
|
266
|
-
}
|
|
267
|
-
// Duration
|
|
268
|
-
const durStr = serializeDuration(event.duration);
|
|
269
|
-
if (!prevDuration ||
|
|
270
|
-
prevDuration.division !== event.duration.division ||
|
|
271
|
-
prevDuration.dots !== event.duration.dots) {
|
|
272
|
-
parts.push(durStr);
|
|
273
|
-
}
|
|
274
|
-
// \rest mark comes after duration for positioned rests
|
|
275
|
-
if (isPitchedRest) {
|
|
276
|
-
parts.push('\\rest');
|
|
277
|
-
}
|
|
278
|
-
return { str: parts.join(''), newEnv: currentEnv };
|
|
279
|
-
};
|
|
280
|
-
// Serialize a context change
|
|
281
|
-
const serializeContextChange = (event) => {
|
|
282
|
-
const parts = [];
|
|
283
|
-
// Clef
|
|
284
|
-
if (event.clef) {
|
|
285
|
-
parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
|
|
286
|
-
}
|
|
287
|
-
// Key signature
|
|
288
|
-
if (event.key) {
|
|
289
|
-
let keyStr = String(event.key.pitch);
|
|
290
|
-
if (event.key.accidental) {
|
|
291
|
-
keyStr += ACCIDENTAL_MAP[event.key.accidental] || '';
|
|
292
|
-
}
|
|
293
|
-
keyStr += ' \\' + event.key.mode;
|
|
294
|
-
parts.push('\\key ' + keyStr);
|
|
295
|
-
}
|
|
296
|
-
// Time signature
|
|
297
|
-
if (event.time) {
|
|
298
|
-
parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
|
|
299
|
-
}
|
|
300
|
-
// Ottava
|
|
301
|
-
if (event.ottava !== undefined) {
|
|
302
|
-
if (event.ottava === 0) {
|
|
303
|
-
parts.push('\\ottava #0');
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
parts.push('\\ottava #' + event.ottava);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
// Stem direction
|
|
310
|
-
if (event.stemDirection) {
|
|
311
|
-
if (event.stemDirection === StemDirection.up) {
|
|
312
|
-
parts.push('\\stemUp');
|
|
313
|
-
}
|
|
314
|
-
else if (event.stemDirection === StemDirection.down) {
|
|
315
|
-
parts.push('\\stemDown');
|
|
316
|
-
}
|
|
317
|
-
else if (event.stemDirection === StemDirection.auto) {
|
|
318
|
-
parts.push('\\stemNeutral');
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
// Tempo
|
|
322
|
-
if (event.tempo) {
|
|
323
|
-
parts.push(serializeTempo(event.tempo));
|
|
324
|
-
}
|
|
325
|
-
return parts.join(' ');
|
|
326
|
-
};
|
|
327
|
-
// Serialize tempo
|
|
328
|
-
const serializeTempo = (tempo) => {
|
|
329
|
-
const parts = ['\\tempo'];
|
|
330
|
-
if (tempo.text) {
|
|
331
|
-
parts.push('"' + tempo.text + '"');
|
|
332
|
-
}
|
|
333
|
-
if (tempo.beat && tempo.bpm) {
|
|
334
|
-
parts.push(tempo.beat.division + '=' + tempo.bpm);
|
|
335
|
-
}
|
|
336
|
-
return parts.join(' ');
|
|
337
|
-
};
|
|
338
|
-
// Serialize a tuplet event with pitch environment tracking
|
|
339
|
-
const serializeTupletEvent = (event, env) => {
|
|
340
|
-
const parts = [];
|
|
341
|
-
let currentEnv = env;
|
|
342
|
-
// \times numerator/denominator { ... }
|
|
343
|
-
parts.push('\\times ' + event.ratio.numerator + '/' + event.ratio.denominator + ' {');
|
|
344
|
-
let prevDuration;
|
|
345
|
-
for (const e of event.events) {
|
|
346
|
-
if (e.type === 'note') {
|
|
347
|
-
const { str, newEnv } = serializeNoteEvent(e, currentEnv, prevDuration);
|
|
348
|
-
parts.push(' ' + str);
|
|
349
|
-
currentEnv = newEnv;
|
|
350
|
-
prevDuration = e.duration;
|
|
351
|
-
}
|
|
352
|
-
else if (e.type === 'rest') {
|
|
353
|
-
const { str, newEnv } = serializeRestEvent(e, currentEnv, prevDuration);
|
|
354
|
-
parts.push(' ' + str);
|
|
355
|
-
currentEnv = newEnv;
|
|
356
|
-
prevDuration = e.duration;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
parts.push(' }');
|
|
360
|
-
return { str: parts.join(''), newEnv: currentEnv };
|
|
361
|
-
};
|
|
362
|
-
// Serialize a tremolo event with pitch environment tracking
|
|
363
|
-
const serializeTremoloEvent = (event, env) => {
|
|
364
|
-
const parts = [];
|
|
365
|
-
let currentEnv = env;
|
|
366
|
-
// \repeat tremolo count { noteA noteB }
|
|
367
|
-
parts.push('\\repeat tremolo ' + event.count + ' {');
|
|
368
|
-
// First pitch/chord
|
|
369
|
-
if (event.pitchA.length === 1) {
|
|
370
|
-
const { str, newEnv } = serializePitchRelative(event.pitchA[0], currentEnv);
|
|
371
|
-
parts.push(' ' + str + event.division);
|
|
372
|
-
currentEnv = newEnv;
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
const pitchStrs = [];
|
|
376
|
-
const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitchA[0], currentEnv);
|
|
377
|
-
pitchStrs.push(firstStr);
|
|
378
|
-
currentEnv = firstEnv;
|
|
379
|
-
let chordEnv = { ...currentEnv };
|
|
380
|
-
for (let i = 1; i < event.pitchA.length; i++) {
|
|
381
|
-
const { str, newEnv } = serializePitchRelative(event.pitchA[i], chordEnv);
|
|
382
|
-
pitchStrs.push(str);
|
|
383
|
-
chordEnv = newEnv;
|
|
384
|
-
}
|
|
385
|
-
parts.push(' <' + pitchStrs.join(' ') + '>' + event.division);
|
|
386
|
-
}
|
|
387
|
-
// Second pitch/chord
|
|
388
|
-
if (event.pitchB.length === 1) {
|
|
389
|
-
const { str, newEnv } = serializePitchRelative(event.pitchB[0], currentEnv);
|
|
390
|
-
parts.push(' ' + str + event.division);
|
|
391
|
-
currentEnv = newEnv;
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
const pitchStrs = [];
|
|
395
|
-
const { str: firstStr, newEnv: firstEnv } = serializePitchRelative(event.pitchB[0], currentEnv);
|
|
396
|
-
pitchStrs.push(firstStr);
|
|
397
|
-
currentEnv = firstEnv;
|
|
398
|
-
let chordEnv = { ...currentEnv };
|
|
399
|
-
for (let i = 1; i < event.pitchB.length; i++) {
|
|
400
|
-
const { str, newEnv } = serializePitchRelative(event.pitchB[i], chordEnv);
|
|
401
|
-
pitchStrs.push(str);
|
|
402
|
-
chordEnv = newEnv;
|
|
403
|
-
}
|
|
404
|
-
parts.push(' <' + pitchStrs.join(' ') + '>' + event.division);
|
|
405
|
-
}
|
|
406
|
-
parts.push(' }');
|
|
407
|
-
return { str: parts.join(''), newEnv: currentEnv };
|
|
408
|
-
};
|
|
409
|
-
// Serialize a barline event
|
|
410
|
-
const serializeBarlineEvent = (event) => {
|
|
411
|
-
// Only output non-default barlines
|
|
412
|
-
if (event.style && event.style !== '|') {
|
|
413
|
-
return '\\bar "' + event.style + '"';
|
|
414
|
-
}
|
|
415
|
-
return '';
|
|
416
|
-
};
|
|
417
|
-
// Serialize a single event with pitch environment tracking
|
|
418
|
-
const serializeEvent = (event, env, prevDuration) => {
|
|
419
|
-
switch (event.type) {
|
|
420
|
-
case 'note':
|
|
421
|
-
return serializeNoteEvent(event, env, prevDuration);
|
|
422
|
-
case 'rest':
|
|
423
|
-
return serializeRestEvent(event, env, prevDuration);
|
|
424
|
-
case 'context':
|
|
425
|
-
return { str: serializeContextChange(event), newEnv: env };
|
|
426
|
-
case 'tuplet':
|
|
427
|
-
return serializeTupletEvent(event, env);
|
|
428
|
-
case 'tremolo':
|
|
429
|
-
return serializeTremoloEvent(event, env);
|
|
430
|
-
case 'barline':
|
|
431
|
-
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
432
|
-
default:
|
|
433
|
-
return { str: '', newEnv: env };
|
|
434
|
-
}
|
|
435
|
-
};
|
|
436
|
-
// Find first clef in voice events
|
|
437
|
-
const findVoiceClef = (voice) => {
|
|
438
|
-
for (const event of voice.events) {
|
|
439
|
-
if (event.type === 'context') {
|
|
440
|
-
const ctx = event;
|
|
441
|
-
if (ctx.clef) {
|
|
442
|
-
return ctx.clef;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
return undefined;
|
|
447
|
-
};
|
|
448
|
-
// Serialize a voice with pitch environment tracking
|
|
449
|
-
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
450
|
-
// If isGrandStaff is true, always output \staff command for clarity
|
|
451
|
-
// measureContext provides key/time for first voice
|
|
452
|
-
// staffClef is the clef for this voice's staff (tracked across measures)
|
|
453
|
-
const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, staffClef) => {
|
|
454
|
-
const parts = [];
|
|
455
|
-
let prevDuration;
|
|
456
|
-
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
457
|
-
let pitchEnv = { step: 0, octave: 0 };
|
|
458
|
-
// Output staff command if voice staff differs from current parser staff,
|
|
459
|
-
// or always output if it's a grand staff score for clarity
|
|
460
|
-
if (isGrandStaff || voice.staff !== currentStaff) {
|
|
461
|
-
parts.push('\\staff "' + voice.staff + '"');
|
|
462
|
-
}
|
|
463
|
-
// Output key/time signatures after \staff (for first voice only)
|
|
464
|
-
if (measureContext && isFirstVoice) {
|
|
465
|
-
if (measureContext.key) {
|
|
466
|
-
let keyStr = String(measureContext.key.pitch);
|
|
467
|
-
if (measureContext.key.accidental) {
|
|
468
|
-
keyStr += ACCIDENTAL_MAP[measureContext.key.accidental] || '';
|
|
469
|
-
}
|
|
470
|
-
keyStr += ' \\' + measureContext.key.mode;
|
|
471
|
-
parts.push('\\key ' + keyStr);
|
|
472
|
-
}
|
|
473
|
-
if (measureContext.time) {
|
|
474
|
-
const { numerator, denominator, symbol } = measureContext.time;
|
|
475
|
-
// Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
|
|
476
|
-
// (meaning numeric display was explicitly requested)
|
|
477
|
-
if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
|
|
478
|
-
parts.push('\\numericTimeSignature');
|
|
479
|
-
}
|
|
480
|
-
parts.push('\\time ' + numerator + '/' + denominator);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
// Output clef for every voice (use staff clef tracked across measures, or find from voice events)
|
|
484
|
-
const voiceClef = staffClef || findVoiceClef(voice);
|
|
485
|
-
if (voiceClef) {
|
|
486
|
-
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
487
|
-
}
|
|
488
|
-
// Track if we've already output the clef to avoid duplication
|
|
489
|
-
let clefOutputted = !!voiceClef;
|
|
490
|
-
for (const event of voice.events) {
|
|
491
|
-
// Skip clef context events if we've already output the clef at the beginning
|
|
492
|
-
if (clefOutputted && event.type === 'context') {
|
|
493
|
-
const ctx = event;
|
|
494
|
-
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
|
|
495
|
-
// This is a clef-only context event, skip it
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
500
|
-
pitchEnv = newEnv;
|
|
501
|
-
if (eventStr) {
|
|
502
|
-
parts.push(eventStr);
|
|
503
|
-
}
|
|
504
|
-
// Track duration for note/rest events
|
|
505
|
-
if (event.type === 'note') {
|
|
506
|
-
prevDuration = event.duration;
|
|
507
|
-
}
|
|
508
|
-
else if (event.type === 'rest') {
|
|
509
|
-
prevDuration = event.duration;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
return { str: parts.join(' '), newStaff: voice.staff };
|
|
513
|
-
};
|
|
514
|
-
// Serialize a part, tracking staff state across voices
|
|
515
|
-
// measureContext is passed to all voices (for clef), but key/time only to first voice
|
|
516
|
-
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff) => {
|
|
517
|
-
if (part.voices.length === 0) {
|
|
518
|
-
return { str: '', newStaff: currentStaff };
|
|
519
|
-
}
|
|
520
|
-
const voiceStrs = [];
|
|
521
|
-
let staff = currentStaff;
|
|
522
|
-
for (let i = 0; i < part.voices.length; i++) {
|
|
523
|
-
const voice = part.voices[i];
|
|
524
|
-
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
525
|
-
// Pass staff clef from clefsByStaff map
|
|
526
|
-
const isFirstVoice = isFirstPart && i === 0;
|
|
527
|
-
const staffClef = clefsByStaff?.[voice.staff];
|
|
528
|
-
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
529
|
-
voiceStrs.push(str);
|
|
530
|
-
staff = newStaff;
|
|
531
|
-
}
|
|
532
|
-
// Multiple voices: separated by \\ with newline
|
|
533
|
-
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
534
|
-
};
|
|
535
|
-
// Serialize a measure, tracking staff state across parts
|
|
536
|
-
// Always output key/time at start of each measure
|
|
537
|
-
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
|
|
538
|
-
const parts = [];
|
|
539
|
-
// Build measure context for all voices (key/time)
|
|
540
|
-
// Key and time are written to first voice, clef to all voices based on staff
|
|
541
|
-
// Use passed currentKey/currentTime which tracks across all measures
|
|
542
|
-
const measureContext = {
|
|
543
|
-
key: currentKey,
|
|
544
|
-
time: currentTime,
|
|
545
|
-
};
|
|
546
|
-
// Pass staffClefs to parts for per-voice clef lookup
|
|
547
|
-
const clefsByStaff = staffClefs || {};
|
|
548
|
-
// Parts
|
|
549
|
-
let staff = currentStaff;
|
|
550
|
-
if (measure.parts.length === 1) {
|
|
551
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
552
|
-
if (partStr) {
|
|
553
|
-
parts.push(partStr);
|
|
554
|
-
}
|
|
555
|
-
staff = newStaff;
|
|
556
|
-
}
|
|
557
|
-
else if (measure.parts.length > 1) {
|
|
558
|
-
// Multiple parts: separated by \\\ with newline
|
|
559
|
-
const partStrs = [];
|
|
560
|
-
for (let i = 0; i < measure.parts.length; i++) {
|
|
561
|
-
const part = measure.parts[i];
|
|
562
|
-
// Pass measureContext to all parts, isFirstPart to first part only
|
|
563
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
564
|
-
if (str) {
|
|
565
|
-
partStrs.push(str);
|
|
566
|
-
}
|
|
567
|
-
staff = newStaff;
|
|
568
|
-
}
|
|
569
|
-
parts.push(partStrs.join(' \\\\\\\\\n'));
|
|
570
|
-
}
|
|
571
|
-
return { str: parts.join(' '), newStaff: staff };
|
|
572
|
-
};
|
|
573
|
-
// Escape string for serialization (quotes and backslashes)
|
|
574
|
-
const escapeString = (str) => {
|
|
575
|
-
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
576
|
-
};
|
|
577
|
-
// Serialize metadata
|
|
578
|
-
const serializeMetadata = (metadata) => {
|
|
579
|
-
const lines = [];
|
|
580
|
-
if (metadata.title) {
|
|
581
|
-
lines.push('[title "' + escapeString(metadata.title) + '"]');
|
|
582
|
-
}
|
|
583
|
-
if (metadata.subtitle) {
|
|
584
|
-
lines.push('[subtitle "' + escapeString(metadata.subtitle) + '"]');
|
|
585
|
-
}
|
|
586
|
-
if (metadata.composer) {
|
|
587
|
-
lines.push('[composer "' + escapeString(metadata.composer) + '"]');
|
|
588
|
-
}
|
|
589
|
-
if (metadata.arranger) {
|
|
590
|
-
lines.push('[arranger "' + escapeString(metadata.arranger) + '"]');
|
|
591
|
-
}
|
|
592
|
-
if (metadata.lyricist) {
|
|
593
|
-
lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
|
|
594
|
-
}
|
|
595
|
-
return lines.join('\n');
|
|
596
|
-
};
|
|
597
|
-
/**
|
|
598
|
-
* Serialize a LilyletDoc to Lilylet (.lyl) string format
|
|
599
|
-
*/
|
|
600
|
-
export const serializeLilyletDoc = (doc) => {
|
|
601
|
-
const parts = [];
|
|
602
|
-
// Metadata
|
|
603
|
-
if (doc.metadata) {
|
|
604
|
-
const metaStr = serializeMetadata(doc.metadata);
|
|
605
|
-
if (metaStr) {
|
|
606
|
-
parts.push(metaStr);
|
|
607
|
-
parts.push('');
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
// Detect grand staff: check if any voice has staff > 1
|
|
611
|
-
const isGrandStaff = doc.measures.some(m => m.parts.some(p => p.voices.some(v => v.staff > 1)));
|
|
612
|
-
// Measures with bar lines, measure numbers, and double newlines
|
|
613
|
-
// Track staff state across measures (parser remembers staff across bar lines)
|
|
614
|
-
// Track key/time/clef across measures to output in every measure
|
|
615
|
-
const measureStrs = [];
|
|
616
|
-
let currentStaff = 1; // Parser starts at staff 1
|
|
617
|
-
let currentKey;
|
|
618
|
-
let currentTime;
|
|
619
|
-
const staffClefs = {}; // Track clef per staff
|
|
620
|
-
for (let i = 0; i < doc.measures.length; i++) {
|
|
621
|
-
const measure = doc.measures[i];
|
|
622
|
-
// Update current key/time if measure has them
|
|
623
|
-
if (measure.key) {
|
|
624
|
-
currentKey = measure.key;
|
|
625
|
-
}
|
|
626
|
-
if (measure.timeSig) {
|
|
627
|
-
currentTime = measure.timeSig;
|
|
628
|
-
}
|
|
629
|
-
// Collect clefs from this measure's voices
|
|
630
|
-
for (const part of measure.parts) {
|
|
631
|
-
for (const voice of part.voices) {
|
|
632
|
-
for (const event of voice.events) {
|
|
633
|
-
if (event.type === 'context' && event.clef) {
|
|
634
|
-
staffClefs[voice.staff] = event.clef;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
640
|
-
// Always include measure, even if empty (use space rest for empty measures)
|
|
641
|
-
measureStrs.push(measureStr || 's1');
|
|
642
|
-
currentStaff = newStaff;
|
|
643
|
-
}
|
|
644
|
-
// Join measures with bar, measure number comment, and double newline
|
|
645
|
-
const measuresOutput = measureStrs
|
|
646
|
-
.map((m, i) => m + ' | %' + (i + 1))
|
|
647
|
-
.join('\n\n');
|
|
648
|
-
parts.push(measuresOutput);
|
|
649
|
-
return parts.join('\n');
|
|
650
|
-
};
|
|
1
|
+
export * from "./lilylet/serializer.js";
|