@k-l-lambda/lilylet 0.1.51 → 0.1.53
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/lilylet/grammar.jison.js +67 -62
- package/lib/lilylet/meiEncoder.js +124 -32
- package/lib/lilylet/serializer.js +1 -0
- package/lib/lilylet/types.d.ts +2 -1
- package/lib/lilylet/types.js +1 -0
- package/package.json +1 -1
- package/source/lilylet/grammar.jison.js +67 -62
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/meiEncoder.ts +125 -28
- package/source/lilylet/serializer.ts +1 -0
- package/source/lilylet/types.ts +1 -0
|
@@ -125,6 +125,7 @@ const DYNAMIC_MAP: Record<string, string> = {
|
|
|
125
125
|
fff: "fff",
|
|
126
126
|
sfz: "sfz",
|
|
127
127
|
rfz: "rfz",
|
|
128
|
+
fp: "fp",
|
|
128
129
|
};
|
|
129
130
|
|
|
130
131
|
|
|
@@ -171,10 +172,12 @@ const getKeyAccidentals = (fifths: number): Record<string, string> => {
|
|
|
171
172
|
return result;
|
|
172
173
|
};
|
|
173
174
|
|
|
174
|
-
// Convert Pitch to MEI attributes, checking against key signature
|
|
175
|
+
// Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
|
|
175
176
|
// ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
|
|
176
177
|
// The written pitch should be adjusted by subtracting the ottava shift
|
|
177
|
-
|
|
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 } => {
|
|
178
181
|
// Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
|
|
179
182
|
// When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
|
|
180
183
|
// For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
|
|
@@ -188,18 +191,40 @@ const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number =
|
|
|
188
191
|
let accid: string | undefined;
|
|
189
192
|
let accidGes: string | undefined;
|
|
190
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
|
+
|
|
191
199
|
if (pitch.accidental) {
|
|
192
200
|
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
193
|
-
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) {
|
|
194
205
|
// Accidental differs from key signature - display it
|
|
195
206
|
accid = noteAccid;
|
|
196
207
|
}
|
|
197
208
|
// Always set gestural accidental for MIDI generation
|
|
198
209
|
accidGes = noteAccid;
|
|
210
|
+
// Record this accidental for in-measure tracking
|
|
211
|
+
if (measureAccidentals) measureAccidentals.set(pitchKey, noteAccid);
|
|
199
212
|
} else if (keyAccid) {
|
|
200
213
|
// Note has no accidental but key implies one - output natural
|
|
201
|
-
|
|
214
|
+
if (prevMeasureAccid === 'n') {
|
|
215
|
+
// Already cancelled earlier in this measure - no need to show again
|
|
216
|
+
} else {
|
|
217
|
+
accid = 'n';
|
|
218
|
+
}
|
|
202
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
|
+
}
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
return { pname: pitch.phonet, oct, accid, accidGes };
|
|
@@ -456,7 +481,8 @@ const noteEventToMEI = (
|
|
|
456
481
|
tieEnd?: boolean,
|
|
457
482
|
contextStemDir?: StemDirection,
|
|
458
483
|
keyFifths: number = 0,
|
|
459
|
-
ottavaShift: number = 0
|
|
484
|
+
ottavaShift: number = 0,
|
|
485
|
+
measureAccidentals?: Map<string, string>
|
|
460
486
|
): NoteEventResult => {
|
|
461
487
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
462
488
|
const dots = event.duration.dots || 0;
|
|
@@ -491,7 +517,7 @@ const noteEventToMEI = (
|
|
|
491
517
|
|
|
492
518
|
// Single note
|
|
493
519
|
if (event.pitches.length === 1) {
|
|
494
|
-
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
|
|
520
|
+
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
|
|
495
521
|
const noteId = generateId('note');
|
|
496
522
|
return {
|
|
497
523
|
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
@@ -532,7 +558,7 @@ const noteEventToMEI = (
|
|
|
532
558
|
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
533
559
|
|
|
534
560
|
for (const p of event.pitches) {
|
|
535
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
561
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
536
562
|
result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
|
|
537
563
|
}
|
|
538
564
|
|
|
@@ -577,7 +603,7 @@ const noteEventToMEI = (
|
|
|
577
603
|
|
|
578
604
|
|
|
579
605
|
// Convert RestEvent to MEI
|
|
580
|
-
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 => {
|
|
581
607
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
582
608
|
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
583
609
|
if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
|
|
@@ -605,6 +631,7 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
605
631
|
// TupletEventResult - return type for tupletEventToMEI
|
|
606
632
|
interface TupletEventResult {
|
|
607
633
|
xml: string;
|
|
634
|
+
firstNoteId: string | null; // ID of first note in tuplet (for attaching pending markups)
|
|
608
635
|
slurStarts: string[]; // Note IDs that start slurs
|
|
609
636
|
slurEnds: string[]; // Note IDs that end slurs
|
|
610
637
|
dynamics: DynamRef[];
|
|
@@ -615,8 +642,23 @@ interface TupletEventResult {
|
|
|
615
642
|
arpeggios: ArpegRef[];
|
|
616
643
|
}
|
|
617
644
|
|
|
645
|
+
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
646
|
+
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
647
|
+
const tupletHasInternalBeams = (event: TupletEvent): boolean => {
|
|
648
|
+
let starts = 0;
|
|
649
|
+
let ends = 0;
|
|
650
|
+
for (const e of event.events) {
|
|
651
|
+
if (e.type === 'note') {
|
|
652
|
+
const markOptions = extractMarkOptions((e as NoteEvent).marks);
|
|
653
|
+
if (markOptions.beamStart) starts++;
|
|
654
|
+
if (markOptions.beamEnd) ends++;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return starts > 0 && starts === ends;
|
|
658
|
+
};
|
|
659
|
+
|
|
618
660
|
// Convert TupletEvent to MEI
|
|
619
|
-
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 => {
|
|
620
662
|
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
621
663
|
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
622
664
|
// MEI: num = number of notes written, numbase = normal equivalent
|
|
@@ -631,6 +673,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
631
673
|
const effectiveStaff = currentStaff ?? layerStaff;
|
|
632
674
|
|
|
633
675
|
// Collect control event info from notes inside tuplet
|
|
676
|
+
let firstNoteId: string | null = null;
|
|
634
677
|
const slurStarts: string[] = [];
|
|
635
678
|
const slurEnds: string[] = [];
|
|
636
679
|
const dynamics: DynamRef[] = [];
|
|
@@ -640,20 +683,32 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
640
683
|
const turns: TurnRef[] = [];
|
|
641
684
|
const arpeggios: ArpegRef[] = [];
|
|
642
685
|
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
686
|
+
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
687
|
+
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
688
|
+
let beamOpen = false;
|
|
646
689
|
|
|
647
690
|
for (const e of event.events) {
|
|
648
691
|
if (e.type === 'note') {
|
|
649
|
-
// For cross-staff notation: set note's staff if different from layerStaff
|
|
650
692
|
const noteEvent = e as NoteEvent;
|
|
693
|
+
const markOptions = extractMarkOptions(noteEvent.marks);
|
|
694
|
+
|
|
695
|
+
// Open beam if this note starts a beam group
|
|
696
|
+
if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
|
|
697
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
698
|
+
beamOpen = true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
702
|
+
|
|
703
|
+
// For cross-staff notation: set note's staff if different from layerStaff
|
|
651
704
|
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
652
705
|
? { ...noteEvent, staff: effectiveStaff }
|
|
653
706
|
: noteEvent;
|
|
654
|
-
const result = noteEventToMEI(effectiveNoteEvent,
|
|
707
|
+
const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
|
|
655
708
|
xml += result.xml;
|
|
656
709
|
|
|
710
|
+
if (!firstNoteId) firstNoteId = result.elementId;
|
|
711
|
+
|
|
657
712
|
// Collect slur info
|
|
658
713
|
if (result.slurStart) slurStarts.push(result.elementId);
|
|
659
714
|
if (result.slurEnd) slurEnds.push(result.elementId);
|
|
@@ -665,18 +720,30 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
665
720
|
if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
|
|
666
721
|
if (result.turn) turns.push({ startid: result.elementId });
|
|
667
722
|
if (result.arpeggio) arpeggios.push({ plist: result.elementId });
|
|
723
|
+
|
|
724
|
+
// Close beam if this note ends a beam group
|
|
725
|
+
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
726
|
+
xml += `${baseIndent}</beam>\n`;
|
|
727
|
+
beamOpen = false;
|
|
728
|
+
}
|
|
668
729
|
} else if (e.type === 'rest') {
|
|
669
|
-
|
|
730
|
+
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
731
|
+
xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
670
732
|
}
|
|
671
733
|
}
|
|
672
734
|
|
|
735
|
+
// Close any unclosed beam
|
|
736
|
+
if (beamOpen) {
|
|
737
|
+
xml += `${baseIndent}</beam>\n`;
|
|
738
|
+
}
|
|
739
|
+
|
|
673
740
|
xml += `${indent}</tuplet>\n`;
|
|
674
|
-
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
741
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
675
742
|
};
|
|
676
743
|
|
|
677
744
|
|
|
678
745
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
679
|
-
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 => {
|
|
680
747
|
const ftremId = generateId('fTrem');
|
|
681
748
|
|
|
682
749
|
// For \repeat tremolo 4 { c16 d16 }:
|
|
@@ -700,7 +767,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
700
767
|
|
|
701
768
|
// First note (or chord)
|
|
702
769
|
if (event.pitchA.length === 1) {
|
|
703
|
-
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
|
|
770
|
+
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
|
|
704
771
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
705
772
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
706
773
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -708,7 +775,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
708
775
|
} else if (event.pitchA.length > 1) {
|
|
709
776
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
710
777
|
for (const p of event.pitchA) {
|
|
711
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
778
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
712
779
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
713
780
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
714
781
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -719,7 +786,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
719
786
|
|
|
720
787
|
// Second note (or chord)
|
|
721
788
|
if (event.pitchB.length === 1) {
|
|
722
|
-
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
|
|
789
|
+
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
|
|
723
790
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
724
791
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
725
792
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -727,7 +794,7 @@ const tremoloEventToMEI = (event: TremoloEvent, indent: string, keyFifths: numbe
|
|
|
727
794
|
} else if (event.pitchB.length > 1) {
|
|
728
795
|
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
729
796
|
for (const p of event.pitchB) {
|
|
730
|
-
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
797
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
|
|
731
798
|
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
732
799
|
if (pitch.accid) attrs += ` accid="${pitch.accid}"`;
|
|
733
800
|
if (pitch.accidGes) attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
@@ -869,6 +936,11 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TremoloE
|
|
|
869
936
|
}
|
|
870
937
|
if (event.type === 'tuplet') {
|
|
871
938
|
const tuplet = event as TupletEvent;
|
|
939
|
+
// If the tuplet has internal beam groups, don't report beam marks to the parent
|
|
940
|
+
// so the parent won't wrap the tuplet in an external <beam>
|
|
941
|
+
if (tupletHasInternalBeams(tuplet)) {
|
|
942
|
+
return { beamStart: false, beamEnd: false };
|
|
943
|
+
}
|
|
872
944
|
let beamStart = false;
|
|
873
945
|
let beamEnd = false;
|
|
874
946
|
for (const e of tuplet.events) {
|
|
@@ -928,6 +1000,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
928
1000
|
const harmonies: HarmonyRef[] = [];
|
|
929
1001
|
const barlines: BarlineRef[] = [];
|
|
930
1002
|
const markups: MarkupRef[] = [];
|
|
1003
|
+
const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
|
|
931
1004
|
|
|
932
1005
|
// Track current stem direction from context changes
|
|
933
1006
|
let currentStemDirection: StemDirection | undefined = undefined;
|
|
@@ -935,9 +1008,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
935
1008
|
// Track current staff for cross-staff notation
|
|
936
1009
|
let currentStaff: number = voice.staff || 1;
|
|
937
1010
|
|
|
1011
|
+
// Track in-measure accidentals for cancellation naturals
|
|
1012
|
+
const measureAccidentals = new Map<string, string>();
|
|
1013
|
+
|
|
938
1014
|
// Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
|
|
939
1015
|
let pendingTiePitches: Pitch[] = [...initialTiePitches];
|
|
940
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
|
+
|
|
941
1025
|
// Helper to check if pitches match for tie continuation
|
|
942
1026
|
const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
|
|
943
1027
|
if (p1.length !== p2.length) return false;
|
|
@@ -975,10 +1059,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
975
1059
|
? { ...noteEvent, staff: currentStaff }
|
|
976
1060
|
: noteEvent;
|
|
977
1061
|
|
|
978
|
-
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
1062
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
979
1063
|
xml += result.xml;
|
|
980
1064
|
lastNoteId = result.elementId;
|
|
981
1065
|
|
|
1066
|
+
// Flush any pending markups onto this note
|
|
1067
|
+
flushPendingMarkups(result.elementId);
|
|
1068
|
+
|
|
982
1069
|
// If there's a pending ottava, start the span on this note
|
|
983
1070
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
984
1071
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -1081,14 +1168,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1081
1168
|
break;
|
|
1082
1169
|
}
|
|
1083
1170
|
case 'rest':
|
|
1084
|
-
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1171
|
+
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1085
1172
|
break;
|
|
1086
1173
|
case 'tuplet': {
|
|
1087
1174
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1088
1175
|
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
1089
|
-
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
1176
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
1090
1177
|
xml += tupletResult.xml;
|
|
1091
1178
|
|
|
1179
|
+
// Flush any pending markups onto the first note of the tuplet
|
|
1180
|
+
if (tupletResult.firstNoteId) {
|
|
1181
|
+
flushPendingMarkups(tupletResult.firstNoteId);
|
|
1182
|
+
lastNoteId = tupletResult.firstNoteId;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1092
1185
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
1093
1186
|
for (const endId of tupletResult.slurEnds) {
|
|
1094
1187
|
if (currentSlur) {
|
|
@@ -1116,7 +1209,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1116
1209
|
break;
|
|
1117
1210
|
}
|
|
1118
1211
|
case 'tremolo':
|
|
1119
|
-
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1212
|
+
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1120
1213
|
break;
|
|
1121
1214
|
case 'context': {
|
|
1122
1215
|
const ctx = event as ContextChange;
|
|
@@ -1181,16 +1274,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1181
1274
|
harmonies.push({ startid: lastNoteId, text: (event as HarmonyEvent).text });
|
|
1182
1275
|
}
|
|
1183
1276
|
break;
|
|
1184
|
-
case 'markup':
|
|
1185
|
-
// Markup needs a note ID to attach to
|
|
1277
|
+
case 'markup': {
|
|
1278
|
+
// Markup needs a note ID to attach to
|
|
1279
|
+
const mkupEvent = event as MarkupEvent;
|
|
1186
1280
|
if (lastNoteId) {
|
|
1187
|
-
const mkupEvent = event as MarkupEvent;
|
|
1188
1281
|
markups.push({
|
|
1189
1282
|
startid: lastNoteId,
|
|
1190
1283
|
content: mkupEvent.content,
|
|
1191
1284
|
placement: mkupEvent.placement,
|
|
1192
1285
|
});
|
|
1286
|
+
} else {
|
|
1287
|
+
// No note yet - save as pending, will attach to next note
|
|
1288
|
+
pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
|
|
1193
1289
|
}
|
|
1290
|
+
}
|
|
1194
1291
|
break;
|
|
1195
1292
|
}
|
|
1196
1293
|
|