@k-l-lambda/lilylet 0.1.39 → 0.1.40
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/lilypondEncoder.d.ts +5 -0
- package/lib/lilypondEncoder.js +113 -23
- package/lib/meiEncoder.js +2 -1
- package/package.json +2 -2
- package/source/lilylet/lilypondDecoder.ts +473 -80
- package/source/lilylet/lilypondEncoder.ts +129 -26
- package/source/lilylet/meiEncoder.ts +2 -1
- package/lib/lilypondDecoder.d.ts +0 -28
- package/lib/lilypondDecoder.js +0 -645
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
RestEvent,
|
|
21
21
|
ContextChange,
|
|
22
22
|
MarkupEvent,
|
|
23
|
+
HarmonyEvent,
|
|
24
|
+
TupletEvent,
|
|
25
|
+
TremoloEvent,
|
|
23
26
|
Pitch,
|
|
24
27
|
Duration,
|
|
25
28
|
Mark,
|
|
@@ -206,6 +209,7 @@ interface ParsedMeasure {
|
|
|
206
209
|
|
|
207
210
|
interface ParsedVoice {
|
|
208
211
|
staff: number;
|
|
212
|
+
partIndex: number; // 1-based part index (from staff ID format "partIndex_staffIndex")
|
|
209
213
|
events: Event[];
|
|
210
214
|
}
|
|
211
215
|
|
|
@@ -237,25 +241,152 @@ const convertDuration = (duration: any): Duration => {
|
|
|
237
241
|
};
|
|
238
242
|
|
|
239
243
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
// Parse raw pitch string (e.g., "c'", "fis''", "bes,") to Pitch
|
|
245
|
+
const parseRawPitch = (pitchStr: string): Pitch | undefined => {
|
|
246
|
+
if (!pitchStr) return undefined;
|
|
247
|
+
|
|
248
|
+
// Match: base note (a-g), optional accidentals (is/es/isis/eses), optional octave marks ('/, or ,)
|
|
249
|
+
const match = pitchStr.match(/^([a-g])(isis|eses|is|es)?([',]*)$/);
|
|
250
|
+
if (!match) return undefined;
|
|
251
|
+
|
|
252
|
+
const [, note, accidental, octaveMarks] = match;
|
|
253
|
+
|
|
254
|
+
// Map note to phonet
|
|
255
|
+
const phonetMap: Record<string, Phonet> = {
|
|
256
|
+
c: Phonet.c, d: Phonet.d, e: Phonet.e, f: Phonet.f,
|
|
257
|
+
g: Phonet.g, a: Phonet.a, b: Phonet.b,
|
|
258
|
+
};
|
|
259
|
+
const phonet = phonetMap[note];
|
|
260
|
+
if (!phonet) return undefined;
|
|
261
|
+
|
|
262
|
+
// Map accidental
|
|
263
|
+
const accidentalMap: Record<string, Accidental> = {
|
|
264
|
+
is: Accidental.sharp,
|
|
265
|
+
es: Accidental.flat,
|
|
266
|
+
isis: Accidental.doubleSharp,
|
|
267
|
+
eses: Accidental.doubleFlat,
|
|
268
|
+
};
|
|
269
|
+
const acc = accidental ? accidentalMap[accidental] : undefined;
|
|
270
|
+
|
|
271
|
+
// Calculate octave from marks (default octave 0 = C4)
|
|
272
|
+
let octave = 0;
|
|
273
|
+
for (const mark of octaveMarks || '') {
|
|
274
|
+
if (mark === "'") octave++;
|
|
275
|
+
else if (mark === ",") octave--;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { phonet, accidental: acc, octave };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// Parse raw duration object from tuplet body
|
|
283
|
+
const parseRawDuration = (duration: any): Duration | undefined => {
|
|
284
|
+
if (!duration) return undefined;
|
|
285
|
+
const number = parseInt(duration.number, 10);
|
|
286
|
+
if (isNaN(number)) return undefined;
|
|
287
|
+
return {
|
|
288
|
+
division: number,
|
|
289
|
+
dots: duration.dots || 0,
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
// Convert raw Chord from tuplet body to NoteEvent
|
|
295
|
+
const convertRawChord = (chord: any, defaultDuration?: Duration): NoteEvent | undefined => {
|
|
296
|
+
if (!chord || chord.proto !== 'Chord') return undefined;
|
|
297
|
+
|
|
298
|
+
const pitches: Pitch[] = [];
|
|
299
|
+
for (const pitchElem of chord.pitches || []) {
|
|
300
|
+
const pitch = parseRawPitch(pitchElem.pitch);
|
|
301
|
+
if (pitch) pitches.push(pitch);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (pitches.length === 0) return undefined;
|
|
305
|
+
|
|
306
|
+
const duration = parseRawDuration(chord.duration) || defaultDuration;
|
|
307
|
+
if (!duration) return undefined;
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
type: 'note',
|
|
311
|
+
pitches,
|
|
312
|
+
duration,
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
// Parse pitch name with accidental (e.g., "cf" -> { pitch: Phonet.c, accidental: Accidental.flat })
|
|
318
|
+
const parsePitchName = (name: string): { pitch: Phonet; accidental?: Accidental } | undefined => {
|
|
319
|
+
if (!name || name.length === 0) return undefined;
|
|
320
|
+
|
|
321
|
+
const phonetChar = name[0].toLowerCase();
|
|
322
|
+
const phonet = {
|
|
323
|
+
'c': Phonet.c, 'd': Phonet.d, 'e': Phonet.e, 'f': Phonet.f,
|
|
324
|
+
'g': Phonet.g, 'a': Phonet.a, 'b': Phonet.b
|
|
325
|
+
}[phonetChar];
|
|
326
|
+
|
|
327
|
+
if (!phonet) return undefined;
|
|
328
|
+
|
|
329
|
+
const accidentalPart = name.slice(1);
|
|
330
|
+
let accidental: Accidental | undefined;
|
|
331
|
+
if (accidentalPart === 's' || accidentalPart === 'is') {
|
|
332
|
+
accidental = Accidental.sharp;
|
|
333
|
+
} else if (accidentalPart === 'ss' || accidentalPart === 'isis') {
|
|
334
|
+
accidental = Accidental.doubleSharp;
|
|
335
|
+
} else if (accidentalPart === 'f' || accidentalPart === 'es') {
|
|
336
|
+
accidental = Accidental.flat;
|
|
337
|
+
} else if (accidentalPart === 'ff' || accidentalPart === 'eses') {
|
|
338
|
+
accidental = Accidental.doubleFlat;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { pitch: phonet, accidental };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Convert key from context to KeySignature
|
|
345
|
+
const convertKeySignature = (keyContext: any): KeySignature | undefined => {
|
|
346
|
+
const args = keyContext?.args;
|
|
347
|
+
|
|
348
|
+
// Always parse from args to get correct pitch and mode
|
|
349
|
+
if (Array.isArray(args) && args.length >= 2) {
|
|
350
|
+
const pitchStr = args[0];
|
|
351
|
+
const modeStr = args[1];
|
|
352
|
+
|
|
353
|
+
const pitchInfo = parsePitchName(pitchStr);
|
|
354
|
+
if (pitchInfo) {
|
|
355
|
+
const mode = modeStr?.includes('minor') ? 'minor' : 'major';
|
|
356
|
+
return {
|
|
357
|
+
pitch: pitchInfo.pitch,
|
|
358
|
+
accidental: pitchInfo.accidental,
|
|
359
|
+
mode,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Fallback to fifths lookup for compatibility (major keys only)
|
|
365
|
+
const fifths = keyContext?.key;
|
|
366
|
+
if (fifths !== undefined && KEY_FIFTHS_MAP[fifths]) {
|
|
367
|
+
const mapping = KEY_FIFTHS_MAP[fifths];
|
|
244
368
|
return {
|
|
245
369
|
pitch: mapping.pitch,
|
|
246
370
|
accidental: mapping.accidental,
|
|
247
371
|
mode: mapping.mode,
|
|
248
372
|
};
|
|
249
373
|
}
|
|
374
|
+
|
|
250
375
|
return undefined;
|
|
251
376
|
};
|
|
252
377
|
|
|
253
378
|
|
|
254
|
-
// Parse post-events to marks
|
|
255
|
-
|
|
379
|
+
// Parse post-events to marks and detect harmony events
|
|
380
|
+
interface PostEventResult {
|
|
381
|
+
marks: Mark[];
|
|
382
|
+
harmonyText?: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const parsePostEvents = (postEvents: any[]): PostEventResult => {
|
|
256
386
|
const marks: Mark[] = [];
|
|
387
|
+
let harmonyText: string | undefined;
|
|
257
388
|
|
|
258
|
-
if (!postEvents) return marks;
|
|
389
|
+
if (!postEvents) return { marks };
|
|
259
390
|
|
|
260
391
|
for (const event of postEvents) {
|
|
261
392
|
// String events
|
|
@@ -309,7 +440,31 @@ const parsePostEvents = (postEvents: any[]): Mark[] => {
|
|
|
309
440
|
} else if (cmd === 'segno') {
|
|
310
441
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
311
442
|
} else if (cmd === '\\markup' || cmd === 'markup') {
|
|
312
|
-
//
|
|
443
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
444
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
445
|
+
if (harmony) {
|
|
446
|
+
harmonyText = harmony;
|
|
447
|
+
} else {
|
|
448
|
+
// Regular markup attached to note
|
|
449
|
+
const text = extractTextFromObject(arg.args);
|
|
450
|
+
if (text && !containsTempoWord(text)) {
|
|
451
|
+
const direction = event.direction;
|
|
452
|
+
const placement: Placement | undefined =
|
|
453
|
+
direction === 'up' ? Placement.above :
|
|
454
|
+
direction === 'down' ? Placement.below : undefined;
|
|
455
|
+
marks.push({ markType: 'markup', content: text, placement });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Handle markup command directly (proto: 'MarkupCommand')
|
|
462
|
+
if (arg && typeof arg === 'object' && arg.proto === 'MarkupCommand') {
|
|
463
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
464
|
+
const harmony = extractHarmonyFromMarkup(arg.args);
|
|
465
|
+
if (harmony) {
|
|
466
|
+
harmonyText = harmony;
|
|
467
|
+
} else {
|
|
313
468
|
const text = extractTextFromObject(arg.args);
|
|
314
469
|
if (text && !containsTempoWord(text)) {
|
|
315
470
|
const direction = event.direction;
|
|
@@ -320,22 +475,10 @@ const parsePostEvents = (postEvents: any[]): Mark[] => {
|
|
|
320
475
|
}
|
|
321
476
|
}
|
|
322
477
|
}
|
|
323
|
-
|
|
324
|
-
// Handle markup command directly (proto: 'Command' with \\markup)
|
|
325
|
-
if (arg && typeof arg === 'object' && arg.proto === 'Command' && arg.cmd === '\\markup') {
|
|
326
|
-
const text = extractTextFromObject(arg.args);
|
|
327
|
-
if (text && !containsTempoWord(text)) {
|
|
328
|
-
const direction = event.direction;
|
|
329
|
-
const placement: Placement | undefined =
|
|
330
|
-
direction === 'up' ? Placement.above :
|
|
331
|
-
direction === 'down' ? Placement.below : undefined;
|
|
332
|
-
marks.push({ markType: 'markup', content: text, placement });
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
478
|
}
|
|
336
479
|
}
|
|
337
480
|
|
|
338
|
-
return marks;
|
|
481
|
+
return { marks, harmonyText };
|
|
339
482
|
};
|
|
340
483
|
|
|
341
484
|
|
|
@@ -353,11 +496,36 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
353
496
|
}
|
|
354
497
|
};
|
|
355
498
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
499
|
+
// Parse staff name to extract partIndex and staff number
|
|
500
|
+
// Format: "partIndex_staffIndex" (e.g., "1_1", "1_2", "2_1")
|
|
501
|
+
// Falls back to partIndex=1 if format doesn't match
|
|
502
|
+
const parseStaffName = (name: string): { partIndex: number; staffNum: number } => {
|
|
503
|
+
const match = name.match(/^(\d+)_(\d+)$/);
|
|
504
|
+
if (match) {
|
|
505
|
+
return { partIndex: parseInt(match[1], 10), staffNum: parseInt(match[2], 10) };
|
|
506
|
+
}
|
|
507
|
+
// Fallback: single part, staff number from name or 1
|
|
508
|
+
const num = parseInt(name, 10);
|
|
509
|
+
return { partIndex: 1, staffNum: isNaN(num) ? 1 : num };
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// Use track.contextDict.Staff as the authoritative staff name (from Staff definition)
|
|
513
|
+
// This won't be affected by \change Staff commands inside the track
|
|
514
|
+
const initialStaffName = track.contextDict?.Staff;
|
|
515
|
+
if (initialStaffName) {
|
|
516
|
+
appendStaff(initialStaffName);
|
|
359
517
|
}
|
|
360
|
-
|
|
518
|
+
const parsedStaff = initialStaffName ? parseStaffName(initialStaffName) : { partIndex: 1, staffNum: 1 };
|
|
519
|
+
// Use these as fixed values for this track - don't update from context.staffName
|
|
520
|
+
const trackStaff = parsedStaff.staffNum;
|
|
521
|
+
const trackPartIndex = parsedStaff.partIndex;
|
|
522
|
+
|
|
523
|
+
// Track emitted context events across measures for this voice
|
|
524
|
+
let lastKey: number | undefined = undefined; // Track value changes (key fifths)
|
|
525
|
+
let lastTimeSig: string | undefined = undefined; // Track value changes (as string for comparison)
|
|
526
|
+
let lastClef: Clef | undefined = undefined; // Track value changes
|
|
527
|
+
let lastOttava: number | undefined = undefined; // Track value changes
|
|
528
|
+
let lastStemDirection: string | undefined = undefined; // Track value changes
|
|
361
529
|
|
|
362
530
|
const context = new lilyParser.TrackContext(undefined, {
|
|
363
531
|
listener: (term: lilyParser.BaseTerm, context: lilyParser.TrackContext) => {
|
|
@@ -373,18 +541,13 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
373
541
|
});
|
|
374
542
|
}
|
|
375
543
|
|
|
376
|
-
// Update staff from context
|
|
377
|
-
if (context.staffName) {
|
|
378
|
-
appendStaff(context.staffName);
|
|
379
|
-
staff = staffNames.indexOf(context.staffName) + 1;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
544
|
const measure = measureMap.get(mi)!;
|
|
383
545
|
|
|
384
|
-
// Initialize voice for this track
|
|
546
|
+
// Initialize voice for this track (use fixed staff/part from track definition)
|
|
385
547
|
if (!measure.voices[vi]) {
|
|
386
548
|
measure.voices[vi] = {
|
|
387
|
-
staff,
|
|
549
|
+
staff: trackStaff,
|
|
550
|
+
partIndex: trackPartIndex,
|
|
388
551
|
events: [],
|
|
389
552
|
};
|
|
390
553
|
}
|
|
@@ -411,30 +574,62 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
411
574
|
|
|
412
575
|
// Handle music events
|
|
413
576
|
if (term instanceof lilyParser.MusicEvent) {
|
|
414
|
-
//
|
|
415
|
-
voice.staff =
|
|
577
|
+
// Staff is fixed per track (from track definition)
|
|
578
|
+
voice.staff = trackStaff;
|
|
579
|
+
|
|
580
|
+
// Handle key context change (emit when value changes)
|
|
581
|
+
if (context.key && context.key.key !== lastKey) {
|
|
582
|
+
const key = convertKeySignature(context.key);
|
|
583
|
+
if (key) {
|
|
584
|
+
voice.events.push({
|
|
585
|
+
type: 'context',
|
|
586
|
+
key,
|
|
587
|
+
});
|
|
588
|
+
lastKey = context.key.key;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Handle time signature context change (emit when value changes)
|
|
593
|
+
if (context.time) {
|
|
594
|
+
const timeSigStr = `${context.time.value.numerator}/${context.time.value.denominator}`;
|
|
595
|
+
if (timeSigStr !== lastTimeSig) {
|
|
596
|
+
voice.events.push({
|
|
597
|
+
type: 'context',
|
|
598
|
+
timeSig: {
|
|
599
|
+
numerator: context.time.value.numerator,
|
|
600
|
+
denominator: context.time.value.denominator,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
lastTimeSig = timeSigStr;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
416
606
|
|
|
417
|
-
// Handle clef context change
|
|
418
|
-
if (context.clef
|
|
607
|
+
// Handle clef context change (emit when value changes)
|
|
608
|
+
if (context.clef) {
|
|
419
609
|
const clef = LILYPOND_CLEF_MAP[context.clef.clefName];
|
|
420
|
-
if (clef) {
|
|
610
|
+
if (clef && clef !== lastClef) {
|
|
421
611
|
voice.events.push({
|
|
422
612
|
type: 'context',
|
|
423
613
|
clef,
|
|
424
614
|
});
|
|
615
|
+
lastClef = clef;
|
|
425
616
|
}
|
|
426
617
|
}
|
|
427
618
|
|
|
428
|
-
// Handle ottava
|
|
429
|
-
if (context.octave
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
619
|
+
// Handle ottava (emit when value changes)
|
|
620
|
+
if (context.octave != null) {
|
|
621
|
+
const currentOttava = context.octave.value ?? 0;
|
|
622
|
+
if (currentOttava !== lastOttava) {
|
|
623
|
+
voice.events.push({
|
|
624
|
+
type: 'context',
|
|
625
|
+
ottava: currentOttava,
|
|
626
|
+
});
|
|
627
|
+
lastOttava = currentOttava;
|
|
628
|
+
}
|
|
434
629
|
}
|
|
435
630
|
|
|
436
|
-
// Handle stem direction context
|
|
437
|
-
if (context.stemDirection &&
|
|
631
|
+
// Handle stem direction context change (emit when value changes)
|
|
632
|
+
if (context.stemDirection && context.stemDirection !== lastStemDirection) {
|
|
438
633
|
const stemDir = context.stemDirection === 'Up' ? StemDirection.up :
|
|
439
634
|
context.stemDirection === 'Down' ? StemDirection.down : undefined;
|
|
440
635
|
if (stemDir) {
|
|
@@ -442,6 +637,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
442
637
|
type: 'context',
|
|
443
638
|
stemDirection: stemDir,
|
|
444
639
|
});
|
|
640
|
+
lastStemDirection = context.stemDirection;
|
|
445
641
|
}
|
|
446
642
|
}
|
|
447
643
|
|
|
@@ -460,7 +656,7 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
460
656
|
}
|
|
461
657
|
|
|
462
658
|
if (pitches.length > 0) {
|
|
463
|
-
const marks = parsePostEvents(term.post_events);
|
|
659
|
+
const { marks, harmonyText } = parsePostEvents(term.post_events);
|
|
464
660
|
|
|
465
661
|
// Add beam marks
|
|
466
662
|
if (term.beamOn) {
|
|
@@ -486,6 +682,15 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
486
682
|
}
|
|
487
683
|
|
|
488
684
|
voice.events.push(noteEvent);
|
|
685
|
+
|
|
686
|
+
// Add harmony event if detected (chord symbol encoded as \bold markup)
|
|
687
|
+
if (harmonyText) {
|
|
688
|
+
const harmonyEvent: HarmonyEvent = {
|
|
689
|
+
type: 'harmony',
|
|
690
|
+
text: harmonyText,
|
|
691
|
+
};
|
|
692
|
+
voice.events.push(harmonyEvent);
|
|
693
|
+
}
|
|
489
694
|
}
|
|
490
695
|
}
|
|
491
696
|
// Process Rest
|
|
@@ -508,40 +713,45 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
508
713
|
voice.events.push(restEvent);
|
|
509
714
|
}
|
|
510
715
|
}
|
|
511
|
-
// Handle standalone stem direction
|
|
716
|
+
// Handle standalone stem direction (emit when value changes)
|
|
512
717
|
else if (term instanceof lilyParser.LilyTerms.StemDirection) {
|
|
513
|
-
|
|
514
|
-
term.direction === '
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
718
|
+
if (term.direction !== lastStemDirection) {
|
|
719
|
+
const stemDir = term.direction === 'Up' ? StemDirection.up :
|
|
720
|
+
term.direction === 'Down' ? StemDirection.down : undefined;
|
|
721
|
+
if (stemDir) {
|
|
722
|
+
voice.events.push({
|
|
723
|
+
type: 'context',
|
|
724
|
+
stemDirection: stemDir,
|
|
725
|
+
});
|
|
726
|
+
lastStemDirection = term.direction;
|
|
727
|
+
}
|
|
520
728
|
}
|
|
521
729
|
}
|
|
522
|
-
// Handle standalone clef
|
|
730
|
+
// Handle standalone clef (emit when value changes)
|
|
523
731
|
else if (term instanceof lilyParser.LilyTerms.Clef) {
|
|
524
732
|
const clef = LILYPOND_CLEF_MAP[term.clefName];
|
|
525
|
-
if (clef) {
|
|
733
|
+
if (clef && clef !== lastClef) {
|
|
526
734
|
voice.events.push({
|
|
527
735
|
type: 'context',
|
|
528
736
|
clef,
|
|
529
737
|
});
|
|
738
|
+
lastClef = clef;
|
|
530
739
|
}
|
|
531
740
|
}
|
|
532
741
|
// Handle ottava shift
|
|
533
742
|
else if (term instanceof lilyParser.LilyTerms.OctaveShift) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
743
|
+
if (term.value !== lastOttava) {
|
|
744
|
+
voice.events.push({
|
|
745
|
+
type: 'context',
|
|
746
|
+
ottava: term.value,
|
|
747
|
+
});
|
|
748
|
+
lastOttava = term.value;
|
|
749
|
+
}
|
|
538
750
|
}
|
|
539
751
|
// Handle staff change
|
|
540
752
|
else if (term instanceof lilyParser.LilyTerms.Change) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
voice.staff = staff;
|
|
544
|
-
}
|
|
753
|
+
// Ignore \change Staff commands - staff is fixed per track
|
|
754
|
+
// (Cross-staff notation is not supported in this decoder)
|
|
545
755
|
}
|
|
546
756
|
// Handle tempo
|
|
547
757
|
else if (term instanceof lilyParser.LilyTerms.Tempo) {
|
|
@@ -553,17 +763,146 @@ const parseLilyDocument = (lilyDocument: lilyParser.LilyDocument): ParsedMeasure
|
|
|
553
763
|
});
|
|
554
764
|
}
|
|
555
765
|
}
|
|
556
|
-
// Handle standalone markup command
|
|
766
|
+
// Handle standalone markup command and barlines
|
|
557
767
|
else {
|
|
558
768
|
const termAny = term as any;
|
|
559
769
|
if (termAny.proto === 'Command' && (termAny.cmd === '\\markup' || termAny.cmd === 'markup')) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
770
|
+
// Check if this is a harmony (chord symbol) - marked with \bold
|
|
771
|
+
const harmonyText = extractHarmonyFromMarkup(termAny.args);
|
|
772
|
+
if (harmonyText) {
|
|
773
|
+
const harmonyEvent: HarmonyEvent = {
|
|
774
|
+
type: 'harmony',
|
|
775
|
+
text: harmonyText,
|
|
565
776
|
};
|
|
566
|
-
voice.events.push(
|
|
777
|
+
voice.events.push(harmonyEvent);
|
|
778
|
+
} else {
|
|
779
|
+
const text = extractTextFromObject(termAny.args);
|
|
780
|
+
if (text && !containsTempoWord(text)) {
|
|
781
|
+
const markupEvent: MarkupEvent = {
|
|
782
|
+
type: 'markup',
|
|
783
|
+
content: text,
|
|
784
|
+
};
|
|
785
|
+
voice.events.push(markupEvent);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Handle barline command - barlines belong to the previous measure
|
|
790
|
+
else if (termAny.proto === 'Command' && termAny.cmd === 'bar') {
|
|
791
|
+
const style = termAny.args?.[0]?.exp;
|
|
792
|
+
if (style && mi > 0) {
|
|
793
|
+
// Remove quotes from the style string
|
|
794
|
+
const barStyle = style.replace(/^"|"$/g, '');
|
|
795
|
+
// Add to previous measure's voice
|
|
796
|
+
const prevMeasure = measureMap.get(mi - 1);
|
|
797
|
+
if (prevMeasure && prevMeasure.voices[vi]) {
|
|
798
|
+
prevMeasure.voices[vi].events.push({
|
|
799
|
+
type: 'barline',
|
|
800
|
+
style: barStyle,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// Handle ChordSymbol (inline chord symbol: \chords "text")
|
|
806
|
+
else if (termAny.proto === 'ChordSymbol') {
|
|
807
|
+
// Extract text from LiteralString (e.g., { exp: '"C"' } -> "C")
|
|
808
|
+
let text = termAny.text;
|
|
809
|
+
if (typeof text === 'object' && text?.exp) {
|
|
810
|
+
text = text.exp.replace(/^"|"$/g, '');
|
|
811
|
+
} else if (typeof text === 'string') {
|
|
812
|
+
text = text.replace(/^"|"$/g, '');
|
|
813
|
+
}
|
|
814
|
+
const harmonyEvent: HarmonyEvent = {
|
|
815
|
+
type: 'harmony',
|
|
816
|
+
text: text,
|
|
817
|
+
};
|
|
818
|
+
voice.events.push(harmonyEvent);
|
|
819
|
+
}
|
|
820
|
+
// Handle tuplet
|
|
821
|
+
// Note: Lotus emits Chord events BEFORE the Tuplet term, so we need to
|
|
822
|
+
// remove the already-added notes and wrap them in a TupletEvent
|
|
823
|
+
else if (termAny.proto === 'Tuplet') {
|
|
824
|
+
const ratioStr = termAny.args?.[0]; // e.g., "3/2"
|
|
825
|
+
const body = termAny.args?.[1]?.body || [];
|
|
826
|
+
|
|
827
|
+
if (ratioStr && body.length > 0) {
|
|
828
|
+
// Parse ratio string
|
|
829
|
+
const ratioMatch = ratioStr.match(/^(\d+)\/(\d+)$/);
|
|
830
|
+
if (ratioMatch) {
|
|
831
|
+
const [, num, denom] = ratioMatch;
|
|
832
|
+
const ratio: Fraction = {
|
|
833
|
+
numerator: parseInt(denom, 10), // Swapped: lilylet uses actual/normal
|
|
834
|
+
denominator: parseInt(num, 10),
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// Count how many note/rest events are in the tuplet body
|
|
838
|
+
const noteCount = body.filter((item: any) =>
|
|
839
|
+
item.proto === 'Chord' || item.proto === 'Rest'
|
|
840
|
+
).length;
|
|
841
|
+
|
|
842
|
+
// Remove the last noteCount note/rest events from voice.events
|
|
843
|
+
// (they were already added by the Chord/Rest handlers)
|
|
844
|
+
const tupletEvents: Event[] = [];
|
|
845
|
+
let removed = 0;
|
|
846
|
+
while (removed < noteCount && voice.events.length > 0) {
|
|
847
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
848
|
+
if (lastEvent.type === 'note' || lastEvent.type === 'rest') {
|
|
849
|
+
tupletEvents.unshift(voice.events.pop()!);
|
|
850
|
+
removed++;
|
|
851
|
+
} else {
|
|
852
|
+
break; // Stop if we hit a non-note/rest event
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (tupletEvents.length > 0) {
|
|
857
|
+
const tupletEvent: TupletEvent = {
|
|
858
|
+
type: 'tuplet',
|
|
859
|
+
ratio,
|
|
860
|
+
events: tupletEvents,
|
|
861
|
+
};
|
|
862
|
+
voice.events.push(tupletEvent);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Handle repeat tremolo
|
|
868
|
+
else if (termAny.proto === 'Repeat' && termAny.args?.[0] === 'tremolo') {
|
|
869
|
+
const count = parseInt(termAny.args?.[1], 10);
|
|
870
|
+
const body = termAny.args?.[2]?.body || [];
|
|
871
|
+
|
|
872
|
+
if (!isNaN(count) && body.length === 2) {
|
|
873
|
+
// Double tremolo has exactly 2 pitches
|
|
874
|
+
const pitch1 = body[0]?.pitches?.[0]?.pitch;
|
|
875
|
+
const pitch2 = body[1]?.pitches?.[0]?.pitch;
|
|
876
|
+
const duration = body[0]?.duration;
|
|
877
|
+
|
|
878
|
+
if (pitch1 && pitch2 && duration) {
|
|
879
|
+
const pitchA = parseRawPitch(pitch1);
|
|
880
|
+
const pitchB = parseRawPitch(pitch2);
|
|
881
|
+
const div = parseInt(duration.number, 10);
|
|
882
|
+
|
|
883
|
+
if (pitchA && pitchB && !isNaN(div)) {
|
|
884
|
+
// Remove the 2 notes that were already added
|
|
885
|
+
let removed = 0;
|
|
886
|
+
while (removed < 2 && voice.events.length > 0) {
|
|
887
|
+
const lastEvent = voice.events[voice.events.length - 1];
|
|
888
|
+
if (lastEvent.type === 'note') {
|
|
889
|
+
voice.events.pop();
|
|
890
|
+
removed++;
|
|
891
|
+
} else {
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const tremoloEvent: TremoloEvent = {
|
|
897
|
+
type: 'tremolo',
|
|
898
|
+
pitchA: [pitchA],
|
|
899
|
+
pitchB: [pitchB],
|
|
900
|
+
count,
|
|
901
|
+
division: div,
|
|
902
|
+
};
|
|
903
|
+
voice.events.push(tremoloEvent);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
567
906
|
}
|
|
568
907
|
}
|
|
569
908
|
}
|
|
@@ -590,6 +929,8 @@ const hasRealContent = (events: Event[]): boolean => {
|
|
|
590
929
|
return events.some(e => {
|
|
591
930
|
if (e.type === 'note') return true;
|
|
592
931
|
if (e.type === 'rest' && !(e as RestEvent).invisible) return true;
|
|
932
|
+
if (e.type === 'tuplet') return true;
|
|
933
|
+
if (e.type === 'tremolo') return true;
|
|
593
934
|
return false;
|
|
594
935
|
});
|
|
595
936
|
};
|
|
@@ -672,6 +1013,43 @@ const extractTextFromObject = (obj: any): string | undefined => {
|
|
|
672
1013
|
};
|
|
673
1014
|
|
|
674
1015
|
|
|
1016
|
+
// Check if markup contains \bold command (indicates harmony/chord symbol)
|
|
1017
|
+
// Returns the text if it's a harmony, undefined otherwise
|
|
1018
|
+
const extractHarmonyFromMarkup = (obj: any): string | undefined => {
|
|
1019
|
+
if (!obj) return undefined;
|
|
1020
|
+
|
|
1021
|
+
// Check array of args
|
|
1022
|
+
if (Array.isArray(obj)) {
|
|
1023
|
+
for (const item of obj) {
|
|
1024
|
+
const result = extractHarmonyFromMarkup(item);
|
|
1025
|
+
if (result !== undefined) return result;
|
|
1026
|
+
}
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (obj && typeof obj === 'object') {
|
|
1031
|
+
// Check if this is a \bold command (can be Command or MarkupCommand)
|
|
1032
|
+
if ((obj.proto === 'Command' || obj.proto === 'MarkupCommand') &&
|
|
1033
|
+
(obj.cmd === 'bold' || obj.cmd === '\\bold')) {
|
|
1034
|
+
// Extract the text from args
|
|
1035
|
+
return extractTextFromObject(obj.args);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Recursively search InlineBlock body
|
|
1039
|
+
if (obj.proto === 'InlineBlock' && obj.body) {
|
|
1040
|
+
return extractHarmonyFromMarkup(obj.body);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Recursively search args
|
|
1044
|
+
if (obj.args) {
|
|
1045
|
+
return extractHarmonyFromMarkup(obj.args);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return undefined;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
|
|
675
1053
|
// Extract string value from header field
|
|
676
1054
|
const extractStringValue = (value: any): string | undefined => {
|
|
677
1055
|
const text = extractTextFromObject(value);
|
|
@@ -726,17 +1104,30 @@ const extractMetadata = (lilyDocument: lilyParser.LilyDocument): Metadata | unde
|
|
|
726
1104
|
const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadata): LilyletDoc => {
|
|
727
1105
|
const measures: Measure[] = parsedMeasures.map(pm => {
|
|
728
1106
|
// Filter out voices that only contain spacer rests and context changes
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
1107
|
+
const filteredVoices = pm.voices.filter(v => hasRealContent(v.events));
|
|
1108
|
+
|
|
1109
|
+
// Group voices by partIndex
|
|
1110
|
+
const partMap = new Map<number, Array<{ staff: number; events: Event[] }>>();
|
|
1111
|
+
for (const v of filteredVoices) {
|
|
1112
|
+
const pi = v.partIndex || 1;
|
|
1113
|
+
if (!partMap.has(pi)) {
|
|
1114
|
+
partMap.set(pi, []);
|
|
1115
|
+
}
|
|
1116
|
+
partMap.get(pi)!.push({
|
|
732
1117
|
staff: v.staff,
|
|
733
1118
|
events: v.events,
|
|
734
|
-
})
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Convert to parts array (sorted by part index)
|
|
1123
|
+
const partIndices = Array.from(partMap.keys()).sort((a, b) => a - b);
|
|
1124
|
+
const parts = partIndices.map(pi => ({
|
|
1125
|
+
voices: partMap.get(pi)!,
|
|
1126
|
+
}));
|
|
735
1127
|
|
|
1128
|
+
// Fallback to single empty part if no voices
|
|
736
1129
|
const measure: Measure = {
|
|
737
|
-
parts: [{
|
|
738
|
-
voices,
|
|
739
|
-
}],
|
|
1130
|
+
parts: parts.length > 0 ? parts : [{ voices: [] }],
|
|
740
1131
|
};
|
|
741
1132
|
|
|
742
1133
|
if (pm.key !== null) {
|
|
@@ -750,7 +1141,9 @@ const parsedMeasuresToDoc = (parsedMeasures: ParsedMeasure[], metadata?: Metadat
|
|
|
750
1141
|
}
|
|
751
1142
|
|
|
752
1143
|
return measure;
|
|
753
|
-
})
|
|
1144
|
+
})
|
|
1145
|
+
// Filter out empty measures (no voices in any part)
|
|
1146
|
+
.filter(m => m.parts.some(p => p.voices.length > 0));
|
|
754
1147
|
|
|
755
1148
|
const doc: LilyletDoc = { measures };
|
|
756
1149
|
if (metadata) {
|