@k-l-lambda/lilylet 0.1.67 → 0.1.68

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.
@@ -538,7 +538,8 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
538
538
  // Convert RestEvent to MEI
539
539
  const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals, crossStaff) => {
540
540
  const dur = DURATIONS[event.duration.division] || "4";
541
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
541
+ const restId = generateId('rest');
542
+ let attrs = `xml:id="${restId}" dur="${dur}"`;
542
543
  if (event.duration.dots > 0)
543
544
  attrs += ` dots="${event.duration.dots}"`;
544
545
  // Cross-staff attribute
@@ -551,13 +552,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
551
552
  }
552
553
  // Space rest (invisible)
553
554
  if (event.invisible) {
554
- return `${indent}<space ${attrs} />\n`;
555
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
555
556
  }
556
557
  // Full measure rest
557
558
  if (event.fullMeasure) {
558
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
559
+ const mRestId = generateId('mrest');
560
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
559
561
  }
560
- return `${indent}<rest ${attrs} />\n`;
562
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
561
563
  };
562
564
  // Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
563
565
  // Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
@@ -645,7 +647,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
645
647
  }
646
648
  else if (e.type === 'rest') {
647
649
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
648
- xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
650
+ xml += restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
649
651
  }
650
652
  else if (e.type === 'context') {
651
653
  const ctx = e;
@@ -809,6 +811,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
809
811
  const barlines = [];
810
812
  const markups = [];
811
813
  const pendingMarkups = [];
814
+ const pendingDynamics = [];
812
815
  // Track current stem direction from context changes
813
816
  let currentStemDirection = undefined;
814
817
  // Track current staff for cross-staff notation
@@ -824,6 +827,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
824
827
  }
825
828
  pendingMarkups.length = 0;
826
829
  };
830
+ // Helper to flush pending leading dynamics onto a note ID
831
+ const flushPendingDynamics = (noteId) => {
832
+ for (const label of pendingDynamics) {
833
+ dynamics.push({ startid: noteId, label });
834
+ }
835
+ pendingDynamics.length = 0;
836
+ };
827
837
  // Helper to check if pitches match for tie continuation
828
838
  const pitchesMatch = (p1, p2) => {
829
839
  if (p1.length !== p2.length)
@@ -884,6 +894,8 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
884
894
  }
885
895
  // Flush any pending markups onto this note
886
896
  flushPendingMarkups(result.elementId);
897
+ // Flush any pending leading dynamics onto this note
898
+ flushPendingDynamics(result.elementId);
887
899
  // If there's a pending ottava, start the span on this note
888
900
  if (pendingOttava !== null && pendingOttava !== 0) {
889
901
  const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -996,7 +1008,12 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
996
1008
  case 'rest': {
997
1009
  // For cross-staff notation: pass staff number if different from voice's home staff
998
1010
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
999
- xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1011
+ const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1012
+ xml += restResult.xml;
1013
+ // A leading dynamic/markup attaches to the next event, which may be this rest
1014
+ flushPendingMarkups(restResult.elementId);
1015
+ flushPendingDynamics(restResult.elementId);
1016
+ lastNoteId = restResult.elementId;
1000
1017
  break;
1001
1018
  }
1002
1019
  case 'tuplet':
@@ -1012,6 +1029,7 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1012
1029
  // Flush any pending markups onto the first note of the tuplet
1013
1030
  if (tupletResult.firstNoteId) {
1014
1031
  flushPendingMarkups(tupletResult.firstNoteId);
1032
+ flushPendingDynamics(tupletResult.firstNoteId);
1015
1033
  lastNoteId = tupletResult.firstNoteId;
1016
1034
  }
1017
1035
  // Process slur ends first (to close any pending slurs from before this tuplet)
@@ -1127,6 +1145,13 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
1127
1145
  }
1128
1146
  }
1129
1147
  break;
