@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.
- package/lib/abc/grammar.jison.js +300 -187
- package/lib/lilylet/abcDecoder.js +40 -12
- package/lib/lilylet/lilypondEncoder.js +3 -0
- package/lib/lilylet/meiEncoder.js +87 -48
- package/lib/source/abc/abc.d.ts +102 -0
- package/lib/source/abc/abc.js +25 -0
- package/lib/source/abc/parser.d.ts +3 -0
- package/lib/source/abc/parser.js +6 -0
- package/lib/source/lilylet/abcDecoder.d.ts +25 -0
- package/lib/source/lilylet/abcDecoder.js +1035 -0
- package/lib/source/lilylet/index.d.ts +10 -0
- package/lib/source/lilylet/index.js +10 -0
- package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/source/lilylet/lilypondDecoder.js +1223 -0
- package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/source/lilylet/lilypondEncoder.js +893 -0
- package/lib/source/lilylet/meiEncoder.d.ts +8 -0
- package/lib/source/lilylet/meiEncoder.js +1985 -0
- package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/source/lilylet/musicXmlEncoder.js +701 -0
- package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/source/lilylet/musicXmlTypes.js +7 -0
- package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/source/lilylet/musicXmlUtils.js +469 -0
- package/lib/source/lilylet/parser.d.ts +14 -0
- package/lib/source/lilylet/parser.js +161 -0
- package/lib/source/lilylet/serializer.d.ts +11 -0
- package/lib/source/lilylet/serializer.js +791 -0
- package/lib/source/lilylet/types.d.ts +253 -0
- package/lib/source/lilylet/types.js +100 -0
- package/lib/tests/abc-abcjs-parse.d.ts +8 -0
- package/lib/tests/abc-abcjs-parse.js +90 -0
- package/lib/tests/abc-abcjs-svg.d.ts +1 -0
- package/lib/tests/abc-abcjs-svg.js +143 -0
- package/lib/tests/abc-decoder.d.ts +1 -0
- package/lib/tests/abc-decoder.js +67 -0
- package/lib/tests/abc-mei-compare.d.ts +1 -0
- package/lib/tests/abc-mei-compare.js +525 -0
- package/lib/tests/auto-beam.d.ts +9 -0
- package/lib/tests/auto-beam.js +151 -0
- package/lib/tests/computeMeiHashes.d.ts +1 -0
- package/lib/tests/computeMeiHashes.js +87 -0
- package/lib/tests/encoder-mutation.d.ts +9 -0
- package/lib/tests/encoder-mutation.js +110 -0
- package/lib/tests/gpt-review-issues.d.ts +5 -0
- package/lib/tests/gpt-review-issues.js +255 -0
- package/lib/tests/json-to-lyl.d.ts +1 -0
- package/lib/tests/json-to-lyl.js +18 -0
- package/lib/tests/lilypond-roundtrip.d.ts +7 -0
- package/lib/tests/lilypond-roundtrip.js +558 -0
- package/lib/tests/lilypondDecoder.d.ts +6 -0
- package/lib/tests/lilypondDecoder.js +95 -0
- package/lib/tests/ly-to-lyl.d.ts +1 -0
- package/lib/tests/ly-to-lyl.js +12 -0
- package/lib/tests/mei.d.ts +1 -0
- package/lib/tests/mei.js +278 -0
- package/lib/tests/musicxml-decoder.d.ts +4 -0
- package/lib/tests/musicxml-decoder.js +61 -0
- package/lib/tests/musicxml-detail.d.ts +4 -0
- package/lib/tests/musicxml-detail.js +85 -0
- package/lib/tests/musicxml-fprod.d.ts +9 -0
- package/lib/tests/musicxml-fprod.js +153 -0
- package/lib/tests/musicxml-roundtrip.d.ts +7 -0
- package/lib/tests/musicxml-roundtrip.js +296 -0
- package/lib/tests/musicxml-to-mei.d.ts +6 -0
- package/lib/tests/musicxml-to-mei.js +115 -0
- package/lib/tests/parser.d.ts +1 -0
- package/lib/tests/parser.js +17 -0
- package/lib/tests/render-k283.d.ts +1 -0
- package/lib/tests/render-k283.js +33 -0
- package/lib/tests/render-lyl.d.ts +1 -0
- package/lib/tests/render-lyl.js +35 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
- package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
- package/lib/tests/unit/gptReviewIssues.test.js +240 -0
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
- package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
- package/lib/tests/unit/partialWarning.test.d.ts +4 -0
- package/lib/tests/unit/partialWarning.test.js +65 -0
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
- package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
- package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
- package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
- package/package.json +5 -2
- package/source/abc/abc.jison +90 -15
- package/source/abc/grammar.jison.js +300 -187
- package/source/lilylet/abcDecoder.ts +42 -14
- package/source/lilylet/lilypondEncoder.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
866
|
+
properties: voiceValue?.properties,
|
|
839
867
|
});
|
|
840
868
|
if (clefStr) {
|
|
841
869
|
const clef = convertClef(clefStr);
|
|
842
870
|
if (clef)
|
|
843
|
-
voiceClefs.set(
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|
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
|
-
//
|
|
1074
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1347
|
-
|
|
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
|
+
/**
|
|
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;
|