@k-l-lambda/lilylet 0.1.62 → 0.1.64

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.
Files changed (108) hide show
  1. package/lib/abc/grammar.jison.js +300 -187
  2. package/lib/lilylet/abcDecoder.js +40 -12
  3. package/lib/lilylet/lilypondEncoder.js +3 -0
  4. package/lib/lilylet/meiEncoder.js +87 -48
  5. package/lib/source/abc/abc.d.ts +102 -0
  6. package/lib/source/abc/abc.js +25 -0
  7. package/lib/source/abc/parser.d.ts +3 -0
  8. package/lib/source/abc/parser.js +6 -0
  9. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  10. package/lib/source/lilylet/abcDecoder.js +1035 -0
  11. package/lib/source/lilylet/index.d.ts +10 -0
  12. package/lib/source/lilylet/index.js +10 -0
  13. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  14. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  15. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  16. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  17. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  18. package/lib/source/lilylet/meiEncoder.js +1985 -0
  19. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  20. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  21. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  22. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  23. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  24. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  25. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  26. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  27. package/lib/source/lilylet/parser.d.ts +14 -0
  28. package/lib/source/lilylet/parser.js +161 -0
  29. package/lib/source/lilylet/serializer.d.ts +11 -0
  30. package/lib/source/lilylet/serializer.js +791 -0
  31. package/lib/source/lilylet/types.d.ts +253 -0
  32. package/lib/source/lilylet/types.js +100 -0
  33. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  34. package/lib/tests/abc-abcjs-parse.js +90 -0
  35. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  36. package/lib/tests/abc-abcjs-svg.js +143 -0
  37. package/lib/tests/abc-decoder.d.ts +1 -0
  38. package/lib/tests/abc-decoder.js +67 -0
  39. package/lib/tests/abc-mei-compare.d.ts +1 -0
  40. package/lib/tests/abc-mei-compare.js +525 -0
  41. package/lib/tests/auto-beam.d.ts +9 -0
  42. package/lib/tests/auto-beam.js +151 -0
  43. package/lib/tests/computeMeiHashes.d.ts +1 -0
  44. package/lib/tests/computeMeiHashes.js +87 -0
  45. package/lib/tests/encoder-mutation.d.ts +9 -0
  46. package/lib/tests/encoder-mutation.js +110 -0
  47. package/lib/tests/gpt-review-issues.d.ts +5 -0
  48. package/lib/tests/gpt-review-issues.js +255 -0
  49. package/lib/tests/json-to-lyl.d.ts +1 -0
  50. package/lib/tests/json-to-lyl.js +18 -0
  51. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  52. package/lib/tests/lilypond-roundtrip.js +558 -0
  53. package/lib/tests/lilypondDecoder.d.ts +6 -0
  54. package/lib/tests/lilypondDecoder.js +95 -0
  55. package/lib/tests/ly-to-lyl.d.ts +1 -0
  56. package/lib/tests/ly-to-lyl.js +12 -0
  57. package/lib/tests/mei.d.ts +1 -0
  58. package/lib/tests/mei.js +278 -0
  59. package/lib/tests/musicxml-decoder.d.ts +4 -0
  60. package/lib/tests/musicxml-decoder.js +61 -0
  61. package/lib/tests/musicxml-detail.d.ts +4 -0
  62. package/lib/tests/musicxml-detail.js +85 -0
  63. package/lib/tests/musicxml-fprod.d.ts +9 -0
  64. package/lib/tests/musicxml-fprod.js +153 -0
  65. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  66. package/lib/tests/musicxml-roundtrip.js +296 -0
  67. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  68. package/lib/tests/musicxml-to-mei.js +115 -0
  69. package/lib/tests/parser.d.ts +1 -0
  70. package/lib/tests/parser.js +17 -0
  71. package/lib/tests/render-k283.d.ts +1 -0
  72. package/lib/tests/render-k283.js +33 -0
  73. package/lib/tests/render-lyl.d.ts +1 -0
  74. package/lib/tests/render-lyl.js +35 -0
  75. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  76. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  77. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  78. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  79. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  80. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  81. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  82. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  83. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  84. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  85. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  86. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  87. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  88. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  89. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  90. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  91. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  92. package/lib/tests/unit/partialWarning.test.js +65 -0
  93. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  94. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  95. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  96. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  97. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  98. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  99. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  100. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  101. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  102. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  103. package/package.json +5 -2
  104. package/source/abc/abc.jison +90 -15
  105. package/source/abc/grammar.jison.js +300 -187
  106. package/source/lilylet/abcDecoder.ts +42 -14
  107. package/source/lilylet/lilypondEncoder.ts +2 -0
  108. package/source/lilylet/meiEncoder.ts +95 -48
