@k-l-lambda/lilylet 0.1.79 → 0.1.80

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.
@@ -720,6 +720,29 @@ const abcInstrumentsToLilylet = (stavesCode, voiceInstr) => {
720
720
  return Object.keys(result).length > 0 ? result : null;
721
721
  };
722
722
  // ============ Marks/Decorations Conversion ============
723
+ const navTextToMark = (raw) => {
724
+ const body = raw.replace(/^[\^_<>@]/, "").trim().replace(/[.\s]+$/, "");
725
+ const t = body.toLowerCase().replace(/\s+/g, " ");
726
+ // glyph names → navigation marks
727
+ if (t === "coda")
728
+ return { markType: "navigation", type: NavigationMarkType.coda };
729
+ if (t === "segno")
730
+ return { markType: "navigation", type: NavigationMarkType.segno };
731
+ // textual jumps → markup with the canonical label used by the XML path
732
+ const isDC = /^d\.?\s*c\.?/.test(t) || /\bda\s*capo\b/.test(t);
733
+ const isDS = /^d\.?\s*s\.?/.test(t) || /\bdal\s*segno\b/.test(t);
734
+ const alFine = /\bal\s*fine\b/.test(t);
735
+ const alCoda = /\bal\s*coda\b/.test(t);
736
+ if (isDC)
737
+ return { markType: "markup", content: alFine ? "D.C. al Fine" : alCoda ? "D.C. al Coda" : "D.C.", placement: Placement.above };
738
+ if (isDS)
739
+ return { markType: "markup", content: alFine ? "D.S. al Fine" : alCoda ? "D.S. al Coda" : "D.S.", placement: Placement.above };
740
+ if (/\bto\s*coda\b/.test(t))
741
+ return { markType: "markup", content: "To Coda", placement: Placement.above };
742
+ if (t === "fine")
743
+ return { markType: "markup", content: "Fine", placement: Placement.above };
744
+ return undefined;
745
+ };
723
746
  const convertArticulationMark = (artName) => {
724
747
  switch (artName) {
725
748
  case "accent":
@@ -1341,6 +1364,11 @@ const decodeTune = (tune, options = {}) => {
1341
1364
  // reference voice so we can derive metadata.measureLayout after the loop. ABC repeat
1342
1365
  // markup is voice-global by convention, so one voice suffices.
1343
1366
  const barInfos = [];
1367
+ // Navigation marks (D.C./D.S./Fine/Coda/Segno) to render as VISIBLE marks on
1368
+ // the first note of each measure that carries them, keyed by 0-based measure
1369
+ // index. Built alongside barInfos' navTexts; attached after each measure's
1370
+ // parts are assembled (mirrors the MusicXML path attaching nav to a note).
1371
+ const navMarksByMeasure = new Map();
1344
1372
  for (let mi = 0; mi < measures.length; mi++) {
1345
1373
  const abcMeasure = measures[mi];
1346
1374
  // Group patches by voice number
@@ -1397,6 +1425,28 @@ const decodeTune = (tune, options = {}) => {
1397
1425
  }
1398
1426
  }
1399
1427
  barInfos.push({ trailBar, leadVolta, navTexts: navTexts.length ? navTexts : undefined });
1428
+ // Convert the navigation texts to VISIBLE marks (consistent with the
1429
+ // MusicXML path). De-dup identical marks so a directive echoed on
1430
+ // several voices yields one glyph/text. (Bare in-note `express` coda/segno
1431
+ // are a separate ABC form handled in processExpressiveTerm; the !coda!/
1432
+ // !segno!/!D.C.!/!fine! decorations gathered here parse to articulation/
1433
+ // directive/text and have no per-note express, so they need attaching.)
1434
+ if (navTexts.length) {
1435
+ const seen = new Set();
1436
+ const marks = [];
1437
+ for (const raw of navTexts) {
1438
+ const mark = navTextToMark(raw);
1439
+ if (!mark)
1440
+ continue;
1441
+ const key = mark.markType === "markup" ? `m:${mark.content}` : `n:${mark.type}`;
1442
+ if (seen.has(key))
1443
+ continue;
1444
+ seen.add(key);
1445
+ marks.push(mark);
1446
+ }
1447
+ if (marks.length)
1448
+ navMarksByMeasure.set(mi, marks);
1449
+ }
1400
1450
  }
1401
1451
  // Process each voice
1402
1452
  const partVoicesMap = new Map(); // partIndex → (staffNum → voices)
@@ -1491,6 +1541,26 @@ const decodeTune = (tune, options = {}) => {
1491
1541
  if (timeSig)
1492
1542
  measure.timeSig = timeSig;
1493
1543
  }
1544
+ // Attach this measure's navigation marks (D.C./D.S./Fine/Coda/Segno) to its
1545
+ // first NoteEvent, scanning parts/voices in order (the reference staff). The
1546
+ // MusicXML path likewise attaches navigation to a note; keeping it on a note
1547
+ // means the serializer renders \coda/\segno or ^\markup "D.C. al Fine".
1548
+ const navMarks = navMarksByMeasure.get(mi);
1549
+ if (navMarks && navMarks.length) {
1550
+ let attached = false;
1551
+ for (const part of parts) {
1552
+ for (const voice of part.voices) {
1553
+ const firstNote = voice.events.find(e => e.type === "note");
1554
+ if (firstNote) {
1555
+ firstNote.marks = [...(firstNote.marks || []), ...navMarks];
1556
+ attached = true;
1557
+ break;
1558
+ }
1559
+ }
1560
+ if (attached)
1561
+ break;
1562
+ }
1563
+ }
1494
1564
  lilyletMeasures.push(measure);
1495
1565
  }
1496
1566
  // Add tempo to first measure's first voice if present
@@ -205,49 +205,52 @@ const renderRepeatSpan = (infos, lo, hi) => {
205
205
  parts.push(rangeCode(lastStop + 1, hi));
206
206
  return parts.join(", ");
207
207
  };
208
- // Render a whole piece that has one OR MORE plain repeat/volta sections in
208
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
209
209
  // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
210
210
  // sonata exposition+recap), which the single-span renderRepeatSpan can't express
211
- // — it bails when it sees >1 repeat-end. Here we cut the piece at section
211
+ // — it bails when it sees >1 repeat-end. Here we cut the span at section
212
212
  // boundaries (each cut ends a repeat section, voltas included) and render each
213
- // span independently, joining with commas. Any non-repeated measures between two
214
- // sections are absorbed as the `pre` of the following span. Returns null if any
215
- // section isn't a clean single repeat/volta (caller then falls back to flat).
216
- const renderRepeatSections = (infos, total) => {
217
- const repeatEnds = infos.filter(i => i.repeatEnd).map(i => i.index).sort((a, b) => a - b);
218
- const repeatStarts = infos.filter(i => i.repeatStart).map(i => i.index).sort((a, b) => a - b);
213
+ // sub-span independently, joining with commas. Any non-repeated measures between
214
+ // two sections are absorbed as the `pre` of the following span. Returns null if
215
+ // any section isn't a clean single repeat/volta (caller then falls back to flat).
216
+ // Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
217
+ // halves of a da-capo ABA.
218
+ const renderRepeatSections = (infos, lo, hi) => {
219
+ const inSpan = (idx) => idx >= lo && idx <= hi;
220
+ const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
221
+ const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
219
222
  if (repeatEnds.length <= 1)
220
- return renderRepeatSpan(infos, 1, total);
221
- const endingStops = infos.filter(i => i.endingStop !== undefined).map(i => i.index);
223
+ return renderRepeatSpan(infos, lo, hi);
224
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
222
225
  const parts = [];
223
- let prevHi = 0;
226
+ let prevHi = lo - 1;
224
227
  for (const e of repeatEnds) {
225
- const lo = prevHi + 1;
228
+ const sectionLo = prevHi + 1;
226
229
  // The next section's repeat-start bounds this section's voltas (2nd/3rd
227
230
  // endings sit AFTER the repeat-end but BEFORE the next section starts).
228
231
  const nextStart = repeatStarts.find(s => s > e);
229
232
  const sectionEndingStarts = infos
230
- .filter(i => i.endingStart !== undefined && i.index >= lo && (nextStart === undefined || i.index < nextStart))
233
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
231
234
  .map(i => i.index)
232
235
  .sort((a, b) => a - b);
233
- let hi;
236
+ let sectionHi;
234
237
  if (sectionEndingStarts.length) {
235
238
  const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
236
239
  // the last ending's stop closes the section; else the ending start itself
237
240
  const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
238
- hi = stop ?? lastStart;
241
+ sectionHi = stop ?? lastStart;
239
242
  }
240
243
  else
241
- hi = e;
242
- const code = renderRepeatSpan(infos, lo, hi);
244
+ sectionHi = e;
245
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
243
246
  if (code === null)
244
247
  return null;
245
248
  parts.push(code);
246
- prevHi = hi;
249
+ prevHi = sectionHi;
247
250
  }
248
251
  // trailing non-repeated tail
249
- if (prevHi < total)
250
- parts.push(rangeCode(prevHi + 1, total));
252
+ if (prevHi < hi)
253
+ parts.push(rangeCode(prevHi + 1, hi));
251
254
  return parts.join(", ");
252
255
  };
253
256
  const tryStructured = (infos, total) => {
@@ -271,14 +274,43 @@ const tryStructured = (infos, total) => {
271
274
  // No navigation: one or more plain repeat/volta sections in sequence. Most
272
275
  // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
273
276
  // render each independently and join with commas.
274
- return renderRepeatSections(infos, total);
277
+ return renderRepeatSections(infos, 1, total);
275
278
  };
276
- // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
277
- // structure; rest = the post-volta tail before the D.C. measure. The Once
278
- // re-expansion of main supplies the da-capo replay (body + last ending), giving
279
- // the over-volta semantics. Returns null when the shape doesn't fit (no volta,
280
- // or no tail to form the ABA "rest") → caller uses the flat fallback.
279
+ // Construct the ABA form <main, rest> for a D.C. case. The ABA expansion plays
280
+ // A B A' where A = main (Full), B = rest (Full), A' = main (Once: volta bodies
281
+ // once + last alternate, no inner repeat). For D.C. al Fine, A is the whole
282
+ // pre-Fine span (1..fine) and B is the post-Fine span up to the D.C. (fine+1..dc);
283
+ // the da-capo replay A' then plays main once more, stopping at Fine. Both A and B
284
+ // can themselves contain repeat/volta sections, so each is structured recursively
285
+ // via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
286
+ // bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
287
+ // re-expand check still validates the result against the simulated order.
281
288
  const buildDaCapoABA = (infos, dcIdx) => {
289
+ const fineIdx = infos.find(i => i.fine)?.index;
290
+ if (fineIdx === undefined) {
291
+ // No explicit Fine: fall back to the legacy single-volta ABA shape, where
292
+ // A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
293
+ return buildDaCapoABALegacy(infos, dcIdx);
294
+ }
295
+ if (fineIdx >= dcIdx)
296
+ return null; // Fine must precede the D.C. tail
297
+ const restLo = fineIdx + 1;
298
+ if (restLo > dcIdx)
299
+ return null; // no B section
300
+ const mainCode = renderRepeatSections(infos, 1, fineIdx);
301
+ const restCode = renderRepeatSections(infos, restLo, dcIdx);
302
+ if (mainCode === null || restCode === null)
303
+ return null;
304
+ // main wraps in [..] only when it is a comma-separated sequence (so the ABA
305
+ // parser reads it as one block); a single token (e.g. "2*[..]") needs no bracket.
306
+ const main = /,/.test(mainCode) ? `[${mainCode}]` : mainCode;
307
+ const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
308
+ return `<${main}, ${rest}>`;
309
+ };
310
+ // Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
311
+ // volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
312
+ // the original over-volta-without-Fine convention.
313
+ const buildDaCapoABALegacy = (infos, dcIdx) => {
282
314
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
283
315
  if (endingStarts.length === 0)
284
316
  return null; // plain D.C. al Fine (no volta) → flat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.79",
3
+ "version": "0.1.80",
4
4
  "description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -839,6 +839,24 @@ const abcInstrumentsToLilylet = (
839
839
 
840
840
  // ============ Marks/Decorations Conversion ============
841
841
 
842
+ const navTextToMark = (raw: string): Mark | undefined => {
843
+ const body = raw.replace(/^[\^_<>@]/, "").trim().replace(/[.\s]+$/, "");
844
+ const t = body.toLowerCase().replace(/\s+/g, " ");
845
+ // glyph names → navigation marks
846
+ if (t === "coda") return { markType: "navigation", type: NavigationMarkType.coda };
847
+ if (t === "segno") return { markType: "navigation", type: NavigationMarkType.segno };
848
+ // textual jumps → markup with the canonical label used by the XML path
849
+ const isDC = /^d\.?\s*c\.?/.test(t) || /\bda\s*capo\b/.test(t);
850
+ const isDS = /^d\.?\s*s\.?/.test(t) || /\bdal\s*segno\b/.test(t);
851
+ const alFine = /\bal\s*fine\b/.test(t);
852
+ const alCoda = /\bal\s*coda\b/.test(t);
853
+ if (isDC) return { markType: "markup", content: alFine ? "D.C. al Fine" : alCoda ? "D.C. al Coda" : "D.C.", placement: Placement.above };
854
+ if (isDS) return { markType: "markup", content: alFine ? "D.S. al Fine" : alCoda ? "D.S. al Coda" : "D.S.", placement: Placement.above };
855
+ if (/\bto\s*coda\b/.test(t)) return { markType: "markup", content: "To Coda", placement: Placement.above };
856
+ if (t === "fine") return { markType: "markup", content: "Fine", placement: Placement.above };
857
+ return undefined;
858
+ };
859
+
842
860
  const convertArticulationMark = (artName: string): Mark | undefined => {
843
861
  switch (artName) {
844
862
  case "accent": case "L":
@@ -1495,6 +1513,12 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1495
1513
  // markup is voice-global by convention, so one voice suffices.
1496
1514
  const barInfos: AbcBarInfo[] = [];
1497
1515
 
1516
+ // Navigation marks (D.C./D.S./Fine/Coda/Segno) to render as VISIBLE marks on
1517
+ // the first note of each measure that carries them, keyed by 0-based measure
1518
+ // index. Built alongside barInfos' navTexts; attached after each measure's
1519
+ // parts are assembled (mirrors the MusicXML path attaching nav to a note).
1520
+ const navMarksByMeasure = new Map<number, Mark[]>();
1521
+
1498
1522
  for (let mi = 0; mi < measures.length; mi++) {
1499
1523
  const abcMeasure = measures[mi];
1500
1524
 
@@ -1550,6 +1574,26 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1550
1574
  }
1551
1575
  }
1552
1576
  barInfos.push({ trailBar, leadVolta, navTexts: navTexts.length ? navTexts : undefined });
1577
+
1578
+ // Convert the navigation texts to VISIBLE marks (consistent with the
1579
+ // MusicXML path). De-dup identical marks so a directive echoed on
1580
+ // several voices yields one glyph/text. (Bare in-note `express` coda/segno
1581
+ // are a separate ABC form handled in processExpressiveTerm; the !coda!/
1582
+ // !segno!/!D.C.!/!fine! decorations gathered here parse to articulation/
1583
+ // directive/text and have no per-note express, so they need attaching.)
1584
+ if (navTexts.length) {
1585
+ const seen = new Set<string>();
1586
+ const marks: Mark[] = [];
1587
+ for (const raw of navTexts) {
1588
+ const mark = navTextToMark(raw);
1589
+ if (!mark) continue;
1590
+ const key = mark.markType === "markup" ? `m:${mark.content}` : `n:${(mark as any).type}`;
1591
+ if (seen.has(key)) continue;
1592
+ seen.add(key);
1593
+ marks.push(mark);
1594
+ }
1595
+ if (marks.length) navMarksByMeasure.set(mi, marks);
1596
+ }
1553
1597
  }
1554
1598
 
1555
1599
  // Process each voice
@@ -1662,6 +1706,26 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1662
1706
  if (timeSig) measure.timeSig = timeSig;
1663
1707
  }
1664
1708
 
1709
+ // Attach this measure's navigation marks (D.C./D.S./Fine/Coda/Segno) to its
1710
+ // first NoteEvent, scanning parts/voices in order (the reference staff). The
1711
+ // MusicXML path likewise attaches navigation to a note; keeping it on a note
1712
+ // means the serializer renders \coda/\segno or ^\markup "D.C. al Fine".
1713
+ const navMarks = navMarksByMeasure.get(mi);
1714
+ if (navMarks && navMarks.length) {
1715
+ let attached = false;
1716
+ for (const part of parts) {
1717
+ for (const voice of part.voices) {
1718
+ const firstNote = voice.events.find(e => e.type === "note") as NoteEvent | undefined;
1719
+ if (firstNote) {
1720
+ firstNote.marks = [...(firstNote.marks || []), ...navMarks];
1721
+ attached = true;
1722
+ break;
1723
+ }
1724
+ }
1725
+ if (attached) break;
1726
+ }
1727
+ }
1728
+
1665
1729
  lilyletMeasures.push(measure);
1666
1730
  }
1667
1731
 
@@ -214,49 +214,52 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
214
214
  return parts.join(", ");
215
215
  };
216
216
 
217
- // Render a whole piece that has one OR MORE plain repeat/volta sections in
217
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
218
218
  // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
219
219
  // sonata exposition+recap), which the single-span renderRepeatSpan can't express
220
- // — it bails when it sees >1 repeat-end. Here we cut the piece at section
220
+ // — it bails when it sees >1 repeat-end. Here we cut the span at section
221
221
  // boundaries (each cut ends a repeat section, voltas included) and render each
222
- // span independently, joining with commas. Any non-repeated measures between two
223
- // sections are absorbed as the `pre` of the following span. Returns null if any
224
- // section isn't a clean single repeat/volta (caller then falls back to flat).
225
- const renderRepeatSections = (infos: MeasureRepeatInfo[], total: number): string | null => {
226
- const repeatEnds = infos.filter(i => i.repeatEnd).map(i => i.index).sort((a, b) => a - b);
227
- const repeatStarts = infos.filter(i => i.repeatStart).map(i => i.index).sort((a, b) => a - b);
228
- if (repeatEnds.length <= 1) return renderRepeatSpan(infos, 1, total);
229
-
230
- const endingStops = infos.filter(i => i.endingStop !== undefined).map(i => i.index);
222
+ // sub-span independently, joining with commas. Any non-repeated measures between
223
+ // two sections are absorbed as the `pre` of the following span. Returns null if
224
+ // any section isn't a clean single repeat/volta (caller then falls back to flat).
225
+ // Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
226
+ // halves of a da-capo ABA.
227
+ const renderRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
228
+ const inSpan = (idx: number) => idx >= lo && idx <= hi;
229
+ const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
230
+ const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
231
+ if (repeatEnds.length <= 1) return renderRepeatSpan(infos, lo, hi);
232
+
233
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
231
234
 
232
235
  const parts: string[] = [];
233
- let prevHi = 0;
236
+ let prevHi = lo - 1;
234
237
  for (const e of repeatEnds) {
235
- const lo = prevHi + 1;
238
+ const sectionLo = prevHi + 1;
236
239
  // The next section's repeat-start bounds this section's voltas (2nd/3rd
237
240
  // endings sit AFTER the repeat-end but BEFORE the next section starts).
238
241
  const nextStart = repeatStarts.find(s => s > e);
239
242
  const sectionEndingStarts = infos
240
- .filter(i => i.endingStart !== undefined && i.index >= lo && (nextStart === undefined || i.index < nextStart))
243
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
241
244
  .map(i => i.index)
242
245
  .sort((a, b) => a - b);
243
246
 
244
- let hi: number;
247
+ let sectionHi: number;
245
248
  if (sectionEndingStarts.length) {
246
249
  const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
247
250
  // the last ending's stop closes the section; else the ending start itself
248
251
  const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
249
- hi = stop ?? lastStart;
252
+ sectionHi = stop ?? lastStart;
250
253
  }
251
- else hi = e;
254
+ else sectionHi = e;
252
255
 
253
- const code = renderRepeatSpan(infos, lo, hi);
256
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
254
257
  if (code === null) return null;
255
258
  parts.push(code);
256
- prevHi = hi;
259
+ prevHi = sectionHi;
257
260
  }
258
261
  // trailing non-repeated tail
259
- if (prevHi < total) parts.push(rangeCode(prevHi + 1, total));
262
+ if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
260
263
  return parts.join(", ");
261
264
  };
262
265
 
@@ -282,15 +285,44 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
282
285
  // No navigation: one or more plain repeat/volta sections in sequence. Most
283
286
  // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
284
287
  // render each independently and join with commas.
285
- return renderRepeatSections(infos, total);
288
+ return renderRepeatSections(infos, 1, total);
286
289
  };
