@k-l-lambda/lilylet 0.1.32 → 0.1.34
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/meiEncoder.js +49 -46
- package/lib/musicXmlDecoder.js +108 -4
- package/package.json +1 -1
- package/source/lilylet/meiEncoder.ts +50 -51
- package/source/lilylet/musicXmlDecoder.ts +125 -4
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
|
|
@@ -1374,9 +1371,15 @@ const encode = (doc, options = {}) => {
|
|
|
1374
1371
|
}
|
|
1375
1372
|
// Encode measures
|
|
1376
1373
|
measures.forEach((measure, mi) => {
|
|
1377
|
-
//
|
|
1374
|
+
// Check for key signature change and output scoreDef if needed
|
|
1378
1375
|
if (measure.key) {
|
|
1379
|
-
|
|
1376
|
+
const newKey = keyToFifths(measure.key);
|
|
1377
|
+
if (newKey !== currentKey) {
|
|
1378
|
+
currentKey = newKey;
|
|
1379
|
+
const newKeySig = KEY_SIGS[currentKey] || "0";
|
|
1380
|
+
// Output a scoreDef with the new key signature
|
|
1381
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1382
|
+
}
|
|
1380
1383
|
}
|
|
1381
1384
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1382
1385
|
});
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
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",
|
|
@@ -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
|
|
|
@@ -1754,9 +1747,15 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
1754
1747
|
|
|
1755
1748
|
// Encode measures
|
|
1756
1749
|
measures.forEach((measure, mi) => {
|
|
1757
|
-
//
|
|
1750
|
+
// Check for key signature change and output scoreDef if needed
|
|
1758
1751
|
if (measure.key) {
|
|
1759
|
-
|
|
1752
|
+
const newKey = keyToFifths(measure.key);
|
|
1753
|
+
if (newKey !== currentKey) {
|
|
1754
|
+
currentKey = newKey;
|
|
1755
|
+
const newKeySig = KEY_SIGS[currentKey] || "0";
|
|
1756
|
+
// Output a scoreDef with the new key signature
|
|
1757
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1758
|
+
}
|
|
1760
1759
|
}
|
|
1761
1760
|
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, currentKey, partInfos, clefState);
|
|
1762
1761
|
});
|
|
@@ -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;
|