@k-l-lambda/lilylet 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,6 +85,7 @@ const DYNAMIC_MAP = {
85
85
  fff: "fff",
86
86
  sfz: "sfz",
87
87
  rfz: "rfz",
88
+ fp: "fp",
88
89
  };
89
90
  // ID generation state - uses session prefix to prevent collisions in concurrent encoding
90
91
  let idCounter = 0;
@@ -118,10 +119,12 @@ const getKeyAccidentals = (fifths) => {
118
119
  }
119
120
  return result;
120
121
  };
121
- // Convert Pitch to MEI attributes, checking against key signature
122
+ // Convert Pitch to MEI attributes, checking against key signature and in-measure accidentals
122
123
  // ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
123
124
  // The written pitch should be adjusted by subtracting the ottava shift
124
- 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) => {
125
128
  // Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
126
129
  // When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
127
130
  // For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
@@ -132,19 +135,44 @@ const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0) => {
132
135
  // Determine accid (written/displayed) and accid.ges (gestural/sounding)
133
136
  let accid;
134
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);
135
141
  if (pitch.accidental) {
136
142
  const noteAccid = ACCIDENTALS[pitch.accidental];
137
- 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) {
138
148
  // Accidental differs from key signature - display it
139
149
  accid = noteAccid;
140
150
  }
141
151
  // Always set gestural accidental for MIDI generation
142
152
  accidGes = noteAccid;
153
+ // Record this accidental for in-measure tracking
154
+ if (measureAccidentals)
155
+ measureAccidentals.set(pitchKey, noteAccid);
143
156
  }
144
157
  else if (keyAccid) {
145
158
  // Note has no accidental but key implies one - output natural
146
- 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
+ }
147
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
+ }
148
176
  }
149
177
  return { pname: pitch.phonet, oct, accid, accidGes };
150
178
  };
@@ -338,7 +366,7 @@ const extractMarkOptions = (marks) => {
338
366
  return result;
339
367
  };
340
368
  // Convert NoteEvent to MEI
341
- const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0) => {
369
+ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
342
370
  const dur = DURATIONS[event.duration.division] || "4";
343
371
  const dots = event.duration.dots || 0;
344
372
  const markOptions = extractMarkOptions(event.marks);
@@ -372,7 +400,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
372
400
  };
373
401
  // Single note
374
402
  if (event.pitches.length === 1) {
375
- const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
403
+ const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift, measureAccidentals);
376
404
  const noteId = generateId('note');
377
405
  return {
378
406
  xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
@@ -415,7 +443,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
415
443
  }
416
444
  let result = `${indent}<chord ${chordAttrs}>\n`;
417
445
  for (const p of event.pitches) {
418
- const pitch = encodePitch(p, keyFifths, ottavaShift);
446
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
419
447
  result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
420
448
  }
421
449
  // Artics for chord - group by placement
@@ -455,7 +483,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
455
483
  };
456
484
  };
457
485
  // Convert RestEvent to MEI
458
- const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
486
+ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
459
487
  const dur = DURATIONS[event.duration.division] || "4";
460
488
  let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
461
489
  if (event.duration.dots > 0)
@@ -475,8 +503,24 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
475
503
  }
476
504
  return `${indent}<rest ${attrs} />\n`;
477
505
  };
506
+ // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
507
+ // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
508
+ const tupletHasInternalBeams = (event) => {
509
+ let starts = 0;
510
+ let ends = 0;
511
+ for (const e of event.events) {
512
+ if (e.type === 'note') {
513
+ const markOptions = extractMarkOptions(e.marks);
514
+ if (markOptions.beamStart)
515
+ starts++;
516
+ if (markOptions.beamEnd)
517
+ ends++;
518
+ }
519
+ }
520
+ return starts > 0 && starts === ends;
521
+ };
478
522
  // Convert TupletEvent to MEI
