@k-l-lambda/lilylet 0.1.68 → 0.1.70

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.
@@ -1,6 +1,7 @@
1
1
  export * from "./types";
2
2
  export * from "./parser";
3
3
  export * from "./serializer";
4
+ export * from "./staffLayout";
4
5
  import * as meiEncoder from "./meiEncoder";
5
6
  import * as musicXmlDecoder from "./musicXmlDecoder";
6
7
  import * as lilypondEncoder from "./lilypondEncoder";
@@ -1,6 +1,7 @@
1
1
  export * from "./types.js";
2
2
  export * from "./parser.js";
3
3
  export * from "./serializer.js";
4
+ export * from "./staffLayout.js";
4
5
  import * as meiEncoder from "./meiEncoder.js";
5
6
  import * as musicXmlDecoder from "./musicXmlDecoder.js";
6
7
  import * as lilypondEncoder from "./lilypondEncoder.js";
@@ -1,4 +1,5 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
+ import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
2
3
  // MEI key signatures: positive = sharps, negative = flats
3
4
  const KEY_SIGS = {
4
5
  0: "0",
@@ -44,38 +45,55 @@ const CLEF_SHAPES = {
44
45
  F: { shape: "F", line: 4 },
45
46
  C: { shape: "C", line: 3 },
46
47
  };
47
- // Resolve a clef string into MEI shape/line plus optional octave displacement.
48
- // Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
49
- // the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
50
- // and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
51
- // and dis.place ("below" | "above").
48
+ // Semitone offsets of the major/perfect intervals within one diatonic octave,
49
+ // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
50
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
51
+ // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
52
+ // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
53
+ // 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
54
+ // raises it. Returns { diat, semi } where diat is the diatonic step shift
55
+ // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
56
+ // extended octave-wise for compound intervals.
57
+ const clefTransposition = (intervalNumber, up) => {
58
+ const k = intervalNumber - 1; // diatonic steps
59
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
60
+ const sign = up ? 1 : -1;
61
+ return { diat: sign * k, semi: sign * semis };
62
+ };
63
+ // Resolve a clef string into MEI shape/line plus optional written→sounding
64
+ // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
65
+ // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
66
+ // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
67
+ // displacement (8|15|22), so all clef transposition — octaves included — is
68
+ // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
52
69
  const resolveClef = (clefStr) => {
53
- const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
70
+ const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
54
71
  const base = match ? match[1] : clefStr;
55
72
  const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
56
73
  if (!match)
57
74
  return { shape: clefInfo.shape, line: clefInfo.line };
75
+ const trans = clefTransposition(Number(match[3]), match[2] === "^");
58
76
  return {
59
77
  shape: clefInfo.shape,
60
78
  line: clefInfo.line,
61
- dis: match[3],
62
- disPlace: match[2] === "^" ? "above" : "below",
79
+ trans,
63
80
  };
64
81
  };
65
82
  // Attributes for a standalone <clef> element (mid-measure clef change).
83
+ // A mid-measure <clef> cannot carry att.transposition (that is a staff-level
84
+ // property on <staffDef>); a mid-piece change of transposition would require a
85
+ // new <staffDef>, which is out of scope here. So only shape/line are emitted.
66
86
  const clefElementAttrs = (clefStr) => {
67
87
  const c = resolveClef(clefStr);
68
- let attrs = `shape="${c.shape}" line="${c.line}"`;
69
- if (c.dis)
70
- attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
71
- return attrs;
88
+ return `shape="${c.shape}" line="${c.line}"`;
72
89
  };
73
- // Attributes for a <staffDef> clef (clef.* namespace).
90
+ // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
91
+ // (trans.diat / trans.semi) when the clef declares a transposition.
74
92
  const staffDefClefAttrs = (clefStr) => {
75
93
  const c = resolveClef(clefStr);
76
94
  let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
77
- if (c.dis)
78
- attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
95
+ if (c.trans)
96
+ attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
79
97
  return attrs;
80
98
  };
81
99
  // Lilylet duration division to MEI dur
@@ -1622,33 +1640,120 @@ const analyzePartStructure = (doc) => {
1622
1640
  }
1623
1641
  return partInfos;
1624
1642
  };
