@k-l-lambda/lilylet 0.1.67 → 0.1.68
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/abc/grammar.jison.js +73 -73
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +158 -36
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +31 -6
- package/lib/lilylet/serializer.js +53 -25
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +1 -2
- package/source/abc/abc.jison +1 -2
- package/source/abc/grammar.jison.js +73 -73
- package/source/lilylet/abcDecoder.ts +169 -30
- package/source/lilylet/grammar.jison.js +195 -185
- package/source/lilylet/lilylet.jison +8 -0
- package/source/lilylet/meiEncoder.ts +36 -8
- package/source/lilylet/serializer.ts +55 -24
- package/source/lilylet/types.ts +6 -1
|
@@ -538,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
538
538
|
// Convert RestEvent to MEI
|
|
539
539
|
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
|
|
540
540
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
541
|
-
|
|
541
|
+
const restId = generateId('rest');
|
|
542
|
+
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
542
543
|
if (event.duration.dots > 0)
|
|
543
544
|
attrs += ` dots="${event.duration.dots}"`;
|
|
544
545
|
// Cross-staff attribute
|
|
@@ -551,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
551
552
|
}
|
|
552
553
|
// Space rest (invisible)
|
|
553
554
|
if (event.invisible) {
|
|
554
|
-
return `${indent}<space ${attrs} />\n
|
|
555
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
555
556
|
}
|
|
556
557
|
// Full measure rest
|
|
557
558
|
if (event.fullMeasure) {
|
|
558
|
-
|
|
559
|
+
const mRestId = generateId('mrest');
|
|
560
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
559
561
|
}
|
|
560
|
-
return `${indent}<rest ${attrs} />\n
|
|
562
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
561
563
|
};
|
|
562
564
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
563
565
|
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
@@ -645,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
645
647
|
}
|
|
646
648
|
else if (e.type === 'rest') {
|
|
647
649
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
648
|
-
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
650
|
+
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
|
|
649
651
|
}
|
|
650
652
|
else if (e.type === 'context') {
|
|
651
653
|
const ctx = e;
|
|
@@ -809,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
809
811
|
const barlines = [];
|
|
810
812
|
const markups = [];
|
|
811
813
|
const pendingMarkups = [];
|
|
814
|
+
const pendingDynamics = [];
|
|
812
815
|
// Track current stem direction from context changes
|
|
813
816
|
let currentStemDirection = undefined;
|
|
814
817
|
// Track current staff for cross-staff notation
|
|
@@ -824,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
824
827
|
}
|
|
825
828
|
pendingMarkups.length = 0;
|
|
826
829
|
};
|
|
830
|
+
// Helper to flush pending leading dynamics onto a note ID
|
|
831
|
+
const flushPendingDynamics = (noteId) => {
|
|
832
|
+
for (const label of pendingDynamics) {
|
|
833
|
+
dynamics.push({ startid: noteId, label });
|
|
834
|
+
}
|
|
835
|
+
pendingDynamics.length = 0;
|
|
836
|
+
};
|
|
827
837
|
// Helper to check if pitches match for tie continuation
|
|
828
838
|
const pitchesMatch = (p1, p2) => {
|
|
829
839
|
if (p1.length !== p2.length)
|
|
@@ -884,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
884
894
|
}
|
|
885
895
|
// Flush any pending markups onto this note
|
|
886
896
|
flushPendingMarkups(result.elementId);
|
|
897
|
+
// Flush any pending leading dynamics onto this note
|
|
898
|
+
flushPendingDynamics(result.elementId);
|
|
887
899
|
// If there's a pending ottava, start the span on this note
|
|
888
900
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
889
901
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -996,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
996
1008
|
case 'rest': {
|
|
997
1009
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
998
1010
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
999
|
-
|
|
1011
|
+
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1012
|
+
xml += restResult.xml;
|
|
1013
|
+
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1014
|
+
flushPendingMarkups(restResult.elementId);
|
|
1015
|
+
flushPendingDynamics(restResult.elementId);
|
|
1016
|
+
lastNoteId = restResult.elementId;
|
|
1000
1017
|
break;
|
|
1001
1018
|
}
|
|
1002
1019
|
case 'tuplet':
|
|
@@ -1012,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1012
1029
|
// Flush any pending markups onto the first note of the tuplet
|
|
1013
1030
|
if (tupletResult.firstNoteId) {
|
|
1014
1031
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1032
|
+
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1015
1033
|
lastNoteId = tupletResult.firstNoteId;
|
|
1016
1034
|
}
|
|
1017
1035
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
@@ -1127,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1127
1145
|
}
|
|
1128
1146
|
}
|
|
1129
1147
|
break;
|
|
1148
|
+
case 'dynamic':
|
|
1149
|
+
{
|
|
1150
|
+
// Standalone (leading) dynamic - attaches to the following note
|
|
1151
|
+
const dynEvent = event;
|
|
1152
|
+
pendingDynamics.push(dynEvent.dynamicType);
|
|
1153
|
+
}
|
|
1154
|
+
break;
|
|
1130
1155
|
}
|
|
1131
1156
|
// Close beam element if beam ends
|
|
1132
1157
|
if (beamEnd) {
|
|
@@ -455,12 +455,25 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
455
455
|
return serializeTremoloEvent(event, env);
|
|
456
456
|
case 'barline':
|
|
457
457
|
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
458
|
+
case 'markup': {
|
|
459
|
+
const mk = event;
|
|
460
|
+
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
461
|
+
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
462
|
+
}
|
|
463
|
+
case 'dynamic': {
|
|
464
|
+
const dynStr = DYNAMIC_MAP[event.dynamicType];
|
|
465
|
+
return { str: dynStr || '', newEnv: env };
|
|
466
|
+
}
|
|
458
467
|
default:
|
|
459
468
|
return { str: '', newEnv: env };
|
|
460
469
|
}
|
|
461
470
|
};
|
|
462
|
-
// Find
|
|
471
|
+
// Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
|
|
472
|
+
// a clef context event that appears before any musical event on the home staff.
|
|
473
|
+
// A clef that first appears AFTER a note is a mid-voice change and must be emitted
|
|
474
|
+
// inline where it occurs, not hoisted to the front — so it is not returned here.
|
|
463
475
|
const findVoiceClef = (voice) => {
|
|
476
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
464
477
|
let activeStaff = voice.staff;
|
|
465
478
|
for (const event of voice.events) {
|
|
466
479
|
if (event.type === 'context') {
|
|
@@ -471,6 +484,11 @@ const findVoiceClef = (voice) => {
|
|
|
471
484
|
return ctx.clef;
|
|
472
485
|
}
|
|
473
486
|
}
|
|
487
|
+
else if (MUSICAL.has(event.type)) {
|
|
488
|
+
// Reached music on the home staff before any clef — no leading clef.
|
|
489
|
+
if (activeStaff === voice.staff)
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
474
492
|
}
|
|
475
493
|
return undefined;
|
|
476
494
|
};
|
|
@@ -490,7 +508,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
490
508
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
491
509
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
492
510
|
// context{staff} events before this index are skipped in the main loop.
|
|
493
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
494
511
|
let effectiveInitialStaff = voice.staff;
|
|
495
512
|
let leadStaffScanEnd = 0;
|
|
496
513
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -516,8 +533,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
516
533
|
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
517
534
|
continue;
|
|
518
535
|
}
|
|
519
|
-
|
|
520
|
-
|
|
536
|
+
// Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
|
|
537
|
+
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
538
|
+
break;
|
|
521
539
|
}
|
|
522
540
|
// Output staff command if voice staff differs from current parser staff,
|
|
523
541
|
// or always output if it's a grand staff score for clarity.
|
|
@@ -545,16 +563,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
545
563
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
546
564
|
}
|
|
547
565
|
}
|
|
548
|
-
// Output clef only if not yet emitted or changed for this staff
|
|
549
|
-
|
|
566
|
+
// Output clef only if not yet emitted or changed for this staff.
|
|
567
|
+
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
568
|
+
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
569
|
+
// hoisted here — it is emitted inline at its position.
|
|
570
|
+
const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
|
|
550
571
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
551
572
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
552
573
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
553
574
|
if (emittedClefs)
|
|
554
575
|
emittedClefs[voice.staff] = voiceClef;
|
|
555
576
|
}
|
|
556
|
-
// Skip redundant clef context events if this staff's clef is already established
|
|
557
|
-
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
558
577
|
let activeStaff = effectiveInitialStaff;
|
|
559
578
|
let activeStemDir;
|
|
560
579
|
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
@@ -581,8 +600,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
581
600
|
if (ctx.staff && !ctx.clef && !ctx.ottava)
|
|
582
601
|
continue; // same staff, pure no-op
|
|
583
602
|
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
584
|
-
// Skip clef-only context
|
|
585
|
-
|
|
603
|
+
// Skip a clef-only context event only if it is REDUNDANT — i.e. it restates the
|
|
604
|
+
// clef already active for this staff. A clef that differs is a genuine change and
|
|
605
|
+
// must be emitted inline at its position.
|
|
606
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
|
|
607
|
+
emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
|
|
586
608
|
continue;
|
|
587
609
|
}
|
|
588
610
|
}
|
|
@@ -775,25 +797,31 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
775
797
|
if (measure.timeSig) {
|
|
776
798
|
currentTime = measure.timeSig;
|
|
777
799
|
}
|
|
778
|
-
// Collect clefs from this measure's voices, per part
|
|
779
|
-
measure
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
800
|
+
// Collect clefs from this measure's voices, per part — but only AFTER serializing
|
|
801
|
+
// the measure, so that during serialization allStaffClefs reflects the clef state
|
|
802
|
+
// CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
|
|
803
|
+
// to the measure's front; it is emitted inline at its position).
|
|
804
|
+
const collectClefs = () => {
|
|
805
|
+
measure.parts.forEach((part, pi) => {
|
|
806
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
807
|
+
for (const voice of part.voices) {
|
|
808
|
+
let clefActiveStaff = voice.staff;
|
|
809
|
+
for (const event of voice.events) {
|
|
810
|
+
if (event.type === 'context') {
|
|
811
|
+
const ctx = event;
|
|
812
|
+
if (ctx.staff) {
|
|
813
|
+
clefActiveStaff = ctx.staff;
|
|
814
|
+
}
|
|
815
|
+
if (ctx.clef) {
|
|
816
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
817
|
+
}
|
|
791
818
|
}
|
|
792
819
|
}
|
|
793
820
|
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
821
|
+
});
|
|
822
|
+
};
|
|
796
823
|
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
824
|
+
collectClefs();
|
|
797
825
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
798
826
|
measureStrs.push(measureStr || 's1');
|
|
799
827
|
currentStaff = newStaff;
|
package/lib/lilylet/types.d.ts
CHANGED
|
@@ -221,7 +221,11 @@ export interface MarkupEvent {
|
|
|
221
221
|
content: string;
|
|
222
222
|
placement?: Placement;
|
|
223
223
|
}
|
|
224
|
-
export
|
|
224
|
+
export interface DynamicEvent {
|
|
225
|
+
type: 'dynamic';
|
|
226
|
+
dynamicType: DynamicType;
|
|
227
|
+
}
|
|
228
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
|
|
225
229
|
export interface Voice {
|
|
226
230
|
staff: number;
|
|
227
231
|
events: Event[];
|
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.68",
|
|
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",
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"test": "tsx ./tests/parser.ts",
|
|
31
31
|
"test:mei": "tsx ./tests/mei.ts",
|
|
32
32
|
"test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
|
|
33
|
-
"test:unit": "tsx ./tests/unit/encodePitch.test.ts",
|
|
34
33
|
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
35
34
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
36
35
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
package/source/abc/abc.jison
CHANGED
|
@@ -239,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
|
239
239
|
<key_signature>"Loc" return 'NAME';
|
|
240
240
|
<key_signature>"HP" return 'NAME';
|
|
241
241
|
<key_signature>"Hp" return 'NAME';
|
|
242
|
-
<key_signature>[a-z]+[ \t]
|
|
242
|
+
<key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
|
|
243
243
|
<key_signature>[A-G] return 'A';
|
|
244
244
|
<key_signature>[A-Z][a-z]+ return 'NAME';
|
|
245
245
|
<key_signature>[b] return 'FLAT';
|
|
@@ -555,7 +555,6 @@ music
|
|
|
555
555
|
| music N -> $1
|
|
556
556
|
| music NAME -> $1
|
|
557
557
|
| music '^' NAME -> $1
|
|
558
|
-
| music '^' -> $1
|
|
559
558
|
| music '[' N -> $1
|
|
560
559
|
;
|
|
561
560
|
|