@k-l-lambda/lilylet 0.1.70 → 0.1.72

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.
Files changed (116) hide show
  1. package/lib/gmInstruments.d.ts +1 -0
  2. package/lib/gmInstruments.js +1 -0
  3. package/lib/highlight.d.ts +1 -0
  4. package/lib/highlight.js +1 -0
  5. package/lib/lilylet/abcDecoder.js +16 -7
  6. package/lib/lilylet/gmInstruments.d.ts +1 -0
  7. package/lib/lilylet/gmInstruments.js +295 -0
  8. package/lib/lilylet/highlight.d.ts +29 -0
  9. package/lib/lilylet/highlight.js +145 -0
  10. package/lib/lilylet/meiEncoder.js +126 -14
  11. package/lib/lilylet/staffLayout.d.ts +5 -0
  12. package/lib/lilylet/staffLayout.js +62 -0
  13. package/package.json +8 -2
  14. package/source/lilylet/abcDecoder.ts +14 -7
  15. package/source/lilylet/gmInstruments.ts +305 -0
  16. package/source/lilylet/highlight.ts +192 -0
  17. package/source/lilylet/meiEncoder.ts +135 -11
  18. package/source/lilylet/staffLayout.ts +76 -0
  19. package/lib/source/abc/abc.d.ts +0 -102
  20. package/lib/source/abc/abc.js +0 -25
  21. package/lib/source/abc/parser.d.ts +0 -3
  22. package/lib/source/abc/parser.js +0 -6
  23. package/lib/source/lilylet/abcDecoder.d.ts +0 -25
  24. package/lib/source/lilylet/abcDecoder.js +0 -1035
  25. package/lib/source/lilylet/index.d.ts +0 -10
  26. package/lib/source/lilylet/index.js +0 -10
  27. package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
  28. package/lib/source/lilylet/lilypondDecoder.js +0 -1223
  29. package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
  30. package/lib/source/lilylet/lilypondEncoder.js +0 -893
  31. package/lib/source/lilylet/meiEncoder.d.ts +0 -8
  32. package/lib/source/lilylet/meiEncoder.js +0 -1985
  33. package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
  34. package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
  35. package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
  36. package/lib/source/lilylet/musicXmlEncoder.js +0 -701
  37. package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
  38. package/lib/source/lilylet/musicXmlTypes.js +0 -7
  39. package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
  40. package/lib/source/lilylet/musicXmlUtils.js +0 -469
  41. package/lib/source/lilylet/parser.d.ts +0 -14
  42. package/lib/source/lilylet/parser.js +0 -161
  43. package/lib/source/lilylet/serializer.d.ts +0 -11
  44. package/lib/source/lilylet/serializer.js +0 -791
  45. package/lib/source/lilylet/types.d.ts +0 -253
  46. package/lib/source/lilylet/types.js +0 -100
  47. package/lib/tests/abc-abcjs-parse.d.ts +0 -8
  48. package/lib/tests/abc-abcjs-parse.js +0 -90
  49. package/lib/tests/abc-abcjs-svg.d.ts +0 -1
  50. package/lib/tests/abc-abcjs-svg.js +0 -143
  51. package/lib/tests/abc-decoder.d.ts +0 -1
  52. package/lib/tests/abc-decoder.js +0 -67
  53. package/lib/tests/abc-mei-compare.d.ts +0 -1
  54. package/lib/tests/abc-mei-compare.js +0 -525
  55. package/lib/tests/auto-beam.d.ts +0 -9
  56. package/lib/tests/auto-beam.js +0 -151
  57. package/lib/tests/computeMeiHashes.d.ts +0 -1
  58. package/lib/tests/computeMeiHashes.js +0 -87
  59. package/lib/tests/encoder-mutation.d.ts +0 -9
  60. package/lib/tests/encoder-mutation.js +0 -110
  61. package/lib/tests/gpt-review-issues.d.ts +0 -5
  62. package/lib/tests/gpt-review-issues.js +0 -255
  63. package/lib/tests/json-to-lyl.d.ts +0 -1
  64. package/lib/tests/json-to-lyl.js +0 -18
  65. package/lib/tests/lilypond-roundtrip.d.ts +0 -7
  66. package/lib/tests/lilypond-roundtrip.js +0 -558
  67. package/lib/tests/lilypondDecoder.d.ts +0 -6
  68. package/lib/tests/lilypondDecoder.js +0 -95
  69. package/lib/tests/ly-to-lyl.d.ts +0 -1
  70. package/lib/tests/ly-to-lyl.js +0 -12
  71. package/lib/tests/mei.d.ts +0 -1
  72. package/lib/tests/mei.js +0 -278
  73. package/lib/tests/musicxml-decoder.d.ts +0 -4
  74. package/lib/tests/musicxml-decoder.js +0 -61
  75. package/lib/tests/musicxml-detail.d.ts +0 -4
  76. package/lib/tests/musicxml-detail.js +0 -85
  77. package/lib/tests/musicxml-fprod.d.ts +0 -9
  78. package/lib/tests/musicxml-fprod.js +0 -153
  79. package/lib/tests/musicxml-roundtrip.d.ts +0 -7
  80. package/lib/tests/musicxml-roundtrip.js +0 -296
  81. package/lib/tests/musicxml-to-mei.d.ts +0 -6
  82. package/lib/tests/musicxml-to-mei.js +0 -115
  83. package/lib/tests/parser.d.ts +0 -1
  84. package/lib/tests/parser.js +0 -17
  85. package/lib/tests/render-k283.d.ts +0 -1
  86. package/lib/tests/render-k283.js +0 -33
  87. package/lib/tests/render-lyl.d.ts +0 -1
  88. package/lib/tests/render-lyl.js +0 -35
  89. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
  90. package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
  91. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
  92. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
  93. package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
  94. package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
  95. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
  96. package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
  97. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
  98. package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
  99. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
  100. package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
  101. package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
  102. package/lib/tests/unit/gptReviewIssues.test.js +0 -240
  103. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
  104. package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
  105. package/lib/tests/unit/partialWarning.test.d.ts +0 -4
  106. package/lib/tests/unit/partialWarning.test.js +0 -65
  107. package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
  108. package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
  109. package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
  110. package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
  111. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
  112. package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
  113. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
  114. package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
  115. package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
  116. package/lib/tests/unit/voiceStaffParsing.test.js +0 -118
