@k-l-lambda/lilylet 0.1.50 → 0.1.52

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;
@@ -408,6 +409,11 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
408
409
  if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
409
410
  chordAttrs += ` staff="${noteOptions.staff}"`;
410
411
  }
412
+ if (noteOptions.tremolo) {
413
+ const stemMod = tremoloToStemMod(noteOptions.tremolo);
414
+ if (stemMod)
415
+ chordAttrs += ` stem.mod="${stemMod}"`;
416
+ }
411
417
  let result = `${indent}<chord ${chordAttrs}>\n`;
412
418
  for (const p of event.pitches) {
413
419
  const pitch = encodePitch(p, keyFifths, ottavaShift);
@@ -470,6 +476,22 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
470
476
  }
471
477
  return `${indent}<rest ${attrs} />\n`;
472
478
  };
479
+ // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
480
+ // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
481
+ const tupletHasInternalBeams = (event) => {
482
+ let starts = 0;
483
+ let ends = 0;
484
+ for (const e of event.events) {
485
+ if (e.type === 'note') {
486
+ const markOptions = extractMarkOptions(e.marks);
487
+ if (markOptions.beamStart)
488
+ starts++;
489
+ if (markOptions.beamEnd)
490
+ ends++;
491
+ }
492
+ }
493
+ return starts > 0 && starts === ends;
494
+ };
473
495
  // Convert TupletEvent to MEI
474
496
  const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
475
497
  // LilyPond \times 2/3 means "multiply duration by 2/3"
@@ -490,17 +512,24 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
490
512
  const mordents = [];
491
513
  const turns = [];
492
514
  const arpeggios = [];
493
- // If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
494
- // MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
495
- // Beam state is managed by encodeLayer, not here.
515
+ // Handle internal beam groups: if notes have manual beam marks, respect them
516
+ const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
517
+ let beamOpen = false;
496
518
  for (const e of event.events) {
497
519
  if (e.type === 'note') {
498
- // For cross-staff notation: set note's staff if different from layerStaff
499
520
  const noteEvent = e;
521
+ const markOptions = extractMarkOptions(noteEvent.marks);
522
+ // Open beam if this note starts a beam group
523
+ if (hasInternalBeams && markOptions.beamStart && !beamOpen) {
524
+ xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
525
+ beamOpen = true;
526
+ }
527
+ const noteIndent = beamOpen ? baseIndent + ' ' : baseIndent;
528
+ // For cross-staff notation: set note's staff if different from layerStaff
500
529
  const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
501
530
  ? { ...noteEvent, staff: effectiveStaff }
502
531
  : noteEvent;
503
- const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
532
+ const result = noteEventToMEI(effectiveNoteEvent, noteIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
504
533
  xml += result.xml;
505
534
  // Collect slur info
506
535
  if (result.slurStart)
@@ -520,11 +549,21 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
520
549
  turns.push({ startid: result.elementId });
521
550
  if (result.arpeggio)
522
551
  arpeggios.push({ plist: result.elementId });
552
+ // Close beam if this note ends a beam group
553
+ if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
554
+ xml += `${baseIndent}</beam>\n`;
555
+ beamOpen = false;
556
+ }
523
557
  }
524
558
  else if (e.type === 'rest') {
525
- xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
559
+ const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
560
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift);
526
561
  }
527
562
  }
563
+ // Close any unclosed beam
564
+ if (beamOpen) {
565
+ xml += `${baseIndent}</beam>\n`;
566
+ }
528
567
  xml += `${indent}</tuplet>\n`;
529
568
  return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
530
569
  };
@@ -603,6 +642,11 @@ const getEventBeamMarks = (event) => {
603
642
  }