479
- 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) => {
480
524
  // LilyPond \times 2/3 means "multiply duration by 2/3"
481
525
  // So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
482
526
  // MEI: num = number of notes written, numbase = normal equivalent
@@ -487,6 +531,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
487
531
  // Effective staff for cross-staff notation
488
532
  const effectiveStaff = currentStaff ?? layerStaff;
489
533
  // Collect control event info from notes inside tuplet
534
+ let firstNoteId = null;
490
535
  const slurStarts = [];
491
536
  const slurEnds = [];
492
537
  const dynamics = [];
@@ -495,18 +540,27 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
495
540
  const mordents = [];
496
541
  const turns = [];
497
542
  const arpeggios = [];
498
- // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
499
- // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
500
- // Beam state is managed by encodeLayer, not here.
543
+ // Handle internal beam groups: if notes have manual beam marks, respect them
544
+ const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
545
+ let beamOpen = false;
501
546
  for (const e of event.events) {
502
547
  if (e.type === 'note') {
503
- // For cross-staff notation: set note's staff if different from layerStaff
504
548
  const noteEvent = e;
549
+ const markOptions = extractMarkOptions(noteEvent.marks);
550
+ // Open beam if this note starts a beam group
551
+ if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
552
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
553
+ beamOpen = true;
554
+ }
555
+ const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
556
+ // For cross-staff notation: set note's staff if different from layerStaff
505
557
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
506
558
  ? { ...noteEvent, staff: effectiveStaff }
507
559
  : noteEvent;
508
- const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
560
+ const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift, measureAccidentals);
509
561
  xml += result.xml;
562
+ if (!firstNoteId)
563
+ firstNoteId = result.elementId;
510
564
  // Collect slur info
511
565
  if (result.slurStart)
512
566
  slurStarts.push(result.elementId);
@@ -525,16 +579,26 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
525
579
  turns.push({ startid: result.elementId });
526
580
  if (result.arpeggio)
527
581
  arpeggios.push({ plist: result.elementId });
582
+ // Close beam if this note ends a beam group
583
+ if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
584
+ xml += `${baseIndent}</beam>\n`;
585
+ beamOpen = false;
586
+ }
528
587
  }
529
588
  else if (e.type === 'rest') {
530
- xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
589
+ const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
590
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
531
591
  }
532
592
  }
593
+ // Close any unclosed beam
594
+ if (beamOpen) {
595
+ xml += `${baseIndent}</beam>\n`;
596
+ }
533
597
  xml += `${indent}</tuplet>\n`;
534
- return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
598
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
535
599
  };
536
600
  // Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
537
- const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
601
+ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
538
602
  const ftremId = generateId('fTrem');
539
603
  // For \repeat tremolo 4 { c16 d16 }:
540
604
  // - count = 4 (repetitions)
@@ -553,7 +617,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
553
617
  let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
554
618
  // First note (or chord)
555
619
  if (event.pitchA.length === 1) {
556
- const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
620
+ const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift, measureAccidentals);
557
621
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
558
622
  if (pitch.accid)
559
623
  attrs += ` accid="${pitch.accid}"`;
