@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
@@ -28,6 +28,7 @@ import {
28
28
  InstrumentName,
29
29
  } from "./types";
30
30
  import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
31
+ import { gmProgramOf } from "./gmInstruments";
31
32
 
32
33
 
33
34
  // MEI key signatures: positive = sharps, negative = flats
@@ -120,13 +121,24 @@ const resolveClef = (clefStr: string): { shape: string; line: number; trans?: {
120
121
 
121
122
  // Attributes for a standalone <clef> element (mid-measure clef change).
122
123
  // A mid-measure <clef> cannot carry att.transposition (that is a staff-level
123
- // property on <staffDef>); a mid-piece change of transposition would require a
124
- // new <staffDef>, which is out of scope here. So only shape/line are emitted.
124
+ // property Verovio only reads from <staffDef>); the visible clef shape/line is
125
+ // emitted here, and a sounding-pitch transposition change is mirrored by a
126
+ // between-measure <scoreDef>/<staffDef trans.*> emitted in the measure loop
127
+ // (see emitClefTranspositionScoreDef). So only shape/line are emitted here.
125
128
  const clefElementAttrs = (clefStr: string): string => {
126
129
  const c = resolveClef(clefStr);
127
130
  return `shape="${c.shape}" line="${c.line}"`;
128
131
  };
129
132
 
133
+ // The written→sounding transposition a clef declares, as {diat, semi}; {0,0}
134
+ // when the clef declares none. Used to detect mid-piece transposition changes
135
+ // and to emit the resetting <staffDef> when a transposing clef is replaced by a
136
+ // plain one (Verovio retains a prior trans.* until an explicit 0/0 overrides it).
137
+ const clefTransOf = (clefStr: string): { diat: number; semi: number } => {
138
+ const c = resolveClef(clefStr);
139
+ return c.trans || { diat: 0, semi: 0 };
140
+ };
141
+
130
142
  // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
131
143
  // (trans.diat / trans.semi) when the clef declares a transposition.
132
144
  const staffDefClefAttrs = (clefStr: string): string => {
@@ -1728,6 +1740,36 @@ const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1):
1728
1740
  // Clef state for cross-measure clef tracking - maps staff number to current clef
1729
1741
  type ClefState = Record<number, Clef>;
1730
1742
 
1743
+ // The clef governing a measure's sounding pitch on one global staff: the clef
1744
+ // carried in from the previous measure, overridden by a leading clef change that
1745
+ // appears before the first note/rest in the measure (the normal boundary-change
1746
+ // case). A clef change occurring *after* notes does not retune this measure — it
1747
+ // becomes the carried clef for the next measure via clefState. This per-measure
1748
+ // granularity matches what Verovio honors for MIDI: a transposition change takes
1749
+ // effect only via a between-measure <scoreDef>/<staffDef trans.*>, never from a
1750
+ // mid-measure <clef> element.
1751
+ const measureStartClef = (measure: Measure, globalStaff: number, partInfos: PartInfo[], carriedClef?: string): string | undefined => {
1752
+ let active = carriedClef;
1753
+ for (let pi = 0; pi < measure.parts.length; pi++) {
1754
+ const partOffset = partInfos[pi]?.staffOffset || 0;
1755
+ for (const voice of measure.parts[pi].voices) {
1756
+ if ((partOffset + (voice.staff || 1)) !== globalStaff) continue;
1757
+ for (const event of voice.events) {
1758
+ if (event.type === 'note' || event.type === 'rest' ||
1759
+ event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
1760
+ break; // notes have started; a later clef change governs the next measure
1761
+ }
1762
+ if (event.type === 'context') {
1763
+ const ctx = event as ContextChange;
1764
+ if (ctx.clef) active = ctx.clef;
1765
+ }
1766
+ }
1767
+ }
1768
+ }
1769
+ return active;
1770
+ };
1771
+
1772
+
1731
1773
  // Barline style to MEI @right attribute mapping
1732
1774
  const BARLINE_TO_MEI: Record<string, string> = {
1733
1775
  '|': 'single',
@@ -2021,16 +2063,54 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
2021
2063
  // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
2022
2064
  const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
2023
2065
 
2066
+ // MIDI channel allocator: assigns one channel per distinct GM program so that
2067
+ // instruments get separate timbres. Verovio defaults every staff to channel 0
2068
+ // unless <instrDef midi.channel> is set, which would make all ProgramChanges
2069
+ // collide on one channel (only the last program would sound). We dedup by
2070
+ // program number — duplicate desks (e.g. two "Violins" staves) share a channel
2071
+ // — and skip channel 9 (the GM percussion channel) for pitched instruments.
2072
+ interface ChannelAllocator {
2073
+ byProgram: Map<number, number>;
2074
+ nextChannel: number;
2075
+ }
2076
+
2077
+ const newChannelAllocator = (): ChannelAllocator => ({ byProgram: new Map(), nextChannel: 0 });
2078
+
2079
+ // Channel for a program: reuse the channel already assigned to that program,
2080
+ // else take the next free channel (skipping 9). Wraps past 16 as graceful
2081
+ // degradation for scores with more than 15 distinct timbres.
2082
+ const allocChannel = (alloc: ChannelAllocator, program: number): number => {
2083
+ const existing = alloc.byProgram.get(program);
2084
+ if (existing !== undefined) return existing;
2085
+ let ch = alloc.nextChannel;
2086
+ if (ch % 16 === 9) ch++; // skip GM drum channel
2087
+ const assigned = ch % 16;
2088
+ alloc.byProgram.set(program, assigned);
2089
+ alloc.nextChannel = ch + 1;
2090
+ return assigned;
2091
+ };
2092
+
2024
2093
  // Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
2094
+ // When the instrument name resolves to a General MIDI program, also emit an
2095
+ // <instrDef midi.instrnum midi.channel> sibling so Verovio's MIDI export assigns
2096
+ // that timbre (it honors only the numeric @midi.instrnum) on its own channel
2097
+ // (@midi.channel, else all instruments collide on channel 0). Unknown names emit
2098
+ // just the label, leaving Verovio's default program (0 = piano).
2025
2099
  const instrumentLabelXML = (
2026
2100
  instr: InstrumentName | undefined,
2027
2101
  indent: string,
2102
+ channels?: ChannelAllocator,
2028
2103
  ): string => {
2029
2104
  if (!instr) return "";
2030
2105
  let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
2031
2106
  if (instr.shortName !== undefined) {
2032
2107
  xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
2033
2108
  }
2109
+ const program = gmProgramOf(instr.name);
2110
+ if (program !== undefined) {
2111
+ const chanAttr = channels ? ` midi.channel="${allocChannel(channels, program)}"` : "";
2112
+ xml += `${indent}<instrDef xml:id="${generateId("instrdef")}" midi.instrnum="${program}"${chanAttr} />\n`;
2113
+ }
2034
2114
  return xml;
2035
2115
  };
2036
2116
 
@@ -2046,28 +2126,29 @@ const layoutGroupToMEI = (
2046
2126
  leafCounter: { i: number },
2047
2127
  indent: string,
2048
2128
  instruments: { [key: string]: InstrumentName },
2129
+ channels: ChannelAllocator,
2049
2130
  ): string => {
2050
2131
  const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
2051
2132
  const instr = group.key !== undefined ? instruments[group.key] : undefined;
2052
2133
 
2053
2134
  // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
2054
2135
  if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
2055
- return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
2136
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
2056
2137
  }
2057
2138
 
2058
2139
  const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
2059
2140
  const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
2060
2141
  let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
2061
2142
  // A multi-staff group's instrument name labels the whole group.
2062
- if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ");
2143
+ if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ", channels);
2063
2144
  if (isLeaf) {
2064
2145
  // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
2065
2146
  // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
2066
2147
  // label goes on the staffDef inside.
2067
- xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
2148
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
2068
2149
  } else {
2069
2150
  for (const sub of group.subs || []) {
2070
- xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
2151
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
2071
2152
  }
2072
2153
  }
2073
2154
  xml += `${indent}</staffGrp>\n`;
@@ -2079,11 +2160,12 @@ const staffDefWithLabel = (
2079
2160
  attrs: string | undefined,
2080
2161
  instr: InstrumentName | undefined,
2081
2162
  indent: string,
2163
+ channels?: ChannelAllocator,
2082
2164
  ): string => {
2083
2165
  if (attrs === undefined) return "";
2084
2166
  if (!instr) return `${indent}<staffDef ${attrs} />\n`;
2085
2167
  let xml = `${indent}<staffDef ${attrs}>\n`;
2086
- xml += instrumentLabelXML(instr, indent + " ");
2168
+ xml += instrumentLabelXML(instr, indent + " ", channels);
2087
2169
  xml += `${indent}</staffDef>\n`;
2088
2170
  return xml;
2089
2171
  };
@@ -2101,6 +2183,10 @@ const encodeScoreDef = (
2101
2183
  ): string => {
2102
2184
  const scoreDefId = generateId("scoredef");
2103
2185
 
2186
+ // One MIDI channel allocator per score: assigns a distinct channel per GM
2187
+ // program so instruments don't collide on channel 0 (see allocChannel).
2188
+ const channels = newChannelAllocator();
2189
+
2104
2190
  // Build meter attributes
2105
2191
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
2106
2192
  let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
@@ -2123,7 +2209,7 @@ const encodeScoreDef = (
2123
2209
  const staffDefAttrs = flatStaves.map(
2124
2210
  s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
2125
2211
  );
2126
- xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
2212
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
2127
2213
  layoutUsed = true;
2128
2214
  }
2129
2215
  }
@@ -2142,13 +2228,13 @@ const encodeScoreDef = (
2142
2228
  const last = info.staffOffset + info.maxStaff;
2143
2229
  const instr = instruments[`${first}-${last}`];
2144
2230
  xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
2145
- xml += instrumentLabelXML(instr, `${indent} `);
2231
+ xml += instrumentLabelXML(instr, `${indent} `, channels);
2146
2232
  for (let ls = 1; ls <= info.maxStaff; ls++) {
2147
2233
  const globalStaff = info.staffOffset + ls;
2148
2234
  const clef = info.clefs[ls] || Clef.treble;
2149
2235
  const leafInstr = instruments[`${globalStaff}`];
2150
2236
  const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2151
- xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
2237
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
2152
2238
  }
2153
2239
  xml += `${indent} </staffGrp>\n`;
2154
2240
  } else {
@@ -2157,7 +2243,7 @@ const encodeScoreDef = (
2157
2243
  const clef = info.clefs[1] || Clef.treble;
2158
2244
  const leafInstr = instruments[`${globalStaff}`];
2159
2245
  const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2160
- xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
2246
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
2161
2247
  }
2162
2248
  }
2163
2249
 
@@ -2547,11 +2633,18 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2547
2633
 
2548
2634
  // Initialize clef state from partInfos (convert local staff to global staff)
2549
2635
  const clefState: ClefState = {};
2636
+ // Running written→sounding transposition per global staff, as the verbatim
2637
+ // "diat,semi" key. Seeded from the initial clefs (which the leading <scoreDef>
2638
+ // already encoded via staffDefClefAttrs), then advanced whenever a measure
2639
+ // starts under a clef with a different transposition.
2640
+ const transState: Record<number, string> = {};
2550
2641
  for (let pi = 0; pi < partInfos.length; pi++) {
2551
2642
  const partInfo = partInfos[pi];
2552
2643
  for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
2553
2644
  const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
2554
2645
  clefState[globalStaff] = clef;
2646
+ const t = clefTransOf(clef);
2647
+ transState[globalStaff] = `${t.diat},${t.semi}`;
2555
2648
  }
2556
2649
  }
2557
2650
 
@@ -2603,6 +2696,37 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2603
2696
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2604
2697
  }
2605
2698
  }
