@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:
|
|
227
|
-
|
|
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.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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.
|
|
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:
|
|
237
|
-
|
|
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.
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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.
|