@k-l-lambda/lilylet 0.1.35 → 0.1.37
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/README.md +30 -25
- package/lib/grammar.jison.js +16 -5
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/lilypondEncoder.d.ts +29 -0
- package/lib/lilypondEncoder.js +669 -0
- package/lib/meiEncoder.js +152 -29
- package/lib/musicXmlDecoder.js +4 -4
- package/lib/serializer.js +86 -20
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +16 -5
- package/source/lilylet/index.ts +2 -0
- package/source/lilylet/lilylet.jison +16 -5
- package/source/lilylet/lilypondEncoder.ts +832 -0
- package/source/lilylet/meiEncoder.ts +171 -29
- package/source/lilylet/musicXmlDecoder.ts +4 -4
- package/source/lilylet/serializer.ts +111 -21
package/lib/meiEncoder.js
CHANGED
|
@@ -618,7 +618,7 @@ const getEventBeamMarks = (event) => {
|
|
|
618
618
|
return { beamStart: false, beamEnd: false };
|
|
619
619
|
};
|
|
620
620
|
// Encode a layer (voice)
|
|
621
|
-
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null) => {
|
|
621
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
|
|
622
622
|
const layerId = generateId("layer");
|
|
623
623
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
624
624
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
@@ -630,12 +630,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
630
630
|
let currentHairpin = initialHairpin;
|
|
631
631
|
// Track pedal marks (each is independent, not paired spans)
|
|
632
632
|
const pedals = [];
|
|
633
|
-
// Track octave spans
|
|
633
|
+
// Track octave spans - initialize from previous measure if continuing
|
|
634
634
|
const octaves = [];
|
|
635
|
-
let currentOctave = null;
|
|
635
|
+
let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
|
|
636
636
|
let pendingOttava = null; // Track ottava to apply to next note
|
|
637
|
-
let currentOttavaShift = 0; // Track current ottava shift for pitch encoding
|
|
637
|
+
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
638
638
|
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
639
|
+
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
639
640
|
// Track slur spans - slurs must be encoded as control events in MEI
|
|
640
641
|
const slurs = [];
|
|
641
642
|
let currentSlur = initialSlur ? { startId: initialSlur } : null;
|
|
@@ -697,7 +698,17 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
697
698
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
698
699
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
699
700
|
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
700
|
-
|
|
701
|
+
// Close existing span first if it has a different value
|
|
702
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
703
|
+
// Different value - close the old span
|
|
704
|
+
// Use the lastNoteId from before this note (which we saved before processing)
|
|
705
|
+
// Note: The span from previous measure will be closed by encodeMeasure
|
|
706
|
+
currentOctave = null;
|
|
707
|
+
}
|
|
708
|
+
// Start new span if we don't already have one with the same value
|
|
709
|
+
if (!currentOctave) {
|
|
710
|
+
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
711
|
+
}
|
|
701
712
|
pendingOttava = null;
|
|
702
713
|
}
|
|
703
714
|
// Update pending tie pitches
|
|
@@ -835,12 +846,25 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
835
846
|
endId: lastNoteId,
|
|
836
847
|
});
|
|
837
848
|
currentOctave = null;
|
|
849
|
+
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
838
850
|
}
|
|
839
|
-
|
|
851
|
+
// Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
|
|
852
|
+
// It may be continued by a subsequent ottava command with the same value
|
|
853
|
+
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
840
854
|
}
|
|
841
855
|
else {
|
|
842
|
-
//
|
|
843
|
-
|
|
856
|
+
// Check if this continues an existing span (same value)
|
|
857
|
+
const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
|
|
858
|
+
const disPlace = ctx.ottava > 0 ? 'above' : 'below';
|
|
859
|
+
if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
|
|
860
|
+
// Continuation - restore the shift but don't change the span
|
|
861
|
+
currentOttavaShift = ctx.ottava;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
// Different value - start new ottava span (will be applied to next note)
|
|
865
|
+
// If there's an existing span with different value, it will be closed when the note is processed
|
|
866
|
+
pendingOttava = ctx.ottava;
|
|
867
|
+
}
|
|
844
868
|
}
|
|
845
869
|
}
|
|
846
870
|
// Check for stem direction changes
|
|
@@ -889,20 +913,16 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
889
913
|
if (beamElementOpen) {
|
|
890
914
|
xml += `${baseIndent}</beam>\n`;
|
|
891
915
|
}
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
startId: currentOctave.startId,
|
|
898
|
-
endId: lastNoteId,
|
|
899
|
-
});
|
|
900
|
-
}
|
|
916
|
+
// Don't close ottava span at measure end - it may continue in the next measure
|
|
917
|
+
// Build pending octave state to return
|
|
918
|
+
const pendingOctave = currentOctave
|
|
919
|
+
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
|
|
920
|
+
: null;
|
|
901
921
|
xml += `${indent}</layer>\n`;
|
|
902
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, endingClef: currentClef };
|
|
922
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
|
|
903
923
|
};
|
|
904
924
|
// Encode a staff
|
|
905
|
-
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, keyFifths = 0, initialClef) => {
|
|
925
|
+
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
906
926
|
const staffId = generateId("staff");
|
|
907
927
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
908
928
|
const allHairpins = [];
|
|
@@ -923,6 +943,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
923
943
|
const pendingTies = {};
|
|
924
944
|
const pendingSlurs = {};
|
|
925
945
|
const pendingHairpins = {};
|
|
946
|
+
const pendingOctaves = {};
|
|
947
|
+
const ottavaExplicitlyClosed = {};
|
|
948
|
+
const lastNoteIds = {};
|
|
926
949
|
let endingClef = initialClef;
|
|
927
950
|
if (voices.length === 0) {
|
|
928
951
|
xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
|
|
@@ -934,7 +957,8 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
934
957
|
const initialTies = tieState[tieKey] || [];
|
|
935
958
|
const initialSlur = slurState[tieKey] || null;
|
|
936
959
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
937
|
-
const
|
|
960
|
+
const initialOctave = ottavaState[tieKey] || null;
|
|
961
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
938
962
|
xml += result.xml;
|
|
939
963
|
allHairpins.push(...result.hairpins);
|
|
940
964
|
allPedals.push(...result.pedals);
|
|
@@ -963,6 +987,16 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
963
987
|
if (result.pendingHairpin) {
|
|
964
988
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
965
989
|
}
|
|
990
|
+
// Track pending ottava spans for this layer
|
|
991
|
+
if (result.pendingOctave) {
|
|
992
|
+
pendingOctaves[tieKey] = result.pendingOctave;
|
|
993
|
+
}
|
|
994
|
+
// Track if ottava was explicitly closed in this layer
|
|
995
|
+
if (result.ottavaExplicitlyClosed) {
|
|
996
|
+
ottavaExplicitlyClosed[tieKey] = true;
|
|
997
|
+
}
|
|
998
|
+
// Track last note IDs for this layer (for closing ottava spans)
|
|
999
|
+
lastNoteIds[tieKey] = result.lastNoteId;
|
|
966
1000
|
// Track ending clef for cross-measure tracking
|
|
967
1001
|
if (result.endingClef) {
|
|
968
1002
|
endingClef = result.endingClef;
|
|
@@ -990,17 +1024,54 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
990
1024
|
pendingTies,
|
|
991
1025
|
pendingSlurs,
|
|
992
1026
|
pendingHairpins,
|
|
1027
|
+
pendingOctaves,
|
|
1028
|
+
ottavaExplicitlyClosed,
|
|
1029
|
+
lastNoteIds,
|
|
993
1030
|
endingClef,
|
|
994
1031
|
};
|
|
995
1032
|
};
|
|
1033
|
+
// Tempo text to BPM mapping (approximate values based on musical convention)
|
|
1034
|
+
const TEMPO_TEXT_TO_BPM = {
|
|
1035
|
+
// Very slow
|
|
1036
|
+
'grave': 35,
|
|
1037
|
+
'largo': 50,
|
|
1038
|
+
'larghetto': 63,
|
|
1039
|
+
'lento': 52,
|
|
1040
|
+
'adagio': 70,
|
|
1041
|
+
// Slow to moderate
|
|
1042
|
+
'andante': 92,
|
|
1043
|
+
'andantino': 96,
|
|
1044
|
+
'moderato': 114,
|
|
1045
|
+
// Fast
|
|
1046
|
+
'allegretto': 116,
|
|
1047
|
+
'allegro': 138,
|
|
1048
|
+
'vivace': 166,
|
|
1049
|
+
'presto': 184,
|
|
1050
|
+
'prestissimo': 208,
|
|
1051
|
+
};
|
|
1052
|
+
// Infer BPM from tempo text
|
|
1053
|
+
const inferBpmFromText = (text) => {
|
|
1054
|
+
const lowerText = text.toLowerCase();
|
|
1055
|
+
for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
|
|
1056
|
+
if (lowerText.includes(keyword)) {
|
|
1057
|
+
return bpm;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return undefined;
|
|
1061
|
+
};
|
|
996
1062
|
// Generate tempo element
|
|
997
1063
|
const generateTempoElement = (tempo, indent, staff = 1) => {
|
|
998
1064
|
let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1065
|
+
// Determine BPM: use explicit value or infer from text
|
|
1066
|
+
let bpm = tempo.bpm;
|
|
1067
|
+
if (!bpm && tempo.text) {
|
|
1068
|
+
bpm = inferBpmFromText(tempo.text);
|
|
1069
|
+
}
|
|
1070
|
+
// Add BPM if available
|
|
1071
|
+
if (bpm) {
|
|
1072
|
+
attrs += ` midi.bpm="${bpm}"`;
|
|
1002
1073
|
if (tempo.beat) {
|
|
1003
|
-
attrs += ` mm="${
|
|
1074
|
+
attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
|
|
1004
1075
|
}
|
|
1005
1076
|
}
|
|
1006
1077
|
// Generate content
|
|
@@ -1030,8 +1101,8 @@ const BARLINE_TO_MEI = {
|
|
|
1030
1101
|
':..:': 'rptboth',
|
|
1031
1102
|
};
|
|
1032
1103
|
// Encode a measure
|
|
1033
|
-
// encodeMeasure accepts mutable tieState, slurState, hairpinState and clefState that persist across measures
|
|
1034
|
-
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1104
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1105
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1035
1106
|
const measureId = generateId("measure");
|
|
1036
1107
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1037
1108
|
const allHairpins = [];
|
|
@@ -1083,11 +1154,11 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1083
1154
|
voicesByStaff[globalStaff].push(voice);
|
|
1084
1155
|
}
|
|
1085
1156
|
}
|
|
1086
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1157
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1087
1158
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1088
1159
|
const voices = voicesByStaff[si] || [];
|
|
1089
1160
|
const initialClef = clefState[si];
|
|
1090
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1161
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1091
1162
|
staffContent += result.xml;
|
|
1092
1163
|
allHairpins.push(...result.hairpins);
|
|
1093
1164
|
allPedals.push(...result.pedals);
|
|
@@ -1110,6 +1181,56 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1110
1181
|
Object.assign(slurState, result.pendingSlurs);
|
|
1111
1182
|
// Update hairpin state with pending hairpins from this staff
|
|
1112
1183
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1184
|
+
// Update ottava state with pending octaves from this staff
|
|
1185
|
+
// Also handle closing spans when ottava ends
|
|
1186
|
+
const currentStaffPrefix = `${si}-`;
|
|
1187
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1188
|
+
if (pending) {
|
|
1189
|
+
// Check if this is a continuation or a new span
|
|
1190
|
+
const prevPending = ottavaState[key];
|
|
1191
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1192
|
+
// Same ottava value continues - keep the original startId
|
|
1193
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
// Different ottava value - close the old span first if exists
|
|
1197
|
+
if (prevPending) {
|
|
1198
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1199
|
+
if (lastNoteId) {
|
|
1200
|
+
allOctaves.push({
|
|
1201
|
+
dis: prevPending.dis,
|
|
1202
|
+
disPlace: prevPending.disPlace,
|
|
1203
|
+
startId: prevPending.startId,
|
|
1204
|
+
endId: lastNoteId,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
// Start new span
|
|
1209
|
+
ottavaState[key] = pending;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1214
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1215
|
+
// Only process keys for the current staff
|
|
1216
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1217
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1218
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1219
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1220
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1221
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1222
|
+
if (lastNoteId) {
|
|
1223
|
+
allOctaves.push({
|
|
1224
|
+
dis: pending.dis,
|
|
1225
|
+
disPlace: pending.disPlace,
|
|
1226
|
+
startId: pending.startId,
|
|
1227
|
+
endId: lastNoteId,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
delete ottavaState[key];
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1113
1234
|
// Update clef state with ending clef from this staff
|
|
1114
1235
|
if (result.endingClef) {
|
|
1115
1236
|
clefState[si] = result.endingClef;
|
|
@@ -1344,6 +1465,8 @@ const encode = (doc, options = {}) => {
|
|
|
1344
1465
|
const slurState = {};
|
|
1345
1466
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1346
1467
|
const hairpinState = {};
|
|
1468
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1469
|
+
const ottavaState = {};
|
|
1347
1470
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1348
1471
|
const clefState = {};
|
|
1349
1472
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1399,7 +1522,7 @@ const encode = (doc, options = {}) => {
|
|
|
1399
1522
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1400
1523
|
}
|
|
1401
1524
|
}
|
|
1402
|
-
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1525
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1403
1526
|
});
|
|
1404
1527
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
1405
1528
|
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
package/lib/musicXmlDecoder.js
CHANGED
|
@@ -727,13 +727,13 @@ const directionToContextChange = (direction, ottavaTracker) => {
|
|
|
727
727
|
ottava = 0;
|
|
728
728
|
ottavaTracker.current = 0;
|
|
729
729
|
}
|
|
730
|
-
else if (type === '
|
|
731
|
-
// 8va = 1, 15ma = 2
|
|
730
|
+
else if (type === 'down') {
|
|
731
|
+
// 8va = 1, 15ma = 2 (type="down" means written notes sound higher)
|
|
732
732
|
ottava = size === 15 ? 2 : 1;
|
|
733
733
|
ottavaTracker.current = ottava;
|
|
734
734
|
}
|
|
735
|
-
else if (type === '
|
|
736
|
-
// 8vb = -1, 15mb = -2
|
|
735
|
+
else if (type === 'up') {
|
|
736
|
+
// 8vb = -1, 15mb = -2 (type="up" means written notes sound lower)
|
|
737
737
|
ottava = size === 15 ? -2 : -1;
|
|
738
738
|
ottavaTracker.current = ottava;
|
|
739
739
|
}
|
package/lib/serializer.js
CHANGED
|
@@ -406,6 +406,14 @@ const serializeTremoloEvent = (event, env) => {
|
|
|
406
406
|
parts.push(' }');
|
|
407
407
|
return { str: parts.join(''), newEnv: currentEnv };
|
|
408
408
|
};
|
|
409
|
+
// Serialize a barline event
|
|
410
|
+
const serializeBarlineEvent = (event) => {
|
|
411
|
+
// Only output non-default barlines
|
|
412
|
+
if (event.style && event.style !== '|') {
|
|
413
|
+
return '\\bar "' + event.style + '"';
|
|
414
|
+
}
|
|
415
|
+
return '';
|
|
416
|
+
};
|
|
409
417
|
// Serialize a single event with pitch environment tracking
|
|
410
418
|
const serializeEvent = (event, env, prevDuration) => {
|
|
411
419
|
switch (event.type) {
|
|
@@ -419,15 +427,30 @@ const serializeEvent = (event, env, prevDuration) => {
|
|
|
419
427
|
return serializeTupletEvent(event, env);
|
|
420
428
|
case 'tremolo':
|
|
421
429
|
return serializeTremoloEvent(event, env);
|
|
430
|
+
case 'barline':
|
|
431
|
+
return { str: serializeBarlineEvent(event), newEnv: env };
|
|
422
432
|
default:
|
|
423
433
|
return { str: '', newEnv: env };
|
|
424
434
|
}
|
|
425
435
|
};
|
|
436
|
+
// Find first clef in voice events
|
|
437
|
+
const findVoiceClef = (voice) => {
|
|
438
|
+
for (const event of voice.events) {
|
|
439
|
+
if (event.type === 'context') {
|
|
440
|
+
const ctx = event;
|
|
441
|
+
if (ctx.clef) {
|
|
442
|
+
return ctx.clef;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
};
|
|
426
448
|
// Serialize a voice with pitch environment tracking
|
|
427
449
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
428
450
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
429
|
-
//
|
|
430
|
-
|
|
451
|
+
// measureContext provides key/time for first voice
|
|
452
|
+
// staffClef is the clef for this voice's staff (tracked across measures)
|
|
453
|
+
const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, staffClef) => {
|
|
431
454
|
const parts = [];
|
|
432
455
|
let prevDuration;
|
|
433
456
|
// Each voice starts fresh from middle C (step=0, octave=0)
|
|
@@ -437,8 +460,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
437
460
|
if (isGrandStaff || voice.staff !== currentStaff) {
|
|
438
461
|
parts.push('\\staff "' + voice.staff + '"');
|
|
439
462
|
}
|
|
440
|
-
// Output key/time signatures after \staff (for first voice
|
|
441
|
-
if (measureContext) {
|
|
463
|
+
// Output key/time signatures after \staff (for first voice only)
|
|
464
|
+
if (measureContext && isFirstVoice) {
|
|
442
465
|
if (measureContext.key) {
|
|
443
466
|
let keyStr = String(measureContext.key.pitch);
|
|
444
467
|
if (measureContext.key.accidental) {
|
|
@@ -457,7 +480,22 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
457
480
|
parts.push('\\time ' + numerator + '/' + denominator);
|
|
458
481
|
}
|
|
459
482
|
}
|
|
483
|
+
// Output clef for every voice (use staff clef tracked across measures, or find from voice events)
|
|
484
|
+
const voiceClef = staffClef || findVoiceClef(voice);
|
|
485
|
+
if (voiceClef) {
|
|
486
|
+
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
487
|
+
}
|
|
488
|
+
// Track if we've already output the clef to avoid duplication
|
|
489
|
+
let clefOutputted = !!voiceClef;
|
|
460
490
|
for (const event of voice.events) {
|
|
491
|
+
// Skip clef context events if we've already output the clef at the beginning
|
|
492
|
+
if (clefOutputted && event.type === 'context') {
|
|
493
|
+
const ctx = event;
|
|
494
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
|
|
495
|
+
// This is a clef-only context event, skip it
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
461
499
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
462
500
|
pitchEnv = newEnv;
|
|
463
501
|
if (eventStr) {
|
|
@@ -474,8 +512,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
474
512
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
475
513
|
};
|
|
476
514
|
// Serialize a part, tracking staff state across voices
|
|
477
|
-
//
|
|
478
|
-
const serializePart = (part, currentStaff, isGrandStaff = false, measureContext) => {
|
|
515
|
+
// 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) => {
|
|
479
517
|
if (part.voices.length === 0) {
|
|
480
518
|
return { str: '', newStaff: currentStaff };
|
|
481
519
|
}
|
|
@@ -483,9 +521,11 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
483
521
|
let staff = currentStaff;
|
|
484
522
|
for (let i = 0; i < part.voices.length; i++) {
|
|
485
523
|
const voice = part.voices[i];
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
const
|
|
524
|
+
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
525
|
+
// Pass staff clef from clefsByStaff map
|
|
526
|
+
const isFirstVoice = isFirstPart && i === 0;
|
|
527
|
+
const staffClef = clefsByStaff?.[voice.staff];
|
|
528
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
489
529
|
voiceStrs.push(str);
|
|
490
530
|
staff = newStaff;
|
|
491
531
|
}
|
|
@@ -493,17 +533,22 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
493
533
|
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
494
534
|
};
|
|
495
535
|
// Serialize a measure, tracking staff state across parts
|
|
496
|
-
|
|
536
|
+
// Always output key/time at start of each measure
|
|
537
|
+
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
|
|
497
538
|
const parts = [];
|
|
498
|
-
// Build measure context for
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
539
|
+
// Build measure context for all voices (key/time)
|
|
540
|
+
// Key and time are written to first voice, clef to all voices based on staff
|
|
541
|
+
// Use passed currentKey/currentTime which tracks across all measures
|
|
542
|
+
const measureContext = {
|
|
543
|
+
key: currentKey,
|
|
544
|
+
time: currentTime,
|
|
545
|
+
};
|
|
546
|
+
// Pass staffClefs to parts for per-voice clef lookup
|
|
547
|
+
const clefsByStaff = staffClefs || {};
|
|
503
548
|
// Parts
|
|
504
549
|
let staff = currentStaff;
|
|
505
550
|
if (measure.parts.length === 1) {
|
|
506
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
|
|
551
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
507
552
|
if (partStr) {
|
|
508
553
|
parts.push(partStr);
|
|
509
554
|
}
|
|
@@ -514,9 +559,8 @@ const serializeMeasure = (measure, isFirst, currentStaff, isGrandStaff = false)
|
|
|
514
559
|
const partStrs = [];
|
|
515
560
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
516
561
|
const part = measure.parts[i];
|
|
517
|
-
//
|
|
518
|
-
const
|
|
519
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
|
|
562
|
+
// Pass measureContext to all parts, isFirstPart to first part only
|
|
563
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
520
564
|
if (str) {
|
|
521
565
|
partStrs.push(str);
|
|
522
566
|
}
|
|
@@ -567,10 +611,32 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
567
611
|
const isGrandStaff = doc.measures.some(m => m.parts.some(p => p.voices.some(v => v.staff > 1)));
|
|
568
612
|
// Measures with bar lines, measure numbers, and double newlines
|
|
569
613
|
// Track staff state across measures (parser remembers staff across bar lines)
|
|
614
|
+
// Track key/time/clef across measures to output in every measure
|
|
570
615
|
const measureStrs = [];
|
|
571
616
|
let currentStaff = 1; // Parser starts at staff 1
|
|
617
|
+
let currentKey;
|
|
618
|
+
let currentTime;
|
|
619
|
+
const staffClefs = {}; // Track clef per staff
|
|
572
620
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
573
|
-
const
|
|
621
|
+
const measure = doc.measures[i];
|
|
622
|
+
// Update current key/time if measure has them
|
|
623
|
+
if (measure.key) {
|
|
624
|
+
currentKey = measure.key;
|
|
625
|
+
}
|
|
626
|
+
if (measure.timeSig) {
|
|
627
|
+
currentTime = measure.timeSig;
|
|
628
|
+
}
|
|
629
|
+
// Collect clefs from this measure's voices
|
|
630
|
+
for (const part of measure.parts) {
|
|
631
|
+
for (const voice of part.voices) {
|
|
632
|
+
for (const event of voice.events) {
|
|
633
|
+
if (event.type === 'context' && event.clef) {
|
|
634
|
+
staffClefs[voice.staff] = event.clef;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
574
640
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
575
641
|
measureStrs.push(measureStr || 's1');
|
|
576
642
|
currentStaff = newStaff;
|
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.37",
|
|
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",
|
|
@@ -150,7 +150,7 @@ case 25:
|
|
|
150
150
|
this.$ = $$[$0-3].concat([part($$[$0])]);
|
|
151
151
|
break;
|
|
152
152
|
case 26:
|
|
153
|
-
currentStaff = 1;
|
|
153
|
+
currentStaff = 1; currentOttava = 0;
|
|
154
154
|
break;
|
|
155
155
|
case 27:
|
|
156
156
|
this.$ = [voice(currentStaff, $$[$0])];
|
|
@@ -174,7 +174,16 @@ case 43:
|
|
|
174
174
|
this.$ = markupEvent($$[$0].slice(1, -1));
|
|
175
175
|
break;
|
|
176
176
|
case 44:
|
|
177
|
-
|
|
177
|
+
|
|
178
|
+
// On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
|
|
179
|
+
if (currentOttava !== 0) {
|
|
180
|
+
const ottavaReset = contextChange({ ottava: 0 });
|
|
181
|
+
currentOttava = 0;
|
|
182
|
+
this.$ = [ottavaReset, { type: 'pitchReset' }];
|
|
183
|
+
} else {
|
|
184
|
+
this.$ = { type: 'pitchReset' };
|
|
185
|
+
}
|
|
186
|
+
|
|
178
187
|
break;
|
|
179
188
|
case 45: case 46:
|
|
180
189
|
currentDuration = $$[$0-1]; this.$ = noteEvent($$[$0-2], $$[$0-1], $$[$0]);
|
|
@@ -299,13 +308,13 @@ case 85:
|
|
|
299
308
|
currentStaff = Number($$[$0].slice(1, -1)); this.$ = currentStaff;
|
|
300
309
|
break;
|
|
301
310
|
case 86:
|
|
302
|
-
|
|
311
|
+
currentOttava = Number($$[$0]); this.$ = currentOttava;
|
|
303
312
|
break;
|
|
304
313
|
case 87:
|
|
305
|
-
|
|
314
|
+
currentOttava = -Number($$[$0]); this.$ = currentOttava;
|
|
306
315
|
break;
|
|
307
316
|
case 88:
|
|
308
|
-
this.$ = 0;
|
|
317
|
+
currentOttava = 0; this.$ = 0;
|
|
309
318
|
break;
|
|
310
319
|
case 89:
|
|
311
320
|
this.$ = 'up';
|
|
@@ -737,6 +746,7 @@ parse: function parse(input) {
|
|
|
737
746
|
let currentTimeSig = null;
|
|
738
747
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
739
748
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
749
|
+
let currentOttava = 0; // Current ottava level, resets on newline
|
|
740
750
|
|
|
741
751
|
// Reset parser state - call before each parse
|
|
742
752
|
const resetParserState = () => {
|
|
@@ -745,6 +755,7 @@ parse: function parse(input) {
|
|
|
745
755
|
currentTimeSig = null;
|
|
746
756
|
currentDuration = { division: 4, dots: 0 };
|
|
747
757
|
numericTimeSignature = false;
|
|
758
|
+
currentOttava = 0;
|
|
748
759
|
};
|
|
749
760
|
|
|
750
761
|
// Export reset function
|
package/source/lilylet/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ export * from "./serializer";
|
|
|
5
5
|
|
|
6
6
|
import * as meiEncoder from "./meiEncoder";
|
|
7
7
|
import * as musicXmlDecoder from "./musicXmlDecoder";
|
|
8
|
+
import * as lilypondEncoder from "./lilypondEncoder";
|
|
8
9
|
|
|
9
10
|
export {
|
|
10
11
|
meiEncoder,
|
|
11
12
|
musicXmlDecoder,
|
|
13
|
+
lilypondEncoder,
|
|
12
14
|
};
|
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
let currentTimeSig = null;
|
|
130
130
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
131
131
|
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
132
|
+
let currentOttava = 0; // Current ottava level, resets on newline
|
|
132
133
|
|
|
133
134
|
// Reset parser state - call before each parse
|
|
134
135
|
const resetParserState = () => {
|
|
@@ -137,6 +138,7 @@
|
|
|
137
138
|
currentTimeSig = null;
|
|
138
139
|
currentDuration = { division: 4, dots: 0 };
|
|
139
140
|
numericTimeSignature = false;
|
|
141
|
+
currentOttava = 0;
|
|
140
142
|
};
|
|
141
143
|
|
|
142
144
|
// Export reset function
|
|
@@ -323,7 +325,7 @@ parts
|
|
|
323
325
|
;
|
|
324
326
|
|
|
325
327
|
part_start
|
|
326
|
-
: /* empty */ %{ currentStaff = 1; %}
|
|
328
|
+
: /* empty */ %{ currentStaff = 1; currentOttava = 0; %}
|
|
327
329
|
;
|
|
328
330
|
|
|
329
331
|
part_voices
|
|
@@ -362,7 +364,16 @@ markup_event
|
|
|
362
364
|
;
|
|
363
365
|
|
|
364
366
|
pitch_reset_event
|
|
365
|
-
: NEWLINE
|
|
367
|
+
: NEWLINE %{
|
|
368
|
+
// On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
|
|
369
|
+
if (currentOttava !== 0) {
|
|
370
|
+
const ottavaReset = contextChange({ ottava: 0 });
|
|
371
|
+
currentOttava = 0;
|
|
372
|
+
$$ = [ottavaReset, { type: 'pitchReset' }];
|
|
373
|
+
} else {
|
|
374
|
+
$$ = { type: 'pitchReset' };
|
|
375
|
+
}
|
|
376
|
+
%}
|
|
366
377
|
;
|
|
367
378
|
|
|
368
379
|
note_event
|
|
@@ -475,9 +486,9 @@ staff_cmd
|
|
|
475
486
|
;
|
|
476
487
|
|
|
477
488
|
ottava_cmd
|
|
478
|
-
: CMD_OTTAVA '#' NUMBER
|
|
479
|
-
| CMD_OTTAVA '#' '-' NUMBER
|
|
480
|
-
| CMD_OTTAVA
|
|
489
|
+
: CMD_OTTAVA '#' NUMBER %{ currentOttava = Number($3); $$ = currentOttava; %}
|
|
490
|
+
| CMD_OTTAVA '#' '-' NUMBER %{ currentOttava = -Number($4); $$ = currentOttava; %}
|
|
491
|
+
| CMD_OTTAVA %{ currentOttava = 0; $$ = 0; %}
|
|
481
492
|
;
|
|
482
493
|
|
|
483
494
|
stem_cmd
|