@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].filter(e => e.type === 'note' || e.type === 'rest').map(e => ({ ...e, grace: true })));
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
- 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)
@@ -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
- // Open beam element if beam starts
721
- if (beamStart && !beamElementOpen) {
722
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
723
- beamElementOpen = true;
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 currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
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
- // Markup needs a note ID to attach to - use the last note if available
941
- if (lastNoteId) {
1007
+ {
1008
+ // Markup needs a note ID to attach to
942
1009
  const mkupEvent = event;
943
- markups.push({
944
- startid: lastNoteId,
945
- content: mkupEvent.content,
946
- placement: mkupEvent.placement,
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 && beamElementOpen) {
953
- xml += `${baseIndent}</beam>\n`;
954
- beamElementOpen = false;
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.52",
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].filter(e => e.type === 'note' || e.type === 'rest').map(e => ({ ...e, grace: true })));
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.filter(e => e.type === 'note' || e.type === 'rest').map(e => ({ ...e, grace: true })))
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
- 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;
@@ -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
- // Open beam element if beam starts
998
- if (beamStart && !beamElementOpen) {
999
- xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
1000
- beamElementOpen = true;
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 currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
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 - use the last note if available
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 && beamElementOpen) {
1242
- xml += `${baseIndent}</beam>\n`;
1243
- beamElementOpen = false;
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`;