@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.
@@ -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 -> ({ type: 'pitchReset' })
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 %{ currentTimeSig = fraction(Number($2), Number($4)); $$ = currentTimeSig; %}
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 -> Number($3)
451
- | CMD_OTTAVA '#' '-' NUMBER -> -Number($4)
452
- | CMD_OTTAVA -> 0
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 = 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
- currentOctave = { dis, disPlace, startId: result.elementId };
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
- currentOttavaShift = 0; // Reset the shift
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
- // Start new ottava span - will be applied to next note
1113
- pendingOttava = ctx.ottava;
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
- // Close any unclosed ottava span at end of layer
1166
- if (currentOctave && lastNoteId) {
1167
- octaves.push({
1168
- dis: currentOctave.dis,
1169
- disPlace: currentOctave.disPlace,
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 result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin);
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
- let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}" meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
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
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
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 === 'up') {
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 === 'down') {
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 {