@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.
@@ -2,6 +2,7 @@
2
2
  export * from "./types";
3
3
  export * from "./parser";
4
4
  export * from "./serializer";
5
+ export * from "./staffLayout";
5
6
 
6
7
  import * as meiEncoder from "./meiEncoder";
7
8
  import * as musicXmlDecoder from "./musicXmlDecoder";
@@ -130,6 +130,25 @@
130
130
  const dynamicEvent = (type) => ({ type: 'dynamic', dynamicType: type });
131
131
  const markupMark = (content) => ({ markType: 'markup', content });
132
132
 
133
+ // Build an { instruments: { <key>: { name, shortName? } } } fragment from a
134
+ // `[instrument-<key>` header token. <key> is a staff-layout group key — a single
135
+ // staff id ("1", "v1", "b") or a range ("1-2", "pl-pr") — taken verbatim after the
136
+ // "[instrument-" prefix so it lines up with staffLayout's groupKey().
137
+ const instrumentStaff = (token, name, shortName) => {
138
+ const key = token.slice('[instrument-'.length);
139
+ const entry = shortName !== undefined ? { name, shortName } : { name };
140
+ return { instruments: { [key]: entry } };
141
+ };
142
+
143
+ // Merge two header fragments. Plain fields are overwritten by the later one, but the
144
+ // `instruments` map is deep-merged so multiple [instrument-<key>] lines accumulate.
145
+ const mergeHeaders = (a, b) => {
146
+ const merged = { ...a, ...b };
147
+ if (a.instruments || b.instruments)
148
+ merged.instruments = { ...a.instruments, ...b.instruments };
149
+ return merged;
150
+ };
151
+
133
152
  // Parse PITCH token (e.g., "c", "cs", "bf", "css", "bff") into phonet and accidental
