@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
|
@@ -816,6 +816,15 @@ type TieState = Record<string, Pitch[]>;
|
|
|
816
816
|
type SlurState = Record<string, string | null>; // voice key -> pending slur startId
|
|
817
817
|
type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
|
|
818
818
|
|
|
819
|
+
// Pending octave span for cross-measure continuation
|
|
820
|
+
interface PendingOctave {
|
|
821
|
+
dis: 8 | 15;
|
|
822
|
+
disPlace: 'above' | 'below';
|
|
823
|
+
startId: string;
|
|
824
|
+
shift: number; // The ottava value (1, -1, 2, -2)
|
|
825
|
+
}
|
|
826
|
+
type OttavaState = Record<string, PendingOctave | null>; // voice key -> pending octave span
|
|
827
|
+
|
|
819
828
|
// Layer result type
|
|
820
829
|
interface LayerResult {
|
|
821
830
|
xml: string;
|
|
@@ -837,7 +846,11 @@ interface LayerResult {
|
|
|
837
846
|
pendingTiePitches: Pitch[]; // For cross-measure tie tracking
|
|
838
847
|
pendingSlur: string | null; // For cross-measure slur tracking (startId)
|
|
839
848
|
pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
|
|
849
|
+
pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
|
|
850
|
+
ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
|
|
840
851
|
endingClef?: Clef; // For cross-measure clef tracking
|
|
852
|
+
lastNoteId: string | null; // For cross-measure ottava span end tracking
|
|
853
|
+
currentOttavaShift: number; // Current ottava shift for pitch encoding
|
|
841
854
|
}
|
|
842
855
|
|
|
843
856
|
|
|
@@ -864,7 +877,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
|
|
|
864
877
|
};
|
|
865
878
|
|
|
866
879
|
// Encode a layer (voice)
|
|
867
|
-
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null): LayerResult => {
|
|
880
|
+
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
|
|
868
881
|
const layerId = generateId("layer");
|
|
869
882
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
870
883
|
|
|
@@ -881,12 +894,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
881
894
|
// Track pedal marks (each is independent, not paired spans)
|
|
882
895
|
const pedals: PedalMark[] = [];
|
|
883
896
|
|
|
884
|
-
// Track octave spans
|
|
897
|
+
// Track octave spans - initialize from previous measure if continuing
|
|
885
898
|
const octaves: OctaveSpan[] = [];
|
|
886
|
-
let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
|
|
899
|
+
let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
|
|
900
|
+
initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
|
|
887
901
|
let pendingOttava: number | null = null; // Track ottava to apply to next note
|
|
888
|
-
let currentOttavaShift: number = 0; // Track current ottava shift for pitch encoding
|
|
902
|
+
let currentOttavaShift: number = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
889
903
|
let lastNoteId: string | null = null; // Track last note id for ending ottava spans
|
|
904
|
+
let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
|
|
890
905
|
|
|
891
906
|
// Track slur spans - slurs must be encoded as control events in MEI
|
|
892
907
|
const slurs: SlurSpan[] = [];
|
|
@@ -961,7 +976,17 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
961
976
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
962
977
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
963
978
|
const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
|
|
964
|
-
|
|
979
|
+
// Close existing span first if it has a different value
|
|
980
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
981
|
+
// Different value - close the old span
|
|
982
|
+
// Use the lastNoteId from before this note (which we saved before processing)
|
|
983
|
+
// Note: The span from previous measure will be closed by encodeMeasure
|
|
984
|
+
currentOctave = null;
|
|
985
|
+
}
|
|
986
|
+
// Start new span if we don't already have one with the same value
|
|
987
|
+
if (!currentOctave) {
|
|
988
|
+
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
989
|
+
}
|
|
965
990
|
pendingOttava = null;
|
|
966
991
|
}
|
|
967
992
|
|
|
@@ -1106,11 +1131,23 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1106
1131
|
endId: lastNoteId,
|
|
1107
1132
|
});
|
|
1108
1133
|
currentOctave = null;
|
|
1134
|
+
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
1109
1135
|
}
|
|
1110
|
-
|
|
1136
|
+
// Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
|
|
1137
|
+
// It may be continued by a subsequent ottava command with the same value
|
|
1138
|
+
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
1111
1139
|
} else {
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1140
|
+
// Check if this continues an existing span (same value)
|
|
1141
|
+
const dis: 8 | 15 = Math.abs(ctx.ottava) === 2 ? 15 : 8;
|
|
1142
|
+
const disPlace: 'above' | 'below' = ctx.ottava > 0 ? 'above' : 'below';
|
|
1143
|
+
if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
|
|
1144
|
+
// Continuation - restore the shift but don't change the span
|
|
1145
|
+
currentOttavaShift = ctx.ottava;
|
|
1146
|
+
} else {
|
|
1147
|
+
// Different value - start new ottava span (will be applied to next note)
|
|
1148
|
+
// If there's an existing span with different value, it will be closed when the note is processed
|
|
1149
|
+
pendingOttava = ctx.ottava;
|
|
1150
|
+
}
|
|
1114
1151
|
}
|
|
1115
1152
|
}
|
|
1116
1153
|
// Check for stem direction changes
|
|
@@ -1162,18 +1199,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1162
1199
|
xml += `${baseIndent}</beam>\n`;
|
|
1163
1200
|
}
|
|
1164
1201
|
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
startId: currentOctave.startId,
|
|
1171
|
-
endId: lastNoteId,
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1202
|
+
// Don't close ottava span at measure end - it may continue in the next measure
|
|
1203
|
+
// Build pending octave state to return
|
|
1204
|
+
const pendingOctave: PendingOctave | null = currentOctave
|
|
1205
|
+
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
|
|
1206
|
+
: null;
|
|
1174
1207
|
|
|
1175
1208
|
xml += `${indent}</layer>\n`;
|
|
1176
|
-
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 };
|
|
1209
|
+
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 };
|
|
1177
1210
|
};
|
|
1178
1211
|
|
|
1179
1212
|
// Staff result type
|
|
@@ -1197,11 +1230,14 @@ interface StaffResult {
|
|
|
1197
1230
|
pendingTies: TieState; // For cross-measure tie tracking
|
|
1198
1231
|
pendingSlurs: SlurState; // For cross-measure slur tracking
|
|
1199
1232
|
pendingHairpins: HairpinState; // For cross-measure hairpin tracking
|
|
1233
|
+
pendingOctaves: OttavaState; // For cross-measure ottava span tracking
|
|
1234
|
+
ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
|
|
1235
|
+
lastNoteIds: Record<string, string | null>; // For cross-measure ottava span end tracking
|
|
1200
1236
|
endingClef?: Clef; // For cross-measure clef tracking
|
|
1201
1237
|
}
|
|
1202
1238
|
|
|
1203
1239
|
// Encode a staff
|
|
1204
|
-
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
|
|
1240
|
+
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
|
|
1205
1241
|
const staffId = generateId("staff");
|
|
1206
1242
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
1207
1243
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1222,6 +1258,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1222
1258
|
const pendingTies: TieState = {};
|
|
1223
1259
|
const pendingSlurs: SlurState = {};
|
|
1224
1260
|
const pendingHairpins: HairpinState = {};
|
|
1261
|
+
const pendingOctaves: OttavaState = {};
|
|
1262
|
+
const ottavaExplicitlyClosed: Record<string, boolean> = {};
|
|
1263
|
+
const lastNoteIds: Record<string, string | null> = {};
|
|
1225
1264
|
let endingClef: Clef | undefined = initialClef;
|
|
1226
1265
|
|
|
1227
1266
|
if (voices.length === 0) {
|
|
@@ -1233,7 +1272,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1233
1272
|
const initialTies = tieState[tieKey] || [];
|
|
1234
1273
|
const initialSlur = slurState[tieKey] || null;
|
|
1235
1274
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1236
|
-
const
|
|
1275
|
+
const initialOctave = ottavaState[tieKey] || null;
|
|
1276
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
1237
1277
|
xml += result.xml;
|
|
1238
1278
|
allHairpins.push(...result.hairpins);
|
|
1239
1279
|
allPedals.push(...result.pedals);
|
|
@@ -1262,6 +1302,16 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1262
1302
|
if (result.pendingHairpin) {
|
|
1263
1303
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
1264
1304
|
}
|
|
1305
|
+
// Track pending ottava spans for this layer
|
|
1306
|
+
if (result.pendingOctave) {
|
|
1307
|
+
pendingOctaves[tieKey] = result.pendingOctave;
|
|
1308
|
+
}
|
|
1309
|
+
// Track if ottava was explicitly closed in this layer
|
|
1310
|
+
if (result.ottavaExplicitlyClosed) {
|
|
1311
|
+
ottavaExplicitlyClosed[tieKey] = true;
|
|
1312
|
+
}
|
|
1313
|
+
// Track last note IDs for this layer (for closing ottava spans)
|
|
1314
|
+
lastNoteIds[tieKey] = result.lastNoteId;
|
|
1265
1315
|
// Track ending clef for cross-measure tracking
|
|
1266
1316
|
if (result.endingClef) {
|
|
1267
1317
|
endingClef = result.endingClef;
|
|
@@ -1290,20 +1340,60 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1290
1340
|
pendingTies,
|
|
1291
1341
|
pendingSlurs,
|
|
1292
1342
|
pendingHairpins,
|
|
1343
|
+
pendingOctaves,
|
|
1344
|
+
ottavaExplicitlyClosed,
|
|
1345
|
+
lastNoteIds,
|
|
1293
1346
|
endingClef,
|
|
1294
1347
|
};
|
|
1295
1348
|
};
|
|
1296
1349
|
|
|
1297
1350
|
|
|
1351
|
+
// Tempo text to BPM mapping (approximate values based on musical convention)
|
|
1352
|
+
const TEMPO_TEXT_TO_BPM: Record<string, number> = {
|
|
1353
|
+
// Very slow
|
|
1354
|
+
'grave': 35,
|
|
1355
|
+
'largo': 50,
|
|
1356
|
+
'larghetto': 63,
|
|
1357
|
+
'lento': 52,
|
|
1358
|
+
'adagio': 70,
|
|
1359
|
+
// Slow to moderate
|
|
1360
|
+
'andante': 92,
|
|
1361
|
+
'andantino': 96,
|
|
1362
|
+
'moderato': 114,
|
|
1363
|
+
// Fast
|
|
1364
|
+
'allegretto': 116,
|
|
1365
|
+
'allegro': 138,
|
|
1366
|
+
'vivace': 166,
|
|
1367
|
+
'presto': 184,
|
|
1368
|
+
'prestissimo': 208,
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
// Infer BPM from tempo text
|
|
1372
|
+
const inferBpmFromText = (text: string): number | undefined => {
|
|
1373
|
+
const lowerText = text.toLowerCase();
|
|
1374
|
+
for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
|
|
1375
|
+
if (lowerText.includes(keyword)) {
|
|
1376
|
+
return bpm;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return undefined;
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1298
1382
|
// Generate tempo element
|
|
1299
1383
|
const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1): string => {
|
|
1300
1384
|
let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
|
|
1301
1385
|
|
|
1302
|
-
//
|
|
1303
|
-
|
|
1304
|
-
|
|
1386
|
+
// Determine BPM: use explicit value or infer from text
|
|
1387
|
+
let bpm = tempo.bpm;
|
|
1388
|
+
if (!bpm && tempo.text) {
|
|
1389
|
+
bpm = inferBpmFromText(tempo.text);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Add BPM if available
|
|
1393
|
+
if (bpm) {
|
|
1394
|
+
attrs += ` midi.bpm="${bpm}"`;
|
|
1305
1395
|
if (tempo.beat) {
|
|
1306
|
-
attrs += ` mm="${
|
|
1396
|
+
attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
|
|
1307
1397
|
}
|
|
1308
1398
|
}
|
|
1309
1399
|
|
|
@@ -1339,8 +1429,8 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1339
1429
|
};
|
|
1340
1430
|
|
|
1341
1431
|
// Encode a measure
|
|
1342
|
-
// encodeMeasure accepts mutable tieState, slurState, hairpinState and clefState that persist across measures
|
|
1343
|
-
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
|
|
1432
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1433
|
+
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}): string => {
|
|
1344
1434
|
const measureId = generateId("measure");
|
|
1345
1435
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1346
1436
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1395,11 +1485,11 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1395
1485
|
}
|
|
1396
1486
|
}
|
|
1397
1487
|
|
|
1398
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1488
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1399
1489
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1400
1490
|
const voices = voicesByStaff[si] || [];
|
|
1401
1491
|
const initialClef = clefState[si];
|
|
1402
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1492
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1403
1493
|
staffContent += result.xml;
|
|
1404
1494
|
allHairpins.push(...result.hairpins);
|
|
1405
1495
|
allPedals.push(...result.pedals);
|
|
@@ -1422,6 +1512,55 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1422
1512
|
Object.assign(slurState, result.pendingSlurs);
|
|
1423
1513
|
// Update hairpin state with pending hairpins from this staff
|
|
1424
1514
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1515
|
+
// Update ottava state with pending octaves from this staff
|
|
1516
|
+
// Also handle closing spans when ottava ends
|
|
1517
|
+
const currentStaffPrefix = `${si}-`;
|
|
1518
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1519
|
+
if (pending) {
|
|
1520
|
+
// Check if this is a continuation or a new span
|
|
1521
|
+
const prevPending = ottavaState[key];
|
|
1522
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1523
|
+
// Same ottava value continues - keep the original startId
|
|
1524
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1525
|
+
} else {
|
|
1526
|
+
// Different ottava value - close the old span first if exists
|
|
1527
|
+
if (prevPending) {
|
|
1528
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1529
|
+
if (lastNoteId) {
|
|
1530
|
+
allOctaves.push({
|
|
1531
|
+
dis: prevPending.dis,
|
|
1532
|
+
disPlace: prevPending.disPlace,
|
|
1533
|
+
startId: prevPending.startId,
|
|
1534
|
+
endId: lastNoteId,
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// Start new span
|
|
1539
|
+
ottavaState[key] = pending;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1544
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1545
|
+
// Only process keys for the current staff
|
|
1546
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1547
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1548
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1549
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1550
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1551
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1552
|
+
if (lastNoteId) {
|
|
1553
|
+
allOctaves.push({
|
|
1554
|
+
dis: pending.dis,
|
|
1555
|
+
disPlace: pending.disPlace,
|
|
1556
|
+
startId: pending.startId,
|
|
1557
|
+
endId: lastNoteId,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
delete ottavaState[key];
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1425
1564
|
// Update clef state with ending clef from this staff
|
|
1426
1565
|
if (result.endingClef) {
|
|
1427
1566
|
clefState[si] = result.endingClef;
|
|
@@ -1718,6 +1857,9 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1718
1857
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1719
1858
|
const hairpinState: HairpinState = {};
|
|
1720
1859
|
|
|
1860
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1861
|
+
const ottavaState: OttavaState = {};
|
|
1862
|
+
|
|
1721
1863
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1722
1864
|
const clefState: ClefState = {};
|
|
1723
1865
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1776,7 +1918,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1776
1918
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1777
1919
|
}
|
|
1778
1920
|
}
|
|
1779
|
-
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1921
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1780
1922
|
});
|
|
1781
1923
|
|
|
1782
1924
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
@@ -922,12 +922,12 @@ const directionToContextChange = (
|
|
|
922
922
|
if (type === 'stop') {
|
|
923
923
|
ottava = 0;
|
|
924
924
|
ottavaTracker.current = 0;
|
|
925
|
-
} else if (type === '
|
|
926
|
-
// 8va = 1, 15ma = 2
|
|
925
|
+
} else if (type === 'down') {
|
|
926
|
+
// 8va = 1, 15ma = 2 (type="down" means written notes sound higher)
|
|
927
927
|
ottava = size === 15 ? 2 : 1;
|
|
928
928
|
ottavaTracker.current = ottava;
|
|
929
|
-
} else if (type === '
|
|
930
|
-
// 8vb = -1, 15mb = -2
|
|
929
|
+
} else if (type === 'up') {
|
|
930
|
+
// 8vb = -1, 15mb = -2 (type="up" means written notes sound lower)
|
|
931
931
|
ottava = size === 15 ? -2 : -1;
|
|
932
932
|
ottavaTracker.current = ottava;
|
|
933
933
|
} else {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
18
|
TremoloEvent,
|
|
19
|
+
BarlineEvent,
|
|
19
20
|
Pitch,
|
|
20
21
|
Duration,
|
|
21
22
|
Mark,
|
|
@@ -524,6 +525,16 @@ const serializeTremoloEvent = (
|
|
|
524
525
|
};
|
|
525
526
|
|
|
526
527
|
|
|
528
|
+
// Serialize a barline event
|
|
529
|
+
const serializeBarlineEvent = (event: BarlineEvent): string => {
|
|
530
|
+
// Only output non-default barlines
|
|
531
|
+
if (event.style && event.style !== '|') {
|
|
532
|
+
return '\\bar "' + event.style + '"';
|
|
533
|
+
}
|
|
534
|
+
return '';
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
|
|
527
538
|
// Serialize a single event with pitch environment tracking
|
|
528
539
|
const serializeEvent = (
|
|
529
540
|
event: Event,
|
|
@@ -541,27 +552,46 @@ const serializeEvent = (
|
|
|
541
552
|
return serializeTupletEvent(event as TupletEvent, env);
|
|
542
553
|
case 'tremolo':
|
|
543
554
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
555
|
+
case 'barline':
|
|
556
|
+
return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
|
|
544
557
|
default:
|
|
545
558
|
return { str: '', newEnv: env };
|
|
546
559
|
}
|
|
547
560
|
};
|
|
548
561
|
|
|
549
562
|
|
|
550
|
-
// Key/time signature info to inject into
|
|
563
|
+
// Key/time/clef signature info to inject into voices
|
|
551
564
|
interface MeasureContext {
|
|
552
565
|
key?: KeySignature;
|
|
553
566
|
time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
|
|
567
|
+
clef?: Clef;
|
|
554
568
|
}
|
|
555
569
|
|
|
570
|
+
// Find first clef in voice events
|
|
571
|
+
const findVoiceClef = (voice: Voice): Clef | undefined => {
|
|
572
|
+
for (const event of voice.events) {
|
|
573
|
+
if (event.type === 'context') {
|
|
574
|
+
const ctx = event as ContextChange;
|
|
575
|
+
if (ctx.clef) {
|
|
576
|
+
return ctx.clef;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
};
|
|
582
|
+
|
|
556
583
|
// Serialize a voice with pitch environment tracking
|
|
557
584
|
// Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
|
|
558
585
|
// If isGrandStaff is true, always output \staff command for clarity
|
|
559
|
-
//
|
|
586
|
+
// measureContext provides key/time for first voice
|
|
587
|
+
// staffClef is the clef for this voice's staff (tracked across measures)
|
|
560
588
|
const serializeVoice = (
|
|
561
589
|
voice: Voice,
|
|
562
590
|
currentStaff: number,
|
|
563
591
|
isGrandStaff: boolean = false,
|
|
564
|
-
measureContext?: MeasureContext
|
|
592
|
+
measureContext?: MeasureContext,
|
|
593
|
+
isFirstVoice: boolean = false,
|
|
594
|
+
staffClef?: Clef
|
|
565
595
|
): { str: string; newStaff: number } => {
|
|
566
596
|
const parts: string[] = [];
|
|
567
597
|
let prevDuration: Duration | undefined;
|
|
@@ -574,8 +604,8 @@ const serializeVoice = (
|
|
|
574
604
|
parts.push('\\staff "' + voice.staff + '"');
|
|
575
605
|
}
|
|
576
606
|
|
|
577
|
-
// Output key/time signatures after \staff (for first voice
|
|
578
|
-
if (measureContext) {
|
|
607
|
+
// Output key/time signatures after \staff (for first voice only)
|
|
608
|
+
if (measureContext && isFirstVoice) {
|
|
579
609
|
if (measureContext.key) {
|
|
580
610
|
let keyStr = String(measureContext.key.pitch);
|
|
581
611
|
if (measureContext.key.accidental) {
|
|
@@ -595,7 +625,25 @@ const serializeVoice = (
|
|
|
595
625
|
}
|
|
596
626
|
}
|
|
597
627
|
|
|
628
|
+
// Output clef for every voice (use staff clef tracked across measures, or find from voice events)
|
|
629
|
+
const voiceClef = staffClef || findVoiceClef(voice);
|
|
630
|
+
if (voiceClef) {
|
|
631
|
+
parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Track if we've already output the clef to avoid duplication
|
|
635
|
+
let clefOutputted = !!voiceClef;
|
|
636
|
+
|
|
598
637
|
for (const event of voice.events) {
|
|
638
|
+
// Skip clef context events if we've already output the clef at the beginning
|
|
639
|
+
if (clefOutputted && event.type === 'context') {
|
|
640
|
+
const ctx = event as ContextChange;
|
|
641
|
+
if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
|
|
642
|
+
// This is a clef-only context event, skip it
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
599
647
|
const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
|
|
600
648
|
pitchEnv = newEnv;
|
|
601
649
|
|
|
@@ -616,12 +664,14 @@ const serializeVoice = (
|
|
|
616
664
|
|
|
617
665
|
|
|
618
666
|
// Serialize a part, tracking staff state across voices
|
|
619
|
-
//
|
|
667
|
+
// measureContext is passed to all voices (for clef), but key/time only to first voice
|
|
620
668
|
const serializePart = (
|
|
621
669
|
part: Part,
|
|
622
670
|
currentStaff: number,
|
|
623
671
|
isGrandStaff: boolean = false,
|
|
624
|
-
measureContext?: MeasureContext
|
|
672
|
+
measureContext?: MeasureContext,
|
|
673
|
+
isFirstPart: boolean = false,
|
|
674
|
+
clefsByStaff?: Record<number, Clef>
|
|
625
675
|
): { str: string; newStaff: number } => {
|
|
626
676
|
if (part.voices.length === 0) {
|
|
627
677
|
return { str: '', newStaff: currentStaff };
|
|
@@ -632,9 +682,11 @@ const serializePart = (
|
|
|
632
682
|
|
|
633
683
|
for (let i = 0; i < part.voices.length; i++) {
|
|
634
684
|
const voice = part.voices[i];
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
const
|
|
685
|
+
// Pass measureContext to all voices, isFirstVoice for key/time
|
|
686
|
+
// Pass staff clef from clefsByStaff map
|
|
687
|
+
const isFirstVoice = isFirstPart && i === 0;
|
|
688
|
+
const staffClef = clefsByStaff?.[voice.staff];
|
|
689
|
+
const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
|
|
638
690
|
voiceStrs.push(str);
|
|
639
691
|
staff = newStaff;
|
|
640
692
|
}
|
|
@@ -645,19 +697,33 @@ const serializePart = (
|
|
|
645
697
|
|
|
646
698
|
|
|
647
699
|
// Serialize a measure, tracking staff state across parts
|
|
648
|
-
|
|
700
|
+
// Always output key/time at start of each measure
|
|
701
|
+
const serializeMeasure = (
|
|
702
|
+
measure: Measure,
|
|
703
|
+
_isFirst: boolean,
|
|
704
|
+
currentStaff: number,
|
|
705
|
+
isGrandStaff: boolean = false,
|
|
706
|
+
currentKey?: KeySignature,
|
|
707
|
+
currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
|
|
708
|
+
staffClefs?: Record<number, Clef>
|
|
709
|
+
): { str: string; newStaff: number } => {
|
|
649
710
|
const parts: string[] = [];
|
|
650
711
|
|
|
651
|
-
// Build measure context for
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
712
|
+
// Build measure context for all voices (key/time)
|
|
713
|
+
// Key and time are written to first voice, clef to all voices based on staff
|
|
714
|
+
// Use passed currentKey/currentTime which tracks across all measures
|
|
715
|
+
const measureContext: MeasureContext = {
|
|
716
|
+
key: currentKey,
|
|
717
|
+
time: currentTime,
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// Pass staffClefs to parts for per-voice clef lookup
|
|
721
|
+
const clefsByStaff = staffClefs || {};
|
|
656
722
|
|
|
657
723
|
// Parts
|
|
658
724
|
let staff = currentStaff;
|
|
659
725
|
if (measure.parts.length === 1) {
|
|
660
|
-
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
|
|
726
|
+
const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
|
|
661
727
|
if (partStr) {
|
|
662
728
|
parts.push(partStr);
|
|
663
729
|
}
|
|
@@ -667,9 +733,8 @@ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: numb
|
|
|
667
733
|
const partStrs: string[] = [];
|
|
668
734
|
for (let i = 0; i < measure.parts.length; i++) {
|
|
669
735
|
const part = measure.parts[i];
|
|
670
|
-
//
|
|
671
|
-
const
|
|
672
|
-
const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
|
|
736
|
+
// Pass measureContext to all parts, isFirstPart to first part only
|
|
737
|
+
const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
|
|
673
738
|
if (str) {
|
|
674
739
|
partStrs.push(str);
|
|
675
740
|
}
|
|
@@ -735,10 +800,35 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
|
|
|
735
800
|
|
|
736
801
|
// Measures with bar lines, measure numbers, and double newlines
|
|
737
802
|
// Track staff state across measures (parser remembers staff across bar lines)
|
|
803
|
+
// Track key/time/clef across measures to output in every measure
|
|
738
804
|
const measureStrs: string[] = [];
|
|
739
805
|
let currentStaff = 1; // Parser starts at staff 1
|
|
806
|
+
let currentKey: KeySignature | undefined;
|
|
807
|
+
let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
|
|
808
|
+
const staffClefs: Record<number, Clef> = {}; // Track clef per staff
|
|
809
|
+
|
|
740
810
|
for (let i = 0; i < doc.measures.length; i++) {
|
|
741
|
-
const
|
|
811
|
+
const measure = doc.measures[i];
|
|
812
|
+
// Update current key/time if measure has them
|
|
813
|
+
if (measure.key) {
|
|
814
|
+
currentKey = measure.key;
|
|
815
|
+
}
|
|
816
|
+
if (measure.timeSig) {
|
|
817
|
+
currentTime = measure.timeSig;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Collect clefs from this measure's voices
|
|
821
|
+
for (const part of measure.parts) {
|
|
822
|
+
for (const voice of part.voices) {
|
|
823
|
+
for (const event of voice.events) {
|
|
824
|
+
if (event.type === 'context' && (event as ContextChange).clef) {
|
|
825
|
+
staffClefs[voice.staff] = (event as ContextChange).clef!;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
|
|
742
832
|
// Always include measure, even if empty (use space rest for empty measures)
|
|
743
833
|
measureStrs.push(measureStr || 's1');
|
|
744
834
|
currentStaff = newStaff;
|