@@ -1,5 +1,6 @@
1
1
  import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
2
2
  import { parseStaffLayout, StaffGroupType } from "./staffLayout.js";
3
+ import { gmProgramOf } from "./gmInstruments.js";
3
4
  // MEI key signatures: positive = sharps, negative = flats
4
5
  const KEY_SIGS = {
5
6
  0: "0",
@@ -81,12 +82,22 @@ const resolveClef = (clefStr) => {
81
82
  };
82
83
  // Attributes for a standalone <clef> element (mid-measure clef change).
83
84
  // 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.
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.
86
89
  const clefElementAttrs = (clefStr) => {
87
90
  const c = resolveClef(clefStr);
88
91
  return `shape="${c.shape}" line="${c.line}"`;
89
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 };
100
+ };
90
101
  // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
91
102
  // (trans.diat / trans.semi) when the clef declares a transposition.
92
103
  const staffDefClefAttrs = (clefStr) => {
@@ -1386,6 +1397,36 @@ const generateTempoElement = (tempo, indent, staff = 1) => {
1386
1397
  }
1387
1398
  return `${indent}<tempo ${attrs} />\n`;
1388
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
+ };
1389
1430
  // Barline style to MEI @right attribute mapping
1390
1431
  const BARLINE_TO_MEI = {
1391
1432
  '|': 'single',
@@ -1642,14 +1683,40 @@ const analyzePartStructure = (doc) => {
1642
1683
  };
1643
1684
  // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
1644
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
+ };
1645
1702
  // Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
1646
- const instrumentLabelXML = (instr, indent) => {
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) => {
1647
1709
  if (!instr)
1648
1710
  return "";
1649
1711
  let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
1650
1712
  if (instr.shortName !== undefined) {
1651
1713
  xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
1652
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
+ }
1653
1720
  return xml;
1654
1721
  };
1655
1722
  // Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