2699
+ // Check for a written→sounding transposition change at this measure
2700
+ // boundary and mirror it into MIDI. Verovio applies att.transposition only
2701
+ // from <staffDef>, never from a mid-measure <clef>, so a change of
2702
+ // transposing clef must be re-declared via a between-measure <scoreDef>.
2703
+ // We emit only the staves whose transposition actually changed (a partial
2704
+ // <staffGrp> leaves the others' state intact), using explicit "0,0" to
2705
+ // clear a prior transposition when a transposing clef is replaced by a
2706
+ // plain one. mi === 0 is already covered by the leading <scoreDef>.
2707
+ if (mi > 0) {
2708
+ const changed: string[] = [];
2709
+ for (let si = 1; si <= totalStaves; si++) {
2710
+ const startClef = measureStartClef(measure, si, partInfos, clefState[si]);
2711
+ if (startClef === undefined) continue;
2712
+ const t = clefTransOf(startClef);
2713
+ const key = `${t.diat},${t.semi}`;
2714
+ if (transState[si] === undefined) { transState[si] = key; continue; }
2715
+ if (key !== transState[si]) {
2716
+ transState[si] = key;
2717
+ const c = resolveClef(startClef);
2718
+ 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}" />`);
2719
+ }
2720
+ }
2721
+ if (changed.length > 0) {
2722
+ const sd = `${indent}${indent}${indent}${indent}${indent}${indent}`;
2723
+ mei += `${sd}<scoreDef xml:id="${generateId('scoredef')}">\n`;
2724
+ mei += `${sd}${indent}<staffGrp xml:id="${generateId('staffgrp')}">\n`;
2725
+ mei += changed.join("\n") + "\n";
2726
+ mei += `${sd}${indent}</staffGrp>\n`;
2727
+ mei += `${sd}</scoreDef>\n`;
2728
+ }
2729
+ }
2606
2730
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2607
2731
  });
