@k-l-lambda/lilylet 0.1.50 → 0.1.51

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.
@@ -190,6 +190,9 @@ const serializeMarks = (marks) => {
190
190
  parts.push(pedalStr);
191
191
  break;
192
192
  }
193
+ case 'fingering':
194
+ parts.push('-' + mark.finger);
195
+ break;
193
196
  }
194
197
  }
195
198
  return parts.join('');
@@ -449,8 +452,9 @@ const findVoiceClef = (voice) => {
449
452
  // Takes currentStaff (what parser thinks staff is) and returns { str, newStaff }
450
453
  // If isGrandStaff is true, always output \staff command for clarity
451
454
  // 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) => {
455
+ // allStaffClefs is the clef map for all staves (tracked across measures)
456
+ // emittedClefs tracks which clefs have already been output (avoids duplicates)
457
+ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContext, isFirstVoice = false, allStaffClefs, emittedClefs) => {
454
458
  const parts = [];
455
459
  let prevDuration;
456
460
  // Each voice starts fresh from middle C (step=0, octave=0)
@@ -480,22 +484,61 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
480
484
  parts.push('\\time ' + numerator + '/' + denominator);
481
485
  }
482
486
  }
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) {
487
+ // Output clef only if not yet emitted or changed for this staff
488
+ const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
489
+ const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
490
+ if (voiceClef && !clefAlreadyEmitted) {
486
491
  parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
492
+ if (emittedClefs)
493
+ emittedClefs[voice.staff] = voiceClef;
487
494
  }
488
- // Track if we've already output the clef to avoid duplication
489
- let clefOutputted = !!voiceClef;
495
+ // Skip redundant clef context events if this staff's clef is already established
496
+ const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
497
+ let activeStaff = voice.staff;
498
+ let activeStemDir;
490
499
  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') {
500
+ if (event.type === 'context') {
493
501
  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
502
+ // Skip context events that belong to a different staff (cross-staff clef/ottava)
503
+ if (ctx.staff && ctx.staff !== voice.staff) {
504
+ continue;
505
+ }
506
+ // Skip clef-only context events if clef already established for this staff
507
+ if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
496
508
  continue;
497
509
  }
498
510
  }
511
+ if (event.type === 'note') {
512
+ const noteEvt = event;
513
+ // Cross-staff: emit \staff when note's effective staff differs from active
514
+ const effectiveStaff = noteEvt.staff || voice.staff;
515
+ if (effectiveStaff !== activeStaff) {
516
+ activeStaff = effectiveStaff;
517
+ parts.push('\\staff "' + activeStaff + '"');
518
+ // Emit the target staff's clef if it differs from what was last emitted for this staff
519
+ const targetClef = allStaffClefs?.[activeStaff];
520
+ if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
521
+ parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
522
+ if (emittedClefs)
523
+ emittedClefs[activeStaff] = targetClef;
524
+ }
525
+ }
526
+ // Stem direction: emit \stemUp/\stemDown/\stemNeutral on change
527
+ const stemDir = noteEvt.stemDirection;
528
+ if (stemDir !== activeStemDir) {
529
+ if (stemDir === StemDirection.up) {
530
+ parts.push('\\stemUp');
531
+ }
532
+ else if (stemDir === StemDirection.down) {
533
+ parts.push('\\stemDown');
534
+ }
535
+ else if (activeStemDir) {
536
+ // Was set, now undefined → reset to neutral
537
+ parts.push('\\stemNeutral');
538
+ }
539
+ activeStemDir = stemDir;
540
+ }
541
+ }
499
542
  const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
500
543
  pitchEnv = newEnv;
501
544
  if (eventStr) {
@@ -508,12 +551,16 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
508
551
  else if (event.type === 'rest') {
509
552
  prevDuration = event.duration;
510
553
  }
554
+ else if (event.type === 'context' && event.clef && emittedClefs) {
555
+ const ctx = event;
556
+ emittedClefs[ctx.staff || activeStaff] = ctx.clef;
557
+ }
511
558
  }
512
559
  return { str: parts.join(' '), newStaff: voice.staff };
513
560
  };
514
561
  // Serialize a part, tracking staff state across voices
515
562
  // 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) => {
563
+ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext, isFirstPart = false, clefsByStaff, emittedClefs) => {
517
564
  if (part.voices.length === 0) {
518
565
  return { str: '', newStaff: currentStaff };
519
566
  }
@@ -522,10 +569,8 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
522
569
  for (let i = 0; i < part.voices.length; i++) {
523
570
  const voice = part.voices[i];
524
571
  // Pass measureContext to all voices, isFirstVoice for key/time
525
- // Pass staff clef from clefsByStaff map
526
572
  const isFirstVoice = isFirstPart && i === 0;
527
- const staffClef = clefsByStaff?.[voice.staff];
528
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, staffClef);
573
+ const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
529
574
  voiceStrs.push(str);
530
575
  staff = newStaff;
531
576
  }
@@ -534,7 +579,7 @@ const serializePart = (part, currentStaff, isGrandStaff = false, measureContext,
534
579
  };
535
580
  // Serialize a measure, tracking staff state across parts
536
581
  // Always output key/time at start of each measure
537
- const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs) => {
582
+ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false, currentKey, currentTime, staffClefs, emittedClefs) => {
538
583
  const parts = [];
539
584
  // Build measure context for all voices (key/time)
540
585
  // Key and time are written to first voice, clef to all voices based on staff
@@ -548,7 +593,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
548
593
  // Parts
549
594
  let staff = currentStaff;
550
595
  if (measure.parts.length === 1) {
551
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff);
596
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
552
597
  if (partStr) {
553
598
  parts.push(partStr);
554
599
  }
@@ -560,7 +605,7 @@ const serializeMeasure = (measure, _isFirst, currentStaff, isGrandStaff = false,
560
605
  for (let i = 0; i < measure.parts.length; i++) {
561
606
  const part = measure.parts[i];
562
607
  // Pass measureContext to all parts, isFirstPart to first part only
563
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff);
608
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
564
609
  if (str) {
565
610
  partStrs.push(str);
566
611
  }
@@ -620,6 +665,7 @@ export const serializeLilyletDoc = (doc) => {
620
665
  let currentKey;
621
666
  let currentTime;
622
667
  const staffClefs = {}; // Track clef per staff
668
+ const emittedClefs = {}; // Track which clefs have been output
623
669
  for (let i = 0; i < doc.measures.length; i++) {
624
670
  const measure = doc.measures[i];
625
671
  // Update current key/time if measure has them
@@ -634,12 +680,15 @@ export const serializeLilyletDoc = (doc) => {
634
680
  for (const voice of part.voices) {
635
681
  for (const event of voice.events) {
636
682
  if (event.type === 'context' && event.clef) {
637
- staffClefs[voice.staff] = event.clef;
683
+ const ctx = event;
684
+ // Use the event's staff if specified (cross-staff), otherwise the voice's staff
685
+ const clefStaff = ctx.staff || voice.staff;
686
+ staffClefs[clefStaff] = ctx.clef;
638
687
  }
639
688
  }
640
689
  }
641
690
  }
642
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs);
691
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
643
692
  // Always include measure, even if empty (use space rest for empty measures)
644
693
  measureStrs.push(measureStr || 's1');
645
694
  currentStaff = newStaff;
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.51",
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",