@k-l-lambda/lilylet 0.1.35 → 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 CHANGED
@@ -1,7 +1,37 @@
1
1
  # Lilylet
2
2
 
3
+ ![](./docs/live-demo.gif)
4
+
3
5
  Lilylet is a LilyPond-like music notation language designed for Markdown rendering and symbolic music representation in AIGC applications.
4
6
 
7
+ ## Hello World
8
+
9
+ ```lyl
10
+ \key c \major \time 4/4 \clef "treble" c1 \bar "|."
11
+ ```
12
+ ![](./docs/hello-world.svg)
13
+
14
+
15
+ ## A Comprehensive Example
16
+
17
+ ```lyl
18
+ [title "Jesu, meine Freude"]
19
+ [subtitle "BWV 610"]
20
+ [composer "J.S. Bach"]
21
+
22
+ \staff "1" \key c \minor \time 4/4 \clef "treble" \stemUp g'4 g f ef \\
23
+ \staff "1" \stemDown ef16[ d ef8]~ ef16[ f ef d] c8[ d]~ d[ c] \\
24
+ \staff "2" \clef "bass" c16[ b c8]~ c16[ b c g] a8[ g]~ g16[ g af ef] \\
25
+ \staff "3" \clef "bass" r8 c,16[ d] ef[ d ef8]~ ef16[ a, b g] c[ b c8] | % 1
26
+
27
+ \staff "1" \stemUp d2 c\fermata \\
28
+ \staff "1" \stemDown c8[ c4 b8] c8.[ \staff "2" \stemUp g16] \staff "1" c[ b c d] \\
29
+ \staff "2" f,16[ ef f d] g[ af g f] ef[ d ef8]~ ef16[ f ef d] \\
30
+ \staff "3" r16 g,[ af f] g[ f g8] c,2 | % 2
31
+ ```
32
+ ![](./docs/BWV610.svg)
33
+
34
+
5
35
  ## Try It Online
6
36
 