2608
2732
 
@@ -240,6 +240,82 @@ export class StaffLayout {
240
240
 
241
241
  export const parseStaffLayout = (code: string): StaffLayout => new StaffLayout(tokenize(code));
242
242
 
243
+ // ── Staff-layout serialization (inverse of parseStaffLayout) ──
244
+ // Reconstruct a layout string from a parsed StaffLayout by walking the group tree,
245
+ // so every staff slot and conjunction is preserved structurally (a regex strip of the
246
+ // ids would drop a BARE anonymous leaf — its empty token gets swallowed by whitespace).
247
+ //
248
+ // `anonymous` emits empty ids (the parser re-auto-names slots "1","2",… by position).
249
+ // `idMap` optionally overrides individual staff ids by their original id.
250
+ //
251
+ // Conjunction rendering: Solid → "-", Dashed → ".", Blank → " " ONLY when both sides
252
+ // are bracketed groups (the brackets self-delimit the slots); otherwise Blank → ","
253
+ // so an adjacent empty/bare leaf still tokenizes as its own slot.
254
+
255
+ const CONJ_CHAR: { [c in StaffConjunctionType]: string } = {
256
+ [StaffConjunctionType.Solid]: "-",
257
+ [StaffConjunctionType.Dashed]: ".",
258
+ [StaffConjunctionType.Blank]: ",",
259
+ };
260
+
261
+ export interface SerializeStaffLayoutOptions {
262
+ anonymous?: boolean;
263
+ idMap?: (originalId: string) => string;
264
+ }
265
+
266
+ export const serializeStaffLayout = (layout: StaffLayout, options: SerializeStaffLayoutOptions = {}): string => {
267
+ const { anonymous = false, idMap } = options;
268
+ const isGrouped = (group: StaffGroup): boolean => group.type !== StaffGroupType.Default && !!group.subs;
269
+
270
+ const leafText = (id: string): string => (anonymous ? "" : idMap ? idMap(id) : id);
271
+
272
+ // flat leaf index of a group's first / last staff (for the inter-child conjunction).
273
+ const firstLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupHead(group)!);
274
+ const lastLeafIndex = (group: StaffGroup): number => layout.staffIds.indexOf(groupTail(group)!);
275
+
276
+ const sep = (conj: StaffConjunctionType, left: StaffGroup, right: StaffGroup): string => {
277
+ if (conj !== StaffConjunctionType.Blank) return CONJ_CHAR[conj];
278
+ // Blank: a space is safe only when both neighbours are bracketed (self-delimiting).
279
+ return isGrouped(left) && isGrouped(right) ? " " : ",";
280
+ };
281
+
282
+ const emit = (group: StaffGroup): string => {
283
+ if (!group.subs) return leafText(group.staff!); // Default leaf
284
+
285
+ const open = group.type === StaffGroupType.Brace ? "{" : group.type === StaffGroupType.Bracket ? "<" : group.type === StaffGroupType.Square ? "[" : "";
286
+ const close = group.type === StaffGroupType.Brace ? "}" : group.type === StaffGroupType.Bracket ? ">" : group.type === StaffGroupType.Square ? "]" : "";
287
+
288
+ let inner = "";
289
+ group.subs.forEach((sub, i) => {
290
+ inner += emit(sub);
291
+ if (i < group.subs!.length - 1) {
292
+ const next = group.subs![i + 1];
293
+ const conj = layout.conjunctions[lastLeafIndex(sub)] ?? StaffConjunctionType.Blank;
294
+ inner += sep(conj, sub, next);
295
+ void firstLeafIndex; // (lastLeafIndex(sub) === firstLeafIndex(next) - 1)
296
+ }
297
+ });
298
+ return open + inner + close;
299
+ };
300
+
301
+ let out = emit(layout.group);
302
+
303
+ // A TRAILING bare anonymous leaf emits "" with nothing after it to delimit the slot
304
+ // (a leaf before a closing bracket is fine — the bracket gives it bounds; an internal
305
+ // one is flushed by the next separator). The tokenizer only flushes a final empty item
306
+ // if it carries bounds, so append one "," to materialize that last empty slot. This only
307
+ // arises when the OUTERMOST container is the Default sequence (no enclosing bracket) and
308
+ // its last child is a bare leaf; if the whole layout is wrapped in a bracket, the closing
309
+ // bracket already delimits the final leaf. The trailing conjunction is dropped on re-parse
310
+ // (conjunctions = items[0..n-1]), so it is harmless. Anonymous output only.
311
+ if (anonymous && layout.group.type === StaffGroupType.Default && layout.group.subs) {
312
+ const lastTop = layout.group.subs[layout.group.subs.length - 1];
313
+ if (!lastTop.subs && lastTop.staff !== undefined) out += ",";
314
+ }
315
+
316
+ return out;
317
+ };
318
+
243
319
  // ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