1148
+ case 'dynamic':
1149
+ {
1150
+ // Standalone (leading) dynamic - attaches to the following note
1151
+ const dynEvent = event;
1152
+ pendingDynamics.push(dynEvent.dynamicType);
1153
+ }
1154
+ break;
1130
1155
  }
1131
1156
  // Close beam element if beam ends
1132
1157
  if (beamEnd) {
@@ -455,12 +455,25 @@ const serializeEvent = (event, env, prevDuration) => {
455
455
  return serializeTremoloEvent(event, env);
456
456
  case 'barline':
457
457
  return { str: serializeBarlineEvent(event), newEnv: env };
458
+ case 'markup': {
459
+ const mk = event;
460
+ const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
461
+ return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
462
+ }
463
+ case 'dynamic': {
464
+ const dynStr = DYNAMIC_MAP[event.dynamicType];
465
+ return { str: dynStr || '', newEnv: env };
466
+ }
458
467
  default:
459
468
  return { str: '', newEnv: env };
460
469
  }
461
470
  };
462
- // Find first clef in voice events
471
+ // Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
472
+ // a clef context event that appears before any musical event on the home staff.
473
+ // A clef that first appears AFTER a note is a mid-voice change and must be emitted
474
+ // inline where it occurs, not hoisted to the front — so it is not returned here.
463
475
  const findVoiceClef = (voice) => {
476
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
464
477
  let activeStaff = voice.staff;
465
478
  for (const event of voice.events) {
466
479
  if (event.type === 'context') {
@@ -471,6 +484,11 @@ const findVoiceClef = (voice) => {
471
484
  return ctx.clef;
472
485
  }
473
486
  }
487
+ else if (MUSICAL.has(event.type)) {
488
+ // Reached music on the home staff before any clef — no leading clef.
489
+ if (activeStaff === voice.staff)
490
+ return undefined;
491
+ }
474
492
  }
475
493
  return undefined;
476
494
  };
@@ -490,7 +508,6 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
490
508
  // before any music collapse to the last one (earlier ones are no-ops).
491
509
  // leadStaffScanEnd is the index of the first event that ends this scan —
492
510
  // context{staff} events before this index are skipped in the main loop.
493
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
494
511
  let effectiveInitialStaff = voice.staff;
495
512
  let leadStaffScanEnd = 0;
496
513
  for (let i = 0; i < voice.events.length; i++) {
@@ -516,8 +533,9 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
516
533
  leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
517
534
  continue;
518
535
  }
519
- if (MUSICAL_TYPES.has(e.type))
520
- break;
536
+ // Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
537
+ // visible position; a staff switch after it must not be hoisted ahead of it.
538
+ break;
521
539
  }
522
540
  // Output staff command if voice staff differs from current parser staff,
523
541
  // or always output if it's a grand staff score for clarity.
@@ -545,16 +563,17 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
545
563
  parts.push('\\time ' + numerator + '/' + denominator);
546
564
  }
547
565
  }
548
- // Output clef only if not yet emitted or changed for this staff
549
- const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
566
+ // Output clef only if not yet emitted or changed for this staff.
567
+ // Prefer this voice's leading clef (a clef before any music); fall back to the
568
+ // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
569
+ // hoisted here — it is emitted inline at its position.
570
+ const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
550
571
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
551
572
  if (voiceClef && !clefAlreadyEmitted) {
552
573
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
553
574
  if (emittedClefs)
554
575
  emittedClefs[voice.staff] = voiceClef;
555
576
  }
556
- // Skip redundant clef context events if this staff's clef is already established
557
- const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
558
577
  let activeStaff = effectiveInitialStaff;
559
578
  let activeStemDir;
560
579
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
@@ -581,8 +600,11 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
581
600
  if (ctx.staff && !ctx.clef && !ctx.ottava)
582
601
  continue; // same staff, pure no-op
583
602
  if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
