@k-l-lambda/lilylet 0.1.78 → 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,6 +205,54 @@ const renderRepeatSpan = (infos, lo, hi) => {
205
205
  parts.push(rangeCode(lastStop + 1, hi));
206
206
  return parts.join(", ");
207
207
  };
208
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
209
+ // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
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 span at section
212
+ // boundaries (each cut ends a repeat section, voltas included) and render each
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);
222
+ if (repeatEnds.length <= 1)
223
+ return renderRepeatSpan(infos, lo, hi);
224
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
225
+ const parts = [];
226
+ let prevHi = lo - 1;
227
+ for (const e of repeatEnds) {
228
+ const sectionLo = prevHi + 1;
229
+ // The next section's repeat-start bounds this section's voltas (2nd/3rd
230
+ // endings sit AFTER the repeat-end but BEFORE the next section starts).
231
+ const nextStart = repeatStarts.find(s => s > e);
232
+ const sectionEndingStarts = infos
233
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
234
+ .map(i => i.index)
235
+ .sort((a, b) => a - b);
236
+ let sectionHi;
237
+ if (sectionEndingStarts.length) {
238
+ const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
239
+ // the last ending's stop closes the section; else the ending start itself
240
+ const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
241
+ sectionHi = stop ?? lastStart;
242
+ }
243
+ else
244
+ sectionHi = e;
245
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
246
+ if (code === null)
247
+ return null;
248
+ parts.push(code);
249
+ prevHi = sectionHi;
250
+ }
251
+ // trailing non-repeated tail
252
+ if (prevHi < hi)
253
+ parts.push(rangeCode(prevHi + 1, hi));
254
+ return parts.join(", ");
255
+ };
208
256
  const tryStructured = (infos, total) => {
209
257
  const dacapo = infos.find(i => i.dacapo);
210
258
  const dalsegno = infos.some(i => i.dalsegno);
@@ -223,15 +271,46 @@ const tryStructured = (infos, total) => {
223
271
  }
224
272
  if (dalsegno || tocoda || infos.some(i => i.fine || i.segno || i.coda))
225
273
  return null;
226
- // No navigation: a single plain repeat/volta over the whole piece.
227
- return renderRepeatSpan(infos, 1, total);
274
+ // No navigation: one or more plain repeat/volta sections in sequence. Most
275
+ // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
276
+ // render each independently and join with commas.
277
+ return renderRepeatSections(infos, 1, total);
228
278
  };
229
- // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
230
- // structure; rest = the post-volta tail before the D.C. measure. The Once
231
- // re-expansion of main supplies the da-capo replay (body + last ending), giving
232
- // the over-volta semantics. Returns null when the shape doesn't fit (no volta,
233
- // 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.
234
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) => {
235
314
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
236
315
  if (endingStarts.length === 0)
237
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.78",
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,6 +214,55 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
214
214
  return parts.join(", ");
215
215
  };
216
216
 
217
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
218
+ // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
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 span at section
221
+ // boundaries (each cut ends a repeat section, voltas included) and render each
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);
234
+
235
+ const parts: string[] = [];
236
+ let prevHi = lo - 1;
237
+ for (const e of repeatEnds) {
238
+ const sectionLo = prevHi + 1;
239
+ // The next section's repeat-start bounds this section's voltas (2nd/3rd
240
+ // endings sit AFTER the repeat-end but BEFORE the next section starts).
241
+ const nextStart = repeatStarts.find(s => s > e);
242
+ const sectionEndingStarts = infos
243
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
244
+ .map(i => i.index)
245
+ .sort((a, b) => a - b);
246
+
247
+ let sectionHi: number;
248
+ if (sectionEndingStarts.length) {
249
+ const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
250
+ // the last ending's stop closes the section; else the ending start itself
251
+ const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
252
+ sectionHi = stop ?? lastStart;
253
+ }
254
+ else sectionHi = e;
255
+
256
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
257
+ if (code === null) return null;
258
+ parts.push(code);
259
+ prevHi = sectionHi;
260
+ }
261
+ // trailing non-repeated tail
262
+ if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
263
+ return parts.join(", ");
264
+ };
265
+
217
266
  const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null => {
218
267
  const dacapo = infos.find(i => i.dacapo);
219
268
  const dalsegno = infos.some(i => i.dalsegno);
@@ -233,16 +282,47 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
233
282
  }
234
283
  if (dalsegno || tocoda || infos.some(i => i.fine || i.segno || i.coda)) return null;
235
284
 
236
- // No navigation: a single plain repeat/volta over the whole piece.
237
- return renderRepeatSpan(infos, 1, total);
285
+ // No navigation: one or more plain repeat/volta sections in sequence. Most
286
+ // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
287
+ // render each independently and join with commas.
288
+ return renderRepeatSections(infos, 1, total);
238
289
  };
239
290
 
240
- // Construct the ABA form <main, rest> for a D.C. case. main = the volta/repeat
241
- // structure; rest = the post-volta tail before the D.C. measure. The Once
242
- // re-expansion of main supplies the da-capo replay (body + last ending), giving
243
- // the over-volta semantics. Returns null when the shape doesn't fit (no volta,
244
- // 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.
245
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 => {
246
326
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
247
327
  if (endingStarts.length === 0) return null; // plain D.C. al Fine (no volta) → flat
248
328
  // body+endings up to the last ending stop; the rest (tail) runs to dcIdx.