244
320
  // Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
245
321
  // with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
@@ -1,102 +0,0 @@
1
- interface Fraction {
2
- numerator: number;
3
- denominator: number;
4
- }
5
- declare namespace ABC {
6
- type Token = string;
7
- interface KeyValue {
8
- name: string;
9
- value: any;
10
- }
11
- export interface ControlTerm {
12
- control: KeyValue;
13
- }
14
- export interface Triplet {
15
- triplet: number;
16
- multiplier?: number;
17
- n?: number;
18
- }
19
- export interface OctaveShift {
20
- octaveShift: number;
21
- }
22
- export interface Fingering {
23
- fingering: string;
24
- }
25
- export interface Tremolo {
26
- tremolo: number;
27
- }
28
- interface Grace {
29
- grace: boolean;
30
- acciaccatura: Token;
31
- events: GraceMusicTerm[];
32
- }
33
- interface Comment {
34
- comment: string;
35
- }
36
- export interface Articulation {
37
- articulation: Token;
38
- scope?: '(' | ')';
39
- }
40
- export type Expressive = Articulation | {
41
- express: Token;
42
- };
43
- export interface TextTerm {
44
- text: string;
45
- }
46
- export interface Pitch {
47
- acc: number | null;
48
- phonet: Token;
49
- quotes: number;
50
- tie?: boolean;
51
- }
52
- export interface Chord {
53
- pitches: Pitch[];
54
- tie?: any;
55
- }
56
- export interface EventData {
57
- chord: Chord;
58
- duration?: Fraction;
59
- }
60
- export interface EventTerm {
61
- event: EventData;
62
- broken?: number;
63
- }
64
- export type MusicTerm = Expressive | TextTerm | EventTerm | Grace | ControlTerm | Triplet | OctaveShift | Fingering | Tremolo;
65
- export type GraceMusicTerm = Expressive | EventTerm | Fingering;
66
- type Header = KeyValue | Comment;
67
- export interface BarPatch {
68
- control: {
69
- [k: string]: any;
70
- };
71
- terms: MusicTerm[];
72
- bar: Token;
73
- }
74
- export interface StaffGroup {
75
- items: (StaffGroup | string)[];
76
- bound?: 'arc' | 'square' | 'curly';
77
- }
78
- export interface StaffLayout {
79
- staffLayout: StaffGroup[];
80
- }
81
- export interface KeySignature {
82
- root: string;
83
- mode?: string;
84
- }
85
- export interface ClefValue {
86
- clef: string;
87
- }
88
- interface Measure {
89
- index: number;
90
- voices: BarPatch[];
91
- }
92
- interface Body {
93
- measures: Measure[];
94
- }
95
- export interface Tune {
96
- header: Header[];
97
- body: Body;
98
- }
99
- export type Document = Tune[];
100
- export {};
101
- }
102
- export { ABC, };
@@ -1,25 +0,0 @@
1
- var ABC;
2
- (function (ABC) {
3
- ;
4
- ;
5
- ;
6
- ;
7
- ;
8
- ;
9
- ;
10
- ;
11
- ;
12
- ;
13
- ;
14
- ;
15
- ;
16
- ;
17
- ;
18
- ;
19
- ;
20
- ;
21
- ;
22
- ;
23
- ;
24
- })(ABC || (ABC = {}));
25
- export { ABC, };
@@ -1,3 +0,0 @@
1
- import { ABC } from "./abc";
2
- export declare const parse: (code: string) => ABC.Document;
3
- export default parse;
@@ -1,6 +0,0 @@
1
- // @ts-ignore - jison generated file
2
- import grammar from "./grammar.jison.js";
3
- export const parse = (code) => {
4
- return grammar.parse(code);
5
- };
6
- export default parse;
@@ -1,25 +0,0 @@
1
- /**
2
- * ABC Notation Decoder for Lilylet
3
- *
4
- * Converts ABC notation files to Lilylet's internal LilyletDoc format.
5
- */
6
- import { LilyletDoc } from "./types";
7
- /**
8
- * Decode ABC notation string to LilyletDoc.
9
- * If the ABC contains multiple tunes, only the first is decoded.
10
- */
11
- export declare const decode: (abcString: string) => LilyletDoc;
12
- /**
13
- * Decode ABC notation string to multiple LilyletDocs (one per tune).
14
- */
15
- export declare const decodeAll: (abcString: string) => LilyletDoc[];
16
- /**
17
- * Decode an ABC file to LilyletDoc
18
- */
19
- export declare const decodeFile: (filePath: string) => Promise<LilyletDoc>;
20
- declare const _default: {
21
- decode: (abcString: string) => LilyletDoc;
22
- decodeAll: (abcString: string) => LilyletDoc[];
23
- decodeFile: (filePath: string) => Promise<LilyletDoc>;
24
- };
25
- export default _default;