@@ -758,13 +758,13 @@ const convertEventTerm = (
758
758
  const firstPitch = chord.pitches[0];
759
759
 
760
760
  // Check if rest
761
- if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x") {
761
+ if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x" || firstPitch.phonet === "y") {
762
762
  const duration = convertDuration(eventData.duration, unitLength);
763
763
  const rest: RestEvent = {
764
764
  type: "rest",
765
765
  duration,
766
766
  };
767
- if (firstPitch.phonet === "x") {
767
+ if (firstPitch.phonet === "x" || firstPitch.phonet === "y") {
768
768
  rest.invisible = true;
769
769
  }
770
770
  if (firstPitch.phonet === "Z") {
@@ -779,7 +779,7 @@ const convertEventTerm = (
779
779
 
780
780
  // Note or chord
781
781
  const pitches = chord.pitches.filter(p =>
782
- p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x"
782
+ p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y"
783
783
  ).map(convertPitch);
784
784
 
785
785
  if (pitches.length === 0) return undefined;
@@ -865,8 +865,17 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
865
865
  let timeSig: TimeSig | undefined;
866
866
  let keySig: KeySignature | undefined;
867
867
  let tempo: Tempo | undefined;
868
- const voiceConfigs = new Map<number, VoiceConfig>();
869
- const voiceClefs = new Map<number, Clef>();
868
+ const voiceConfigs = new Map<number | string, VoiceConfig>();
869
+ const voiceClefs = new Map<number | string, Clef>();
870
+
871
+ // Pre-scan for unit length (needed for bare Q: tempo)
872
+ for (const h of headers) {
873
+ const hdr = h as { name: string; value: any };
874
+ if (hdr.name === "L" && hdr.value?.numerator && hdr.value?.denominator) {
875
+ unitLength = hdr.value;
876
+ break;
877
+ }
878
+ }
870
879
 
871
880
  for (const h of headers) {
872
881
  if ((h as any).comment) continue;
@@ -905,24 +914,43 @@ const decodeTune = (tune: ABC.Tune): LilyletDoc => {
905
914
  const beatDuration = convertDuration(header.value.note, { numerator: 1, denominator: 1 });
906
915
  tempo = { beat: beatDuration, bpm: header.value.bpm };
907
916
  } else if (typeof header.value === "number") {
908
- tempo = { bpm: header.value };
917
+ const beat = convertDuration({ numerator: 1, denominator: 1 }, unitLength);
918
+ tempo = { beat, bpm: header.value };
909
919
  }
910
920
  break;
911
921
  case "V": {
912
922
  const voiceValue = header.value;
913
923
  if (voiceValue) {
914
- const voiceNum = typeof voiceValue === "number" ? voiceValue :
915
- (voiceValue.name || 1);
916
- const clefStr = typeof voiceValue === "string" ? voiceValue :
917
- (voiceValue.clef || undefined);
918
- voiceConfigs.set(voiceNum, {
919
- name: voiceNum,
924
+ let voiceId: number | string;
925
+ let clefStr: string | undefined;
926
+
927
+ if (typeof voiceValue === "number") {
928
+ voiceId = voiceValue;
929
+ } else if (typeof voiceValue === "string") {
930
+ voiceId = voiceValue;
931
+ } else {
932
+ const rawClef = (voiceValue.clef || "").replace(/,+$/, "").trim();
933
+ const isKnownClef = !!convertClef(rawClef);
934
+ if (isKnownClef) {
935
+ // V:1 treble → voiceId=number, clef=treble
936
+ voiceId = voiceValue.name || 1;
937
+ clefStr = rawClef;
938
+ } else {
939
+ // V:S clef=treble → voiceId=voiceName, clef from properties
940
+ voiceId = rawClef || voiceValue.name || 1;
941
+ const propClef = (voiceValue.properties?.clef || "").replace(/,+$/, "").trim();
942
+ clefStr = propClef || undefined;
943
+ }
944
+ }
945
+
946
+ voiceConfigs.set(voiceId, {
947
+ name: typeof voiceId === "number" ? voiceId : 1,
920
948
  clef: clefStr,
921
- properties: voiceValue.properties,
949
+ properties: voiceValue?.properties,
922
950
  });
923
951
  if (clefStr) {
924
952
  const clef = convertClef(clefStr);
925
- if (clef) voiceClefs.set(voiceNum, clef);
953
+ if (clef) voiceClefs.set(voiceId, clef);
926
954
  }
927
955
  }
928
956
  break;
@@ -577,6 +577,8 @@ const encodeTupletEvent = (event: TupletEvent | TimesEvent, env: PitchEnv, lastD
577
577
  result += str + ' ';
578
578
  newEnv = ne;
579
579
  newDuration = nd;
580
+ } else if (subEvent.type === 'context') {
581
+ result += encodeContextChange(subEvent) + ' ';
580
582
  }
581
583
  }
582
584
 
@@ -655,6 +655,7 @@ interface TupletEventResult {
655
655
  mordents: MordentRef[];
656
656
  turns: TurnRef[];
657
657
  arpeggios: ArpegRef[];
658
+ endingClef?: string; // Updated clef name if changed inside the tuplet
658
659
  }
659
660
 
660
661
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
@@ -673,7 +674,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
673
674
  };
674
675
 
675
676
  // Convert TupletEvent to MEI
676
- const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>): TupletEventResult => {
677
+ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>, currentClef?: string): TupletEventResult => {
677
678
  // LilyPond \times 2/3 means "multiply duration by 2/3"
678
679
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
679
680
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -701,6 +702,8 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
701
702
  // Handle internal beam groups: if notes have manual beam marks, respect them
702
703
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
703
704
  let beamOpen = false;
705
+ let activeClef = currentClef;
706
+ let endingClef: string | undefined;
704
707
 
705
708
  for (const e of event.events) {
706
709
  if (e.type === 'note') {
@@ -744,6 +747,19 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
744
747
  } else if (e.type === 'rest') {
745
748
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
746
749
  xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
750
+ } else if (e.type === 'context') {
751
+ const ctx = e as ContextChange;
752
+ if (ctx.clef && ctx.clef !== activeClef) {
753
+ const layerStaffNum = layerStaff || 1;
754
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
755
+ if (effectiveStaffNum === layerStaffNum) {
756
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
757
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
758
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
759
+ }
760
+ activeClef = ctx.clef;
761
+ endingClef = ctx.clef;
762
+ }
747
763
  }
748
764
  }
749
765
 
@@ -753,7 +769,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
753
769
  }
754
770
 
755
771
  xml += `${indent}</tuplet>\n`;
756
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
772
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
757
773
  };
758
774
 
759
775
 
@@ -911,6 +927,10 @@ interface PendingOctave {
911
927
  disPlace: 'above' | 'below';
912
928
  startId: string;
913
929
  shift: number; // The ottava value (1, -1, 2, -2)
930
+ continued?: boolean;
931
+ emitted?: boolean;
932
+ endToken?: string;
933
+ endFallbackId?: string;
914
934
  }
915
935
  type OttavaState = Record<string, PendingOctave | null>; // voice key -> pending octave span
916
936
 
@@ -940,6 +960,7 @@ interface LayerResult {
940
960
  endingClef?: Clef; // For cross-measure clef tracking
941
961
  lastNoteId: string | null; // For cross-measure ottava span end tracking
942
962
  currentOttavaShift: number; // Current ottava shift for pitch encoding
963
+ octaveEndReplacements: Record<string, string>;
943
964
  }
944
965
 
945
966
 
@@ -949,7 +970,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
949
970
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
950
971
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
951
972
  }
952
- if (event.type === 'tuplet') {
973
+ if (event.type === 'tuplet' || event.type === 'times') {
953
974
  const tuplet = event as TupletEvent;
954
975
  // If the tuplet has internal beam groups, don't report beam marks to the parent
955
976
  // so the parent won't wrap the tuplet in an external <beam>
@@ -990,8 +1011,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
990
1011
 
991
1012
  // Track octave spans - initialize from previous measure if continuing
992
1013
  const octaves: OctaveSpan[] = [];
993
- let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string } | null =
994
- initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
1014
+ const octaveEndReplacements: Record<string, string> = {};
1015
+ let currentOctave: { dis: 8 | 15; disPlace: 'above' | 'below'; startId: string; continued?: boolean; emitted?: boolean; endToken?: string; endFallbackId?: string } | null =
1016
+ initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId, continued: initialOctave.continued, emitted: initialOctave.emitted, endToken: initialOctave.endToken, endFallbackId: initialOctave.endFallbackId } : null;
995
1017
  let pendingOttava: number | null = null; // Track ottava to apply to next note
996
1018
  let currentOttavaShift: number = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
997
1019
  let lastNoteId: string | null = null; // Track last note id for ending ottava spans
@@ -1099,6 +1121,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1099
1121
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
1100
1122
  xml += result.xml;
1101
1123
  lastNoteId = result.elementId;
1124
+ if (currentOctave?.endToken) {
1125
+ octaveEndReplacements[currentOctave.endToken] = result.elementId;
1126
+ }
1102
1127
 
1103
1128
  // Flush any pending markups onto this note
1104
1129
  flushPendingMarkups(result.elementId);
@@ -1109,9 +1134,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1109
1134
  const disPlace: 'above' | 'below' = pendingOttava > 0 ? 'above' : 'below';
1110
1135
  // Close existing span first if it has a different value
1111
1136
  if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
1112
- // Different value - close the old span
1113
- // Use the lastNoteId from before this note (which we saved before processing)
1114
- // Note: The span from previous measure will be closed by encodeMeasure
1137
+ if (lastNoteId) {
1138
+ octaves.push({
1139
+ dis: currentOctave.dis,
1140
+ disPlace: currentOctave.disPlace,
1141
+ startId: currentOctave.startId,
1142
+ endId: lastNoteId,
1143
+ });
1144
+ }
1115
1145
  currentOctave = null;
1116
1146
  }
1117
1147
  // Start new span if we don't already have one with the same value
@@ -1119,6 +1149,11 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1119
1149
  currentOctave = { dis, disPlace, startId: result.elementId };
1120
1150
  }
1121
1151
  pendingOttava = null;
1152
+ } else if (currentOctave?.continued) {
1153
+ if (!currentOctave.endToken) {
1154
+ currentOctave.startId = result.elementId;
1155
+ }
1156
+ currentOctave.continued = false;
1122
1157
  }
