@k-l-lambda/lilylet 0.1.66 → 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,
@@ -79,6 +80,41 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
79
80
  };
80
81
 
81
82
 
83
+ // Resolve a clef string into MEI shape/line plus optional octave displacement.
84
+ // Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
85
+ // the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
86
+ // and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
87
+ // and dis.place ("below" | "above").
88
+ const resolveClef = (clefStr: string): { shape: string; line: number; dis?: "8" | "15"; disPlace?: "above" | "below" } => {
89
+ const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
90
+ const base = match ? match[1] : clefStr;
91
+ const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
92
+ if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
93
+ return {
94
+ shape: clefInfo.shape,
95
+ line: clefInfo.line,
96
+ dis: match[3] as "8" | "15",
97
+ disPlace: match[2] === "^" ? "above" : "below",
98
+ };
99
+ };
100
+
101
+ // Attributes for a standalone <clef> element (mid-measure clef change).
102
+ const clefElementAttrs = (clefStr: string): string => {
103
+ const c = resolveClef(clefStr);
104
+ let attrs = `shape="${c.shape}" line="${c.line}"`;
105
+ if (c.dis) attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
106
+ return attrs;
107
+ };
108
+
109
+ // Attributes for a <staffDef> clef (clef.* namespace).
110
+ const staffDefClefAttrs = (clefStr: string): string => {
111
+ const c = resolveClef(clefStr);
112
+ let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
113
+ if (c.dis) attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
114
+ return attrs;
115
+ };
116
+
117
+
82
118
  // Lilylet duration division to MEI dur
83
119
  // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
84
120
  const DURATIONS: Record<number, string> = {
@@ -618,9 +654,10 @@ const noteEventToMEI = (
618
654
 
619
655
 
620
656
  // Convert RestEvent to MEI
621
- 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 } => {
622
658
  const dur = DURATIONS[event.duration.division] || "4";
623
- let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
659
+ const restId = generateId('rest');
660
+ let attrs = `xml:id="${restId}" dur="${dur}"`;
624
661
  if (event.duration.dots > 0) attrs += ` dots="${event.duration.dots}"`;
625
662
 
626
663
  // Cross-staff attribute
@@ -634,15 +671,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
634
671
 
635
672
  // Space rest (invisible)
636
673
  if (event.invisible) {
637
- return `${indent}<space ${attrs} />\n`;
674
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
638
675
  }
639
676
 
640
677
  // Full measure rest
641
678
  if (event.fullMeasure) {
642
- return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
679
+ const mRestId = generateId('mrest');
680
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
643
681
  }
644
682
 
645
- return `${indent}<rest ${attrs} />\n`;
683
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
646
684
  };
647
685
 
648
686
 
@@ -749,7 +787,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
749
787
  }
