@k-l-lambda/lilylet 0.1.52 → 0.1.54
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.
|
@@ -329,7 +329,7 @@ case 92:
|
|
|
329
329
|
this.$ = 'auto';
|
|
330
330
|
break;
|
|
331
331
|
case 93:
|
|
332
|
-
this.$ = ($$[$0-1].
|
|
332
|
+
this.$ = ($$[$0-1].map(e => (e.type === 'note' || e.type === 'rest') ? { ...e, grace: true } : e));
|
|
333
333
|
break;
|
|
334
334
|
case 94: case 95:
|
|
335
335
|
this.$ = ({ ...$$[$0], grace: true });
|
|
@@ -119,10 +119,12 @@ const getKeyAccidentals = (fifths) => {
|
|
|
119
119
|
}
|
|
120
120
|
return result;
|
|
121
121
|
};
|
|
122
|
-
// Convert Pitch to MEI attributes, checking against key signature
|
|
122
|
+
// Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
|
|
123
123
|
// ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
|
|
124
124
|
// The written pitch should be adjusted by subtracting the ottava shift
|
|
125
|
-
|
|
125
|
+
// measureAccidentals: tracks accidentals used earlier in the same measure (keyed by "pname-oct")
|
|
126
|
+
// - mutated to record new accidentals; used to add cancellation naturals
|
|
127
|
+
const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
126
128
|
// Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
|
|
127
129
|
// When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
|
|
128
130
|
// For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
|
|
@@ -133,19 +135,44 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0) => {
|
|
|
133
135
|
// Determine accid (written/displayed) and accid.ges (gestural/sounding)
|
|
134
136
|
let accid;
|
|
135
137
|
let accidGes;
|
|
138
|
+
const pitchKey = `${pitch.phonet}-${oct}`;
|
|
139
|
+
// Check what was previously established for this pitch in this measure
|
|
140
|
+
const prevMeasureAccid = measureAccidentals?.get(pitchKey);
|
|
136
141
|
if (pitch.accidental) {
|
|
137
142
|
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
138
|
-
if (noteAccid !==
|
|
143
|
+
if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
|
|
144
|
+
// Previous note in this measure had a different accidental - must re-assert
|
|
145
|
+
accid = noteAccid;
|
|
146
|
+
}
|
|
147
|
+
else if (noteAccid !== keyAccid) {
|
|
139
148
|
// Accidental differs from key signature - display it
|
|
140
149
|
accid = noteAccid;
|
|
141
150
|
}
|
|
142
151
|
// Always set gestural accidental for MIDI generation
|
|
143
152
|
accidGes = noteAccid;
|
|
153
|
+
// Record this accidental for in-measure tracking
|
|
154
|
+
if (measureAccidentals)
|
|
155
|
+
measureAccidentals.set(pitchKey, noteAccid);
|
|
144
156
|
}
|
|
145
157
|
else if (keyAccid) {
|
|
146
158
|
// Note has no accidental but key implies one - output natural
|
|
147
|
-
|
|
159
|
+
if (prevMeasureAccid === 'n') {
|
|
160
|
+
// Already cancelled earlier in this measure - no need to show again
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
accid = 'n';
|
|
164
|
+
}
|
|
148
165
|
accidGes = 'n';
|
|
166
|
+
if (measureAccidentals)
|
|
167
|
+
measureAccidentals.set(pitchKey, 'n');
|
|
168
|
+
}
|
|
169
|
+
else if (measureAccidentals) {
|
|
170
|
+
// No explicit accidental, no key accidental - check if earlier note in measure had one
|
|
171
|
+
if (prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
172
|
+
// Previous note had an accidental - add cancellation natural
|
|
173
|
+
accid = 'n';
|
|
174
|
+
measureAccidentals.set(pitchKey, 'n');
|
|
175
|
+
}
|
|
149
176
|
}
|
|
150
177
|
return { pname: pitch.phonet, oct, accid, accidGes };
|
|
151
178
|
};
|
|
@@ -339,7 +366,7 @@ const extractMarkOptions = (marks) => {
|
|
|
339
366
|
return result;
|
|
340
367
|
};
|
|
341
368
|
// Convert NoteEvent to MEI
|
|
342
|
-
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0) => {
|
|
369
|
+
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
343
370
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
344
371
|
const dots = event.duration.dots || 0;
|
|
345
372
|
const markOptions = extractMarkOptions(event.marks);
|
|
@@ -373,7 +400,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
373
400
|
};
|
|
374
401
|
// Single note
|
|
375
402
|
if (event.pitches.length === 1) {
|
|
376
|
-
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
|
|
403
|
+
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
377
404
|
const noteId = generateId('note');
|
|
378
405
|
return {
|
|
379
406
|
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
@@ -416,7 +443,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
416
443
|
}
|
|
417
444
|
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
418
445
|
for (const p of event.pitches) {
|
|
419
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
446
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
420
447
|
result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
|
|
421
448
|
}
|
|
422
449
|
// Artics for chord - group by placement
|
|
@@ -456,7 +483,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
456
483
|
};
|
|
457
484
|
};
|
|
458
485
|
// Convert RestEvent to MEI
|
|
459
|
-
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
486
|
+
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
460
487
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
461
488
|
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
462
489
|
if (event.duration.dots > 0)
|
|
@@ -493,7 +520,7 @@ const tupletHasInternalBeams = (event) => {
|
|
|
493
520
|
return starts > 0 && starts === ends;
|
|
494
521
|
};
|
|
495
522
|
// Convert TupletEvent to MEI
|
|
496
|
-
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
|
|
523
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false, measureAccidentals) => {
|
|
497
524
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
498
525
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
499
526
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -504,6 +531,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
504
531
|
// Effective staff for cross-staff notation
|
|
505
532
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
506
533
|
// Collect control event info from notes inside tuplet
|
|
534
|
+
let firstNoteId = null;
|
|
507
535
|
const slurStarts = [];
|
|
508
536
|
const slurEnds = [];
|
|
509
537
|
const dynamics = [];
|
|
@@ -529,8 +557,10 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
529
557
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
530
558
|
? { ...noteEvent, staff: effectiveStaff }
|
|
531
559
|
: noteEvent;
|
|
532
|
-
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
560
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
|
|
533
561
|
xml += result.xml;
|
|
562
|
+
if (!firstNoteId)
|
|
563
|
+
firstNoteId = result.elementId;
|
|
534
564
|
// Collect slur info
|
|
535
565
|
if (result.slurStart)
|
|
536
566
|
slurStarts.push(result.elementId);
|
|
@@ -557,7 +587,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
557
587
|
}
|
|
558
588
|
else if (e.type === 'rest') {
|
|
559
589
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
560
|
-
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift);
|
|
590
|
+
xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
561
591
|
}
|
|
562
592
|
}
|
|
563
593
|
// Close any unclosed beam
|
|
@@ -565,10 +595,10 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
565
595
|
xml += `${baseIndent}</beam>\n`;
|
|
566
596
|
}
|
|
567
597
|
xml += `${indent}</tuplet>\n`;
|
|
568
|
-
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
598
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
569
599
|
};
|
|
570
600
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
571
|
-
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
601
|
+
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
572
602
|
const ftremId = generateId('fTrem');
|
|
573
603
|
// For \repeat tremolo 4 { c16 d16 }:
|
|
574
604
|
// - count = 4 (repetitions)
|
|
@@ -587,7 +617,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
587
617
|
let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
|
|
588
618
|
// First note (or chord)
|
|
589
619
|
if (event.pitchA.length === 1) {
|
|
590
|
-
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
|
|
620
|
+
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
|
|
591
621
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
592
622
|
if (pitch.accid)
|
|
593
623
|
attrs += ` accid="${pitch.accid}"`;
|
|
@@ -598,7 +628,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
598
628
|
else if (event.pitchA.length > 1) {
|
|
599
629
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
600
630
|
for (const p of event.pitchA) {
|
|
601
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
631
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
602
632
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
603
633
|
if (pitch.accid)
|
|
604
634
|
attrs += ` accid="${pitch.accid}"`;
|
|
@@ -610,7 +640,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
610
640
|
}
|
|
611
641
|
// Second note (or chord)
|
|
612
642
|
if (event.pitchB.length === 1) {
|
|
613
|
-
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
|
|
643
|
+
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
|
|
614
644
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
615
645
|
if (pitch.accid)
|
|
616
646
|
attrs += ` accid="${pitch.accid}"`;
|
|
@@ -621,7 +651,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
|
621
651
|
else if (event.pitchB.length > 1) {
|
|
622
652
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
623
653
|
for (const p of event.pitchB) {
|
|
624
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
654
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
625
655
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
626
656
|
if (pitch.accid)
|
|
627
657
|
attrs += ` accid="${pitch.accid}"`;
|
|
@@ -698,12 +728,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
698
728
|
const harmonies = [];
|
|
699
729
|
const barlines = [];
|
|
700
730
|
const markups = [];
|
|
731
|
+
const pendingMarkups = [];
|
|
701
732
|
// Track current stem direction from context changes
|
|
702
733
|
let currentStemDirection = undefined;
|
|
703
734
|
// Track current staff for cross-staff notation
|
|
704
735
|
let currentStaff = voice.staff || 1;
|
|
736
|
+
// Track in-measure accidentals for cancellation naturals
|
|
737
|
+
const measureAccidentals = new Map();
|
|
705
738
|
// Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
|
|
706
739
|
let pendingTiePitches = [...initialTiePitches];
|
|
740
|
+
// Helper to flush pending markups onto a note ID
|
|
741
|
+
const flushPendingMarkups = (noteId) => {
|
|
742
|
+
for (const mkup of pendingMarkups) {
|
|
743
|
+
markups.push({ startid: noteId, content: mkup.content, placement: mkup.placement });
|
|
744
|
+
}
|
|
745
|
+
pendingMarkups.length = 0;
|
|
746
|
+
};
|
|
707
747
|
// Helper to check if pitches match for tie continuation
|
|
708
748
|
const pitchesMatch = (p1, p2) => {
|
|
709
749
|
if (p1.length !== p2.length)
|
|
@@ -714,15 +754,35 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
714
754
|
}
|
|
715
755
|
return true;
|
|
716
756
|
};
|
|
757
|
+
let graceBeamOpen = false; // Whether a grace note <beam> is open (independent of main beam)
|
|
717
758
|
for (const event of voice.events) {
|
|
718
759
|
// Check for beam start/end in this event (including inside tuplets)
|
|
719
760
|
const { beamStart, beamEnd } = getEventBeamMarks(event);
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
761
|
+
// Grace notes have independent beam groups - don't interfere with main beam
|
|
762
|
+
const isGraceNote = event.type === 'note' && event.grace;
|
|
763
|
+
if (isGraceNote) {
|
|
764
|
+
// Grace beam: open/close independently, nested inside parent beam if open
|
|
765
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
766
|
+
if (beamStart && !graceBeamOpen) {
|
|
767
|
+
xml += `${graceBaseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
768
|
+
graceBeamOpen = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
// Close any open grace beam before processing a non-grace event
|
|
773
|
+
if (graceBeamOpen) {
|
|
774
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
775
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
776
|
+
graceBeamOpen = false;
|
|
777
|
+
}
|
|
778
|
+
// Open main beam element if beam starts
|
|
779
|
+
if (beamStart && !beamElementOpen) {
|
|
780
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
781
|
+
beamElementOpen = true;
|
|
782
|
+
}
|
|
724
783
|
}
|
|
725
|
-
const
|
|
784
|
+
const graceIndentBase = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
785
|
+
const currentIndent = graceBeamOpen ? graceIndentBase + ' ' : (beamElementOpen ? baseIndent + ' ' : baseIndent);
|
|
726
786
|
switch (event.type) {
|
|
727
787
|
case 'note': {
|
|
728
788
|
const noteEvent = event;
|
|
@@ -736,9 +796,11 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
736
796
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
737
797
|
? { ...noteEvent, staff: currentStaff }
|
|
738
798
|
: noteEvent;
|
|
739
|
-
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
799
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
740
800
|
xml += result.xml;
|
|
741
801
|
lastNoteId = result.elementId;
|
|
802
|
+
// Flush any pending markups onto this note
|
|
803
|
+
flushPendingMarkups(result.elementId);
|
|
742
804
|
// If there's a pending ottava, start the span on this note
|
|
743
805
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
744
806
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -838,13 +900,18 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
838
900
|
break;
|
|
839
901
|
}
|
|
840
902
|
case 'rest':
|
|
841
|
-
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
903
|
+
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
842
904
|
break;
|
|
843
905
|
case 'tuplet': {
|
|
844
906
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
845
907
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
846
|
-
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
908
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
847
909
|
xml += tupletResult.xml;
|
|
910
|
+
// Flush any pending markups onto the first note of the tuplet
|
|
911
|
+
if (tupletResult.firstNoteId) {
|
|
912
|
+
flushPendingMarkups(tupletResult.firstNoteId);
|
|
913
|
+
lastNoteId = tupletResult.firstNoteId;
|
|
914
|
+
}
|
|
848
915
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
849
916
|
for (const endId of tupletResult.slurEnds) {
|
|
850
917
|
if (currentSlur) {
|
|
@@ -869,7 +936,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
869
936
|
break;
|
|
870
937
|
}
|
|
871
938
|
case 'tremolo':
|
|
872
|
-
xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
939
|
+
xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
873
940
|
break;
|
|
874
941
|
case 'context': {
|
|
875
942
|
const ctx = event;
|
|
@@ -937,23 +1004,43 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
937
1004
|
}
|
|
938
1005
|
break;
|
|
939
1006
|
case 'markup':
|
|
940
|
-
|
|
941
|
-
|
|
1007
|
+
{
|
|
1008
|
+
// Markup needs a note ID to attach to
|
|
942
1009
|
const mkupEvent = event;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1010
|
+
if (lastNoteId) {
|
|
1011
|
+
markups.push({
|
|
1012
|
+
startid: lastNoteId,
|
|
1013
|
+
content: mkupEvent.content,
|
|
1014
|
+
placement: mkupEvent.placement,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
// No note yet - save as pending, will attach to next note
|
|
1019
|
+
pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
|
|
1020
|
+
}
|
|
948
1021
|
}
|
|
949
1022
|
break;
|
|
950
1023
|
}
|
|
951
1024
|
// Close beam element if beam ends
|
|
952
|
-
if (beamEnd
|
|
953
|
-
|
|
954
|
-
|
|
1025
|
+
if (beamEnd) {
|
|
1026
|
+
if (isGraceNote && graceBeamOpen) {
|
|
1027
|
+
// Close grace beam
|
|
1028
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1029
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
1030
|
+
graceBeamOpen = false;
|
|
1031
|
+
}
|
|
1032
|
+
else if (!isGraceNote && beamElementOpen) {
|
|
1033
|
+
// Close main beam
|
|
1034
|
+
xml += `${baseIndent}</beam>\n`;
|
|
1035
|
+
beamElementOpen = false;
|
|
1036
|
+
}
|
|
955
1037
|
}
|
|
956
1038
|
}
|
|
1039
|
+
// Close any unclosed grace beam
|
|
1040
|
+
if (graceBeamOpen) {
|
|
1041
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1042
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
1043
|
+
}
|
|
957
1044
|
// Close any unclosed beam
|
|
958
1045
|
if (beamElementOpen) {
|
|
959
1046
|
xml += `${baseIndent}</beam>\n`;
|
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.54",
|
|
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",
|
|
@@ -329,7 +329,7 @@ case 92:
|
|
|
329
329
|
this.$ = 'auto';
|
|
330
330
|
break;
|
|
331
331
|
case 93:
|
|
332
|
-
this.$ = ($$[$0-1].
|
|
332
|
+
this.$ = ($$[$0-1].map(e => (e.type === 'note' || e.type === 'rest') ? { ...e, grace: true } : e));
|
|
333
333
|
break;
|
|
334
334
|
case 94: case 95:
|
|
335
335
|
this.$ = ({ ...$$[$0], grace: true });
|
|
@@ -501,7 +501,7 @@ stem_cmd
|
|
|
501
501
|
;
|
|
502
502
|
|
|
503
503
|
grace_event
|
|
504
|
-
: CMD_GRACE '{' voice_events '}' -> ($3.
|
|
504
|
+
: CMD_GRACE '{' voice_events '}' -> ($3.map(e => (e.type === 'note' || e.type === 'rest') ? { ...e, grace: true } : e))
|
|
505
505
|
| CMD_GRACE note_event -> ({ ...$2, grace: true })
|
|
506
506
|
| CMD_GRACE rest_event -> ({ ...$2, grace: true })
|
|
507
507
|
;
|
|
@@ -172,10 +172,12 @@ const getKeyAccidentals = (fifths: number): Record<string, string> => {
|
|
|
172
172
|
return result;
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
-
// Convert Pitch to MEI attributes, checking against key signature
|
|
175
|
+
// Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
|
|
176
176
|
// ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
|
|
177
177
|
// The written pitch should be adjusted by subtracting the ottava shift
|
|
178
|
-
|
|
178
|
+
// measureAccidentals: tracks accidentals used earlier in the same measure (keyed by "pname-oct")
|
|
179
|
+
// - mutated to record new accidentals; used to add cancellation naturals
|
|
180
|
+
const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): { pname: string; oct: number; accid?: string; accidGes?: string } => {
|
|
179
181
|
// Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
|
|
180
182
|
// When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
|
|
181
183
|
// For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
|
|
@@ -189,18 +191,40 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
|
|
|
189
191
|
let accid: string | undefined;
|
|
190
192
|
let accidGes: string | undefined;
|
|
191
193
|
|
|
194
|
+
const pitchKey = `${pitch.phonet}-${oct}`;
|
|
195
|
+
|
|
196
|
+
// Check what was previously established for this pitch in this measure
|
|
197
|
+
const prevMeasureAccid = measureAccidentals?.get(pitchKey);
|
|
198
|
+
|
|
192
199
|
if (pitch.accidental) {
|
|
193
200
|
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
194
|
-
if (noteAccid !==
|
|
201
|
+
if (prevMeasureAccid !== undefined && noteAccid !== prevMeasureAccid) {
|
|
202
|
+
// Previous note in this measure had a different accidental - must re-assert
|
|
203
|
+
accid = noteAccid;
|
|
204
|
+
} else if (noteAccid !== keyAccid) {
|
|
195
205
|
// Accidental differs from key signature - display it
|
|
196
206
|
accid = noteAccid;
|
|
197
207
|
}
|
|
198
208
|
// Always set gestural accidental for MIDI generation
|
|
199
209
|
accidGes = noteAccid;
|
|
210
|
+
// Record this accidental for in-measure tracking
|
|
211
|
+
if (measureAccidentals) measureAccidentals.set(pitchKey, noteAccid);
|
|
200
212
|
} else if (keyAccid) {
|
|
201
213
|
// Note has no accidental but key implies one - output natural
|
|
202
|
-
|
|
214
|
+
if (prevMeasureAccid === 'n') {
|
|
215
|
+
// Already cancelled earlier in this measure - no need to show again
|
|
216
|
+
} else {
|
|
217
|
+
accid = 'n';
|
|
218
|
+
}
|
|
203
219
|
accidGes = 'n';
|
|
220
|
+
if (measureAccidentals) measureAccidentals.set(pitchKey, 'n');
|
|
221
|
+
} else if (measureAccidentals) {
|
|
222
|
+
// No explicit accidental, no key accidental - check if earlier note in measure had one
|
|
223
|
+
if (prevMeasureAccid && prevMeasureAccid !== 'n') {
|
|
224
|
+
// Previous note had an accidental - add cancellation natural
|
|
225
|
+
accid = 'n';
|
|
226
|
+
measureAccidentals.set(pitchKey, 'n');
|
|
227
|
+
}
|
|
204
228
|
}
|
|
205
229
|
|
|
206
230
|
return { pname: pitch.phonet, oct, accid, accidGes };
|
|
@@ -457,7 +481,8 @@ const noteEventToMEI = (
|
|
|
457
481
|
tieEnd?: boolean,
|
|
458
482
|
contextStemDir?: StemDirection,
|
|
459
483
|
keyFifths: number = 0,
|
|
460
|
-
ottavaShift: number = 0
|
|
484
|
+
ottavaShift: number = 0,
|
|
485
|
+
measureAccidentals?: Map<string, string>
|
|
461
486
|
): NoteEventResult => {
|
|
462
487
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
463
488
|
const dots = event.duration.dots || 0;
|
|
@@ -492,7 +517,7 @@ const noteEventToMEI = (
|
|
|
492
517
|
|
|
493
518
|
// Single note
|
|
494
519
|
if (event.pitches.length === 1) {
|
|
495
|
-
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
|
|
520
|
+
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
496
521
|
const noteId = generateId('note');
|
|
497
522
|
return {
|
|
498
523
|
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
@@ -533,7 +558,7 @@ const noteEventToMEI = (
|
|
|
533
558
|
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
534
559
|
|
|
535
560
|
for (const p of event.pitches) {
|
|
536
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
561
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
537
562
|
result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
|
|
538
563
|
}
|
|
539
564
|
|
|
@@ -578,7 +603,7 @@ const noteEventToMEI = (
|
|
|
578
603
|
|
|
579
604
|
|
|
580
605
|
// Convert RestEvent to MEI
|
|
581
|
-
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0): string => {
|
|
606
|
+
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): string => {
|
|
582
607
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
583
608
|
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
584
609
|
if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
|
|
@@ -606,6 +631,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
606
631
|
// TupletEventResult - return type for tupletEventToMEI
|
|
607
632
|
interface TupletEventResult {
|
|
608
633
|
xml: string;
|
|
634
|
+
firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
|
|
609
635
|
slurStarts: string[]; // Note IDs that start slurs
|
|
610
636
|
slurEnds: string[]; // Note IDs that end slurs
|
|
611
637
|
dynamics: DynamRef[];
|
|
@@ -632,7 +658,7 @@ const tupletHasInternalBeams = (event: TupletEvent): boolean => {
|
|
|
632
658
|
};
|
|
633
659
|
|
|
634
660
|
// Convert TupletEvent to MEI
|
|
635
|
-
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false): TupletEventResult => {
|
|
661
|
+
const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: number, keyFifths: number = 0, currentStaff?: number, ottavaShift: number = 0, inParentBeam: boolean = false, measureAccidentals?: Map<string, string>): TupletEventResult => {
|
|
636
662
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
637
663
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
638
664
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -647,6 +673,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
647
673
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
648
674
|
|
|
649
675
|
// Collect control event info from notes inside tuplet
|
|
676
|
+
let firstNoteId: string | null = null;
|
|
650
677
|
const slurStarts: string[] = [];
|
|
651
678
|
const slurEnds: string[] = [];
|
|
652
679
|
const dynamics: DynamRef[] = [];
|
|
@@ -677,9 +704,11 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
677
704
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
678
705
|
? { ...noteEvent, staff: effectiveStaff }
|
|
679
706
|
: noteEvent;
|
|
680
|
-
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
707
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
|
|
681
708
|
xml += result.xml;
|
|
682
709
|
|
|
710
|
+
if (!firstNoteId) firstNoteId = result.elementId;
|
|
711
|
+
|
|
683
712
|
// Collect slur info
|
|
684
713
|
if (result.slurStart) slurStarts.push(result.elementId);
|
|
685
714
|
if (result.slurEnd) slurEnds.push(result.elementId);
|
|
@@ -699,7 +728,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
699
728
|
}
|
|
700
729
|
} else if (e.type === 'rest') {
|
|
701
730
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
702
|
-
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift);
|
|
731
|
+
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
703
732
|
}
|
|
704
733
|
}
|
|
705
734
|
|
|
@@ -709,12 +738,12 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
709
738
|
}
|
|
710
739
|
|
|
711
740
|
xml += `${indent}</tuplet>\n`;
|
|
712
|
-
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
741
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
713
742
|
};
|
|
714
743
|
|
|
715
744
|
|
|
716
745
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
717
|
-
const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0): string => {
|
|
746
|
+
const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>): string => {
|
|
718
747
|
const ftremId = generateId('fTrem');
|
|
719
748
|
|
|
720
749
|
// For \repeat tremolo 4 { c16 d16 }:
|
|
@@ -738,7 +767,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
738
767
|
|
|
739
768
|
// First note (or chord)
|
|
740
769
|
if (event.pitchA.length === 1) {
|
|
741
|
-
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
|
|
770
|
+
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
|
|
742
771
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
743
772
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
744
773
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -746,7 +775,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
746
775
|
} else if (event.pitchA.length > 1) {
|
|
747
776
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
748
777
|
for (const p of event.pitchA) {
|
|
749
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
778
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
750
779
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
751
780
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
752
781
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -757,7 +786,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
757
786
|
|
|
758
787
|
// Second note (or chord)
|
|
759
788
|
if (event.pitchB.length === 1) {
|
|
760
|
-
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
|
|
789
|
+
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
|
|
761
790
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
762
791
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
763
792
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -765,7 +794,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
765
794
|
} else if (event.pitchB.length > 1) {
|
|
766
795
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
767
796
|
for (const p of event.pitchB) {
|
|
768
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
797
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
769
798
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
770
799
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
771
800
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -971,6 +1000,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
971
1000
|
const harmonies: HarmonyRef[] = [];
|
|
972
1001
|
const barlines: BarlineRef[] = [];
|
|
973
1002
|
const markups: MarkupRef[] = [];
|
|
1003
|
+
const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
|
|
974
1004
|
|
|
975
1005
|
// Track current stem direction from context changes
|
|
976
1006
|
let currentStemDirection: StemDirection | undefined = undefined;
|
|
@@ -978,9 +1008,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
978
1008
|
// Track current staff for cross-staff notation
|
|
979
1009
|
let currentStaff: number = voice.staff || 1;
|
|
980
1010
|
|
|
1011
|
+
// Track in-measure accidentals for cancellation naturals
|
|
1012
|
+
const measureAccidentals = new Map<string, string>();
|
|
1013
|
+
|
|
981
1014
|
// Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
|
|
982
1015
|
let pendingTiePitches: Pitch[] = [...initialTiePitches];
|
|
983
1016
|
|
|
1017
|
+
// Helper to flush pending markups onto a note ID
|
|
1018
|
+
const flushPendingMarkups = (noteId: string) => {
|
|
1019
|
+
for (const mkup of pendingMarkups) {
|
|
1020
|
+
markups.push({ startid: noteId, content: mkup.content, placement: mkup.placement });
|
|
1021
|
+
}
|
|
1022
|
+
pendingMarkups.length = 0;
|
|
1023
|
+
};
|
|
1024
|
+
|
|
984
1025
|
// Helper to check if pitches match for tie continuation
|
|
985
1026
|
const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
|
|
986
1027
|
if (p1.length !== p2.length) return false;
|
|
@@ -990,17 +1031,39 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
990
1031
|
return true;
|
|
991
1032
|
};
|
|
992
1033
|
|
|
1034
|
+
let graceBeamOpen = false; // Whether a grace note <beam> is open (independent of main beam)
|
|
1035
|
+
|
|
993
1036
|
for (const event of voice.events) {
|
|
994
1037
|
// Check for beam start/end in this event (including inside tuplets)
|
|
995
1038
|
const { beamStart, beamEnd } = getEventBeamMarks(event);
|
|
996
1039
|
|
|
997
|
-
//
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1040
|
+
// Grace notes have independent beam groups - don't interfere with main beam
|
|
1041
|
+
const isGraceNote = event.type === 'note' && (event as NoteEvent).grace;
|
|
1042
|
+
|
|
1043
|
+
if (isGraceNote) {
|
|
1044
|
+
// Grace beam: open/close independently, nested inside parent beam if open
|
|
1045
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1046
|
+
if (beamStart && !graceBeamOpen) {
|
|
1047
|
+
xml += `${graceBaseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
1048
|
+
graceBeamOpen = true;
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
// Close any open grace beam before processing a non-grace event
|
|
1052
|
+
if (graceBeamOpen) {
|
|
1053
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1054
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
1055
|
+
graceBeamOpen = false;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Open main beam element if beam starts
|
|
1059
|
+
if (beamStart && !beamElementOpen) {
|
|
1060
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
1061
|
+
beamElementOpen = true;
|
|
1062
|
+
}
|
|
1001
1063
|
}
|
|
1002
1064
|
|
|
1003
|
-
const
|
|
1065
|
+
const graceIndentBase = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1066
|
+
const currentIndent = graceBeamOpen ? graceIndentBase + ' ' : (beamElementOpen ? baseIndent + ' ' : baseIndent);
|
|
1004
1067
|
|
|
1005
1068
|
switch (event.type) {
|
|
1006
1069
|
case 'note': {
|
|
@@ -1018,10 +1081,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1018
1081
|
? { ...noteEvent, staff: currentStaff }
|
|
1019
1082
|
: noteEvent;
|
|
1020
1083
|
|
|
1021
|
-
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
1084
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1022
1085
|
xml += result.xml;
|
|
1023
1086
|
lastNoteId = result.elementId;
|
|
1024
1087
|
|
|
1088
|
+
// Flush any pending markups onto this note
|
|
1089
|
+
flushPendingMarkups(result.elementId);
|
|
1090
|
+
|
|
1025
1091
|
// If there's a pending ottava, start the span on this note
|
|
1026
1092
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1027
1093
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -1124,14 +1190,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1124
1190
|
break;
|
|
1125
1191
|
}
|
|
1126
1192
|
case 'rest':
|
|
1127
|
-
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1193
|
+
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1128
1194
|
break;
|
|
1129
1195
|
case 'tuplet': {
|
|
1130
1196
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1131
1197
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1132
|
-
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
1198
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
1133
1199
|
xml += tupletResult.xml;
|
|
1134
1200
|
|
|
1201
|
+
// Flush any pending markups onto the first note of the tuplet
|
|
1202
|
+
if (tupletResult.firstNoteId) {
|
|
1203
|
+
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1204
|
+
lastNoteId = tupletResult.firstNoteId;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1135
1207
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
1136
1208
|
for (const endId of tupletResult.slurEnds) {
|
|
1137
1209
|
if (currentSlur) {
|
|
@@ -1159,7 +1231,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1159
1231
|
break;
|
|
1160
1232
|
}
|
|
1161
1233
|
case 'tremolo':
|
|
1162
|
-
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1234
|
+
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1163
1235
|
break;
|
|
1164
1236
|
case 'context': {
|
|
1165
1237
|
const ctx = event as ContextChange;
|
|
@@ -1224,26 +1296,44 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1224
1296
|
harmonies.push({ startid: lastNoteId, text: (event as HarmonyEvent).text });
|
|
1225
1297
|
}
|
|
1226
1298
|
break;
|
|
1227
|
-
case 'markup':
|
|
1228
|
-
// Markup needs a note ID to attach to
|
|
1299
|
+
case 'markup': {
|
|
1300
|
+
// Markup needs a note ID to attach to
|
|
1301
|
+
const mkupEvent = event as MarkupEvent;
|
|
1229
1302
|
if (lastNoteId) {
|
|
1230
|
-
const mkupEvent = event as MarkupEvent;
|
|
1231
1303
|
markups.push({
|
|
1232
1304
|
startid: lastNoteId,
|
|
1233
1305
|
content: mkupEvent.content,
|
|
1234
1306
|
placement: mkupEvent.placement,
|
|
1235
1307
|
});
|
|
1308
|
+
} else {
|
|
1309
|
+
// No note yet - save as pending, will attach to next note
|
|
1310
|
+
pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
|
|
1236
1311
|
}
|
|
1312
|
+
}
|
|
1237
1313
|
break;
|
|
1238
1314
|
}
|
|
1239
1315
|
|
|
1240
1316
|
// Close beam element if beam ends
|
|
1241
|
-
if (beamEnd
|
|
1242
|
-
|
|
1243
|
-
|
|
1317
|
+
if (beamEnd) {
|
|
1318
|
+
if (isGraceNote && graceBeamOpen) {
|
|
1319
|
+
// Close grace beam
|
|
1320
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1321
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
1322
|
+
graceBeamOpen = false;
|
|
1323
|
+
} else if (!isGraceNote && beamElementOpen) {
|
|
1324
|
+
// Close main beam
|
|
1325
|
+
xml += `${baseIndent}</beam>\n`;
|
|
1326
|
+
beamElementOpen = false;
|
|
1327
|
+
}
|
|
1244
1328
|
}
|
|
1245
1329
|
}
|
|
1246
1330
|
|
|
1331
|
+
// Close any unclosed grace beam
|
|
1332
|
+
if (graceBeamOpen) {
|
|
1333
|
+
const graceBaseIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
1334
|
+
xml += `${graceBaseIndent}</beam>\n`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1247
1337
|
// Close any unclosed beam
|
|
1248
1338
|
if (beamElementOpen) {
|
|
1249
1339
|
xml += `${baseIndent}</beam>\n`;
|