@k-l-lambda/lilylet 0.1.68 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 @@ import {
25
25
  NavigationMarkType,
26
26
  Tempo,
27
27
  Event,
28
+ InstrumentName,
28
29
  } from "./types";
30
+ import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
29
31
 
30
32
 
31
33
  // MEI key signatures: positive = sharps, negative = flats
@@ -80,41 +82,62 @@ const CLEF_SHAPES: Record<string, { shape: string; line: number }> = {
80
82
  };
81
83
 
82
84
 
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)$/);
85
+ // Semitone offsets of the major/perfect intervals within one diatonic octave,
86
+ // indexed by diatonic step (0 = unison, 1 = 2nd, … 6 = 7th).
87
+ const DIATONIC_SEMITONES = [0, 2, 4, 5, 7, 9, 11];
88
+
89
+ // Convert a LilyPond clef interval-number suffix into an MEI written→sounding
90
+ // transposition. The number N is a diatonic interval number (2 = 2nd, 3 = 3rd,
91
+ // 5 = 5th, 8 = octave, 15 = two octaves); "_" lowers the sounding pitch, "^"
92
+ // raises it. Returns { diat, semi } where diat is the diatonic step shift
93
+ // (N - 1, signed) and semi is the corresponding chromatic shift in semitones,
94
+ // extended octave-wise for compound intervals.
95
+ const clefTransposition = (intervalNumber: number, up: boolean): { diat: number; semi: number } => {
96
+ const k = intervalNumber - 1; // diatonic steps
97
+ const semis = DIATONIC_SEMITONES[k % 7] + 12 * Math.floor(k / 7);
98
+ const sign = up ? 1 : -1;
99
+ return { diat: sign * k, semi: sign * semis };
100
+ };
101
+
102
+ // Resolve a clef string into MEI shape/line plus optional written→sounding
103
+ // transposition. Per the LilyPond convention a "_N"/"^N" suffix transposes the
104
+ // clef down/up by the diatonic interval N (e.g. "treble_8" octave down,
105
+ // "treble_5" fifth down, "treble^3" third up). MEI's clef.dis only covers octave
106
+ // displacement (8|15|22), so all clef transposition — octaves included — is
107
+ // encoded uniformly via att.transposition (trans.diat / trans.semi) on staffDef.
108
+ const resolveClef = (clefStr: string): { shape: string; line: number; trans?: { diat: number; semi: number } } => {
109
+ const match = clefStr.match(/^(.*?)([_^])(\d+)$/);
90
110
  const base = match ? match[1] : clefStr;
91
111
  const clefInfo = CLEF_SHAPES[base] || CLEF_SHAPES.treble;
92
112
  if (!match) return { shape: clefInfo.shape, line: clefInfo.line };
113
+ const trans = clefTransposition(Number(match[3]), match[2] === "^");
93
114
  return {
94
115
  shape: clefInfo.shape,
95
116
  line: clefInfo.line,
96
- dis: match[3] as "8" | "15",
97
- disPlace: match[2] === "^" ? "above" : "below",
117
+ trans,
98
118
  };
99
119
  };
100
120
 
101
121
  // Attributes for a standalone <clef> element (mid-measure clef change).
122
+ // 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.
102
125
  const clefElementAttrs = (clefStr: string): string => {
103
126
  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;
127
+ return `shape="${c.shape}" line="${c.line}"`;
107
128
  };
108
129
 
109
- // Attributes for a <staffDef> clef (clef.* namespace).
130
+ // Attributes for a <staffDef> clef (clef.* namespace), plus att.transposition
131
+ // (trans.diat / trans.semi) when the clef declares a transposition.
110
132
  const staffDefClefAttrs = (clefStr: string): string => {
111
133
  const c = resolveClef(clefStr);
112
134
  let attrs = `clef.shape="${c.shape}" clef.line="${c.line}"`;
113
- if (c.dis) attrs += ` clef.dis="${c.dis}" clef.dis.place="${c.disPlace}"`;
135
+ if (c.trans) attrs += ` trans.diat="${c.trans.diat}" trans.semi="${c.trans.semi}"`;
114
136
  return attrs;
115
137
  };
116
138
 
117
139
 
140
+
118
141
  // Lilylet duration division to MEI dur
119
142
  // division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