@@ -1658,47 +1725,50 @@ const instrumentLabelXML = (instr, indent) => {
1658
1725
  // key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
1659
1726
  // names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
1660
1727
  // bar.thru reflects the group's conjunction (Solid).
1661
- const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments) => {
1728
+ const layoutGroupToMEI = (group, staffDefAttrs, leafCounter, indent, instruments, channels) => {
1662
1729
  const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
1663
1730
  const instr = group.key !== undefined ? instruments[group.key] : undefined;
1664
1731
  // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
1665
1732
  if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
1666
- return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
1733
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
1667
1734
  }
1668
1735
  const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
1669
1736
  const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
1670
1737
  let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
1671
1738
  // A multi-staff group's instrument name labels the whole group.
1672
1739
  if (!isLeaf)
1673
- xml += instrumentLabelXML(instr, indent + " ");
1740
+ xml += instrumentLabelXML(instr, indent + " ", channels);
1674
1741
  if (isLeaf) {
1675
1742
  // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
1676
1743
  // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
1677
1744
  // label goes on the staffDef inside.
1678
- xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
1745
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
1679
1746
  }
1680
1747
  else {
1681
1748
  for (const sub of group.subs || []) {
1682
- xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
1749
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
1683
1750
  }
1684
1751
  }
1685
1752
  xml += `${indent}</staffGrp>\n`;
1686
1753
  return xml;
1687
1754
  };
1688
1755
  // Emit a <staffDef> from its attribute string, with optional instrument <label> children.
1689
- const staffDefWithLabel = (attrs, instr, indent) => {
1756
+ const staffDefWithLabel = (attrs, instr, indent, channels) => {
1690
1757
  if (attrs === undefined)
1691
1758
  return "";
1692
1759
  if (!instr)
1693
1760
  return `${indent}<staffDef ${attrs} />\n`;
1694
1761
  let xml = `${indent}<staffDef ${attrs}>\n`;
1695
- xml += instrumentLabelXML(instr, indent + " ");
1762
+ xml += instrumentLabelXML(instr, indent + " ", channels);
1696
1763
  xml += `${indent}</staffDef>\n`;
1697
1764
  return xml;
1698
1765
  };
1699
1766
  // Encode scoreDef with part groups
1700
1767
  const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol, stavesCode, instruments = {}) => {
1701
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();
1702
1772
  // Build meter attributes
1703
1773
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
1704
1774
  let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
@@ -1717,7 +1787,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1717
1787
  const layout = parseStaffLayout(stavesCode);
1718
1788
  if (layout.stavesCount === flatStaves.length) {
1719
1789
  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);
1790
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
1721
1791
  layoutUsed = true;
1722
1792
  }
1723
1793
  }
@@ -1733,13 +1803,13 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1733
1803
  const last = info.staffOffset + info.maxStaff;
1734
1804
  const instr = instruments[`${first}-${last}`];
1735
1805
  xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
1736
- xml += instrumentLabelXML(instr, `${indent} `);
1806
+ xml += instrumentLabelXML(instr, `${indent} `, channels);
1737
1807
  for (let ls = 1; ls <= info.maxStaff; ls++) {
1738
1808
  const globalStaff = info.staffOffset + ls;
1739
1809
  const clef = info.clefs[ls] || Clef.treble;
1740
1810
  const leafInstr = instruments[`${globalStaff}`];
1741
1811
  const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1742
- xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
1812
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
1743
1813
  }
1744
1814
  xml += `${indent} </staffGrp>\n`;
1745
1815
  }
@@ -1749,7 +1819,7 @@ const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol
1749
1819
  const clef = info.clefs[1] || Clef.treble;
1750
1820
  const leafInstr = instruments[`${globalStaff}`];
1751
1821
  const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
1752
- xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
1822
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
1753
1823
  }
1754
1824
  }
1755
1825
  xml += `${indent} </staffGrp>\n`;
@@ -2101,11 +2171,18 @@ const encode = (doc, options = {}) => {
2101
2171
  const octaveEndReplacements = {};
2102
2172
  // Initialize clef state from partInfos (convert local staff to global staff)
2103
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 = {};
2104
2179
  for (let pi = 0; pi < partInfos.length; pi++) {
2105
2180
  const partInfo = partInfos[pi];
2106
2181
  for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
2107
2182
  const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
2108
2183
  clefState[globalStaff] = clef;
2184
+ const t = clefTransOf(clef);
2185
+ transState[globalStaff] = `${t.diat},${t.semi}`;
2109
2186
  }
2110
2187
  }
2111
2188
  // Helper to check if a measure has any musical content
@@ -2154,6 +2231,41 @@ const encode = (doc, options = {}) => {
2154
2231
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2155
2232
  }
2156
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
+ }
2157
2269
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2158
2270
  });
2159
2271
  mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
@@ -52,6 +52,11 @@ export declare class StaffLayout {
52
52
  get stavesCount(): number;
53
53
  }
54
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;
55
60
  export declare const encodeStaffLayoutMEI: (layout: StaffLayout, nameDict?: {
56
61
  [key: string]: string;
57
62
  }, indent?: number, tab?: string) => string;