1123
1158
 
1124
1159
  // Update pending tie pitches
@@ -1210,12 +1245,18 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1210
1245
  xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1211
1246
  break;
1212
1247
  }
1213
- case 'tuplet': {
1248
+ case 'tuplet':
1249
+ case 'times': {
1214
1250
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
1215
1251
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
1216
- const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
1252
+ const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
1217
1253
  xml += tupletResult.xml;
1218
1254
 
1255
+ // Propagate clef change from inside the tuplet to the parent tracker
1256
+ if (tupletResult.endingClef) {
1257
+ currentClef = tupletResult.endingClef as Clef;
1258
+ }
1259
+
1219
1260
  // Flush any pending markups onto the first note of the tuplet
1220
1261
  if (tupletResult.firstNoteId) {
1221
1262
  flushPendingMarkups(tupletResult.firstNoteId);
@@ -1268,12 +1309,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1268
1309
  if (ctx.ottava === 0) {
1269
1310
  // End current ottava span
1270
1311
  if (currentOctave && lastNoteId) {
1271
- octaves.push({
1272
- dis: currentOctave.dis,
1273
- disPlace: currentOctave.disPlace,
1274
- startId: currentOctave.startId,
1275
- endId: lastNoteId,
1276
- });
1312
+ if (!currentOctave.emitted) {
1313
+ octaves.push({
1314
+ dis: currentOctave.dis,
1315
+ disPlace: currentOctave.disPlace,
1316
+ startId: currentOctave.startId,
1317
+ endId: lastNoteId,
1318
+ });
1319
+ }
1277
1320
  currentOctave = null;
1278
1321
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
1279
1322
  }
@@ -1285,7 +1328,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1285
1328
  const dis: 8 | 15 = Math.abs(ctx.ottava) === 2 ? 15 : 8;
1286
1329
  const disPlace: 'above' | 'below' = ctx.ottava > 0 ? 'above' : 'below';
1287
1330
  if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
1288
- // Continuation - restore the shift but don't change the span
1331
+ // Continuation - restore the shift and let the existing 8va line reach this measure's first note
1289
1332
  currentOttavaShift = ctx.ottava;
1290
1333
  } else {
1291
1334
  // Different value - start new ottava span (will be applied to next note)
@@ -1361,14 +1404,25 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1361
1404
  xml += `${baseIndent}</beam>\n`;
1362
1405
  }
1363
1406
 
1364
- // Don't close ottava span at measure end - it may continue in the next measure
1365
- // Build pending octave state to return
1407
+ // Emit one visible octave span for a continuing ottava; a repeated same-value command can extend it to the next measure.
1408
+ if (currentOctave && lastNoteId && !currentOctave.emitted) {
1409
+ const endToken = `__OTTAVA_END_${generateId('octaveEnd')}__`;
1410
+ octaves.push({
1411
+ dis: currentOctave.dis,
1412
+ disPlace: currentOctave.disPlace,
1413
+ startId: currentOctave.startId,
1414
+ endId: endToken,
1415
+ });
1416
+ currentOctave.emitted = true;
1417
+ currentOctave.endToken = endToken;
1418
+ currentOctave.endFallbackId = lastNoteId;
1419
+ }
1366
1420
  const pendingOctave: PendingOctave | null = currentOctave
1367
- ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
1421
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
1368
1422
  : null;
1369
1423
 
1370
1424
  xml += `${indent}</layer>\n`;
1371
- 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 };
1425
+ 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, octaveEndReplacements };
1372
1426
  };
