@k-l-lambda/lilylet 0.1.50 → 0.1.52
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/lilylet/grammar.jison.js +124 -119
- package/lib/lilylet/meiEncoder.js +50 -6
- package/lib/lilylet/serializer.js +70 -20
- package/lib/lilylet/types.d.ts +2 -1
- package/lib/lilylet/types.js +1 -0
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +124 -119
- package/source/lilylet/lilylet.jison +10 -3
- package/source/lilylet/meiEncoder.ts +53 -6
- package/source/lilylet/serializer.ts +73 -21
- package/source/lilylet/types.ts +1 -0
|
@@ -85,6 +85,7 @@ const DYNAMIC_MAP = {
|
|
|
85
85
|
fff: "fff",
|
|
86
86
|
sfz: "sfz",
|
|
87
87
|
rfz: "rfz",
|
|
88
|
+
fp: "fp",
|
|
88
89
|
};
|
|
89
90
|
// ID generation state - uses session prefix to prevent collisions in concurrent encoding
|
|
90
91
|
let idCounter = 0;
|
|
@@ -408,6 +409,11 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
408
409
|
if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
|
|
409
410
|
chordAttrs += ` staff="${noteOptions.staff}"`;
|
|
410
411
|
}
|
|
412
|
+
if (noteOptions.tremolo) {
|
|
413
|
+
const stemMod = tremoloToStemMod(noteOptions.tremolo);
|
|
414
|
+
if (stemMod)
|
|
415
|
+
chordAttrs += ` stem.mod="${stemMod}"`;
|
|
416
|
+
}
|
|
411
417
|
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
412
418
|
for (const p of event.pitches) {
|
|
413
419
|
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
@@ -470,6 +476,22 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
470
476
|
}
|
|
471
477
|
return `${indent}<rest ${attrs} />\n`;
|
|
472
478
|
};
|
|
479
|
+
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
480
|
+
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
481
|
+
const tupletHasInternalBeams = (event) => {
|
|
482
|
+
let starts = 0;
|
|
483
|
+
let ends = 0;
|
|
484
|
+
for (const e of event.events) {
|
|
485
|
+
if (e.type === 'note') {
|
|
486
|
+
const markOptions = extractMarkOptions(e.marks);
|
|
487
|
+
if (markOptions.beamStart)
|
|
488
|
+
starts++;
|
|
489
|
+
if (markOptions.beamEnd)
|
|
490
|
+
ends++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return starts > 0 && starts === ends;
|
|
494
|
+
};
|
|
473
495
|
// Convert TupletEvent to MEI
|
|
474
496
|
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
|
|
475
497
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
@@ -490,17 +512,24 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
490
512
|
const mordents = [];
|
|
491
513
|
const turns = [];
|
|
492
514
|
const arpeggios = [];
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
515
|
+
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
516
|
+
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
517
|
+
let beamOpen = false;
|
|
496
518
|
for (const e of event.events) {
|
|
497
519
|
if (e.type === 'note') {
|
|
498
|
-
// For cross-staff notation: set note's staff if different from layerStaff
|
|
499
520
|
const noteEvent = e;
|
|
521
|
+
const markOptions = extractMarkOptions(noteEvent.marks);
|
|
522
|
+
// Open beam if this note starts a beam group
|
|
523
|
+
if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
|
|
524
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
525
|
+
beamOpen = true;
|
|
526
|
+
}
|
|
527
|
+
const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
528
|
+
// For cross-staff notation: set note's staff if different from layerStaff
|
|
500
529
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
501
530
|
? { ...noteEvent, staff: effectiveStaff }
|
|
502
531
|
: noteEvent;
|
|
503
|
-
const result = noteEventToMEI(effectiveNoteEvent,
|
|
532
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
504
533
|
xml += result.xml;
|
|
505
534
|
// Collect slur info
|
|
506
535
|
if (result.slurStart)
|
|
@@ -520,11 +549,21 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
520
549
|
turns.push({ startid: result.elementId });
|
|
521
550
|
if (result.arpeggio)
|
|
522
551
|
arpeggios.push({ plist: result.elementId });
|
|
552
|
+
// Close beam if this note ends a beam group
|
|
553
|
+
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
554
|
+
xml += `${baseIndent}</beam>\n`;
|
|
555
|
+
beamOpen = false;
|
|
556
|
+
}
|
|
523
557
|
}
|
|
524
558
|
else if (e.type === 'rest') {
|
|
525
|
-
|
|
559
|
+
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
560
|
+
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift);
|
|
526
561
|
}
|
|
527
562
|
}
|
|
563
|
+
// Close any unclosed beam
|
|
564
|
+
if (beamOpen) {
|
|
565
|
+
xml += `${baseIndent}</beam>\n`;
|
|
566
|
+
}
|
|
528
567
|
xml += `${indent}</tuplet>\n`;
|
|
529
568
|
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
530
569
|
};
|
|
@@ -603,6 +642,11 @@ const getEventBeamMarks = (event) => {
|
|
|
603
642
|
}
|
|
604
643
|
if (event.type === 'tuplet') {
|
|
605
644
|
const tuplet = event;
|
|
645
|
+
// If the tuplet has internal beam groups, don't report beam marks to the parent
|
|
646
|
+
// so the parent won't wrap the tuplet in an external <beam>
|
|
647
|
+
if (tupletHasInternalBeams(tuplet)) {
|
|
648
|
+
return { beamStart: false, beamEnd: false };
|
|
649
|
+
}
|
|
606
650
|
let beamStart = false;
|
|
607
651
|
let beamEnd = false;
|
|
608
652
|
for (const e of tuplet.events) {
|
|
@@ -89,6 +89,7 @@ const DYNAMIC_MAP = {
|
|
|
89
89
|
fff: '\\fff',
|
|
90
90
|
sfz: '\\sfz',
|
|
91
91
|
rfz: '\\rfz',
|
|
92
|
+
fp: '\\fp',
|
|
92
93
|
};
|
|
93
94
|
// Hairpin to Lilylet notation
|
|
94
95
|
const HAIRPIN_MAP = {
|
|
@@ -190,6 +191,9 @@ const serializeMarks = (marks) => {
|
|
|
190
191
|
parts.push(pedalStr);
|
|
191
192
|
break;
|
|
192
193
|
}
|
|
194
|
+
case 'fingering':
|
|
195
|
+
parts.push('-' + mark.finger);
|
|
196
|
+
break;
|
|
193
197
|
}
|
|
194
198
|
}
|
|
195
199
|
return parts.join('');
|
|
@@ -449,8 +453,9 @@ const findVoiceClef = (voice) => {
|
|
|
449
453
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
450
454
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
451
455
|
// measureContext provides key/time for first voice
|
|
452
|
-
//
|
|
453
|
-
|
|
456
|
+
// allStaffClefs is the clef map for all staves (tracked across measures)
|
|
457
|
+
// emittedClefs tracks which clefs have already been output (avoids duplicates)
|
|
458
|
+
const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, allStaffClefs, emittedClefs) => {
|
|
454
459
|
const parts = [];
|
|
455
460
|
let prevDuration;
|
|
456
461
|
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
@@ -480,22 +485,61 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
480
485
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
481
486
|
}
|
|
482
487
|
}
|
|
483
|
-
// Output clef
|
|
484
|
-
const voiceClef =
|
|
485
|
-
|
|
488
|
+
// Output clef only if not yet emitted or changed for this staff
|
|
489
|
+
const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
|
|
490
|
+
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
491
|
+
if (voiceClef && !clefAlreadyEmitted) {
|
|
486
492
|
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
493
|
+
if (emittedClefs)
|
|
494
|
+
emittedClefs[voice.staff] = voiceClef;
|
|
487
495
|
}
|
|
488
|
-
//
|
|
489
|
-
|
|
496
|
+
// Skip redundant clef context events if this staff's clef is already established
|
|
497
|
+
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
498
|
+
let activeStaff = voice.staff;
|
|
499
|
+
let activeStemDir;
|
|
490
500
|
for (const event of voice.events) {
|
|
491
|
-
|
|
492
|
-
if (clefOutputted && event.type === 'context') {
|
|
501
|
+
if (event.type === 'context') {
|
|
493
502
|
const ctx = event;
|
|
494
|
-
|
|
495
|
-
|
|
503
|
+
// Skip context events that belong to a different staff (cross-staff clef/ottava)
|
|
504
|
+
if (ctx.staff && ctx.staff !== voice.staff) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
// Skip clef-only context events if clef already established for this staff
|
|
508
|
+
if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
|
|
496
509
|
continue;
|
|
497
510
|
}
|
|
498
511
|
}
|
|
512
|
+
if (event.type === 'note') {
|
|
513
|
+
const noteEvt = event;
|
|
514
|
+
// Cross-staff: emit \staff when note's effective staff differs from active
|
|
515
|
+
const effectiveStaff = noteEvt.staff || voice.staff;
|
|
516
|
+
if (effectiveStaff !== activeStaff) {
|
|
517
|
+
activeStaff = effectiveStaff;
|
|
518
|
+
parts.push('\\staff "' + activeStaff + '"');
|
|
519
|
+
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
520
|
+
const targetClef = allStaffClefs?.[activeStaff];
|
|
521
|
+
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
522
|
+
parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
|
|
523
|
+
if (emittedClefs)
|
|
524
|
+
emittedClefs[activeStaff] = targetClef;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
|
|
528
|
+
const stemDir = noteEvt.stemDirection;
|
|
529
|
+
if (stemDir !== activeStemDir) {
|
|
530
|
+
if (stemDir === StemDirection.up) {
|
|
531
|
+
parts.push('\\stemUp');
|
|
532
|
+
}
|
|
533
|
+
else if (stemDir === StemDirection.down) {
|
|
534
|
+
parts.push('\\stemDown');
|
|
535
|
+
}
|
|
536
|
+
else if (activeStemDir) {
|
|
537
|
+
// Was set, now undefined → reset to neutral
|
|
538
|
+
parts.push('\\stemNeutral');
|
|
539
|
+
}
|
|
540
|
+
activeStemDir = stemDir;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
499
543
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
500
544
|
pitchEnv = newEnv;
|
|
501
545
|
if (eventStr) {
|
|
@@ -508,12 +552,16 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
508
552
|
else if (event.type === 'rest') {
|
|
509
553
|
prevDuration = event.duration;
|
|
510
554
|
}
|
|
555
|
+
else if (event.type === 'context' && event.clef && emittedClefs) {
|
|
556
|
+
const ctx = event;
|
|
557
|
+
emittedClefs[ctx.staff || activeStaff] = ctx.clef;
|
|
558
|
+
}
|
|
511
559
|
}
|
|
512
560
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
513
561
|
};
|
|
514
562
|
// Serialize a part, tracking staff state across voices
|
|
515
563
|
// measureContext is passed to all voices (for clef), but key/time only to first voice
|
|
516
|
-
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff) => {
|
|
564
|
+
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff, emittedClefs) => {
|
|
517
565
|
if (part.voices.length === 0) {
|
|
518
566
|
return { str: '', newStaff: currentStaff };
|
|
519
567
|
}
|
|
@@ -522,10 +570,8 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
522
570
|
for (let i = 0; i < part.voices.length; i++) {
|
|
523
571
|
const voice = part.voices[i];
|
|
524
572
|
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
525
|
-
// Pass staff clef from clefsByStaff map
|
|
526
573
|
const isFirstVoice = isFirstPart && i === 0;
|
|
527
|
-
const
|
|
528
|
-
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
574
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
|
|
529
575
|
voiceStrs.push(str);
|
|
530
576
|
staff = newStaff;
|
|
531
577
|
}
|
|
@@ -534,7 +580,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
|
|
|
534
580
|
};
|
|
535
581
|
// Serialize a measure, tracking staff state across parts
|
|
536
582
|
// Always output key/time at start of each measure
|
|
537
|
-
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
|
|
583
|
+
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs, emittedClefs) => {
|
|
538
584
|
const parts = [];
|
|
539
585
|
// Build measure context for all voices (key/time)
|
|
540
586
|
// Key and time are written to first voice, clef to all voices based on staff
|
|
@@ -548,7 +594,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
548
594
|
// Parts
|
|
549
595
|
let staff = currentStaff;
|
|
550
596
|
if (measure.parts.length === 1) {
|
|
551
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
597
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
|
|
552
598
|
if (partStr) {
|
|
553
599
|
parts.push(partStr);
|
|
554
600
|
}
|
|
@@ -560,7 +606,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
|
|
|
560
606
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
561
607
|
const part = measure.parts[i];
|
|
562
608
|
// Pass measureContext to all parts, isFirstPart to first part only
|
|
563
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
609
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
|
|
564
610
|
if (str) {
|
|
565
611
|
partStrs.push(str);
|
|
566
612
|
}
|
|
@@ -620,6 +666,7 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
620
666
|
let currentKey;
|
|
621
667
|
let currentTime;
|
|
622
668
|
const staffClefs = {}; // Track clef per staff
|
|
669
|
+
const emittedClefs = {}; // Track which clefs have been output
|
|
623
670
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
624
671
|
const measure = doc.measures[i];
|
|
625
672
|
// Update current key/time if measure has them
|
|
@@ -634,12 +681,15 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
634
681
|
for (const voice of part.voices) {
|
|
635
682
|
for (const event of voice.events) {
|
|
636
683
|
if (event.type === 'context' && event.clef) {
|
|
637
|
-
|
|
684
|
+
const ctx = event;
|
|
685
|
+
// Use the event's staff if specified (cross-staff), otherwise the voice's staff
|
|
686
|
+
const clefStaff = ctx.staff || voice.staff;
|
|
687
|
+
staffClefs[clefStaff] = ctx.clef;
|
|
638
688
|
}
|
|
639
689
|
}
|
|
640
690
|
}
|
|
641
691
|
}
|
|
642
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
692
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
|
|
643
693
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
644
694
|
measureStrs.push(measureStr || 's1');
|
|
645
695
|
currentStaff = newStaff;
|
package/lib/lilylet/types.d.ts
CHANGED
package/lib/lilylet/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.52",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|