@@ -200,6 +200,68 @@ export class StaffLayout {
200
200
  }
201
201
  }
202
202
  export const parseStaffLayout = (code) => new StaffLayout(tokenize(code));
203
+ // ── Staff-layout serialization (inverse of parseStaffLayout) ──
204
+ // Reconstruct a layout string from a parsed StaffLayout by walking the group tree,
205
+ // so every staff slot and conjunction is preserved structurally (a regex strip of the
206
+ // ids would drop a BARE anonymous leaf — its empty token gets swallowed by whitespace).
207
+ //
208
+ // `anonymous` emits empty ids (the parser re-auto-names slots "1","2",… by position).
209
+ // `idMap` optionally overrides individual staff ids by their original id.
210
+ //
211
+ // Conjunction rendering: Solid → "-", Dashed → ".", Blank → " " ONLY when both sides
212
+ // are bracketed groups (the brackets self-delimit the slots); otherwise Blank → ","
213
+ // so an adjacent empty/bare leaf still tokenizes as its own slot.
214
+ const CONJ_CHAR = {
215
+ [StaffConjunctionType.Solid]: "-",
216
+ [StaffConjunctionType.Dashed]: ".",
217
+ [StaffConjunctionType.Blank]: ",",
218
+ };
219
+ export const serializeStaffLayout = (layout, options = {}) => {
220
+ const { anonymous = false, idMap } = options;
221
+ const isGrouped = (group) => group.type !== StaffGroupType.Default && !!group.subs;
222
+ const leafText = (id) => (anonymous ? "" : idMap ? idMap(id) : id);
223
+ // flat leaf index of a group's first / last staff (for the inter-child conjunction).
224
+ const firstLeafIndex = (group) => layout.staffIds.indexOf(groupHead(group));
225
+ const lastLeafIndex = (group) => layout.staffIds.indexOf(groupTail(group));
226
+ const sep = (conj, left, right) => {
227
+ if (conj !== StaffConjunctionType.Blank)
228
+ return CONJ_CHAR[conj];
229
+ // Blank: a space is safe only when both neighbours are bracketed (self-delimiting).
230
+ return isGrouped(left) && isGrouped(right) ? " " : ",";
231
+ };
232
+ const emit = (group) => {
233
+ if (!group.subs)
234
+ return leafText(group.staff); // Default leaf
235
+ const open = group.type === StaffGroupType.Brace ? "{" : group.type === StaffGroupType.Bracket ? "<" : group.type === StaffGroupType.Square ? "[" : "";
236
+ const close = group.type === StaffGroupType.Brace ? "}" : group.type === StaffGroupType.Bracket ? ">" : group.type === StaffGroupType.Square ? "]" : "";
237
+ let inner = "";
238
+ group.subs.forEach((sub, i) => {
239
+ inner += emit(sub);
240
+ if (i < group.subs.length - 1) {
241
+ const next = group.subs[i + 1];
242
+ const conj = layout.conjunctions[lastLeafIndex(sub)] ?? StaffConjunctionType.Blank;
243
+ inner += sep(conj, sub, next);
244
+ void firstLeafIndex; // (lastLeafIndex(sub) === firstLeafIndex(next) - 1)
245
+ }
246
+ });
247
+ return open + inner + close;
248
+ };
249
+ let out = emit(layout.group);
250
+ // A TRAILING bare anonymous leaf emits "" with nothing after it to delimit the slot
251
+ // (a leaf before a closing bracket is fine — the bracket gives it bounds; an internal
252
+ // one is flushed by the next separator). The tokenizer only flushes a final empty item
253
+ // if it carries bounds, so append one "," to materialize that last empty slot. This only
254
+ // arises when the OUTERMOST container is the Default sequence (no enclosing bracket) and
255
+ // its last child is a bare leaf; if the whole layout is wrapped in a bracket, the closing
256
+ // bracket already delimits the final leaf. The trailing conjunction is dropped on re-parse
257
+ // (conjunctions = items[0..n-1]), so it is harmless. Anonymous output only.
258
+ if (anonymous && layout.group.type === StaffGroupType.Default && layout.group.subs) {
259
+ const lastTop = layout.group.subs[layout.group.subs.length - 1];
260
+ if (!lastTop.subs && lastTop.staff !== undefined)
261
+ out += ",";
262
+ }
263
+ return out;
264
+ };
203
265
  // ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
204
266
  // Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
205
267
  // with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k-l-lambda/lilylet",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
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",
@@ -14,6 +14,10 @@
14
14
  "types": "./lib/browser.d.ts",