1373
1427
 
1374
1428
  // Staff result type
@@ -1395,6 +1449,7 @@ interface StaffResult {
1395
1449
  pendingOctaves: OttavaState; // For cross-measure ottava span tracking
1396
1450
  ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
1397
1451
  lastNoteIds: Record<string, string | null>; // For cross-measure ottava span end tracking
1452
+ octaveEndReplacements: Record<string, string>;
1398
1453
  endingClef?: Clef; // For cross-measure clef tracking
1399
1454
  }
1400
1455
 
@@ -1423,6 +1478,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1423
1478
  const pendingOctaves: OttavaState = {};
1424
1479
  const ottavaExplicitlyClosed: Record<string, boolean> = {};
1425
1480
  const lastNoteIds: Record<string, string | null> = {};
1481
+ const octaveEndReplacements: Record<string, string> = {};
1426
1482
  let endingClef: Clef | undefined = initialClef;
1427
1483
 
1428
1484
  if (voices.length === 0) {
@@ -1452,6 +1508,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1452
1508
  allHarmonies.push(...result.harmonies);
1453
1509
  allBarlines.push(...result.barlines);
1454
1510
  allMarkups.push(...result.markups);
1511
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1455
1512
  // Track pending ties for this layer
1456
1513
  if (result.pendingTiePitches.length > 0) {
1457
1514
  pendingTies[tieKey] = result.pendingTiePitches;
@@ -1505,6 +1562,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1505
1562
  pendingOctaves,
1506
1563
  ottavaExplicitlyClosed,
1507
1564
  lastNoteIds,
1565
+ octaveEndReplacements,
1508
1566
  endingClef,
1509
1567
  };
1510
1568
  };
