@k-l-lambda/lilylet 0.1.66 → 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 +200 -179
- package/lib/lilylet/abcDecoder.d.ts +12 -6
- package/lib/lilylet/abcDecoder.js +248 -116
- package/lib/lilylet/grammar.jison.js +195 -185
- package/lib/lilylet/meiEncoder.js +69 -14
- package/lib/lilylet/serializer.js +81 -37
- package/lib/lilylet/types.d.ts +5 -1
- package/package.json +4 -2
- package/source/abc/abc.jison +20 -6
- package/source/abc/grammar.jison.js +200 -179
- package/source/lilylet/abcDecoder.ts +272 -103
- package/source/lilylet/grammar.jison.js +195 -185
- package/source/lilylet/lilylet.jison +8 -0
- package/source/lilylet/meiEncoder.ts +75 -16
- package/source/lilylet/serializer.ts +85 -37
- 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,
|
|
@@ -79,6 +80,41 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
|
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
// Resolve a clef string into MEI shape/line plus optional octave displacement.
|
|
84
|
+
// Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
|
|
85
|
+
// the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
|
|
86
|
+
// and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
|
|
87
|
+
// and dis.place ("below" | "above").
|
|
88
|
+
const resolveClef = (clefStr: string): { shape: string; line: number; dis?: "8" | "15"; disPlace?: "above" | "below" } => {
|
|
89
|
+
const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
|
|
90
|
+
const base = match ? match[1] : clefStr;
|
|
91
|
+
const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
|
|
92
|
+
if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
|
|
93
|
+
return {
|
|
94
|
+
shape: clefInfo.shape,
|
|
95
|
+
line: clefInfo.line,
|
|
96
|
+
dis: match[3] as "8" | "15",
|
|
97
|
+
disPlace: match[2] === "^" ? "above" : "below",
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Attributes for a standalone <clef> element (mid-measure clef change).
|
|
102
|
+
const clefElementAttrs = (clefStr: string): string => {
|
|
103
|
+
const c = resolveClef(clefStr);
|
|
104
|
+
let attrs = `shape="${c.shape}" line="${c.line}"`;
|
|
105
|
+
if (c.dis) attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
|
|
106
|
+
return attrs;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Attributes for a <staffDef> clef (clef.* namespace).
|
|
110
|
+
const staffDefClefAttrs = (clefStr: string): string => {
|
|
111
|
+
const c = resolveClef(clefStr);
|
|
112
|
+
let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
|
|
113
|
+
if (c.dis) attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
|
|
114
|
+
return attrs;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
|
|
82
118
|
// Lilylet duration division to MEI dur
|
|
83
119
|
// division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
|
|
84
120
|
const DURATIONS: Record<number, string> = {
|
|
@@ -618,9 +654,10 @@ const noteEventToMEI = (
|
|
|
618
654
|
|
|
619
655
|
|
|
620
656
|
// Convert RestEvent to MEI
|
|
621
|
-
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 } => {
|
|
622
658
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
623
|
-
|
|
659
|
+
const restId = generateId('rest');
|
|
660
|
+
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
624
661
|
if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
|
|
625
662
|
|
|
626
663
|
// Cross-staff attribute
|
|
@@ -634,15 +671,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
634
671
|
|
|
635
672
|
// Space rest (invisible)
|
|
636
673
|
if (event.invisible) {
|
|
637
|
-
return `${indent}<space ${attrs} />\n
|
|
674
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
638
675
|
}
|
|
639
676
|
|
|
640
677
|
// Full measure rest
|
|
641
678
|
if (event.fullMeasure) {
|
|
642
|
-
|
|
679
|
+
const mRestId = generateId('mrest');
|
|
680
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
643
681
|
}
|
|
644
682
|
|
|
645
|
-
return `${indent}<rest ${attrs} />\n
|
|
683
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
646
684
|
};
|
|
647
685
|
|
|
648
686
|
|
|
@@ -749,7 +787,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
749
787
|
}
|
|
750
788
|
} else if (e.type === 'rest') {
|
|
751
789
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
752
|
-
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
790
|
+
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
|
|
753
791
|
} else if (e.type === 'context') {
|
|
754
792
|
const ctx = e as ContextChange;
|
|
755
793
|
if (ctx.clef && ctx.clef !== activeClef) {
|
|
@@ -757,8 +795,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
757
795
|
const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
|
|
758
796
|
if (effectiveStaffNum === layerStaffNum) {
|
|
759
797
|
const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
760
|
-
|
|
761
|
-
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
798
|
+
xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
762
799
|
}
|
|
763
800
|
activeClef = ctx.clef;
|
|
764
801
|
endingClef = ctx.clef;
|
|
@@ -972,7 +1009,7 @@ interface LayerResult {
|
|
|
972
1009
|
|
|
973
1010
|
|
|
974
1011
|
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
975
|
-
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 } => {
|
|
976
1013
|
if (event.type === 'note') {
|
|
977
1014
|
const markOptions = extractMarkOptions((event as NoteEvent).marks);
|
|
978
1015
|
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
@@ -1045,6 +1082,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1045
1082
|
const barlines: BarlineRef[] = [];
|
|
1046
1083
|
const markups: MarkupRef[] = [];
|
|
1047
1084
|
const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
|
|
1085
|
+
const pendingDynamics: string[] = [];
|
|
1048
1086
|
|
|
1049
1087
|
// Track current stem direction from context changes
|
|
1050
1088
|
let currentStemDirection: StemDirection | undefined = undefined;
|
|
@@ -1066,6 +1104,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1066
1104
|
pendingMarkups.length = 0;
|
|
1067
1105
|
};
|
|
1068
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
|
+
|
|
1069
1115
|
// Helper to check if pitches match for tie continuation
|
|
1070
1116
|
const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
|
|
1071
1117
|
if (p1.length !== p2.length) return false;
|
|
@@ -1135,6 +1181,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1135
1181
|
// Flush any pending markups onto this note
|
|
1136
1182
|
flushPendingMarkups(result.elementId);
|
|
1137
1183
|
|
|
1184
|
+
// Flush any pending leading dynamics onto this note
|
|
1185
|
+
flushPendingDynamics(result.elementId);
|
|
1186
|
+
|
|
1138
1187
|
// If there's a pending ottava, start the span on this note
|
|
1139
1188
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1140
1189
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -1249,7 +1298,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1249
1298
|
case 'rest': {
|
|
1250
1299
|
// For cross-staff notation: pass staff number if different from voice's home staff
|
|
1251
1300
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1252
|
-
|
|
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;
|
|
1253
1307
|
break;
|
|
1254
1308
|
}
|
|
1255
1309
|
case 'tuplet':
|
|
@@ -1267,6 +1321,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1267
1321
|
// Flush any pending markups onto the first note of the tuplet
|
|
1268
1322
|
if (tupletResult.firstNoteId) {
|
|
1269
1323
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1324
|
+
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1270
1325
|
lastNoteId = tupletResult.firstNoteId;
|
|
1271
1326
|
}
|
|
1272
1327
|
|
|
@@ -1306,8 +1361,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1306
1361
|
if (ctx.clef && ctx.clef !== currentClef) {
|
|
1307
1362
|
const layerStaff = voice.staff || 1;
|
|
1308
1363
|
if (currentStaff === layerStaff) {
|
|
1309
|
-
|
|
1310
|
-
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
1364
|
+
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
|
|
1311
1365
|
}
|
|
1312
1366
|
currentClef = ctx.clef;
|
|
1313
1367
|
}
|
|
@@ -1383,6 +1437,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1383
1437
|
}
|
|
1384
1438
|
}
|
|
1385
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;
|
|
1386
1447
|
}
|
|
1387
1448
|
|
|
1388
1449
|
// Close beam element if beam ends
|
|
@@ -1959,16 +2020,14 @@ const encodeScoreDef = (
|
|
|
1959
2020
|
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1960
2021
|
const globalStaff = info.staffOffset + ls;
|
|
1961
2022
|
const clef = info.clefs[ls] || Clef.treble;
|
|
1962
|
-
|
|
1963
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
2023
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1964
2024
|
}
|
|
1965
2025
|
xml += `${indent} </staffGrp>\n`;
|
|
1966
2026
|
} else {
|
|
1967
2027
|
// Single staff part
|
|
1968
2028
|
const globalStaff = info.staffOffset + 1;
|
|
1969
2029
|
const clef = info.clefs[1] || Clef.treble;
|
|
1970
|
-
|
|
1971
|
-
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
2030
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
|
|
1972
2031
|
}
|
|
1973
2032
|
}
|
|
1974
2033
|
|
|
@@ -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,
|
|
@@ -383,7 +385,7 @@ const serializeContextChange = (event: ContextChange): string => {
|
|
|
383
385
|
|
|
384
386
|
// Clef
|
|
385
387
|
if (event.clef) {
|
|
386
|
-
parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
|
|
388
|
+
parts.push('\\clef "' + (CLEF_MAP[event.clef] ?? event.clef) + '"');
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
// Key signature
|
|
@@ -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
|
-
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
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;
|
|
@@ -712,15 +732,20 @@ const serializeVoice = (
|
|
|
712
732
|
// Emit target staff clef if the event carries one or allStaffClefs knows it
|
|
713
733
|
const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
|
|
714
734
|
if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
|
|
715
|
-
parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
|
|
735
|
+
parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
|
|
716
736
|
if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
|
|
717
737
|
}
|
|
718
738
|
continue;
|
|
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
|
}
|
|
@@ -736,7 +761,7 @@ const serializeVoice = (
|
|
|
736
761
|
// Emit the target staff's clef if it differs from what was last emitted for this staff
|
|
737
762
|
const targetClef = allStaffClefs?.[activeStaff];
|
|
738
763
|
if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
|
|
739
|
-
parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
|
|
764
|
+
parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
|
|
740
765
|
if (emittedClefs) emittedClefs[activeStaff] = targetClef;
|
|
741
766
|
}
|
|
742
767
|
}
|
|
@@ -802,11 +827,16 @@ const serializePart = (
|
|
|
802
827
|
const voiceStrs: string[] = [];
|
|
803
828
|
let staff = currentStaff;
|
|
804
829
|
|
|
830
|
+
// A part is a grand staff only if its voices span more than one staff.
|
|
831
|
+
// Only then do we force \staff on every voice; single-staff parts emit \staff
|
|
832
|
+
// solely when the staff actually changes (e.g. resetting after a prior grand staff).
|
|
833
|
+
const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
|
|
834
|
+
|
|
805
835
|
for (let i = 0; i < part.voices.length; i++) {
|
|
806
836
|
const voice = part.voices[i];
|
|
807
837
|
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
808
838
|
const isFirstVoice = isFirstPart && i === 0;
|
|
809
|
-
const { str, newStaff } = serializeVoice(voice, staff,
|
|
839
|
+
const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
|
|
810
840
|
voiceStrs.push(str);
|
|
811
841
|
staff = newStaff;
|
|
812
842
|
}
|
|
@@ -825,8 +855,8 @@ const serializeMeasure = (
|
|
|
825
855
|
isGrandStaff: boolean = false,
|
|
826
856
|
currentKey?: KeySignature,
|
|
827
857
|
currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
|
|
828
|
-
|
|
829
|
-
|
|
858
|
+
partStaffClefs?: Record<number, Record<number, Clef>>,
|
|
859
|
+
partEmittedClefs?: Record<number, Record<number, Clef>>
|
|
830
860
|
): { str: string; newStaff: number } => {
|
|
831
861
|
const parts: string[] = [];
|
|
832
862
|
|
|
@@ -838,13 +868,15 @@ const serializeMeasure = (
|
|
|
838
868
|
time: currentTime,
|
|
839
869
|
};
|
|
840
870
|
|
|
841
|
-
//
|
|
842
|
-
|
|
871
|
+
// Per-part clef state: each part has its own staff→clef maps so that distinct
|
|
872
|
+
// parts sharing staff number 1 do not clobber each other's clefs.
|
|
873
|
+
const clefsFor = (pi: number) => partStaffClefs?.[pi] || {};
|
|
874
|
+
const emittedFor = (pi: number) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
|
|
843
875
|
|
|
844
876
|
// Parts
|
|
845
877
|
let staff = currentStaff;
|
|
846
878
|
if (measure.parts.length === 1) {
|
|
847
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true,
|
|
879
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
|
|
848
880
|
if (partStr) {
|
|
849
881
|
parts.push(partStr);
|
|
850
882
|
}
|
|
@@ -855,7 +887,7 @@ const serializeMeasure = (
|
|
|
855
887
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
856
888
|
const part = measure.parts[i];
|
|
857
889
|
// Pass measureContext to all parts, isFirstPart to first part only
|
|
858
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0,
|
|
890
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
|
|
859
891
|
if (str) {
|
|
860
892
|
partStrs.push(str);
|
|
861
893
|
}
|
|
@@ -892,6 +924,12 @@ const serializeMetadata = (metadata: Metadata): string => {
|
|
|
892
924
|
if (metadata.lyricist) {
|
|
893
925
|
lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
|
|
894
926
|
}
|
|
927
|
+
if (metadata.genre) {
|
|
928
|
+
lines.push('[genre "' + escapeString(metadata.genre) + '"]');
|
|
929
|
+
}
|
|
930
|
+
if (metadata.instrument) {
|
|
931
|
+
lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
|
|
932
|
+
}
|
|
895
933
|
if (metadata.autoBeam) {
|
|
896
934
|
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
897
935
|
}
|
|
@@ -929,8 +967,11 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
929
967
|
let currentStaff = 1; // Parser starts at staff 1
|
|
930
968
|
let currentKey: KeySignature | undefined;
|
|
931
969
|
let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
|
|
932
|
-
|
|
933
|
-
|
|
970
|
+
// Clefs are tracked per part (each part is an independent instrument). Voice `staff`
|
|
971
|
+
// numbers are staff-within-part, so distinct parts may both use staff 1 — keying clef
|
|
972
|
+
// state by staff alone would conflate them. Outer key = part index, inner key = staff.
|
|
973
|
+
const partStaffClefs: Record<number, Record<number, Clef>> = {};
|
|
974
|
+
const partEmittedClefs: Record<number, Record<number, Clef>> = {};
|
|
934
975
|
|
|
935
976
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
936
977
|
const measure = doc.measures[i];
|
|
@@ -942,25 +983,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
942
983
|
currentTime = measure.timeSig;
|
|
943
984
|
}
|
|
944
985
|
|
|
945
|
-
// Collect clefs from this measure's voices
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
if (
|
|
956
|
-
|
|
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
|
+
}
|
|
957
1004
|
}
|
|
958
1005
|
}
|
|
959
1006
|
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
1007
|
+
});
|
|
1008
|
+
};
|
|
962
1009
|
|
|
963
|
-
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime,
|
|
1010
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
|
|
1011
|
+
collectClefs();
|
|
964
1012
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
965
1013
|
measureStrs.push(measureStr || 's1');
|
|
966
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
|
|