@k-l-lambda/lilylet 0.1.67 → 0.1.69
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 +155 -135
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +161 -39
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +31 -6
- package/lib/lilylet/serializer.js +76 -31
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +1 -2
- package/source/abc/abc.jison +38 -14
- package/source/abc/grammar.jison.js +155 -135
- package/source/lilylet/abcDecoder.ts +173 -33
- 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 +82 -30
- 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,
|
|
@@ -276,13 +278,15 @@ const serializeMarks = (marks: Mark[]): string => {
|
|
|
276
278
|
const serializeNoteEvent = (
|
|
277
279
|
event: NoteEvent,
|
|
278
280
|
env: PitchEnv,
|
|
279
|
-
prevDuration?: Duration
|
|
281
|
+
prevDuration?: Duration,
|
|
282
|
+
suppressGracePrefix: boolean = false
|
|
280
283
|
): { str: string; newEnv: PitchEnv } => {
|
|
281
284
|
const parts: string[] = [];
|
|
282
285
|
let currentEnv = env;
|
|
283
286
|
|
|
284
|
-
// Grace note prefix
|
|
285
|
-
|
|
287
|
+
// Grace note prefix. When the caller groups consecutive grace notes into a single
|
|
288
|
+
// scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
|
|
289
|
+
if (event.grace && !suppressGracePrefix) {
|
|
286
290
|
parts.push('\\grace ');
|
|
287
291
|
}
|
|
288
292
|
|
|
@@ -562,11 +566,12 @@ const serializeBarlineEvent = (event: BarlineEvent): string => {
|
|
|
562
566
|
const serializeEvent = (
|
|
563
567
|
event: Event,
|
|
564
568
|
env: PitchEnv,
|
|
565
|
-
prevDuration?: Duration
|
|
569
|
+
prevDuration?: Duration,
|
|
570
|
+
suppressGracePrefix: boolean = false
|
|
566
571
|
): { str: string; newEnv: PitchEnv } => {
|
|
567
572
|
switch (event.type) {
|
|
568
573
|
case 'note':
|
|
569
|
-
return serializeNoteEvent(event as NoteEvent, env, prevDuration);
|
|
574
|
+
return serializeNoteEvent(event as NoteEvent, env, prevDuration, suppressGracePrefix);
|
|
570
575
|
case 'rest':
|
|
571
576
|
return serializeRestEvent(event as RestEvent, env, prevDuration);
|
|
572
577
|
case 'context':
|
|
@@ -578,6 +583,15 @@ const serializeEvent = (
|
|
|
578
583
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
579
584
|
case 'barline':
|
|
580
585
|
return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
|
|
586
|
+
case 'markup': {
|
|
587
|
+
const mk = event as MarkupEvent;
|
|
588
|
+
const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
|
|
589
|
+
return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
|
|
590
|
+
}
|
|
591
|
+
case 'dynamic': {
|
|
592
|
+
const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
|
|
593
|
+
return { str: dynStr || '', newEnv: env };
|
|
594
|
+
}
|
|
581
595
|
default:
|
|
582
596
|
return { str: '', newEnv: env };
|
|
583
597
|
}
|
|
@@ -591,8 +605,12 @@ interface MeasureContext {
|
|
|
591
605
|
clef?: Clef;
|
|
592
606
|
}
|
|
593
607
|
|
|
594
|
-
// Find
|
|
608
|
+
// Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
|
|
609
|
+
// a clef context event that appears before any musical event on the home staff.
|
|
610
|
+
// A clef that first appears AFTER a note is a mid-voice change and must be emitted
|
|
611
|
+
// inline where it occurs, not hoisted to the front — so it is not returned here.
|
|
595
612
|
const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
613
|
+
const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
596
614
|
let activeStaff = voice.staff;
|
|
597
615
|
for (const event of voice.events) {
|
|
598
616
|
if (event.type === 'context') {
|
|
@@ -601,6 +619,9 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
|
601
619
|
if (ctx.clef && activeStaff === voice.staff) {
|
|
602
620
|
return ctx.clef;
|
|
603
621
|
}
|
|
622
|
+
} else if (MUSICAL.has(event.type)) {
|
|
623
|
+
// Reached music on the home staff before any clef — no leading clef.
|
|
624
|
+
if (activeStaff === voice.staff) return undefined;
|
|
604
625
|
}
|
|
605
626
|
}
|
|
606
627
|
return undefined;
|
|
@@ -631,7 +652,6 @@ const serializeVoice = (
|
|
|
631
652
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
632
653
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
633
654
|
// context{staff} events before this index are skipped in the main loop.
|
|
634
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
635
655
|
let effectiveInitialStaff = voice.staff;
|
|
636
656
|
let leadStaffScanEnd = 0;
|
|
637
657
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -653,7 +673,9 @@ const serializeVoice = (
|
|
|
653
673
|
leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
|
|
654
674
|
continue;
|
|
655
675
|
}
|
|
656
|
-
|
|
676
|
+
// Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
|
|
677
|
+
// visible position; a staff switch after it must not be hoisted ahead of it.
|
|
678
|
+
break;
|
|
657
679
|
}
|
|
658
680
|
|
|
659
681
|
// Output staff command if voice staff differs from current parser staff,
|
|
@@ -684,18 +706,20 @@ const serializeVoice = (
|
|
|
684
706
|
}
|
|
685
707
|
}
|
|
686
708
|
|
|
687
|
-
// Output clef only if not yet emitted or changed for this staff
|
|
688
|
-
|
|
709
|
+
// Output clef only if not yet emitted or changed for this staff.
|
|
710
|
+
// Prefer this voice's leading clef (a clef before any music); fall back to the
|
|
711
|
+
// carry-in clef from previous measures. A clef that first appears mid-voice is NOT
|
|
712
|
+
// hoisted here — it is emitted inline at its position.
|
|
713
|
+
const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
|
|
689
714
|
const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
|
|
690
715
|
if (voiceClef && !clefAlreadyEmitted) {
|
|
691
716
|
parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
|
|
692
717
|
if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
|
|
693
718
|
}
|
|
694
|
-
// Skip redundant clef context events if this staff's clef is already established
|
|
695
|
-
const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
|
|
696
719
|
|
|
697
720
|
let activeStaff = effectiveInitialStaff;
|
|
698
721
|
let activeStemDir: StemDirection | undefined;
|
|
722
|
+
let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
|
|
699
723
|
|
|
700
724
|
for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
|
|
701
725
|
const event = voice.events[eventIdx];
|
|
@@ -719,8 +743,13 @@ const serializeVoice = (
|
|
|
719
743
|
}
|
|
720
744
|
if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
|
|
721
745
|
if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
|
|
722
|
-
// Skip clef-only context
|
|
723
|
-
|
|
746
|
+
// Skip a clef-only context event only if it is REDUNDANT — i.e. it restates the
|
|
747
|
+
// clef already active for this staff. A clef that differs is a genuine change and
|
|
748
|
+
// must be emitted inline at its position.
|
|
749
|
+
if (
|
|
750
|
+
ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
|
|
751
|
+
emittedClefs?.[ctx.staff || activeStaff] === ctx.clef
|
|
752
|
+
) {
|
|
724
753
|
continue;
|
|
725
754
|
}
|
|
726
755
|
}
|
|
@@ -756,7 +785,19 @@ const serializeVoice = (
|
|
|
756
785
|
}
|
|
757
786
|
}
|
|
758
787
|
|
|
759
|
-
const
|
|
788
|
+
const isGraceNote = event.type === 'note' && !!(event as NoteEvent).grace;
|
|
789
|
+
|
|
790
|
+
// Group consecutive grace notes into one scoped \grace { ... } instead of
|
|
791
|
+
// emitting a separate \grace prefix per note.
|
|
792
|
+
if (isGraceNote && !graceGroupOpen) {
|
|
793
|
+
parts.push('\\grace {');
|
|
794
|
+
graceGroupOpen = true;
|
|
795
|
+
} else if (!isGraceNote && graceGroupOpen) {
|
|
796
|
+
parts.push('}');
|
|
797
|
+
graceGroupOpen = false;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
|
|
760
801
|
pitchEnv = newEnv;
|
|
761
802
|
|
|
762
803
|
if (eventStr) {
|
|
@@ -780,6 +821,11 @@ const serializeVoice = (
|
|
|
780
821
|
}
|
|
781
822
|
}
|
|
782
823
|
|
|
824
|
+
// Close a grace group left open at the end of the voice (unusual but possible).
|
|
825
|
+
if (graceGroupOpen) {
|
|
826
|
+
parts.push('}');
|
|
827
|
+
}
|
|
828
|
+
|
|
783
829
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
784
830
|
};
|
|
785
831
|
|
|
@@ -958,26 +1004,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
958
1004
|
currentTime = measure.timeSig;
|
|
959
1005
|
}
|
|
960
1006
|
|
|
961
|
-
// Collect clefs from this measure's voices, per part
|
|
962
|
-
measure
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1007
|
+
// Collect clefs from this measure's voices, per part — but only AFTER serializing
|
|
1008
|
+
// the measure, so that during serialization allStaffClefs reflects the clef state
|
|
1009
|
+
// CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
|
|
1010
|
+
// to the measure's front; it is emitted inline at its position).
|
|
1011
|
+
const collectClefs = () => {
|
|
1012
|
+
measure.parts.forEach((part, pi) => {
|
|
1013
|
+
const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
|
|
1014
|
+
for (const voice of part.voices) {
|
|
1015
|
+
let clefActiveStaff = voice.staff;
|
|
1016
|
+
for (const event of voice.events) {
|
|
1017
|
+
if (event.type === 'context') {
|
|
1018
|
+
const ctx = event as ContextChange;
|
|
1019
|
+
if (ctx.staff) {
|
|
1020
|
+
clefActiveStaff = ctx.staff;
|
|
1021
|
+
}
|
|
1022
|
+
if (ctx.clef) {
|
|
1023
|
+
staffClefs[clefActiveStaff] = ctx.clef;
|
|
1024
|
+
}
|
|
974
1025
|
}
|
|
975
1026
|
}
|
|
976
1027
|
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
1028
|
+
});
|
|
1029
|
+
};
|
|
979
1030
|
|
|
980
1031
|
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
1032
|
+
collectClefs();
|
|
981
1033
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
982
1034
|
measureStrs.push(measureStr || 's1');
|
|
983
1035
|
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
|
|