@@ -1592,7 +1650,7 @@ const BARLINE_TO_MEI: Record<string, string> = {
1592
1650
 
1593
1651
  // Encode a measure
1594
1652
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1595
- 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 => {
1653
+ 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 = {}, octaveEndReplacements: Record<string, string> = {}): string => {
1596
1654
  const measureId = generateId("measure");
1597
1655
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1598
1656
  const allHairpins: HairpinSpan[] = [];
@@ -1668,38 +1726,22 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
1668
1726
  allHarmonies.push(...result.harmonies);
1669
1727
  allBarlines.push(...result.barlines);
1670
1728
  allMarkups.push(...result.markups);
1729
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1671
1730
  // Update tie state with pending ties from this staff
1672
1731
  Object.assign(tieState, result.pendingTies);
1673
1732
  // Update slur state with pending slurs from this staff
1674
1733
  Object.assign(slurState, result.pendingSlurs);
1675
1734
  // Update hairpin state with pending hairpins from this staff
1676
1735
  Object.assign(hairpinState, result.pendingHairpins);
1677
- // Update ottava state with pending octaves from this staff
1678
- // Also handle closing spans when ottava ends
1736
+ // Update ottava state with pending octaves from this staff.
1737
+ // encodeLayer already emits measure-local octave spans, so keep the next measure's start independent.
1679
1738
  const currentStaffPrefix = `${si}-`;
1680
1739
  for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1681
1740
  if (pending) {
1682
- // Check if this is a continuation or a new span
1683
- const prevPending = ottavaState[key];
1684
- if (prevPending && prevPending.shift === pending.shift) {
1685
- // Same ottava value continues - keep the original startId
1686
- ottavaState[key] = { ...pending, startId: prevPending.startId };
1687
- } else {
1688
- // Different ottava value - close the old span first if exists
1689
- if (prevPending) {
1690
- const lastNoteId = result.lastNoteIds[key];
1691
- if (lastNoteId) {
1692
- allOctaves.push({
1693
- dis: prevPending.dis,
1694
- disPlace: prevPending.disPlace,
1695
- startId: prevPending.startId,
1696
- endId: lastNoteId,
1697
- });
1698
- }
1699
- }
1700
- // Start new span
1701
- ottavaState[key] = pending;
1741
+ if (pending.endToken && pending.endFallbackId && !octaveEndReplacements[pending.endToken]) {
1742
+ octaveEndReplacements[pending.endToken] = pending.endFallbackId;
1702
1743
  }
1744
+ ottavaState[key] = pending;
1703
1745
  }
1704
1746
  }
1705
1747
  // For layers in this staff that had pending octaves but didn't in this measure, close the spans
@@ -1944,7 +1986,7 @@ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
1944
1986
  if (m.markType === 'beam') return true;
1945
1987
  }
1946
1988
  }
