@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.
Files changed (71) hide show
  1. package/lib/abc/abc.d.ts +102 -0
  2. package/lib/abc/abc.js +25 -0
  3. package/lib/abc/grammar.jison.js +1203 -0
  4. package/lib/abc/parser.d.ts +3 -0
  5. package/lib/abc/parser.js +6 -0
  6. package/lib/abcDecoder.d.ts +1 -0
  7. package/lib/abcDecoder.js +1 -0
  8. package/lib/grammar.jison.js +1 -1303
  9. package/lib/index.d.ts +1 -8
  10. package/lib/index.js +1 -10
  11. package/lib/lilylet/abcDecoder.d.ts +25 -0
  12. package/lib/lilylet/abcDecoder.js +1007 -0
  13. package/lib/lilylet/grammar.jison.js +1308 -0
  14. package/lib/lilylet/index.d.ts +10 -0
  15. package/lib/lilylet/index.js +10 -0
  16. package/lib/lilylet/lilypondDecoder.d.ts +29 -0
  17. package/lib/lilylet/lilypondDecoder.js +1053 -0
  18. package/lib/lilylet/lilypondEncoder.d.ts +34 -0
  19. package/lib/lilylet/lilypondEncoder.js +759 -0
  20. package/lib/lilylet/meiEncoder.d.ts +8 -0
  21. package/lib/lilylet/meiEncoder.js +1808 -0
  22. package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
  23. package/lib/lilylet/musicXmlDecoder.js +1195 -0
  24. package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
  25. package/lib/lilylet/musicXmlEncoder.js +701 -0
  26. package/lib/lilylet/musicXmlTypes.d.ts +199 -0
  27. package/lib/lilylet/musicXmlTypes.js +7 -0
  28. package/lib/lilylet/musicXmlUtils.d.ts +92 -0
  29. package/lib/lilylet/musicXmlUtils.js +469 -0
  30. package/lib/lilylet/parser.d.ts +3 -0
  31. package/lib/lilylet/parser.js +151 -0
  32. package/lib/lilylet/serializer.d.ts +11 -0
  33. package/lib/lilylet/serializer.js +653 -0
  34. package/lib/lilylet/types.d.ts +245 -0
  35. package/lib/lilylet/types.js +99 -0
  36. package/lib/lilypondDecoder.d.ts +1 -29
  37. package/lib/lilypondDecoder.js +1 -1006
  38. package/lib/lilypondEncoder.d.ts +1 -34
  39. package/lib/lilypondEncoder.js +1 -759
  40. package/lib/meiEncoder.d.ts +1 -8
  41. package/lib/meiEncoder.js +1 -1545
  42. package/lib/musicXmlDecoder.d.ts +1 -20
  43. package/lib/musicXmlDecoder.js +1 -1151
  44. package/lib/musicXmlEncoder.d.ts +1 -15
  45. package/lib/musicXmlEncoder.js +1 -666
  46. package/lib/musicXmlTypes.d.ts +1 -199
  47. package/lib/musicXmlTypes.js +1 -7
  48. package/lib/musicXmlUtils.d.ts +1 -81
  49. package/lib/musicXmlUtils.js +1 -435
  50. package/lib/parser.d.ts +1 -3
  51. package/lib/parser.js +1 -151
  52. package/lib/serializer.d.ts +1 -11
  53. package/lib/serializer.js +1 -650
  54. package/lib/types.d.ts +1 -244
  55. package/lib/types.js +1 -99
  56. package/package.json +2 -1
  57. package/source/abc/abc.jison +692 -0
  58. package/source/abc/abc.ts +176 -0
  59. package/source/abc/grammar.jison.js +1203 -0
  60. package/source/abc/parser.ts +12 -0
  61. package/source/lilylet/abcDecoder.ts +1121 -0
  62. package/source/lilylet/grammar.jison.js +170 -165
  63. package/source/lilylet/index.ts +4 -3
  64. package/source/lilylet/lilylet.jison +2 -0
  65. package/source/lilylet/lilypondDecoder.ts +92 -42
  66. package/source/lilylet/meiEncoder.ts +280 -0
  67. package/source/lilylet/musicXmlDecoder.ts +74 -27
  68. package/source/lilylet/musicXmlEncoder.ts +201 -146
  69. package/source/lilylet/musicXmlUtils.ts +46 -4
  70. package/source/lilylet/serializer.ts +3 -0
  71. package/source/lilylet/types.ts +1 -0
