@k-l-lambda/lilylet 0.1.76 → 0.1.79

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.
@@ -40,6 +40,7 @@ import {
40
40
  InstrumentName,
41
41
  } from "./types";
42
42
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
43
+ import { MeasureRepeatInfo, buildMeasureLayout, applyNavText } from "./measureLayoutFromXml";
43
44
 
44
45
 
45
46
  // ============ Constants ============
@@ -456,6 +457,136 @@ const convertBarline = (bar: string | null): string | undefined => {
456
457
  };
457
458
 
458
459
 
460
+ // ============ Repeat / Volta → measure-layout ============
461
+
462
+ // Per-measure repeat markup gathered from the ABC bar tokens, in document order.
463
+ // trailBar — the measure's TRAILING bar string (patch.bar), e.g. "|", ":|", "|:",
464
+ // ":|:", ":|]", "|2", ":|1,2". In ABC a repeat-START / volta-START sits
465
+ // on the bar that PRECEDES a measure, so it is the trailing bar of the
466
+ // previous measure.
467
+ // leadVolta — a bracket-volta marker ("1", "2", "1,2", …) captured at the START of
468
+ // this measure's music (the `[N` term the grammar now preserves).
469
+ // navTexts — navigation directive/glyph texts on this measure ("D.C.", "D.S. al
470
+ // Coda", "Fine", "Coda", "Segno") from ABC !decorations! — fed through
471
+ // the shared applyNavText so ABC nav semantics match the MusicXML path.
472
+ interface AbcBarInfo {
473
+ trailBar: string | null;
474
+ leadVolta?: string;
475
+ navTexts?: string[];
476
+ }
477
+
478
+ // First integer of a volta spec ("2" from "2", "1" from "1,2", "1" from "1,3,5").
479
+ const firstVoltaNum = (spec: string): number => {
480
+ const m = spec.match(/\d+/);
481
+ return m ? parseInt(m[0], 10) : 1;
482
+ };
483
+
484
+ // Pull a trailing volta digit list out of a bar string: ":|1,2" → "1,2", "|2" → "2",
485
+ // "|" / ":|" / "|]" → undefined. (The repeat glyph is stripped; only the volta remains.)
486
+ const trailVoltaOf = (bar: string): string | undefined => {
487
+ const m = bar.match(/(\d+(?:,\d+)*)$/);
488
+ return m ? m[1] : undefined;
489
+ };
490
+
491
+ /**
492
+ * Build a MeasureRepeatInfo[] from the per-measure ABC bar tokens, mirroring how
493
+ * abc2xml maps ABC bars to MusicXML <repeat>/<ending> (so this converges with the
494
+ * MusicXML→lilylet path through the shared buildMeasureLayout). One entry per lilylet
495
+ * measure (indices are 1:1 — the decoder never merges bars).
496
+ *
497
+ * ABC convention (matches abc2xml): a repeat-START "|:" / volta-START "[1" attaches to
498
+ * the bar PRECEDING the measure it opens, i.e. the trailing bar of measure N opens the
499
+ * repeat/volta of measure N+1. A repeat-END ":|" closes ON measure N. "::"/":|:" both
500
+ * ends (on N) and starts (on N+1). The 3×-repeat forms ("|::"/"::|") are treated as a
501
+ * plain 2× backward+forward pair, exactly as abc2xml emits them.
502
+ */
503
+ const collectAbcRepeatInfo = (bars: AbcBarInfo[]): MeasureRepeatInfo[] => {
504
+ const n = bars.length;
505
+ const infos: MeasureRepeatInfo[] = [];
506
+ for (let i = 0; i < n; i++) infos.push({ index: i + 1 });
507
+
508
+ // Track the currently-open volta so we can close it (endingStop) when the next
509
+ // volta opens or a repeat ends — abc2xml back-patches the stop onto the prior
510
+ // measure's right barline; here we set endingStop on the measure before the new one.
511
+ let openEndingStart = -1; // measure index (1-based) where the current volta began
512
+
513
+ const closeOpenEnding = (lastIndex: number): void => {
514
+ if (openEndingStart >= 1 && lastIndex >= openEndingStart) {
515
+ infos[lastIndex - 1].endingStop = infos[openEndingStart - 1].endingStart;
516
+ }
517
+ openEndingStart = -1;
518
+ };
519
+
520
+ // ABC marks the "to coda" jump origin and the coda landing with the SAME !coda!
521
+ // glyph: the first occurrence is the To-Coda jump, the second is the Coda section.
522
+ // (applyNavText can't tell them apart from a lone "coda" word, so disambiguate here.)
523
+ let codaSeen = 0;
524
+
525
+ for (let i = 0; i < n; i++) {
526
+ const idx = i + 1; // 1-based measure index
527
+ const info = infos[i];
528
+ const { trailBar, leadVolta, navTexts } = bars[i];
529
+
530
+ // Navigation directives / glyphs (D.C./D.S./al Fine/al Coda, Fine, Coda, Segno)
531
+ // → MeasureRepeatInfo flags via the shared recognizer, so ABC and MusicXML derive
532
+ // the same jumps. The first !coda! is the To-Coda origin, the second the landing.
533
+ for (const raw of navTexts || []) {
534
+ if (/\bcoda\b/i.test(raw) && !/\b(to|al)\s*coda\b/i.test(raw)) {
535
+ // bare "Coda" glyph: 1st = tocoda origin, 2nd = coda landing
536
+ if (codaSeen === 0) info.tocoda = true; else info.coda = true;
537
+ codaSeen++;
538
+ } else {
539
+ applyNavText(info, raw);
540
+ }
541
+ }
542
+
543
+ // A bracket-volta term ("[1") at the START of this measure opens an ending here.
544
+ if (leadVolta !== undefined) {
545
+ if (openEndingStart >= 1 && openEndingStart !== idx) closeOpenEnding(idx - 1);
546
+ info.endingStart = firstVoltaNum(leadVolta);
547
+ openEndingStart = idx;
548
+ }
549
+
550
+ if (!trailBar) continue;
551
+
552
+ // Repeat END on this measure: any bar beginning with ":|" or "::".
553
+ // Consistency note: like abc2xml, we do NOT honor the ABC 3×-repeat forms
554
+ // ("|::"/"::|") as a times count — abc2xml emits them as a plain backward+forward
555
+ // repeat pair (2×), and matching that keeps the ABC and MusicXML paths identical.
556
+ const isEndStart = trailBar === ":|:" || trailBar === ":||:" || trailBar === "::";
557
+ const isEnd = trailBar.startsWith(":|") || trailBar.startsWith("::");
558
+ if (isEnd) {
559
+ info.repeatEnd = true;
560
+ closeOpenEnding(idx);
561
+ }
562
+
563
+ // Repeat START opened by this trailing bar attaches to the NEXT measure
564
+ // ("|:" forward, ":|:"/"::"/":||:" end-and-start).
565
+ const startsNext = trailBar.includes("|:") || isEndStart;
566
+ if (startsNext && idx < n) {
567
+ infos[idx].repeatStart = true;
568
+ }
569
+
570
+ // A trailing volta digit ("|2", ":|1,2") opens an ending on the NEXT measure.
571
+ const tv = trailVoltaOf(trailBar);
572
+ if (tv !== undefined && idx < n) {
573
+ if (openEndingStart >= 1) closeOpenEnding(idx);
574
+ infos[idx].endingStart = firstVoltaNum(tv);
575
+ openEndingStart = idx + 1;
576
+ } else if ((trailBar === "||" || trailBar === "|]" || trailBar === ":|]") && openEndingStart >= 1 && openEndingStart <= idx) {
577
+ // A section bar ("||", final "|]") closes an open ending here — matching
578
+ // abc2xml, which stops the volta at the next structural bar (so a trailing
579
+ // final ending like ":|2 …||" is a single section, not run to piece end).
580
+ closeOpenEnding(idx);
581
+ }
582
+ }
583
+
584
+ // Close a still-open final volta at the last measure.
585
+ closeOpenEnding(n);
586
+ return infos;
587
+ };
588
+
589
+
459
590
  // ============ Score Layout Parser ============
