@k-l-lambda/lilylet 0.1.79 → 0.1.81

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
@@ -163,7 +163,11 @@ const renderRepeatSpan = (infos, lo, hi) => {
163
163
  return rangeCode(lo, hi);
164
164
  }
165
165
  const endInfo = repeatEnds[0];
166
- const startIdx = span.find(i => i.repeatStart)?.index ?? lo;
166
+ // The repeat-start for THIS end is the latest repeat-start at or before it; a
167
+ // repeat-start sitting AFTER the end (e.g. the second half of a ":|:"/"::" that
168
+ // opens the next section) belongs to a following span, not this one. Falling
169
+ // back to `lo` models the implicit "repeat from the start of the span".
170
+ const startIdx = span.filter(i => i.repeatStart && i.index <= endInfo.index).map(i => i.index).sort((a, b) => b - a)[0] ?? lo;
167
171
  const times = endInfo.repeatTimes ?? 2;
168
172
  const endingStarts = span.filter(i => i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
169
173
  if (endingStarts.length === 0) {
@@ -205,49 +209,52 @@ const renderRepeatSpan = (infos, lo, hi) => {
205
209
  parts.push(rangeCode(lastStop + 1, hi));
206
210
  return parts.join(", ");
207
211
  };
208
- // Render a whole piece that has one OR MORE plain repeat/volta sections in
212
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
209
213
  // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
210
214
  // 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
215
+ // — it bails when it sees >1 repeat-end. Here we cut the span at section
212
216
  // 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);
217
+ // sub-span independently, joining with commas. Any non-repeated measures between
218
+ // two sections are absorbed as the `pre` of the following span. Returns null if
219
+ // any section isn't a clean single repeat/volta (caller then falls back to flat).
220
+ // Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
221
+ // halves of a da-capo ABA.
222
+ const renderRepeatSections = (infos, lo, hi) => {
223
+ const inSpan = (idx) => idx >= lo && idx <= hi;
224
+ const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
225
+ const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
219
226
  if (repeatEnds.length <= 1)
220
- return renderRepeatSpan(infos, 1, total);
221
- const endingStops = infos.filter(i => i.endingStop !== undefined).map(i => i.index);
227
+ return renderRepeatSpan(infos, lo, hi);
228
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
222
229
  const parts = [];
223
- let prevHi = 0;
230
+ let prevHi = lo - 1;
224
231
  for (const e of repeatEnds) {
225
- const lo = prevHi + 1;
232
+ const sectionLo = prevHi + 1;
226
233
  // The next section's repeat-start bounds this section's voltas (2nd/3rd
227
234
  // endings sit AFTER the repeat-end but BEFORE the next section starts).
228
235
  const nextStart = repeatStarts.find(s => s > e);
229
236
  const sectionEndingStarts = infos
230
- .filter(i => i.endingStart !== undefined && i.index >= lo && (nextStart === undefined || i.index < nextStart))
237
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
231
238
  .map(i => i.index)
232
239
  .sort((a, b) => a - b);
233
- let hi;
240
+ let sectionHi;
234
241
  if (sectionEndingStarts.length) {
235
242
  const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
236
243
  // the last ending's stop closes the section; else the ending start itself
237
244
  const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
238
- hi = stop ?? lastStart;
245
+ sectionHi = stop ?? lastStart;
239
246
  }
240
247
  else
241
- hi = e;
242
- const code = renderRepeatSpan(infos, lo, hi);
248
+ sectionHi = e;
249
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
243
250
  if (code === null)
244
251
  return null;
245
252
  parts.push(code);
246
- prevHi = hi;
253
+ prevHi = sectionHi;
247
254
  }
248
255
  // trailing non-repeated tail
249
- if (prevHi < total)
250
- parts.push(rangeCode(prevHi + 1, total));
256
+ if (prevHi < hi)
257
+ parts.push(rangeCode(prevHi + 1, hi));
251
258
  return parts.join(", ");
252
259
  };
253
260
  const tryStructured = (infos, total) => {
@@ -271,14 +278,48 @@ const tryStructured = (infos, total) => {
271
278
  // No navigation: one or more plain repeat/volta sections in sequence. Most
272
279
  // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
273
280
  // render each independently and join with commas.
274
- return renderRepeatSections(infos, total);
281
+ return renderRepeatSections(infos, 1, total);
275
282
  };
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.
283
+ // Construct the ABA form <main, rest> for a D.C. case. The ABA expansion plays
284
+ // A B A' where A = main (Full), B = rest (Full), A' = main (Once: volta bodies
285
+ // once + last alternate, no inner repeat). For D.C. al Fine, A is the whole
286
+ // pre-Fine span (1..fine) and B is the post-Fine span up to the D.C. (fine+1..dc);
287
+ // the da-capo replay A' then plays main once more, stopping at Fine. Both A and B
288
+ // can themselves contain repeat/volta sections, so each is structured recursively
289
+ // via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
290
+ // bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
291
+ // re-expand check still validates the result against the simulated order.
281
292
  const buildDaCapoABA = (infos, dcIdx) => {
293
+ const fineIdx = infos.find(i => i.fine)?.index;
294
+ if (fineIdx === undefined) {
295
+ // No explicit Fine: fall back to the legacy single-volta ABA shape, where
296
+ // A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
297
+ return buildDaCapoABALegacy(infos, dcIdx);
298
+ }
299
+ if (fineIdx >= dcIdx)
300
+ return null; // Fine must precede the D.C. tail
301
+ const restLo = fineIdx + 1;
302
+ if (restLo > dcIdx)
303
+ return null; // no B section
304
+ const mainCode = renderRepeatSections(infos, 1, fineIdx);
305
+ const restCode = renderRepeatSections(infos, restLo, dcIdx);
306
+ if (mainCode === null || restCode === null)
307
+ return null;
308
+ // `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
309
+ // in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
310
+ // which parseItem stops at the first number). A lone number, or a single
311
+ // already-structured token ("2*[..]" / "2*[..]{..}" / "[..]"), parses as one item
312
+ // and needs no extra bracket. `rest` is parsed as a sequence, so it only needs
313
+ // bracketing to group a comma sequence — a range there is fine.
314
+ const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
315
+ const main = isSingleToken ? mainCode : `[${mainCode}]`;
316
+ const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
317
+ return `<${main}, ${rest}>`;
318
+ };
319
+ // Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
320
+ // volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
321
+ // the original over-volta-without-Fine convention.
322
+ const buildDaCapoABALegacy = (infos, dcIdx) => {
282
323
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
283
324
  if (endingStarts.length === 0)
284
325
  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.81",
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
 
@@ -174,7 +174,11 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
174
174
  }
175
175
 
176
176
  const endInfo = repeatEnds[0];
177
- const startIdx = span.find(i => i.repeatStart)?.index ?? lo;
177
+ // The repeat-start for THIS end is the latest repeat-start at or before it; a
178
+ // repeat-start sitting AFTER the end (e.g. the second half of a ":|:"/"::" that
179
+ // opens the next section) belongs to a following span, not this one. Falling
180
+ // back to `lo` models the implicit "repeat from the start of the span".
181
+ const startIdx = span.filter(i => i.repeatStart && i.index <= endInfo.index).map(i => i.index).sort((a, b) => b - a)[0] ?? lo;
178
182
  const times = endInfo.repeatTimes ?? 2;
179
183
  const endingStarts = span.filter(i => i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
180
184
 
@@ -214,49 +218,52 @@ const renderRepeatSpan = (infos: MeasureRepeatInfo[], lo: number, hi: number): s
214
218
  return parts.join(", ");
215
219
  };
216
220
 
217
- // Render a whole piece that has one OR MORE plain repeat/volta sections in
221
+ // Render a span [lo..hi] that has one OR MORE plain repeat/volta sections in
218
222
  // sequence (no navigation). Real scores are usually multi-section (AABB minuets,
219
223
  // 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
224
+ // — it bails when it sees >1 repeat-end. Here we cut the span at section
221
225
  // 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);
226
+ // sub-span independently, joining with commas. Any non-repeated measures between
227
+ // two sections are absorbed as the `pre` of the following span. Returns null if
228
+ // any section isn't a clean single repeat/volta (caller then falls back to flat).
229
+ // Used both for the whole piece (lo=1, hi=total) and recursively for the A / B
230
+ // halves of a da-capo ABA.
231
+ const renderRepeatSections = (infos: MeasureRepeatInfo[], lo: number, hi: number): string | null => {
232
+ const inSpan = (idx: number) => idx >= lo && idx <= hi;
233
+ const repeatEnds = infos.filter(i => i.repeatEnd && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
234
+ const repeatStarts = infos.filter(i => i.repeatStart && inSpan(i.index)).map(i => i.index).sort((a, b) => a - b);
235
+ if (repeatEnds.length <= 1) return renderRepeatSpan(infos, lo, hi);
236
+
237
+ const endingStops = infos.filter(i => i.endingStop !== undefined && inSpan(i.index)).map(i => i.index);
231
238
 
232
239
  const parts: string[] = [];
233
- let prevHi = 0;
240
+ let prevHi = lo - 1;
234
241
  for (const e of repeatEnds) {
235
- const lo = prevHi + 1;
242
+ const sectionLo = prevHi + 1;
236
243
  // The next section's repeat-start bounds this section's voltas (2nd/3rd
237
244
  // endings sit AFTER the repeat-end but BEFORE the next section starts).
238
245
  const nextStart = repeatStarts.find(s => s > e);
239
246
  const sectionEndingStarts = infos
240
- .filter(i => i.endingStart !== undefined && i.index >= lo && (nextStart === undefined || i.index < nextStart))
247
+ .filter(i => i.endingStart !== undefined && i.index >= sectionLo && (nextStart === undefined || i.index < nextStart) && inSpan(i.index))
241
248
  .map(i => i.index)
242
249
  .sort((a, b) => a - b);
243
250
 
244
- let hi: number;
251
+ let sectionHi: number;
245
252
  if (sectionEndingStarts.length) {
246
253
  const lastStart = sectionEndingStarts[sectionEndingStarts.length - 1];
247
254
  // the last ending's stop closes the section; else the ending start itself
248
255
  const stop = endingStops.filter(s => s >= lastStart).sort((a, b) => a - b)[0];
249
- hi = stop ?? lastStart;
256
+ sectionHi = stop ?? lastStart;
250
257
  }
251
- else hi = e;
258
+ else sectionHi = e;
252
259
 
253
- const code = renderRepeatSpan(infos, lo, hi);
260
+ const code = renderRepeatSpan(infos, sectionLo, sectionHi);
254
261
  if (code === null) return null;
255
262
  parts.push(code);
256
- prevHi = hi;
263
+ prevHi = sectionHi;
257
264
  }
258
265
  // trailing non-repeated tail
259
- if (prevHi < total) parts.push(rangeCode(prevHi + 1, total));
266
+ if (prevHi < hi) parts.push(rangeCode(prevHi + 1, hi));
260
267
  return parts.join(", ");
261
268
  };
262
269
 
@@ -282,15 +289,49 @@ const tryStructured = (infos: MeasureRepeatInfo[], total: number): string | null
282
289
  // No navigation: one or more plain repeat/volta sections in sequence. Most
283
290
  // real pieces (AABB minuets, sonata exposition+recap) have ≥2 repeat sections;
284
291
  // render each independently and join with commas.
285
- return renderRepeatSections(infos, total);
292
+ return renderRepeatSections(infos, 1, total);
286
293
  };
287
294
 
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.
295
+ // Construct the ABA form <main, rest> for a D.C. case. The ABA expansion plays
296
+ // A B A' where A = main (Full), B = rest (Full), A' = main (Once: volta bodies
297
+ // once + last alternate, no inner repeat). For D.C. al Fine, A is the whole
298
+ // pre-Fine span (1..fine) and B is the post-Fine span up to the D.C. (fine+1..dc);
299
+ // the da-capo replay A' then plays main once more, stopping at Fine. Both A and B
300
+ // can themselves contain repeat/volta sections, so each is structured recursively
301
+ // via renderRepeatSections. Returns null when the shape doesn't fit (no Fine to
302
+ // bound A, or no rest tail) → caller uses the flat fallback. The end-of-build
303
+ // re-expand check still validates the result against the simulated order.
293
304
  const buildDaCapoABA = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
305
+ const fineIdx = infos.find(i => i.fine)?.index;
306
+ if (fineIdx === undefined) {
307
+ // No explicit Fine: fall back to the legacy single-volta ABA shape, where
308
+ // A ends at the last pre-D.C. ending and B is the tail to the D.C. measure.
309
+ return buildDaCapoABALegacy(infos, dcIdx);
310
+ }
311
+ if (fineIdx >= dcIdx) return null; // Fine must precede the D.C. tail
312
+ const restLo = fineIdx + 1;
313
+ if (restLo > dcIdx) return null; // no B section
314
+
315
+ const mainCode = renderRepeatSections(infos, 1, fineIdx);
316
+ const restCode = renderRepeatSections(infos, restLo, dcIdx);
317
+ if (mainCode === null || restCode === null) return null;
318
+
319
+ // `main` is parsed by the ABA grammar as ONE item (parseItem). It needs wrapping
320
+ // in [..] when it is a comma sequence ("1, 2*[..]") or a BARE range ("1..37",
321
+ // which parseItem stops at the first number). A lone number, or a single
322
+ // already-structured token ("2*[..]" / "2*[..]{..}" / "[..]"), parses as one item
323
+ // and needs no extra bracket. `rest` is parsed as a sequence, so it only needs
324
+ // bracketing to group a comma sequence — a range there is fine.
325
+ const isSingleToken = /^\d+$/.test(mainCode) || /^\d+\*\[/.test(mainCode) || /^\[/.test(mainCode);
326
+ const main = isSingleToken ? mainCode : `[${mainCode}]`;
327
+ const rest = /,/.test(restCode) ? `[${restCode}]` : restCode;
328
+ return `<${main}, ${rest}>`;
329
+ };
330
+
331
+ // Legacy ABA builder for D.C. WITHOUT an explicit Fine: main = the single
332
+ // volta/repeat structure before the D.C., rest = the post-volta tail. Kept for
333
+ // the original over-volta-without-Fine convention.
334
+ const buildDaCapoABALegacy = (infos: MeasureRepeatInfo[], dcIdx: number): string | null => {
294
335
  const endingStarts = infos.filter(i => i.index <= dcIdx && i.endingStart !== undefined).map(i => i.index).sort((a, b) => a - b);
295
336
  if (endingStarts.length === 0) return null; // plain D.C. al Fine (no volta) → flat
296
337
  // body+endings up to the last ending stop; the rest (tail) runs to dcIdx.