@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
@@ -695,13 +695,13 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
695
695
  return undefined;
696
696
  const firstPitch = chord.pitches[0];
697
697
  // Check if rest
698
- if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x") {
698
+ if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x" || firstPitch.phonet === "y") {
699
699
  const duration = convertDuration(eventData.duration, unitLength);
700
700
  const rest = {
701
701
  type: "rest",
702
702
  duration,
703
703
  };
704
- if (firstPitch.phonet === "x") {
704
+ if (firstPitch.phonet === "x" || firstPitch.phonet === "y") {
705
705
  rest.invisible = true;
706
706
  }
707
707
  if (firstPitch.phonet === "Z") {
@@ -712,7 +712,7 @@ const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextCha
712
712
  return rest;
713
713
  }
714
714
  // Note or chord
715
- const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map(convertPitch);
715
+ const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(convertPitch);
716
716
  if (pitches.length === 0)
717
717
  return undefined;
718
718
  const duration = convertDuration(eventData.duration, unitLength);
@@ -781,6 +781,14 @@ const decodeTune = (tune) => {
781
781
  let tempo;
782
782
  const voiceConfigs = new Map();
783
783
  const voiceClefs = new Map();
784
+ // Pre-scan for unit length (needed for bare Q: tempo)
785
+ for (const h of headers) {
786
+ const hdr = h;
787
+ if (hdr.name === "L" && hdr.value?.numerator && hdr.value?.denominator) {
788
+ unitLength = hdr.value;
789
+ break;
790
+ }
791
+ }
784
792
  for (const h of headers) {
785
793
  if (h.comment)
786
794
  continue;
@@ -822,25 +830,45 @@ const decodeTune = (tune) => {
822
830
  tempo = { beat: beatDuration, bpm: header.value.bpm };
823
831
  }
824
832
  else if (typeof header.value === "number") {
825
- tempo = { bpm: header.value };
833
+ const beat = convertDuration({ numerator: 1, denominator: 1 }, unitLength);
834
+ tempo = { beat, bpm: header.value };
826
835
  }
827
836
  break;
828
837
  case "V": {
829
838
  const voiceValue = header.value;
830
839
  if (voiceValue) {
831
- const voiceNum = typeof voiceValue === "number" ? voiceValue :
832
- (voiceValue.name || 1);
833
- const clefStr = typeof voiceValue === "string" ? voiceValue :
834
- (voiceValue.clef || undefined);
835
- voiceConfigs.set(voiceNum, {
836
- name: voiceNum,
840
+ let voiceId;
841
+ let clefStr;
842
+ if (typeof voiceValue === "number") {
843
+ voiceId = voiceValue;
844
+ }
845
+ else if (typeof voiceValue === "string") {
846
+ voiceId = voiceValue;
847
+ }
848
+ else {
849
+ const rawClef = (voiceValue.clef || "").replace(/,+$/, "").trim();
850
+ const isKnownClef = !!convertClef(rawClef);
851
+ if (isKnownClef) {
852
+ // V:1 treble → voiceId=number, clef=treble
853
+ voiceId = voiceValue.name || 1;
854
+ clefStr = rawClef;
855
+ }
856
+ else {
857
+ // V:S clef=treble → voiceId=voiceName, clef from properties
858
+ voiceId = rawClef || voiceValue.name || 1;
859
+ const propClef = (voiceValue.properties?.clef || "").replace(/,+$/, "").trim();
860
+ clefStr = propClef || undefined;
861
+ }
862
+ }
863
+ voiceConfigs.set(voiceId, {
864
+ name: typeof voiceId === "number" ? voiceId : 1,
837
865
  clef: clefStr,
838
- properties: voiceValue.properties,
866
+ properties: voiceValue?.properties,
839
867
  });
840
868
  if (clefStr) {
841
869
  const clef = convertClef(clefStr);
842
870
  if (clef)
843
- voiceClefs.set(voiceNum, clef);
871
+ voiceClefs.set(voiceId, clef);
844
872
  }
845
873
  }
846
874
  break;
@@ -469,6 +469,9 @@ const encodeTupletEvent = (event, env, lastDuration) => {
469
469
  newEnv = ne;
470
470
  newDuration = nd;
471
471
  }
472
+ else if (subEvent.type === 'context') {
473
+ result += encodeContextChange(subEvent) + ' ';
474
+ }
472
475
  }
473
476
  result += '}';
474
477
  return { str: result, newEnv, newDuration };
@@ -538,7 +538,7 @@ const tupletHasInternalBeams = (event) => {
538
538
  return starts > 0 && starts === ends;
539
539
  };
540
540
  // Convert TupletEvent to MEI
541
- const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals) => {
541
+ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals, currentClef) => {
542
542
  // LilyPond \times 2/3 means "multiply duration by 2/3"
543
543
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
544
544
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -561,6 +561,8 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
561
561
  // Handle internal beam groups: if notes have manual beam marks, respect them
562
562
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
563
563
  let beamOpen = false;
564
+ let activeClef = currentClef;
565
+ let endingClef;
564
566
  for (const e of event.events) {
565
567
  if (e.type === 'note') {
566
568
  const noteEvent = e;
@@ -607,13 +609,27 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
607
609
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
608
610
  xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
609
611
  }
612
+ else if (e.type === 'context') {
613
+ const ctx = e;
614
+ if (ctx.clef && ctx.clef !== activeClef) {
615
+ const layerStaffNum = layerStaff || 1;
616
+ const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
617
+ if (effectiveStaffNum === layerStaffNum) {
618
+ const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
619
+ const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
620
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
621
+ }
622
+ activeClef = ctx.clef;
623
+ endingClef = ctx.clef;
624
+ }
625
+ }
610
626
  }
611
627
  // Close any unclosed beam
612
628
  if (beamOpen) {
613
629
  xml += `${baseIndent}</beam>\n`;
614
630
  }
615
631
  xml += `${indent}</tuplet>\n`;
616
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
632
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
617
633
  };
618
634
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
619
635
  const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
@@ -688,7 +704,7 @@ const getEventBeamMarks = (event) => {
688
704
  const markOptions = extractMarkOptions(event.marks);
689
705
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
690
706
  }
691
- if (event.type === 'tuplet') {
707
+ if (event.type === 'tuplet' || event.type === 'times') {
692
708
  const tuplet = event;
693
709
  // If the tuplet has internal beam groups, don't report beam marks to the parent
694
710
  // so the parent won't wrap the tuplet in an external <beam>
@@ -725,7 +741,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
725
741
  const pedals = [];
726
742
  // Track octave spans - initialize from previous measure if continuing
727
743
  const octaves = [];
728
- let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
744
+ const octaveEndReplacements = {};
745
+ let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId, continued: initialOctave.continued, emitted: initialOctave.emitted, endToken: initialOctave.endToken, endFallbackId: initialOctave.endFallbackId } : null;
729
746
  let pendingOttava = null; // Track ottava to apply to next note
730
747
  let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
731
748
  let lastNoteId = null; // Track last note id for ending ottava spans
@@ -817,6 +834,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
817
834
  const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
818
835
  xml += result.xml;
819
836
  lastNoteId = result.elementId;
837
+ if (currentOctave?.endToken) {
838
+ octaveEndReplacements[currentOctave.endToken] = result.elementId;
839
+ }
820
840
  // Flush any pending markups onto this note
821
841
  flushPendingMarkups(result.elementId);
822
842
  // If there's a pending ottava, start the span on this note
@@ -825,9 +845,14 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
825
845
  const disPlace = pendingOttava > 0 ? 'above' : 'below';
826
846
  // Close existing span first if it has a different value
827
847
  if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
828
- // Different value - close the old span
829
- // Use the lastNoteId from before this note (which we saved before processing)
830
- // Note: The span from previous measure will be closed by encodeMeasure
848
+ if (lastNoteId) {
849
+ octaves.push({
850
+ dis: currentOctave.dis,
851
+ disPlace: currentOctave.disPlace,
852
+ startId: currentOctave.startId,
853
+ endId: lastNoteId,
854
+ });
855
+ }
831
856
  currentOctave = null;
832
857
  }
833
858
  // Start new span if we don't already have one with the same value
@@ -836,6 +861,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
836
861
  }
837
862
  pendingOttava = null;
838
863
  }
864
+ else if (currentOctave?.continued) {
865
+ if (!currentOctave.endToken) {
866
+ currentOctave.startId = result.elementId;
867
+ }
868
+ currentOctave.continued = false;
869
+ }
839
870
  // Update pending tie pitches
840
871
  if (result.hasTieStart) {
841
872
  pendingTiePitches = result.pitches;
@@ -923,11 +954,16 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
923
954
  xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
924
955
  break;
925
956
  }
926
- case 'tuplet': {
957
+ case 'tuplet':
958
+ case 'times': {
927
959
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
928
960
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
929
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
961
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
930
962
  xml += tupletResult.xml;
963
+ // Propagate clef change from inside the tuplet to the parent tracker
964
+ if (tupletResult.endingClef) {
965
+ currentClef = tupletResult.endingClef;
966
+ }
931
967
  // Flush any pending markups onto the first note of the tuplet
932
968
  if (tupletResult.firstNoteId) {
933
969
  flushPendingMarkups(tupletResult.firstNoteId);
@@ -976,12 +1012,14 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
976
1012
  if (ctx.ottava === 0) {
977
1013
  // End current ottava span
978
1014
  if (currentOctave && lastNoteId) {
979
- octaves.push({
980
- dis: currentOctave.dis,
981
- disPlace: currentOctave.disPlace,
982
- startId: currentOctave.startId,
983
- endId: lastNoteId,
984
- });
1015
+ if (!currentOctave.emitted) {
1016
+ octaves.push({
1017
+ dis: currentOctave.dis,
1018
+ disPlace: currentOctave.disPlace,
1019
+ startId: currentOctave.startId,
1020
+ endId: lastNoteId,
1021
+ });
1022
+ }
985
1023
  currentOctave = null;
986
1024
  ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
987
1025
  }
@@ -994,7 +1032,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
994
1032
  const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
995
1033
  const disPlace = ctx.ottava > 0 ? 'above' : 'below';
996
1034
  if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
997
- // Continuation - restore the shift but don't change the span
1035
+ // Continuation - restore the shift and let the existing 8va line reach this measure's first note
998
1036
  currentOttavaShift = ctx.ottava;
999
1037
  }
1000
1038
  else {
@@ -1070,13 +1108,24 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1070
1108
  if (beamElementOpen) {
1071
1109
  xml += `${baseIndent}</beam>\n`;
1072
1110
  }
1073
- // Don't close ottava span at measure end - it may continue in the next measure
1074
- // Build pending octave state to return
1111
+ // Emit one visible octave span for a continuing ottava; a repeated same-value command can extend it to the next measure.
1112
+ if (currentOctave && lastNoteId && !currentOctave.emitted) {
1113
+ const endToken = `__OTTAVA_END_${generateId('octaveEnd')}__`;
1114
+ octaves.push({
1115
+ dis: currentOctave.dis,
1116
+ disPlace: currentOctave.disPlace,
1117
+ startId: currentOctave.startId,
1118
+ endId: endToken,
1119
+ });
1120
+ currentOctave.emitted = true;
1121
+ currentOctave.endToken = endToken;
1122
+ currentOctave.endFallbackId = lastNoteId;
1123
+ }
1075
1124
  const pendingOctave = currentOctave
1076
- ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
1125
+ ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
1077
1126
  : null;
1078
1127
  xml += `${indent}</layer>\n`;
1079
- 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 };
1128
+ 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 };
1080
1129
  };
1081
1130
  // Encode a staff
1082
1131
  const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
@@ -1103,6 +1152,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1103
1152
  const pendingOctaves = {};
1104
1153
  const ottavaExplicitlyClosed = {};
1105
1154
  const lastNoteIds = {};
1155
+ const octaveEndReplacements = {};
1106
1156
  let endingClef = initialClef;
1107
1157
  if (voices.length === 0) {
1108
1158
  xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
@@ -1132,6 +1182,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1132
1182
  allHarmonies.push(...result.harmonies);
1133
1183
  allBarlines.push(...result.barlines);
1134
1184
  allMarkups.push(...result.markups);
1185
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1135
1186
  // Track pending ties for this layer
1136
1187
  if (result.pendingTiePitches.length > 0) {
1137
1188
  pendingTies[tieKey] = result.pendingTiePitches;
@@ -1184,6 +1235,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
1184
1235
  pendingOctaves,
1185
1236
  ottavaExplicitlyClosed,
1186
1237
  lastNoteIds,
1238
+ octaveEndReplacements,
1187
1239
  endingClef,
1188
1240
  };
1189
1241
  };
@@ -1259,7 +1311,7 @@ const BARLINE_TO_MEI = {
1259
1311
  };
1260
1312
  // Encode a measure
1261
1313
  // encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
1262
- const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
1314
+ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}, octaveEndReplacements = {}) => {
1263
1315
  const measureId = generateId("measure");
1264
1316
  let staffContent = ''; // Build staff content first, then add measure tag with barline
1265
1317
  const allHairpins = [];
@@ -1332,39 +1384,22 @@ const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurSta
1332
1384
  allHarmonies.push(...result.harmonies);
1333
1385
  allBarlines.push(...result.barlines);
1334
1386
  allMarkups.push(...result.markups);
1387
+ Object.assign(octaveEndReplacements, result.octaveEndReplacements);
1335
1388
  // Update tie state with pending ties from this staff
1336
1389
  Object.assign(tieState, result.pendingTies);
1337
1390
  // Update slur state with pending slurs from this staff
1338
1391
  Object.assign(slurState, result.pendingSlurs);
1339
1392
  // Update hairpin state with pending hairpins from this staff
1340
1393
  Object.assign(hairpinState, result.pendingHairpins);
1341
- // Update ottava state with pending octaves from this staff
1342
- // Also handle closing spans when ottava ends
1394
+ // Update ottava state with pending octaves from this staff.
1395
+ // encodeLayer already emits measure-local octave spans, so keep the next measure's start independent.
1343
1396
  const currentStaffPrefix = `${si}-`;
1344
1397
  for (const [key, pending] of Object.entries(result.pendingOctaves)) {
1345
1398
  if (pending) {
1346
- // Check if this is a continuation or a new span
1347
- const prevPending = ottavaState[key];
1348
- if (prevPending && prevPending.shift === pending.shift) {
1349
- // Same ottava value continues - keep the original startId
1350
- ottavaState[key] = { ...pending, startId: prevPending.startId };
1351
- }
1352
- else {
1353
- // Different ottava value - close the old span first if exists
1354
- if (prevPending) {
1355
- const lastNoteId = result.lastNoteIds[key];
1356
- if (lastNoteId) {
1357
- allOctaves.push({
1358
- dis: prevPending.dis,
1359
- disPlace: prevPending.disPlace,
1360
- startId: prevPending.startId,
1361
- endId: lastNoteId,
1362
- });
1363
- }
1364
- }
1365
- // Start new span
1366
- ottavaState[key] = pending;
1399
+ if (pending.endToken && pending.endFallbackId && !octaveEndReplacements[pending.endToken]) {
1400
+ octaveEndReplacements[pending.endToken] = pending.endFallbackId;
1367
1401
  }
1402
+ ottavaState[key] = pending;
1368
1403
  }
1369
1404
  }
1370
1405
  // For layers in this staff that had pending octaves but didn't in this measure, close the spans
@@ -1566,7 +1601,7 @@ const docHasBeamMarks = (doc) => {
1566
1601
  }
1567
1602
  }
1568
1603
  }
1569
- else if (event.type === 'tuplet') {
1604
+ else if (event.type === 'tuplet' || event.type === 'times') {
1570
1605
  const tuplet = event;
1571
1606
  for (const e of tuplet.events) {
1572
1607
  if (e.type === 'note') {
@@ -1729,7 +1764,7 @@ const applyAutoBeamToVoice = (events, beamGroups) => {
1729
1764
  flushRun();
1730
1765
  position += dur;
1731
1766
  }
1732
- else if (event.type === 'tuplet') {
1767
+ else if (event.type === 'tuplet' || event.type === 'times') {
1733
1768
  const tuplet = event;
1734
1769
  const ratio = tuplet.ratio; // LilyPond ratio: num/den
1735
1770
  // Check if all inner notes are beamable (division >= 8)
@@ -1891,6 +1926,7 @@ const encode = (doc, options = {}) => {
1891
1926
  const hairpinState = {};
1892
1927
  // Track ottava state across measures for cross-measure ottava spans
1893
1928
  const ottavaState = {};
1929
+ const octaveEndReplacements = {};
1894
1930
  // Initialize clef state from partInfos (convert local staff to global staff)
1895
1931
  const clefState = {};
1896
1932
  for (let pi = 0; pi < partInfos.length; pi++) {
@@ -1907,7 +1943,7 @@ const encode = (doc, options = {}) => {
1907
1943
  for (const event of voice.events) {
1908
1944
  // Check for actual musical content (not just context changes or pitch resets)
1909
1945
  if (event.type === 'note' || event.type === 'rest' ||
1910
- event.type === 'tuplet' || event.type === 'tremolo') {
1946
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
1911
1947
  return true;
1912
1948
  }
1913
1949
  }
@@ -1946,7 +1982,7 @@ const encode = (doc, options = {}) => {
1946
1982
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
1947
1983
  }
1948
1984
  }
1949
- mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
1985
+ mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
1950
1986
  });
1951
1987
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
1952
1988
  mei += `${indent}${indent}${indent}${indent}</score>\n`;
@@ -1954,6 +1990,9 @@ const encode = (doc, options = {}) => {
1954
1990
  mei += `${indent}${indent}</body>\n`;
1955
1991
  mei += `${indent}</music>\n`;
1956
1992
  mei += '</mei>\n';
1993
+ for (const [token, endId] of Object.entries(octaveEndReplacements)) {
1994
+ mei = mei.replaceAll(token, endId);
1995
+ }
1957
1996
  return mei;
1958
1997
  };
1959
1998
  // Escape XML special characters
@@ -0,0 +1,102 @@
1
+ interface Fraction {
2
+ numerator: number;
3
+ denominator: number;
4
+ }
5
+ declare namespace ABC {
6
+ type Token = string;
7
+ interface KeyValue {
8
+ name: string;
9
+ value: any;
10
+ }
11
+ export interface ControlTerm {
12
+ control: KeyValue;
13
+ }
14
+ export interface Triplet {
15
+ triplet: number;
16
+ multiplier?: number;
17
+ n?: number;
18
+ }
19
+ export interface OctaveShift {
20
+ octaveShift: number;
21
+ }
22
+ export interface Fingering {
23
+ fingering: string;
24
+ }
25
+ export interface Tremolo {
26
+ tremolo: number;
27
+ }
28
+ interface Grace {
29
+ grace: boolean;
30
+ acciaccatura: Token;
31
+ events: GraceMusicTerm[];
32
+ }
33
+ interface Comment {
34
+ comment: string;
35
+ }
36
+ export interface Articulation {
37
+ articulation: Token;
38
+ scope?: '(' | ')';
39
+ }
40
+ export type Expressive = Articulation | {
41
+ express: Token;
42
+ };
43
+ export interface TextTerm {
44
+ text: string;
45
+ }
46
+ export interface Pitch {
47
+ acc: number | null;
48
+ phonet: Token;
49
+ quotes: number;
50
+ tie?: boolean;
51
+ }
52
+ export interface Chord {
53
+ pitches: Pitch[];
54
+ tie?: any;
55
+ }
56
+ export interface EventData {
57
+ chord: Chord;
58
+ duration?: Fraction;
59
+ }
60
+ export interface EventTerm {
61
+ event: EventData;
62
+ broken?: number;
63
+ }
64
+ export type MusicTerm = Expressive | TextTerm | EventTerm | Grace | ControlTerm | Triplet | OctaveShift | Fingering | Tremolo;
65
+ export type GraceMusicTerm = Expressive | EventTerm | Fingering;
66
+ type Header = KeyValue | Comment;
67
+ export interface BarPatch {
68
+ control: {
69
+ [k: string]: any;
70
+ };
71
+ terms: MusicTerm[];
72
+ bar: Token;
73
+ }
74
+ export interface StaffGroup {
75
+ items: (StaffGroup | string)[];
76
+ bound?: 'arc' | 'square' | 'curly';
77
+ }
78
+ export interface StaffLayout {
79
+ staffLayout: StaffGroup[];
80
+ }
81
+ export interface KeySignature {
82
+ root: string;
83
+ mode?: string;
84
+ }
85
+ export interface ClefValue {
86
+ clef: string;
87
+ }
88
+ interface Measure {
89
+ index: number;
90
+ voices: BarPatch[];
91
+ }
92
+ interface Body {
93
+ measures: Measure[];
94
+ }
95
+ export interface Tune {
96
+ header: Header[];
97
+ body: Body;
98
+ }
99
+ export type Document = Tune[];
100
+ export {};
101
+ }
102
+ export { ABC, };
@@ -0,0 +1,25 @@
1
+ var ABC;
2
+ (function (ABC) {
3
+ ;
4
+ ;
5
+ ;
6
+ ;
7
+ ;
8
+ ;
9
+ ;
10
+ ;
11
+ ;
12
+ ;
13
+ ;
14
+ ;
15
+ ;
16
+ ;
17
+ ;
18
+ ;
19
+ ;
20
+ ;
21
+ ;
22
+ ;
23
+ ;
24
+ })(ABC || (ABC = {}));
25
+ export { ABC, };
@@ -0,0 +1,3 @@
1
+ import { ABC } from "./abc";
2
+ export declare const parse: (code: string) => ABC.Document;
3
+ export default parse;
@@ -0,0 +1,6 @@
1
+ // @ts-ignore - jison generated file
2
+ import grammar from "./grammar.jison.js";
3
+ export const parse = (code) => {
4
+ return grammar.parse(code);
5
+ };
6
+ export default parse;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ABC Notation Decoder for Lilylet
3
+ *
4
+ * Converts ABC notation files to Lilylet's internal LilyletDoc format.
5
+ */
6
+ import { LilyletDoc } from "./types";
7
+ /**
8
+ * Decode ABC notation string to LilyletDoc.
9
+ * If the ABC contains multiple tunes, only the first is decoded.
10
+ */
11
+ export declare const decode: (abcString: string) => LilyletDoc;
12
+ /**
13
+ * Decode ABC notation string to multiple LilyletDocs (one per tune).
14
+ */
15
+ export declare const decodeAll: (abcString: string) => LilyletDoc[];
16
+ /**
17
+ * Decode an ABC file to LilyletDoc
18
+ */
19
+ export declare const decodeFile: (filePath: string) => Promise<LilyletDoc>;
20
+ declare const _default: {
21
+ decode: (abcString: string) => LilyletDoc;
22
+ decodeAll: (abcString: string) => LilyletDoc[];
23
+ decodeFile: (filePath: string) => Promise<LilyletDoc>;
24
+ };
25
+ export default _default;