@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/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
- currentOctave = { dis, disPlace, startId: result.elementId };
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
- currentOttavaShift = 0; // Reset the shift
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
- // Start new ottava span - will be applied to next note
843
- pendingOttava = ctx.ottava;
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
- // Close any unclosed ottava span at end of layer
893
- if (currentOctave && lastNoteId) {
894
- octaves.push({
895
- dis: currentOctave.dis,
896
- disPlace: currentOctave.disPlace,
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 result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin);
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
- let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}" meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
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
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
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`;
@@ -727,13 +727,13 @@ const directionToContextChange = (direction, ottavaTracker) => {
727
727
  ottava = 0;
728
728
  ottavaTracker.current = 0;
729
729
  }
730
- else if (type === 'up') {
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 === 'down') {
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
- // If measureContext is provided, output key/time after \staff (for first voice only)
430
- const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext) => {
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 of first measure)
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
- parts.push('\\time ' + measureContext.time.numerator + '/' + measureContext.time.denominator);
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
- // If measureContext is provided, pass it to the first voice only
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
- // Only pass measureContext to first voice
481
- const ctx = i === 0 ? measureContext : undefined;
482
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, ctx);
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
- const serializeMeasure = (measure, isFirst, currentStaff, isGrandStaff = false) => {
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 first voice (key/time signatures)
493
- const measureContext = isFirst ? {
494
- key: measure.key,
495
- time: measure.timeSig,
496
- } : undefined;
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
- // Only pass measureContext to first part
512
- const ctx = i === 0 ? measureContext : undefined;
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 { str: measureStr, newStaff } = serializeMeasure(doc.measures[i], i === 0, currentStaff, isGrandStaff);
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?: Fraction;
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.34",
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",