@k-l-lambda/lilylet 0.1.52 → 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/meiEncoder.js +81 -28
- package/package.json +1 -1
- package/source/lilylet/meiEncoder.ts +78 -24
|
@@ -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)
|
|
@@ -736,9 +776,11 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
736
776
|
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
737
777
|
? { ...noteEvent, staff: currentStaff }
|
|
738
778
|
: noteEvent;
|
|
739
|
-
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
779
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
740
780
|
xml += result.xml;
|
|
741
781
|
lastNoteId = result.elementId;
|
|
782
|
+
// Flush any pending markups onto this note
|
|
783
|
+
flushPendingMarkups(result.elementId);
|
|
742
784
|
// If there's a pending ottava, start the span on this note
|
|
743
785
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
744
786
|
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -838,13 +880,18 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
838
880
|
break;
|
|
839
881
|
}
|
|
840
882
|
case 'rest':
|
|
841
|
-
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
883
|
+
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
842
884
|
break;
|
|
843
885
|
case 'tuplet': {
|
|
844
886
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
845
887
|
// 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);
|
|
888
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
847
889
|
xml += tupletResult.xml;
|
|
890
|
+
// Flush any pending markups onto the first note of the tuplet
|
|
891
|
+
if (tupletResult.firstNoteId) {
|
|
892
|
+
flushPendingMarkups(tupletResult.firstNoteId);
|
|
893
|
+
lastNoteId = tupletResult.firstNoteId;
|
|
894
|
+
}
|
|
848
895
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
849
896
|
for (const endId of tupletResult.slurEnds) {
|
|
850
897
|
if (currentSlur) {
|
|
@@ -869,7 +916,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
869
916
|
break;
|
|
870
917
|
}
|
|
871
918
|
case 'tremolo':
|
|
872
|
-
xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
919
|
+
xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
873
920
|
break;
|
|
874
921
|
case 'context': {
|
|
875
922
|
const ctx = event;
|
|
@@ -937,14 +984,20 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
937
984
|
}
|
|
938
985
|
break;
|
|
939
986
|
case 'markup':
|
|
940
|
-
|
|
941
|
-
|
|
987
|
+
{
|
|
988
|
+
// Markup needs a note ID to attach to
|
|
942
989
|
const mkupEvent = event;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
990
|
+
if (lastNoteId) {
|
|
991
|
+
markups.push({
|
|
992
|
+
startid: lastNoteId,
|
|
993
|
+
content: mkupEvent.content,
|
|
994
|
+
placement: mkupEvent.placement,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
// No note yet - save as pending, will attach to next note
|
|
999
|
+
pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
|
|
1000
|
+
}
|
|
948
1001
|
}
|
|
949
1002
|
break;
|
|
950
1003
|
}
|
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.53",
|
|
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",
|
|
@@ -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;
|
|
@@ -1018,10 +1059,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1018
1059
|
? { ...noteEvent, staff: currentStaff }
|
|
1019
1060
|
: noteEvent;
|
|
1020
1061
|
|
|
1021
|
-
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
1062
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1022
1063
|
xml += result.xml;
|
|
1023
1064
|
lastNoteId = result.elementId;
|
|
1024
1065
|
|
|
1066
|
+
// Flush any pending markups onto this note
|
|
1067
|
+
flushPendingMarkups(result.elementId);
|
|
1068
|
+
|
|
1025
1069
|
// If there's a pending ottava, start the span on this note
|
|
1026
1070
|
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
1027
1071
|
const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
@@ -1124,14 +1168,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1124
1168
|
break;
|
|
1125
1169
|
}
|
|
1126
1170
|
case 'rest':
|
|
1127
|
-
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1171
|
+
xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1128
1172
|
break;
|
|
1129
1173
|
case 'tuplet': {
|
|
1130
1174
|
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
1131
1175
|
// 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);
|
|
1176
|
+
const tupletResult = tupletEventToMEI(event as TupletEvent, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
|
|
1133
1177
|
xml += tupletResult.xml;
|
|
1134
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
|
+
|
|
1135
1185
|
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
1136
1186
|
for (const endId of tupletResult.slurEnds) {
|
|
1137
1187
|
if (currentSlur) {
|
|
@@ -1159,7 +1209,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1159
1209
|
break;
|
|
1160
1210
|
}
|
|
1161
1211
|
case 'tremolo':
|
|
1162
|
-
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift);
|
|
1212
|
+
xml += tremoloEventToMEI(event as TremoloEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
|
|
1163
1213
|
break;
|
|
1164
1214
|
case 'context': {
|
|
1165
1215
|
const ctx = event as ContextChange;
|
|
@@ -1224,16 +1274,20 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1224
1274
|
harmonies.push({ startid: lastNoteId, text: (event as HarmonyEvent).text });
|
|
1225
1275
|
}
|
|
1226
1276
|
break;
|
|
1227
|
-
case 'markup':
|
|
1228
|
-
// 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;
|
|
1229
1280
|
if (lastNoteId) {
|
|
1230
|
-
const mkupEvent = event as MarkupEvent;
|
|
1231
1281
|
markups.push({
|
|
1232
1282
|
startid: lastNoteId,
|
|
1233
1283
|
content: mkupEvent.content,
|
|
1234
1284
|
placement: mkupEvent.placement,
|
|
1235
1285
|
});
|
|
1286
|
+
} else {
|
|
1287
|
+
// No note yet - save as pending, will attach to next note
|
|
1288
|
+
pendingMarkups.push({ content: mkupEvent.content, placement: mkupEvent.placement });
|
|
1236
1289
|
}
|
|
1290
|
+
}
|
|
1237
1291
|
break;
|
|
1238
1292
|
}
|
|
1239
1293
|
|