@k-l-lambda/lilylet 0.1.67 → 0.1.69

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.
@@ -127,6 +127,7 @@
127
127
  const barlineEvent = (style) => ({ type: 'barline', style });
128
128
  const harmonyEvent = (text) => ({ type: 'harmony', text });
129
129
  const markupEvent = (content, placement) => ({ type: 'markup', content, placement: placement || undefined });
130
+ const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
130
131
  const markupMark = (content) => ({ markType: 'markup', content });
131
132
 
132
133
  // Parse PITCH token (e.g., "c", "cs", "bf", "css", "bff") into phonet and accidental
@@ -445,6 +446,7 @@ event
445
446
  | barline_event
446
447
  | harmony_event
447
448
  | markup_event
449
+ | dynamic_event
448
450
  ;
449
451
 
450
452
  barline_event
@@ -457,6 +459,12 @@ harmony_event
457
459
 
458
460
  markup_event
459
461
  : CMD_MARKUP STRING -> markupEvent($2.slice(1, -1))
462
+ | '^' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'above')
463
+ | '_' CMD_MARKUP STRING -> markupEvent($3.slice(1, -1), 'below')
464
+ ;
465
+
466
+ dynamic_event
467
+ : dynamic_mark -> dynamicEvent($1.type)
460
468
  ;
461
469
 
462
470
  pitch_reset_event
@@ -12,6 +12,7 @@ import {
12
12
  BarlineEvent,
13
13
  HarmonyEvent,
14
14
  MarkupEvent,
15
+ DynamicEvent,
15
16
  Pitch,
16
17
  Clef,
17
18
  Accidental,
@@ -653,9 +654,10 @@ const noteEventToMEI = (
653
654
 
654
655
 
655
656
  // Convert RestEvent to MEI
656
- const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): string => {
657
+ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string } => {
657
658
  const dur = DURATIONS[event.duration.division] || "4";
658
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
659
+ const restId = generateId('rest');
660
+ let attrs = `xml:id="${restId}" dur="${dur}"`;
659
661
  if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
660
662
 
661
663
  // Cross-staff attribute
@@ -669,15 +671,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
669
671
 
670
672
  // Space rest (invisible)
671
673
  if (event.invisible) {
672
- return `${indent}<space ${attrs} />\n`;
674
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
673
675
  }
674
676
 
675
677
  // Full measure rest
676
678
  if (event.fullMeasure) {
677
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
679
+ const mRestId = generateId('mrest');
680
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
678
681
  }
679
682
 
680
- return `${indent}<rest ${attrs} />\n`;
683
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
681
684
  };
682
685
 
683
686
 
@@ -784,7 +787,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
784
787
  }