@@ -564,7 +628,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
564
628
  else if (event.pitchA.length > 1) {
565
629
  result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
566
630
  for (const p of event.pitchA) {
567
- const pitch = encodePitch(p, keyFifths, ottavaShift);
631
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
568
632
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
569
633
  if (pitch.accid)
570
634
  attrs += ` accid="${pitch.accid}"`;
@@ -576,7 +640,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
576
640
  }
577
641
  // Second note (or chord)
578
642
  if (event.pitchB.length === 1) {
579
- const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
643
+ const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift, measureAccidentals);
580
644
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
581
645
  if (pitch.accid)
582
646
  attrs += ` accid="${pitch.accid}"`;
@@ -587,7 +651,7 @@ const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
587
651
  else if (event.pitchB.length > 1) {
588
652
  result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
589
653
  for (const p of event.pitchB) {
590
- const pitch = encodePitch(p, keyFifths, ottavaShift);
654
+ const pitch = encodePitch(p, keyFifths, ottavaShift, measureAccidentals);
591
655
  let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
592
656
  if (pitch.accid)
593
657
  attrs += ` accid="${pitch.accid}"`;
@@ -608,6 +672,11 @@ const getEventBeamMarks = (event) => {
608
672
  }
609
673
  if (event.type === 'tuplet') {
610
674
  const tuplet = event;
675
+ // If the tuplet has internal beam groups, don't report beam marks to the parent
676
+ // so the parent won't wrap the tuplet in an external <beam>
677
+ if (tupletHasInternalBeams(tuplet)) {
678
+ return { beamStart: false, beamEnd: false };
679
+ }
611
680
  let beamStart = false;
612
681
  let beamEnd = false;
613
682
  for (const e of tuplet.events) {
@@ -659,12 +728,22 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
659
728
  const harmonies = [];
660
729
  const barlines = [];
661
730
  const markups = [];
731
+ const pendingMarkups = [];
662
732
  // Track current stem direction from context changes
663
733
  let currentStemDirection = undefined;
664
734
  // Track current staff for cross-staff notation
665
735
  let currentStaff = voice.staff || 1;
736
+ // Track in-measure accidentals for cancellation naturals
737
+ const measureAccidentals = new Map();
666
738
  // Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
667
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
+ };
668
747
  // Helper to check if pitches match for tie continuation
669
748
  const pitchesMatch = (p1, p2) => {
670
749
  if (p1.length !== p2.length)
@@ -697,9 +776,11 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
697
776
  const effectiveNoteEvent = currentStaff !== voice.staff
698
777
  ? { ...noteEvent, staff: currentStaff }
699
778
  : noteEvent;
700
- const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
779
+ const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift, measureAccidentals);
701
780
  xml += result.xml;
702
781
  lastNoteId = result.elementId;
782
+ // Flush any pending markups onto this note
783
+ flushPendingMarkups(result.elementId);
703
784
  // If there's a pending ottava, start the span on this note
704
785
  if (pendingOttava !== null && pendingOttava !== 0) {
705
786
  const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -799,13 +880,18 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
799
880
  break;
800
881
  }
801
882
  case 'rest':
802
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
883
+ xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
803
884
  break;
804
885
  case 'tuplet': {
805
886
  // Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
806
887
  // Pass beamElementOpen to tuplet so it knows not to create its own beam
807
- const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
888
+ const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen, measureAccidentals);
808
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
+ }
809
895
  // Process slur ends first (to close any pending slurs from before this tuplet)
810
896
  for (const endId of tupletResult.slurEnds) {
811
897
  if (currentSlur) {
@@ -830,7 +916,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
830
916
  break;
831
917
  }
832
918
  case 'tremolo':
833
- xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
919
+ xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals);
834
920
  break;
835
921
  case 'context': {
836
922
  const ctx = event;
@@ -898,14 +984,20 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
898
984
  }
899
985
  break;
900
986
  case 'markup':
901
- // Markup needs a note ID to attach to - use the last note if available
902
- if (lastNoteId) {
987
+ {
988
+ // Markup needs a note ID to attach to
903
989
  const mkupEvent = event;
904
- markups.push({
905
- startid: lastNoteId,
906
- content: mkupEvent.content,
907
- placement: mkupEvent.placement,
908
- });
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
+ }
909
1001
  }
910
1002
  break;
911
1003
  }
@@ -89,6 +89,7 @@ const DYNAMIC_MAP = {
89
89
  fff: '\\fff',
90
90
  sfz: '\\sfz',
91
91
  rfz: '\\rfz',
92
+ fp: '\\fp',
92
93
  };
93
94
  // Hairpin to Lilylet notation
94
95
  const HAIRPIN_MAP = {
@@ -51,7 +51,8 @@ export declare enum DynamicType {
51
51
  ff = "ff",
52
52
  fff = "fff",
53
53
  sfz = "sfz",
54
- rfz = "rfz"
54
+ rfz = "rfz",
55
+ fp = "fp"
55
56
  }
56
57
  export declare enum HairpinType {
57
58
  crescendoStart = "crescendoStart",
@@ -60,6 +60,7 @@ export var DynamicType;
60
60
  DynamicType["fff"] = "fff";
61
61
  DynamicType["sfz"] = "sfz";
62
62
  DynamicType["rfz"] = "rfz";
63
+ DynamicType["fp"] = "fp";
63
64
  })(DynamicType || (DynamicType = {}));
64
65
  export var HairpinType;
65
66
  (function (HairpinType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.51",
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",