@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
package/lib/meiEncoder.js
CHANGED
|
@@ -470,14 +470,13 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
470
470
|
return `${indent}<rest ${attrs} />\n`;
|
|
471
471
|
};
|
|
472
472
|
// Convert TupletEvent to MEI
|
|
473
|
-
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0) => {
|
|
473
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
|
|
474
474
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
475
475
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
476
476
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
477
477
|
const num = event.ratio.denominator; // denominator = actual note count
|
|
478
478
|
const numbase = event.ratio.numerator; // numerator = time equivalent
|
|
479
479
|
let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
|
|
480
|
-
let inBeam = false;
|
|
481
480
|
const baseIndent = indent + ' ';
|
|
482
481
|
// Effective staff for cross-staff notation
|
|
483
482
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
@@ -490,28 +489,17 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
490
489
|
const mordents = [];
|
|
491
490
|
const turns = [];
|
|
492
491
|
const arpeggios = [];
|
|
492
|
+
// If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
|
|
493
|
+
// MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
|
|
494
|
+
// Beam state is managed by encodeLayer, not here.
|
|
493
495
|
for (const e of event.events) {
|
|
494
|
-
// Check for beam marks in note events
|
|
495
|
-
let beamStart = false;
|
|
496
|
-
let beamEnd = false;
|
|
497
|
-
if (e.type === 'note') {
|
|
498
|
-
const markOptions = extractMarkOptions(e.marks);
|
|
499
|
-
beamStart = markOptions.beamStart;
|
|
500
|
-
beamEnd = markOptions.beamEnd;
|
|
501
|
-
}
|
|
502
|
-
// Open beam element if beam starts
|
|
503
|
-
if (beamStart && !inBeam) {
|
|
504
|
-
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
505
|
-
inBeam = true;
|
|
506
|
-
}
|
|
507
|
-
const currentIndent = inBeam ? baseIndent + ' ' : baseIndent;
|
|
508
496
|
if (e.type === 'note') {
|
|
509
497
|
// For cross-staff notation: set note's staff if different from layerStaff
|
|
510
498
|
const noteEvent = e;
|
|
511
499
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
512
500
|
? { ...noteEvent, staff: effectiveStaff }
|
|
513
501
|
: noteEvent;
|
|
514
|
-
const result = noteEventToMEI(effectiveNoteEvent,
|
|
502
|
+
const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
515
503
|
xml += result.xml;
|
|
516
504
|
// Collect slur info
|
|
517
505
|
if (result.slurStart)
|
|
@@ -533,18 +521,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
533
521
|
arpeggios.push({ plist: result.elementId });
|
|
534
522
|
}
|
|
535
523
|
else if (e.type === 'rest') {
|
|
536
|
-
xml += restEventToMEI(e,
|
|
537
|
-
}
|
|
538
|
-
// Close beam element if beam ends
|
|
539
|
-
if (beamEnd && inBeam) {
|
|
540
|
-
xml += `${baseIndent}</beam>\n`;
|
|
541
|
-
inBeam = false;
|
|
524
|
+
xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
|
|
542
525
|
}
|
|
543
526
|
}
|
|
544
|
-
// Close any unclosed beam
|
|
545
|
-
if (inBeam) {
|
|
546
|
-
xml += `${baseIndent}</beam>\n`;
|
|
547
|
-
}
|
|
548
527
|
xml += `${indent}</tuplet>\n`;
|
|
549
528
|
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
550
529
|
};
|
|
@@ -615,11 +594,34 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
615
594
|
result += `${indent}</fTrem>\n`;
|
|
616
595
|
return result;
|
|
617
596
|
};
|
|
597
|
+
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
598
|
+
const getEventBeamMarks = (event) => {
|
|
599
|
+
if (event.type === 'note') {
|
|
600
|
+
const markOptions = extractMarkOptions(event.marks);
|
|
601
|
+
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
602
|
+
}
|
|
603
|
+
if (event.type === 'tuplet') {
|
|
604
|
+
const tuplet = event;
|
|
605
|
+
let beamStart = false;
|
|
606
|
+
let beamEnd = false;
|
|
607
|
+
for (const e of tuplet.events) {
|
|
608
|
+
if (e.type === 'note') {
|
|
609
|
+
const markOptions = extractMarkOptions(e.marks);
|
|
610
|
+
if (markOptions.beamStart)
|
|
611
|
+
beamStart = true;
|
|
612
|
+
if (markOptions.beamEnd)
|
|
613
|
+
beamEnd = true;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { beamStart, beamEnd };
|
|
617
|
+
}
|
|
618
|
+
return { beamStart: false, beamEnd: false };
|
|
619
|
+
};
|
|
618
620
|
// Encode a layer (voice)
|
|
619
621
|
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null) => {
|
|
620
622
|
const layerId = generateId("layer");
|
|
621
623
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
622
|
-
let
|
|
624
|
+
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
623
625
|
const baseIndent = indent + ' ';
|
|
624
626
|
// Track current clef to only emit changes
|
|
625
627
|
let currentClef = initialClef;
|
|
@@ -667,21 +669,14 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
667
669
|
return true;
|
|
668
670
|
};
|
|
669
671
|
for (const event of voice.events) {
|
|
670
|
-
// Check for beam start/end in
|
|
671
|
-
|
|
672
|
-
let beamEnd = false;
|
|
673
|
-
if (event.type === 'note') {
|
|
674
|
-
const noteEvent = event;
|
|
675
|
-
const markOptions = extractMarkOptions(noteEvent.marks);
|
|
676
|
-
beamStart = markOptions.beamStart;
|
|
677
|
-
beamEnd = markOptions.beamEnd;
|
|
678
|
-
}
|
|
672
|
+
// Check for beam start/end in this event (including inside tuplets)
|
|
673
|
+
const { beamStart, beamEnd } = getEventBeamMarks(event);
|
|
679
674
|
// Open beam element if beam starts
|
|
680
|
-
if (beamStart && !
|
|
675
|
+
if (beamStart && !beamElementOpen) {
|
|
681
676
|
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
682
|
-
|
|
677
|
+
beamElementOpen = true;
|
|
683
678
|
}
|
|
684
|
-
const currentIndent =
|
|
679
|
+
const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
685
680
|
switch (event.type) {
|
|
686
681
|
case 'note': {
|
|
687
682
|
const noteEvent = event;
|
|
@@ -790,7 +785,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
790
785
|
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
791
786
|
break;
|
|
792
787
|
case 'tuplet': {
|
|
793
|
-
|
|
788
|
+
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
789
|
+
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
790
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
794
791
|
xml += tupletResult.xml;
|
|
795
792
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
796
793
|
for (const endId of tupletResult.slurEnds) {
|
|
@@ -883,13 +880,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
883
880
|
break;
|
|
884
881
|
}
|
|
885
882
|
// Close beam element if beam ends
|
|
886
|
-
if (beamEnd &&
|
|
883
|
+
if (beamEnd && beamElementOpen) {
|
|
887
884
|
xml += `${baseIndent}</beam>\n`;
|
|
888
|
-
|
|
885
|
+
beamElementOpen = false;
|
|
889
886
|
}
|
|
890
887
|
}
|
|
891
888
|
// Close any unclosed beam
|
|
892
|
-
if (
|
|
889
|
+
if (beamElementOpen) {
|
|
893
890
|
xml += `${baseIndent}</beam>\n`;
|
|
894
891
|
}
|
|
895
892
|
// Close any unclosed ottava span at end of layer
|
|
@@ -1240,9 +1237,11 @@ const analyzePartStructure = (doc) => {
|
|
|
1240
1237
|
return partInfos;
|
|
1241
1238
|
};
|
|
1242
1239
|
// Encode scoreDef with part groups
|
|
1243
|
-
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent) => {
|
|
1240
|
+
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
|
|
1244
1241
|
const scoreDefId = generateId("scoredef");
|
|
1245
|
-
|
|
1242
|
+
// Build meter attributes
|
|
1243
|
+
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1244
|
+
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1246
1245
|
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1247
1246
|
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1248
1247
|
const info = partInfos[pi];
|
|
@@ -1284,6 +1283,7 @@ const encode = (doc, options = {}) => {
|
|
|
1284
1283
|
let currentKey = 0;
|
|
1285
1284
|
let currentTimeNum = 4;
|
|
1286
1285
|
let currentTimeDen = 4;
|
|
1286
|
+
let currentMeterSymbol = undefined;
|
|
1287
1287
|
const firstMeasure = doc.measures[0];
|
|
1288
1288
|
if (firstMeasure.key) {
|
|
1289
1289
|
currentKey = keyToFifths(firstMeasure.key);
|
|
@@ -1291,6 +1291,7 @@ const encode = (doc, options = {}) => {
|
|
|
1291
1291
|
if (firstMeasure.timeSig) {
|
|
1292
1292
|
currentTimeNum = firstMeasure.timeSig.numerator;
|
|
1293
1293
|
currentTimeDen = firstMeasure.timeSig.denominator;
|
|
1294
|
+
currentMeterSymbol = firstMeasure.timeSig.symbol;
|
|
1294
1295
|
}
|
|
1295
1296
|
const keySig = KEY_SIGS[currentKey] || "0";
|
|
1296
1297
|
// Build MEI document
|
|
@@ -1335,7 +1336,7 @@ const encode = (doc, options = {}) => {
|
|
|
1335
1336
|
mei += `${indent}${indent}<body>\n`;
|
|
1336
1337
|
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1337
1338
|
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1338
|
-
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}
|
|
1339
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
1339
1340
|
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1340
1341
|
// Track tie state across measures for cross-measure ties
|
|
1341
1342
|
const tieState = {};
|
|
@@ -1384,6 +1385,20 @@ const encode = (doc, options = {}) => {
|
|
|
1384
1385
|
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1385
1386
|
}
|
|
1386
1387
|
}
|
|
1388
|
+
// Check for time signature change and output scoreDef if needed
|
|
1389
|
+
if (measure.timeSig && mi > 0) {
|
|
1390
|
+
const newTimeNum = measure.timeSig.numerator;
|
|
1391
|
+
const newTimeDen = measure.timeSig.denominator;
|
|
1392
|
+
const newMeterSymbol = measure.timeSig.symbol;
|
|
1393
|
+
if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
|
|
1394
|
+
currentTimeNum = newTimeNum;
|
|
1395
|
+
currentTimeDen = newTimeDen;
|
|
1396
|
+
currentMeterSymbol = newMeterSymbol;
|
|
1397
|
+
// Output a scoreDef with the new time signature
|
|
1398
|
+
const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
|
|
1399
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1387
1402
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1388
1403
|
});
|
|
1389
1404
|
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
package/lib/musicXmlDecoder.js
CHANGED
|
@@ -60,6 +60,75 @@ class SpannerTracker {
|
|
|
60
60
|
this.ties.clear();
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
// ============ Tuplet Tracker ============
|
|
64
|
+
/**
|
|
65
|
+
* Track tuplet groups by number attribute.
|
|
66
|
+
* Collects notes between tuplet start and stop to create TupletEvent.
|
|
67
|
+
*/
|
|
68
|
+
class TupletTracker {
|
|
69
|
+
// Map from tuplet number to collected events and ratio
|
|
70
|
+
activeTuplets = new Map();
|
|
71
|
+
/**
|
|
72
|
+
* Start a new tuplet group
|
|
73
|
+
*/
|
|
74
|
+
startTuplet(number = 1) {
|
|
75
|
+
this.activeTuplets.set(number, { events: [] });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Add an event to active tuplet(s)
|
|
79
|
+
* Returns true if the event was added to at least one tuplet
|
|
80
|
+
*/
|
|
81
|
+
addEvent(event) {
|
|
82
|
+
if (this.activeTuplets.size === 0)
|
|
83
|
+
return false;
|
|
84
|
+
// Add to all active tuplets (in case of nested tuplets)
|
|
85
|
+
for (const [, tuplet] of this.activeTuplets) {
|
|
86
|
+
// Set ratio from first event's duration.tuplet
|
|
87
|
+
if (!tuplet.ratio && event.duration.tuplet) {
|
|
88
|
+
// In Lilylet, ratio is denominator/numerator (e.g., 2/3 for triplet)
|
|
89
|
+
tuplet.ratio = {
|
|
90
|
+
numerator: event.duration.tuplet.denominator,
|
|
91
|
+
denominator: event.duration.tuplet.numerator,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Store event without tuplet info in duration (it's handled at TupletEvent level)
|
|
95
|
+
const cleanEvent = { ...event, duration: { ...event.duration } };
|
|
96
|
+
delete cleanEvent.duration.tuplet;
|
|
97
|
+
tuplet.events.push(cleanEvent);
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Stop a tuplet group and return the TupletEvent
|
|
103
|
+
*/
|
|
104
|
+
stopTuplet(number = 1) {
|
|
105
|
+
const tuplet = this.activeTuplets.get(number);
|
|
106
|
+
if (!tuplet || tuplet.events.length === 0) {
|
|
107
|
+
this.activeTuplets.delete(number);
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
this.activeTuplets.delete(number);
|
|
111
|
+
// Default ratio if not set (shouldn't happen normally)
|
|
112
|
+
const ratio = tuplet.ratio || { numerator: 2, denominator: 3 };
|
|
113
|
+
return {
|
|
114
|
+
type: 'tuplet',
|
|
115
|
+
ratio,
|
|
116
|
+
events: tuplet.events,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if any tuplet is active
|
|
121
|
+
*/
|
|
122
|
+
isActive() {
|
|
123
|
+
return this.activeTuplets.size > 0;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reset tracker
|
|
127
|
+
*/
|
|
128
|
+
reset() {
|
|
129
|
+
this.activeTuplets.clear();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
63
132
|
class VoiceTracker {
|
|
64
133
|
voices = new Map();
|
|
65
134
|
currentPosition = 0;
|
|
@@ -737,7 +806,7 @@ const directionToMarks = (direction, spannerTracker) => {
|
|
|
737
806
|
/**
|
|
738
807
|
* Convert a MusicXML measure to Lilylet events, grouped by voice
|
|
739
808
|
*/
|
|
740
|
-
const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker) => {
|
|
809
|
+
const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker) => {
|
|
741
810
|
let key;
|
|
742
811
|
let timeSig;
|
|
743
812
|
let barline;
|
|
@@ -782,6 +851,11 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
|
|
|
782
851
|
const voiceNum = note.voice;
|
|
783
852
|
const staffNum = note.staff || 1;
|
|
784
853
|
currentVoice = voiceNum;
|
|
854
|
+
// Check for tuplet start BEFORE processing the note
|
|
855
|
+
const tupletNotation = note.notations?.tuplet;
|
|
856
|
+
if (tupletNotation?.type === 'start') {
|
|
857
|
+
tupletTracker.startTuplet(tupletNotation.number);
|
|
858
|
+
}
|
|
785
859
|
// Add any pending context changes before the note (tempo, ottava)
|
|
786
860
|
if (pendingContextChanges.length > 0) {
|
|
787
861
|
for (const ctx of pendingContextChanges) {
|
|
@@ -801,7 +875,13 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
|
|
|
801
875
|
};
|
|
802
876
|
// Grace notes don't advance time
|
|
803
877
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
804
|
-
|
|
878
|
+
// Check if we're in a tuplet
|
|
879
|
+
if (tupletTracker.isActive()) {
|
|
880
|
+
tupletTracker.addEvent(restEvent);
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
voiceTracker.addEvent(voiceNum, restEvent, advanceDuration, staffNum);
|
|
884
|
+
}
|
|
805
885
|
}
|
|
806
886
|
else if (note.pitch) {
|
|
807
887
|
// Note or chord - convert MusicXmlPitch to Lilylet Pitch
|
|
@@ -858,7 +938,30 @@ const convertMeasure = (measureEl, voiceTracker, spannerTracker, ottavaTracker)
|
|
|
858
938
|
}
|
|
859
939
|
// Grace notes don't advance time
|
|
860
940
|
const advanceDuration = note.isGrace ? 0 : note.duration.divisions;
|
|
861
|
-
|
|
941
|
+
// Check if we're in a tuplet
|
|
942
|
+
if (tupletTracker.isActive()) {
|
|
943
|
+
tupletTracker.addEvent(noteEvent);
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
voiceTracker.addEvent(voiceNum, noteEvent, advanceDuration, staffNum);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Check for tuplet stop AFTER processing the note
|
|
950
|
+
if (tupletNotation?.type === 'stop') {
|
|
951
|
+
const tupletEvent = tupletTracker.stopTuplet(tupletNotation.number);
|
|
952
|
+
if (tupletEvent) {
|
|
953
|
+
// Calculate total duration of tuplet for voiceTracker
|
|
954
|
+
let totalDuration = 0;
|
|
955
|
+
for (const evt of tupletEvent.events) {
|
|
956
|
+
if (evt.duration) {
|
|
957
|
+
// Convert division to duration units (quarter = 1)
|
|
958
|
+
totalDuration += (4 / evt.duration.division) * voiceTracker.getDivisions();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Apply tuplet ratio to get actual duration
|
|
962
|
+
totalDuration = totalDuration * tupletEvent.ratio.numerator / tupletEvent.ratio.denominator;
|
|
963
|
+
voiceTracker.addEvent(voiceNum, tupletEvent, totalDuration, staffNum);
|
|
964
|
+
}
|
|
862
965
|
}
|
|
863
966
|
}
|
|
864
967
|
else if (tagName === 'direction') {
|
|
@@ -917,6 +1020,7 @@ const convertPart = (partEl) => {
|
|
|
917
1020
|
const voiceTracker = new VoiceTracker();
|
|
918
1021
|
const spannerTracker = new SpannerTracker();
|
|
919
1022
|
const ottavaTracker = { current: 0 };
|
|
1023
|
+
const tupletTracker = new TupletTracker();
|
|
920
1024
|
let lastKey;
|
|
921
1025
|
let lastTimeSig;
|
|
922
1026
|
let isFirstMeasure = true;
|
|
@@ -924,7 +1028,7 @@ const convertPart = (partEl) => {
|
|
|
924
1028
|
const measureEls = getDirectChildren(partEl, 'measure');
|
|
925
1029
|
for (const measureEl of measureEls) {
|
|
926
1030
|
voiceTracker.reset();
|
|
927
|
-
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker);
|
|
1031
|
+
const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
|
|
928
1032
|
// Update running key/time
|
|
929
1033
|
if (key)
|
|
930
1034
|
lastKey = key;
|
package/lib/serializer.js
CHANGED
|
@@ -448,7 +448,13 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
|
|
|
448
448
|
parts.push('\\key ' + keyStr);
|
|
449
449
|
}
|
|
450
450
|
if (measureContext.time) {
|
|
451
|
-
|
|
451
|
+
const { numerator, denominator, symbol } = measureContext.time;
|
|
452
|
+
// Output \numericTimeSignature before 4/4 or 2/2 if no symbol is set
|
|
453
|
+
// (meaning numeric display was explicitly requested)
|
|
454
|
+
if (!symbol && ((numerator === 4 && denominator === 4) || (numerator === 2 && denominator === 2))) {
|
|
455
|
+
parts.push('\\numericTimeSignature');
|
|
456
|
+
}
|
|
457
|
+
parts.push('\\time ' + numerator + '/' + denominator);
|
|
452
458
|
}
|
|
453
459
|
}
|
|
454
460
|
for (const event of voice.events) {
|
package/lib/types.d.ts
CHANGED
|
@@ -83,6 +83,9 @@ export interface Fraction {
|
|
|
83
83
|
numerator: number;
|
|
84
84
|
denominator: number;
|
|
85
85
|
}
|
|
86
|
+
export interface TimeSig extends Fraction {
|
|
87
|
+
symbol?: 'common' | 'cut';
|
|
88
|
+
}
|
|
86
89
|
export interface Pitch {
|
|
87
90
|
phonet: Phonet;
|
|
88
91
|
accidental?: Accidental;
|
|
@@ -231,7 +234,7 @@ export interface Part {
|
|
|
231
234
|
}
|
|
232
235
|
export interface Measure {
|
|
233
236
|
key?: KeySignature;
|
|
234
|
-
timeSig?:
|
|
237
|
+
timeSig?: TimeSig;
|
|
235
238
|
parts: Part[];
|
|
236
239
|
partial?: boolean;
|
|
237
240
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|