584
- // Skip clef-only context events if clef already established for this staff
585
- if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
603
+ // Skip a clef-only context event only if it is REDUNDANT i.e. it restates the
604
+ // clef already active for this staff. A clef that differs is a genuine change and
605
+ // must be emitted inline at its position.
606
+ if (ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
607
+ emittedClefs?.[ctx.staff || activeStaff] === ctx.clef) {
586
608
  continue;
587
609
  }
588
610
  }
@@ -775,25 +797,31 @@ export const serializeLilyletDoc = (doc) => {
775
797
  if (measure.timeSig) {
776
798
  currentTime = measure.timeSig;
777
799
  }
778
- // Collect clefs from this measure's voices, per part
779
- measure.parts.forEach((part, pi) => {
780
- const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
781
- for (const voice of part.voices) {
782
- let clefActiveStaff = voice.staff;
783
- for (const event of voice.events) {
784
- if (event.type === 'context') {
785
- const ctx = event;
786
- if (ctx.staff) {
787
- clefActiveStaff = ctx.staff;
788
- }
789
- if (ctx.clef) {
790
- staffClefs[clefActiveStaff] = ctx.clef;
800
+ // Collect clefs from this measure's voices, per part — but only AFTER serializing
801
+ // the measure, so that during serialization allStaffClefs reflects the clef state
802
+ // CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
803
+ // to the measure's front; it is emitted inline at its position).
804
+ const collectClefs = () => {
805
+ measure.parts.forEach((part, pi) => {
806
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
807
+ for (const voice of part.voices) {
808
+ let clefActiveStaff = voice.staff;
809
+ for (const event of voice.events) {
810
+ if (event.type === 'context') {
811
+ const ctx = event;
812
+ if (ctx.staff) {
813
+ clefActiveStaff = ctx.staff;
814
+ }
815
+ if (ctx.clef) {
816
+ staffClefs[clefActiveStaff] = ctx.clef;
817
+ }
791
818
  }
792
819
  }
793
820
  }
794
- }
795
- });
821
+ });
822
+ };
796
823
  const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
824
+ collectClefs();
797
825
  // Always include measure, even if empty (use space rest for empty measures)
798
826
  measureStrs.push(measureStr || 's1');
799
827
  currentStaff = newStaff;
@@ -221,7 +221,11 @@ export interface MarkupEvent {
221
221
  content: string;
222
222
  placement?: Placement;
223
223
  }
224
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
224
+ export interface DynamicEvent {
225
+ type: 'dynamic';
226
+ dynamicType: DynamicType;
227
+ }
228
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
225
229
  export interface Voice {
226
230
  staff: number;
227
231
  events: Event[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
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",
@@ -30,7 +30,6 @@
30
30
  "test": "tsx ./tests/parser.ts",
31
31
  "test:mei": "tsx ./tests/mei.ts",
32
32
  "test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
33
- "test:unit": "tsx ./tests/unit/encodePitch.test.ts",
34
33
  "test:partial": "tsx ./tests/unit/partialWarning.test.ts",
35
34
  "test:decoder": "tsx ./tests/lilypondDecoder.ts",
36
35
  "test:abc": "tsx ./tests/abc-decoder.ts",
@@ -239,7 +239,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
239
239
  <key_signature>"Loc" return 'NAME';
240
240
  <key_signature>"HP" return 'NAME';
241
241
  <key_signature>"Hp" return 'NAME';
242
- <key_signature>[a-z]+[ \t]*=[^\n\]]* {}
242
+ <key_signature>[a-z]+[ \t]*[=][^\n\]]* {}
243
243
  <key_signature>[A-G] return 'A';
244
244
  <key_signature>[A-Z][a-z]+ return 'NAME';
245
245
  <key_signature>[b] return 'FLAT';
@@ -555,7 +555,6 @@ music
555
555
  | music N -> $1
556
556
  | music NAME -> $1
557
557
  | music '^' NAME -> $1
558
- | music '^' -> $1
559
558
  | music '[' N -> $1
560
559
  ;
561
560