287
290
 
288
- // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
289
- // structure; rest = the post-volta tail before the D.C. measure. The Once
290
- // re-expansion of main supplies the da-capo replay (body + last ending), giving
291
- // the over-volta semantics. Returns null when the shape doesn't fit (no volta,
292
- // or no tail to form the ABA "rest") → caller uses the flat fallback.
291
+ // Construct the ABA form <main, rest> for a D.C. case. The ABA expansion plays
292
+ // A B A' where A = main (Full), B = rest (Full), A' = main (Once: volta bodies
293
+ // once + last alternate, no inner repeat). For D.C. al Fine, A is the whole
294
+ // pre-Fine span (1..fine) and B is the post-Fine span up to the D.C. (fine+1..dc);
295
+ // the da-capo replay A' then plays main once more, stopping at Fine. Both A and B
296
+ // can themselves contain repeat/volta sections, so each is structured recursively
297
+ // via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
298
+ // bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
299
+ // re-expand check still validates the result against the simulated order.
293
300
  const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
301
+ const fineIdx = infos.find(i => i.fine)?.index;
302
+ if (fineIdx === undefined) {
303
+ // No explicit Fine: fall back to the legacy single-volta ABA shape, where
304
+ // A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
305
+ return buildDaCapoABALegacy(infos, dcIdx);
306
+ }
307
+ if (fineIdx >= dcIdx) return null; // Fine must precede the D.C. tail
308
+ const restLo = fineIdx + 1;
309
+ if (restLo > dcIdx) return null; // no B section
310
+
311
+ const mainCode = renderRepeatSections(infos, 1, fineIdx);
312
+ const restCode = renderRepeatSections(infos, restLo, dcIdx);
313
+ if (mainCode === null || restCode === null) return null;
314
+
315
+ // main wraps in [..] only when it is a comma-separated sequence (so the ABA
316
+ // parser reads it as one block); a single token (e.g. "2*[..]") needs no bracket.
317
+ const main = /,/.test(mainCode) ? `[${mainCode}]` : mainCode;
318
+ const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
319
+ return `<${main}, ${rest}>`;
320
+ };
321
+
322
+ // Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
323
+ // volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
324
+ // the original over-volta-without-Fine convention.
325
+ const buildDaCapoABALegacy = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
294
326
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
295
327
  if (endingStarts.length === 0) return null; // plain D.C. al Fine (no volta) → flat
296
328
  // body+endings up to the last ending stop; the rest (tail) runs to dcIdx.