@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
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
const barlineEvent = (style) => ({ type: 'barline', style });
|
|
128
128
|
const harmonyEvent = (text) => ({ type: 'harmony', text });
|
|
129
129
|
const markupEvent = (content, placement) => ({ type: 'markup', content, placement: placement || undefined });
|
|
130
|
+
const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
|
|
130
131
|
const markupMark = (content) => ({ markType: 'markup', content });
|
|
131
132
|
|
|
132
133
|
// Parse PITCH token (e.g., "c", "cs", "bf", "css", "bff") into phonet and accidental
|
|
@@ -445,6 +446,7 @@ event
|
|
|
445
446
|
| barline_event
|
|
446
447
|
| harmony_event
|
|
447
448
|
| markup_event
|
|
449
|
+
| dynamic_event
|
|
448
450
|
;
|
|
449
451
|
|
|
450
452
|
barline_event
|
|
@@ -457,6 +459,12 @@ harmony_event
|
|
|
457
459
|
|
|
458
460
|
markup_event
|
|
459
461
|
: CMD_MARKUP STRING -> markupEvent($2.slice(1, -1))
|
|
462
|
+
| '^' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'above')
|
|
463
|
+
| '_' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'below')
|
|
464
|
+
;
|
|
465
|
+
|
|
466
|
+
dynamic_event
|
|
467
|
+
: dynamic_mark -> dynamicEvent($1.type)
|
|
460
468
|
;
|
|
461
469
|
|
|
462
470
|
pitch_reset_event
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
BarlineEvent,
|
|
13
13
|
HarmonyEvent,
|
|
14
14
|
MarkupEvent,
|
|
15
|
+
DynamicEvent,
|
|
15
16
|
Pitch,
|
|
16
17
|
Clef,
|
|
17
18
|
Accidental,
|
|
@@ -653,9 +654,10 @@ const noteEventToMEI = (
|
|
|
653
654
|
|
|
654
655
|
|
|
655
656
|
// Convert RestEvent to MEI
|
|
656
|
-
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): string => {
|
|
657
|
+
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string } => {
|
|
657
658
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
658
|
-
|
|
659
|
+
const restId = generateId('rest');
|
|
660
|
+
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
659
661
|
if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
|
|
660
662
|
|
|
661
663
|
// Cross-staff attribute
|
|
@@ -669,15 +671,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
669
671
|
|
|
670
672
|
// Space rest (invisible)
|
|
671
673
|
if (event.invisible) {
|
|
672
|
-
return `${indent}<space ${attrs} />\n
|
|
674
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
673
675
|
}
|
|
674
676
|
|
|
675
677
|
// Full measure rest
|
|
676
678
|
if (event.fullMeasure) {
|
|
677
|
-
|
|
679
|
+
const mRestId = generateId('mrest');
|
|
680
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
678
681
|
}
|
|
679
682
|
|
|
680
|
-
return `${indent}<rest ${attrs} />\n
|
|
683
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
681
684
|
};
|
|
682
685
|
|
|
683
686
|
|
|
@@ -784,7 +787,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
784
787
|
}
|
|
785
788
|
} else if (e.type === 'rest') {
|
|
786
789
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
787
|
-
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
790
|
+
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
|
|
788
791
|
} else if (e.type === 'context') {
|
|
789
792
|
const ctx = e as ContextChange;
|
|
790
793
|
if (ctx.clef && ctx.clef !== activeClef) {
|
|
@@ -1006,7 +1009,7 @@ interface LayerResult {
|
|
|
1006
1009
|
|
|
1007
1010
|
|
|
1008
1011
|
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
1009
|
-
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
1012
|
+
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
1010
1013
|
if (event.type === 'note') {
|
|
1011
1014
|
const markOptions = extractMarkOptions((event as NoteEvent).marks);
|
|
1012
1015
|
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
@@ -1079,6 +1082,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1079
1082
|
const barlines: BarlineRef[] = [];
|
|
1080
1083
|
const markups: MarkupRef[] = [];
|
|
1081
1084
|
const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
|
|
1085
|
+
const pendingDynamics: string[] = [];
|
|
1082
1086
|
|
|
1083
1087
|
// Track current stem direction from context changes
|
|
1084
1088
|
let currentStemDirection: StemDirection | undefined = undefined;
|
|
@@ -1100,6 +1104,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1100
1104
|
pendingMarkups.length = 0;
|
|
1101
1105
|
};
|
|
1102
1106
|
|
|
1107
|
+
// Helper to flush pending leading dynamics onto a note ID
|
|
1108
|
+
const flushPendingDynamics = (noteId: string) => {
|
|
1109
|
+
for (const label of pendingDynamics) {
|
|
1110
|
+
dynamics.push({ startid: noteId, label });
|
|
1111
|
+
}
|
|
1112
|
+
pendingDynamics.length = 0;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1103
1115
|
// Helper to check if pitches match for tie continuation
|
|
1104
1116
|
const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
|
|
1105
1117
|
if (p1.length !== p2.length) return false;
|
|
@@ -1169,6 +1181,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1169
1181
|
// Flush any pending markups onto this note
|
|
1170
1182
|
flushPendingMarkups(result.elementId);
|
|
1171
1183
|
|
|
1184
|
+
// Flush any pending leading dynamics onto this note
|
|
1185
|
+
flushPendingDynamics(result.elementId);
|
|
1186
|
+
|
|
1172
1187
|
// If there's a pending ottava, start the span on this note
|
|
1173
1188
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1174
1189
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -1283,7 +1298,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1283
1298
|
case 'rest': {
|
|
1284
1299
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1285
1300
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1286
|
-
|
|
1301
|
+
const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1302
|
+
xml += restResult.xml;
|
|
1303
|
+
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1304
|
+
flushPendingMarkups(restResult.elementId);
|
|
1305
|
+
flushPendingDynamics(restResult.elementId);
|
|
1306
|
+
lastNoteId = restResult.elementId;
|
|
1287
1307
|
break;
|
|
1288
1308
|
}
|
|
1289
1309
|
case 'tuplet':
|
|
@@ -1301,6 +1321,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1301
1321
|
// Flush any pending markups onto the first note of the tuplet
|
|
1302
1322
|
if (tupletResult.firstNoteId) {
|
|
1303
1323
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1324
|
+
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1304
1325
|
lastNoteId = tupletResult.firstNoteId;
|
|
1305
1326
|
}
|
|
1306
1327
|
|
|
@@ -1416,6 +1437,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1416
1437
|
}
|
|
1417
1438
|
}
|
|
1418
1439
|
break;
|
|
1440
|
+
|
|
1441
|
+
case 'dynamic': {
|
|
1442
|
+
// Standalone (leading) dynamic - attaches to the following note
|
|
1443
|
+
const dynEvent = event as DynamicEvent;
|
|
1444
|
+
pendingDynamics.push(dynEvent.dynamicType);
|
|
1445
|
+
}
|
|
1446
|
+
break;
|
|
1419
1447
|
}
|
|
1420
1448
|
|
|
1421
1449
|
// Close beam element if beam ends
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
TimesEvent,
|
|
19
19
|
TremoloEvent,
|
|
20
20
|
BarlineEvent,
|
|
21
|
+
MarkupEvent,
|
|
22
|
+
DynamicEvent,
|
|
21
23
|
Pitch,
|
|
22
24
|
Duration,
|
|
23
25
|
Mark,
|
|
@@ -578,6 +580,15 @@ const serializeEvent = (
|
|
|
578
580
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
579
581
|
case 'barline':
|
|
580
582
|
return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
|
|
583
|
+
case 'markup': {
|
|
584
|
+
const mk = event as MarkupEvent;
|
|
585
|
+
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
586
|
+
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
587
|
+
}
|
|
588
|
+
case 'dynamic': {
|
|
589
|
+
const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
|
|
590
|
+
return { str: dynStr || '', newEnv: env };
|
|
591
|
+
}
|
|
581
592
|
default:
|
|
582
593
|
return { str: '', newEnv: env };
|
|
583
594
|
}
|
|
@@ -591,8 +602,12 @@ interface MeasureContext {
|
|
|
591
602
|
clef?: Clef;
|
|
592
603
|
}
|
|
593
604
|
|
|
594
|
-
// Find
|
|
605
|
+
// Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
|
|
606
|
+
// a clef context event that appears before any musical event on the home staff.
|
|
607
|
+
// A clef that first appears AFTER a note is a mid-voice change and must be emitted
|
|
608
|
+
// inline where it occurs, not hoisted to the front — so it is not returned here.
|
|
595
609
|
const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
610
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
596
611
|
let activeStaff = voice.staff;
|
|
597
612
|
for (const event of voice.events) {
|
|
598
613
|
if (event.type === 'context') {
|
|
@@ -601,6 +616,9 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
|
601
616
|
if (ctx.clef && activeStaff === voice.staff) {
|
|
602
617
|
return ctx.clef;
|
|
603
618
|
}
|
|
619
|
+
} else if (MUSICAL.has(event.type)) {
|
|
620
|
+
// Reached music on the home staff before any clef — no leading clef.
|
|
621
|
+
if (activeStaff === voice.staff) return undefined;
|
|
604
622
|
}
|
|
605
623
|
}
|
|
606
624
|
return undefined;
|
|
@@ -631,7 +649,6 @@ const serializeVoice = (
|
|
|
631
649
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
632
650
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
633
651
|
// context{staff} events before this index are skipped in the main loop.
|
|
634
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
635
652
|
let effectiveInitialStaff = voice.staff;
|
|
636
653
|
let leadStaffScanEnd = 0;
|
|
637
654
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -653,7 +670,9 @@ const serializeVoice = (
|
|
|
653
670
|
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
654
671
|
continue;
|
|
655
672
|
}
|
|
656
|
-
|
|
673
|
+
// Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
|
|
674
|
+
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
675
|
+
break;
|
|
657
676
|
}
|
|
658
677
|
|
|
659
678
|
// Output staff command if voice staff differs from current parser staff,
|
|
@@ -684,15 +703,16 @@ const serializeVoice = (
|
|
|
684
703
|
}
|
|
685
704
|
}
|
|
686
705
|
|
|
687
|
-
// Output clef only if not yet emitted or changed for this staff
|
|
688
|
-
|
|
706
|
+
// Output clef only if not yet emitted or changed for this staff.
|
|
707
|
+
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
708
|
+
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
709
|
+
// hoisted here — it is emitted inline at its position.
|
|
710
|
+
const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
|
|
689
711
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
690
712
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
691
713
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
692
714
|
if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
|
|
693
715
|
}
|
|
694
|
-
// Skip redundant clef context events if this staff's clef is already established
|
|
695
|
-
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
696
716
|
|
|
697
717
|
let activeStaff = effectiveInitialStaff;
|
|
698
718
|
let activeStemDir: StemDirection | undefined;
|
|
@@ -719,8 +739,13 @@ const serializeVoice = (
|
|
|
719
739
|
}
|
|
720
740
|
if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
|
|
721
741
|
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
722
|
-
// Skip clef-only context
|
|
723
|
-
|
|
742
|
+
// Skip a clef-only context event only if it is REDUNDANT — i.e. it restates the
|
|
743
|
+
// clef already active for this staff. A clef that differs is a genuine change and
|
|
744
|
+
// must be emitted inline at its position.
|
|
745
|
+
if (
|
|
746
|
+
ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
|
|
747
|
+
emittedClefs?.[ctx.staff || activeStaff] === ctx.clef
|
|
748
|
+
) {
|
|
724
749
|
continue;
|
|
725
750
|
}
|
|
726
751
|
}
|
|
@@ -958,26 +983,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
958
983
|
currentTime = measure.timeSig;
|
|
959
984
|
}
|
|
960
985
|
|
|
961
|
-
// Collect clefs from this measure's voices, per part
|
|
962
|
-
measure
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
986
|
+
// Collect clefs from this measure's voices, per part — but only AFTER serializing
|
|
987
|
+
// the measure, so that during serialization allStaffClefs reflects the clef state
|
|
988
|
+
// CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
|
|
989
|
+
// to the measure's front; it is emitted inline at its position).
|
|
990
|
+
const collectClefs = () => {
|
|
991
|
+
measure.parts.forEach((part, pi) => {
|
|
992
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
993
|
+
for (const voice of part.voices) {
|
|
994
|
+
let clefActiveStaff = voice.staff;
|
|
995
|
+
for (const event of voice.events) {
|
|
996
|
+
if (event.type === 'context') {
|
|
997
|
+
const ctx = event as ContextChange;
|
|
998
|
+
if (ctx.staff) {
|
|
999
|
+
clefActiveStaff = ctx.staff;
|
|
1000
|
+
}
|
|
1001
|
+
if (ctx.clef) {
|
|
1002
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
1003
|
+
}
|
|
974
1004
|
}
|
|
975
1005
|
}
|
|
976
1006
|
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
1007
|
+
});
|
|
1008
|
+
};
|
|
979
1009
|
|
|
980
1010
|
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
1011
|
+
collectClefs();
|
|
981
1012
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
982
1013
|
measureStrs.push(measureStr || 's1');
|
|
983
1014
|
currentStaff = newStaff;
|
package/source/lilylet/types.ts
CHANGED
|
@@ -278,7 +278,12 @@ export interface MarkupEvent {
|
|
|
278
278
|
placement?: Placement; // Optional placement (above/below)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
export
|
|
281
|
+
export interface DynamicEvent {
|
|
282
|
+
type: 'dynamic';
|
|
283
|
+
dynamicType: DynamicType; // Standalone dynamic at a leading position (before any note)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
|
|
282
287
|
|
|
283
288
|
// === Structure ===
|
|
284
289
|
|