604
643
  if (event.type === 'tuplet') {
605
644
  const tuplet = event;
645
+ // If the tuplet has internal beam groups, don't report beam marks to the parent
646
+ // so the parent won't wrap the tuplet in an external <beam>
647
+ if (tupletHasInternalBeams(tuplet)) {
648
+ return { beamStart: false, beamEnd: false };
649
+ }
606
650
  let beamStart = false;
607
651
  let beamEnd = false;
608
652
  for (const e of tuplet.events) {
@@ -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 = {
@@ -190,6 +191,9 @@ const serializeMarks = (marks) => {
190
191
  parts.push(pedalStr);
191
192
  break;
192
193
  }
194
+ case 'fingering':
195
+ parts.push('-' + mark.finger);
196
+ break;
193
197
  }
194
198
  }
195
199
  return parts.join('');
@@ -449,8 +453,9 @@ const findVoiceClef = (voice) => {
449
453
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
450
454
  // If isGrandStaff is true, always output \staff command for clarity
451
455
  // measureContext provides key/time for first voice
452
- // staffClef is the clef for this voice's staff (tracked across measures)
453
- const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, staffClef) => {
456
+ // allStaffClefs is the clef map for all staves (tracked across measures)
457
+ // emittedClefs tracks which clefs have already been output (avoids duplicates)
458
+ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, allStaffClefs, emittedClefs) => {
454
459
  const parts = [];
455
460
  let prevDuration;
456
461
  // Each voice starts fresh from middle C (step=0, octave=0)
@@ -480,22 +485,61 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
480
485
  parts.push('\\time ' + numerator + '/' + denominator);
481
486
  }
482
487
  }
483
- // Output clef for every voice (use staff clef tracked across measures, or find from voice events)
484
- const voiceClef = staffClef || findVoiceClef(voice);
485
- if (voiceClef) {
488
+ // Output clef only if not yet emitted or changed for this staff
489
+ const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
490
+ const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
491
+ if (voiceClef && !clefAlreadyEmitted) {
486
492
  parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
493
+ if (emittedClefs)
494
+ emittedClefs[voice.staff] = voiceClef;
487
495
  }
488
- // Track if we've already output the clef to avoid duplication
489
- let clefOutputted = !!voiceClef;
496
+ // Skip redundant clef context events if this staff's clef is already established
497
+ const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
498
+ let activeStaff = voice.staff;
499
+ let activeStemDir;
490
500
  for (const event of voice.events) {
491
- // Skip clef context events if we've already output the clef at the beginning
492
- if (clefOutputted && event.type === 'context') {
501
+ if (event.type === 'context') {
493
502
  const ctx = event;
494
- if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo && !ctx.staff) {
495
- // This is a clef-only context event, skip it
503
+ // Skip context events that belong to a different staff (cross-staff clef/ottava)
504
+ if (ctx.staff && ctx.staff !== voice.staff) {
505
+ continue;
506
+ }
507
+ // Skip clef-only context events if clef already established for this staff
508
+ if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
496
509
  continue;
497
510
  }
498
511
  }
512
+ if (event.type === 'note') {
513
+ const noteEvt = event;
514
+ // Cross-staff: emit \staff when note's effective staff differs from active
515
+ const effectiveStaff = noteEvt.staff || voice.staff;
516
+ if (effectiveStaff !== activeStaff) {
517
+ activeStaff = effectiveStaff;
518
+ parts.push('\\staff "' + activeStaff + '"');
519
+ // Emit the target staff's clef if it differs from what was last emitted for this staff
520
+ const targetClef = allStaffClefs?.[activeStaff];
521
+ if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
522
+ parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
523
+ if (emittedClefs)
524
+ emittedClefs[activeStaff] = targetClef;
525
+ }
526
+ }
527
+ // Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
528
+ const stemDir = noteEvt.stemDirection;
529
+ if (stemDir !== activeStemDir) {
530
+ if (stemDir === StemDirection.up) {
531
+ parts.push('\\stemUp');
532
+ }
533
+ else if (stemDir === StemDirection.down) {
534
+ parts.push('\\stemDown');
535
+ }
536
+ else if (activeStemDir) {
537
+ // Was set, now undefined → reset to neutral
538
+ parts.push('\\stemNeutral');
539
+ }
540
+ activeStemDir = stemDir;
541
+ }
542
+ }
499
543
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
500
544
  pitchEnv = newEnv;
501
545
  if (eventStr) {
@@ -508,12 +552,16 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
508
552
  else if (event.type === 'rest') {
509
553
  prevDuration = event.duration;
510
554
  }
555
+ else if (event.type === 'context' && event.clef && emittedClefs) {
556
+ const ctx = event;
557
+ emittedClefs[ctx.staff || activeStaff] = ctx.clef;
558
+ }
511
559
  }
512
560
  return { str: parts.join(' '), newStaff: voice.staff };
513
561
  };
