@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
|
@@ -128,6 +128,8 @@
|
|
|
128
128
|
let currentKey = null;
|
|
129
129
|
let currentTimeSig = null;
|
|
130
130
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
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
|
|
131
133
|
|
|
132
134
|
// Reset parser state - call before each parse
|
|
133
135
|
const resetParserState = () => {
|
|
@@ -135,6 +137,8 @@
|
|
|
135
137
|
currentKey = null;
|
|
136
138
|
currentTimeSig = null;
|
|
137
139
|
currentDuration = { division: 4, dots: 0 };
|
|
140
|
+
numericTimeSignature = false;
|
|
141
|
+
currentOttava = 0;
|
|
138
142
|
};
|
|
139
143
|
|
|
140
144
|
// Export reset function
|
|
@@ -168,6 +172,8 @@
|
|
|
168
172
|
"\\clef" return 'CMD_CLEF'
|
|
169
173
|
"\\key" return 'CMD_KEY'
|
|
170
174
|
"\\time" return 'CMD_TIME'
|
|
175
|
+
"\\numericTimeSignature" return 'CMD_NUMERIC_TIME_SIG'
|
|
176
|
+
"\\defaultTimeSignature" return 'CMD_DEFAULT_TIME_SIG'
|
|
171
177
|
"\\tempo" return 'CMD_TEMPO'
|
|
172
178
|
"\\staff" return 'CMD_STAFF'
|
|
173
179
|
"\\grace" return 'CMD_GRACE'
|
|
@@ -319,7 +325,7 @@ parts
|
|
|
319
325
|
;
|
|
320
326
|
|
|
321
327
|
part_start
|
|
322
|
-
: /* empty */ %{ currentStaff = 1; %}
|
|
328
|
+
: /* empty */ %{ currentStaff = 1; currentOttava = 0; %}
|
|
323
329
|
;
|
|
324
330
|
|
|
325
331
|
part_voices
|
|
@@ -329,7 +335,7 @@ part_voices
|
|
|
329
335
|
|
|
330
336
|
voice_events
|
|
331
337
|
: /* empty */ { $$ = []; }
|
|
332
|
-
| voice_events event { $$ = $1.concat(Array.isArray($2) ? $2 : [$2]); }
|
|
338
|
+
| voice_events event { $$ = $2 === null ? $1 : $1.concat(Array.isArray($2) ? $2 : [$2]); }
|
|
333
339
|
;
|
|
334
340
|
|
|
335
341
|
event
|
|
@@ -358,7 +364,16 @@ markup_event
|
|
|
358
364
|
;
|
|
359
365
|
|
|
360
366
|
pitch_reset_event
|
|
361
|
-
: 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
|
+
%}
|
|
362
377
|
;
|
|
363
378
|
|
|
364
379
|
note_event
|
|
@@ -413,6 +428,8 @@ context_event
|
|
|
413
428
|
| staff_cmd -> contextChange({ staff: $1 })
|
|
414
429
|
| ottava_cmd -> contextChange({ ottava: $1 })
|
|
415
430
|
| stem_cmd -> contextChange({ stemDirection: $1 })
|
|
431
|
+
| numeric_time_sig_cmd -> null
|
|
432
|
+
| default_time_sig_cmd -> null
|
|
416
433
|
;
|
|
417
434
|
|
|
418
435
|
clef_cmd
|
|
@@ -433,7 +450,29 @@ mode
|
|
|
433
450
|
;
|
|
434
451
|
|
|
435
452
|
time_cmd
|
|
436
|
-
: CMD_TIME NUMBER '/' NUMBER %{
|
|
453
|
+
: CMD_TIME NUMBER '/' NUMBER %{
|
|
454
|
+
const num = Number($2);
|
|
455
|
+
const den = Number($4);
|
|
456
|
+
const timeSig = fraction(num, den);
|
|
457
|
+
// Add symbol for 4/4 (common) and 2/2 (cut) unless numericTimeSignature is set
|
|
458
|
+
if (!numericTimeSignature) {
|
|
459
|
+
if (num === 4 && den === 4) {
|
|
460
|
+
timeSig.symbol = 'common';
|
|
461
|
+
} else if (num === 2 && den === 2) {
|
|
462
|
+
timeSig.symbol = 'cut';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
currentTimeSig = timeSig;
|
|
466
|
+
$$ = currentTimeSig;
|
|
467
|
+
%}
|
|
468
|
+
;
|
|
469
|
+
|
|
470
|
+
numeric_time_sig_cmd
|
|
471
|
+
: CMD_NUMERIC_TIME_SIG %{ numericTimeSignature = true; $$ = null; %}
|
|
472
|
+
;
|
|
473
|
+
|
|
474
|
+
default_time_sig_cmd
|
|
475
|
+
: CMD_DEFAULT_TIME_SIG %{ numericTimeSignature = false; $$ = null; %}
|
|
437
476
|
;
|
|
438
477
|
|
|
439
478
|
tempo_cmd
|
|
@@ -447,9 +486,9 @@ staff_cmd
|
|
|
447
486
|
;
|
|
448
487
|
|
|
449
488
|
ottava_cmd
|
|
450
|
-
: CMD_OTTAVA '#' NUMBER
|
|
451
|
-
| CMD_OTTAVA '#' '-' NUMBER
|
|
452
|
-
| CMD_OTTAVA
|
|
489
|
+
: CMD_OTTAVA '#' NUMBER %{ currentOttava = Number($3); $$ = currentOttava; %}
|
|
490
|
+
| CMD_OTTAVA '#' '-' NUMBER %{ currentOttava = -Number($4); $$ = currentOttava; %}
|
|
491
|
+
| CMD_OTTAVA %{ currentOttava = 0; $$ = 0; %}
|
|
453
492
|
;
|
|
454
493
|
|
|
455
494
|
stem_cmd
|
|
@@ -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,6 +1340,9 @@ 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
|
};
|
|
@@ -1339,8 +1392,8 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1339
1392
|
};
|
|
1340
1393
|
|
|
1341
1394
|
// 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 => {
|
|
1395
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1396
|
+
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
1397
|
const measureId = generateId("measure");
|
|
1345
1398
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1346
1399
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1395,11 +1448,11 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1395
1448
|
}
|
|
1396
1449
|
}
|
|
1397
1450
|
|
|
1398
|
-
// Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
|
|
1451
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1399
1452
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1400
1453
|
const voices = voicesByStaff[si] || [];
|
|
1401
1454
|
const initialClef = clefState[si];
|
|
1402
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
|
|
1455
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1403
1456
|
staffContent += result.xml;
|
|
1404
1457
|
allHairpins.push(...result.hairpins);
|
|
1405
1458
|
allPedals.push(...result.pedals);
|
|
@@ -1422,6 +1475,55 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1422
1475
|
Object.assign(slurState, result.pendingSlurs);
|
|
1423
1476
|
// Update hairpin state with pending hairpins from this staff
|
|
1424
1477
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1478
|
+
// Update ottava state with pending octaves from this staff
|
|
1479
|
+
// Also handle closing spans when ottava ends
|
|
1480
|
+
const currentStaffPrefix = `${si}-`;
|
|
1481
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1482
|
+
if (pending) {
|
|
1483
|
+
// Check if this is a continuation or a new span
|
|
1484
|
+
const prevPending = ottavaState[key];
|
|
1485
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1486
|
+
// Same ottava value continues - keep the original startId
|
|
1487
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1488
|
+
} else {
|
|
1489
|
+
// Different ottava value - close the old span first if exists
|
|
1490
|
+
if (prevPending) {
|
|
1491
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1492
|
+
if (lastNoteId) {
|
|
1493
|
+
allOctaves.push({
|
|
1494
|
+
dis: prevPending.dis,
|
|
1495
|
+
disPlace: prevPending.disPlace,
|
|
1496
|
+
startId: prevPending.startId,
|
|
1497
|
+
endId: lastNoteId,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
// Start new span
|
|
1502
|
+
ottavaState[key] = pending;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1507
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1508
|
+
// Only process keys for the current staff
|
|
1509
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1510
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1511
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1512
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1513
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1514
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1515
|
+
if (lastNoteId) {
|
|
1516
|
+
allOctaves.push({
|
|
1517
|
+
dis: pending.dis,
|
|
1518
|
+
disPlace: pending.disPlace,
|
|
1519
|
+
startId: pending.startId,
|
|
1520
|
+
endId: lastNoteId,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
delete ottavaState[key];
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1425
1527
|
// Update clef state with ending clef from this staff
|
|
1426
1528
|
if (result.endingClef) {
|
|
1427
1529
|
clefState[si] = result.endingClef;
|
|
@@ -1586,11 +1688,14 @@ const encodeScoreDef = (
|
|
|
1586
1688
|
timeNum: number,
|
|
1587
1689
|
timeDen: number,
|
|
1588
1690
|
partInfos: PartInfo[],
|
|
1589
|
-
indent: string
|
|
1691
|
+
indent: string,
|
|
1692
|
+
meterSymbol?: 'common' | 'cut'
|
|
1590
1693
|
): string => {
|
|
1591
1694
|
const scoreDefId = generateId("scoredef");
|
|
1592
1695
|
|
|
1593
|
-
|
|
1696
|
+
// Build meter attributes
|
|
1697
|
+
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1698
|
+
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1594
1699
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1595
1700
|
|
|
1596
1701
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1640,6 +1745,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1640
1745
|
let currentKey = 0;
|
|
1641
1746
|
let currentTimeNum = 4;
|
|
1642
1747
|
let currentTimeDen = 4;
|
|
1748
|
+
let currentMeterSymbol: 'common' | 'cut' | undefined = undefined;
|
|
1643
1749
|
|
|
1644
1750
|
const firstMeasure = doc.measures[0];
|
|
1645
1751
|
if (firstMeasure.key) {
|
|
@@ -1648,6 +1754,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1648
1754
|
if (firstMeasure.timeSig) {
|
|
1649
1755
|
currentTimeNum = firstMeasure.timeSig.numerator;
|
|
1650
1756
|
currentTimeDen = firstMeasure.timeSig.denominator;
|
|
1757
|
+
currentMeterSymbol = firstMeasure.timeSig.symbol;
|
|
1651
1758
|
}
|
|
1652
1759
|
|
|
1653
1760
|
const keySig = KEY_SIGS[currentKey] || "0";
|
|
@@ -1701,7 +1808,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1701
1808
|
mei += `${indent}${indent}<body>\n`;
|
|
1702
1809
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1703
1810
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1704
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}
|
|
1811
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
1705
1812
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1706
1813
|
|
|
1707
1814
|
// Track tie state across measures for cross-measure ties
|
|
@@ -1713,6 +1820,9 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1713
1820
|
// Track hairpin state across measures for cross-measure hairpins
|
|
1714
1821
|
const hairpinState: HairpinState = {};
|
|
1715
1822
|
|
|
1823
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1824
|
+
const ottavaState: OttavaState = {};
|
|
1825
|
+
|
|
1716
1826
|
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1717
1827
|
const clefState: ClefState = {};
|
|
1718
1828
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1757,7 +1867,21 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1757
1867
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1758
1868
|
}
|
|
1759
1869
|
}
|
|
1760
|
-
|
|
1870
|
+
// Check for time signature change and output scoreDef if needed
|
|
1871
|
+
if (measure.timeSig && mi > 0) {
|
|
1872
|
+
const newTimeNum = measure.timeSig.numerator;
|
|
1873
|
+
const newTimeDen = measure.timeSig.denominator;
|
|
1874
|
+
const newMeterSymbol = measure.timeSig.symbol;
|
|
1875
|
+
if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
|
|
1876
|
+
currentTimeNum = newTimeNum;
|
|
1877
|
+
currentTimeDen = newTimeDen;
|
|
1878
|
+
currentMeterSymbol = newMeterSymbol;
|
|
1879
|
+
// Output a scoreDef with the new time signature
|
|
1880
|
+
const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
|
|
1881
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1761
1885
|
});
|
|
1762
1886
|
|
|
1763
1887
|
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 {
|