@k-l-lambda/lilylet 0.1.69 → 0.1.71

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,6 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
+ import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
+ import { gmProgramOf } from "./gmInstruments.js";
2
4
  // MEI key signatures: positive = sharps, negative = flats
3
5
  const KEY_SIGS = {
4
6
  0: "0",
@@ -44,38 +46,65 @@ const CLEF_SHAPES = {
44
46
  F: { shape: "F", line: 4 },
45
47
  C: { shape: "C", line: 3 },
46
48
  };
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").
49
+ // Semitone offsets of the major/perfect intervals within one diatonic octave,
50
+ // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
51
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
52
+ // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
53
+ // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
54
+ // 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
55
+ // raises it. Returns { diat, semi } where diat is the diatonic step shift
56
+ // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
57
+ // extended octave-wise for compound intervals.
58
+ const clefTransposition = (intervalNumber, up) => {
59
+ const k = intervalNumber - 1; // diatonic steps
60
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
61
+ const sign = up ? 1 : -1;
62
+ return { diat: sign * k, semi: sign * semis };
63
+ };
64
+ // Resolve a clef string into MEI shape/line plus optional written→sounding
65
+ // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
66
+ // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
67
+ // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
68
+ // displacement (8|15|22), so all clef transposition — octaves included — is
69
+ // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
52
70
  const resolveClef = (clefStr) => {
53
- const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
71
+ const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
54
72
  const base = match ? match[1] : clefStr;
55
73
  const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
56
74
  if (!match)
57
75
  return { shape: clefInfo.shape, line: clefInfo.line };
76
+ const trans = clefTransposition(Number(match[3]), match[2] === "^");
58
77
  return {
59
78
  shape: clefInfo.shape,
60
79
  line: clefInfo.line,
61
- dis: match[3],
62
- disPlace: match[2] === "^" ? "above" : "below",
80
+ trans,
63
81
  };
64
82
  };
65
83
  // Attributes for a standalone <clef> element (mid-measure clef change).
84
+ // A mid-measure <clef> cannot carry att.transposition (that is a staff-level
85
+ // property Verovio only reads from <staffDef>); the visible clef shape/line is
86
+ // emitted here, and a sounding-pitch transposition change is mirrored by a
87
+ // between-measure <scoreDef>/<staffDef trans.*> emitted in the measure loop
88
+ // (see emitClefTranspositionScoreDef). So only shape/line are emitted here.
66
89
  const clefElementAttrs = (clefStr) => {
67
90
  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;
91
+ return `shape="${c.shape}" line="${c.line}"`;
92
+ };
93
+ // The written→sounding transposition a clef declares, as {diat, semi}; {0,0}
94
+ // when the clef declares none. Used to detect mid-piece transposition changes
95
+ // and to emit the resetting <staffDef> when a transposing clef is replaced by a
96
+ // plain one (Verovio retains a prior trans.* until an explicit 0/0 overrides it).
97
+ const clefTransOf = (clefStr) => {
98
+ const c = resolveClef(clefStr);
99
+ return c.trans || { diat: 0, semi: 0 };
72
100
  };
73
- // Attributes for a <staffDef> clef (clef.* namespace).
101
+ // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
102
+ // (trans.diat / trans.semi) when the clef declares a transposition.
74
103
  const staffDefClefAttrs = (clefStr) => {
75
104
  const c = resolveClef(clefStr);
76
105
  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}"`;
106
+ if (c.trans)
107
+ attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
79
108
  return attrs;
80
109
  };
81
110
  // Lilylet duration division to MEI dur
@@ -1368,6 +1397,36 @@ const generateTempoElement = (tempo, indent, staff = 1) => {
1368
1397
  }
1369
1398
  return `${indent}<tempo ${attrs} />\n`;
1370
1399
  };