1947
- } else if (event.type === 'tuplet') {
1989
+ } else if (event.type === 'tuplet' || event.type === 'times') {
1948
1990
  const tuplet = event as TupletEvent;
1949
1991
  for (const e of tuplet.events) {
1950
1992
  if (e.type === 'note') {
@@ -2119,7 +2161,7 @@ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
2119
2161
  // Rests break beam groups
2120
2162
  flushRun();
2121
2163
  position += dur;
2122
- } else if (event.type === 'tuplet') {
2164
+ } else if (event.type === 'tuplet' || event.type === 'times') {
2123
2165
  const tuplet = event as TupletEvent;
2124
2166
  const ratio = tuplet.ratio; // LilyPond ratio: num/den
2125
2167
 
@@ -2303,6 +2345,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2303
2345
 
2304
2346
  // Track ottava state across measures for cross-measure ottava spans
2305
2347
  const ottavaState: OttavaState = {};
2348
+ const octaveEndReplacements: Record<string, string> = {};
2306
2349
 
2307
2350
  // Initialize clef state from partInfos (convert local staff to global staff)
2308
2351
  const clefState: ClefState = {};
@@ -2321,7 +2364,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2321
2364
  for (const event of voice.events) {
2322
2365
  // Check for actual musical content (not just context changes or pitch resets)
2323
2366
  if (event.type === 'note' || event.type === 'rest' ||
2324
- event.type === 'tuplet' || event.type === 'tremolo') {
2367
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
2325
2368
  return true;
2326
2369
  }
2327
2370
  }
@@ -2362,7 +2405,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2362
2405
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2363
2406
  }
2364
2407
  }
2365
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
2408
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2366
2409
  });
2367
2410
 
2368
2411
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -2372,6 +2415,10 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2372
2415
  mei += `${indent}</music>\n`;
2373
2416
  mei += '</mei>\n';
2374
2417
 
2418
+ for (const [token, endId] of Object.entries(octaveEndReplacements)) {
2419
+ mei = mei.replaceAll(token, endId);
2420
+ }
2421
+
2375
2422
  return mei;
2376
2423
  };
2377
2424