7
37
  - [Live Editor](https://k-l-lambda.github.io/lilylet-live-editor/) - Interactive editor with real-time music notation rendering
@@ -33,28 +63,3 @@ LilyPond is powerful but overly flexible—the same music can be written in mult
33
63
  - **Shorter context description**: Removes redundant information, allowing LLMs to process more music content within limited context windows
34
64
  - **Formatted layout**: Fixed syntax structure facilitates model learning and generation
35
65
  - **Markdown-embeddable**: Music snippets can be directly embedded in documents
36
-
37
- ### Basic Syntax
38
-
39
- | Element | Syntax | Description |
40
- |---------|--------|-------------|
41
- | Staff | `\staff "1"` | Specifies which staff the current voice belongs to |
42
- | Key | `\key c \major` | C major |
43
- | Time | `\time 4/4` | 4/4 time signature |
44
- | Clef | `\clef "treble"` | Treble clef |
45
- | Notes | `c4 d8 e16` | C quarter note, D eighth note, E sixteenth note |
46
- | Accidentals | `cs` `cf` `css` `cff` | C sharp, C flat, C double-sharp, C double-flat |
47
- | Octave | `c'` `c,` | One octave higher, one octave lower |
48
- | Chord | `<c e g>4` | C major triad, quarter note |
49
- | Voice separator | `\\` | Separates multiple voices within the same staff |
50
- | Part separator | `\\\` | Separates different instrument tracks (parts) in a score |
51
- | Bar line | `\|` | Separates measures |
52
-
53
- ## Syntax Example
54
-
55
- ```lilylet
56
- \staff "1" \key e \major \time 2/4 \clef "treble" \stemUp e8 [ ds16 e16 ] fs4 ~ \\
57
- \staff "1" s4 \stemDown ds4 ~ \\
58
- \staff "2" \key e \major \clef "bass" \stemUp e,,4 b4 \\
59
- \staff "2" \stemDown e,,16 [ b'8 -> b16 ] b,16 [ b'8 -> b16 ] |
60
- ```
@@ -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
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;
@@ -1344,6 +1431,8 @@ const encode = (doc, options = {}) => {
1344
1431
  const slurState = {};
1345
1432
  // Track hairpin state across measures for cross-measure hairpins
1346
1433
  const hairpinState = {};
1434
+ // Track ottava state across measures for cross-measure ottava spans
1435
+ const ottavaState = {};
1347
1436
  // Initialize clef state from partInfos (convert local staff to global staff)
1348
1437
  const clefState = {};
1349
1438
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1399,7 +1488,7 @@ const encode = (doc, options = {}) => {
1399
1488
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1400
1489
  }
1401
1490
  }
1402
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1491
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1403
1492
  });
1404
1493
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1405
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) {
@@ -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.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",
@@ -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
@@ -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
@@ -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;
@@ -1718,6 +1820,9 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1718
1820
  // Track hairpin state across measures for cross-measure hairpins
1719
1821
  const hairpinState: HairpinState = {};
1720
1822
 
1823
+ // Track ottava state across measures for cross-measure ottava spans
1824
+ const ottavaState: OttavaState = {};
1825
+
1721
1826
  // Initialize clef state from partInfos (convert local staff to global staff)
1722
1827
  const clefState: ClefState = {};
1723
1828
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1776,7 +1881,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
1776
1881
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1777
1882
  }
1778
1883
  }
1779
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
1884
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1780
1885
  });
1781
1886
 
1782
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 {
@@ -16,6 +16,7 @@ import {
16
16
  ContextChange,
17
17
  TupletEvent,
18
18
  TremoloEvent,
19
+ BarlineEvent,
19
20
  Pitch,
20
21
  Duration,
21
22
  Mark,
@@ -524,6 +525,16 @@ const serializeTremoloEvent = (
524
525
  };
525
526
 
526
527
 
528
+ // Serialize a barline event
529
+ const serializeBarlineEvent = (event: BarlineEvent): string => {
530
+ // Only output non-default barlines
531
+ if (event.style && event.style !== '|') {
532
+ return '\\bar "' + event.style + '"';
533
+ }
534
+ return '';
535
+ };
536
+
537
+
527
538
  // Serialize a single event with pitch environment tracking
528
539
  const serializeEvent = (
529
540
  event: Event,
@@ -541,27 +552,46 @@ const serializeEvent = (
541
552
  return serializeTupletEvent(event as TupletEvent, env);
542
553
  case 'tremolo':
543
554
  return serializeTremoloEvent(event as TremoloEvent, env);
555
+ case 'barline':
556
+ return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
544
557
  default:
545
558
  return { str: '', newEnv: env };
546
559
  }
547
560
  };
548
561
 
549
562
 
550
- // Key/time signature info to inject into first voice
563
+ // Key/time/clef signature info to inject into voices
551
564
  interface MeasureContext {
552
565
  key?: KeySignature;
553
566
  time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
567
+ clef?: Clef;
554
568
  }
555
569
 
570
+ // Find first clef in voice events
571
+ const findVoiceClef = (voice: Voice): Clef | undefined => {
572
+ for (const event of voice.events) {
573
+ if (event.type === 'context') {
574
+ const ctx = event as ContextChange;
575
+ if (ctx.clef) {
576
+ return ctx.clef;
577
+ }
578
+ }
579
+ }
580
+ return undefined;
581
+ };
582
+
556
583
  // Serialize a voice with pitch environment tracking
557
584
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
558
585
  // If isGrandStaff is true, always output \staff command for clarity
559
- // If measureContext is provided, output key/time after \staff (for first voice only)
586
+ // measureContext provides key/time for first voice
587
+ // staffClef is the clef for this voice's staff (tracked across measures)
560
588
  const serializeVoice = (
561
589
  voice: Voice,
562
590
  currentStaff: number,
563
591
  isGrandStaff: boolean = false,
564
- measureContext?: MeasureContext
592
+ measureContext?: MeasureContext,
593
+ isFirstVoice: boolean = false,
594
+ staffClef?: Clef
565
595
  ): { str: string; newStaff: number } => {
566
596
  const parts: string[] = [];
567
597
  let prevDuration: Duration | undefined;
@@ -574,8 +604,8 @@ const serializeVoice = (
574
604
  parts.push('\\staff "' + voice.staff + '"');
575
605
  }
576
606
 
577
- // Output key/time signatures after \staff (for first voice of first measure)
578
- if (measureContext) {
607
+ // Output key/time signatures after \staff (for first voice only)
608
+ if (measureContext && isFirstVoice) {
579
609
  if (measureContext.key) {
580
610
  let keyStr = String(measureContext.key.pitch);
581
611
  if (measureContext.key.accidental) {
@@ -595,7 +625,25 @@ const serializeVoice = (
595
625
  }
596
626
  }
597
627
 
628
+ // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
629
+ const voiceClef = staffClef || findVoiceClef(voice);
630
+ if (voiceClef) {
631
+ parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
632
+ }
633
+
634
+ // Track if we've already output the clef to avoid duplication
635
+ let clefOutputted = !!voiceClef;
636
+
598
637
  for (const event of voice.events) {
638
+ // Skip clef context events if we've already output the clef at the beginning
639
+ if (clefOutputted && event.type === 'context') {
640
+ const ctx = event as ContextChange;
641
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
642
+ // This is a clef-only context event, skip it
643
+ continue;
644
+ }
645
+ }
646
+
599
647
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
600
648
  pitchEnv = newEnv;
601
649
 
@@ -616,12 +664,14 @@ const serializeVoice = (
616
664
 
617
665
 
618
666
  // Serialize a part, tracking staff state across voices
619
- // If measureContext is provided, pass it to the first voice only
667
+ // measureContext is passed to all voices (for clef), but key/time only to first voice
620
668
  const serializePart = (
621
669
  part: Part,
622
670
  currentStaff: number,
623
671
  isGrandStaff: boolean = false,
624
- measureContext?: MeasureContext
672
+ measureContext?: MeasureContext,
673
+ isFirstPart: boolean = false,
674
+ clefsByStaff?: Record<number, Clef>
625
675
  ): { str: string; newStaff: number } => {
626
676
  if (part.voices.length === 0) {
627
677
  return { str: '', newStaff: currentStaff };
@@ -632,9 +682,11 @@ const serializePart = (
632
682
 
633
683
  for (let i = 0; i < part.voices.length; i++) {
634
684
  const voice = part.voices[i];
635
- // Only pass measureContext to first voice
636
- const ctx = i === 0 ? measureContext : undefined;
637
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, ctx);
685
+ // Pass measureContext to all voices, isFirstVoice for key/time
686
+ // Pass staff clef from clefsByStaff map
687
+ const isFirstVoice = isFirstPart && i === 0;
688
+ const staffClef = clefsByStaff?.[voice.staff];
689
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
638
690
  voiceStrs.push(str);
639
691
  staff = newStaff;
640
692
  }
@@ -645,19 +697,33 @@ const serializePart = (
645
697
 
646
698
 
647
699
  // Serialize a measure, tracking staff state across parts
648
- const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: number, isGrandStaff: boolean = false): { str: string; newStaff: number } => {
700
+ // Always output key/time at start of each measure
701
+ const serializeMeasure = (
702
+ measure: Measure,
703
+ _isFirst: boolean,
704
+ currentStaff: number,
705
+ isGrandStaff: boolean = false,
706
+ currentKey?: KeySignature,
707
+ currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
708
+ staffClefs?: Record<number, Clef>
709
+ ): { str: string; newStaff: number } => {
649
710
  const parts: string[] = [];
650
711
 
651
- // Build measure context for first voice (key/time signatures)
652
- const measureContext: MeasureContext | undefined = isFirst ? {
653
- key: measure.key,
654
- time: measure.timeSig,
655
- } : undefined;
712
+ // Build measure context for all voices (key/time)
713
+ // Key and time are written to first voice, clef to all voices based on staff
714
+ // Use passed currentKey/currentTime which tracks across all measures
715
+ const measureContext: MeasureContext = {
716
+ key: currentKey,
717
+ time: currentTime,
718
+ };
719
+
720
+ // Pass staffClefs to parts for per-voice clef lookup
721
+ const clefsByStaff = staffClefs || {};
656
722
 
657
723
  // Parts
658
724
  let staff = currentStaff;
659
725
  if (measure.parts.length === 1) {
660
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext);
726
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
661
727
  if (partStr) {
662
728
  parts.push(partStr);
663
729
  }
@@ -667,9 +733,8 @@ const serializeMeasure = (measure: Measure, isFirst: boolean, currentStaff: numb
667
733
  const partStrs: string[] = [];
668
734
  for (let i = 0; i < measure.parts.length; i++) {
669
735
  const part = measure.parts[i];
670
- // Only pass measureContext to first part
671
- const ctx = i === 0 ? measureContext : undefined;
672
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, ctx);
736
+ // Pass measureContext to all parts, isFirstPart to first part only
737
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
673
738
  if (str) {
674
739
  partStrs.push(str);
675
740
  }
@@ -735,10 +800,35 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
735
800
 
736
801
  // Measures with bar lines, measure numbers, and double newlines
737
802
  // Track staff state across measures (parser remembers staff across bar lines)
803
+ // Track key/time/clef across measures to output in every measure
738
804
  const measureStrs: string[] = [];
739
805
  let currentStaff = 1; // Parser starts at staff 1
806
+ let currentKey: KeySignature | undefined;
807
+ let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
808
+ const staffClefs: Record<number, Clef> = {}; // Track clef per staff
809
+
740
810
  for (let i = 0; i < doc.measures.length; i++) {
741
- const { str: measureStr, newStaff } = serializeMeasure(doc.measures[i], i === 0, currentStaff, isGrandStaff);
811
+ const measure = doc.measures[i];
812
+ // Update current key/time if measure has them
813
+ if (measure.key) {
814
+ currentKey = measure.key;
815
+ }
816
+ if (measure.timeSig) {
817
+ currentTime = measure.timeSig;
818
+ }
819
+
820
+ // Collect clefs from this measure's voices
821
+ for (const part of measure.parts) {
822
+ for (const voice of part.voices) {
823
+ for (const event of voice.events) {
824
+ if (event.type === 'context' && (event as ContextChange).clef) {
825
+ staffClefs[voice.staff] = (event as ContextChange).clef!;
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
742
832
  // Always include measure, even if empty (use space rest for empty measures)
743
833
  measureStrs.push(measureStr || 's1');
744
834
  currentStaff = newStaff;