120
143
  const DURATIONS: Record<number, string> = {
@@ -1995,6 +2018,76 @@ const analyzePartStructure = (doc: LilyletDoc): PartInfo[] => {
1995
2018
  return partInfos;
1996
2019
  };
1997
2020
 
2021
+ // MEI staffGrp @symbol for a layout group type (Default → none; Square → bracketsq).
2022
+ const LAYOUT_SYMBOL: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
2023
+
2024
+ // Build <label>/<labelAbbr> child XML for an instrument entry, or "" if none.
2025
+ const instrumentLabelXML = (
2026
+ instr: InstrumentName | undefined,
2027
+ indent: string,
2028
+ ): string => {
2029
+ if (!instr) return "";
2030
+ let xml = `${indent}<label>${escapeXml(instr.name)}</label>\n`;
2031
+ if (instr.shortName !== undefined) {
2032
+ xml += `${indent}<labelAbbr>${escapeXml(instr.shortName)}</labelAbbr>\n`;
2033
+ }
2034
+ return xml;
2035
+ };
2036
+
2037
+ // Recursively emit a <staffGrp>/<staffDef> tree from a parsed [staves] layout group.
2038
+ // `staffDefAttrs` maps a leaf staff index (0-based, in layout order) to the attribute
2039
+ // string for the matching global staff's <staffDef>. `instruments` maps a layout group
2040
+ // key (staffLayout's groupKey: a staff id or range) to its instrument name; matching
2041
+ // names are emitted as <label>/<labelAbbr> on the staffGrp (groups) or staffDef (leaves).
2042
+ // bar.thru reflects the group's conjunction (Solid).
2043
+ const layoutGroupToMEI = (
2044
+ group: StaffGroup,
2045
+ staffDefAttrs: string[],
2046
+ leafCounter: { i: number },
2047
+ indent: string,
2048
+ instruments: { [key: string]: InstrumentName },
2049
+ ): string => {
2050
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
2051
+ const instr = group.key !== undefined ? instruments[group.key] : undefined;
2052
+
2053
+ // A leaf with no grouping symbol (Default) emits just the next staffDef (with label).
2054
+ if (isLeaf && (group.type === StaffGroupType.Default || !LAYOUT_SYMBOL[group.type])) {
2055
+ return staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent);
2056
+ }
2057
+
2058
+ const symbol = LAYOUT_SYMBOL[group.type] ? ` symbol="${LAYOUT_SYMBOL[group.type]}"` : "";
2059
+ const barThru = (group.bar ?? 0) > 1 ? ' bar.thru="true"' : '';
2060
+ let xml = `${indent}<staffGrp xml:id="${generateId("staffgrp")}"${symbol}${barThru}>\n`;
2061
+ // A multi-staff group's instrument name labels the whole group.
2062
+ if (!isLeaf) xml += instrumentLabelXML(instr, indent + " ");
2063
+ if (isLeaf) {
2064
+ // A single staff that still carries a grouping bracket (e.g. "<b>"): MEI allows
2065
+ // a staffGrp wrapping one staffDef. The instrument names the lone staff, so the
2066
+ // label goes on the staffDef inside.
2067
+ xml += staffDefWithLabel(staffDefAttrs[leafCounter.i++], instr, indent + " ");
2068
+ } else {
2069
+ for (const sub of group.subs || []) {
2070
+ xml += layoutGroupToMEI(sub, staffDefAttrs, leafCounter, indent + " ", instruments);
2071
+ }
2072
+ }
2073
+ xml += `${indent}</staffGrp>\n`;
2074
+ return xml;
2075
+ };
2076
+
2077
+ // Emit a <staffDef> from its attribute string, with optional instrument <label> children.
2078
+ const staffDefWithLabel = (
2079
+ attrs: string | undefined,
2080
+ instr: InstrumentName | undefined,
2081
+ indent: string,
2082
+ ): string => {
2083
+ if (attrs === undefined) return "";
2084
+ if (!instr) return `${indent}<staffDef ${attrs} />\n`;
2085
+ let xml = `${indent}<staffDef ${attrs}>\n`;
2086
+ xml += instrumentLabelXML(instr, indent + " ");
2087
+ xml += `${indent}</staffDef>\n`;
2088
+ return xml;
2089
+ };
2090
+
1998
2091
  // Encode scoreDef with part groups