1400
+ // The clef governing a measure's sounding pitch on one global staff: the clef
1401
+ // carried in from the previous measure, overridden by a leading clef change that
1402
+ // appears before the first note/rest in the measure (the normal boundary-change
1403
+ // case). A clef change occurring *after* notes does not retune this measure — it
1404
+ // becomes the carried clef for the next measure via clefState. This per-measure
1405
+ // granularity matches what Verovio honors for MIDI: a transposition change takes
1406
+ // effect only via a between-measure <scoreDef>/<staffDef trans.*>, never from a
1407
+ // mid-measure <clef> element.
1408
+ const measureStartClef = (measure, globalStaff, partInfos, carriedClef) => {
1409
+ let active = carriedClef;
1410
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1411
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1412
+ for (const voice of measure.parts[pi].voices) {
1413
+ if ((partOffset + (voice.staff || 1)) !== globalStaff)
1414
+ continue;
1415
+ for (const event of voice.events) {
1416
+ if (event.type === 'note' || event.type === 'rest' ||
1417
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
1418
+ break; // notes have started; a later clef change governs the next measure
1419
+ }
1420
+ if (event.type === 'context') {
1421
+ const ctx = event;
1422
+ if (ctx.clef)
1423
+ active = ctx.clef;
1424
+ }
1425
+ }
1426
+ }
1427
+ }
1428
+ return active;
1429
+ };
1371
1430
  // Barline style to MEI @right attribute mapping
1372
1431
  const BARLINE_TO_MEI = {
1373
1432
  '|': 'single',
@@ -1622,33 +1681,149 @@ const analyzePartStructure = (doc) => {
1622
1681
  }
1623
1682
  return partInfos;
1624
1683
  };
1684
+ // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
1685
+ const LAYOUT_SYMBOL = [null, "brace", "bracket", "bracketsq"];
1686
+ const newChannelAllocator = () => ({ byProgram: new Map(), nextChannel: 0 });
1687
+ // Channel for a program: reuse the channel already assigned to that program,
1688
+ // else take the next free channel (skipping 9). Wraps past 16 as graceful
1689
+ // degradation for scores with more than 15 distinct timbres.
1690
+ const allocChannel = (alloc, program) => {
1691
+ const existing = alloc.byProgram.get(program);
1692
+ if (existing !== undefined)
1693
+ return existing;
1694
+ let ch = alloc.nextChannel;
1695
+ if (ch % 16 === 9)
1696
+ ch++; // skip GM drum channel
1697
+ const assigned = ch % 16;
1698
+ alloc.byProgram.set(program, assigned);
1699
+ alloc.nextChannel = ch + 1;
1700
+ return assigned;
1701
+ };
1702
+ // Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
1703
+ // When the instrument name resolves to a General MIDI program, also emit an
1704
+ // <instrDef midi.instrnum midi.channel> sibling so Verovio's MIDI export assigns
1705
+ // that timbre (it honors only the numeric @midi.instrnum) on its own channel
1706
+ // (@midi.channel, else all instruments collide on channel 0). Unknown names emit
1707
+ // just the label, leaving Verovio's default program (0 = piano).
1708
+ const instrumentLabelXML = (instr, indent, channels) => {
1709
+ if (!instr)
1710
+ return "";
1711
+ let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
1712
+ if (instr.shortName !== undefined) {
1713
+ xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
1714
+ }
1715
+ const program = gmProgramOf(instr.name);
1716
+ if (program !== undefined) {
1717
+ const chanAttr = channels ? ` midi.channel="${allocChannel(channels, program)}"` : "";
1718
+ xml += `${indent}<instrDef xml:id="${generateId("instrdef")}" midi.instrnum="${program}"${chanAttr} />\n`;
1719
+ }
1720
+ return xml;
1721
+ };
1722
+ // Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
1723
+ // `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
1724
+ // string for the matching global staff's <staffDef>. `instruments` maps a layout group
1725
+ // key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
1726
+ // names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
1727
+ // bar.thru reflects the group's conjunction (Solid).
1728
+ const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments, channels) => {
1729
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
1730
+ const instr = group.key !== undefined ? instruments[group.key] : undefined;
1731
+ // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
1732
+ if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
1733
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
1734
+ }
1735
+ const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
1736
+ const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
1737
+ let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
1738
+ // A multi-staff group's instrument name labels the whole group.
1739
+ if (!isLeaf)
1740
+ xml += instrumentLabelXML(instr, indent + " ", channels);
1741
+ if (isLeaf) {
1742
+ // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
1743
+ // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
1744
+ // label goes on the staffDef inside.
1745
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
1746
+ }
1747
+ else {
1748
+ for (const sub of group.subs || []) {
1749
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
1750
+ }
1751
+ }
1752
+ xml += `${indent}</staffGrp>\n`;
1753
+ return xml;
1754
+ };
1755
+ // Emit a <staffDef> from its attribute string, with optional instrument <label> children.
1756
+ const staffDefWithLabel = (attrs, instr, indent, channels) => {
1757
+ if (attrs === undefined)
1758
+ return "";
1759
+ if (!instr)
1760
+ return `${indent}<staffDef ${attrs} />\n`;
1761
+ let xml = `${indent}<staffDef ${attrs}>\n`;
1762
+ xml += instrumentLabelXML(instr, indent + " ", channels);
1763
+ xml += `${indent}</staffDef>\n`;
1764
+ return xml;
1765
+ };
1625
1766
  // Encode scoreDef with part groups
