@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.
@@ -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
- const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0) => {
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 !== keyAccid) {
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
- accid = 'n';
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
- // Markup needs a note ID to attach to - use the last note if available
941
- if (lastNoteId) {
987
+ {
988
+ // Markup needs a note ID to attach to
942
989
  const mkupEvent = event;
943
- markups.push({
944
- startid: lastNoteId,
945
- content: mkupEvent.content,
946
- placement: mkupEvent.placement,
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.52",
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
- const encodePitch = (pitch: Pitch, keyFifths: number = 0, ottavaShift: number = 0): { pname: string; oct: number; accid?: string; accidGes?: string } => {
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 !== keyAccid) {
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
- accid = 'n';
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 - use the last note if available
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