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