134
153
  const parsePitch = (text, octave) => {
135
154
  const phonet = text[0].toLowerCase();
@@ -227,8 +246,10 @@
227
246
  \[arranger return 'HEADER_ARRANGER'
228
247
  \[lyricist return 'HEADER_LYRICIST'
229
248
  \[opus return 'HEADER_OPUS'
249
+ \[instrument\-[A-Za-z0-9_]+(?:\-[A-Za-z0-9_]+)* return 'HEADER_INSTRUMENT_STAFF'
230
250
  \[instrument return 'HEADER_INSTRUMENT'
231
251
  \[genre return 'HEADER_GENRE'
252
+ \[staves return 'HEADER_STAVES'
232
253
  \[auto\-beam return 'HEADER_AUTOBEAM'
233
254
  \] return ']'
234
255
 
@@ -350,6 +371,8 @@ document
350
371
  content
351
372
  : headers measures -> ({ metadata: $1, measures: $2 })
352
373
  | headers newlines measures -> ({ metadata: $1, measures: $3 })
374
+ | newlines headers measures -> ({ metadata: $2, measures: $3 })
375
+ | newlines headers newlines measures -> ({ metadata: $2, measures: $4 })
353
376
  | newlines measures -> ({ metadata: undefined, measures: $2 })
354
377
  | measures -> ({ metadata: undefined, measures: $1 })
355
378
  ;
@@ -361,9 +384,9 @@ newlines
361
384
 
362
385
  headers
363
386
  : header -> $1
364
- | headers header -> ({ ...$1, ...$2 })
387
+ | headers header -> mergeHeaders($1, $2)
365
388
  | headers NEWLINE -> $1
366
- | headers NEWLINE header -> ({ ...$1, ...$3 })
389
+ | headers NEWLINE header -> mergeHeaders($1, $3)
367
390
  ;
368
391
 
369
392
  header
@@ -374,7 +397,10 @@ header
374
397
  | HEADER_LYRICIST STRING ']' -> ({ lyricist: $2.slice(1, -1) })
375
398
  | HEADER_OPUS STRING ']' -> ({ opus: $2.slice(1, -1) })
376
399
  | HEADER_INSTRUMENT STRING ']' -> ({ instrument: $2.slice(1, -1) })
400
+ | HEADER_INSTRUMENT_STAFF STRING ']' -> instrumentStaff($1, $2.slice(1, -1))
401
+ | HEADER_INSTRUMENT_STAFF STRING STRING ']' -> instrumentStaff($1, $2.slice(1, -1), $3.slice(1, -1))
377
402
  | HEADER_GENRE STRING ']' -> ({ genre: $2.slice(1, -1) })
403
+ | HEADER_STAVES STRING ']' -> ({ staves: $2.slice(1, -1) })
378
404
  | HEADER_AUTOBEAM STRING ']' -> ({ autoBeam: $2.slice(1, -1) })
379
405
  ;
380
406
 
@@ -25,7 +25,10 @@ import {
25
25
  NavigationMarkType,
26
26
  Tempo,
27
27
  Event,
28
+ InstrumentName,
28
29
  } from "./types";
30
+ import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
31
+ import { gmProgramOf } from "./gmInstruments";
29
32
 
30
33
 
31
34
  // MEI key signatures: positive = sharps, negative = flats
@@ -80,41 +83,73 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
80
83
  };
81
84
 
82
85
 
83
- // Resolve a clef string into MEI shape/line plus optional octave displacement.
84
- // Octave transposition follows the LilyPond convention: a "_8"/"_15" suffix lowers
85
- // the sounding pitch by one/two octaves (the small 8/15 is drawn below the clef),
86
- // and "^8"/"^15" raises it (drawn above). MEI encodes this as dis ("8" | "15")
87
- // and dis.place ("below" | "above").
88
- const resolveClef = (clefStr: string): { shape: string; line: number; dis?: "8" | "15"; disPlace?: "above" | "below" } => {
89
- const match = clefStr.match(/^(.*?)([_^])(8|15)$/);
86
+ // Semitone offsets of the major/perfect intervals within one diatonic octave,
87
+ // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
88
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
89
+
90
+ // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
91
+ // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
92
+ // 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
93
+ // raises it. Returns { diat, semi } where diat is the diatonic step shift
94
+ // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
95
+ // extended octave-wise for compound intervals.
96
+ const clefTransposition = (intervalNumber: number, up: boolean): { diat: number; semi: number } => {
97
+ const k = intervalNumber - 1; // diatonic steps
98
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
99
+ const sign = up ? 1 : -1;
100
+ return { diat: sign * k, semi: sign * semis };
101
+ };
102
+
103
+ // Resolve a clef string into MEI shape/line plus optional written→sounding
104
+ // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
105
+ // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
106
+ // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
107
+ // displacement (8|15|22), so all clef transposition — octaves included — is
108
+ // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
109
+ const resolveClef = (clefStr: string): { shape: string; line: number; trans?: { diat: number; semi: number } } => {
110
+ const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
90
111
  const base = match ? match[1] : clefStr;
91
112
  const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
92
113
  if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
114
+ const trans = clefTransposition(Number(match[3]), match[2] === "^");
93
115
  return {
94
116
  shape: clefInfo.shape,
95
117
  line: clefInfo.line,
96
- dis: match[3] as "8" | "15",
97
- disPlace: match[2] === "^" ? "above" : "below",
118
+ trans,
98
119
  };
99
120
  };
100
121
 
101
122
  // Attributes for a standalone <clef> element (mid-measure clef change).
123
+ // A mid-measure <clef> cannot carry att.transposition (that is a staff-level
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.
102
128
  const clefElementAttrs = (clefStr: string): string => {
103
129
  const c = resolveClef(clefStr);
104
- let attrs = `shape="${c.shape}" line="${c.line}"`;
105
- if (c.dis) attrs += ` dis="${c.dis}" dis.place="${c.disPlace}"`;
106
- return attrs;
130
+ return `shape="${c.shape}" line="${c.line}"`;
131
+ };
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 };
107
140
  };
108
141
 
109
- // Attributes for a <staffDef> clef (clef.* namespace).
142
+ // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
143
+ // (trans.diat / trans.semi) when the clef declares a transposition.
110
144
  const staffDefClefAttrs = (clefStr: string): string => {
111
145
  const c = resolveClef(clefStr);
112
146
  let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
113
- if (c.dis) attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
147
+ if (c.trans) attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
114
148
  return attrs;
115
149
  };
116
150
 
117
151
 
152
+
118
153
  // Lilylet duration division to MEI dur
119
154
  // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
120
155
  const DURATIONS: Record<number, string> = {
@@ -1705,6 +1740,36 @@ const generateTempoElement = (tempo: Tempo, indent: string, staff: number = 1):
1705
1740
  // Clef state for cross-measure clef tracking - maps staff number to current clef
1706
1741
  type ClefState = Record<number, Clef>;
1707
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
+
1708
1773
  // Barline style to MEI @right attribute mapping
1709
1774
  const BARLINE_TO_MEI: Record<string, string> = {
1710
1775
  '|': 'single',
@@ -1995,6 +2060,116 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
1995
2060
  return partInfos;
1996
2061
  };
1997
2062
 
2063
+ // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
2064
+ const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
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
+
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).
2099
+ const instrumentLabelXML = (
2100
+ instr: InstrumentName | undefined,
2101
+ indent: string,
2102
+ channels?: ChannelAllocator,
2103
+ ): string => {
2104
+ if (!instr) return "";
2105
+ let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
2106
+ if (instr.shortName !== undefined) {
2107
+ xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
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
+ }
2114
+ return xml;
2115
+ };
2116
+
2117
+ // Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
2118
+ // `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
2119
+ // string for the matching global staff's <staffDef>. `instruments` maps a layout group
2120
+ // key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
2121
+ // names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
2122
+ // bar.thru reflects the group's conjunction (Solid).
2123
+ const layoutGroupToMEI = (
2124
+ group: StaffGroup,
2125
+ staffDefAttrs: string[],
2126
+ leafCounter: { i: number },
2127
+ indent: string,
2128
+ instruments: { [key: string]: InstrumentName },
2129
+ channels: ChannelAllocator,
2130
+ ): string => {
2131
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
2132
+ const instr = group.key !== undefined ? instruments[group.key] : undefined;
2133
+
2134
+ // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
2135
+ if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
2136
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent, channels);
2137
+ }
2138
+
2139
+ const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
2140
+ const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
2141
+ let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
2142
+ // A multi-staff group's instrument name labels the whole group.
2143
+ if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ", channels);
2144
+ if (isLeaf) {
2145
+ // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
2146
+ // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
2147
+ // label goes on the staffDef inside.
2148
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ", channels);
2149
+ } else {
2150
+ for (const sub of group.subs || []) {
2151
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments, channels);
2152
+ }
2153
+ }
2154
+ xml += `${indent}</staffGrp>\n`;
2155
+ return xml;
2156
+ };
2157
+
2158
+ // Emit a <staffDef> from its attribute string, with optional instrument <label> children.
2159
+ const staffDefWithLabel = (
2160
+ attrs: string | undefined,
2161
+ instr: InstrumentName | undefined,
2162
+ indent: string,
2163
+ channels?: ChannelAllocator,
2164
+ ): string => {
2165
+ if (attrs === undefined) return "";
2166
+ if (!instr) return `${indent}<staffDef ${attrs} />\n`;
2167
+ let xml = `${indent}<staffDef ${attrs}>\n`;
2168
+ xml += instrumentLabelXML(instr, indent + " ", channels);
2169
+ xml += `${indent}</staffDef>\n`;
2170
+ return xml;
2171
+ };
2172
+
1998
2173
  // Encode scoreDef with part groups
1999
2174
  const encodeScoreDef = (
2000
2175
  keySig: string,
@@ -2002,36 +2177,79 @@ const encodeScoreDef = (
2002
2177
  timeDen: number,
2003
2178
  partInfos: PartInfo[],
2004
2179
  indent: string,
2005
- meterSymbol?: 'common' | 'cut'
2180
+ meterSymbol?: 'common' | 'cut',
2181
+ stavesCode?: string,
2182
+ instruments: { [key: string]: InstrumentName } = {}
2006
2183
  ): string => {
2007
2184
  const scoreDefId = generateId("scoredef");
2008
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
+
2009
2190
  // Build meter attributes
2010
2191
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
2011
2192
  let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
2012
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
2013
2193
 
2014
- for (let pi = 0; pi < partInfos.length; pi++) {
2015
- const info = partInfos[pi];
2016
-
2017
- // If part has multiple staves (grand staff), wrap in staffGrp with brace
2018
- if (info.maxStaff > 1) {
2019
- xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
2020
- for (let ls = 1; ls <= info.maxStaff; ls++) {
2021
- const globalStaff = info.staffOffset + ls;
2022
- const clef = info.clefs[ls] || Clef.treble;
2023
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
2194
+ // Flat ordered list of global staves (n + clef), in part/voice order.
2195
+ const flatStaves: { n: number; clef: Clef }[] = [];
2196
+ for (const info of partInfos) {
2197
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
2198
+ flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
2199
+ }
2200
+ }
2201
+
2202
+ // If a [staves] layout is present and its leaf count matches the staves, drive
2203
+ // the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
2204
+ // the per-part grand-staff grouping.
2205
+ let layoutUsed = false;
2206
+ if (stavesCode) {
2207
+ const layout = parseStaffLayout(stavesCode);
2208
+ if (layout.stavesCount === flatStaves.length) {
2209
+ const staffDefAttrs = flatStaves.map(
2210
+ s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
2211
+ );
2212
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments, channels);
2213
+ layoutUsed = true;
2214
+ }
2215
+ }
2216
+
2217
+ if (!layoutUsed) {
2218
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
2219
+
2220
+ for (let pi = 0; pi < partInfos.length; pi++) {
2221
+ const info = partInfos[pi];
2222
+
2223
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace.
2224
+ // Instrument key for a part follows staffLayout's groupKey: a single staff
2225
+ // number, or "first-last" for a grand staff.
2226
+ if (info.maxStaff > 1) {
2227
+ const first = info.staffOffset + 1;
2228
+ const last = info.staffOffset + info.maxStaff;
2229
+ const instr = instruments[`${first}-${last}`];
2230
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
2231
+ xml += instrumentLabelXML(instr, `${indent} `, channels);
2232
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
2233
+ const globalStaff = info.staffOffset + ls;
2234
+ const clef = info.clefs[ls] || Clef.treble;
2235
+ const leafInstr = instruments[`${globalStaff}`];
2236
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2237
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
2238
+ }
2239
+ xml += `${indent} </staffGrp>\n`;
2240
+ } else {
2241
+ // Single staff part
2242
+ const globalStaff = info.staffOffset + 1;
2243
+ const clef = info.clefs[1] || Clef.treble;
2244
+ const leafInstr = instruments[`${globalStaff}`];
2245
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2246
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `, channels);
2024
2247
  }
2025
- xml += `${indent} </staffGrp>\n`;
2026
- } else {
2027
- // Single staff part
2028
- const globalStaff = info.staffOffset + 1;
2029
- const clef = info.clefs[1] || Clef.treble;
2030
- xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)} />\n`;
2031
2248
  }
2249
+
2250
+ xml += `${indent} </staffGrp>\n`;
2032
2251
  }
2033
2252
 
2034
- xml += `${indent} </staffGrp>\n`;
2035
2253
  xml += `${indent}</scoreDef>\n`;
2036
2254
  return xml;
2037
2255
  };
@@ -2397,7 +2615,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2397
2615
  mei += `${indent}${indent}<body>\n`;
2398
2616
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
2399
2617
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2400
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
2618
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2401
2619
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2402
2620
 
2403
2621
  // Track tie state across measures for cross-measure ties
@@ -2415,11 +2633,18 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2415
2633
 
2416
2634
  // Initialize clef state from partInfos (convert local staff to global staff)
2417
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> = {};
2418
2641
  for (let pi = 0; pi < partInfos.length; pi++) {
2419
2642
  const partInfo = partInfos[pi];
2420
2643
  for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
2421
2644
  const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
2422
2645
  clefState[globalStaff] = clef;
2646
+ const t = clefTransOf(clef);
2647
+ transState[globalStaff] = `${t.diat},${t.semi}`;
2423
2648
  }
2424
2649
  }
2425
2650
 
@@ -2471,6 +2696,37 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2471
2696
  mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
2472
2697
  }
2473
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
+ }
2474
2730
  mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements);
2475
2731
  });
2476
2732
 
@@ -749,6 +749,15 @@ const parseMetadata = (doc: Document): Metadata => {
749
749
  }
750
750
  }
751
751
  }
752
+
753
+ // Staff layout: recover the raw [staves] string stashed at encode time.
754
+ const miscFields = getElements(identificationEl, 'miscellaneous-field');
755
+ for (const field of miscFields) {
756
+ if (getAttribute(field, 'name') === 'lilylet-staves') {
757
+ const code = field.textContent?.trim();
758
+ if (code) metadata.staves = code;
759
+ }
760
+ }
752
761
  }
753
762
 
754
763
  return Object.keys(metadata).length > 0 ? metadata : {};