@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/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,17 +1024,54 @@ 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
  };
1033
+ // Tempo text to BPM mapping (approximate values based on musical convention)
1034
+ const TEMPO_TEXT_TO_BPM = {
1035
+ // Very slow
1036
+ 'grave': 35,
1037
+ 'largo': 50,
1038
+ 'larghetto': 63,
1039
+ 'lento': 52,
1040
+ 'adagio': 70,
1041
+ // Slow to moderate
1042
+ 'andante': 92,
1043
+ 'andantino': 96,
1044
+ 'moderato': 114,
1045
+ // Fast
1046
+ 'allegretto': 116,
1047
+ 'allegro': 138,
1048
+ 'vivace': 166,
1049
+ 'presto': 184,
1050
+ 'prestissimo': 208,
1051
+ };
1052
+ // Infer BPM from tempo text
1053
+ const inferBpmFromText = (text) => {
1054
+ const lowerText = text.toLowerCase();
1055
+ for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
1056
+ if (lowerText.includes(keyword)) {
1057
+ return bpm;
1058
+ }
1059
+ }
1060
+ return undefined;
1061
+ };
996
1062
  // Generate tempo element
997
1063
  const generateTempoElement = (tempo, indent, staff = 1) => {
998
1064
  let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
999
- // Add BPM if specified
1000
- if (tempo.bpm) {
1001
- attrs += ` midi.bpm="${tempo.bpm}"`;
1065
+ // Determine BPM: use explicit value or infer from text
1066
+ let bpm = tempo.bpm;
1067
+ if (!bpm && tempo.text) {
1068
+ bpm = inferBpmFromText(tempo.text);
1069
+ }
1070
+ // Add BPM if available
1071
+ if (bpm) {
1072
+ attrs += ` midi.bpm="${bpm}"`;
1002
1073
  if (tempo.beat) {
1003
- attrs += ` mm="${tempo.bpm}" mm.unit="${tempo.beat.division}"`;
1074
+ attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
1004
1075
  }
1005
1076
  }
1006
1077
  // Generate content
@@ -1030,8 +1101,8 @@ const BARLINE_TO_MEI = {
1030
1101
  ':..:': 'rptboth',
1031
1102
  };
1032
1103
  // 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 = {}) => {
1104
+ // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1105
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
1035
1106
  const measureId = generateId("measure");
1036
1107
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1037
1108
  const allHairpins = [];
@@ -1083,11 +1154,11 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1083
1154
  voicesByStaff[globalStaff].push(voice);
1084
1155
  }
1085
1156
  }
1086
- // Encode each staff, passing and updating tie state, slur state, hairpin state and clef state
1157
+ // Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
1087
1158
  for (let si = 1; si <= totalStaves; si++) {
1088
1159
  const voices = voicesByStaff[si] || [];
1089
1160
  const initialClef = clefState[si];
1090
- const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, keyFifths, initialClef);
1161
+ const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
1091
1162
  staffContent += result.xml;
1092
1163
  allHairpins.push(...result.hairpins);
1093
1164
  allPedals.push(...result.pedals);
@@ -1110,6 +1181,56 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1110
1181
  Object.assign(slurState, result.pendingSlurs);
1111
1182
  // Update hairpin state with pending hairpins from this staff
1112
1183
  Object.assign(hairpinState, result.pendingHairpins);
1184
+ // Update ottava state with pending octaves from this staff
1185
+ // Also handle closing spans when ottava ends
1186
+ const currentStaffPrefix = `${si}-`;
1187
+ for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1188
+ if (pending) {
1189
+ // Check if this is a continuation or a new span
1190
+ const prevPending = ottavaState[key];
1191
+ if (prevPending && prevPending.shift === pending.shift) {
1192
+ // Same ottava value continues - keep the original startId
1193
+ ottavaState[key] = { ...pending, startId: prevPending.startId };
1194
+ }
1195
+ else {
1196
+ // Different ottava value - close the old span first if exists
1197
+ if (prevPending) {
1198
+ const lastNoteId = result.lastNoteIds[key];
1199
+ if (lastNoteId) {
1200
+ allOctaves.push({
1201
+ dis: prevPending.dis,
1202
+ disPlace: prevPending.disPlace,
1203
+ startId: prevPending.startId,
1204
+ endId: lastNoteId,
1205
+ });
1206
+ }
1207
+ }
1208
+ // Start new span
1209
+ ottavaState[key] = pending;
1210
+ }
1211
+ }
1212
+ }
1213
+ // For layers in this staff that had pending octaves but didn't in this measure, close the spans
1214
+ for (const [key, pending] of Object.entries(ottavaState)) {
1215
+ // Only process keys for the current staff
1216
+ if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
1217
+ // Check if the span was already explicitly closed in encodeLayer
1218
+ // If so, don't generate another span (it was already pushed to octaves in encodeLayer)
1219
+ if (!result.ottavaExplicitlyClosed[key]) {
1220
+ // Ottava ended without explicit close - generate the closing span
1221
+ const lastNoteId = result.lastNoteIds[key];
1222
+ if (lastNoteId) {
1223
+ allOctaves.push({
1224
+ dis: pending.dis,
1225
+ disPlace: pending.disPlace,
1226
+ startId: pending.startId,
1227
+ endId: lastNoteId,
1228
+ });
1229
+ }
1230
+ }
1231
+ delete ottavaState[key];
1232
+ }
1233
+ }
1113
1234
  // Update clef state with ending clef from this staff