785
788
  } else if (e.type === 'rest') {
786
789
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
787
- xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
790
+ xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
788
791
  } else if (e.type === 'context') {
789
792
  const ctx = e as ContextChange;
790
793
  if (ctx.clef && ctx.clef !== activeClef) {
@@ -1006,7 +1009,7 @@ interface LayerResult {
1006
1009
 
1007
1010
 
1008
1011
  // Helper: check if an event (or any note inside a tuplet) has beam start/end
1009
- const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
1012
+ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEvent | TremoloEvent | ContextChange | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent | { type: 'pitchReset' }): { beamStart: boolean; beamEnd: boolean } => {
1010
1013
  if (event.type === 'note') {
1011
1014
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
1012
1015
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
@@ -1079,6 +1082,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1079
1082
  const barlines: BarlineRef[] = [];
1080
1083
  const markups: MarkupRef[] = [];
1081
1084
  const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
1085
+ const pendingDynamics: string[] = [];
1082
1086
 
1083
1087
  // Track current stem direction from context changes
1084
1088
  let currentStemDirection: StemDirection | undefined = undefined;
@@ -1100,6 +1104,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1100
1104
  pendingMarkups.length = 0;
1101
1105
  };
1102
1106
 
1107
+ // Helper to flush pending leading dynamics onto a note ID
1108
+ const flushPendingDynamics = (noteId: string) => {
1109
+ for (const label of pendingDynamics) {
1110
+ dynamics.push({ startid: noteId, label });
1111
+ }
1112
+ pendingDynamics.length = 0;
1113
+ };
1114
+
1103
1115
  // Helper to check if pitches match for tie continuation
1104
1116
  const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
1105
1117
  if (p1.length !== p2.length) return false;
@@ -1169,6 +1181,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1169
1181
  // Flush any pending markups onto this note
1170
1182
  flushPendingMarkups(result.elementId);
1171
1183
 
1184
+ // Flush any pending leading dynamics onto this note
1185
+ flushPendingDynamics(result.elementId);
1186
+
1172
1187
  // If there's a pending ottava, start the span on this note
1173
1188
  if (pendingOttava !== null && pendingOttava !== 0) {
1174
1189
  const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -1283,7 +1298,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1283
1298
  case 'rest': {
1284
1299
  // For cross-staff notation: pass staff number if different from voice's home staff
1285
1300
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1286
- xml += restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1301
+ const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1302
+ xml += restResult.xml;
1303
+ // A leading dynamic/markup attaches to the next event, which may be this rest
1304
+ flushPendingMarkups(restResult.elementId);
1305
+ flushPendingDynamics(restResult.elementId);
1306
+ lastNoteId = restResult.elementId;
1287
1307
  break;
1288
1308
  }
1289
1309
  case 'tuplet':
@@ -1301,6 +1321,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1301
1321
  // Flush any pending markups onto the first note of the tuplet
1302
1322
  if (tupletResult.firstNoteId) {
1303
1323
  flushPendingMarkups(tupletResult.firstNoteId);
1324
+ flushPendingDynamics(tupletResult.firstNoteId);
1304
1325
  lastNoteId = tupletResult.firstNoteId;
1305
1326
  }
1306
1327
 
@@ -1416,6 +1437,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1416
1437
  }
1417
1438
  }
1418
1439
  break;
1440
+
1441
+ case 'dynamic': {
1442
+ // Standalone (leading) dynamic - attaches to the following note
1443
+ const dynEvent = event as DynamicEvent;
1444
+ pendingDynamics.push(dynEvent.dynamicType);
1445
+ }
1446
+ break;
1419
1447
  }
1420
1448
 
1421
1449
  // Close beam element if beam ends
@@ -18,6 +18,8 @@ import {
18
18
  TimesEvent,
19
19
  TremoloEvent,
20
20
  BarlineEvent,
21
+ MarkupEvent,
22
+ DynamicEvent,
21
23
  Pitch,
22
24
  Duration,
23
25
  Mark,
@@ -276,13 +278,15 @@ const serializeMarks = (marks: Mark[]): string => {
276
278
  const serializeNoteEvent = (
277
279
  event: NoteEvent,
278
280
  env: PitchEnv,
279
- prevDuration?: Duration
281
+ prevDuration?: Duration,
282
+ suppressGracePrefix: boolean = false
280
283
  ): { str: string; newEnv: PitchEnv } => {
281
284
  const parts: string[] = [];
282
285
  let currentEnv = env;
283
286
 
284
- // Grace note prefix
285
- if (event.grace) {
287
+ // Grace note prefix. When the caller groups consecutive grace notes into a single
288
+ // scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
289
+ if (event.grace && !suppressGracePrefix) {
286
290
  parts.push('\\grace ');
287
291
  }
288
292
 
@@ -562,11 +566,12 @@ const serializeBarlineEvent = (event: BarlineEvent): string => {
562
566
  const serializeEvent = (
563
567
  event: Event,
564
568
  env: PitchEnv,
565
- prevDuration?: Duration
569
+ prevDuration?: Duration,
570
+ suppressGracePrefix: boolean = false
566
571
  ): { str: string; newEnv: PitchEnv } => {
567
572
  switch (event.type) {
568
573
  case 'note':
569
- return serializeNoteEvent(event as NoteEvent, env, prevDuration);
574
+ return serializeNoteEvent(event as NoteEvent, env, prevDuration, suppressGracePrefix);
570
575
  case 'rest':
571
576
  return serializeRestEvent(event as RestEvent, env, prevDuration);
572
577
  case 'context':
@@ -578,6 +583,15 @@ const serializeEvent = (
578
583
  return serializeTremoloEvent(event as TremoloEvent, env);
579
584
  case 'barline':
580
585
  return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
586
+ case 'markup': {
587
+ const mk = event as MarkupEvent;
588
+ const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
589
+ return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
590
+ }
591
+ case 'dynamic': {
592
+ const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
593
+ return { str: dynStr || '', newEnv: env };
594
+ }
581
595
  default:
582
596
  return { str: '', newEnv: env };
583
597
  }
@@ -591,8 +605,12 @@ interface MeasureContext {
591
605
  clef?: Clef;
592
606
  }
593
607
 
594
- // Find first clef in voice events
608
+ // Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
609
+ // a clef context event that appears before any musical event on the home staff.
610
+ // A clef that first appears AFTER a note is a mid-voice change and must be emitted
611
+ // inline where it occurs, not hoisted to the front — so it is not returned here.
595
612
  const findVoiceClef = (voice: Voice): Clef | undefined => {
613
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
596
614
  let activeStaff = voice.staff;
597
615
  for (const event of voice.events) {
598
616
  if (event.type === 'context') {
@@ -601,6 +619,9 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
601
619
  if (ctx.clef && activeStaff === voice.staff) {
602
620
  return ctx.clef;
603
621
  }
622
+ } else if (MUSICAL.has(event.type)) {
623
+ // Reached music on the home staff before any clef — no leading clef.
624
+ if (activeStaff === voice.staff) return undefined;
604
625
  }
605
626
  }
606
627
  return undefined;
@@ -631,7 +652,6 @@ const serializeVoice = (
631
652
  // before any music collapse to the last one (earlier ones are no-ops).
632
653
  // leadStaffScanEnd is the index of the first event that ends this scan —
633
654
  // context{staff} events before this index are skipped in the main loop.
634
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
635
655
  let effectiveInitialStaff = voice.staff;
636
656
  let leadStaffScanEnd = 0;
637
657
  for (let i = 0; i < voice.events.length; i++) {
@@ -653,7 +673,9 @@ const serializeVoice = (
653
673
  leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
654
674
  continue;
655
675
  }
656
- if (MUSICAL_TYPES.has(e.type)) break;
676
+ // Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
677
+ // visible position; a staff switch after it must not be hoisted ahead of it.
678
+ break;
657
679
  }
658
680
 
659
681
  // Output staff command if voice staff differs from current parser staff,
@@ -684,18 +706,20 @@ const serializeVoice = (
684
706
  }
685
707
  }
686
708
 
687
- // Output clef only if not yet emitted or changed for this staff
688
- const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
709
+ // Output clef only if not yet emitted or changed for this staff.
710
+ // Prefer this voice's leading clef (a clef before any music); fall back to the
711
+ // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
712
+ // hoisted here — it is emitted inline at its position.
713
+ const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
689
714
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
690
715
  if (voiceClef && !clefAlreadyEmitted) {
691
716
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
692
717
  if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
693
718
  }
694
- // Skip redundant clef context events if this staff's clef is already established
695
- const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
696
719
 
697
720
  let activeStaff = effectiveInitialStaff;
698
721
  let activeStemDir: StemDirection | undefined;
722
+ let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
699
723
 
700
724
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
701
725
  const event = voice.events[eventIdx];
@@ -719,8 +743,13 @@ const serializeVoice = (
719
743
  }
720
744
  if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
721
745
  if (ctx.staff) { /* same staff but has clef/ottava — fall through to emit them */ }
722
- // Skip clef-only context events if clef already established for this staff
723
- if (clefOutputted && ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo) {
746
+ // Skip a clef-only context event only if it is REDUNDANT i.e. it restates the
747
+ // clef already active for this staff. A clef that differs is a genuine change and
748
+ // must be emitted inline at its position.
749
+ if (
750
+ ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
751
+ emittedClefs?.[ctx.staff || activeStaff] === ctx.clef
752
+ ) {
724
753
  continue;
725
754
  }
726
755
  }
@@ -756,7 +785,19 @@ const serializeVoice = (
756
785
  }
757
786
  }
758
787
 
759
- const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
788
+ const isGraceNote = event.type === 'note' && !!(event as NoteEvent).grace;
789
+
790
+ // Group consecutive grace notes into one scoped \grace { ... } instead of
791
+ // emitting a separate \grace prefix per note.
792
+ if (isGraceNote && !graceGroupOpen) {
793
+ parts.push('\\grace {');
794
+ graceGroupOpen = true;
795
+ } else if (!isGraceNote && graceGroupOpen) {
796
+ parts.push('}');
797
+ graceGroupOpen = false;
798
+ }
799
+
800
+ const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
760
801
  pitchEnv = newEnv;
761
802
 
762
803
  if (eventStr) {
@@ -780,6 +821,11 @@ const serializeVoice = (
780
821
  }
781
822
  }
782
823
 
824
+ // Close a grace group left open at the end of the voice (unusual but possible).
825
+ if (graceGroupOpen) {
826
+ parts.push('}');
827
+ }
828
+
783
829
  return { str: parts.join(' '), newStaff: voice.staff };
784
830
  };
785
831
 
@@ -958,26 +1004,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
958
1004
  currentTime = measure.timeSig;
959
1005
  }
960
1006
 
961
- // Collect clefs from this measure's voices, per part
962
- measure.parts.forEach((part, pi) => {
963
- const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
964
- for (const voice of part.voices) {
965
- let clefActiveStaff = voice.staff;
966
- for (const event of voice.events) {
967
- if (event.type === 'context') {
968
- const ctx = event as ContextChange;
969
- if (ctx.staff) {
970
- clefActiveStaff = ctx.staff;
971
- }
972
- if (ctx.clef) {
973
- staffClefs[clefActiveStaff] = ctx.clef;
1007
+ // Collect clefs from this measure's voices, per part — but only AFTER serializing
1008
+ // the measure, so that during serialization allStaffClefs reflects the clef state
1009
+ // CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
1010
+ // to the measure's front; it is emitted inline at its position).
1011
+ const collectClefs = () => {
1012
+ measure.parts.forEach((part, pi) => {
1013
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
1014
+ for (const voice of part.voices) {
1015
+ let clefActiveStaff = voice.staff;
1016
+ for (const event of voice.events) {
1017
+ if (event.type === 'context') {
1018
+ const ctx = event as ContextChange;
1019
+ if (ctx.staff) {
1020
+ clefActiveStaff = ctx.staff;
1021
+ }
1022
+ if (ctx.clef) {
1023
+ staffClefs[clefActiveStaff] = ctx.clef;
1024
+ }
974
1025
  }
975
1026
  }
976
1027
  }
977
- }
978
- });
1028
+ });
1029
+ };
979
1030
 
980
1031
  const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
1032
+ collectClefs();
981
1033
  // Always include measure, even if empty (use space rest for empty measures)
982
1034
  measureStrs.push(measureStr || 's1');
983
1035
  currentStaff = newStaff;
@@ -278,7 +278,12 @@ export interface MarkupEvent {
278
278
  placement?: Placement; // Optional placement (above/below)
279
279
  }
280
280
 
281
- export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent;
281
+ export interface DynamicEvent {
282
+ type: 'dynamic';
283
+ dynamicType: DynamicType; // Standalone dynamic at a leading position (before any note)
284
+ }
285
+
286
+ export type Event = NoteEvent | RestEvent | ContextChange | TremoloEvent | TupletEvent | TimesEvent | PitchResetEvent | BarlineEvent | HarmonyEvent | MarkupEvent | DynamicEvent;
282
287
 
283
288
  // === Structure ===
284
289