1999
2092
  const encodeScoreDef = (
2000
2093
  keySig: string,
@@ -2002,36 +2095,75 @@ const encodeScoreDef = (
2002
2095
  timeDen: number,
2003
2096
  partInfos: PartInfo[],
2004
2097
  indent: string,
2005
- meterSymbol?: 'common' | 'cut'
2098
+ meterSymbol?: 'common' | 'cut',
2099
+ stavesCode?: string,
2100
+ instruments: { [key: string]: InstrumentName } = {}
2006
2101
  ): string => {
2007
2102
  const scoreDefId = generateId("scoredef");
2008
2103
 
2009
2104
  // Build meter attributes
2010
2105
  const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
2011
2106
  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
2107
 
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`;
2108
+ // Flat ordered list of global staves (n + clef), in part/voice order.
2109
+ const flatStaves: { n: number; clef: Clef }[] = [];
2110
+ for (const info of partInfos) {
2111
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
2112
+ flatStaves.push({ n: info.staffOffset + ls, clef: info.clefs[ls] || Clef.treble });
2113
+ }
2114
+ }
2115
+
2116
+ // If a [staves] layout is present and its leaf count matches the staves, drive
2117
+ // the nested staffGrp (bracket/bracketsq/brace) from it. Otherwise fall back to
2118
+ // the per-part grand-staff grouping.
2119
+ let layoutUsed = false;
2120
+ if (stavesCode) {
2121
+ const layout = parseStaffLayout(stavesCode);
2122
+ if (layout.stavesCount === flatStaves.length) {
2123
+ const staffDefAttrs = flatStaves.map(
2124
+ s => `xml:id="${generateId('staffdef')}" n="${s.n}" lines="5" ${staffDefClefAttrs(s.clef)}`
2125
+ );
2126
+ xml += layoutGroupToMEI(layout.group, staffDefAttrs, { i: 0 }, `${indent} `, instruments);
2127
+ layoutUsed = true;
2128
+ }
2129
+ }
2130
+
2131
+ if (!layoutUsed) {
2132
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
2133
+
2134
+ for (let pi = 0; pi < partInfos.length; pi++) {
2135
+ const info = partInfos[pi];
2136
+
2137
+ // If part has multiple staves (grand staff), wrap in staffGrp with brace.
2138
+ // Instrument key for a part follows staffLayout's groupKey: a single staff
2139
+ // number, or "first-last" for a grand staff.
2140
+ if (info.maxStaff > 1) {
2141
+ const first = info.staffOffset + 1;
2142
+ const last = info.staffOffset + info.maxStaff;
2143
+ const instr = instruments[`${first}-${last}`];
2144
+ xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
2145
+ xml += instrumentLabelXML(instr, `${indent} `);
2146
+ for (let ls = 1; ls <= info.maxStaff; ls++) {
2147
+ const globalStaff = info.staffOffset + ls;
2148
+ const clef = info.clefs[ls] || Clef.treble;
2149
+ const leafInstr = instruments[`${globalStaff}`];
2150
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2151
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
2152
+ }
2153
+ xml += `${indent} </staffGrp>\n`;
2154
+ } else {
2155
+ // Single staff part
2156
+ const globalStaff = info.staffOffset + 1;
2157
+ const clef = info.clefs[1] || Clef.treble;
2158
+ const leafInstr = instruments[`${globalStaff}`];
2159
+ const attrs = `xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" ${staffDefClefAttrs(clef)}`;
2160
+ xml += staffDefWithLabel(attrs, leafInstr, `${indent} `);
2024
2161
  }
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
2162
  }
2163
+
2164
+ xml += `${indent} </staffGrp>\n`;
2032
2165
  }
2033
2166
 
2034
- xml += `${indent} </staffGrp>\n`;
2035
2167
  xml += `${indent}</scoreDef>\n`;
2036
2168
  return xml;
2037
2169
  };
@@ -2397,7 +2529,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
2397
2529
  mei += `${indent}${indent}<body>\n`;
2398
2530
  mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
2399
2531
  mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
2400
- mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
2532
+ mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol, doc.metadata?.staves, doc.metadata?.instruments);
2401
2533
  mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
2402
2534
 
2403
2535
  // Track tie state across measures for cross-measure ties
@@ -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 : {};
@@ -43,6 +43,8 @@ import {
43
43
  calculateDuration,
44
44
  } from "./musicXmlUtils";
45
45
 
46
+ import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
47
+
46
48
 
47
49
  // === Constants and Reverse Mappings ===
48
50
 
@@ -135,6 +137,11 @@ const BARLINE_TO_XML: Record<string, { barStyle: string; repeat?: string }> = {
135
137
  };