460
591
 
461
592
  interface StaffAssignment {
@@ -1359,6 +1490,11 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1359
1490
  // Process each ABC measure into Lilylet Measure
1360
1491
  const lilyletMeasures: Measure[] = [];
1361
1492
 
1493
+ // Per-measure repeat markup (trailing bar + leading bracket-volta), gathered for the
1494
+ // reference voice so we can derive metadata.measureLayout after the loop. ABC repeat
1495
+ // markup is voice-global by convention, so one voice suffices.
1496
+ const barInfos: AbcBarInfo[] = [];
1497
+
1362
1498
  for (let mi = 0; mi < measures.length; mi++) {
1363
1499
  const abcMeasure = measures[mi];
1364
1500
 
@@ -1372,6 +1508,50 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1372
1508
  voicePatches.get(voiceNum)!.push(patch);
1373
1509
  }
1374
1510
 
1511
+ // Capture this measure's repeat markup from the reference voice (the lowest
1512
+ // voice number present). trailBar = its raw trailing bar; leadVolta = a "[N"
1513
+ // bracket-volta term at the start of its music. Navigation directives/glyphs
1514
+ // (!D.C.!/!D.S.!/!fine!/!coda!/!segno!) are gathered from ALL voices, since an
1515
+ // engraver may place them on any single staff of a grand-staff system.
1516
+ {
1517
+ const refVoice = Math.min(...Array.from(voicePatches.keys()));
1518
+ const refPatches = voicePatches.get(refVoice) || [];
1519
+ let trailBar: string | null = null;
1520
+ let leadVolta: string | undefined;
1521
+ for (const p of refPatches) {
1522
+ if (p.bar) trailBar = p.bar; // last non-null trailing bar wins
1523
+ if (leadVolta === undefined) {
1524
+ const vt = (p.terms || []).find((t: any) => t.volta !== undefined);
1525
+ if (vt) leadVolta = (vt as any).volta;
1526
+ }
1527
+ }
1528
+ const navTexts: string[] = [];
1529
+ for (const patches of voicePatches.values()) {
1530
+ for (const p of patches) {
1531
+ for (const t of (p.terms || []) as any[]) {
1532
+ // !D.C.!/!D.S.! (incl. "al fine"/"al coda") → directive text.
1533
+ if (typeof t.directive === "string") navTexts.push(t.directive);
1534
+ // !fine!/!coda!/!segno! decorations parse to an articulation name.
1535
+ else if (t.articulation === "fine" || t.articulation === "coda" || t.articulation === "segno") {
1536
+ navTexts.push(t.articulation);
1537
+ }
1538
+ // A quoted text annotation that is EXACTLY a navigation word
1539
+ // ("Fine", "D.C. al Fine", "Coda", …) is treated as navigation, as
1540
+ // abc2xml does. Strip the placement prefix (^/_/</>/@) and trailing
1541
+ // punctuation; require a pure nav phrase so ordinary text/lyrics are
1542
+ // not misread.
1543
+ else if (typeof t.text === "string") {
1544
+ const body = t.text.replace(/^[\^_<>@]/, "").trim().replace(/[.\s]+$/, "");
1545
+ if (/^(d\.?\s*[cs]\.?(\s+al\s+(fine|coda))?|fine|coda|segno|to\s*coda)$/i.test(body)) {
1546
+ navTexts.push(body);
1547
+ }
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ barInfos.push({ trailBar, leadVolta, navTexts: navTexts.length ? navTexts : undefined });
1553
+ }
1554
+
1375
1555
  // Process each voice
1376
1556
  const partVoicesMap = new Map<number, Map<number, Voice>>(); // partIndex → (staffNum → voices)
1377
1557
 
@@ -1496,6 +1676,15 @@ const decodeTune = (tune: ABC.Tune, options: DecodeOptions = {}): LilyletDoc =>
1496
1676
  }
1497
1677
  }
1498
1678
 
1679
+ // Derive a performance-order measure layout from the ABC repeat/volta markup,
1680
+ // converging with the MusicXML path via the shared buildMeasureLayout. Emitted as
1681
+ // metadata.measureLayout → serialized [measures "..."] → MEI <expansion>.
1682
+ try {
1683
+ const repeatInfos = collectAbcRepeatInfo(barInfos);
1684
+ const layout = buildMeasureLayout(repeatInfos, repeatInfos.length);
1685
+ if (layout) metadata.measureLayout = layout;
1686
+ } catch { /* repeat derivation is best-effort; never block decode */ }
1687
+
1499
1688
  const doc: LilyletDoc = {
1500
1689
  measures: lilyletMeasures,
1501
1690
  };