@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
|
-
|
|
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
|
|
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
|
|
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
|
|
214
|
-
// sections are absorbed as the `pre` of the following span. Returns null if
|
|
215
|
-
// section isn't a clean single repeat/volta (caller then falls back to flat).
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
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 =
|
|
230
|
+
let prevHi = lo - 1;
|
|
224
231
|
for (const e of repeatEnds) {
|
|
225
|
-
const
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
245
|
+
sectionHi = stop ?? lastStart;
|
|
239
246
|
}
|
|
240
247
|
else
|
|
241
|
-
|
|
242
|
-
const code = renderRepeatSpan(infos,
|
|
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 =
|
|
253
|
+
prevHi = sectionHi;
|
|
247
254
|
}
|
|
248
255
|
// trailing non-repeated tail
|
|
249
|
-
if (prevHi <
|
|
250
|
-
parts.push(rangeCode(prevHi + 1,
|
|
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.
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
223
|
-
// sections are absorbed as the `pre` of the following span. Returns null if
|
|
224
|
-
// section isn't a clean single repeat/volta (caller then falls back to flat).
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
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 =
|
|
240
|
+
let prevHi = lo - 1;
|
|
234
241
|
for (const e of repeatEnds) {
|
|
235
|
-
const
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
256
|
+
sectionHi = stop ?? lastStart;
|
|
250
257
|
}
|
|
251
|
-
else
|
|
258
|
+
else sectionHi = e;
|
|
252
259
|
|
|
253
|
-
const code = renderRepeatSpan(infos,
|
|
260
|
+
const code = renderRepeatSpan(infos, sectionLo, sectionHi);
|
|
254
261
|
if (code === null) return null;
|
|
255
262
|
parts.push(code);
|
|
256
|
-
prevHi =
|
|
263
|
+
prevHi = sectionHi;
|
|
257
264
|
}
|
|
258
265
|
// trailing non-repeated tail
|
|
259
|
-
if (prevHi <
|
|
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.
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
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.
|