750
788
  } else if (e.type === 'rest') {
751
789
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
752
- xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
790
+ xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
753
791
  } else if (e.type === 'context') {
754
792
  const ctx = e as ContextChange;
755
793
  if (ctx.clef && ctx.clef !== activeClef) {
@@ -757,8 +795,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
757
795
  const effectiveStaffNum = effectiveStaff ?? layerStaffNum;
758
796
  if (effectiveStaffNum === layerStaffNum) {
759
797
  const clefIndent = beamOpen ? baseIndent + ' ' : baseIndent;
760
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
761
- xml += `${clefIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
798
+ xml += `${clefIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
762
799
  }
763
800
  activeClef = ctx.clef;
764
801
  endingClef = ctx.clef;
@@ -972,7 +1009,7 @@ interface LayerResult {
972
1009
 
973
1010
 
974
1011
  // Helper: check if an event (or any note inside a tuplet) has beam start/end
975
- 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 } => {
976
1013
  if (event.type === 'note') {
977
1014
  const markOptions = extractMarkOptions((event as NoteEvent).marks);
978
1015
  return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
@@ -1045,6 +1082,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1045
1082
  const barlines: BarlineRef[] = [];
1046
1083
  const markups: MarkupRef[] = [];
1047
1084
  const pendingMarkups: { content: string; placement?: 'above' | 'below' }[] = [];
1085
+ const pendingDynamics: string[] = [];
1048
1086
 
1049
1087
  // Track current stem direction from context changes
1050
1088
  let currentStemDirection: StemDirection | undefined = undefined;
@@ -1066,6 +1104,14 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1066
1104
  pendingMarkups.length = 0;
1067
1105
  };
1068
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
+
1069
1115
  // Helper to check if pitches match for tie continuation
1070
1116
  const pitchesMatch = (p1: Pitch[], p2: Pitch[]): boolean => {
1071
1117
  if (p1.length !== p2.length) return false;
@@ -1135,6 +1181,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1135
1181
  // Flush any pending markups onto this note
1136
1182
  flushPendingMarkups(result.elementId);
1137
1183
 
1184
+ // Flush any pending leading dynamics onto this note
1185
+ flushPendingDynamics(result.elementId);
1186
+
1138
1187
  // If there's a pending ottava, start the span on this note
1139
1188
  if (pendingOttava !== null && pendingOttava !== 0) {
1140
1189
  const dis: 8 | 15 = Math.abs(pendingOttava) === 2 ? 15 : 8;
@@ -1249,7 +1298,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1249
1298
  case 'rest': {
1250
1299
  // For cross-staff notation: pass staff number if different from voice's home staff
1251
1300
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1252
- 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;
1253
1307
  break;
1254
1308
  }
1255
1309
  case 'tuplet':
@@ -1267,6 +1321,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1267
1321
  // Flush any pending markups onto the first note of the tuplet
1268
1322
  if (tupletResult.firstNoteId) {
1269
1323
  flushPendingMarkups(tupletResult.firstNoteId);
1324
+ flushPendingDynamics(tupletResult.firstNoteId);
1270
1325
  lastNoteId = tupletResult.firstNoteId;
1271
1326
  }
1272
1327
 
@@ -1306,8 +1361,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1306
1361
  if (ctx.clef && ctx.clef !== currentClef) {
1307
1362
  const layerStaff = voice.staff || 1;
1308
1363
  if (currentStaff === layerStaff) {
1309
- const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
1310
- xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
1364
+ xml += `${currentIndent}<clef xml:id="${generateId('clef')}" ${clefElementAttrs(ctx.clef)} />\n`;
1311
1365
  }
1312
1366
  currentClef = ctx.clef;
1313
1367
  }
@@ -1383,6 +1437,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1383
1437
  }
1384
1438
  }
1385
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;
1386
1447
  }
1387
1448
 
1388
1449
  // Close beam element if beam ends
@@ -1959,16 +2020,14 @@ const encodeScoreDef = (
1959
2020
  for (let ls = 1; ls <= info.maxStaff; ls++) {
1960
2021
  const globalStaff = info.staffOffset + ls;
1961
2022
  const clef = info.clefs[ls] || Clef.treble;
1962
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1963
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
2023
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1964
2024
  }
1965
2025
  xml += `${indent} </staffGrp>\n`;
1966
2026
  } else {
1967
2027
  // Single staff part
1968
2028
  const globalStaff = info.staffOffset + 1;
1969
2029
  const clef = info.clefs[1] || Clef.treble;
1970
- const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
1971
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
2030
+ xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1972
2031
  }
1973
2032
  }
1974
2033
 
@@ -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,
@@ -383,7 +385,7 @@ const serializeContextChange = (event: ContextChange): string => {
383
385
 
384
386
  // Clef
385
387
  if (event.clef) {
386
- parts.push('\\clef "' + CLEF_MAP[event.clef] + '"');
388
+ parts.push('\\clef "' + (CLEF_MAP[event.clef] ?? event.clef) + '"');
387
389
  }
388
390
 
389
391
  // Key signature
@@ -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
- parts.push('\\clef "' + CLEF_MAP[voiceClef] + '"');
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;
@@ -712,15 +732,20 @@ const serializeVoice = (
712
732
  // Emit target staff clef if the event carries one or allStaffClefs knows it
713
733
  const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
714
734
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
715
- parts.push('\\clef "' + CLEF_MAP[ctxClef] + '"');
735
+ parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
716
736
  if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
717
737
  }
718
738
  continue;
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
  }
@@ -736,7 +761,7 @@ const serializeVoice = (
736
761
  // Emit the target staff's clef if it differs from what was last emitted for this staff
737
762
  const targetClef = allStaffClefs?.[activeStaff];
738
763
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
739
- parts.push('\\clef "' + CLEF_MAP[targetClef] + '"');
764
+ parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
740
765
  if (emittedClefs) emittedClefs[activeStaff] = targetClef;
741
766
  }
742
767
  }
@@ -802,11 +827,16 @@ const serializePart = (
802
827
  const voiceStrs: string[] = [];
803
828
  let staff = currentStaff;
804
829
 
830
+ // A part is a grand staff only if its voices span more than one staff.
831
+ // Only then do we force \staff on every voice; single-staff parts emit \staff
832
+ // solely when the staff actually changes (e.g. resetting after a prior grand staff).
833
+ const partIsGrandStaff = new Set(part.voices.map(v => v.staff)).size > 1;
834
+
805
835
  for (let i = 0; i < part.voices.length; i++) {
806
836
  const voice = part.voices[i];
807
837
  // Pass measureContext to all voices, isFirstVoice for key/time
808
838
  const isFirstVoice = isFirstPart && i === 0;
809
- const { str, newStaff } = serializeVoice(voice, staff, isGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
839
+ const { str, newStaff } = serializeVoice(voice, staff, partIsGrandStaff, measureContext, isFirstVoice, clefsByStaff, emittedClefs);
810
840
  voiceStrs.push(str);
811
841
  staff = newStaff;
812
842
  }
@@ -825,8 +855,8 @@ const serializeMeasure = (
825
855
  isGrandStaff: boolean = false,
826
856
  currentKey?: KeySignature,
827
857
  currentTime?: { numerator: number; denominator: number; symbol?: 'common' | 'cut' },
828
- staffClefs?: Record<number, Clef>,
829
- emittedClefs?: Record<number, Clef>
858
+ partStaffClefs?: Record<number, Record<number, Clef>>,
859
+ partEmittedClefs?: Record<number, Record<number, Clef>>
830
860
  ): { str: string; newStaff: number } => {
831
861
  const parts: string[] = [];
832
862
 
@@ -838,13 +868,15 @@ const serializeMeasure = (
838
868
  time: currentTime,
839
869
  };
840
870
 
841
- // Pass staffClefs to parts for per-voice clef lookup
842
- const clefsByStaff = staffClefs || {};
871
+ // Per-part clef state: each part has its own staff→clef maps so that distinct
872
+ // parts sharing staff number 1 do not clobber each other's clefs.
873
+ const clefsFor = (pi: number) => partStaffClefs?.[pi] || {};
874
+ const emittedFor = (pi: number) => partEmittedClefs?.[pi] || (partEmittedClefs ? (partEmittedClefs[pi] = {}) : {});
843
875
 
844
876
  // Parts
845
877
  let staff = currentStaff;
846
878
  if (measure.parts.length === 1) {
847
- const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsByStaff, emittedClefs);
879
+ const { str: partStr, newStaff } = serializePart(measure.parts[0], staff, isGrandStaff, measureContext, true, clefsFor(0), emittedFor(0));
848
880
  if (partStr) {
849
881
  parts.push(partStr);
850
882
  }
@@ -855,7 +887,7 @@ const serializeMeasure = (
855
887
  for (let i = 0; i < measure.parts.length; i++) {
856
888
  const part = measure.parts[i];
857
889
  // Pass measureContext to all parts, isFirstPart to first part only
858
- const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsByStaff, emittedClefs);
890
+ const { str, newStaff } = serializePart(part, staff, isGrandStaff, measureContext, i === 0, clefsFor(i), emittedFor(i));
859
891
  if (str) {
860
892
  partStrs.push(str);
861
893
  }
@@ -892,6 +924,12 @@ const serializeMetadata = (metadata: Metadata): string => {
892
924
  if (metadata.lyricist) {
893
925
  lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
894
926
  }
927
+ if (metadata.genre) {
928
+ lines.push('[genre "' + escapeString(metadata.genre) + '"]');
929
+ }
930
+ if (metadata.instrument) {
931
+ lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
932
+ }
895
933
  if (metadata.autoBeam) {
896
934
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
897
935
  }
@@ -929,8 +967,11 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
929
967
  let currentStaff = 1; // Parser starts at staff 1
930
968
  let currentKey: KeySignature | undefined;
931
969
  let currentTime: { numerator: number; denominator: number; symbol?: 'common' | 'cut' } | undefined;
932
- const staffClefs: Record<number, Clef> = {}; // Track clef per staff
933
- const emittedClefs: Record<number, Clef> = {}; // Track which clefs have been output
970
+ // Clefs are tracked per part (each part is an independent instrument). Voice `staff`
971
+ // numbers are staff-within-part, so distinct parts may both use staff 1 keying clef
972
+ // state by staff alone would conflate them. Outer key = part index, inner key = staff.
973
+ const partStaffClefs: Record<number, Record<number, Clef>> = {};
974
+ const partEmittedClefs: Record<number, Record<number, Clef>> = {};
934
975
 
935
976
  for (let i = 0; i < doc.measures.length; i++) {
936
977
  const measure = doc.measures[i];
@@ -942,25 +983,32 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
942
983
  currentTime = measure.timeSig;
943
984
  }
944
985
 
945
- // Collect clefs from this measure's voices
946
- for (const part of measure.parts) {
947
- for (const voice of part.voices) {
948
- let clefActiveStaff = voice.staff;
949
- for (const event of voice.events) {
950
- if (event.type === 'context') {
951
- const ctx = event as ContextChange;
952
- if (ctx.staff) {
953
- clefActiveStaff = ctx.staff;
954
- }
955
- if (ctx.clef) {
956
- 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
+ }
957
1004
  }
958
1005
  }
959
1006
  }
960
- }
961
- }
1007
+ });
1008
+ };
962
1009
 
963
- const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, staffClefs, emittedClefs);
1010
+ const { str: measureStr, newStaff } = serializeMeasure(measure, i === 0, currentStaff, isGrandStaff, currentKey, currentTime, partStaffClefs, partEmittedClefs);
1011
+ collectClefs();
964
1012
  // Always include measure, even if empty (use space rest for empty measures)
965
1013
  measureStrs.push(measureStr || 's1');
966
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