@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.
@@ -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,
@@ -578,6 +580,15 @@ const serializeEvent = (
578
580
  return serializeTremoloEvent(event as TremoloEvent, env);
579
581
  case 'barline':
580
582
  return { str: serializeBarlineEvent(event as BarlineEvent), newEnv: env };
583
+ case 'markup': {
584
+ const mk = event as MarkupEvent;
585
+ const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
586
+ return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
587
+ }
588
+ case 'dynamic': {
589
+ const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
590
+ return { str: dynStr || '', newEnv: env };
591
+ }
581
592
  default:
582
593
  return { str: '', newEnv: env };
583
594
  }
@@ -591,8 +602,12 @@ interface MeasureContext {
591
602
  clef?: Clef;
592
603
  }
593
604
 
594
- // Find first clef in voice events
605
+ // Find the voice's leading clef: the clef in effect at the START of the voice, i.e.
606
+ // a clef context event that appears before any musical event on the home staff.
607
+ // A clef that first appears AFTER a note is a mid-voice change and must be emitted
608
+ // inline where it occurs, not hoisted to the front — so it is not returned here.
595
609
  const findVoiceClef = (voice: Voice): Clef | undefined => {
610
+ const MUSICAL = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
596
611
  let activeStaff = voice.staff;
597
612
  for (const event of voice.events) {
598
613
  if (event.type === 'context') {
@@ -601,6 +616,9 @@ const findVoiceClef = (voice: Voice): Clef | undefined => {
601
616
  if (ctx.clef && activeStaff === voice.staff) {
602
617
  return ctx.clef;
603
618
  }
619
+ } else if (MUSICAL.has(event.type)) {
620
+ // Reached music on the home staff before any clef — no leading clef.
621
+ if (activeStaff === voice.staff) return undefined;
604
622
  }
605
623
  }
606
624
  return undefined;
@@ -631,7 +649,6 @@ const serializeVoice = (
631
649
  // before any music collapse to the last one (earlier ones are no-ops).
632
650
  // leadStaffScanEnd is the index of the first event that ends this scan —
633
651
  // context{staff} events before this index are skipped in the main loop.
634
- const MUSICAL_TYPES = new Set(['note', 'rest', 'tuplet', 'times', 'tremolo']);
635
652
  let effectiveInitialStaff = voice.staff;
636
653
  let leadStaffScanEnd = 0;
637
654
  for (let i = 0; i < voice.events.length; i++) {
@@ -653,7 +670,9 @@ const serializeVoice = (
653
670
  leadStaffScanEnd = i + 1; // time/key/stemDir — continue scan
654
671
  continue;
655
672
  }
656
- if (MUSICAL_TYPES.has(e.type)) break;
673
+ // Any other event (note/rest/tuplet/markup/dynamic/harmony/barline/…) has a
674
+ // visible position; a staff switch after it must not be hoisted ahead of it.
675
+ break;
657
676
  }
658
677
 
659
678
  // Output staff command if voice staff differs from current parser staff,
@@ -684,15 +703,16 @@ const serializeVoice = (
684
703
  }
685
704
  }
686
705
 
687
- // Output clef only if not yet emitted or changed for this staff
688
- const voiceClef = allStaffClefs?.[voice.staff] || findVoiceClef(voice);
706
+ // Output clef only if not yet emitted or changed for this staff.
707
+ // Prefer this voice's leading clef (a clef before any music); fall back to the
708
+ // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
709
+ // hoisted here — it is emitted inline at its position.
710
+ const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
689
711
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
690
712
  if (voiceClef && !clefAlreadyEmitted) {
691
713
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
692
714
  if (emittedClefs) emittedClefs[voice.staff] = voiceClef;
693
715
  }
694
- // Skip redundant clef context events if this staff's clef is already established
695
- const clefOutputted = !!voiceClef && !!emittedClefs?.[voice.staff];
696
716
 
697
717
  let activeStaff = effectiveInitialStaff;
698
718
  let activeStemDir: StemDirection | undefined;
@@ -719,8 +739,13 @@ const serializeVoice = (
719
739
  }
720
740
  if (ctx.staff && !ctx.clef && !ctx.ottava) continue; // same staff, pure no-op
721
741
  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) {
742
+ // Skip a clef-only context event only if it is REDUNDANT i.e. it restates the
743
+ // clef already active for this staff. A clef that differs is a genuine change and
744
+ // must be emitted inline at its position.
745
+ if (
746
+ ctx.clef && !ctx.key && !ctx.time && !ctx.ottava && !ctx.stemDirection && !ctx.tempo &&
747
+ emittedClefs?.[ctx.staff || activeStaff] === ctx.clef
748
+ ) {
724
749
  continue;
725
750
  }
726
751
  }
@@ -958,26 +983,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
958
983
  currentTime = measure.timeSig;
959
984
  }
960
985
 
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;
986
+ // Collect clefs from this measure's voices, per part — but only AFTER serializing
987
+ // the measure, so that during serialization allStaffClefs reflects the clef state
988
+ // CARRIED IN from previous measures (a mid-measure clef change must not be hoisted
989
+ // to the measure's front; it is emitted inline at its position).
990
+ const collectClefs = () => {
991
+ measure.parts.forEach((part, pi) => {
992
+ const staffClefs = partStaffClefs[pi] || (partStaffClefs[pi] = {});
993
+ for (const voice of part.voices) {
994
+ let clefActiveStaff = voice.staff;
995
+ for (const event of voice.events) {
996
+ if (event.type === 'context') {
997
+ const ctx = event as ContextChange;
998
+ if (ctx.staff) {
999
+ clefActiveStaff = ctx.staff;
1000
+ }
1001
+ if (ctx.clef) {
1002
+ staffClefs[clefActiveStaff] = ctx.clef;
1003
+ }
974
1004
  }
975
1005
  }
976
1006
  }
977
- }
978
- });
1007
+ });
1008
+ };
979
1009
 
980
1010
  const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
1011
+ collectClefs();
981
1012
  // Always include measure, even if empty (use space rest for empty measures)
982
1013
  measureStrs.push(measureStr || 's1');
983
1014
  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