@k-l-lambda/lilylet 0.1.30 → 0.1.32
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/grammar.jison.js +87 -87
- package/lib/lilypondDecoder.d.ts +28 -0
- package/lib/lilypondDecoder.js +645 -0
- package/lib/meiEncoder.js +17 -3
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +87 -87
- package/source/lilylet/lilylet.jison +6 -1
- package/source/lilylet/meiEncoder.ts +15 -5
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LilyPond to Lilylet Decoder
|
|
3
|
+
*
|
|
4
|
+
* Converts LilyPond notation files to Lilylet document format using the lotus parser.
|
|
5
|
+
* This module is browser-compatible - it uses pre-compiled parser from lotus.
|
|
6
|
+
*/
|
|
7
|
+
// Import directly from the compiled lib directory to avoid ESM issues
|
|
8
|
+
import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
|
|
9
|
+
// Import pre-compiled LilyPond parser (browser-compatible)
|
|
10
|
+
// @ts-ignore - CommonJS module
|
|
11
|
+
import * as lilypondParser from "@k-l-lambda/lotus/lib.browser/lib/lilyParser.js";
|
|
12
|
+
import { Clef, StemDirection, Accidental, Phonet, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, Placement, } from "./types.js";
|
|
13
|
+
// Phonet names mapping
|
|
14
|
+
const PHONET_NAMES = {
|
|
15
|
+
0: Phonet.c,
|
|
16
|
+
1: Phonet.d,
|
|
17
|
+
2: Phonet.e,
|
|
18
|
+
3: Phonet.f,
|
|
19
|
+
4: Phonet.g,
|
|
20
|
+
5: Phonet.a,
|
|
21
|
+
6: Phonet.b,
|
|
22
|
+
};
|
|
23
|
+
// Alter value to accidental
|
|
24
|
+
const ALTER_TO_ACCIDENTAL = {
|
|
25
|
+
[-2]: Accidental.doubleFlat,
|
|
26
|
+
[-1]: Accidental.flat,
|
|
27
|
+
[0]: Accidental.natural,
|
|
28
|
+
[1]: Accidental.sharp,
|
|
29
|
+
[2]: Accidental.doubleSharp,
|
|
30
|
+
};
|
|
31
|
+
// LilyPond clef names to Lilylet clef
|
|
32
|
+
const LILYPOND_CLEF_MAP = {
|
|
33
|
+
treble: Clef.treble,
|
|
34
|
+
G: Clef.treble,
|
|
35
|
+
bass: Clef.bass,
|
|
36
|
+
F: Clef.bass,
|
|
37
|
+
alto: Clef.alto,
|
|
38
|
+
C: Clef.alto,
|
|
39
|
+
};
|
|
40
|
+
// Key signature fifths to pitch/accidental mapping
|
|
41
|
+
const KEY_FIFTHS_MAP = {
|
|
42
|
+
[-7]: { pitch: Phonet.c, accidental: Accidental.flat, mode: 'major' },
|
|
43
|
+
[-6]: { pitch: Phonet.g, accidental: Accidental.flat, mode: 'major' },
|
|
44
|
+
[-5]: { pitch: Phonet.d, accidental: Accidental.flat, mode: 'major' },
|
|
45
|
+
[-4]: { pitch: Phonet.a, accidental: Accidental.flat, mode: 'major' },
|
|
46
|
+
[-3]: { pitch: Phonet.e, accidental: Accidental.flat, mode: 'major' },
|
|
47
|
+
[-2]: { pitch: Phonet.b, accidental: Accidental.flat, mode: 'major' },
|
|
48
|
+
[-1]: { pitch: Phonet.f, mode: 'major' },
|
|
49
|
+
[0]: { pitch: Phonet.c, mode: 'major' },
|
|
50
|
+
[1]: { pitch: Phonet.g, mode: 'major' },
|
|
51
|
+
[2]: { pitch: Phonet.d, mode: 'major' },
|
|
52
|
+
[3]: { pitch: Phonet.a, mode: 'major' },
|
|
53
|
+
[4]: { pitch: Phonet.e, mode: 'major' },
|
|
54
|
+
[5]: { pitch: Phonet.b, mode: 'major' },
|
|
55
|
+
[6]: { pitch: Phonet.f, accidental: Accidental.sharp, mode: 'major' },
|
|
56
|
+
[7]: { pitch: Phonet.c, accidental: Accidental.sharp, mode: 'major' },
|
|
57
|
+
};
|
|
58
|
+
// Articulation mapping from LilyPond
|
|
59
|
+
const ARTICULATION_MAP = {
|
|
60
|
+
staccato: ArticulationType.staccato,
|
|
61
|
+
staccatissimo: ArticulationType.staccatissimo,
|
|
62
|
+
tenuto: ArticulationType.tenuto,
|
|
63
|
+
marcato: ArticulationType.marcato,
|
|
64
|
+
accent: ArticulationType.accent,
|
|
65
|
+
portato: ArticulationType.portato,
|
|
66
|
+
};
|
|
67
|
+
// Ornament mapping from LilyPond
|
|
68
|
+
const ORNAMENT_MAP = {
|
|
69
|
+
trill: OrnamentType.trill,
|
|
70
|
+
turn: OrnamentType.turn,
|
|
71
|
+
mordent: OrnamentType.mordent,
|
|
72
|
+
prall: OrnamentType.prall,
|
|
73
|
+
fermata: OrnamentType.fermata,
|
|
74
|
+
shortfermata: OrnamentType.shortFermata,
|
|
75
|
+
arpeggio: OrnamentType.arpeggio,
|
|
76
|
+
};
|
|
77
|
+
// Dynamic regex and mapping
|
|
78
|
+
const DYNAMIC_REGEX = /^[fpmrsz]+$/;
|
|
79
|
+
const DYNAMIC_MAP = {
|
|
80
|
+
ppp: DynamicType.ppp,
|
|
81
|
+
pp: DynamicType.pp,
|
|
82
|
+
p: DynamicType.p,
|
|
83
|
+
mp: DynamicType.mp,
|
|
84
|
+
mf: DynamicType.mf,
|
|
85
|
+
f: DynamicType.f,
|
|
86
|
+
ff: DynamicType.ff,
|
|
87
|
+
fff: DynamicType.fff,
|
|
88
|
+
sfz: DynamicType.sfz,
|
|
89
|
+
rfz: DynamicType.rfz,
|
|
90
|
+
};
|
|
91
|
+
// Common tempo text words (from fprod corpus analysis)
|
|
92
|
+
// Single words, lowercase for case-insensitive matching
|
|
93
|
+
const TEMPO_WORDS = new Set([
|
|
94
|
+
// Basic tempo markings (Italian) - very slow to very fast
|
|
95
|
+
'grave', 'largo', 'larghetto', 'lento', 'adagio', 'adagietto',
|
|
96
|
+
'andante', 'andantino', 'moderato', 'allegretto', 'allegro',
|
|
97
|
+
'vivace', 'presto', 'prestissimo',
|
|
98
|
+
// Tempo modifiers
|
|
99
|
+
'molto', 'poco', 'più', 'meno', 'assai', 'con', 'moto', 'brio',
|
|
100
|
+
'ma', 'non', 'troppo', 'cantabile', 'sostenuto', 'espressivo',
|
|
101
|
+
'grazioso', 'maestoso', 'agitato', 'animato', 'tranquillo',
|
|
102
|
+
// Tempo changes
|
|
103
|
+
'tempo', 'primo',
|
|
104
|
+
'rit', 'ritard', 'ritardando', 'riten', 'ritenuto',
|
|
105
|
+
'rall', 'rallentando',
|
|
106
|
+
'accel', 'accelerando',
|
|
107
|
+
'allarg', 'allargando',
|
|
108
|
+
'calando', 'morendo', 'smorzando', 'smorz',
|
|
109
|
+
'rubato',
|
|
110
|
+
]);
|
|
111
|
+
// Check if text contains any tempo-related word (case-insensitive)
|
|
112
|
+
const containsTempoWord = (text) => {
|
|
113
|
+
// Remove punctuation and split into words
|
|
114
|
+
const words = text.toLowerCase().replace(/[.,!?]/g, '').split(/\s+/);
|
|
115
|
+
return words.some(word => TEMPO_WORDS.has(word));
|
|
116
|
+
};
|
|
117
|
+
// Convert lotus Tempo to Lilylet Tempo
|
|
118
|
+
const convertTempo = (lotusTempo) => {
|
|
119
|
+
if (!lotusTempo)
|
|
120
|
+
return undefined;
|
|
121
|
+
const tempo = {};
|
|
122
|
+
// Text (e.g., "Allegro")
|
|
123
|
+
if (lotusTempo.text) {
|
|
124
|
+
tempo.text = typeof lotusTempo.text === 'string'
|
|
125
|
+
? lotusTempo.text
|
|
126
|
+
: extractTextFromObject(lotusTempo.text);
|
|
127
|
+
}
|
|
128
|
+
// Metronome mark (e.g., ♩ = 120)
|
|
129
|
+
if (lotusTempo.beatsPerMinute !== undefined && Number.isFinite(lotusTempo.beatsPerMinute)) {
|
|
130
|
+
tempo.bpm = lotusTempo.beatsPerMinute;
|
|
131
|
+
// Beat unit (note value)
|
|
132
|
+
if (lotusTempo.unit) {
|
|
133
|
+
tempo.beat = convertDuration(lotusTempo.unit);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Return undefined if no meaningful tempo data
|
|
137
|
+
if (!tempo.text && !tempo.bpm) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return tempo;
|
|
141
|
+
};
|
|
142
|
+
// Convert LilyPond pitch to Lilylet pitch
|
|
143
|
+
const convertPitch = (phonetStep, alterValue, octave) => {
|
|
144
|
+
const phonet = PHONET_NAMES[phonetStep % 7];
|
|
145
|
+
const accidental = alterValue !== 0 ? ALTER_TO_ACCIDENTAL[alterValue] : undefined;
|
|
146
|
+
// Lotus parser absolute octave: 0 = C3, 1 = C4, 2 = C5
|
|
147
|
+
// Lilylet octave: 0 = C4, 1 = C5, -1 = C3
|
|
148
|
+
// Conversion: lilyletOctave = lotusAbsoluteOctave - 1
|
|
149
|
+
const lilyletOctave = octave - 1;
|
|
150
|
+
return {
|
|
151
|
+
phonet,
|
|
152
|
+
accidental,
|
|
153
|
+
octave: lilyletOctave,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
// Convert LilyPond duration to Lilylet duration
|
|
157
|
+
const convertDuration = (duration) => {
|
|
158
|
+
return {
|
|
159
|
+
division: Math.pow(2, duration.division),
|
|
160
|
+
dots: duration.dots || 0,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
// Convert key fifths to KeySignature
|
|
164
|
+
const convertKeySignature = (fifths) => {
|
|
165
|
+
const mapping = KEY_FIFTHS_MAP[fifths];
|
|
166
|
+
if (mapping) {
|
|
167
|
+
return {
|
|
168
|
+
pitch: mapping.pitch,
|
|
169
|
+
accidental: mapping.accidental,
|
|
170
|
+
mode: mapping.mode,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
};
|
|
175
|
+
// Parse post-events to marks
|
|
176
|
+
const parsePostEvents = (postEvents) => {
|
|
177
|
+
const marks = [];
|
|
178
|
+
if (!postEvents)
|
|
179
|
+
return marks;
|
|
180
|
+
for (const event of postEvents) {
|
|
181
|
+
// String events
|
|
182
|
+
if (typeof event === 'string') {
|
|
183
|
+
if (event === '~') {
|
|
184
|
+
marks.push({ markType: 'tie', start: true });
|
|
185
|
+
}
|
|
186
|
+
else if (event === '(') {
|
|
187
|
+
marks.push({ markType: 'slur', start: true });
|
|
188
|
+
}
|
|
189
|
+
else if (event === ')') {
|
|
190
|
+
marks.push({ markType: 'slur', start: false });
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// PostEvent objects
|
|
195
|
+
if (event && typeof event === 'object') {
|
|
196
|
+
const arg = event.arg;
|
|
197
|
+
// String articulation/ornament
|
|
198
|
+
if (typeof arg === 'string') {
|
|
199
|
+
const cleanArg = arg.replace(/^-/, '');
|
|
200
|
+
if (ARTICULATION_MAP[cleanArg]) {
|
|
201
|
+
marks.push({ markType: 'articulation', type: ARTICULATION_MAP[cleanArg] });
|
|
202
|
+
}
|
|
203
|
+
else if (ORNAMENT_MAP[cleanArg]) {
|
|
204
|
+
marks.push({ markType: 'ornament', type: ORNAMENT_MAP[cleanArg] });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Fingering (number 1-5)
|
|
208
|
+
if (typeof arg === 'number' && arg >= 1 && arg <= 5) {
|
|
209
|
+
marks.push({ markType: 'fingering', finger: arg });
|
|
210
|
+
}
|
|
211
|
+
// Command (dynamics, hairpins, etc.)
|
|
212
|
+
if (arg && typeof arg === 'object' && 'cmd' in arg) {
|
|
213
|
+
const cmd = arg.cmd;
|
|
214
|
+
if (DYNAMIC_REGEX.test(cmd) && DYNAMIC_MAP[cmd]) {
|
|
215
|
+
marks.push({ markType: 'dynamic', type: DYNAMIC_MAP[cmd] });
|
|
216
|
+
}
|
|
217
|
+
else if (cmd === '<') {
|
|
218
|
+
marks.push({ markType: 'hairpin', type: HairpinType.crescendoStart });
|
|
219
|
+
}
|
|
220
|
+
else if (cmd === '>') {
|
|
221
|
+
marks.push({ markType: 'hairpin', type: HairpinType.diminuendoStart });
|
|
222
|
+
}
|
|
223
|
+
else if (cmd === '!') {
|
|
224
|
+
marks.push({ markType: 'hairpin', type: HairpinType.crescendoEnd }); // or diminuendoEnd
|
|
225
|
+
}
|
|
226
|
+
else if (cmd === 'sustainOn') {
|
|
227
|
+
marks.push({ markType: 'pedal', type: PedalType.sustainOn });
|
|
228
|
+
}
|
|
229
|
+
else if (cmd === 'sustainOff') {
|
|
230
|
+
marks.push({ markType: 'pedal', type: PedalType.sustainOff });
|
|
231
|
+
}
|
|
232
|
+
else if (cmd === 'coda') {
|
|
233
|
+
marks.push({ markType: 'navigation', type: NavigationMarkType.coda });
|
|
234
|
+
}
|
|
235
|
+
else if (cmd === 'segno') {
|
|
236
|
+
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
237
|
+
}
|
|
238
|
+
else if (cmd === '\\markup' || cmd === 'markup') {
|
|
239
|
+
// Markup attached to note
|
|
240
|
+
const text = extractTextFromObject(arg.args);
|
|
241
|
+
if (text && !containsTempoWord(text)) {
|
|
242
|
+
const direction = event.direction;
|
|
243
|
+
const placement = direction === 'up' ? Placement.above :
|
|
244
|
+
direction === 'down' ? Placement.below : undefined;
|
|
245
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Handle markup command directly (proto: 'Command' with \\markup)
|
|
250
|
+
if (arg && typeof arg === 'object' && arg.proto === 'Command' && arg.cmd === '\\markup') {
|
|
251
|
+
const text = extractTextFromObject(arg.args);
|
|
252
|
+
if (text && !containsTempoWord(text)) {
|
|
253
|
+
const direction = event.direction;
|
|
254
|
+
const placement = direction === 'up' ? Placement.above :
|
|
255
|
+
direction === 'down' ? Placement.below : undefined;
|
|
256
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return marks;
|
|
262
|
+
};
|
|
263
|
+
// Parse a LilyPond document to measures
|
|
264
|
+
const parseLilyDocument = (lilyDocument) => {
|
|
265
|
+
const measureMap = new Map();
|
|
266
|
+
const staffNames = [];
|
|
267
|
+
const interpreter = lilyDocument.interpret();
|
|
268
|
+
interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
|
|
269
|
+
const appendStaff = (staffName) => {
|
|
270
|
+
if (!staffNames.includes(staffName)) {
|
|
271
|
+
staffNames.push(staffName);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const staffName = track.contextDict?.Staff;
|
|
275
|
+
if (staffName) {
|
|
276
|
+
appendStaff(staffName);
|
|
277
|
+
}
|
|
278
|
+
let staff = staffName ? staffNames.indexOf(staffName) + 1 : 1;
|
|
279
|
+
const context = new lilyParser.TrackContext(undefined, {
|
|
280
|
+
listener: (term, context) => {
|
|
281
|
+
const mi = term._measure;
|
|
282
|
+
if (mi === undefined)
|
|
283
|
+
return;
|
|
284
|
+
if (!measureMap.has(mi)) {
|
|
285
|
+
measureMap.set(mi, {
|
|
286
|
+
key: null,
|
|
287
|
+
timeSig: null,
|
|
288
|
+
voices: [],
|
|
289
|
+
partial: false,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// Update staff from context
|
|
293
|
+
if (context.staffName) {
|
|
294
|
+
appendStaff(context.staffName);
|
|
295
|
+
staff = staffNames.indexOf(context.staffName) + 1;
|
|
296
|
+
}
|
|
297
|
+
const measure = measureMap.get(mi);
|
|
298
|
+
// Initialize voice for this track
|
|
299
|
+
if (!measure.voices[vi]) {
|
|
300
|
+
measure.voices[vi] = {
|
|
301
|
+
staff,
|
|
302
|
+
events: [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const voice = measure.voices[vi];
|
|
306
|
+
// Update key/time from context on music events
|
|
307
|
+
if (term instanceof lilyParser.MusicEvent ||
|
|
308
|
+
term instanceof lilyParser.LilyTerms.StemDirection ||
|
|
309
|
+
term instanceof lilyParser.LilyTerms.OctaveShift) {
|
|
310
|
+
if (context.key && measure.key === null) {
|
|
311
|
+
measure.key = context.key.key;
|
|
312
|
+
}
|
|
313
|
+
if (context.time && measure.timeSig === null) {
|
|
314
|
+
measure.timeSig = {
|
|
315
|
+
numerator: context.time.value.numerator,
|
|
316
|
+
denominator: context.time.value.denominator,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (context.partialDuration) {
|
|
320
|
+
measure.partial = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Handle music events
|
|
324
|
+
if (term instanceof lilyParser.MusicEvent) {
|
|
325
|
+
// Update staff from voice events
|
|
326
|
+
voice.staff = staff;
|
|
327
|
+
// Handle clef context change
|
|
328
|
+
if (context.clef && !voice.events.some(e => e.type === 'context' && e.clef)) {
|
|
329
|
+
const clef = LILYPOND_CLEF_MAP[context.clef.clefName];
|
|
330
|
+
if (clef) {
|
|
331
|
+
voice.events.push({
|
|
332
|
+
type: 'context',
|
|
333
|
+
clef,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Handle ottava
|
|
338
|
+
if (context.octave?.value && !voice.events.some(e => e.type === 'context' && e.ottava !== undefined)) {
|
|
339
|
+
voice.events.push({
|
|
340
|
+
type: 'context',
|
|
341
|
+
ottava: context.octave.value,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Handle stem direction context
|
|
345
|
+
if (context.stemDirection && !voice.events.some(e => e.type === 'context' && e.stemDirection)) {
|
|
346
|
+
const stemDir = context.stemDirection === 'Up' ? StemDirection.up :
|
|
347
|
+
context.stemDirection === 'Down' ? StemDirection.down : undefined;
|
|
348
|
+
if (stemDir) {
|
|
349
|
+
voice.events.push({
|
|
350
|
+
type: 'context',
|
|
351
|
+
stemDirection: stemDir,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Process Chord (note or chord)
|
|
356
|
+
if (term instanceof lilyParser.LilyTerms.Chord) {
|
|
357
|
+
const pitches = [];
|
|
358
|
+
for (const pitch of term.pitchesValue) {
|
|
359
|
+
if (pitch instanceof lilyParser.LilyTerms.ChordElement) {
|
|
360
|
+
pitches.push(convertPitch(pitch.phonetStep, pitch.alterValue || 0, pitch.absolutePitch.octave));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (pitches.length > 0) {
|
|
364
|
+
const marks = parsePostEvents(term.post_events);
|
|
365
|
+
// Add beam marks
|
|
366
|
+
if (term.beamOn) {
|
|
367
|
+
marks.push({ markType: 'beam', start: true });
|
|
368
|
+
}
|
|
369
|
+
else if (term.beamOff) {
|
|
370
|
+
marks.push({ markType: 'beam', start: false });
|
|
371
|
+
}
|
|
372
|
+
// Add tie
|
|
373
|
+
if (term.isTying) {
|
|
374
|
+
marks.push({ markType: 'tie', start: true });
|
|
375
|
+
}
|
|
376
|
+
const noteEvent = {
|
|
377
|
+
type: 'note',
|
|
378
|
+
pitches,
|
|
379
|
+
duration: convertDuration(term.durationValue),
|
|
380
|
+
grace: context.inGrace || undefined,
|
|
381
|
+
};
|
|
382
|
+
if (marks.length > 0) {
|
|
383
|
+
noteEvent.marks = marks;
|
|
384
|
+
}
|
|
385
|
+
voice.events.push(noteEvent);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Process Rest
|
|
389
|
+
else if (term instanceof lilyParser.LilyTerms.Rest) {
|
|
390
|
+
const restEvent = {
|
|
391
|
+
type: 'rest',
|
|
392
|
+
duration: convertDuration(term.durationValue),
|
|
393
|
+
invisible: term.isSpacer || undefined,
|
|
394
|
+
};
|
|
395
|
+
// Positioned rest
|
|
396
|
+
if (!term.isSpacer && context.pitch) {
|
|
397
|
+
restEvent.pitch = convertPitch(context.pitch.phonetStep, 0, context.pitch.octave);
|
|
398
|
+
}
|
|
399
|
+
voice.events.push(restEvent);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Handle standalone stem direction
|
|
403
|
+
else if (term instanceof lilyParser.LilyTerms.StemDirection) {
|
|
404
|
+
const stemDir = term.direction === 'Up' ? StemDirection.up :
|
|
405
|
+
term.direction === 'Down' ? StemDirection.down : undefined;
|
|
406
|
+
if (stemDir) {
|
|
407
|
+
voice.events.push({
|
|
408
|
+
type: 'context',
|
|
409
|
+
stemDirection: stemDir,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Handle standalone clef
|
|
414
|
+
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
415
|
+
const clef = LILYPOND_CLEF_MAP[term.clefName];
|
|
416
|
+
if (clef) {
|
|
417
|
+
voice.events.push({
|
|
418
|
+
type: 'context',
|
|
419
|
+
clef,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Handle ottava shift
|
|
424
|
+
else if (term instanceof lilyParser.LilyTerms.OctaveShift) {
|
|
425
|
+
voice.events.push({
|
|
426
|
+
type: 'context',
|
|
427
|
+
ottava: term.value,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// Handle staff change
|
|
431
|
+
else if (term instanceof lilyParser.LilyTerms.Change) {
|
|
432
|
+
if (term.args?.[0]?.key === 'Staff') {
|
|
433
|
+
// Staff change mid-voice
|
|
434
|
+
voice.staff = staff;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Handle tempo
|
|
438
|
+
else if (term instanceof lilyParser.LilyTerms.Tempo) {
|
|
439
|
+
const tempo = convertTempo(term);
|
|
440
|
+
if (tempo) {
|
|
441
|
+
voice.events.push({
|
|
442
|
+
type: 'context',
|
|
443
|
+
tempo,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Handle standalone markup command
|
|
448
|
+
else {
|
|
449
|
+
const termAny = term;
|
|
450
|
+
if (termAny.proto === 'Command' && (termAny.cmd === '\\markup' || termAny.cmd === 'markup')) {
|
|
451
|
+
const text = extractTextFromObject(termAny.args);
|
|
452
|
+
if (text && !containsTempoWord(text)) {
|
|
453
|
+
const markupEvent = {
|
|
454
|
+
type: 'markup',
|
|
455
|
+
content: text,
|
|
456
|
+
};
|
|
457
|
+
voice.events.push(markupEvent);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
context.execute(track.music);
|
|
464
|
+
});
|
|
465
|
+
// Filter out empty voices and convert to array, sorted by measure number
|
|
466
|
+
const measures = Array.from(measureMap.entries())
|
|
467
|
+
.sort(([a], [b]) => a - b)
|
|
468
|
+
.map(([, measure]) => measure);
|
|
469
|
+
for (const measure of measures) {
|
|
470
|
+
measure.voices = measure.voices.filter(Boolean);
|
|
471
|
+
}
|
|
472
|
+
return measures;
|
|
473
|
+
};
|
|
474
|
+
// Check if a voice has real music content (not just spacer rests and context changes)
|
|
475
|
+
const hasRealContent = (events) => {
|
|
476
|
+
return events.some(e => {
|
|
477
|
+
if (e.type === 'note')
|
|
478
|
+
return true;
|
|
479
|
+
if (e.type === 'rest' && !e.invisible)
|
|
480
|
+
return true;
|
|
481
|
+
return false;
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
// Remove quotes from string literal
|
|
485
|
+
const unquoteString = (str) => {
|
|
486
|
+
if (str.startsWith('"') && str.endsWith('"')) {
|
|
487
|
+
return str.slice(1, -1);
|
|
488
|
+
}
|
|
489
|
+
return str;
|
|
490
|
+
};
|
|
491
|
+
// Extract text from lotus parser objects recursively
|
|
492
|
+
const extractTextFromObject = (obj) => {
|
|
493
|
+
if (!obj)
|
|
494
|
+
return undefined;
|
|
495
|
+
// Simple string
|
|
496
|
+
if (typeof obj === 'string') {
|
|
497
|
+
return obj;
|
|
498
|
+
}
|
|
499
|
+
// Array - concatenate all text
|
|
500
|
+
if (Array.isArray(obj)) {
|
|
501
|
+
const texts = [];
|
|
502
|
+
for (const item of obj) {
|
|
503
|
+
const text = extractTextFromObject(item);
|
|
504
|
+
if (text)
|
|
505
|
+
texts.push(text);
|
|
506
|
+
}
|
|
507
|
+
return texts.join(' ').trim() || undefined;
|
|
508
|
+
}
|
|
509
|
+
// Object with proto property (lotus parser objects)
|
|
510
|
+
if (obj && typeof obj === 'object' && obj.proto) {
|
|
511
|
+
switch (obj.proto) {
|
|
512
|
+
case 'LiteralString':
|
|
513
|
+
// exp contains quoted string like '"Hello"'
|
|
514
|
+
if (obj.exp) {
|
|
515
|
+
return unquoteString(obj.exp);
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
case 'MarkupCommand':
|
|
519
|
+
case 'Command':
|
|
520
|
+
// Recursively extract from args
|
|
521
|
+
if (obj.args) {
|
|
522
|
+
return extractTextFromObject(obj.args);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
case 'InlineBlock':
|
|
526
|
+
// Extract from body, skip primitive commands
|
|
527
|
+
if (obj.body) {
|
|
528
|
+
const texts = [];
|
|
529
|
+
for (const item of obj.body) {
|
|
530
|
+
if (item.proto !== 'Primitive') {
|
|
531
|
+
const text = extractTextFromObject(item);
|
|
532
|
+
if (text)
|
|
533
|
+
texts.push(text);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return texts.join(' ').trim() || undefined;
|
|
537
|
+
}
|
|
538
|
+
break;
|
|
539
|
+
case 'String':
|
|
540
|
+
if (obj.value) {
|
|
541
|
+
return obj.value;
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Fallback: try value property
|
|
547
|
+
if (obj.value !== undefined) {
|
|
548
|
+
return extractTextFromObject(obj.value);
|
|
549
|
+
}
|
|
550
|
+
return undefined;
|
|
551
|
+
};
|
|
552
|
+
// Extract string value from header field
|
|
553
|
+
const extractStringValue = (value) => {
|
|
554
|
+
const text = extractTextFromObject(value);
|
|
555
|
+
return text ? text.trim() : undefined;
|
|
556
|
+
};
|
|
557
|
+
// Extract metadata from LilyDocument
|
|
558
|
+
const extractMetadata = (lilyDocument) => {
|
|
559
|
+
try {
|
|
560
|
+
const attrs = lilyDocument.globalAttributesReadOnly();
|
|
561
|
+
const metadata = {};
|
|
562
|
+
// Extract each field, handling markup structures
|
|
563
|
+
if (attrs.title) {
|
|
564
|
+
metadata.title = extractStringValue(attrs.title);
|
|
565
|
+
}
|
|
566
|
+
if (attrs.subtitle) {
|
|
567
|
+
metadata.subtitle = extractStringValue(attrs.subtitle);
|
|
568
|
+
}
|
|
569
|
+
if (attrs.composer) {
|
|
570
|
+
metadata.composer = extractStringValue(attrs.composer);
|
|
571
|
+
}
|
|
572
|
+
if (attrs.arranger) {
|
|
573
|
+
metadata.arranger = extractStringValue(attrs.arranger);
|
|
574
|
+
}
|
|
575
|
+
if (attrs.poet) {
|
|
576
|
+
metadata.lyricist = extractStringValue(attrs.poet);
|
|
577
|
+
}
|
|
578
|
+
if (attrs.opus) {
|
|
579
|
+
metadata.opus = extractStringValue(attrs.opus);
|
|
580
|
+
}
|
|
581
|
+
if (attrs.instrument) {
|
|
582
|
+
metadata.instrument = extractStringValue(attrs.instrument);
|
|
583
|
+
}
|
|
584
|
+
// Return undefined if no metadata fields were populated
|
|
585
|
+
if (Object.keys(metadata).length === 0) {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
return metadata;
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
// If metadata extraction fails, continue without it
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
// Convert parsed measures to LilyletDoc
|
|
596
|
+
const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
|
|
597
|
+
const measures = parsedMeasures.map(pm => {
|
|
598
|
+
// Filter out voices that only contain spacer rests and context changes
|
|
599
|
+
const voices = pm.voices
|
|
600
|
+
.filter(v => hasRealContent(v.events))
|
|
601
|
+
.map(v => ({
|
|
602
|
+
staff: v.staff,
|
|
603
|
+
events: v.events,
|
|
604
|
+
}));
|
|
605
|
+
const measure = {
|
|
606
|
+
parts: [{
|
|
607
|
+
voices,
|
|
608
|
+
}],
|
|
609
|
+
};
|
|
610
|
+
if (pm.key !== null) {
|
|
611
|
+
measure.key = convertKeySignature(pm.key);
|
|
612
|
+
}
|
|
613
|
+
if (pm.timeSig) {
|
|
614
|
+
measure.timeSig = pm.timeSig;
|
|
615
|
+
}
|
|
616
|
+
if (pm.partial) {
|
|
617
|
+
measure.partial = true;
|
|
618
|
+
}
|
|
619
|
+
return measure;
|
|
620
|
+
});
|
|
621
|
+
const doc = { measures };
|
|
622
|
+
if (metadata) {
|
|
623
|
+
doc.metadata = metadata;
|
|
624
|
+
}
|
|
625
|
+
return doc;
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
|
|
629
|
+
*/
|
|
630
|
+
const decode = (lilypondSource) => {
|
|
631
|
+
const rawData = lilypondParser.parse(lilypondSource);
|
|
632
|
+
const lilyDocument = new lilyParser.LilyDocument(rawData);
|
|
633
|
+
const parsedMeasures = parseLilyDocument(lilyDocument);
|
|
634
|
+
const metadata = extractMetadata(lilyDocument);
|
|
635
|
+
return parsedMeasuresToDoc(parsedMeasures, metadata);
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
|
|
639
|
+
*/
|
|
640
|
+
const decodeFromDocument = (lilyDocument) => {
|
|
641
|
+
const parsedMeasures = parseLilyDocument(lilyDocument);
|
|
642
|
+
const metadata = extractMetadata(lilyDocument);
|
|
643
|
+
return parsedMeasuresToDoc(parsedMeasures, metadata);
|
|
644
|
+
};
|
|
645
|
+
export { decode, decodeFromDocument, parseLilyDocument, };
|