15
15
  "default": "./lib/browser.js"
16
16
  },
17
+ "./highlight": {
18
+ "types": "./lib/lilylet/highlight.d.ts",
19
+ "default": "./lib/lilylet/highlight.js"
20
+ },
17
21
  "./lib/lilypondDecoder.js": {
18
22
  "types": "./lib/lilypondDecoder.d.ts",
19
23
  "default": "./lib/lilypondDecoder.js"
@@ -25,7 +29,8 @@
25
29
  ],
26
30
  "scripts": {
27
31
  "build": "tsc -p tsconfig.build.json && node tools/convertGrammarToESM.cjs && node tools/fixEsmExtensions.cjs",
28
- "build:grammar": "npx tsx ./tools/buildJisonParser.ts && node tools/convertGrammarToESM.cjs",
32
+ "build:grammar": "npx tsx ./tools/buildJisonParser.ts && node tools/convertGrammarToESM.cjs && npm run build:highlight",
33
+ "build:highlight": "npx tsx ./tools/buildHighlight.ts",
29
34
  "prepublishOnly": "npm run build:grammar && npm run build",
30
35
  "test": "tsx ./tests/parser.ts",
31
36
  "test:mei": "tsx ./tests/mei.ts",
@@ -37,6 +42,7 @@
37
42
  "test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
38
43
  "train:bpe": "tsx ./tools/trainBpeTokenizer.ts",
39
44
  "build:manual-tokenizer": "tsx ./tools/buildManualTokenizer.ts",
45
+ "build:browser-bundle": "esbuild tools/browserBundle.ts --bundle --format=iife --platform=browser --minify --outfile=dist/lilylet.bundle.js",
40
46
  "test:bpe": "tsx ./tests/unit/bpeTokenizer.test.ts",
41
47
  "build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
42
48
  "test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
@@ -580,27 +580,34 @@ const abcLayoutToStaves = (layout: ABC.StaffGroup[]): string | null => {
580
580
  return (node.items || []).every(isStaffLeaf);
581
581
  };
582
582
 
583
- const emit = (node: ABC.StaffGroup | string): string => {
583
+ // A square group maps to lilylet Bracket `<>` at the TOP level, but to lilylet Square
584
+ // `[]` when nested inside another group — e.g. ABC `[[1 2] 3 | 4]` → `<[1,2]3-4>`.
585
+ // A curly group always maps to Brace `{}`. `nested` is false for a top-level entry.
586
+ const emit = (node: ABC.StaffGroup | string, nested: boolean): string => {
584
587
  if (isStaffLeaf(node)) return firstVoice(node) || "";
585
588
 
586
589
  const group = node as ABC.StaffGroup;
587
- const open = group.bound === "curly" ? "{" : "<"; // square bracket <>, curly → brace {}
588
- const close = group.bound === "curly" ? "}" : ">";
590
+ const open = group.bound === "curly" ? "{" : (group.bound === "square" && nested) ? "[" : "<";
591
+ const close = group.bound === "curly" ? "}" : (group.bound === "square" && nested) ? "]" : ">";
589
592
 
590
593
  const items = group.items || [];
591
594
  let inner = "";
592
595
  items.forEach((item, i) => {
593
- inner += emit(item);
596
+ inner += emit(item, true);
594
597
  if (i < items.length - 1) {
595
- const conj = (item as ABC.StaffGroup).barThruAfter ? "-" : ",";
596
- inner += conj;
598
+ // A Blank separator (',') is only needed between two bare staff leaves; a
599
+ // grouped neighbour's bracket already delimits the slot, so suppress it there
600
+ // (giving `[1,2]3` not `[1,2],3`). A Solid join ('-', barThru) is always kept.
601
+ const next = items[i + 1];
602
+ if ((item as ABC.StaffGroup).barThruAfter) inner += "-";
603
+ else if (isStaffLeaf(item) && isStaffLeaf(next)) inner += ",";
597
604
  }
598
605
  });
599
606
  return `${open}${inner}${close}`;
600
607
  };
601
608
 
602
609
  const tops = layout.map((top, i) => {
603
- let s = emit(top);
610
+ let s = emit(top, false);
604
611
  // A bare top-level staff leaf (e.g. the `9` in `[ … ] 9 [ … ]`) still occupies a slot;
605
612
  // emit() already yields its id with no wrapper, which is the desired output.
606
613
  return { s, barThru: !!top.barThruAfter, isLast: i === layout.length - 1 };