1114
1235
  if (result.endingClef) {
1115
1236
  clefState[si] = result.endingClef;
@@ -1344,6 +1465,8 @@ const encode = (doc, options = {}) => {
1344
1465
  const slurState = {};
1345
1466
  // Track hairpin state across measures for cross-measure hairpins
1346
1467
  const hairpinState = {};
1468
+ // Track ottava state across measures for cross-measure ottava spans
1469
+ const ottavaState = {};
1347
1470
  // Initialize clef state from partInfos (convert local staff to global staff)
1348
1471
  const clefState = {};
1349
1472
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1399,7 +1522,7 @@ const encode = (doc, options = {}) => {
1399
1522
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1400
1523
  }
1401
1524
  }
1402
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1525
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1403
1526
  });
1404
1527
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1405
1528
  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) {
@@ -457,7 +480,22 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
457
480
  parts.push('\\time ' + numerator + '/' + denominator);
458
481
  }
459
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;
460
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
+ }
461
499
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
462
500
  pitchEnv = newEnv;
463
501
  if (eventStr) {
@@ -474,8 +512,8 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
474
512
  return { str: parts.join(' '), newStaff: voice.staff };
475
513
  };
476
514
  // Serialize a part, tracking staff state across voices
477
- // If measureContext is provided, pass it to the first voice only
478
- 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) => {
479
517
  if (part.voices.length === 0) {
480
518
  return { str: '', newStaff: currentStaff };
481
519
  }
@@ -483,9 +521,11 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
483
521
  let staff = currentStaff;
484
522
  for (let i = 0; i < part.voices.length; i++) {
485
523
  const voice = part.voices[i];
486
- // Only pass measureContext to first voice
487
- const ctx = i === 0 ? measureContext : undefined;
488
- 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);
489
529
  voiceStrs.push(str);
490
530
  staff = newStaff;
491
531
  }
@@ -493,17 +533,22 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext)
493
533
  return { str: voiceStrs.join(' \\\\\n'), newStaff: staff };
494
534
  };
495
535
  // Serialize a measure, tracking staff state across parts