1643
+ // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
1644
+ const LAYOUT_SYMBOL = [null, "brace", "bracket", "bracketsq"];
1645
+ // Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
1646
+ const instrumentLabelXML = (instr, indent) => {
1647
+ if (!instr)
1648
+ return "";
1649
+ let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
1650
+ if (instr.shortName !== undefined) {
1651
+ xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
1652
+ }
1653
+ return xml;
1654
+ };
1655
+ // Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
1656
+ // `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
1657
+ // string for the matching global staff's <staffDef>. `instruments` maps a layout group
1658
+ // key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
1659
+ // names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
1660
+ // bar.thru reflects the group's conjunction (Solid).
1661
+ const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments) => {
1662
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
1663
+ const instr = group.key !== undefined ? instruments[group.key] : undefined;
1664
+ // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
1665
+ if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
1666
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
1667
+ }
1668
+ const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
1669
+ const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
1670
+ let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
1671
+ // A multi-staff group's instrument name labels the whole group.
1672
+ if (!isLeaf)
1673
+ xml += instrumentLabelXML(instr, indent + " ");
1674
+ if (isLeaf) {
1675
+ // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
1676
+ // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
1677
+ // label goes on the staffDef inside.
1678
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
1679
+ }
1680
+ else {
1681
+ for (const sub of group.subs || []) {
1682
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
1683
+ }
1684
+ }
1685
+ xml += `${indent}</staffGrp>\n`;
1686
+ return xml;
1687
+ };
1688
+ // Emit a <staffDef> from its attribute string, with optional instrument <label> children.
1689
+ const staffDefWithLabel = (attrs, instr, indent) => {
1690
+ if (attrs === undefined)
1691
+ return "";
1692
+ if (!instr)
1693
+ return `${indent}<staffDef ${attrs} />\n`;
1694
+ let xml = `${indent}<staffDef ${attrs}>\n`;
1695
+ xml += instrumentLabelXML(instr, indent + " ");
1696
+ xml += `${indent}</staffDef>\n`;
1697
+ return xml;
1698
+ };
1625
1699
  // Encode scoreDef with part groups
1626
- const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1700
+ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
1627
1701
  const scoreDefId = generateId("scoredef");
1628
1702
  // Build meter attributes
1629
1703
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1630
1704
  let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
