@k-l-lambda/lilylet 0.1.60 → 0.1.63
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/grammar.jison.js +273 -169
- package/lib/lilylet/lilypondDecoder.js +163 -39
- package/lib/lilylet/lilypondEncoder.js +120 -7
- package/lib/lilylet/meiEncoder.js +29 -8
- package/lib/lilylet/musicXmlEncoder.js +1 -1
- package/lib/lilylet/parser.d.ts +12 -1
- package/lib/lilylet/parser.js +11 -1
- package/lib/lilylet/serializer.js +33 -4
- package/lib/lilylet/types.d.ts +8 -2
- package/package.json +16 -8
- package/source/abc/TODO.md +97 -0
- 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/grammar.jison.js +273 -169
- package/source/lilylet/lilylet.jison +114 -15
- package/source/lilylet/lilypondDecoder.ts +139 -42
- package/source/lilylet/lilypondEncoder.ts +116 -9
- package/source/lilylet/meiEncoder.ts +32 -9
- package/source/lilylet/musicXmlDecoder.ts +2 -2
- package/source/lilylet/musicXmlEncoder.ts +1 -1
- package/source/lilylet/parser.ts +20 -0
- package/source/lilylet/serializer.ts +31 -6
- package/source/lilylet/types.ts +10 -2
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RestEvent,
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
|
+
TimesEvent,
|
|
18
19
|
TremoloEvent,
|
|
19
20
|
BarlineEvent,
|
|
20
21
|
HarmonyEvent,
|
|
@@ -164,6 +165,53 @@ const getSpacerRest = (timeSig?: { numerator: number; denominator: number }): st
|
|
|
164
165
|
};
|
|
165
166
|
|
|
166
167
|
|
|
168
|
+
// === Partial Measure Helpers ===
|
|
169
|
+
|
|
170
|
+
const TPQN = 480;
|
|
171
|
+
|
|
172
|
+
const voiceDurationTicks = (voice: Voice): number => {
|
|
173
|
+
let ticks = 0;
|
|
174
|
+
const addEvent = (e: Event, factor = 1): void => {
|
|
175
|
+
if (e.type === 'note' || e.type === 'rest') {
|
|
176
|
+
const ev = e as NoteEvent | RestEvent;
|
|
177
|
+
if ((ev as NoteEvent).grace) return;
|
|
178
|
+
let t = (TPQN * 4) / ev.duration.division;
|
|
179
|
+
let dot = t / 2;
|
|
180
|
+
for (let i = 0; i < ev.duration.dots; i++) { t += dot; dot /= 2; }
|
|
181
|
+
ticks += Math.round(t * factor);
|
|
182
|
+
} else if (e.type === 'tuplet' || e.type === 'times') {
|
|
183
|
+
const te = e as TupletEvent;
|
|
184
|
+
const f = factor * te.ratio.numerator / te.ratio.denominator;
|
|
185
|
+
for (const inner of te.events) addEvent(inner as Event, f);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (const e of voice.events) addEvent(e);
|
|
189
|
+
return ticks;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const ticksToLyDuration = (ticks: number): string => {
|
|
193
|
+
const whole = TPQN * 4;
|
|
194
|
+
for (const div of [1, 2, 4, 8, 16, 32, 64]) {
|
|
195
|
+
const t = whole / div;
|
|
196
|
+
if (Math.abs(ticks - t) < 1) return String(div);
|
|
197
|
+
if (Math.abs(ticks - Math.round(t * 1.5)) < 1) return `${div}.`;
|
|
198
|
+
if (Math.abs(ticks - Math.round(t * 1.75)) < 1) return `${div}..`;
|
|
199
|
+
}
|
|
200
|
+
return '4';
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Return a spacer-rest suffix to pad a voice to the full measure duration.
|
|
204
|
+
// Uses s16*N to avoid complexity with dotted/compound values.
|
|
205
|
+
const padVoiceToMeasure = (voiceTicks: number, measureTicks: number): string => {
|
|
206
|
+
const remaining = Math.round(measureTicks - voiceTicks);
|
|
207
|
+
if (remaining <= 0) return '';
|
|
208
|
+
const sixteenth = Math.round(TPQN / 4); // 120 ticks
|
|
209
|
+
const numSixteenths = Math.round(remaining / sixteenth);
|
|
210
|
+
if (numSixteenths <= 0) return '';
|
|
211
|
+
return numSixteenths === 1 ? ' s16' : ` s16*${numSixteenths}`;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
|
|
167
215
|
// === Pitch Environment for Relative Mode ===
|
|
168
216
|
|
|
169
217
|
interface PitchEnv {
|
|
@@ -509,8 +557,12 @@ const encodeContextChange = (event: ContextChange): string => {
|
|
|
509
557
|
/**
|
|
510
558
|
* Encode a tuplet event
|
|
511
559
|
*/
|
|
512
|
-
const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
|
|
513
|
-
|
|
560
|
+
const encodeTupletEvent = (event: TupletEvent | TimesEvent, env: PitchEnv, lastDuration: Duration | null): { str: string; newEnv: PitchEnv; newDuration: Duration | null } => {
|
|
561
|
+
// \times preserves type:"times"; \tuplet is denominator/numerator
|
|
562
|
+
const header = event.type === 'times'
|
|
563
|
+
? `\\times ${event.ratio.numerator}/${event.ratio.denominator}`
|
|
564
|
+
: `\\tuplet ${event.ratio.denominator}/${event.ratio.numerator}`;
|
|
565
|
+
let result = `${header} { `;
|
|
514
566
|
let newEnv = env;
|
|
515
567
|
let newDuration = lastDuration;
|
|
516
568
|
|
|
@@ -525,6 +577,8 @@ const encodeTupletEvent = (event: TupletEvent, env: PitchEnv, lastDuration: Dura
|
|
|
525
577
|
result += str + ' ';
|
|
526
578
|
newEnv = ne;
|
|
527
579
|
newDuration = nd;
|
|
580
|
+
} else if (subEvent.type === 'context') {
|
|
581
|
+
result += encodeContextChange(subEvent) + ' ';
|
|
528
582
|
}
|
|
529
583
|
}
|
|
530
584
|
|
|
@@ -650,8 +704,9 @@ const encodeVoice = (
|
|
|
650
704
|
result += encodeContextChange(event) + ' ';
|
|
651
705
|
break;
|
|
652
706
|
}
|
|
653
|
-
case 'tuplet':
|
|
654
|
-
|
|
707
|
+
case 'tuplet':
|
|
708
|
+
case 'times': {
|
|
709
|
+
const { str, newEnv, newDuration } = encodeTupletEvent(event as TupletEvent | TimesEvent, env, lastDuration);
|
|
655
710
|
result += str + ' ';
|
|
656
711
|
env = newEnv;
|
|
657
712
|
lastDuration = newDuration;
|
|
@@ -740,7 +795,7 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
740
795
|
for (const part of measure.parts) {
|
|
741
796
|
for (const voice of part.voices) {
|
|
742
797
|
for (const event of voice.events) {
|
|
743
|
-
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'tremolo') {
|
|
798
|
+
if (event.type === 'note' || event.type === 'rest' || event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
744
799
|
return true;
|
|
745
800
|
}
|
|
746
801
|
}
|
|
@@ -769,6 +824,8 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
769
824
|
|
|
770
825
|
// Track time signature for each measure (for spacer rests)
|
|
771
826
|
const measureTimeSigs: Array<{ numerator: number; denominator: number } | undefined> = [];
|
|
827
|
+
// Track partial (pickup) measure durations as LY duration strings (e.g. "8" for \partial 8)
|
|
828
|
+
const measurePartialDurs: Array<string | undefined> = [];
|
|
772
829
|
|
|
773
830
|
let currentKey: KeySignature | undefined;
|
|
774
831
|
let currentTimeSig: { numerator: number; denominator: number } | undefined;
|
|
@@ -783,6 +840,29 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
783
840
|
// Store time signature for this measure
|
|
784
841
|
measureTimeSigs[mi] = currentTimeSig;
|
|
785
842
|
|
|
843
|
+
// Detect partial (pickup) measures and compute their duration.
|
|
844
|
+
// Only the first measure (mi===0) can be an implicit partial;
|
|
845
|
+
// subsequent incomplete measures are NOT treated as partial.
|
|
846
|
+
const isExplicitPartial = measure.partial === true;
|
|
847
|
+
if (isExplicitPartial || (mi === 0 && currentTimeSig)) {
|
|
848
|
+
let maxVoiceTicks = 0;
|
|
849
|
+
for (const part of measure.parts) {
|
|
850
|
+
for (const v of part.voices) {
|
|
851
|
+
maxVoiceTicks = Math.max(maxVoiceTicks, voiceDurationTicks(v));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const expectedTicks = currentTimeSig
|
|
855
|
+
? Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator)
|
|
856
|
+
: TPQN * 4;
|
|
857
|
+
if (maxVoiceTicks > 0 && maxVoiceTicks < expectedTicks) {
|
|
858
|
+
measurePartialDurs[mi] = ticksToLyDuration(maxVoiceTicks);
|
|
859
|
+
} else {
|
|
860
|
+
measurePartialDurs[mi] = undefined;
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
measurePartialDurs[mi] = undefined;
|
|
864
|
+
}
|
|
865
|
+
|
|
786
866
|
// Process each part
|
|
787
867
|
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
788
868
|
const part = measure.parts[pi];
|
|
@@ -803,12 +883,21 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
803
883
|
}
|
|
804
884
|
|
|
805
885
|
// Encode voice content
|
|
806
|
-
|
|
886
|
+
let voiceContent = encodeVoice(voice, {
|
|
807
887
|
key: currentKey,
|
|
808
888
|
timeSig: currentTimeSig,
|
|
809
889
|
isFirst: mi === 0
|
|
810
890
|
}, vi);
|
|
811
891
|
|
|
892
|
+
// For non-partial measures, pad incomplete voices to fill the full
|
|
893
|
+
// measure duration. lotus doesn't auto-advance on barlines, so
|
|
894
|
+
// under-full voices cause measure boundary miscounting.
|
|
895
|
+
if (!measurePartialDurs[mi] && currentTimeSig) {
|
|
896
|
+
const expectedTicks = Math.round(TPQN * 4 * currentTimeSig.numerator / currentTimeSig.denominator);
|
|
897
|
+
const voiceTicks = voiceDurationTicks(voice);
|
|
898
|
+
voiceContent += padVoiceToMeasure(voiceTicks, expectedTicks);
|
|
899
|
+
}
|
|
900
|
+
|
|
812
901
|
staffMeasures[mi].push(voiceContent);
|
|
813
902
|
}
|
|
814
903
|
}
|
|
@@ -823,10 +912,28 @@ export const encode = (doc: LilyletDoc, options: RenderOptions = {}): string =>
|
|
|
823
912
|
// Build voice lines
|
|
824
913
|
const voiceLines: string[] = [];
|
|
825
914
|
for (let vi = 0; vi < maxVoices; vi++) {
|
|
915
|
+
let prevTimeSigStr: string | undefined;
|
|
826
916
|
const measureContents = measures.map((m, mi) => {
|
|
827
|
-
//
|
|
828
|
-
const
|
|
829
|
-
const
|
|
917
|
+
// For partial (pickup) measures, use a partial-duration spacer
|
|
918
|
+
const partialDur = measurePartialDurs[mi];
|
|
919
|
+
const spacer = partialDur ? `s${partialDur}` : getSpacerRest(measureTimeSigs[mi]);
|
|
920
|
+
let content = m[vi] || spacer;
|
|
921
|
+
// Inject \time if the content lacks it and the time sig changed.
|
|
922
|
+
// lotus processes each voice independently — without \time it
|
|
923
|
+
// defaults to 4/4, miscounting measure boundaries for other meters.
|
|
924
|
+
const ts = measureTimeSigs[mi];
|
|
925
|
+
if (ts) {
|
|
926
|
+
const tsStr = `${ts.numerator}/${ts.denominator}`;
|
|
927
|
+
if (tsStr !== prevTimeSigStr && !content.includes('\\time')) {
|
|
928
|
+
content = `${encodeTimeSig(ts)} ${content}`;
|
|
929
|
+
}
|
|
930
|
+
prevTimeSigStr = tsStr;
|
|
931
|
+
}
|
|
932
|
+
// For partial measures, prepend \partial DUR before all other commands
|
|
933
|
+
// so the lotus interpreter correctly tracks the pickup measure boundary.
|
|
934
|
+
if (partialDur && !content.includes('\\partial')) {
|
|
935
|
+
content = `\\partial ${partialDur} ${content}`;
|
|
936
|
+
}
|
|
830
937
|
// Wrap each measure in its own \relative c' to reset pitch context
|
|
831
938
|
return `${indent} \\relative c' { ${content} } | % ${mi + 1}`;
|
|
832
939
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RestEvent,
|
|
8
8
|
ContextChange,
|
|
9
9
|
TupletEvent,
|
|
10
|
+
TimesEvent,
|
|
10
11
|
TremoloEvent,
|
|
11
12
|
BarlineEvent,
|
|
12
13
|
HarmonyEvent,
|
|
@@ -654,6 +655,7 @@ interface TupletEventResult {
|
|
|
654
655
|
mordents: MordentRef[];
|
|
655
656
|
turns: TurnRef[];
|
|
656
657
|
arpeggios: ArpegRef[];
|
|
658
|
+
endingClef?: string; // Updated clef name if changed inside the tuplet
|
|
657
659
|
}
|
|
658
660
|
|
|
659
661
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
@@ -672,7 +674,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
|
|
|
672
674
|
};
|
|
673
675
|
|
|
674
676
|
// Convert TupletEvent to MEI
|
|
675
|
-
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string
|
|
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 => {
|
|
676
678
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
677
679
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
678
680
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -700,6 +702,8 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
700
702
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
701
703
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
702
704
|
let beamOpen = false;
|
|
705
|
+
let activeClef = currentClef;
|
|
706
|
+
let endingClef: string | undefined;
|
|
703
707
|
|
|
704
708
|
for (const e of event.events) {
|
|
705
709
|
if (e.type === 'note') {
|
|
@@ -743,6 +747,19 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
743
747
|
} else if (e.type === 'rest') {
|
|
744
748
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
745
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
|
+
}
|
|
746
763
|
}
|
|
747
764
|
}
|
|
748
765
|
|
|
@@ -752,7 +769,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
752
769
|
}
|
|
753
770
|
|
|
754
771
|
xml += `${indent}</tuplet>\n`;
|
|
755
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
772
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
756
773
|
};
|
|
757
774
|
|
|
758
775
|
|
|
@@ -943,12 +960,12 @@ interface LayerResult {
|
|
|
943
960
|
|
|
944
961
|
|
|
945
962
|
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
946
|
-
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
963
|
+
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
947
964
|
if (event.type === 'note') {
|
|
948
965
|
const markOptions = extractMarkOptions((event as NoteEvent).marks);
|
|
949
966
|
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
950
967
|
}
|
|
951
|
-
if (event.type === 'tuplet') {
|
|
968
|
+
if (event.type === 'tuplet' || event.type === 'times') {
|
|
952
969
|
const tuplet = event as TupletEvent;
|
|
953
970
|
// If the tuplet has internal beam groups, don't report beam marks to the parent
|
|
954
971
|
// so the parent won't wrap the tuplet in an external <beam>
|
|
@@ -1209,12 +1226,18 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1209
1226
|
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1210
1227
|
break;
|
|
1211
1228
|
}
|
|
1212
|
-
case 'tuplet':
|
|
1229
|
+
case 'tuplet':
|
|
1230
|
+
case 'times': {
|
|
1213
1231
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1214
1232
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1215
|
-
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
1233
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals, currentClef);
|
|
1216
1234
|
xml += tupletResult.xml;
|
|
1217
1235
|
|
|
1236
|
+
// Propagate clef change from inside the tuplet to the parent tracker
|
|
1237
|
+
if (tupletResult.endingClef) {
|
|
1238
|
+
currentClef = tupletResult.endingClef as Clef;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1218
1241
|
// Flush any pending markups onto the first note of the tuplet
|
|
1219
1242
|
if (tupletResult.firstNoteId) {
|
|
1220
1243
|
flushPendingMarkups(tupletResult.firstNoteId);
|
|
@@ -1943,7 +1966,7 @@ const docHasBeamMarks = (doc: LilyletDoc): boolean => {
|
|
|
1943
1966
|
if (m.markType === 'beam') return true;
|
|
1944
1967
|
}
|
|
1945
1968
|
}
|
|
1946
|
-
} else if (event.type === 'tuplet') {
|
|
1969
|
+
} else if (event.type === 'tuplet' || event.type === 'times') {
|
|
1947
1970
|
const tuplet = event as TupletEvent;
|
|
1948
1971
|
for (const e of tuplet.events) {
|
|
1949
1972
|
if (e.type === 'note') {
|
|
@@ -2118,7 +2141,7 @@ const applyAutoBeamToVoice = (events: Event[], beamGroups: number[]): void => {
|
|
|
2118
2141
|
// Rests break beam groups
|
|
2119
2142
|
flushRun();
|
|
2120
2143
|
position += dur;
|
|
2121
|
-
} else if (event.type === 'tuplet') {
|
|
2144
|
+
} else if (event.type === 'tuplet' || event.type === 'times') {
|
|
2122
2145
|
const tuplet = event as TupletEvent;
|
|
2123
2146
|
const ratio = tuplet.ratio; // LilyPond ratio: num/den
|
|
2124
2147
|
|
|
@@ -2320,7 +2343,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2320
2343
|
for (const event of voice.events) {
|
|
2321
2344
|
// Check for actual musical content (not just context changes or pitch resets)
|
|
2322
2345
|
if (event.type === 'note' || event.type === 'rest' ||
|
|
2323
|
-
event.type === 'tuplet' || event.type === 'tremolo') {
|
|
2346
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2324
2347
|
return true;
|
|
2325
2348
|
}
|
|
2326
2349
|
}
|
|
@@ -1201,9 +1201,9 @@ const convertMeasure = (
|
|
|
1201
1201
|
// Calculate total duration of tuplet for voiceTracker
|
|
1202
1202
|
let totalDuration = 0;
|
|
1203
1203
|
for (const evt of tupletEvent.events) {
|
|
1204
|
-
if (evt.duration) {
|
|
1204
|
+
if ((evt as NoteEvent | RestEvent).duration) {
|
|
1205
1205
|
// Convert division to duration units (quarter = 1)
|
|
1206
|
-
totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
|
|
1206
|
+
totalDuration += (4 / (evt as NoteEvent | RestEvent).duration.division) * voiceTracker.getDivisions();
|
|
1207
1207
|
}
|
|
1208
1208
|
}
|
|
1209
1209
|
// Apply tuplet ratio to get actual duration
|
|
@@ -754,7 +754,7 @@ const encodeMeasure = (
|
|
|
754
754
|
}
|
|
755
755
|
|
|
756
756
|
case 'tuplet': {
|
|
757
|
-
const tupletEvents = event.events;
|
|
757
|
+
const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest') as (NoteEvent | RestEvent)[];
|
|
758
758
|
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
759
759
|
const subEvent = tupletEvents[ti];
|
|
760
760
|
// Set tuplet ratio on duration so encodeDuration emits <time-modification>
|
package/source/lilylet/parser.ts
CHANGED
|
@@ -157,6 +157,14 @@ const resolveDocumentPitches = (doc: LilyletDoc): void => {
|
|
|
157
157
|
};
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
export interface ParseWarning {
|
|
161
|
+
type: 'partial-mismatch';
|
|
162
|
+
message: string;
|
|
163
|
+
declared: number; // ticks
|
|
164
|
+
actual: number; // ticks
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
160
168
|
const parseCode = (code: string): LilyletDoc => {
|
|
161
169
|
// Reset parser state before each parse to avoid contamination
|
|
162
170
|
if (parser && (parser as any).resetState) {
|
|
@@ -172,7 +180,19 @@ const parseCode = (code: string): LilyletDoc => {
|
|
|
172
180
|
};
|
|
173
181
|
|
|
174
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Return warnings emitted during the most recent parseCode() call.
|
|
185
|
+
* Currently reports \partial duration mismatches.
|
|
186
|
+
*/
|
|
187
|
+
const getParseWarnings = (): ParseWarning[] => {
|
|
188
|
+
if (parser && (parser as any).getWarnings) {
|
|
189
|
+
return (parser as any).getWarnings() as ParseWarning[];
|
|
190
|
+
}
|
|
191
|
+
return [];
|
|
192
|
+
};
|
|
193
|
+
|
|
175
194
|
|
|
176
195
|
export {
|
|
177
196
|
parseCode,
|
|
197
|
+
getParseWarnings,
|
|
178
198
|
};
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RestEvent,
|
|
16
16
|
ContextChange,
|
|
17
17
|
TupletEvent,
|
|
18
|
+
TimesEvent,
|
|
18
19
|
TremoloEvent,
|
|
19
20
|
BarlineEvent,
|
|
20
21
|
Pitch,
|
|
@@ -400,6 +401,11 @@ const serializeContextChange = (event: ContextChange): string => {
|
|
|
400
401
|
parts.push('\\time ' + event.time.numerator + '/' + event.time.denominator);
|
|
401
402
|
}
|
|
402
403
|
|
|
404
|
+
// Partial (pickup measure duration check)
|
|
405
|
+
if (event.partial) {
|
|
406
|
+
parts.push('\\partial ' + event.partial.division + '.'.repeat(event.partial.dots || 0));
|
|
407
|
+
}
|
|
408
|
+
|
|
403
409
|
// Ottava
|
|
404
410
|
if (event.ottava !== undefined) {
|
|
405
411
|
if (event.ottava === 0) {
|
|
@@ -447,14 +453,17 @@ const serializeTempo = (tempo: Tempo): string => {
|
|
|
447
453
|
|
|
448
454
|
// Serialize a tuplet event with pitch environment tracking
|
|
449
455
|
const serializeTupletEvent = (
|
|
450
|
-
event: TupletEvent,
|
|
456
|
+
event: TupletEvent | TimesEvent,
|
|
451
457
|
env: PitchEnv
|
|
452
458
|
): { str: string; newEnv: PitchEnv } => {
|
|
453
459
|
const parts: string[] = [];
|
|
454
460
|
let currentEnv = env;
|
|
455
461
|
|
|
456
|
-
// \times numerator/denominator
|
|
457
|
-
|
|
462
|
+
// \tuplet denominator/numerator { ... } for tuplet type, \times numerator/denominator for times type
|
|
463
|
+
const keyword = event.type === 'times'
|
|
464
|
+
? '\\times ' + event.ratio.numerator + '/' + event.ratio.denominator
|
|
465
|
+
: '\\tuplet ' + event.ratio.denominator + '/' + event.ratio.numerator;
|
|
466
|
+
parts.push(keyword + ' {');
|
|
458
467
|
|
|
459
468
|
let prevDuration: Duration | undefined;
|
|
460
469
|
for (const e of event.events) {
|
|
@@ -468,6 +477,15 @@ const serializeTupletEvent = (
|
|
|
468
477
|
parts.push(' ' + str);
|
|
469
478
|
currentEnv = newEnv;
|
|
470
479
|
prevDuration = (e as RestEvent).duration;
|
|
480
|
+
} else if (e.type === 'context') {
|
|
481
|
+
const ctx = e as ContextChange;
|
|
482
|
+
if (ctx.staff != null) {
|
|
483
|
+
parts.push(' \\staff "' + ctx.staff + '"');
|
|
484
|
+
} else if (ctx.stemDirection != null) {
|
|
485
|
+
if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
|
|
486
|
+
else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
|
|
487
|
+
else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
|
|
488
|
+
}
|
|
471
489
|
}
|
|
472
490
|
}
|
|
473
491
|
|
|
@@ -554,7 +572,8 @@ const serializeEvent = (
|
|
|
554
572
|
case 'context':
|
|
555
573
|
return { str: serializeContextChange(event as ContextChange), newEnv: env };
|
|
556
574
|
case 'tuplet':
|
|
557
|
-
|
|
575
|
+
case 'times':
|
|
576
|
+
return serializeTupletEvent(event as TupletEvent | TimesEvent, env);
|
|
558
577
|
case 'tremolo':
|
|
559
578
|
return serializeTremoloEvent(event as TremoloEvent, env);
|
|
560
579
|
case 'barline':
|
|
@@ -612,7 +631,7 @@ const serializeVoice = (
|
|
|
612
631
|
// before any music collapse to the last one (earlier ones are no-ops).
|
|
613
632
|
// leadStaffScanEnd is the index of the first event that ends this scan —
|
|
614
633
|
// context{staff} events before this index are skipped in the main loop.
|
|
615
|
-
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'tremolo']);
|
|
634
|
+
const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
|
|
616
635
|
let effectiveInitialStaff = voice.staff;
|
|
617
636
|
let leadStaffScanEnd = 0;
|
|
618
637
|
for (let i = 0; i < voice.events.length; i++) {
|
|
@@ -749,6 +768,12 @@ const serializeVoice = (
|
|
|
749
768
|
prevDuration = (event as NoteEvent).duration;
|
|
750
769
|
} else if (event.type === 'rest') {
|
|
751
770
|
prevDuration = (event as RestEvent).duration;
|
|
771
|
+
} else if (event.type === 'tuplet' || event.type === 'times') {
|
|
772
|
+
// After a tuplet/times block the LilyPond parser's "current duration" is the
|
|
773
|
+
// last note duration inside the tuplet, not the duration before the tuplet.
|
|
774
|
+
// Reset prevDuration so the first note after the block always emits its
|
|
775
|
+
// duration explicitly, avoiding wrong inheritance from inside the tuplet.
|
|
776
|
+
prevDuration = undefined;
|
|
752
777
|
} else if (event.type === 'context' && (event as ContextChange).clef && emittedClefs) {
|
|
753
778
|
const ctx = event as ContextChange;
|
|
754
779
|
emittedClefs[ctx.staff || activeStaff] = ctx.clef!;
|
|
@@ -836,7 +861,7 @@ const serializeMeasure = (
|
|
|
836
861
|
}
|
|
837
862
|
staff = newStaff;
|
|
838
863
|
}
|
|
839
|
-
parts.push(partStrs.join('
|
|
864
|
+
parts.push(partStrs.join(' \\\\\\\n'));
|
|
840
865
|
}
|
|
841
866
|
|
|
842
867
|
return { str: parts.join(' '), newStaff: staff };
|
package/source/lilylet/types.ts
CHANGED
|
@@ -229,6 +229,7 @@ export interface ContextChange {
|
|
|
229
229
|
type: 'context';
|
|
230
230
|
key?: KeySignature;
|
|
231
231
|
time?: Fraction;
|
|
232
|
+
partial?: Duration; // Pickup measure duration (check-only, warns if mismatch)
|
|
232
233
|
clef?: Clef;
|
|
233
234
|
ottava?: number; // -1, 0, 1
|
|
234
235
|
stemDirection?: StemDirection;
|
|
@@ -247,7 +248,14 @@ export interface TremoloEvent {
|
|
|
247
248
|
export interface TupletEvent {
|
|
248
249
|
type: 'tuplet';
|
|
249
250
|
ratio: Fraction; // e.g., {numerator: 2, denominator: 3} for triplet
|
|
250
|
-
events: (NoteEvent | RestEvent)[];
|
|
251
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// TimesEvent: from lilylet \times syntax (distinct from \tuplet decoded from LilyPond)
|
|
255
|
+
export interface TimesEvent {
|
|
256
|
+
type: 'times';
|
|
257
|
+
ratio: Fraction;
|
|
258
|
+
events: (NoteEvent | RestEvent | ContextChange)[];
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
export interface PitchResetEvent {
|
|
@@ -270,7 +278,7 @@ export interface MarkupEvent {
|
|
|
270
278
|
placement?: Placement; // Optional placement (above/below)
|
|
271
279
|
}
|
|
272
280
|
|
|
273
|
-
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
281
|
+
export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
|
|
274
282
|
|
|
275
283
|
// === Structure ===
|
|
276
284
|
|