496
- 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) => {
497
538
  const parts = [];
498
- // Build measure context for first voice (key/time signatures)
499
- const measureContext = isFirst ? {
500
- key: measure.key,
501
- time: measure.timeSig,
502
- } : 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 || {};
503
548
  // Parts
504
549
  let staff = currentStaff;
505
550
  if (measure.parts.length === 1) {
506
- 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);
507
552
  if (partStr) {
508
553
  parts.push(partStr);
509
554
  }
@@ -514,9 +559,8 @@ const serializeMeasure = (measure, isFirst, currentStaff, isGrandStaff = false)
514
559
  const partStrs = [];
515
560
  for (let i = 0; i < measure.parts.length; i++) {
516
561
  const part = measure.parts[i];
517
- // Only pass measureContext to first part
518
- const ctx = i === 0 ? measureContext : undefined;
519
- 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);
520
564
  if (str) {
521
565
  partStrs.push(str);
522
566
  }
@@ -567,10 +611,32 @@ export const serializeLilyletDoc = (doc) => {
567
611
  const isGrandStaff = doc.measures.some(m => m.parts.some(p => p.voices.some(v => v.staff > 1)));
568
612
  // Measures with bar lines, measure numbers, and double newlines
569
613
  // Track staff state across measures (parser remembers staff across bar lines)
614
+ // Track key/time/clef across measures to output in every measure
570
615
  const measureStrs = [];
571
616
  let currentStaff = 1; // Parser starts at staff 1
617
+ let currentKey;
618
+ let currentTime;
619
+ const staffClefs = {}; // Track clef per staff
572
620
  for (let i = 0; i < doc.measures.length; i++) {
573
- 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);
574
640
  // Always include measure, even if empty (use space rest for empty measures)
575
641
  measureStrs.push(measureStr || 's1');
576
642
  currentStaff = newStaff;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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",
@@ -150,7 +150,7 @@ case 25:
150
150
  this.$ = $$[$0-3].concat([part($$[$0])]);
151
151
  break;
152
152
  case 26:
153
- currentStaff = 1;
153
+ currentStaff = 1; currentOttava = 0;
154
154
  break;
155
155
  case 27:
156
156
  this.$ = [voice(currentStaff, $$[$0])];
@@ -174,7 +174,16 @@ case 43:
174
174
  this.$ = markupEvent($$[$0].slice(1, -1));
175
175
  break;
176
176
  case 44:
177
- this.$ = ({ type: 'pitchReset' });
177
+
178
+ // On newline, reset ottava to 0 if it's non-zero (like pitch base resets)
179
+ if (currentOttava !== 0) {
180
+ const ottavaReset = contextChange({ ottava: 0 });
181
+ currentOttava = 0;
182
+ this.$ = [ottavaReset, { type: 'pitchReset' }];
183
+ } else {
184
+ this.$ = { type: 'pitchReset' };
185
+ }
186
+
178
187
  break;
179
188
  case 45: case 46:
180
189
  currentDuration = $$[$0-1]; this.$ = noteEvent($$[$0-2], $$[$0-1], $$[$0]);
@@ -299,13 +308,13 @@ case 85:
299
308
  currentStaff = Number($$[$0].slice(1, -1)); this.$ = currentStaff;
300
309
  break;
301
310
  case 86:
302
- this.$ = Number($$[$0]);
311
+ currentOttava = Number($$[$0]); this.$ = currentOttava;
303
312
  break;
304
313
  case 87:
305
- this.$ = -Number($$[$0]);
314
+ currentOttava = -Number($$[$0]); this.$ = currentOttava;
306
315
  break;
307
316
  case 88:
308
- this.$ = 0;
317
+ currentOttava = 0; this.$ = 0;
309
318
  break;
310
319
  case 89:
311
320
  this.$ = 'up';
@@ -737,6 +746,7 @@ parse: function parse(input) {
737
746
  let currentTimeSig = null;
738
747
  let currentDuration = { division: 4, dots: 0 }; // default quarter note
739
748
  let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
749
+ let currentOttava = 0; // Current ottava level, resets on newline
740
750
 
741
751
  // Reset parser state - call before each parse
742
752
  const resetParserState = () => {
@@ -745,6 +755,7 @@ parse: function parse(input) {
745
755
  currentTimeSig = null;
746
756
  currentDuration = { division: 4, dots: 0 };
747
757
  numericTimeSignature = false;
758
+ currentOttava = 0;
748
759
  };
749
760
 
750
761
  // Export reset function
@@ -5,8 +5,10 @@ export * from "./serializer";
5
5
 
6
6
  import * as meiEncoder from "./meiEncoder";
7
7
  import * as musicXmlDecoder from "./musicXmlDecoder";
8
+ import * as lilypondEncoder from "./lilypondEncoder";
8
9
 
9
10
  export {
10
11
  meiEncoder,
11
12
  musicXmlDecoder,
13
+ lilypondEncoder,
12
14
  };
@@ -129,6 +129,7 @@
129
129
  let currentTimeSig = null;
130
130
  let currentDuration = { division: 4, dots: 0 }; // default quarter note
131
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
132
133
 
133
134
  // Reset parser state - call before each parse
134
135
  const resetParserState = () => {
@@ -137,6 +138,7 @@
137
138
  currentTimeSig = null;
138
139
  currentDuration = { division: 4, dots: 0 };
139
140
  numericTimeSignature = false;
141
+ currentOttava = 0;
140
142
  };
141
143
 
142
144
  // Export reset function
@@ -323,7 +325,7 @@ parts
323
325
  ;
324
326
 
325
327
  part_start
326
- : /* empty */ %{ currentStaff = 1; %}
328
+ : /* empty */ %{ currentStaff = 1; currentOttava = 0; %}
327
329
  ;
328
330
 
329
331
  part_voices
@@ -362,7 +364,16 @@ markup_event
362
364
  ;
363
365
 
364
366
  pitch_reset_event
365
- : 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
+ %}
366
377
  ;
367
378
 
368
379
  note_event
@@ -475,9 +486,9 @@ staff_cmd
475
486
  ;
476
487
 
477
488
  ottava_cmd
478
- : CMD_OTTAVA '#' NUMBER -> Number($3)
479
- | CMD_OTTAVA '#' '-' NUMBER -> -Number($4)
480
- | 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; %}
481
492
  ;
482
493
 
483
494
  stem_cmd