@k-l-lambda/lilylet 0.1.74 → 0.1.76

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.
@@ -69,6 +69,7 @@ import {
69
69
  createFraction,
70
70
  TYPE_TO_DIVISION,
71
71
  } from './musicXmlUtils';
72
+ import { measureLayoutFromPart } from './measureLayoutFromXml';
72
73
 
73
74
  // ============ Spanner Tracker ============
74
75
 
@@ -630,6 +631,25 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
630
631
  result.placement = getAttribute(dirEl, 'placement') as 'above' | 'below' | undefined;
631
632
  result.staff = getElementInt(dirEl, 'staff');
632
633
 
634
+ // Navigation jumps live on the <sound> child of <direction> (a sibling of
635
+ // <direction-type>). Parse it first, before the no-direction-type early return,
636
+ // so a sound-only direction still contributes its jump semantics.
637
+ const soundEl = dirEl.getElementsByTagName('sound')[0];
638
+ if (soundEl) {
639
+ const sound: NonNullable<MusicXmlDirection['sound']> = {};
640
+ if (getAttribute(soundEl, 'dacapo') === 'yes') sound.dacapo = true;
641
+ if (getAttribute(soundEl, 'fine') === 'yes') sound.fine = true;
642
+ const dalsegno = getAttribute(soundEl, 'dalsegno');
643
+ if (dalsegno) sound.dalsegno = dalsegno;
644
+ const segno = getAttribute(soundEl, 'segno');
645
+ if (segno) sound.segno = segno;
646
+ const coda = getAttribute(soundEl, 'coda');
647
+ if (coda) sound.coda = coda;
648
+ const tocoda = getAttribute(soundEl, 'tocoda');
649
+ if (tocoda) sound.tocoda = tocoda;
650
+ if (Object.keys(sound).length > 0) result.sound = sound;
651
+ }
652
+
633
653
  const dirTypeEl = dirEl.getElementsByTagName('direction-type')[0];
