@k-l-lambda/lilylet 0.1.33 → 0.1.35
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/grammar.jison.js +179 -150
- package/lib/meiEncoder.js +62 -47
- package/lib/musicXmlDecoder.js +108 -4
- package/lib/serializer.js +7 -1
- package/lib/types.d.ts +4 -1
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +179 -150
- package/source/lilylet/lilylet.jison +30 -2
- package/source/lilylet/meiEncoder.ts +64 -52
- package/source/lilylet/musicXmlDecoder.ts +125 -4
- package/source/lilylet/serializer.ts +8 -2
- package/source/lilylet/types.ts +7 -1
- package/lib/lilypondDecoder.d.ts +0 -28
- package/lib/lilypondDecoder.js +0 -645
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
let currentKey = null;
|
|
129
129
|
let currentTimeSig = null;
|
|
130
130
|
let currentDuration = { division: 4, dots: 0 }; // default quarter note
|
|
131
|
+
let numericTimeSignature = false; // When true, 4/4 and 2/2 use numeric display instead of C/C|
|
|
131
132
|
|
|
132
133
|
// Reset parser state - call before each parse
|
|
133
134
|
const resetParserState = () => {
|
|
@@ -135,6 +136,7 @@
|
|
|
135
136
|
currentKey = null;
|
|
136
137
|
currentTimeSig = null;
|
|
137
138
|
currentDuration = { division: 4, dots: 0 };
|
|
139
|
+
numericTimeSignature = false;
|
|
138
140
|
};
|
|
139
141
|
|
|
140
142
|
// Export reset function
|
|
@@ -168,6 +170,8 @@
|
|
|
168
170
|
"\\clef" return 'CMD_CLEF'
|
|
169
171
|
"\\key" return 'CMD_KEY'
|
|
170
172
|
"\\time" return 'CMD_TIME'
|
|
173
|
+
"\\numericTimeSignature" return 'CMD_NUMERIC_TIME_SIG'
|
|
174
|
+
"\\defaultTimeSignature" return 'CMD_DEFAULT_TIME_SIG'
|
|
171
175
|
"\\tempo" return 'CMD_TEMPO'
|
|
172
176
|
"\\staff" return 'CMD_STAFF'
|
|
173
177
|
"\\grace" return 'CMD_GRACE'
|
|
@@ -329,7 +333,7 @@ part_voices
|
|
|
329
333
|
|
|
330
334
|
voice_events
|
|
331
335
|
: /* empty */ { $$ = []; }
|
|
332
|
-
| voice_events event { $$ = $1.concat(Array.isArray($2) ? $2 : [$2]); }
|
|
336
|
+
| voice_events event { $$ = $2 === null ? $1 : $1.concat(Array.isArray($2) ? $2 : [$2]); }
|
|
333
337
|
;
|
|
334
338
|
|
|
335
339
|
event
|
|
@@ -413,6 +417,8 @@ context_event
|
|
|
413
417
|
| staff_cmd -> contextChange({ staff: $1 })
|
|
414
418
|
| ottava_cmd -> contextChange({ ottava: $1 })
|
|
415
419
|
| stem_cmd -> contextChange({ stemDirection: $1 })
|
|
420
|
+
| numeric_time_sig_cmd -> null
|
|
421
|
+
| default_time_sig_cmd -> null
|
|
416
422
|
;
|
|
417
423
|
|
|
418
424
|
clef_cmd
|
|
@@ -433,7 +439,29 @@ mode
|
|
|
433
439
|
;
|
|
434
440
|
|
|
435
441
|
time_cmd
|
|
436
|
-
: CMD_TIME NUMBER '/' NUMBER %{
|
|
442
|
+
: CMD_TIME NUMBER '/' NUMBER %{
|
|
443
|
+
const num = Number($2);
|
|
444
|
+
const den = Number($4);
|
|
445
|
+
const timeSig = fraction(num, den);
|
|
446
|
+
// Add symbol for 4/4 (common) and 2/2 (cut) unless numericTimeSignature is set
|
|
447
|
+
if (!numericTimeSignature) {
|
|
448
|
+
if (num === 4 && den === 4) {
|
|
449
|
+
timeSig.symbol = 'common';
|
|
450
|
+
} else if (num === 2 && den === 2) {
|
|
451
|
+
timeSig.symbol = 'cut';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
currentTimeSig = timeSig;
|
|
455
|
+
$$ = currentTimeSig;
|
|
456
|
+
%}
|
|
457
|
+
;
|
|
458
|
+
|
|
459
|
+
numeric_time_sig_cmd
|
|
460
|
+
: CMD_NUMERIC_TIME_SIG %{ numericTimeSignature = true; $$ = null; %}
|
|
461
|
+
;
|
|
462
|
+
|
|
463
|
+
default_time_sig_cmd
|
|
464
|
+
: CMD_DEFAULT_TIME_SIG %{ numericTimeSignature = false; $$ = null; %}
|
|
437
465
|
;
|
|
438
466
|
|
|
439
467
|
tempo_cmd
|
|
@@ -609,7 +609,7 @@ interface TupletEventResult {
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
// Convert TupletEvent to MEI
|
|
612
|
-
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0): TupletEventResult => {
|
|
612
|
+
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
|
|
613
613
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
614
614
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
615
615
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -618,7 +618,6 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
618
618
|
|
|
619
619
|
let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
|
|
620
620
|
|
|
621
|
-
let inBeam = false;
|
|
622
621
|
const baseIndent = indent + ' ';
|
|
623
622
|
|
|
624
623
|
// Effective staff for cross-staff notation
|
|
@@ -634,31 +633,18 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
634
633
|
const turns: TurnRef[] = [];
|
|
635
634
|
const arpeggios: ArpegRef[] = [];
|
|
636
635
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
let beamEnd = false;
|
|
641
|
-
if (e.type === 'note') {
|
|
642
|
-
const markOptions = extractMarkOptions((e as NoteEvent).marks);
|
|
643
|
-
beamStart = markOptions.beamStart;
|
|
644
|
-
beamEnd = markOptions.beamEnd;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Open beam element if beam starts
|
|
648
|
-
if (beamStart && !inBeam) {
|
|
649
|
-
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
650
|
-
inBeam = true;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
|
|
636
|
+
// If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
|
|
637
|
+
// MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
|
|
638
|
+
// Beam state is managed by encodeLayer, not here.
|
|
654
639
|
|
|
640
|
+
for (const e of event.events) {
|
|
655
641
|
if (e.type === 'note') {
|
|
656
642
|
// For cross-staff notation: set note's staff if different from layerStaff
|
|
657
643
|
const noteEvent = e as NoteEvent;
|
|
658
644
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
659
645
|
? { ...noteEvent, staff: effectiveStaff }
|
|
660
646
|
: noteEvent;
|
|
661
|
-
const result = noteEventToMEI(effectiveNoteEvent,
|
|
647
|
+
const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
662
648
|
xml += result.xml;
|
|
663
649
|
|
|
664
650
|
// Collect slur info
|
|
@@ -673,21 +659,10 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
673
659
|
if (result.turn) turns.push({ startid: result.elementId });
|
|
674
660
|
if (result.arpeggio) arpeggios.push({ plist: result.elementId });
|
|
675
661
|
} else if (e.type === 'rest') {
|
|
676
|
-
xml += restEventToMEI(e as RestEvent,
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Close beam element if beam ends
|
|
680
|
-
if (beamEnd && inBeam) {
|
|
681
|
-
xml += `${baseIndent}</beam>\n`;
|
|
682
|
-
inBeam = false;
|
|
662
|
+
xml += restEventToMEI(e as RestEvent, baseIndent, keyFifths, ottavaShift);
|
|
683
663
|
}
|
|
684
664
|
}
|
|
685
665
|
|
|
686
|
-
// Close any unclosed beam
|
|
687
|
-
if (inBeam) {
|
|
688
|
-
xml += `${baseIndent}</beam>\n`;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
666
|
xml += `${indent}</tuplet>\n`;
|
|
692
667
|
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
693
668
|
};
|
|
@@ -865,12 +840,35 @@ interface LayerResult {
|
|
|
865
840
|
endingClef?: Clef; // For cross-measure clef tracking
|
|
866
841
|
}
|
|
867
842
|
|
|
843
|
+
|
|
844
|
+
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
845
|
+
const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
|
|
846
|
+
if (event.type === 'note') {
|
|
847
|
+
const markOptions = extractMarkOptions((event as NoteEvent).marks);
|
|
848
|
+
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
849
|
+
}
|
|
850
|
+
if (event.type === 'tuplet') {
|
|
851
|
+
const tuplet = event as TupletEvent;
|
|
852
|
+
let beamStart = false;
|
|
853
|
+
let beamEnd = false;
|
|
854
|
+
for (const e of tuplet.events) {
|
|
855
|
+
if (e.type === 'note') {
|
|
856
|
+
const markOptions = extractMarkOptions((e as NoteEvent).marks);
|
|
857
|
+
if (markOptions.beamStart) beamStart = true;
|
|
858
|
+
if (markOptions.beamEnd) beamEnd = true;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return { beamStart, beamEnd };
|
|
862
|
+
}
|
|
863
|
+
return { beamStart: false, beamEnd: false };
|
|
864
|
+
};
|
|
865
|
+
|
|
868
866
|
// Encode a layer (voice)
|
|
869
867
|
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null): LayerResult => {
|
|
870
868
|
const layerId = generateId("layer");
|
|
871
869
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
872
870
|
|
|
873
|
-
let
|
|
871
|
+
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
874
872
|
const baseIndent = indent + ' ';
|
|
875
873
|
|
|
876
874
|
// Track current clef to only emit changes
|
|
@@ -928,23 +926,16 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
928
926
|
};
|
|
929
927
|
|
|
930
928
|
for (const event of voice.events) {
|
|
931
|
-
// Check for beam start/end in
|
|
932
|
-
|
|
933
|
-
let beamEnd = false;
|
|
934
|
-
if (event.type === 'note') {
|
|
935
|
-
const noteEvent = event as NoteEvent;
|
|
936
|
-
const markOptions = extractMarkOptions(noteEvent.marks);
|
|
937
|
-
beamStart = markOptions.beamStart;
|
|
938
|
-
beamEnd = markOptions.beamEnd;
|
|
939
|
-
}
|
|
929
|
+
// Check for beam start/end in this event (including inside tuplets)
|
|
930
|
+
const { beamStart, beamEnd } = getEventBeamMarks(event);
|
|
940
931
|
|
|
941
932
|
// Open beam element if beam starts
|
|
942
|
-
if (beamStart && !
|
|
933
|
+
if (beamStart && !beamElementOpen) {
|
|
943
934
|
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
944
|
-
|
|
935
|
+
beamElementOpen = true;
|
|
945
936
|
}
|
|
946
937
|
|
|
947
|
-
const currentIndent =
|
|
938
|
+
const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
948
939
|
|
|
949
940
|
switch (event.type) {
|
|
950
941
|
case 'note': {
|
|
@@ -1061,7 +1052,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1061
1052
|
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1062
1053
|
break;
|
|
1063
1054
|
case 'tuplet': {
|
|
1064
|
-
|
|
1055
|
+
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1056
|
+
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1057
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
1065
1058
|
xml += tupletResult.xml;
|
|
1066
1059
|
|
|
1067
1060
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
@@ -1158,14 +1151,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1158
1151
|
}
|
|
1159
1152
|
|
|
1160
1153
|
// Close beam element if beam ends
|
|
1161
|
-
if (beamEnd &&
|
|
1154
|
+
if (beamEnd && beamElementOpen) {
|
|
1162
1155
|
xml += `${baseIndent}</beam>\n`;
|
|
1163
|
-
|
|
1156
|
+
beamElementOpen = false;
|
|
1164
1157
|
}
|
|
1165
1158
|
}
|
|
1166
1159
|
|
|
1167
1160
|
// Close any unclosed beam
|
|
1168
|
-
if (
|
|
1161
|
+
if (beamElementOpen) {
|
|
1169
1162
|
xml += `${baseIndent}</beam>\n`;
|
|
1170
1163
|
}
|
|
1171
1164
|
|
|
@@ -1593,11 +1586,14 @@ const encodeScoreDef = (
|
|
|
1593
1586
|
timeNum: number,
|
|
1594
1587
|
timeDen: number,
|
|
1595
1588
|
partInfos: PartInfo[],
|
|
1596
|
-
indent: string
|
|
1589
|
+
indent: string,
|
|
1590
|
+
meterSymbol?: 'common' | 'cut'
|
|
1597
1591
|
): string => {
|
|
1598
1592
|
const scoreDefId = generateId("scoredef");
|
|
1599
1593
|
|
|
1600
|
-
|
|
1594
|
+
// Build meter attributes
|
|
1595
|
+
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1596
|
+
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1601
1597
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1602
1598
|
|
|
1603
1599
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
@@ -1647,6 +1643,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1647
1643
|
let currentKey = 0;
|
|
1648
1644
|
let currentTimeNum = 4;
|
|
1649
1645
|
let currentTimeDen = 4;
|
|
1646
|
+
let currentMeterSymbol: 'common' | 'cut' | undefined = undefined;
|
|
1650
1647
|
|
|
1651
1648
|
const firstMeasure = doc.measures[0];
|
|
1652
1649
|
if (firstMeasure.key) {
|
|
@@ -1655,6 +1652,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1655
1652
|
if (firstMeasure.timeSig) {
|
|
1656
1653
|
currentTimeNum = firstMeasure.timeSig.numerator;
|
|
1657
1654
|
currentTimeDen = firstMeasure.timeSig.denominator;
|
|
1655
|
+
currentMeterSymbol = firstMeasure.timeSig.symbol;
|
|
1658
1656
|
}
|
|
1659
1657
|
|
|
1660
1658
|
const keySig = KEY_SIGS[currentKey] || "0";
|
|
@@ -1708,7 +1706,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1708
1706
|
mei += `${indent}${indent}<body>\n`;
|
|
1709
1707
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1710
1708
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1711
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}
|
|
1709
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
1712
1710
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1713
1711
|
|
|
1714
1712
|
// Track tie state across measures for cross-measure ties
|
|
@@ -1764,6 +1762,20 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1764
1762
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1765
1763
|
}
|
|
1766
1764
|
}
|
|
1765
|
+
// Check for time signature change and output scoreDef if needed
|
|
1766
|
+
if (measure.timeSig && mi > 0) {
|
|
1767
|
+
const newTimeNum = measure.timeSig.numerator;
|
|
1768
|
+
const newTimeDen = measure.timeSig.denominator;
|
|
1769
|
+
const newMeterSymbol = measure.timeSig.symbol;
|
|
1770
|
+
if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
|
|
1771
|
+
currentTimeNum = newTimeNum;
|
|
1772
|
+
currentTimeDen = newTimeDen;
|
|
1773
|
+
currentMeterSymbol = newMeterSymbol;
|
|
1774
|
+
// Output a scoreDef with the new time signature
|
|
1775
|
+
const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
|
|
1776
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1767
1779
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1768
1780
|
});
|
|
1769
1781
|
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
NavigationMarkType,
|
|
28
28
|
BarlineEvent,
|
|
29
29
|
HarmonyEvent,
|
|
30
|
+
TupletEvent,
|
|
30
31
|
} from './types';
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -132,6 +133,88 @@ class SpannerTracker {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
// ============ Tuplet Tracker ============
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Track tuplet groups by number attribute.
|
|
140
|
+
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
141
|
+
*/
|
|
142
|
+
class TupletTracker {
|
|
143
|
+
// Map from tuplet number to collected events and ratio
|
|
144
|
+
private activeTuplets: Map<number, {
|
|
145
|
+
events: (NoteEvent | RestEvent)[];
|
|
146
|
+
ratio?: Fraction;
|
|
147
|
+
}> = new Map();
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Start a new tuplet group
|
|
151
|
+
*/
|
|
152
|
+
startTuplet(number: number = 1): void {
|
|
153
|
+
this.activeTuplets.set(number, { events: [] });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Add an event to active tuplet(s)
|
|
158
|
+
* Returns true if the event was added to at least one tuplet
|
|
159
|
+
*/
|
|
160
|
+
addEvent(event: NoteEvent | RestEvent): boolean {
|
|
161
|
+
if (this.activeTuplets.size === 0) return false;
|
|
162
|
+
|
|
163
|
+
// Add to all active tuplets (in case of nested tuplets)
|
|
164
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
165
|
+
// Set ratio from first event's duration.tuplet
|
|
166
|
+
if (!tuplet.ratio && event.duration.tuplet) {
|
|
167
|
+
// In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
|
|
168
|
+
tuplet.ratio = {
|
|
169
|
+
numerator: event.duration.tuplet.denominator,
|
|
170
|
+
denominator: event.duration.tuplet.numerator,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
174
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
175
|
+
delete cleanEvent.duration.tuplet;
|
|
176
|
+
tuplet.events.push(cleanEvent);
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stop a tuplet group and return the TupletEvent
|
|
183
|
+
*/
|
|
184
|
+
stopTuplet(number: number = 1): TupletEvent | undefined {
|
|
185
|
+
const tuplet = this.activeTuplets.get(number);
|
|
186
|
+
if (!tuplet || tuplet.events.length === 0) {
|
|
187
|
+
this.activeTuplets.delete(number);
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.activeTuplets.delete(number);
|
|
192
|
+
|
|
193
|
+
// Default ratio if not set (shouldn't happen normally)
|
|
194
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
type: 'tuplet',
|
|
198
|
+
ratio,
|
|
199
|
+
events: tuplet.events,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Check if any tuplet is active
|
|
205
|
+
*/
|
|
206
|
+
isActive(): boolean {
|
|
207
|
+
return this.activeTuplets.size > 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reset tracker
|
|
212
|
+
*/
|
|
213
|
+
reset(): void {
|
|
214
|
+
this.activeTuplets.clear();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
135
218
|
// ============ Voice Position Tracker ============
|
|
136
219
|
|
|
137
220
|
/**
|
|
@@ -940,7 +1023,8 @@ const convertMeasure = (
|
|
|
940
1023
|
measureEl: Element,
|
|
941
1024
|
voiceTracker: VoiceTracker,
|
|
942
1025
|
spannerTracker: SpannerTracker,
|
|
943
|
-
ottavaTracker: { current: number }
|
|
1026
|
+
ottavaTracker: { current: number },
|
|
1027
|
+
tupletTracker: TupletTracker
|
|
944
1028
|
): MeasureConversionResult => {
|
|
945
1029
|
let key: KeySignature | undefined;
|
|
946
1030
|
let timeSig: Fraction | undefined;
|
|
@@ -994,6 +1078,12 @@ const convertMeasure = (
|
|
|
994
1078
|
const staffNum = note.staff || 1;
|
|
995
1079
|
currentVoice = voiceNum;
|
|
996
1080
|
|
|
1081
|
+
// Check for tuplet start BEFORE processing the note
|
|
1082
|
+
const tupletNotation = note.notations?.tuplet;
|
|
1083
|
+
if (tupletNotation?.type === 'start') {
|
|
1084
|
+
tupletTracker.startTuplet(tupletNotation.number);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
997
1087
|
// Add any pending context changes before the note (tempo, ottava)
|
|
998
1088
|
if (pendingContextChanges.length > 0) {
|
|
999
1089
|
for (const ctx of pendingContextChanges) {
|
|
@@ -1023,7 +1113,13 @@ const convertMeasure = (
|
|
|
1023
1113
|
|
|
1024
1114
|
// Grace notes don't advance time
|
|
1025
1115
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1026
|
-
|
|
1116
|
+
|
|
1117
|
+
// Check if we're in a tuplet
|
|
1118
|
+
if (tupletTracker.isActive()) {
|
|
1119
|
+
tupletTracker.addEvent(restEvent);
|
|
1120
|
+
} else {
|
|
1121
|
+
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
1122
|
+
}
|
|
1027
1123
|
} else if (note.pitch) {
|
|
1028
1124
|
// Note or chord - convert MusicXmlPitch to Lilylet Pitch
|
|
1029
1125
|
const lilyletPitch = musicXmlPitchToLilylet(note.pitch);
|
|
@@ -1093,7 +1189,31 @@ const convertMeasure = (
|
|
|
1093
1189
|
|
|
1094
1190
|
// Grace notes don't advance time
|
|
1095
1191
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
1096
|
-
|
|
1192
|
+
|
|
1193
|
+
// Check if we're in a tuplet
|
|
1194
|
+
if (tupletTracker.isActive()) {
|
|
1195
|
+
tupletTracker.addEvent(noteEvent);
|
|
1196
|
+
} else {
|
|
1197
|
+
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Check for tuplet stop AFTER processing the note
|
|
1202
|
+
if (tupletNotation?.type === 'stop') {
|
|
1203
|
+
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
1204
|
+
if (tupletEvent) {
|
|
1205
|
+
// Calculate total duration of tuplet for voiceTracker
|
|
1206
|
+
let totalDuration = 0;
|
|
1207
|
+
for (const evt of tupletEvent.events) {
|
|
1208
|
+
if (evt.duration) {
|
|
1209
|
+
// Convert division to duration units (quarter = 1)
|
|
1210
|
+
totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Apply tuplet ratio to get actual duration
|
|
1214
|
+
totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
1215
|
+
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
1216
|
+
}
|
|
1097
1217
|
}
|
|
1098
1218
|
} else if (tagName === 'direction') {
|
|
1099
1219
|
const direction = parseDirection(child);
|
|
@@ -1158,6 +1278,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1158
1278
|
const voiceTracker = new VoiceTracker();
|
|
1159
1279
|
const spannerTracker = new SpannerTracker();
|
|
1160
1280
|
const ottavaTracker = { current: 0 };
|
|
1281
|
+
const tupletTracker = new TupletTracker();
|
|
1161
1282
|
|
|
1162
1283
|
let lastKey: KeySignature | undefined;
|
|
1163
1284
|
let lastTimeSig: Fraction | undefined;
|
|
@@ -1168,7 +1289,7 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
|
|
|
1168
1289
|
|
|
1169
1290
|
for (const measureEl of measureEls) {
|
|
1170
1291
|
voiceTracker.reset();
|
|
1171
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
|
|
1292
|
+
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
1172
1293
|
|
|
1173
1294
|
// Update running key/time
|
|
1174
1295
|
if (key) lastKey = key;
|
|
@@ -550,7 +550,7 @@ const serializeEvent = (
|
|
|
550
550
|
// Key/time signature info to inject into first voice
|
|
551
551
|
interface MeasureContext {
|
|
552
552
|
key?: KeySignature;
|
|
553
|
-
time?: { numerator: number; denominator: number };
|
|
553
|
+
time?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' };
|
|
554
554
|
}
|
|
555
555
|
|
|
556
556
|
// Serialize a voice with pitch environment tracking
|
|
@@ -585,7 +585,13 @@ const serializeVoice = (
|
|
|
585
585
|
parts.push('\\key ' + keyStr);
|
|
586
586
|
}
|
|
587
587
|
if (measureContext.time) {
|
|
588
|
-
|
|
588
|
+
const { numerator, denominator, symbol } = measureContext.time;
|
|
589
|
+
// Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
|
|
590
|
+
// (meaning numeric display was explicitly requested)
|
|
591
|
+
if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
|
|
592
|
+
parts.push('\\numericTimeSignature');
|
|
593
|
+
}
|
|
594
|
+
parts.push('\\time ' + numerator + '/' + denominator);
|
|
589
595
|
}
|
|
590
596
|
}
|
|
591
597
|
|
package/source/lilylet/types.ts
CHANGED
|
@@ -99,6 +99,12 @@ export interface Fraction {
|
|
|
99
99
|
denominator: number;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Time signature with optional symbol display
|
|
103
|
+
// symbol: 'common' for C (4/4), 'cut' for C| (2/2), undefined for numeric
|
|
104
|
+
export interface TimeSig extends Fraction {
|
|
105
|
+
symbol?: 'common' | 'cut';
|
|
106
|
+
}
|
|
107
|
+
|
|
102
108
|
export interface Pitch {
|
|
103
109
|
phonet: Phonet;
|
|
104
110
|
accidental?: Accidental;
|
|
@@ -292,7 +298,7 @@ export interface Part {
|
|
|
292
298
|
// Measure contains parts separated by \\\
|
|
293
299
|
export interface Measure {
|
|
294
300
|
key?: KeySignature;
|
|
295
|
-
timeSig?:
|
|
301
|
+
timeSig?: TimeSig;
|
|
296
302
|
parts: Part[];
|
|
297
303
|
partial?: boolean;
|
|
298
304
|
}
|
package/lib/lilypondDecoder.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LilyPond to Lilylet Decoder
|
|
3
|
-
*
|
|
4
|
-
* Converts LilyPond notation files to Lilylet document format using the lotus parser.
|
|
5
|
-
* This module is browser-compatible - it uses pre-compiled parser from lotus.
|
|
6
|
-
*/
|
|
7
|
-
import * as lilyParser from "@k-l-lambda/lotus/lib/inc/lilyParser/index.js";
|
|
8
|
-
import { LilyletDoc, Event, Fraction } from "./types";
|
|
9
|
-
interface ParsedMeasure {
|
|
10
|
-
key: number | null;
|
|
11
|
-
timeSig: Fraction | null;
|
|
12
|
-
voices: ParsedVoice[];
|
|
13
|
-
partial: boolean;
|
|
14
|
-
}
|
|
15
|
-
interface ParsedVoice {
|
|
16
|
-
staff: number;
|
|
17
|
-
events: Event[];
|
|
18
|
-
}
|
|
19
|
-
declare const parseLilyDocument: (lilyDocument: lilyParser.LilyDocument) => ParsedMeasure[];
|
|
20
|
-
/**
|
|
21
|
-
* Decode a LilyPond string to LilyletDoc (synchronous, browser-compatible)
|
|
22
|
-
*/
|
|
23
|
-
declare const decode: (lilypondSource: string) => LilyletDoc;
|
|
24
|
-
/**
|
|
25
|
-
* Decode from pre-parsed LilyDocument (synchronous, for when you already have parsed data)
|
|
26
|
-
*/
|
|
27
|
-
declare const decodeFromDocument: (lilyDocument: lilyParser.LilyDocument) => LilyletDoc;
|
|
28
|
-
export { decode, decodeFromDocument, parseLilyDocument, };
|