136
138
 
137
139
 
140
+ // MusicXML <group-symbol> value by StaffGroupType (Default → none). MusicXML's
141
+ // allowed values are brace | bracket | line | square | none — note that, unlike
142
+ // MEI (which uses "bracketsq"), MusicXML's square variant IS spelled "square".
143
+ const GROUP_SYMBOLS_XML: (string | null)[] = [null, "brace", "bracket", "square"];
144
+
138
145
  // === XML Helper Functions ===
139
146
 
140
147
  const escapeXml = (text: string): string => {
@@ -753,6 +760,7 @@ const encodeMeasure = (
753
760
  break;
754
761
  }
755
762
 
763
+ case 'times':
756
764
  case 'tuplet': {
757
765
  const tupletEvents = event.events.filter(e => e.type === 'note' || e.type === 'rest') as (NoteEvent | RestEvent)[];
758
766
  for (let ti = 0; ti < tupletEvents.length; ti++) {
@@ -847,12 +855,122 @@ const encodeMetadata = (metadata: Metadata, level: number): string => {
847
855
  xml += `${indent(level + 2)}<encoding-date>${new Date().toISOString().split('T')[0]}</encoding-date>\n`;
848
856
  xml += `${indent(level + 1)}</encoding>\n`;
849
857
 
858
+ // Preserve the raw staff-layout string for a lossless round-trip. MusicXML has
859
+ // no native carrier for it (its <part-group> only expresses grouping, not the
860
+ // conjunction/anonymous-id detail), so we stash the verbatim code here.
861
+ if (metadata.staves) {
862
+ xml += `${indent(level + 1)}<miscellaneous>\n`;
863
+ xml += `${indent(level + 2)}<miscellaneous-field name="lilylet-staves">${escapeXml(metadata.staves)}</miscellaneous-field>\n`;
864
+ xml += `${indent(level + 1)}</miscellaneous>\n`;
865
+ }
866
+
850
867
  xml += `${indent(level)}</identification>\n`;
851
868
 
852
869
  return xml;
853
870
  };
854
871
 
855
872
 
873
+ /**
874
+ * Build <part-group> start/stop brackets from a parsed staff-layout, keyed by the
875
+ * part index they wrap around.
876
+ *
877
+ * The layout is staff-leaf (one leaf per staff); MusicXML <part-group> brackets group
878
+ * *parts*. We map each part to the consecutive run of staff-leaves it owns (a grand-staff
879
+ * part owns `maxStaff` leaves), then translate every layout group whose leaf-span aligns
880
+ * with whole-part boundaries into a part-group. Groups that fall entirely inside one part
881
+ * (e.g. the brace over a single grand-staff part) are intrinsic to that part's <staves>
882
+ * and are skipped here. Returns { starts, stops } maps: partIndex → XML snippets.
883
+ */
884
+ const buildPartGroups = (
885
+ stavesCode: string,
886
+ staffCountPerPart: number[],
887
+ level: number,
888
+ ): { starts: Map<number, string>; stops: Map<number, string> } => {
889
+ const starts = new Map<number, string>();
890
+ const stops = new Map<number, string>();
891
+
892
+ const layout = parseStaffLayout(stavesCode);
893
+ const totalLeaves = staffCountPerPart.reduce((a, b) => a + b, 0);
894
+ if (layout.stavesCount !== totalLeaves) {
895
+ // Layout/parts mismatch — skip grouping rather than emit something wrong.
896
+ return { starts, stops };
897
+ }
898
+
899
+ // leaf index → part index, and the [firstLeaf, lastLeaf] each part spans.
900
+ const partFirstLeaf: number[] = [];
901
+ const partLastLeaf: number[] = [];
902
+ let leaf = 0;
903
+ for (let pi = 0; pi < staffCountPerPart.length; pi++) {
904
+ partFirstLeaf[pi] = leaf;
905
+ leaf += staffCountPerPart[pi];
906
+ partLastLeaf[pi] = leaf - 1;
907
+ }
908
+
909
+ const leafToPart = (leafIdx: number): number => {
910
+ for (let pi = 0; pi < staffCountPerPart.length; pi++) {
911
+ if (leafIdx >= partFirstLeaf[pi] && leafIdx <= partLastLeaf[pi]) return pi;
912
+ }
913
+ return -1;
914
+ };
915
+
916
+ let groupNumber = 0;
917
+ const leafCounter = { i: 0 };
918
+
919
+ const walk = (group: StaffGroup): void => {
920
+ const isLeaf = !!group.staff && (!group.subs || group.subs.length === 0);
921
+ const symbol = GROUP_SYMBOLS_XML[group.type];
922
+
923
+ if (isLeaf) {
924
+ // A leaf may itself carry a bracket (e.g. <b>): a one-staff part-group.
925
+ const li = leafCounter.i++;
926
+ if (symbol) {
927
+ const pi = leafToPart(li);
928
+ if (pi >= 0 && partFirstLeaf[pi] === li && partLastLeaf[pi] === li) {
929
+ emit(pi, pi, symbol, group.bar);
930
+ }
931
+ }
932
+ return;
933
+ }
934
+
935
+ // Span the leaves covered by this group's subtree, then recurse.
936
+ const firstLeaf = leafCounter.i;
937
+ for (const sub of group.subs || []) walk(sub);
938
+ const lastLeaf = leafCounter.i - 1;
939
+
940
+ if (symbol) {
941
+ const startPart = leafToPart(firstLeaf);
942
+ const endPart = leafToPart(lastLeaf);
943
+ // Only emit when the span aligns with whole-part boundaries AND wraps >1 part
944
+ // (a group inside a single part is the part's own grand staff, not a part-group).
945
+ if (
946
+ startPart >= 0 && endPart >= 0 && startPart !== endPart &&
947
+ partFirstLeaf[startPart] === firstLeaf && partLastLeaf[endPart] === lastLeaf
948
+ ) {
949
+ emit(startPart, endPart, symbol, group.bar);
950
+ }
951
+ }
952
+ };
953
+
954
+ const emit = (startPart: number, endPart: number, symbol: string, bar?: number): void => {
955
+ const num = ++groupNumber;
956
+ const barLine = (bar ?? 0) > 1;
957
+ let s = `${indent(level)}<part-group type="start" number="${num}">\n`;
958
+ s += `${indent(level + 1)}<group-symbol>${symbol}</group-symbol>\n`;
959
+ s += `${indent(level + 1)}<group-barline>${barLine ? 'yes' : 'no'}</group-barline>\n`;
960
+ s += `${indent(level)}</part-group>\n`;
961
+ starts.set(startPart, (starts.get(startPart) || '') + s);
962
+
963
+ const e = `${indent(level)}<part-group type="stop" number="${num}"/>\n`;
964
+ // Stops are emitted after the end part; inner groups must close before outer ones,
965
+ // so prepend (deepest group, emitted last, closes first).
966
+ stops.set(endPart, e + (stops.get(endPart) || ''));
967
+ };
968
+
969
+ walk(layout.group);
970
+ return { starts, stops };
971
+ };
972
+
973
+
856
974
  /**
857
975
  * Encode complete LilyletDoc to MusicXML
858
976
  */
@@ -869,15 +987,31 @@ export const encode = (doc: LilyletDoc): string => {
869
987
  // Determine number of parts from first measure
870
988
  const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
871
989
 
990
+ // Staff-layout → <part-group> brackets/braces (if a [staves] layout is present and
991
+ // its staff count matches the parts' total staves).
992
+ let partGroups: { starts: Map<number, string>; stops: Map<number, string> } = {
993
+ starts: new Map(), stops: new Map(),
994
+ };
995
+ if (doc.metadata?.staves && doc.measures.length > 0) {
996
+ const staffCountPerPart = doc.measures[0].parts.map(part => {
997
+ let maxStaff = 1;
998
+ for (const voice of part.voices) maxStaff = Math.max(maxStaff, voice.staff || 1);
999
+ return maxStaff;
1000
+ });
1001
+ partGroups = buildPartGroups(doc.metadata.staves, staffCountPerPart, 2);
1002
+ }
1003
+
872
1004
  // Part list
873
1005
  xml += `${indent(1)}<part-list>\n`;
874
1006
  for (let pi = 0; pi < numParts; pi++) {
875
1007
  const partId = `P${pi + 1}`;
876
1008
  const partName = doc.measures[0]?.parts[pi]?.name
877
1009
  || (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
1010
+ xml += partGroups.starts.get(pi) || '';
878
1011
  xml += `${indent(2)}<score-part id="${partId}">\n`;
879
1012
  xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
880
1013
  xml += `${indent(2)}</score-part>\n`;
1014
+ xml += partGroups.stops.get(pi) || '';
881
1015
  }
882
1016
  xml += `${indent(1)}</part-list>\n`;
883
1017
 
@@ -278,13 +278,15 @@ const serializeMarks = (marks: Mark[]): string => {
278
278
  const serializeNoteEvent = (
279
279
  event: NoteEvent,
280
280
  env: PitchEnv,
281
- prevDuration?: Duration
281
+ prevDuration?: Duration,
282
+ suppressGracePrefix: boolean = false
282
283
  ): { str: string; newEnv: PitchEnv } => {
283
284
  const parts: string[] = [];
284
285
  let currentEnv = env;
285
286
 
286
- // Grace note prefix
287
- if (event.grace) {
287
+ // Grace note prefix. When the caller groups consecutive grace notes into a single
288
+ // scoped \grace { ... }, it suppresses the per-note prefix and emits the wrapper.
289
+ if (event.grace && !suppressGracePrefix) {
288
290
  parts.push('\\grace ');
289
291
  }
290
292
 
@@ -564,11 +566,12 @@ const serializeBarlineEvent = (event: BarlineEvent): string => {
564
566
  const serializeEvent = (
565
567
  event: Event,
566
568
  env: PitchEnv,
567
- prevDuration?: Duration
569
+ prevDuration?: Duration,
570
+ suppressGracePrefix: boolean = false
568
571
  ): { str: string; newEnv: PitchEnv } => {
569
572
  switch (event.type) {
570
573
  case 'note':
571
- return serializeNoteEvent(event as NoteEvent, env, prevDuration);
574
+ return serializeNoteEvent(event as NoteEvent, env, prevDuration, suppressGracePrefix);
572
575
  case 'rest':
573
576
  return serializeRestEvent(event as RestEvent, env, prevDuration);
574
577
  case 'context':
@@ -716,6 +719,7 @@ const serializeVoice = (
716
719
 
717
720
  let activeStaff = effectiveInitialStaff;
718
721
  let activeStemDir: StemDirection | undefined;
722
+ let graceGroupOpen = false; // whether a scoped \grace { ... } is currently open
719
723
 
720
724
  for (let eventIdx = 0; eventIdx < voice.events.length; eventIdx++) {
721
725
  const event = voice.events[eventIdx];
@@ -781,7 +785,19 @@ const serializeVoice = (
781
785
  }
782
786
  }
783
787
 
784
- const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration);
788
+ const isGraceNote = event.type === 'note' && !!(event as NoteEvent).grace;
789
+
790
+ // Group consecutive grace notes into one scoped \grace { ... } instead of
791
+ // emitting a separate \grace prefix per note.
792
+ if (isGraceNote && !graceGroupOpen) {
793
+ parts.push('\\grace {');
794
+ graceGroupOpen = true;
795
+ } else if (!isGraceNote && graceGroupOpen) {
796
+ parts.push('}');
797
+ graceGroupOpen = false;
798
+ }
799
+
800
+ const { str: eventStr, newEnv } = serializeEvent(event, pitchEnv, prevDuration, graceGroupOpen);
785
801
  pitchEnv = newEnv;
786
802
 
787
803
  if (eventStr) {
@@ -805,6 +821,11 @@ const serializeVoice = (
805
821
  }
806
822
  }
807
823
 
824
+ // Close a grace group left open at the end of the voice (unusual but possible).
825
+ if (graceGroupOpen) {
826
+ parts.push('}');
827
+ }
828
+
808
829
  return { str: parts.join(' '), newStaff: voice.staff };
809
830
  };
810
831
 
@@ -930,6 +951,19 @@ const serializeMetadata = (metadata: Metadata): string => {
930
951
  if (metadata.instrument) {
931
952
  lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
932
953
  }
954
+ if (metadata.staves) {
955
+ lines.push('[staves "' + escapeString(metadata.staves) + '"]');
956
+ }
957
+ if (metadata.instruments) {
958
+ for (const [key, instr] of Object.entries(metadata.instruments)) {
959
+ let line = '[instrument-' + key + ' "' + escapeString(instr.name) + '"';
960
+ if (instr.shortName !== undefined) {
961
+ line += ' "' + escapeString(instr.shortName) + '"';
962
+ }
963
+ line += ']';
964
+ lines.push(line);
965
+ }
966
+ }
933
967
  if (metadata.autoBeam) {
934
968
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
935
969
  }