@@ -1,1006 +1 @@
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/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
- // Parse raw pitch string (e.g., "c'", "fis''", "bes,") to Pitch
164
- const parseRawPitch = (pitchStr) => {
165
- if (!pitchStr)
166
- return undefined;
167
- // Match: base note (a-g), optional accidentals (is/es/isis/eses), optional octave marks ('/, or ,)
168
- const match = pitchStr.match(/^([a-g])(isis|eses|is|es)?([',]*)$/);
169
- if (!match)
170
- return undefined;
171
- const [, note, accidental, octaveMarks] = match;
172
- // Map note to phonet
173
- const phonetMap = {
174
- c: Phonet.c, d: Phonet.d, e: Phonet.e, f: Phonet.f,
175
- g: Phonet.g, a: Phonet.a, b: Phonet.b,
176
- };
177
- const phonet = phonetMap[note];
178
- if (!phonet)
179
- return undefined;
180
- // Map accidental
181
- const accidentalMap = {
182
- is: Accidental.sharp,
183
- es: Accidental.flat,
184
- isis: Accidental.doubleSharp,
185
- eses: Accidental.doubleFlat,
186
- };
187
- const acc = accidental ? accidentalMap[accidental] : undefined;
188
- // Calculate octave from marks (default octave 0 = C4)
189
- let octave = 0;
190
- for (const mark of octaveMarks || '') {
191
- if (mark === "'")
192
- octave++;
193
- else if (mark === ",")
194
- octave--;
195
- }
196
- return { phonet, accidental: acc, octave };
197
- };
198
- // Parse raw duration object from tuplet body
199
- const parseRawDuration = (duration) => {
200
- if (!duration)
201
- return undefined;
202
- const number = parseInt(duration.number, 10);
203
- if (isNaN(number))
204
- return undefined;
205
- return {
206
- division: number,
207
- dots: duration.dots || 0,
208
- };
209
- };
210
- // Convert raw Chord from tuplet body to NoteEvent
211
- const convertRawChord = (chord, defaultDuration) => {
212
- if (!chord || chord.proto !== 'Chord')
213
- return undefined;
214
- const pitches = [];
215
- for (const pitchElem of chord.pitches || []) {
216
- const pitch = parseRawPitch(pitchElem.pitch);
217
- if (pitch)
218
- pitches.push(pitch);
219
- }
220
- if (pitches.length === 0)
221
- return undefined;
222
- const duration = parseRawDuration(chord.duration) || defaultDuration;
223
- if (!duration)
224
- return undefined;
225
- return {
226
- type: 'note',
227
- pitches,
228
- duration,
229
- };
230
- };
231
- // Parse pitch name with accidental (e.g., "cf" -> { pitch: Phonet.c, accidental: Accidental.flat })
232
- const parsePitchName = (name) => {
233
- if (!name || name.length === 0)
234
- return undefined;
235
- const phonetChar = name[0].toLowerCase();
236
- const phonet = {
237
- 'c': Phonet.c, 'd': Phonet.d, 'e': Phonet.e, 'f': Phonet.f,
238
- 'g': Phonet.g, 'a': Phonet.a, 'b': Phonet.b
239
- }[phonetChar];
240
- if (!phonet)
241
- return undefined;
242
- const accidentalPart = name.slice(1);
243
- let accidental;
244
- if (accidentalPart === 's' || accidentalPart === 'is') {
245
- accidental = Accidental.sharp;
246
- }
247
- else if (accidentalPart === 'ss' || accidentalPart === 'isis') {
248
- accidental = Accidental.doubleSharp;
249
- }
250
- else if (accidentalPart === 'f' || accidentalPart === 'es') {
251
- accidental = Accidental.flat;
252
- }
253
- else if (accidentalPart === 'ff' || accidentalPart === 'eses') {
254
- accidental = Accidental.doubleFlat;
255
- }
256
- return { pitch: phonet, accidental };
257
- };
258
- // Convert key from context to KeySignature
259
- const convertKeySignature = (keyContext) => {
260
- const args = keyContext?.args;
261
- // Always parse from args to get correct pitch and mode
262
- if (Array.isArray(args) && args.length >= 2) {
263
- const pitchStr = args[0];
264
- const modeStr = args[1];
265
- const pitchInfo = parsePitchName(pitchStr);
266
- if (pitchInfo) {
267
- const mode = modeStr?.includes('minor') ? 'minor' : 'major';
268
- return {
269
- pitch: pitchInfo.pitch,
270
- accidental: pitchInfo.accidental,
271
- mode,
272
- };
273
- }
274
- }
275
- // Fallback to fifths lookup for compatibility (major keys only)
276
- const fifths = keyContext?.key;
277
- if (fifths !== undefined && KEY_FIFTHS_MAP[fifths]) {
278
- const mapping = KEY_FIFTHS_MAP[fifths];
279
- return {
280
- pitch: mapping.pitch,
281
- accidental: mapping.accidental,
282
- mode: mapping.mode,
283
- };
284
- }
285
- return undefined;
286
- };
287
- const parsePostEvents = (postEvents) => {
288
- const marks = [];
289
- let harmonyText;
290
- if (!postEvents)
291
- return { marks };
292
- for (const event of postEvents) {
293
- // String events
294
- if (typeof event === 'string') {
295
- if (event === '~') {
296
- marks.push({ markType: 'tie', start: true });
297
- }
298
- else if (event === '(') {
299
- marks.push({ markType: 'slur', start: true });
300
- }
301
- else if (event === ')') {
302
- marks.push({ markType: 'slur', start: false });
303
- }
304
- continue;
305
- }
306
- // PostEvent objects
307
- if (event && typeof event === 'object') {
308
- const arg = event.arg;
309
- // String articulation/ornament
310
- if (typeof arg === 'string') {
311
- const cleanArg = arg.replace(/^-/, '');
312
- if (ARTICULATION_MAP[cleanArg]) {
313
- marks.push({ markType: 'articulation', type: ARTICULATION_MAP[cleanArg] });
314
- }
315
- else if (ORNAMENT_MAP[cleanArg]) {
316
- marks.push({ markType: 'ornament', type: ORNAMENT_MAP[cleanArg] });
317
- }
318
- }
319
- // Fingering (number 1-5)
320
- if (typeof arg === 'number' && arg >= 1 && arg <= 5) {
321
- marks.push({ markType: 'fingering', finger: arg });
322
- }
323
- // Command (dynamics, hairpins, etc.)
324
- if (arg && typeof arg === 'object' && 'cmd' in arg) {
325
- const cmd = arg.cmd;
326
- if (DYNAMIC_REGEX.test(cmd) && DYNAMIC_MAP[cmd]) {
327
- marks.push({ markType: 'dynamic', type: DYNAMIC_MAP[cmd] });
328
- }
329
- else if (cmd === '<') {
330
- marks.push({ markType: 'hairpin', type: HairpinType.crescendoStart });
331
- }
332
- else if (cmd === '>') {
333
- marks.push({ markType: 'hairpin', type: HairpinType.diminuendoStart });
334
- }
335
- else if (cmd === '!') {
336
- marks.push({ markType: 'hairpin', type: HairpinType.crescendoEnd }); // or diminuendoEnd
337
- }
338
- else if (cmd === 'sustainOn') {
339
- marks.push({ markType: 'pedal', type: PedalType.sustainOn });
340
- }
341
- else if (cmd === 'sustainOff') {
342
- marks.push({ markType: 'pedal', type: PedalType.sustainOff });
343
- }
344
- else if (cmd === 'coda') {
345
- marks.push({ markType: 'navigation', type: NavigationMarkType.coda });
346
- }
347
- else if (cmd === 'segno') {
348
- marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
349
- }
350
- else if (cmd === '\\markup' || cmd === 'markup') {
351
- // Check if this is a harmony (chord symbol) - marked with \bold
352
- const harmony = extractHarmonyFromMarkup(arg.args);
353
- if (harmony) {
354
- harmonyText = harmony;
355
- }
356
- else {
357
- // Regular markup attached to note
358
- const text = extractTextFromObject(arg.args);
359
- if (text && !containsTempoWord(text)) {
360
- const direction = event.direction;
361
- const placement = direction === 'up' ? Placement.above :
362
- direction === 'down' ? Placement.below : undefined;
363
- marks.push({ markType: 'markup', content: text, placement });
364
- }
365
- }
366
- }
367
- }
368
- // Handle markup command directly (proto: 'MarkupCommand')
369
- if (arg && typeof arg === 'object' && arg.proto === 'MarkupCommand') {
370
- // Check if this is a harmony (chord symbol) - marked with \bold
371
- const harmony = extractHarmonyFromMarkup(arg.args);
372
- if (harmony) {
373
- harmonyText = harmony;
374
- }
375
- else {
376
- const text = extractTextFromObject(arg.args);
377
- if (text && !containsTempoWord(text)) {
378
- const direction = event.direction;
379
- const placement = direction === 'up' ? Placement.above :
380
- direction === 'down' ? Placement.below : undefined;
381
- marks.push({ markType: 'markup', content: text, placement });
382
- }
383
- }
384
- }
385
- }
386
- }
387
- return { marks, harmonyText };
388
- };
389
- // Parse a LilyPond document to measures
390
- const parseLilyDocument = (lilyDocument) => {
391
- const measureMap = new Map();
392
- const staffNames = [];
393
- const interpreter = lilyDocument.interpret();
394
- interpreter.layoutMusic.musicTracks.forEach((track, vi) => {
395
- const appendStaff = (staffName) => {
396
- if (!staffNames.includes(staffName)) {
397
- staffNames.push(staffName);
398
- }
399
- };
400
- // Parse staff name to extract partIndex and staff number
401
- // Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
402
- // Falls back to partIndex=1 if format doesn't match
403
- const parseStaffName = (name) => {
404
- const match = name.match(/^(\d+)_(\d+)$/);
405
- if (match) {
406
- return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
407
- }
408
- // Fallback: single part, staff number from name or 1
409
- const num = parseInt(name, 10);
410
- return { partIndex: 1, staffNum: isNaN(num) ? 1 : num };
411
- };
412
- // Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
413
- // This won't be affected by \change Staff commands inside the track
414
- const initialStaffName = track.contextDict?.Staff;
415
- if (initialStaffName) {
416
- appendStaff(initialStaffName);
417
- }
418
- const parsedStaff = initialStaffName ? parseStaffName(initialStaffName) : { partIndex: 1, staffNum: 1 };
419
- // Use these as fixed values for this track - don't update from context.staffName
420
- const trackStaff = parsedStaff.staffNum;
421
- const trackPartIndex = parsedStaff.partIndex;
422
- // Track emitted context events across measures for this voice
423
- let lastKey = undefined; // Track value changes (key fifths)
424
- let lastTimeSig = undefined; // Track value changes (as string for comparison)
425
- let lastClef = undefined; // Track value changes
426
- let lastOttava = undefined; // Track value changes
427
- let lastStemDirection = undefined; // Track value changes
428
- const context = new lilyParser.TrackContext(undefined, {
429
- listener: (term, context) => {
430
- const mi = term._measure;
431
- if (mi === undefined)
432
- return;
433
- if (!measureMap.has(mi)) {
434
- measureMap.set(mi, {
435
- key: null,
436
- timeSig: null,
437
- voices: [],
438
- partial: false,
439
- });
440
- }
441
- const measure = measureMap.get(mi);
442
- // Initialize voice for this track (use fixed staff/part from track definition)
443
- if (!measure.voices[vi]) {
444
- measure.voices[vi] = {
445
- staff: trackStaff,
446
- partIndex: trackPartIndex,
447
- events: [],
448
- };
449
- }
450
- const voice = measure.voices[vi];
451
- // Update key/time from context on music events
452
- if (term instanceof lilyParser.MusicEvent ||
453
- term instanceof lilyParser.LilyTerms.StemDirection ||
454
- term instanceof lilyParser.LilyTerms.OctaveShift) {
455
- if (context.key && measure.key === null) {
456
- measure.key = context.key.key;
457
- }
458
- if (context.time && measure.timeSig === null) {
459
- measure.timeSig = {
460
- numerator: context.time.value.numerator,
461
- denominator: context.time.value.denominator,
462
- };
463
- }
464
- if (context.partialDuration) {
465
- measure.partial = true;
466
- }
467
- }
468
- // Handle music events
469
- if (term instanceof lilyParser.MusicEvent) {
470
- // Staff is fixed per track (from track definition)
471
- voice.staff = trackStaff;
472
- // Handle key context change (emit when value changes)
473
- if (context.key && context.key.key !== lastKey) {
474
- const key = convertKeySignature(context.key);
475
- if (key) {
476
- voice.events.push({
477
- type: 'context',
478
- key,
479
- });
480
- lastKey = context.key.key;
481
- }
482
- }
483
- // Handle time signature context change (emit when value changes)
484
- if (context.time) {
485
- const timeSigStr = `${context.time.value.numerator}/${context.time.value.denominator}`;
486
- if (timeSigStr !== lastTimeSig) {
487
- voice.events.push({
488
- type: 'context',
489
- time: {
490
- numerator: context.time.value.numerator,
491
- denominator: context.time.value.denominator,
492
- },
493
- });
494
- lastTimeSig = timeSigStr;
495
- }
496
- }
497
- // Handle clef context change (emit when value changes)
498
- if (context.clef) {
499
- const clef = LILYPOND_CLEF_MAP[context.clef.clefName];
500
- if (clef && clef !== lastClef) {
501
- voice.events.push({
502
- type: 'context',
503
- clef,
504
- });
505
- lastClef = clef;
506
- }
507
- }
508
- // Handle ottava (emit when value changes)
509
- if (context.octave != null) {
510
- const currentOttava = context.octave.value ?? 0;
511
- if (currentOttava !== lastOttava) {
512
- voice.events.push({
513
- type: 'context',
514
- ottava: currentOttava,
515
- });
516
- lastOttava = currentOttava;
517
- }
518
- }
519
- // Handle stem direction context change (emit when value changes)
520
- if (context.stemDirection && context.stemDirection !== lastStemDirection) {
521
- const stemDir = context.stemDirection === 'Up' ? StemDirection.up :
522
- context.stemDirection === 'Down' ? StemDirection.down : undefined;
523
- if (stemDir) {
524
- voice.events.push({
525
- type: 'context',
526
- stemDirection: stemDir,
527
- });
528
- lastStemDirection = context.stemDirection;
529
- }
530
- }
531
- // Process Chord (note or chord)
532
- if (term instanceof lilyParser.LilyTerms.Chord) {
533
- const pitches = [];
534
- for (const pitch of term.pitchesValue) {
535
- if (pitch instanceof lilyParser.LilyTerms.ChordElement) {
536
- pitches.push(convertPitch(pitch.phonetStep, pitch.alterValue || 0, pitch.absolutePitch.octave));
537
- }
538
- }
539
- if (pitches.length > 0) {
540
- const { marks, harmonyText } = parsePostEvents(term.post_events);
541
- // Add beam marks
542
- if (term.beamOn) {
543
- marks.push({ markType: 'beam', start: true });
544
- }
545
- else if (term.beamOff) {
546
- marks.push({ markType: 'beam', start: false });
547
- }
548
- // Add tie
549
- if (term.isTying) {
550
- marks.push({ markType: 'tie', start: true });
551
- }
552
- const noteEvent = {
553
- type: 'note',
554
- pitches,
555
- duration: convertDuration(term.durationValue),
556
- grace: context.inGrace || undefined,
557
- };
558
- if (marks.length > 0) {
559
- noteEvent.marks = marks;
560
- }
561
- voice.events.push(noteEvent);
562
- // Add harmony event if detected (chord symbol encoded as \bold markup)
563
- if (harmonyText) {
564
- const harmonyEvent = {
565
- type: 'harmony',
566
- text: harmonyText,
567
- };
568
- voice.events.push(harmonyEvent);
569
- }
570
- }
571
- }
572
- // Process Rest
573
- else if (term instanceof lilyParser.LilyTerms.Rest) {
574
- const restEvent = {
575
- type: 'rest',
576
- duration: convertDuration(term.durationValue),
577
- invisible: term.isSpacer || undefined,
578
- };
579
- // Positioned rest
580
- if (!term.isSpacer && context.pitch) {
581
- restEvent.pitch = convertPitch(context.pitch.phonetStep, 0, context.pitch.octave);
582
- }
583
- voice.events.push(restEvent);
584
- }
585
- }
586
- // Handle standalone stem direction (emit when value changes)
587
- else if (term instanceof lilyParser.LilyTerms.StemDirection) {
588
- if (term.direction !== lastStemDirection) {
589
- const stemDir = term.direction === 'Up' ? StemDirection.up :
590
- term.direction === 'Down' ? StemDirection.down : undefined;
591
- if (stemDir) {
592
- voice.events.push({
593
- type: 'context',
594
- stemDirection: stemDir,
595
- });
596
- lastStemDirection = term.direction;
597
- }
598
- }
599
- }
600
- // Handle standalone clef (emit when value changes)
601
- else if (term instanceof lilyParser.LilyTerms.Clef) {
602
- const clef = LILYPOND_CLEF_MAP[term.clefName];
603
- if (clef && clef !== lastClef) {
604
- voice.events.push({
605
- type: 'context',
606
- clef,
607
- });
608
- lastClef = clef;
609
- }
610
- }
611
- // Handle ottava shift
612
- else if (term instanceof lilyParser.LilyTerms.OctaveShift) {
613
- if (term.value !== lastOttava) {
614
- voice.events.push({
615
- type: 'context',
616
- ottava: term.value,
617
- });
618
- lastOttava = term.value;
619
- }
620
- }
621
- // Handle staff change
622
- else if (term instanceof lilyParser.LilyTerms.Change) {
623
- // Ignore \change Staff commands - staff is fixed per track
624
- // (Cross-staff notation is not supported in this decoder)
625
- }
626
- // Handle tempo
627
- else if (term instanceof lilyParser.LilyTerms.Tempo) {
628
- const tempo = convertTempo(term);
629
- if (tempo) {
630
- voice.events.push({
631
- type: 'context',
632
- tempo,
633
- });
634
- }
635
- }
636
- // Handle standalone markup command and barlines
637
- else {
638
- const termAny = term;
639
- if (termAny.proto === 'Command' && (termAny.cmd === '\\markup' || termAny.cmd === 'markup')) {
640
- // Check if this is a harmony (chord symbol) - marked with \bold
641
- const harmonyText = extractHarmonyFromMarkup(termAny.args);
642
- if (harmonyText) {
643
- const harmonyEvent = {
644
- type: 'harmony',
645
- text: harmonyText,
646
- };
647
- voice.events.push(harmonyEvent);
648
- }
649
- else {
650
- const text = extractTextFromObject(termAny.args);
651
- if (text && !containsTempoWord(text)) {
652
- const markupEvent = {
653
- type: 'markup',
654
- content: text,
655
- };
656
- voice.events.push(markupEvent);
657
- }
658
- }
659
- }
660
- // Handle barline command - barlines belong to the previous measure
661
- else if (termAny.proto === 'Command' && termAny.cmd === 'bar') {
662
- const style = termAny.args?.[0]?.exp;
663
- if (style && mi > 0) {
664
- // Remove quotes from the style string
665
- const barStyle = style.replace(/^"|"$/g, '');
666
- // Add to previous measure's voice
667
- const prevMeasure = measureMap.get(mi - 1);
668
- if (prevMeasure && prevMeasure.voices[vi]) {
669
- prevMeasure.voices[vi].events.push({
670
- type: 'barline',
671
- style: barStyle,
672
- });
673
- }
674
- }
675
- }
676
- // Handle ChordSymbol (inline chord symbol: \chords "text")
677
- else if (termAny.proto === 'ChordSymbol') {
678
- // Extract text from LiteralString (e.g., { exp: '"C"' } -> "C")
679
- let text = termAny.text;
680
- if (typeof text === 'object' && text?.exp) {
681
- text = text.exp.replace(/^"|"$/g, '');
682
- }
683
- else if (typeof text === 'string') {
684
- text = text.replace(/^"|"$/g, '');
685
- }
686
- const harmonyEvent = {
687
- type: 'harmony',
688
- text: text,
689
- };
690
- voice.events.push(harmonyEvent);
691
- }
692
- // Handle tuplet
693
- // Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
694
- // remove the already-added notes and wrap them in a TupletEvent
695
- else if (termAny.proto === 'Tuplet') {
696
- const ratioStr = termAny.args?.[0]; // e.g., "3/2"
697
- const body = termAny.args?.[1]?.body || [];
698
- if (ratioStr && body.length > 0) {
699
- // Parse ratio string
700
- const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
701
- if (ratioMatch) {
702
- const [, num, denom] = ratioMatch;
703
- const ratio = {
704
- numerator: parseInt(denom, 10), // Swapped: lilylet uses actual/normal
705
- denominator: parseInt(num, 10),
706
- };
707
- // Count how many note/rest events are in the tuplet body
708
- const noteCount = body.filter((item) => item.proto === 'Chord' || item.proto === 'Rest').length;
709
- // Remove the last noteCount note/rest events from voice.events
710
- // (they were already added by the Chord/Rest handlers)
711
- const tupletEvents = [];
712
- let removed = 0;
713
- while (removed < noteCount && voice.events.length > 0) {
714
- const lastEvent = voice.events[voice.events.length - 1];
715
- if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
716
- tupletEvents.unshift(voice.events.pop());
717
- removed++;
718
- }
719
- else {
720
- break; // Stop if we hit a non-note/rest event
721
- }
722
- }
723
- if (tupletEvents.length > 0) {
724
- const tupletEvent = {
725
- type: 'tuplet',
726
- ratio,
727
- events: tupletEvents,
728
- };
729
- voice.events.push(tupletEvent);
730
- }
731
- }
732
- }
733
- }
734
- // Handle repeat tremolo
735
- else if (termAny.proto === 'Repeat' && termAny.args?.[0] === 'tremolo') {
736
- const count = parseInt(termAny.args?.[1], 10);
737
- const body = termAny.args?.[2]?.body || [];
738
- if (!isNaN(count) && body.length === 2) {
739
- // Double tremolo has exactly 2 pitches
740
- const pitch1 = body[0]?.pitches?.[0]?.pitch;
741
- const pitch2 = body[1]?.pitches?.[0]?.pitch;
742
- const duration = body[0]?.duration;
743
- if (pitch1 && pitch2 && duration) {
744
- const pitchA = parseRawPitch(pitch1);
745
- const pitchB = parseRawPitch(pitch2);
746
- const div = parseInt(duration.number, 10);
747
- if (pitchA && pitchB && !isNaN(div)) {
748
- // Remove the 2 notes that were already added
749
- let removed = 0;
750
- while (removed < 2 && voice.events.length > 0) {
751
- const lastEvent = voice.events[voice.events.length - 1];
752
- if (lastEvent.type === 'note') {
753
- voice.events.pop();
754
- removed++;
755
- }
756
- else {
757
- break;
758
- }
759
- }
760
- const tremoloEvent = {
761
- type: 'tremolo',
762
- pitchA: [pitchA],
763
- pitchB: [pitchB],
764
- count,
765
- division: div,
766
- };
767
- voice.events.push(tremoloEvent);
768
- }
769
- }
770
- }
771
- }
772
- }
773
- },
774
- });
775
- context.execute(track.music);
776
- });
777
- // Filter out empty voices and convert to array, sorted by measure number
778
- const measures = Array.from(measureMap.entries())
779
- .sort(([a], [b]) => a - b)
780
- .map(([, measure]) => measure);
781
- for (const measure of measures) {
782
- measure.voices = measure.voices.filter(Boolean);
783
- }
784
- return measures;
785
- };
786
- // Check if a voice has real music content (not just spacer rests and context changes)
787
- const hasRealContent = (events) => {
788
- return events.some(e => {
789
- if (e.type === 'note')
790
- return true;
791
- if (e.type === 'rest' && !e.invisible)
792
- return true;
793
- if (e.type === 'tuplet')
794
- return true;
795
- if (e.type === 'tremolo')
796
- return true;
797
- return false;
798
- });
799
- };
800
- // Remove quotes from string literal
801
- const unquoteString = (str) => {
802
- if (str.startsWith('"') && str.endsWith('"')) {
803
- return str.slice(1, -1);
804
- }
805
- return str;
806
- };
807
- // Extract text from lotus parser objects recursively
808
- const extractTextFromObject = (obj) => {
809
- if (!obj)
810
- return undefined;
811
- // Simple string
812
- if (typeof obj === 'string') {
813
- return obj;
814
- }
815
- // Array - concatenate all text
816
- if (Array.isArray(obj)) {
817
- const texts = [];
818
- for (const item of obj) {
819
- const text = extractTextFromObject(item);
820
- if (text)
821
- texts.push(text);
822
- }
823
- return texts.join(' ').trim() || undefined;
824
- }
825
- // Object with proto property (lotus parser objects)
826
- if (obj && typeof obj === 'object' && obj.proto) {
827
- switch (obj.proto) {
828
- case 'LiteralString':
829
- // exp contains quoted string like '"Hello"'
830
- if (obj.exp) {
831
- return unquoteString(obj.exp);
832
- }
833
- break;
834
- case 'MarkupCommand':
835
- case 'Command':
836
- // Recursively extract from args
837
- if (obj.args) {
838
- return extractTextFromObject(obj.args);
839
- }
840
- break;
841
- case 'InlineBlock':
842
- // Extract from body, skip primitive commands
843
- if (obj.body) {
844
- const texts = [];
845
- for (const item of obj.body) {
846
- if (item.proto !== 'Primitive') {
847
- const text = extractTextFromObject(item);
848
- if (text)
849
- texts.push(text);
850
- }
851
- }
852
- return texts.join(' ').trim() || undefined;
853
- }
854
- break;
855
- case 'String':
856
- if (obj.value) {
857
- return obj.value;
858
- }
859
- break;
860
- }
861
- }
862
- // Fallback: try value property
863
- if (obj.value !== undefined) {
864
- return extractTextFromObject(obj.value);
865
- }
866
- return undefined;
867
- };
868
- // Check if markup contains \bold command (indicates harmony/chord symbol)
869
- // Returns the text if it's a harmony, undefined otherwise
870
- const extractHarmonyFromMarkup = (obj) => {
871
- if (!obj)
872
- return undefined;
873
- // Check array of args
874
- if (Array.isArray(obj)) {
875
- for (const item of obj) {
876
- const result = extractHarmonyFromMarkup(item);
877
- if (result !== undefined)
878
- return result;
879
- }
880
- return undefined;
881
- }
882
- if (obj && typeof obj === 'object') {
883
- // Check if this is a \bold command (can be Command or MarkupCommand)
884
- if ((obj.proto === 'Command' || obj.proto === 'MarkupCommand') &&
885
- (obj.cmd === 'bold' || obj.cmd === '\\bold')) {
886
- // Extract the text from args
887
- return extractTextFromObject(obj.args);
888
- }
889
- // Recursively search InlineBlock body
890
- if (obj.proto === 'InlineBlock' && obj.body) {
891
- return extractHarmonyFromMarkup(obj.body);
892
- }
893
- // Recursively search args
894
- if (obj.args) {
895
- return extractHarmonyFromMarkup(obj.args);
896
- }
897
- }
898
- return undefined;
899
- };
900
- // Extract string value from header field
901
- const extractStringValue = (value) => {
902
- const text = extractTextFromObject(value);
903
- return text ? text.trim() : undefined;
904
- };
905
- // Extract metadata from LilyDocument
906
- const extractMetadata = (lilyDocument) => {
907
- try {
908
- const attrs = lilyDocument.globalAttributesReadOnly();
909
- const metadata = {};
910
- // Extract each field, handling markup structures
911
- if (attrs.title) {
912
- metadata.title = extractStringValue(attrs.title);
913
- }
914
- if (attrs.subtitle) {
915
- metadata.subtitle = extractStringValue(attrs.subtitle);
916
- }
917
- if (attrs.composer) {
918
- metadata.composer = extractStringValue(attrs.composer);
919
- }
920
- if (attrs.arranger) {
921
- metadata.arranger = extractStringValue(attrs.arranger);
922
- }
923
- if (attrs.poet) {
924
- metadata.lyricist = extractStringValue(attrs.poet);
925
- }
926
- if (attrs.opus) {
927
- metadata.opus = extractStringValue(attrs.opus);
928
- }
929
- if (attrs.instrument) {
930
- metadata.instrument = extractStringValue(attrs.instrument);
931
- }
932
- // Return undefined if no metadata fields were populated
933
- if (Object.keys(metadata).length === 0) {
934
- return undefined;
935
- }
936
- return metadata;
937
- }
938
- catch (e) {
939
- // If metadata extraction fails, continue without it
940
- return undefined;
941
- }
942
- };
943
- // Convert parsed measures to LilyletDoc
944
- const parsedMeasuresToDoc = (parsedMeasures, metadata) => {
945
- const measures = parsedMeasures.map(pm => {
946
- // Filter out voices that only contain spacer rests and context changes
947
- const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
948
- // Group voices by partIndex
949
- const partMap = new Map();
950
- for (const v of filteredVoices) {
951
- const pi = v.partIndex || 1;
952
- if (!partMap.has(pi)) {
953
- partMap.set(pi, []);
954
- }
955
- partMap.get(pi).push({
956
- staff: v.staff,
957
- events: v.events,
958
- });
959
- }
960
- // Convert to parts array (sorted by part index)
961
- const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
962
- const parts = partIndices.map(pi => ({
963
- voices: partMap.get(pi),
964
- }));
965
- // Fallback to single empty part if no voices
966
- const measure = {
967
- parts: parts.length > 0 ? parts : [{ voices: [] }],
968
- };
969
- if (pm.key !== null) {
970
- measure.key = convertKeySignature(pm.key);
971
- }
972
- if (pm.timeSig) {
973
- measure.timeSig = pm.timeSig;
974
- }
975
- if (pm.partial) {
976
- measure.partial = true;
977
- }
978
- return measure;
979
- })
980
- // Filter out empty measures (no voices in any part)
981
- .filter(m => m.parts.some(p => p.voices.length > 0));
982
- const doc = { measures };
983
- if (metadata) {
984
- doc.metadata = metadata;
985
- }
986
- return doc;
987
- };
988
- /**
989
- * Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
990
- */
991
- const decode = (lilypondSource) => {
992
- const rawData = lilypondParser.parse(lilypondSource);
993
- const lilyDocument = new lilyParser.LilyDocument(rawData);
994
- const parsedMeasures = parseLilyDocument(lilyDocument);
995
- const metadata = extractMetadata(lilyDocument);
996
- return parsedMeasuresToDoc(parsedMeasures, metadata);
997
- };
998
- /**
999
- * Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
1000
- */
1001
- const decodeFromDocument = (lilyDocument) => {
1002
- const parsedMeasures = parseLilyDocument(lilyDocument);
1003
- const metadata = extractMetadata(lilyDocument);
1004
- return parsedMeasuresToDoc(parsedMeasures, metadata);
1005
- };
1006
- export { decode, decodeFromDocument, parseLilyDocument, };
1
+ export * from "./lilylet/lilypondDecoder.js";