@k-l-lambda/lilylet 0.1.73 → 0.1.74

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.
@@ -410,8 +410,10 @@ const extractMarkOptions = (marks?: Mark[]): {
410
410
  beamStart: boolean;
411
411
  beamEnd: boolean;
412
412
  dynamic?: string;
413
+ dynamics: string[]; // all dynamics on this event (a note may carry several, e.g. fp written as two marks)
413
414
  hairpin?: string;
414
415
  pedal?: string;
416
+ pedals?: ('up' | 'down')[];
415
417
  tremolo?: number;
416
418
  fingerings: { finger: number; placement?: 'above' | 'below' }[];
417
419
  navigation?: 'coda' | 'segno';
@@ -430,8 +432,10 @@ const extractMarkOptions = (marks?: Mark[]): {
430
432
  beamStart: false,
431
433
  beamEnd: false,
432
434
  dynamic: undefined as string | undefined,
435
+ dynamics: [] as string[],
433
436
  hairpin: undefined as string | undefined,
434
437
  pedal: undefined as string | undefined,
438
+ pedals: [] as ('up' | 'down')[],
435
439
  tremolo: undefined as number | undefined,
436
440
  fingerings: [] as { finger: number; placement?: 'above' | 'below' }[],
437
441
  navigation: undefined as 'coda' | 'segno' | undefined,
@@ -472,7 +476,8 @@ const extractMarkOptions = (marks?: Mark[]): {
472
476
  case 'dynamic': {
473
477
  const dynStr = DYNAMIC_MAP[mark.type];
474
478
  if (dynStr) {
475
- result.dynamic = dynStr;
479
+ result.dynamic = dynStr; // kept for back-compat (first dynamic)
480
+ result.dynamics.push(dynStr);
476
481
  }
477
482
  break;
478
483
  }
@@ -481,15 +486,20 @@ const extractMarkOptions = (marks?: Mark[]): {
481
486
  result.hairpin = 'crescStart';
482
487
  } else if (mark.type === HairpinType.diminuendoStart) {
483
488
  result.hairpin = 'dimStart';
484
- } else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
485
- result.hairpin = 'end';
489
+ } else if (mark.type === HairpinType.crescendoEnd) {
490
+ result.hairpin = 'crescEnd';
491
+ } else if (mark.type === HairpinType.diminuendoEnd) {
492
+ result.hairpin = 'dimEnd';
486
493
  }
487
494
  break;
488
495
  case 'pedal':
496
+ // A note can carry more than one pedal mark (a pedal "bounce": an up
497
+ // to release the previous pedal and an immediate down to re-pedal on
498
+ // the same note), so collect ALL of them rather than keep one scalar.
489
499
  if (mark.type === PedalType.sustainOn) {
490
- result.pedal = 'down';
500
+ result.pedals.push('down');
491
501
  } else if (mark.type === PedalType.sustainOff) {
492
- result.pedal = 'up';
502
+ result.pedals.push('up');
493
503
  }
494
504
  break;
495
505
  case 'tie':
@@ -544,6 +554,7 @@ interface NoteEventResult {
544
554
  elementId: string;
545
555
  hairpin?: string;
546
556
  pedal?: string;
557
+ pedals?: ('up' | 'down')[];
547
558
  hasTieStart: boolean;
548
559
  pitches: Pitch[];
549
560
  arpeggio: boolean;
@@ -551,7 +562,8 @@ interface NoteEventResult {
551
562
  trill: boolean;
552
563
  mordent: 'lower' | 'upper' | false; // lower = mordent, upper = prall
553
564
  turn: boolean;
554
- dynamic?: string; // dynamic marking (p, pp, f, ff, etc.)
565
+ dynamic?: string; // dynamic marking (p, pp, f, ff, etc.) — first one (back-compat)
566
+ dynamics: string[]; // all dynamics on this event
555
567
  slurStart: boolean; // For tracking slur spans
556
568
  slurEnd: boolean; // For tracking slur spans
557
569
  fingerings: { finger: number; placement?: 'above' | 'below' }[];
@@ -610,6 +622,7 @@ const noteEventToMEI = (
610
622
  elementId: noteId,
611
623
  hairpin: markOptions.hairpin,
612
624
  pedal: markOptions.pedal,
625
+ pedals: markOptions.pedals,
613
626
  hasTieStart: markOptions.tieStart,
614
627
  pitches: event.pitches,
615
628
  arpeggio: markOptions.arpeggio,
@@ -618,6 +631,7 @@ const noteEventToMEI = (
618
631
  mordent: markOptions.mordent,
619
632
  turn: markOptions.turn,
620
633
  dynamic: markOptions.dynamic,
634
+ dynamics: markOptions.dynamics,
621
635
  slurStart: markOptions.slurStart,
622
636
  slurEnd: markOptions.slurEnd,
623
637
  fingerings: markOptions.fingerings,
@@ -671,6 +685,7 @@ const noteEventToMEI = (
671
685
  elementId: chordId,
672
686
  hairpin: markOptions.hairpin,
673
687
  pedal: markOptions.pedal,
688
+ pedals: markOptions.pedals,
674
689
  hasTieStart: markOptions.tieStart,
675
690
  pitches: event.pitches,
676
691
  arpeggio: markOptions.arpeggio,
@@ -679,6 +694,7 @@ const noteEventToMEI = (
679
694
  mordent: markOptions.mordent,
680
695
  turn: markOptions.turn,
681
696
  dynamic: markOptions.dynamic,
697
+ dynamics: markOptions.dynamics,
682
698
  slurStart: markOptions.slurStart,
683
699
  slurEnd: markOptions.slurEnd,
684
700
  fingerings: markOptions.fingerings,
@@ -689,7 +705,7 @@ const noteEventToMEI = (
689
705
 
690
706
 
691
707
  // Convert RestEvent to MEI
692
- const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string } => {
708
+ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string; fermata?: 'normal' | 'short' } => {
693
709
  const dur = DURATIONS[event.duration.division] || "4";
694
710
  const restId = generateId('rest');
695
711
  let attrs = `xml:id="${restId}" dur="${dur}"`;
@@ -698,6 +714,15 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
698
714
  // Cross-staff attribute
699
715
  if (crossStaff) attrs += ` staff="${crossStaff}"`;
700
716
 
717
+ // A rest may carry a fermata (held silence). Surface it so the layer loop can
718
+ // emit a <fermata> control event referencing this rest's id.
719
+ let fermata: 'normal' | 'short' | undefined;
720
+ if (event.marks) {
721
+ for (const mk of event.marks) {
722
+ if (mk.markType === 'ornament' && (mk as { type?: string }).type === 'fermata') fermata = 'normal';
723
+ }
724
+ }
725
+
701
726
  // Pitched rest (positioned at specific pitch)
702
727
  if (event.pitch) {
703
728
  const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
@@ -706,16 +731,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
706
731
 
707
732
  // Space rest (invisible)
708
733
  if (event.invisible) {
709
- return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
734
+ return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
710
735
  }
711
736
 
712
737
  // Full measure rest
713
738
  if (event.fullMeasure) {
714
739
  const mRestId = generateId('mrest');
715
- return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
740
+ return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
716
741
  }
717
742
 
718
- return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
743
+ return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
719
744
  };
720
745
 
721
746
 
@@ -731,6 +756,9 @@ interface TupletEventResult {
731
756
  mordents: MordentRef[];
732
757
  turns: TurnRef[];
733
758
  arpeggios: ArpegRef[];
759
+ pedals: PedalMark[]; // pedal marks on notes inside the tuplet (independent events)
760
+ fingerings: FingerRef[]; // fingering marks on notes inside the tuplet
761
+ markups: MarkupRef[]; // text directions (markup) on notes inside the tuplet
734
762
  endingClef?: string; // Updated clef name if changed inside the tuplet
735
763
  }
736
764
 
@@ -774,6 +802,9 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
774
802
  const mordents: MordentRef[] = [];
775
803
  const turns: TurnRef[] = [];
776
804
  const arpeggios: ArpegRef[] = [];
805
+ const pedals: PedalMark[] = [];
806
+ const fingerings: FingerRef[] = [];
807
+ const markups: MarkupRef[] = [];
777
808
 
778
809
  // Handle internal beam groups: if notes have manual beam marks, respect them
779
810
  const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
@@ -808,12 +839,20 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
808
839
  if (result.slurEnd) slurEnds.push(result.elementId);
809
840
 
810
841
  // Collect other control events
811
- if (result.dynamic) dynamics.push({ startid: result.elementId, label: result.dynamic });
842
+ if (result.dynamics) for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
812
843
  if (result.fermata) fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
813
844
  if (result.trill) trills.push({ startid: result.elementId });
814
845
  if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
815
846
  if (result.turn) turns.push({ startid: result.elementId });
816
847
  if (result.arpeggio) arpeggios.push({ plist: result.elementId });
848
+ if (result.pedals) for (const dir of result.pedals) pedals.push({ startId: result.elementId, dir });
849
+ // Fingerings and text directions (markup) on inner notes are control
850
+ // events that attach by id — collect them so the layer loop can emit
851
+ // <fing>/<dir> for them (previously silently dropped inside tuplets).
852
+ for (const fing of result.fingerings)
853
+ fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
854
+ for (const mkup of result.markups)
855
+ markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
817
856
 
818
857
  // Close beam if this note ends a beam group
819
858
  if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
@@ -822,7 +861,9 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
822
861
  }
823
862
  } else if (e.type === 'rest') {
824
863
  const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
825
- xml += restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals).xml;
864
+ const restResult = restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
865
+ xml += restResult.xml;
866
+ if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
826
867
  } else if (e.type === 'context') {
827
868
  const ctx = e as ContextChange;
828
869
  if (ctx.clef && ctx.clef !== activeClef) {
@@ -844,7 +885,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
844
885
  }
845
886
 
846
887
  xml += `${indent}</tuplet>\n`;
847
- return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
888
+ return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
848
889
  };
849
890
 
850
891
 
@@ -997,7 +1038,7 @@ interface SlurSpan {
997
1038
 
998
1039
  // Tie state for cross-measure ties - maps staff:layer to pending pitches
999
1040
  type TieState = Record<string, Pitch[]>;
1000
- type SlurState = Record<string, string | null>; // voice key -> pending slur startId
1041
+ type SlurState = Record<string, string[]>; // voice key -> open slur startIds (stack, oldest first)
1001
1042
  type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
1002
1043
 
1003
1044
  // Pending octave span for cross-measure continuation
@@ -1032,7 +1073,7 @@ interface LayerResult {
1032
1073
  barlines: BarlineRef[];
1033
1074
  markups: MarkupRef[];
1034
1075
  pendingTiePitches: Pitch[]; // For cross-measure tie tracking
1035
- pendingSlur: string | null; // For cross-measure slur tracking (startId)
1076
+ pendingSlur: string[]; // For cross-measure slur tracking (all open slur startIds)
1036
1077
  pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
1037
1078
  pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
1038
1079
  ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
@@ -1071,7 +1112,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
1071
1112
  };
1072
1113
 
1073
1114
  // Encode a layer (voice)
1074
- const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlur: string | null = null, initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
1115
+ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlurs: string[] = [], initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
1075
1116
  const layerId = generateId("layer");
1076
1117
  let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
1077
1118
 
@@ -1081,9 +1122,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1081
1122
  // Track current clef to only emit changes
1082
1123
  let currentClef: Clef | undefined = initialClef;
1083
1124
 
1084
- // Track hairpin spans
1125
+ // Track hairpin spans. Use a stack of open hairpins (not a single slot): the
1126
+ // flattened layer stream can interleave overlapping/cross-staff hairpins
1127
+ // (e.g. a crescendo starting before the previous one ended), and a single slot
1128
+ // would silently overwrite the earlier one. Seed with any hairpin still open
1129
+ // from the previous measure (cross-measure carry).
1085
1130
  const hairpins: HairpinSpan[] = [];
1086
- let currentHairpin: { form: 'cres' | 'dim'; startId: string } | null = initialHairpin;
1131
+ const openHairpins: { form: 'cres' | 'dim'; startId: string }[] = initialHairpin ? [initialHairpin] : [];
1087
1132
 
1088
1133
  // Track pedal marks (each is independent, not paired spans)
1089
1134
  const pedals: PedalMark[] = [];
@@ -1098,9 +1143,15 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1098
1143
  let lastNoteId: string | null = null; // Track last note id for ending ottava spans
1099
1144
  let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
1100
1145
 
1101
- // Track slur spans - slurs must be encoded as control events in MEI
1146
+ // Track slur spans - slurs must be encoded as control events in MEI.
1147
+ // A single slot can't hold the overlapping/concurrent slurs that piano writing
1148
+ // produces (measured up to 3 open at once per voice); a new start while one was
1149
+ // open overwrote it and the span was lost. Use a STACK and pair each end to the
1150
+ // most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
1151
+ // as a view of the stack top for the existing cross-measure carry plumbing.
1102
1152
  const slurs: SlurSpan[] = [];
1103
- let currentSlur: { startId: string } | null = initialSlur ? { startId: initialSlur } : null;
1153
+ const openSlurs: { startId: string }[] = initialSlurs.map(startId => ({ startId }));
1154
+ let currentSlur: { startId: string } | null = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
1104
1155
 
1105
1156
  // Track arpeggio refs
1106
1157
  const arpeggios: ArpegRef[] = [];
@@ -1256,38 +1307,48 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1256
1307
 
1257
1308
  // Track hairpin spans
1258
1309
  if (result.hairpin === 'crescStart') {
1259
- currentHairpin = { form: 'cres', startId: result.elementId };
1310
+ openHairpins.push({ form: 'cres', startId: result.elementId });
1260
1311
  } else if (result.hairpin === 'dimStart') {
1261
- currentHairpin = { form: 'dim', startId: result.elementId };
1262
- } else if (result.hairpin === 'end' && currentHairpin) {
1312
+ openHairpins.push({ form: 'dim', startId: result.elementId });
1313
+ } else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
1314
+ const endForm: 'cres' | 'dim' = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
1315
+ // Close the most-recent open hairpin of the matching form; if none
1316
+ // matches (interleaved/malformed input), fall back to the newest open.
1317
+ let idx = -1;
1318
+ for (let i = openHairpins.length - 1; i >= 0; i--) {
1319
+ if (openHairpins[i].form === endForm) { idx = i; break; }
1320
+ }
1321
+ if (idx < 0) idx = openHairpins.length - 1;
1322
+ const open = openHairpins.splice(idx, 1)[0];
1263
1323
  hairpins.push({
1264
- form: currentHairpin.form,
1265
- startId: currentHairpin.startId,
1324
+ form: open.form,
1325
+ startId: open.startId,
1266
1326
  endId: result.elementId,
1267
1327
  });
1268
- currentHairpin = null;
1269
1328
  }
1270
1329
 
1271
- // Track pedal marks (each is independent)
1272
- if (result.pedal === 'down' || result.pedal === 'up') {
1273
- pedals.push({
1274
- startId: result.elementId,
1275
- dir: result.pedal,
1276
- });
1330
+ // Track pedal marks (each is independent; a note may carry several,
1331
+ // e.g. an up+down pedal bounce at the same beat).
1332
+ if (result.pedals) {
1333
+ for (const dir of result.pedals) {
1334
+ pedals.push({ startId: result.elementId, dir });
1335
+ }
1277
1336
  }
1278
1337
 
1279
1338
  // Track slur spans - end must be processed before start
1280
- // in case a note ends one slur and starts another
1281
- if (result.slurEnd && currentSlur) {
1339
+ // in case a note ends one slur and starts another.
1340
+ // Pair an end to the most-recent open slur (LIFO).
1341
+ if (result.slurEnd && openSlurs.length > 0) {
1342
+ const open = openSlurs.pop()!;
1282
1343
  slurs.push({
1283
- startId: currentSlur.startId,
1344
+ startId: open.startId,
1284
1345
  endId: result.elementId,
1285
1346
  });
1286
- currentSlur = null;
1287
1347
  }
1288
1348
  if (result.slurStart) {
1289
- currentSlur = { startId: result.elementId };
1349
+ openSlurs.push({ startId: result.elementId });
1290
1350
  }
1351
+ currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
1291
1352
 
1292
1353
  // Track arpeggio refs
1293
1354
  if (result.arpeggio) {
@@ -1313,8 +1374,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1313
1374
  if (result.turn) {
1314
1375
  turns.push({ startid: result.elementId });
1315
1376
  }
1316
- if (result.dynamic) {
1317
- dynamics.push({ startid: result.elementId, label: result.dynamic });
1377
+ if (result.dynamics) {
1378
+ for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
1318
1379
  }
1319
1380
  // Track fingerings
1320
1381
  for (const fing of result.fingerings) {
@@ -1335,6 +1396,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1335
1396
  const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
1336
1397
  const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
1337
1398
  xml += restResult.xml;
1399
+ // Fermata over a rest (held silence) — emit as a control event on the rest.
1400
+ if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
1338
1401
  // A leading dynamic/markup attaches to the next event, which may be this rest
1339
1402
  flushPendingMarkups(restResult.elementId);
1340
1403
  flushPendingDynamics(restResult.elementId);
@@ -1360,21 +1423,22 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1360
1423
  lastNoteId = tupletResult.firstNoteId;
1361
1424
  }
1362
1425
 
1363
- // Process slur ends first (to close any pending slurs from before this tuplet)
1426
+ // Process slur ends first (to close open slurs, LIFO), then starts.
1364
1427
  for (const endId of tupletResult.slurEnds) {
1365
- if (currentSlur) {
1428
+ if (openSlurs.length > 0) {
1429
+ const open = openSlurs.pop()!;
1366
1430
  slurs.push({
1367
- startId: currentSlur.startId,
1431
+ startId: open.startId,
1368
1432
  endId: endId,
1369
1433
  });
1370
- currentSlur = null;
1371
1434
  }
1372
1435
  }
1373
1436
 
1374
1437
  // Then process slur starts (to open new slurs)
1375
1438
  for (const startId of tupletResult.slurStarts) {
1376
- currentSlur = { startId };
1439
+ openSlurs.push({ startId });
1377
1440
  }
1441
+ currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
1378
1442
 
1379
1443
  // Collect other control events from tuplet
1380
1444
  dynamics.push(...tupletResult.dynamics);
@@ -1383,6 +1447,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1383
1447
  mordents.push(...tupletResult.mordents);
1384
1448
  turns.push(...tupletResult.turns);
1385
1449
  arpeggios.push(...tupletResult.arpeggios);
1450
+ pedals.push(...tupletResult.pedals);
1451
+ fingerings.push(...tupletResult.fingerings);
1452
+ markups.push(...tupletResult.markups);
1386
1453
 
1387
1454
  break;
1388
1455
  }
@@ -1524,8 +1591,24 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
1524
1591
  ? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
1525
1592
  : null;
1526
1593
 
1594
+ // Resolve hairpins still open at layer end. The cross-measure carry supports a
1595
+ // single pending hairpin, so keep the OLDEST open span (bottom of stack — most
1596
+ // likely to legitimately continue into the next measure) as pendingHairpin, and
1597
+ // flush the rest here, ending them at the last note so they aren't dropped
1598
+ // (MusicXML files routinely leave wedges unclosed). Without a last note id there
1599
+ // is nothing to attach to, so those are unavoidably dropped.
1600
+ let pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null = null;
1601
+ if (openHairpins.length > 0) {
1602
+ pendingHairpin = openHairpins[0];
1603
+ for (let i = 1; i < openHairpins.length; i++) {
1604
+ if (lastNoteId) {
1605
+ hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
1606
+ }
1607
+ }
1608
+ }
1609
+
1527
1610
  xml += `${indent}</layer>\n`;
1528
- return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1611
+ return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
1529
1612
  };
1530
1613
 
1531
1614
  // Staff result type
@@ -1591,7 +1674,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1591
1674
  const layerN = vi + 1;
1592
1675
  const tieKey = `${staffN}-${layerN}`;
1593
1676
  const initialTies = tieState[tieKey] || [];
1594
- const initialSlur = slurState[tieKey] || null;
1677
+ const initialSlur = slurState[tieKey] || [];
1595
1678
  const initialHairpin = hairpinState[tieKey] || null;
1596
1679
  const initialOctave = ottavaState[tieKey] || null;
1597
1680
  const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
@@ -1617,9 +1700,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
1617
1700
  pendingTies[tieKey] = result.pendingTiePitches;
1618
1701
  }
1619
1702
  // Track pending slurs for this layer
1620
- if (result.pendingSlur) {
1621
- pendingSlurs[tieKey] = result.pendingSlur;
1622
- }
1703
+ // Always record (even empty) so a measure that closed all its slurs
1704
+ // clears the carried stack rather than leaving a stale open slur.
1705
+ pendingSlurs[tieKey] = result.pendingSlur;
1623
1706
  // Track pending hairpins for this layer
1624
1707
  if (result.pendingHairpin) {
1625
1708
  pendingHairpins[tieKey] = result.pendingHairpin;