514
562
  // Serialize a part, tracking staff state across voices
515
563
  // measureContext is passed to all voices (for clef), but key/time only to first voice
516
- const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff) => {
564
+ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff, emittedClefs) => {
517
565
  if (part.voices.length === 0) {
518
566
  return { str: '', newStaff: currentStaff };
519
567
  }
@@ -522,10 +570,8 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
522
570
  for (let i = 0; i < part.voices.length; i++) {
523
571
  const voice = part.voices[i];
524
572
  // Pass measureContext to all voices, isFirstVoice for key/time
525
- // Pass staff clef from clefsByStaff map
526
573
  const isFirstVoice = isFirstPart && i === 0;
527
- const staffClef = clefsByStaff?.[voice.staff];
528
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
574
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
529
575
  voiceStrs.push(str);
530
576
  staff = newStaff;
531
577
  }
@@ -534,7 +580,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
534
580
  };
535
581
  // Serialize a measure, tracking staff state across parts
536
582
  // Always output key/time at start of each measure
537
- const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
583
+ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs, emittedClefs) => {
538
584
  const parts = [];
539
585
  // Build measure context for all voices (key/time)
540
586
  // Key and time are written to first voice, clef to all voices based on staff
@@ -548,7 +594,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
548
594
  // Parts
549
595
  let staff = currentStaff;
550
596
  if (measure.parts.length === 1) {
551
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
597
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
552
598
  if (partStr) {
553
599
  parts.push(partStr);
554
600
  }
@@ -560,7 +606,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
560
606
  for (let i = 0; i < measure.parts.length; i++) {
561
607
  const part = measure.parts[i];
562
608
  // Pass measureContext to all parts, isFirstPart to first part only
563
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
609
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
564
610
  if (str) {
565
611
  partStrs.push(str);
566
612
  }
@@ -620,6 +666,7 @@ export const serializeLilyletDoc = (doc) => {
620
666
  let currentKey;
621
667
  let currentTime;
622
668
  const staffClefs = {}; // Track clef per staff
669
+ const emittedClefs = {}; // Track which clefs have been output
623
670
  for (let i = 0; i < doc.measures.length; i++) {
624
671
  const measure = doc.measures[i];
625
672
  // Update current key/time if measure has them
@@ -634,12 +681,15 @@ export const serializeLilyletDoc = (doc) => {
634
681
  for (const voice of part.voices) {
635
682
  for (const event of voice.events) {
636
683
  if (event.type === 'context' && event.clef) {
637
- staffClefs[voice.staff] = event.clef;
684
+ const ctx = event;
685
+ // Use the event's staff if specified (cross-staff), otherwise the voice's staff
686
+ const clefStaff = ctx.staff || voice.staff;
687
+ staffClefs[clefStaff] = ctx.clef;
638
688
  }
639
689
  }
640
690
  }
641
691
  }
642
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
692
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
643
693
  // Always include measure, even if empty (use space rest for empty measures)
644
694
  measureStrs.push(measureStr || 's1');
645
695
  currentStaff = newStaff;
@@ -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.50",
3
+ "version": "0.1.52",
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",