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