1631
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1632
- for (let pi = 0; pi < partInfos.length; pi++) {
1633
- const info = partInfos[pi];
1634
- // If part has multiple staves (grand staff), wrap in staffGrp with brace
1635
- if (info.maxStaff > 1) {
1636
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1637
- for (let ls = 1; ls <= info.maxStaff; ls++) {
1638
- const globalStaff = info.staffOffset + ls;
1639
- const clef = info.clefs[ls] || Clef.treble;
1640
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1641
- }
1642
- xml += `${indent} </staffGrp>\n`;
1705
+ // Flat ordered list of global staves (n + clef), in part/voice order.
1706
+ const flatStaves = [];
1707
+ for (const info of partInfos) {
1708
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1709
+ flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
1643
1710
  }
1644
- else {
1645
- // Single staff part
1646
- const globalStaff = info.staffOffset + 1;
1647
- const clef = info.clefs[1] || Clef.treble;
1648
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
1711
+ }
1712
+ // If a [staves] layout is present and its leaf count matches the staves, drive
1713
+ // the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
1714
+ // the per-part grand-staff grouping.
1715
+ let layoutUsed = false;
1716
+ if (stavesCode) {
1717
+ const layout = parseStaffLayout(stavesCode);
1718
+ if (layout.stavesCount === flatStaves.length) {
1719
+ const staffDefAttrs = flatStaves.map(s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`);
1720
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
1721
+ layoutUsed = true;
1722
+ }
1723
+ }
1724
+ if (!layoutUsed) {
1725
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1726
+ for (let pi = 0; pi < partInfos.length; pi++) {
1727
+ const info = partInfos[pi];
1728
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace.
1729
+ // Instrument key for a part follows staffLayout's groupKey: a single staff
1730
+ // number, or "first-last" for a grand staff.
1731
+ if (info.maxStaff > 1) {
1732
+ const first = info.staffOffset + 1;
1733
+ const last = info.staffOffset + info.maxStaff;
1734
+ const instr = instruments[`${first}-${last}`];
1735
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1736
+ xml += instrumentLabelXML(instr, `${indent} `);
1737
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1738
+ const globalStaff = info.staffOffset + ls;
1739
+ const clef = info.clefs[ls] || Clef.treble;
1740
+ const leafInstr = instruments[`${globalStaff}`];
1741
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1742
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
1743
+ }
1744
+ xml += `${indent} </staffGrp>\n`;
1745
+ }
1746
+ else {
1747
+ // Single staff part
1748
+ const globalStaff = info.staffOffset + 1;
1749
+ const clef = info.clefs[1] || Clef.treble;
1750
+ const leafInstr = instruments[`${globalStaff}`];
1751
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1752
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
1753
+ }
1649
1754
  }
1755
+ xml += `${indent} </staffGrp>\n`;
1650
1756
  }
1651
- xml += `${indent} </staffGrp>\n`;
1652
1757
  xml += `${indent}</scoreDef>\n`;
1653
1758
  return xml;
1654
1759
  };
@@ -1983,7 +2088,7 @@ const encode = (doc, options = {}) => {
1983
2088
  mei += `${indent}${indent}<body>\n`;
1984
2089
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1985
2090
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1986
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
2091
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
1987
2092
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1988
2093
  // Track tie state across measures for cross-measure ties
1989
2094
  const tieState = {};
@@ -575,6 +575,15 @@ const parseMetadata = (doc) => {
575
575
  }
576
576
  }
577
577
  }
578
+ // Staff layout: recover the raw [staves] string stashed at encode time.
579
+ const miscFields = getElements(identificationEl, 'miscellaneous-field');
580
+ for (const field of miscFields) {
581
+ if (getAttribute(field, 'name') === 'lilylet-staves') {
582
+ const code = field.textContent?.trim();
583
+ if (code)
584
+ metadata.staves = code;
585
+ }
586
+ }
578
587
  }
579
588
  return Object.keys(metadata).length > 0 ? metadata : {};
580
589
  };
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { StemDirection, Accidental, HairpinType, PedalType, } from "./types.js";
8
8
  import { DIVISIONS, DIVISION_TO_TYPE, calculateDuration, } from "./musicXmlUtils.js";
9
+ import { parseStaffLayout } from "./staffLayout.js";
9
10
  // === Constants and Reverse Mappings ===
10
11
  // Phonet to MusicXML step
11
12
  const PHONET_TO_STEP = {
@@ -87,6 +88,10 @@ const BARLINE_TO_XML = {
87
88
  ':|.': { barStyle: 'light-heavy', repeat: 'backward' },
88
89
  ':..:': { barStyle: 'light-heavy', repeat: 'backward' }, // Will need special handling
89
90
  };
91
+ // MusicXML <group-symbol> value by StaffGroupType (Default → none). MusicXML's
92
+ // allowed values are brace | bracket | line | square | none — note that, unlike
93
+ // MEI (which uses "bracketsq"), MusicXML's square variant IS spelled "square".
94
+ const GROUP_SYMBOLS_XML = [null, "brace", "bracket", "square"];
90
95
  // === XML Helper Functions ===
91
96
  const escapeXml = (text) => {
92
97
  return text
@@ -566,6 +571,7 @@ const encodeMeasure = (measure, partIndex, measureNumber, isFirst, prevKey, prev
566
571
  // Other context changes are handled in attributes
567
572
  break;
568
573
  }
574
+ case 'times':
569
575
  case 'tuplet': {
570
576
  const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest');
571
577
  for (let ti = 0; ti < tupletEvents.length; ti++) {
@@ -649,9 +655,101 @@ const encodeMetadata = (metadata, level) => {
649
655
  xml += `${indent(level + 2)}<software>Lilylet</software>\n`;
650
656
  xml += `${indent(level + 2)}<encoding-date>${new Date().toISOString().split('T')[0]}</encoding-date>\n`;
651
657
  xml += `${indent(level + 1)}</encoding>\n`;
658
+ // Preserve the raw staff-layout string for a lossless round-trip. MusicXML has
659
+ // no native carrier for it (its <part-group> only expresses grouping, not the
660
+ // conjunction/anonymous-id detail), so we stash the verbatim code here.
661
+ if (metadata.staves) {
662
+ xml += `${indent(level + 1)}<miscellaneous>\n`;
663
+ xml += `${indent(level + 2)}<miscellaneous-field name="lilylet-staves">${escapeXml(metadata.staves)}</miscellaneous-field>\n`;
664
+ xml += `${indent(level + 1)}</miscellaneous>\n`;
665
+ }
652
666
  xml += `${indent(level)}</identification>\n`;
653
667
  return xml;
654
668
  };
669
+ /**
670
+ * Build <part-group> start/stop brackets from a parsed staff-layout, keyed by the
671
+ * part index they wrap around.
672
+ *
673
+ * The layout is staff-leaf (one leaf per staff); MusicXML <part-group> brackets group
674
+ * *parts*. We map each part to the consecutive run of staff-leaves it owns (a grand-staff
675
+ * part owns `maxStaff` leaves), then translate every layout group whose leaf-span aligns
676
+ * with whole-part boundaries into a part-group. Groups that fall entirely inside one part
677
+ * (e.g. the brace over a single grand-staff part) are intrinsic to that part's <staves>
678
+ * and are skipped here. Returns { starts, stops } maps: partIndex → XML snippets.
679
+ */
680
+ const buildPartGroups = (stavesCode, staffCountPerPart, level) => {
681
+ const starts = new Map();
682
+ const stops = new Map();
683
+ const layout = parseStaffLayout(stavesCode);
684
+ const totalLeaves = staffCountPerPart.reduce((a, b) => a + b, 0);
685
+ if (layout.stavesCount !== totalLeaves) {
686
+ // Layout/parts mismatch — skip grouping rather than emit something wrong.
687
+ return { starts, stops };
688
+ }
689
+ // leaf index → part index, and the [firstLeaf, lastLeaf] each part spans.
690
+ const partFirstLeaf = [];
691
+ const partLastLeaf = [];
692
+ let leaf = 0;
693
+ for (let pi = 0; pi < staffCountPerPart.length; pi++) {
694
+ partFirstLeaf[pi] = leaf;
695
+ leaf += staffCountPerPart[pi];
696
+ partLastLeaf[pi] = leaf - 1;
697
+ }
698
+ const leafToPart = (leafIdx) => {
699
+ for (let pi = 0; pi < staffCountPerPart.length; pi++) {
700
+ if (leafIdx >= partFirstLeaf[pi] && leafIdx <= partLastLeaf[pi])
701
+ return pi;
702
+ }
703
+ return -1;
704
+ };
705
+ let groupNumber = 0;
706
+ const leafCounter = { i: 0 };
707
+ const walk = (group) => {
708
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
709
+ const symbol = GROUP_SYMBOLS_XML[group.type];
710
+ if (isLeaf) {
711
+ // A leaf may itself carry a bracket (e.g. <b>): a one-staff part-group.
712
+ const li = leafCounter.i++;
713
+ if (symbol) {
714
+ const pi = leafToPart(li);
715
+ if (pi >= 0 && partFirstLeaf[pi] === li && partLastLeaf[pi] === li) {
716
+ emit(pi, pi, symbol, group.bar);
717
+ }
718
+ }
719
+ return;
720
+ }
721
+ // Span the leaves covered by this group's subtree, then recurse.
722
+ const firstLeaf = leafCounter.i;
723
+ for (const sub of group.subs || [])
724
+ walk(sub);
725
+ const lastLeaf = leafCounter.i - 1;
726
+ if (symbol) {
727
+ const startPart = leafToPart(firstLeaf);
728
+ const endPart = leafToPart(lastLeaf);
729
+ // Only emit when the span aligns with whole-part boundaries AND wraps >1 part
730
+ // (a group inside a single part is the part's own grand staff, not a part-group).
731
+ if (startPart >= 0 && endPart >= 0 && startPart !== endPart &&
732
+ partFirstLeaf[startPart] === firstLeaf && partLastLeaf[endPart] === lastLeaf) {
733
+ emit(startPart, endPart, symbol, group.bar);
734
+ }
735
+ }
736
+ };
737
+ const emit = (startPart, endPart, symbol, bar) => {
738
+ const num = ++groupNumber;
739
+ const barLine = (bar ?? 0) > 1;
740
+ let s = `${indent(level)}<part-group type="start" number="${num}">\n`;
741
+ s += `${indent(level + 1)}<group-symbol>${symbol}</group-symbol>\n`;
742
+ s += `${indent(level + 1)}<group-barline>${barLine ? 'yes' : 'no'}</group-barline>\n`;
743
+ s += `${indent(level)}</part-group>\n`;
744
+ starts.set(startPart, (starts.get(startPart) || '') + s);
745
+ const e = `${indent(level)}<part-group type="stop" number="${num}"/>\n`;
746
+ // Stops are emitted after the end part; inner groups must close before outer ones,
747
+ // so prepend (deepest group, emitted last, closes first).
748
+ stops.set(endPart, e + (stops.get(endPart) || ''));
749
+ };
750
+ walk(layout.group);
751
+ return { starts, stops };
752
+ };
655
753
  /**
656
754
  * Encode complete LilyletDoc to MusicXML
657
755
  */
@@ -665,15 +763,31 @@ export const encode = (doc) => {
665
763
  }
666
764
  // Determine number of parts from first measure
667
765
  const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
766
+ // Staff-layout → <part-group> brackets/braces (if a [staves] layout is present and
767
+ // its staff count matches the parts' total staves).
768
+ let partGroups = {
769
+ starts: new Map(), stops: new Map(),
770
+ };
771
+ if (doc.metadata?.staves && doc.measures.length > 0) {
772
+ const staffCountPerPart = doc.measures[0].parts.map(part => {
773
+ let maxStaff = 1;
774
+ for (const voice of part.voices)
775
+ maxStaff = Math.max(maxStaff, voice.staff || 1);
776
+ return maxStaff;
777
+ });
778
+ partGroups = buildPartGroups(doc.metadata.staves, staffCountPerPart, 2);
779
+ }
668
780
  // Part list
669
781
  xml += `${indent(1)}<part-list>\n`;
670
782
  for (let pi = 0; pi < numParts; pi++) {
671
783
  const partId = `P${pi + 1}`;
672
784
  const partName = doc.measures[0]?.parts[pi]?.name
673
785
  || (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
786
+ xml += partGroups.starts.get(pi) || '';
674
787
  xml += `${indent(2)}<score-part id="${partId}">\n`;
675
788
  xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
676
789
  xml += `${indent(2)}</score-part>\n`;
790
+ xml += partGroups.stops.get(pi) || '';
677
791
  }
678
792
  xml += `${indent(1)}</part-list>\n`;
679
793
  // Encode each part
@@ -199,11 +199,12 @@ const serializeMarks = (marks) => {
199
199
  return parts.join('');
200
200
  };
201
201
  // Serialize a note event with pitch environment tracking
202
- const serializeNoteEvent = (event, env, prevDuration) => {
202
+ const serializeNoteEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
203
203
  const parts = [];
204
204
  let currentEnv = env;
205
- // Grace note prefix
206
- if (event.grace) {
205
+ // Grace note prefix. When the caller groups consecutive grace notes into a single
206
+ // scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
207
+ if (event.grace && !suppressGracePrefix) {
207
208
  parts.push('\\grace ');
208
209
  }
209
210
  // Single note or chord
@@ -440,10 +441,10 @@ const serializeBarlineEvent = (event) => {
440
441
  return '';
441
442
  };
442
443
  // Serialize a single event with pitch environment tracking
443
- const serializeEvent = (event, env, prevDuration) => {
444
+ const serializeEvent = (event, env, prevDuration, suppressGracePrefix = false) => {
444
445
  switch (event.type) {
445
446
  case 'note':
446
- return serializeNoteEvent(event, env, prevDuration);
447
+ return serializeNoteEvent(event, env, prevDuration, suppressGracePrefix);
447
448
  case 'rest':
448
449
  return serializeRestEvent(event, env, prevDuration);
449
450
  case 'context':
@@ -576,6 +577,7 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
576
577
  }
577
578
  let activeStaff = effectiveInitialStaff;
578
579
  let activeStemDir;
580
+ let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
579
581
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
580
582
  const event = voice.events[eventIdx];
581
583
  // Skip leading context-staff events already absorbed into effectiveInitialStaff
@@ -639,7 +641,18 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
639
641
  activeStemDir = stemDir;
640
642
  }
641
643
  }
642
- const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
644
+ const isGraceNote = event.type === 'note' && !!event.grace;
645
+ // Group consecutive grace notes into one scoped \grace { ... } instead of
646
+ // emitting a separate \grace prefix per note.
647
+ if (isGraceNote && !graceGroupOpen) {
648
+ parts.push('\\grace {');
649
+ graceGroupOpen = true;
650
+ }
651
+ else if (!isGraceNote && graceGroupOpen) {
652
+ parts.push('}');
653
+ graceGroupOpen = false;
654
+ }
655
+ const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
643
656
  pitchEnv = newEnv;
644
657
  if (eventStr) {
645
658
  parts.push(eventStr);
@@ -663,6 +676,10 @@ const serializeVoice = (voice, currentStaff, isGrandStaff = false, measureContex
663
676
  emittedClefs[ctx.staff || activeStaff] = ctx.clef;
664
677
  }
665
678
  }
679
+ // Close a grace group left open at the end of the voice (unusual but possible).
680
+ if (graceGroupOpen) {
681
+ parts.push('}');
682
+ }
666
683
  return { str: parts.join(' '), newStaff: voice.staff };
667
684
  };
668
685
  // Serialize a part, tracking staff state across voices
@@ -756,6 +773,19 @@ const serializeMetadata = (metadata) => {
756
773
  if (metadata.instrument) {
757
774
  lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
758
775
  }
776
+ if (metadata.staves) {
777
+ lines.push('[staves "' + escapeString(metadata.staves) + '"]');
778
+ }
779
+ if (metadata.instruments) {
780
+ for (const [key, instr] of Object.entries(metadata.instruments)) {
781
+ let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
782
+ if (instr.shortName !== undefined) {
783
+ line += ' "' + escapeString(instr.shortName) + '"';
784
+ }
785
+ line += ']';
786
+ lines.push(line);
787
+ }
788
+ }
759
789
  if (metadata.autoBeam) {
760
790
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
761
791
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Staff-layout parser, ported from FindLab starry (app/staffLayout/).
3
+ *
4
+ * The layout string uses STAFF as the leaf unit (distinct from ABC's %%score,
5
+ * which is voice-leaf). Brackets group staves: `{}` = Brace (grand staff),
6
+ * `<>` = Bracket, `[]` = Square; conjunctions between consecutive staves:
7
+ * `,` = Blank, `-` = Solid, `.` = Dashed. Staff ids are [a-zA-Z_0-9]+; a slot
8
+ * with no id is an anonymous staff (auto-named "1","2",…).
9
+ *
10
+ * Example: "<[v1-v2].va> {pl-pr} <b>" → 6 staves v1,v2,va,pl,pr,b grouped as
11
+ * a Bracket over { a Square [v1-v2] dashed-joined to va }, a Brace {pl-pr},
12
+ * and a Bracket <b>.
13
+ */
14
+ export declare enum StaffGroupType {
15
+ Default = 0,
16
+ Brace = 1,// {}
17
+ Bracket = 2,// <>
18
+ Square = 3
19
+ }
20
+ export declare enum StaffConjunctionType {
21
+ Blank = 0,
22
+ Dashed = 1,
23
+ Solid = 2
24
+ }
25
+ export interface RawItem {
26
+ id: string | null;
27
+ leftBounds: string[];
28
+ rightBounds: string[];
29
+ conjunction: string | null;
30
+ }
31
+ export interface StaffGroup {
32
+ type: StaffGroupType;
33
+ subs?: StaffGroup[];
34
+ staff?: string;
35
+ level?: number;
36
+ grand?: boolean;
37
+ key?: string;
38
+ bar?: number;
39
+ }
40
+ export interface StaffGroupTrait {
41
+ group: StaffGroup;
42
+ range: [number, number];
43
+ key: string;
44
+ }
45
+ export declare const groupKey: (group: StaffGroup) => string | undefined;
46
+ export declare class StaffLayout {
47
+ staffIds: string[];
48
+ conjunctions: StaffConjunctionType[];
49
+ group: StaffGroup;
50
+ groups: StaffGroupTrait[];
51
+ constructor(raw: RawItem[]);
52
+ get stavesCount(): number;
53
+ }
54
+ export declare const parseStaffLayout: (code: string) => StaffLayout;
55
+ export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
56
+ [key: string]: string;
57
+ }, indent?: number, tab?: string) => string;