634
654
  if (!dirTypeEl) {
635
655
  return result;
@@ -722,7 +742,8 @@ const parseBarline = (barlineEl: Element): MusicXmlBarline => {
722
742
  if (repeatEl) {
723
743
  const direction = getAttribute(repeatEl, 'direction') as 'forward' | 'backward';
724
744
  if (direction) {
725
- result.repeat = { direction };
745
+ const times = getAttributeNumber(repeatEl, 'times');
746
+ result.repeat = times !== undefined ? { direction, times } : { direction };
726
747
  }
727
748
  }
728
749
 
@@ -1065,7 +1086,10 @@ const directionToMarks = (
1065
1086
  // Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
1066
1087
  // ContextChange by directionToContextChange, so skip those here to avoid
1067
1088
  // double-emitting; everything else becomes a markup mark → MEI <dir>. Metronome
1068
- // directions are tempo too, never markup.
1089
+ // directions are tempo too, never markup. Navigation text ("D.C. al Fine",
1090
+ // "To Coda", "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a
1091
+ // markup glyph — the jump SEMANTICS are captured separately as measure-layout.
1092
+ let emittedWords = false;
1069
1093
  if (direction.words && direction.words.length > 0 && !direction.metronome) {
1070
1094
  const text = direction.words.map(w => w.text).join('').trim();
1071
1095
  if (text && !isTempoWord(text)) {
@@ -1073,6 +1097,27 @@ const directionToMarks = (
1073
1097
  : direction.placement === 'below' ? Placement.below
1074
1098
  : undefined;
1075
1099
  marks.push({ markType: 'markup', content: text, placement });
1100
+ emittedWords = true;
1101
+ }
1102
+ }
1103
+
1104
+ // A <sound> navigation with NO visible <words> (e.g. a bare <sound tocoda=>):
1105
+ // synthesize the conventional glyph text so the score still shows the marking.
1106
+ // Segno/coda GLYPHS (the symbols, via <direction-type><segno|coda>) are already
1107
+ // emitted above as NavigationMark; only the textual D.C./D.S./Fine/To-Coda need
1108
+ // synthesizing, and only when the engraver left them implicit.
1109
+ if (!emittedWords && direction.sound && !direction.coda && !direction.segno) {
1110
+ const s = direction.sound;
1111
+ const label = s.dacapo ? (s.fine ? 'D.C. al Fine' : 'D.C.')
1112
+ : s.dalsegno ? (s.fine ? 'D.S. al Fine' : 'D.S. al Coda')
1113
+ : s.tocoda ? 'To Coda'
1114
+ : s.fine ? 'Fine'
1115
+ : undefined;
1116
+ if (label) {
1117
+ const placement = direction.placement === 'above' ? Placement.above
1118
+ : direction.placement === 'below' ? Placement.below
1119
+ : undefined;
1120
+ marks.push({ markType: 'markup', content: label, placement });
1076
1121
  }
1077
1122
  }
1078
1123
 
@@ -1087,6 +1132,12 @@ interface MeasureConversionResult {
1087
1132
  key?: KeySignature;
1088
1133
  timeSig?: Fraction;
1089
1134
  barline?: BarlineEvent;
1135
+ // A `location="left"` repeat (e.g. forward-repeat `.|:`) belongs at the FRONT
1136
+ // of this measure, but lilylet models a `\bar` as the END bar of its measure.
1137
+ // So it is carried back onto the PREVIOUS measure's trailing barline by
1138
+ // convertPart (dropped if this is the first measure — a leading `|:` at the
1139
+ // very start of the piece is conventionally omitted).
1140
+ leadingBarline?: BarlineEvent;
1090
1141
  harmonies: HarmonyEvent[];
1091
1142
  clefs: Map<number, ContextChange>; // staff number → clef context
1092
1143
  }
@@ -1149,6 +1200,7 @@ const convertMeasure = (
1149
1200
  let key: KeySignature | undefined;
1150
1201
  let timeSig: Fraction | undefined;
1151
1202
  let barline: BarlineEvent | undefined;
1203
+ let leadingBarline: BarlineEvent | undefined;
1152
1204
  const harmonies: HarmonyEvent[] = [];
1153
1205
  const clefs: Map<number, ContextChange> = new Map();
1154
1206
 
@@ -1166,6 +1218,15 @@ const convertMeasure = (
1166
1218
  // layer (otherwise the encoder can't pair them and drops the span).
1167
1219
  const pendingContextChanges: { ctx: ContextChange; staff?: number }[] = [];
1168
1220
  let currentVoice = 1; // Track current voice for directions
1221
+ // Mid-measure clef support: a staff can change clef partway through a measure
1222
+ // (common in scale/arpeggio études where the LH crosses up). Such a clef arrives
1223
+ // in an <attributes> block AFTER notes on that staff. We must attach it inline at
1224
+ // that point on the staff's currently-active voice, not collapse it into the
1225
+ // measure-start `clefs` map (which keeps only one clef per staff and emits it at
1226
+ // the bar start). Track the last voice that added a note on each staff, and which
1227
+ // staves already have notes this measure.
1228
+ const lastVoiceOnStaff: Map<number, number> = new Map();
1229
+ const staffHasNotes: Set<number> = new Set();
1169
1230
 
1170
1231
  // Process all children in order
1171
1232
  for (const child of getChildElements(measureEl)) {
@@ -1197,7 +1258,30 @@ const convertMeasure = (
1197
1258
  for (const clefEntry of attrs.clefs) {
1198
1259
  const clef = convertClef(clefEntry.clef.sign, clefEntry.clef.line);
1199
1260
  if (clef) {
1200
- clefs.set(clefEntry.staff, { type: 'context', clef });
1261
+ const staff = clefEntry.staff;
1262
+ if (staffHasNotes.has(staff)) {
1263
+ // Mid-measure clef change: this staff already has notes in this
1264
+ // measure, so the clef takes effect HERE, not at the bar start.
1265
+ // Append it inline on the staff's currently-active voice via
1266
+ // addEvent (which inserts a staff-switch context if that voice is
1267
+ // currently positioned on another staff — e.g. a cross-staff
1268
+ // voice — so the clef binds to `staff`, not the voice's current
1269
+ // staff). Zero duration: a clef does not advance time.
1270
+ const vNum = lastVoiceOnStaff.get(staff);
1271
+ if (vNum !== undefined) {
1272
+ // Tag the clef context with its staff explicitly. In a
1273
+ // cross-staff voice (where notes interleave staves) a bare
1274
+ // clef is ambiguous and is dropped/misplaced by the serializer;
1275
+ // the explicit staff makes it self-describing (serializer emits
1276
+ // `\staff "N" \clef ...`). addEvent still inserts a leading
1277
+ // staff switch when the voice is currently on another staff.
1278
+ voiceTracker.addEvent(vNum, { type: 'context', staff, clef } as ContextChange, 0, staff);
1279
+ } else {
1280
+ clefs.set(staff, { type: 'context', clef });
1281
+ }
1282
+ } else {
1283
+ clefs.set(staff, { type: 'context', clef });
1284
+ }
1201
1285
  }
1202
1286
  }
1203
1287
  }
@@ -1206,6 +1290,10 @@ const convertMeasure = (
1206
1290
  const voiceNum = note.voice;
1207
1291
  const staffNum = note.staff || 1;
1208
1292
  currentVoice = voiceNum;
1293
+ // Record that this staff now has notes this measure and which voice is
1294
+ // active on it, so a subsequent mid-measure <clef> attaches inline here.
1295
+ staffHasNotes.add(staffNum);
1296
+ lastVoiceOnStaff.set(staffNum, voiceNum);
1209
1297
 
1210
1298
  // Ensure voice exists with correct staff tracking (needed for cross-staff tuplets
1211
1299
  // where notes go to tupletTracker but voice must be initialized for staff detection)
@@ -1412,7 +1500,14 @@ const convertMeasure = (
1412
1500
  const barlineData = parseBarline(child);
1413
1501
  const style = convertBarlineStyle(barlineData.barStyle, barlineData.repeat?.direction);
1414
1502
  if (style && style !== '|') {
1415
- barline = { type: 'barline', style };
1503
+ // A `location="left"` barline (forward repeat at the measure front)
1504
+ // is carried back onto the previous measure's end bar by convertPart;
1505
+ // everything else is this measure's own trailing end bar.
1506
+ if (barlineData.location === 'left') {
1507
+ leadingBarline = { type: 'barline', style };
1508
+ } else {
1509
+ barline = { type: 'barline', style };
1510
+ }
1416
1511
  }
1417
1512
  } else if (tagName === 'harmony') {
1418
1513
  const harmonyData = parseHarmony(child);
@@ -1506,7 +1601,7 @@ const convertMeasure = (
1506
1601
  });
1507
1602
  }
1508
1603
 
1509
- return { voiceMap, key, timeSig, barline, harmonies, clefs };
1604
+ return { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs };
1510
1605
  };
1511
1606
 
1512
1607
  /**
@@ -1524,12 +1619,30 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1524
1619
  let isFirstMeasure = true;
1525
1620
  let lastVoiceStaff = 1; // Track last known primary voice staff for empty measure fallback
1526
1621
  const lastClefs: Map<number, ContextChange> = new Map(); // Track last clef per staff
1622
+ // Initial clef per staff declared in the FIRST <attributes> block. A staff whose
1623
+ // first voice does not appear until a later measure (e.g. an accompaniment staff
1624
+ // silent for the opening bars) would otherwise lose its initial clef — it is only
1625
+ // declared in measure 1's attributes, where that staff has no voice to attach it
1626
+ // to. We hold these and flush each onto the staff's FIRST appearing voice.
1627
+ const pendingInitialClefs: Map<number, ContextChange> = new Map();
1527
1628
 
1528
1629
  const measureEls = getDirectChildren(partEl, 'measure');
1529
1630
 
1530
1631
  for (const measureEl of measureEls) {
1531
1632
  voiceTracker.reset();
1532
- const { voiceMap, key, timeSig, barline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1633
+ const { voiceMap, key, timeSig, barline, leadingBarline, harmonies, clefs } = convertMeasure(measureEl, voiceTracker, spannerTracker, ottavaTracker, tupletTracker);
1634
+
1635
+ // A forward-repeat (or any `location="left"`) barline belongs at the END of
1636
+ // the PREVIOUS measure in lilylet's "\bar = current measure's end bar" model.
1637
+ // Append it to the previous measure's first voice; if this is the first
1638
+ // measure there is no previous one, so a leading repeat at the very start of
1639
+ // the piece is conventionally dropped.
1640
+ if (leadingBarline && measures.length > 0) {
1641
+ const prevVoices = measures[measures.length - 1].parts[0]?.voices;
1642
+ if (prevVoices && prevVoices.length > 0) {
1643
+ prevVoices[0].events.push(leadingBarline);
1644
+ }
1645
+ }
1533
1646
 
1534
1647
  // Update running key/time
1535
1648
  if (key) lastKey = key;
@@ -1566,6 +1679,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1566
1679
  lastClefs.set(voiceData.staff, clef);
1567
1680
  }
1568
1681
  }
1682
+ pendingInitialClefs.delete(voiceData.staff);
1683
+ } else if (pendingInitialClefs.has(voiceData.staff)) {
1684
+ // This staff's clef was declared in an earlier measure where the staff
1685
+ // had no voice to carry it (its first appearance is delayed, or it is
1686
+ // an intermittently-empty cross-staff passage). This measure declares
1687
+ // no clef of its own — emit the carried-forward pending clef if it
1688
+ // differs from the clef last in effect for this staff.
1689
+ const pending = pendingInitialClefs.get(voiceData.staff)!;
1690
+ const lastClef = lastClefs.get(voiceData.staff);
1691
+ const isSameClef = lastClef &&
1692
+ (lastClef as ContextChange).clef === (pending as ContextChange).clef;
1693
+ if (!isSameClef) {
1694
+ events.push(pending);
1695
+ lastClefs.set(voiceData.staff, pending);
1696
+ }
1697
+ pendingInitialClefs.delete(voiceData.staff);
1569
1698
  }
1570
1699
  staffsWithClef.add(voiceData.staff);
1571
1700
  }
@@ -1573,6 +1702,22 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1573
1702
  // Add voice events
1574
1703
  events.push(...voiceData.events);
1575
1704
 
1705
+ // A mid-measure clef change was appended inline to this voice's events (it
1706
+ // does not pass through the measure-start `clefs` map). Scan for the LAST
1707
+ // clef per staff in this voice and update lastClefs, so the next measure's
1708
+ // dedup compares against the clef actually in effect — otherwise a later
1709
+ // measure restating the pre-change clef would be wrongly suppressed.
1710
+ {
1711
+ let scanStaff = voiceData.staff;
1712
+ for (const ev of voiceData.events) {
1713
+ if (ev.type === 'context') {
1714
+ const c = ev as ContextChange;
1715
+ if (c.staff != null) scanStaff = c.staff;
1716
+ if (c.clef) lastClefs.set(scanStaff, { type: 'context', clef: c.clef });
1717
+ }
1718
+ }
1719
+ }
1720
+
1576
1721
  // Add harmonies and barline to first voice only
1577
1722
  if (voiceNum === voiceNumbers[0]) {
1578
1723
  for (const h of harmonies) {
@@ -1596,6 +1741,16 @@ const convertPart = (partEl: Element): { measures: Measure[]; name?: string } =>
1596
1741
  lastVoiceStaff = voices[0].staff || 1;
1597
1742
  }
1598
1743
 
1744
+ // Carry forward any clef declared this measure for a staff that had NO voice to
1745
+ // attach it to (an intermittently-empty staff — e.g. cross-staff passages where
1746
+ // the LH is written entirely on the upper staff for several bars). Remember it
1747
+ // as pending so it is emitted when that staff next reappears; otherwise the clef
1748
+ // (and any change it represents) is lost when this measure's `clefs` map is
1749
+ // discarded. Generalizes the first-measure seeding above to every measure.
1750
+ for (const [staff, clef] of clefs) {
1751
+ if (!staffsWithClef.has(staff)) pendingInitialClefs.set(staff, clef);
1752
+ }
1753
+
1599
1754
  const measure: Measure = {
1600
1755
  parts: [{
1601
1756
  voices,
@@ -1730,6 +1885,14 @@ export const decode = (input: string | Uint8Array): LilyletDoc => {
1730
1885
  measures: mergedMeasures,
1731
1886
  };
1732
1887
 
1888
+ // Derive the performance order (repeats / voltas / D.C. / D.S. / Coda / Fine)
1889
+ // from the first part's barline + <sound> navigation markup → measure-layout
1890
+ // string. The MEI encoder turns this into an <expansion> that verovio unfolds
1891
+ // for MIDI. Repeat markup is part-global; the first part carries it. Never
1892
+ // throws (returns undefined when there is nothing to unfold).
1893
+ const measureLayout = measureLayoutFromPart(partEls[0]);
1894
+ if (measureLayout) metadata.measureLayout = measureLayout;
1895
+
1733
1896
  if (Object.keys(metadata).length > 0) {
1734
1897
  result.metadata = metadata;
1735
1898
  }
@@ -124,6 +124,17 @@ export interface MusicXmlDirection {
124
124
  octaveShift?: MusicXmlOctaveShift;
125
125
  coda?: boolean;
126
126
  segno?: boolean;
127
+ // Navigation jumps from the <sound> sibling inside <direction>. Present when
128
+ // the score carries D.C./D.S./Coda/Fine playback semantics; drives the
129
+ // performance-order (measure-layout) derivation, not a notational glyph.
130
+ sound?: {
131
+ dacapo?: boolean; // <sound dacapo="yes"> — da capo
132
+ dalsegno?: string; // <sound dalsegno="N"> — dal segno (target segno id)
133
+ segno?: string; // <sound segno="N"> — marks a segno point
134
+ coda?: string; // <sound coda="N"> — marks a coda point
135
+ tocoda?: string; // <sound tocoda="N"> — "to coda" jump
136
+ fine?: boolean; // <sound fine="yes"> — Fine (stop point on D.C./D.S.)
137
+ };
127
138
  }
128
139
 
129
140
  // ============ Barline Types ============
@@ -133,6 +144,7 @@ export interface MusicXmlBarline {
133
144
  barStyle?: string; // regular, light-light, light-heavy, heavy-light, heavy-heavy, etc.
134
145
  repeat?: {
135
146
  direction: 'forward' | 'backward';
147
+ times?: number; // <repeat times="N"> — repeat count (default 2 for backward)
136
148
  };
137
149
  ending?: {
138
150
  type: 'start' | 'stop' | 'discontinue';
@@ -36,6 +36,40 @@ import {
36
36
  Placement,
37
37
  Metadata,
38
38
  } from "./types";
39
+ import { parseMeasureLayout, MeasureLayout } from "./measureLayout";
40
+
41
+
42
+ // Walk a measure-layout AST, recording the FIRST measure index of each alternate
43
+ // ending (1st/2nd house) → its ending number, for the `% volta N` serializer
44
+ // comment convention. Only the alternates of a VoltaMLayout produce labels.
45
+ const collectVoltaLabels = (layout: MeasureLayout, out: Map<number, number>): void => {
46
+ const firstIndexOf = (node: MeasureLayout): number | undefined => {
47
+ switch (node.kind) {
48
+ case "single": return node.measure;
49
+ case "block": return node.seq.length ? firstIndexOf(node.seq[0]) : undefined;
50
+ case "volta": return node.body.length ? firstIndexOf(node.body[0]) : undefined;
51
+ case "aba": return firstIndexOf(node.main);
52
+ }
53
+ };
54
+ const walk = (node: MeasureLayout): void => {
55
+ switch (node.kind) {
56
+ case "block": node.seq.forEach(walk); break;
57
+ case "aba": walk(node.main); node.rest.forEach(walk); break;
58
+ case "volta":
59
+ node.body.forEach(walk);
60
+ if (node.alternates) {
61
+ node.alternates.forEach((alt, ai) => {
62
+ const first = alt.length ? firstIndexOf(alt[0]) : undefined;
63
+ if (first !== undefined && !out.has(first)) out.set(first, ai + 1);
64
+ alt.forEach(walk);
65
+ });
66
+ }
67
+ break;
68
+ default: break;
69
+ }
70
+ };
71
+ walk(layout);
72
+ };
39
73
 
40
74
 
41
75
  const PHONETS = "cdefgab";
@@ -483,9 +517,20 @@ const serializeTupletEvent = (
483
517
  prevDuration = (e as RestEvent).duration;
484
518
  } else if (e.type === 'context') {
485
519
  const ctx = e as ContextChange;
520
+ // A context event inside a tuplet may carry several components at once
521
+ // (e.g. staff + clef). Emit each present one — previously only staff and
522
+ // stemDirection were handled, so a clef/ottava change inside a tuplet was
523
+ // silently dropped on serialization.
486
524
  if (ctx.staff != null) {
487
525
  parts.push(' \\staff "' + ctx.staff + '"');
488
- } else if (ctx.stemDirection != null) {
526
+ }
527
+ if (ctx.clef != null) {
528
+ parts.push(' \\clef "' + (CLEF_MAP[ctx.clef] ?? ctx.clef) + '"');
529
+ }
530
+ if (ctx.ottava != null) {
531
+ parts.push(' \\ottava #' + ctx.ottava);
532
+ }
533
+ if (ctx.stemDirection != null) {
489
534
  if (ctx.stemDirection === StemDirection.up) parts.push(' \\stemUp');
490
535
  else if (ctx.stemDirection === StemDirection.down) parts.push(' \\stemDown');
491
536
  else if (ctx.stemDirection === StemDirection.auto) parts.push(' \\stemNeutral');
@@ -586,7 +631,7 @@ const serializeEvent = (
586
631
  case 'markup': {
587
632
  const mk = event as MarkupEvent;
588
633
  const prefix = mk.placement === 'above' ? '^' : mk.placement === 'below' ? '_' : '';
589
- return { str: `${prefix}\\markup "${mk.content}"`, newEnv: env };
634
+ return { str: `${prefix}\\markup "${escapeString(mk.content)}"`, newEnv: env };
590
635
  }
591
636
  case 'dynamic': {
592
637
  const dynStr = DYNAMIC_MAP[(event as DynamicEvent).dynamicType];
@@ -654,6 +699,28 @@ const serializeVoice = (
654
699
  // context{staff} events before this index are skipped in the main loop.
655
700
  let effectiveInitialStaff = voice.staff;
656
701
  let leadStaffScanEnd = 0;
702
+ // Staff of the first MUSICAL event (note/rest/...), honouring an explicit
703
+ // note.staff. If it differs from the home staff, the first inline staff switch
704
+ // would emit a `\staff "X"` that the parser mistakes for the LEADING staff (it
705
+ // binds home to the first `\staff` before any music), relocating the voice and
706
+ // any leading clef. We then force an explicit home-staff anchor up front. This
707
+ // scan is independent of leadStaffScanEnd because a leading clef/key stops that
708
+ // scan before the first note is reached.
709
+ let firstMusicalStaff: number | null = null;
710
+ {
711
+ let scanStaff = voice.staff;
712
+ for (const e of voice.events) {
713
+ if (e.type === 'context') {
714
+ const ctx = e as ContextChange;
715
+ if (ctx.staff != null) scanStaff = ctx.staff;
716
+ continue;
717
+ }
718
+ if (e.type === 'pitchReset') continue;
719
+ if (e.type === 'note') firstMusicalStaff = (e as NoteEvent).staff ?? scanStaff;
720
+ else firstMusicalStaff = scanStaff;
721
+ break;
722
+ }
723
+ }
657
724
  for (let i = 0; i < voice.events.length; i++) {
658
725
  const e = voice.events[i];
659
726
  if (e.type === 'pitchReset') { leadStaffScanEnd = i + 1; continue; }
@@ -678,10 +745,19 @@ const serializeVoice = (
678
745
  break;
679
746
  }
680
747
 
681
- // Output staff command if voice staff differs from current parser staff,
682
- // or always output if it's a grand staff score for clarity.
683
- // Use effectiveInitialStaff so carry-over replaces the default emission.
684
- if (isGrandStaff || effectiveInitialStaff !== currentStaff) {
748
+ // Output staff command if the voice's initial staff is not what the PARSER will
749
+ // assume. The parser (lilylet.jison voice()) assigns every voice with no leading
750
+ // `\staff` directive to staff 1 unconditionally — it does NOT carry staff across
751
+ // voices or measures. So we must emit `\staff "N"` whenever effectiveInitialStaff
752
+ // is not 1, even if the cross-measure carry (currentStaff) happens to match it
753
+ // (e.g. a whole measure sitting on staff 2 after a prior staff-2 measure — without
754
+ // this, the round-trip would silently relocate that voice, and its leading clef,
755
+ // to staff 1). Also emit when it differs from the carry (resets after grand staff)
756
+ // or always in a grand-staff part for clarity. Finally, force an anchor when the
757
+ // first note switches staff via note.staff (otherwise that inline `\staff` would
758
+ // be read as the leading staff and rebind the voice's home).
759
+ const firstNoteSwitches = firstMusicalStaff != null && firstMusicalStaff !== effectiveInitialStaff;
760
+ if (isGrandStaff || effectiveInitialStaff !== 1 || effectiveInitialStaff !== currentStaff || firstNoteSwitches) {
685
761
  parts.push('\\staff "' + effectiveInitialStaff + '"');
686
762
  }
687
763
 
@@ -710,7 +786,17 @@ const serializeVoice = (
710
786
  // Prefer this voice's leading clef (a clef before any music); fall back to the
711
787
  // carry-in clef from previous measures. A clef that first appears mid-voice is NOT
712
788
  // hoisted here — it is emitted inline at its position.
713
- const voiceClef = findVoiceClef(voice) ?? allStaffClefs?.[voice.staff];
789
+ //
790
+ // The carry-in fallback (`allStaffClefs[staff]`) is the clef state at the START of
791
+ // the measure and is NOT updated mid-measure, whereas `emittedClefs` IS. So a voice
792
+ // with no clef of its own must only use the fallback to ESTABLISH a staff that has
793
+ // not been emitted yet — never to "restate" it once any sibling voice has already
794
+ // emitted/changed this staff's clef this measure, because the fallback would then be
795
+ // stale and would wrongly revert that sibling's change (e.g. v1 moves staff 2 to
796
+ // treble, then v2 with no clef must not re-emit the carried-in bass).
797
+ const ownClef = findVoiceClef(voice);
798
+ const carryClef = allStaffClefs?.[voice.staff];
799
+ const voiceClef = ownClef ?? (emittedClefs?.[voice.staff] === undefined ? carryClef : undefined);
714
800
  const clefAlreadyEmitted = voiceClef && emittedClefs?.[voice.staff] === voiceClef;
715
801
  if (voiceClef && !clefAlreadyEmitted) {
716
802
  parts.push('\\clef "' + (CLEF_MAP[voiceClef] ?? voiceClef) + '"');
@@ -734,7 +820,8 @@ const serializeVoice = (
734
820
  activeStaff = ctx.staff;
735
821
  parts.push('\\staff "' + activeStaff + '"');
736
822
  // Emit target staff clef if the event carries one or allStaffClefs knows it
737
- const ctxClef = ctx.clef || allStaffClefs?.[activeStaff];
823
+ const ctxClef = ctx.clef ||
824
+ (emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined);
738
825
  if (ctxClef && emittedClefs?.[activeStaff] !== ctxClef) {
739
826
  parts.push('\\clef "' + (CLEF_MAP[ctxClef] ?? ctxClef) + '"');
740
827
  if (emittedClefs) emittedClefs[activeStaff] = ctxClef;
@@ -763,7 +850,8 @@ const serializeVoice = (
763
850
  activeStaff = effectiveStaff;
764
851
  parts.push('\\staff "' + activeStaff + '"');
765
852
  // Emit the target staff's clef if it differs from what was last emitted for this staff
766
- const targetClef = allStaffClefs?.[activeStaff];
853
+ const targetClef =
854
+ emittedClefs?.[activeStaff] === undefined ? allStaffClefs?.[activeStaff] : undefined;
767
855
  if (targetClef && emittedClefs?.[activeStaff] !== targetClef) {
768
856
  parts.push('\\clef "' + (CLEF_MAP[targetClef] ?? targetClef) + '"');
769
857
  if (emittedClefs) emittedClefs[activeStaff] = targetClef;
@@ -923,7 +1011,12 @@ const serializeMeasure = (
923
1011
 
924
1012
  // Escape string for serialization (quotes and backslashes)
925
1013
  const escapeString = (str: string): string => {
926
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1014
+ return str
1015
+ .replace(/\\/g, '\\\\')
1016
+ .replace(/"/g, '\\"')
1017
+ .replace(/\n/g, '\\n')
1018
+ .replace(/\r/g, '\\r')
1019
+ .replace(/\t/g, '\\t');
927
1020
  };
928
1021
 
929
1022
  // Serialize metadata
@@ -954,6 +1047,9 @@ const serializeMetadata = (metadata: Metadata): string => {
954
1047
  if (metadata.staves) {
955
1048
  lines.push('[staves "' + escapeString(metadata.staves) + '"]');
956
1049
  }
1050
+ if (metadata.measureLayout) {
1051
+ lines.push('[measures "' + escapeString(metadata.measureLayout) + '"]');
1052
+ }
957
1053
  if (metadata.instruments) {
958
1054
  for (const [key, instr] of Object.entries(metadata.instruments)) {
959
1055
  let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
@@ -1048,9 +1144,26 @@ export const serializeLilyletDoc = (doc: LilyletDoc): string => {
1048
1144
  currentStaff = newStaff;
1049
1145
  }
1050
1146
 
1051
- // Join measures with bar, measure number comment, and double newline
1147
+ // Volta house-label comments (non-binding convention, like the `% n` measure
1148
+ // index): if a measure-layout directive is present, mark the first measure of
1149
+ // each alternate ending with a `% volta N` comment. Derived from the layout
1150
+ // AST; purely cosmetic, never parsed back.
1151
+ const voltaLabels = new Map<number, number>(); // 1-based measure index → ending number
1152
+ if (doc.metadata?.measureLayout) {
1153
+ try {
1154
+ const layout = parseMeasureLayout(doc.metadata.measureLayout);
1155
+ collectVoltaLabels(layout, voltaLabels);
1156
+ } catch { /* ignore malformed layout */ }
1157
+ }
1158
+
1159
+ // Join measures with bar, measure number comment, and double newline. A
1160
+ // `% volta N` label (if any) is prepended on its own line before the measure.
1052
1161
  const measuresOutput = measureStrs
1053
- .map((m, i) => m + ' | %' + (i + 1))
1162
+ .map((m, i) => {
1163
+ const label = voltaLabels.get(i + 1);
1164
+ const prefix = label !== undefined ? '% volta ' + label + '\n' : '';
1165
+ return prefix + m + ' | %' + (i + 1);
1166
+ })
1054
1167
  .join('\n\n');
1055
1168
  parts.push(measuresOutput);
1056
1169
 
@@ -304,6 +304,10 @@ export interface Metadata {
304
304
  instrument?: string;
305
305
  genre?: string;
306
306
  staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
307
+ // Raw measure-layout (performance/repeat order) DSL, lotus-style, e.g.
308
+ // "2*[1..8]{9,10}, 11..16" (index-wise) or "s: 4 <2 6> 2" (segment-wise).
309
+ // Stored verbatim; parsing/expansion to MEI <expansion> is a later phase.
310
+ measureLayout?: string;
307
311
  // Per-staff / per-group instrument names, keyed by staff-layout group key (a single
308
312
  // staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
309
313
  // [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.