1626
- const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
1767
+ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
1627
1768
  const scoreDefId = generateId("scoredef");
1769
+ // One MIDI channel allocator per score: assigns a distinct channel per GM
1770
+ // program so instruments don't collide on channel 0 (see allocChannel).
1771
+ const channels = newChannelAllocator();
1628
1772
  // Build meter attributes
1629
1773
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1630
1774
  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`;
1775
+ // Flat ordered list of global staves (n + clef), in part/voice order.
1776
+ const flatStaves = [];
1777
+ for (const info of partInfos) {
1778
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1779
+ flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
1643
1780
  }
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`;
1781
+ }
1782
+ // If a [staves] layout is present and its leaf count matches the staves, drive
1783
+ // the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
1784
+ // the per-part grand-staff grouping.
1785
+ let layoutUsed = false;
1786
+ if (stavesCode) {
1787
+ const layout = parseStaffLayout(stavesCode);
1788
+ if (layout.stavesCount === flatStaves.length) {
1789
+ const staffDefAttrs = flatStaves.map(s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`);
1790
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
1791
+ layoutUsed = true;
1649
1792
  }
1650
1793
  }
1651
- xml += `${indent} </staffGrp>\n`;
1794
+ if (!layoutUsed) {
1795
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
1796
+ for (let pi = 0; pi < partInfos.length; pi++) {
1797
+ const info = partInfos[pi];
1798
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace.
1799
+ // Instrument key for a part follows staffLayout's groupKey: a single staff
1800
+ // number, or "first-last" for a grand staff.
1801
+ if (info.maxStaff > 1) {
1802
+ const first = info.staffOffset + 1;
1803
+ const last = info.staffOffset + info.maxStaff;
1804
+ const instr = instruments[`${first}-${last}`];
1805
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1806
+ xml += instrumentLabelXML(instr, `${indent} `, channels);
1807
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
1808
+ const globalStaff = info.staffOffset + ls;
1809
+ const clef = info.clefs[ls] || Clef.treble;
1810
+ const leafInstr = instruments[`${globalStaff}`];
1811
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1812
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
1813
+ }
1814
+ xml += `${indent} </staffGrp>\n`;
1815
+ }
1816
+ else {
1817
+ // Single staff part
1818
+ const globalStaff = info.staffOffset + 1;
1819
+ const clef = info.clefs[1] || Clef.treble;
1820
+ const leafInstr = instruments[`${globalStaff}`];
1821
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1822
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
1823
+ }
1824
+ }
1825
+ xml += `${indent} </staffGrp>\n`;
1826
+ }
1652
1827
  xml += `${indent}</scoreDef>\n`;
1653
1828
  return xml;
1654
1829
  };
@@ -1983,7 +2158,7 @@ const encode = (doc, options = {}) => {
1983
2158
  mei += `${indent}${indent}<body>\n`;
1984
2159
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
1985
2160
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
1986
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
2161
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
1987
2162
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
1988
2163
  // Track tie state across measures for cross-measure ties
1989
2164
  const tieState = {};
@@ -1996,11 +2171,18 @@ const encode = (doc, options = {}) => {
1996
2171
  const octaveEndReplacements = {};
1997
2172
  // Initialize clef state from partInfos (convert local staff to global staff)
1998
2173
  const clefState = {};
2174
+ // Running written→sounding transposition per global staff, as the verbatim
2175
+ // "diat,semi" key. Seeded from the initial clefs (which the leading <scoreDef>
2176
+ // already encoded via staffDefClefAttrs), then advanced whenever a measure
2177
+ // starts under a clef with a different transposition.
2178
+ const transState = {};
1999
2179
  for (let pi = 0; pi < partInfos.length; pi++) {
2000
2180
  const partInfo = partInfos[pi];
2001
2181
  for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
2002
2182
  const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
2003
2183
  clefState[globalStaff] = clef;
2184
+ const t = clefTransOf(clef);
2185
+ transState[globalStaff] = `${t.diat},${t.semi}`;
2004
2186
  }
2005
2187
  }
2006
2188
  // Helper to check if a measure has any musical content
@@ -2049,6 +2231,41 @@ const encode = (doc, options = {}) => {
2049
2231
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2050
2232
  }
2051
2233
  }
2234
+ // Check for a written→sounding transposition change at this measure
2235
+ // boundary and mirror it into MIDI. Verovio applies att.transposition only
2236
+ // from <staffDef>, never from a mid-measure <clef>, so a change of
2237
+ // transposing clef must be re-declared via a between-measure <scoreDef>.
2238
+ // We emit only the staves whose transposition actually changed (a partial
2239
+ // <staffGrp> leaves the others' state intact), using explicit "0,0" to
2240
+ // clear a prior transposition when a transposing clef is replaced by a
2241
+ // plain one. mi === 0 is already covered by the leading <scoreDef>.
2242
+ if (mi > 0) {
2243
+ const changed = [];
2244
+ for (let si = 1; si <= totalStaves; si++) {
2245
+ const startClef = measureStartClef(measure, si, partInfos, clefState[si]);
2246
+ if (startClef === undefined)
2247
+ continue;
2248
+ const t = clefTransOf(startClef);
2249
+ const key = `${t.diat},${t.semi}`;
2250
+ if (transState[si] === undefined) {
2251
+ transState[si] = key;
2252
+ continue;
2253
+ }
2254
+ if (key !== transState[si]) {
2255
+ transState[si] = key;
2256
+ const c = resolveClef(startClef);
2257
+ changed.push(`${indent}${indent}${indent}${indent}${indent}${indent}${indent}${indent}<staffDef xml:id="${generateId('staffdef')}" n="${si}" lines="5" clef.shape="${c.shape}" clef.line="${c.line}" trans.diat="${t.diat}" trans.semi="${t.semi}" />`);
2258
+ }
2259
+ }
2260
+ if (changed.length > 0) {
2261
+ const sd = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2262
+ mei += `${sd}<scoreDef xml:id="${generateId('scoredef')}">\n`;
2263
+ mei += `${sd}${indent}<staffGrp xml:id="${generateId('staffgrp')}">\n`;
2264
+ mei += changed.join("\n") + "\n";
2265
+ mei += `${sd}${indent}</staffGrp>\n`;
2266
+ mei += `${sd}</scoreDef>\n`;
2267
+ }
2268
+ }
2052
2269
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2053
2270
  });
2054
2271
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -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
@@ -773,6 +773,19 @@ const serializeMetadata = (metadata) => {
773
773
  if (metadata.instrument) {
774
774
  lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
775
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
+ }
776
789
  if (metadata.autoBeam) {
777
790
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
778
791
  }
@@ -0,0 +1,62 @@
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 interface SerializeStaffLayoutOptions {
56
+ anonymous?: boolean;
57
+ idMap?: (originalId: string) => string;
58
+ }
59
+ export declare const serializeStaffLayout: (layout: StaffLayout, options?: SerializeStaffLayoutOptions) => string;
60
+ export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
61
+ [key: string]: string;
62
+ }, indent?: number, tab?: string) => string;