@k-l-lambda/lilylet 0.1.34 → 0.1.36
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 +193 -153
- package/lib/meiEncoder.js +135 -28
- package/lib/musicXmlDecoder.js +4 -4
- package/lib/serializer.js +93 -21
- package/lib/types.d.ts +4 -1
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +193 -153
- package/source/lilylet/lilylet.jison +46 -7
- package/source/lilylet/meiEncoder.ts +152 -28
- package/source/lilylet/musicXmlDecoder.ts +4 -4
- package/source/lilylet/serializer.ts +119 -23
- package/source/lilylet/types.ts +7 -1
- package/lib/lilypondDecoder.d.ts +0 -28
- package/lib/lilypondDecoder.js +0 -645
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,6 +1024,9 @@ 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
|
};
|
|
@@ -1030,8 +1067,8 @@ const BARLINE_TO_MEI = {
|
|
|
1030
1067
|
':..:': 'rptboth',
|
|
1031
1068
|
};
|
|
1032
1069
|
// 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 = {}) => {
|
|
1070
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1071
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1035
1072
|
const measureId = generateId("measure");
|
|
1036
1073
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1037
1074
|
const allHairpins = [];
|
|
@@ -1083,11 +1120,11 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1083
1120
|
voicesByStaff[globalStaff].push(voice);
|
|
1084
1121
|
}
|
|
1085
1122
|
}
|
|
1086
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1123
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1087
1124
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1088
1125
|
const voices = voicesByStaff[si] || [];
|
|
1089
1126
|
const initialClef = clefState[si];
|
|
1090
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1127
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1091
1128
|
staffContent += result.xml;
|
|
1092
1129
|
allHairpins.push(...result.hairpins);
|
|
1093
1130
|
allPedals.push(...result.pedals);
|
|
@@ -1110,6 +1147,56 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
|
|
|
1110
1147
|
Object.assign(slurState, result.pendingSlurs);
|
|
1111
1148
|
// Update hairpin state with pending hairpins from this staff
|
|
1112
1149
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1150
|
+
// Update ottava state with pending octaves from this staff
|
|
1151
|
+
// Also handle closing spans when ottava ends
|
|
1152
|
+
const currentStaffPrefix = `${si}-`;
|
|
1153
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1154
|
+
if (pending) {
|
|
1155
|
+
// Check if this is a continuation or a new span
|
|
1156
|
+
const prevPending = ottavaState[key];
|
|
1157
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1158
|
+
// Same ottava value continues - keep the original startId
|
|
1159
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
// Different ottava value - close the old span first if exists
|
|
1163
|
+
if (prevPending) {
|
|
1164
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1165
|
+
if (lastNoteId) {
|
|
1166
|
+
allOctaves.push({
|
|
1167
|
+
dis: prevPending.dis,
|
|
1168
|
+
disPlace: prevPending.disPlace,
|
|
1169
|
+
startId: prevPending.startId,
|
|
1170
|
+
endId: lastNoteId,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
// Start new span
|
|
1175
|
+
ottavaState[key] = pending;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1180
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1181
|
+
// Only process keys for the current staff
|
|
1182
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1183
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1184
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1185
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1186
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1187
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1188
|
+
if (lastNoteId) {
|
|
1189
|
+
allOctaves.push({
|
|
1190
|
+
dis: pending.dis,
|
|
1191
|
+
disPlace: pending.disPlace,
|
|
1192
|
+
startId: pending.startId,
|
|
1193
|
+
endId: lastNoteId,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
delete ottavaState[key];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1113
1200
|
// Update clef state with ending clef from this staff
|
|
1114
1201
|
if (result.endingClef) {
|
|
1115
1202
|
clefState[si] = result.endingClef;
|
|
@@ -1237,9 +1324,11 @@ const analyzePartStructure = (doc) => {
|
|
|
1237
1324
|
return partInfos;
|
|
1238
1325
|
};
|
|
1239
1326
|
// Encode scoreDef with part groups
|
|
1240
|
-
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent) => {
|
|
1327
|
+
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
|
|
1241
1328
|
const scoreDefId = generateId("scoredef");
|
|
1242
|
-
|
|
1329
|
+
// Build meter attributes
|
|
1330
|
+
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1331
|
+
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1243
1332
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1244
1333
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1245
1334
|
const info = partInfos[pi];
|
|
@@ -1281,6 +1370,7 @@ const encode = (doc, options = {}) => {
|
|
|
1281
1370
|
let currentKey = 0;
|
|
1282
1371
|
let currentTimeNum = 4;
|
|
1283
1372
|
let currentTimeDen = 4;
|
|
1373
|
+
let currentMeterSymbol = undefined;
|
|
1284
1374
|
const firstMeasure = doc.measures[0];
|
|
1285
1375
|
if (firstMeasure.key) {
|
|
1286
1376
|
currentKey = keyToFifths(firstMeasure.key);
|
|
@@ -1288,6 +1378,7 @@ const encode = (doc, options = {}) => {
|
|
|
1288
1378
|
if (firstMeasure.timeSig) {
|
|
1289
1379
|
currentTimeNum = firstMeasure.timeSig.numerator;
|
|
1290
1380
|
currentTimeDen = firstMeasure.timeSig.denominator;
|
|
1381
|
+
currentMeterSymbol = firstMeasure.timeSig.symbol;
|
|
1291
1382
|
}
|
|
1292
1383
|
const keySig = KEY_SIGS[currentKey] || "0";
|
|
1293
1384
|
// Build MEI document
|
|
@@ -1332,7 +1423,7 @@ const encode = (doc, options = {}) => {
|
|
|
1332
1423
|
mei += `${indent}${indent}<body>\n`;
|
|
1333
1424
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1334
1425
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1335
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}
|
|
1426
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
1336
1427
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1337
1428
|
// Track tie state across measures for cross-measure ties
|
|
1338
1429
|
const tieState = {};
|
|
@@ -1340,6 +1431,8 @@ const encode = (doc, options = {}) => {
|
|
|
1340
1431
|
const slurState = {};
|
|
1341
1432
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1342
1433
|
const hairpinState = {};
|
|
1434
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1435
|
+
const ottavaState = {};
|
|
1343
1436
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1344
1437
|
const clefState = {};
|
|
1345
1438
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1381,7 +1474,21 @@ const encode = (doc, options = {}) => {
|
|
|
1381
1474
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1382
1475
|
}
|
|
1383
1476
|
}
|
|
1384
|
-
|
|
1477
|
+
// Check for time signature change and output scoreDef if needed
|
|
1478
|
+
if (measure.timeSig && mi > 0) {
|
|
1479
|
+
const newTimeNum = measure.timeSig.numerator;
|
|
1480
|
+
const newTimeDen = measure.timeSig.denominator;
|
|
1481
|
+
const newMeterSymbol = measure.timeSig.symbol;
|
|
1482
|
+
if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
|
|
1483
|
+
currentTimeNum = newTimeNum;
|
|
1484
|
+
currentTimeDen = newTimeDen;
|
|
1485
|
+
currentMeterSymbol = newMeterSymbol;
|
|
1486
|
+
// Output a scoreDef with the new time signature
|
|
1487
|
+
const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
|
|
1488
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1385
1492
|
});
|
|
1386
1493
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
1387
1494
|
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) {
|
|
@@ -448,10 +471,31 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
448
471
|
parts.push('\\key ' + keyStr);
|
|
449
472
|
}
|
|
450
473
|
if (measureContext.time) {
|
|
451
|
-
|
|
474
|
+
const { numerator, denominator, symbol } = measureContext.time;
|
|
475
|
+
// Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
|
|
476
|
+
// (meaning numeric display was explicitly requested)
|
|
477
|
+
if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
|
|
478
|
+
parts.push('\\numericTimeSignature');
|
|
479
|
+
}
|
|
480
|
+
parts.push('\\time ' + numerator + '/' + denominator);
|
|
452
481
|
}
|
|
453
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;
|
|
454
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
|
+
}
|
|
455
499
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
456
500
|
pitchEnv = newEnv;
|
|
457
501
|
if (eventStr) {
|
|
@@ -468,8 +512,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
468
512
|
return { str: parts.join(' '), newStaff: voice.staff };
|
|
469
513
|
};
|
|
470
514
|
// Serialize a part, tracking staff state across voices
|
|
471
|
-
//
|
|
472
|
-
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) => {
|
|
473
517
|
if (part.voices.length === 0) {
|
|
474
518
|
return { str: '', newStaff: currentStaff };
|
|
475
519
|
}
|
|
@@ -477,9 +521,11 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
477
521
|
let staff = currentStaff;
|
|
478
522
|
for (let i = 0; i < part.voices.length; i++) {
|
|
479
523
|
const voice = part.voices[i];
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
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);
|
|
483
529
|
voiceStrs.push(str);
|
|
484
530
|
staff = newStaff;
|
|
485
531
|
}
|
|
@@ -487,17 +533,22 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
|
|
|
487
533
|
return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
|
|
488
534
|
};
|
|
489
535
|
// Serialize a measure, tracking staff state across parts
|
|
490
|
-
|
|
536
|
+
// Always output key/time at start of each measure
|
|
537
|
+
const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
|
|
491
538
|
const parts = [];
|
|
492
|
-
// Build measure context for
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 || {};
|
|
497
548
|
// Parts
|
|
498
549
|
let staff = currentStaff;
|
|
499
550
|
if (measure.parts.length === 1) {
|
|
500
|
-
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);
|
|
501
552
|
if (partStr) {
|
|
502
553
|
parts.push(partStr);
|
|
503
554
|
}
|
|
@@ -508,9 +559,8 @@ const serializeMeasure = (measure, isFirst, currentStaff, isGrandStaff = false)
|
|
|
508
559
|
const partStrs = [];
|
|
509
560
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
510
561
|
const part = measure.parts[i];
|
|
511
|
-
//
|
|
512
|
-
const
|
|
513
|
-
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);
|
|
514
564
|
if (str) {
|
|
515
565
|
partStrs.push(str);
|
|
516
566
|
}
|
|
@@ -561,10 +611,32 @@ export const serializeLilyletDoc = (doc) => {
|
|
|
561
611
|
const isGrandStaff = doc.measures.some(m => m.parts.some(p => p.voices.some(v => v.staff > 1)));
|
|
562
612
|
// Measures with bar lines, measure numbers, and double newlines
|
|
563
613
|
// Track staff state across measures (parser remembers staff across bar lines)
|
|
614
|
+
// Track key/time/clef across measures to output in every measure
|
|
564
615
|
const measureStrs = [];
|
|
565
616
|
let currentStaff = 1; // Parser starts at staff 1
|
|
617
|
+
let currentKey;
|
|
618
|
+
let currentTime;
|
|
619
|
+
const staffClefs = {}; // Track clef per staff
|
|
566
620
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
567
|
-
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);
|
|
568
640
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
569
641
|
measureStrs.push(measureStr || 's1');
|
|
570
642
|
currentStaff = newStaff;
|
package/lib/types.d.ts
CHANGED
|
@@ -83,6 +83,9 @@ export interface Fraction {
|
|
|
83
83
|
numerator: number;
|
|
84
84
|
denominator: number;
|
|
85
85
|
}
|
|
86
|
+
export interface TimeSig extends Fraction {
|
|
87
|
+
symbol?: 'common' | 'cut';
|
|
88
|
+
}
|
|
86
89
|
export interface Pitch {
|
|
87
90
|
phonet: Phonet;
|
|
88
91
|
accidental?: Accidental;
|
|
@@ -231,7 +234,7 @@ export interface Part {
|
|
|
231
234
|
}
|
|
232
235
|
export interface Measure {
|
|
233
236
|
key?: KeySignature;
|
|
234
|
-
timeSig?:
|
|
237
|
+
timeSig?: TimeSig;
|
|
235
238
|
parts: Part[];
|
|
236
239
|
partial?: boolean;
|
|
237
240
|
}
|
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.36",
|
|
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",
|