@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.
@@ -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
 
@@ -951,6 +951,19 @@ const serializeMetadata = (metadata: Metadata): string => {
951
951
  if (metadata.instrument) {
952
952
  lines.push('[instrument "' + escapeString(metadata.instrument) + '"]');
953
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
+ }
954
967
  if (metadata.autoBeam) {
955
968
  lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
956
969
  }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Staff-layout parser, ported from FindLab starry (app/staffLayout/).
3
+ *
4
+ * The layout string uses STAFF as the leaf unit (distinct from ABC's %%score,
5
+ * which is voice-leaf). Brackets group staves: `{}` = Brace (grand staff),
6
+ * `<>` = Bracket, `[]` = Square; conjunctions between consecutive staves:
7
+ * `,` = Blank, `-` = Solid, `.` = Dashed. Staff ids are [a-zA-Z_0-9]+; a slot
8
+ * with no id is an anonymous staff (auto-named "1","2",…).
9
+ *
10
+ * Example: "<[v1-v2].va> {pl-pr} <b>" → 6 staves v1,v2,va,pl,pr,b grouped as
11
+ * a Bracket over { a Square [v1-v2] dashed-joined to va }, a Brace {pl-pr},
12
+ * and a Bracket <b>.
13
+ */
14
+
15
+ export enum StaffGroupType {
16
+ Default = 0,
17
+ Brace = 1, // {}
18
+ Bracket = 2, // <>
19
+ Square = 3, // []
20
+ }
21
+
22
+ export enum StaffConjunctionType {
23
+ Blank = 0,
24
+ Dashed = 1,
25
+ Solid = 2,
26
+ }
27
+
28
+ export interface RawItem {
29
+ id: string | null;
30
+ leftBounds: string[];
31
+ rightBounds: string[];
32
+ conjunction: string | null;
33
+ }
34
+
35
+ export interface StaffGroup {
36
+ type: StaffGroupType;
37
+ subs?: StaffGroup[];
38
+ staff?: string;
39
+ level?: number;
40
+ grand?: boolean;
41
+ key?: string;
42
+ bar?: number;
43
+ }
44
+
45
+ export interface StaffGroupTrait {
46
+ group: StaffGroup;
47
+ range: [number, number];
48
+ key: string;
49
+ }
50
+
51
+ const singleGroup = (id: string): StaffGroup => ({ type: StaffGroupType.Default, staff: id });
52
+
53
+ const BOUNDS_TO_GROUPTYPE: { [bound: string]: StaffGroupType } = {
54
+ "{": StaffGroupType.Brace,
55
+ "}": StaffGroupType.Brace,
56
+ "<": StaffGroupType.Bracket,
57
+ ">": StaffGroupType.Bracket,
58
+ "[": StaffGroupType.Square,
59
+ "]": StaffGroupType.Square,
60
+ };
61
+
62
+ const OPEN_BOUNDS = "{<[";
63
+ const CLOSE_BOUNDS = "}>]";
64
+
65
+ const CONJUNCTIONS_MAP: { [conj: string]: StaffConjunctionType } = {
66
+ ",": StaffConjunctionType.Blank,
67
+ "-": StaffConjunctionType.Solid,
68
+ ".": StaffConjunctionType.Dashed,
69
+ };
70
+
71
+ // MEI staffGrp @symbol by StaffGroupType (Default → none). MEI's allowed values
72
+ // are brace | bracket | bracketsq | line | none — note the square variant is
73
+ // "bracketsq", NOT "square" (the latter is MusicXML's <group-symbol> value).
74
+ const GROUP_SYMBOLS_MEI: (string | null)[] = [null, "brace", "bracket", "bracketsq"];
75
+
76
+ const randomB64 = (): string => {
77
+ const code = Buffer.from(Math.random().toString().slice(2)).toString("base64").replace(/=/g, "");
78
+ return code.split("").reverse().slice(0, 6).join("");
79
+ };
80
+
81
+ const makeUniqueName = (set: Set<string>, index: number, prefix?: string): string => {
82
+ let name = prefix;
83
+ if (!name) name = index.toString();
84
+ else if (set.has(name)) name += "_" + index.toString();
85
+
86
+ while (set.has(name)) name = (prefix ? prefix + "_" : "") + randomB64();
87
+
88
+ return name;
89
+ };
90
+
91
+ // Tokenize a layout string into RawItem[] (one per staff slot). Lexer: whitespace
92
+ // skipped, single chars [-,.{}<>[]] are bounds/conjunctions, [a-zA-Z_0-9]+ is an id.
93
+ // An item accumulates leading open-bounds (leftBounds), an optional id, trailing
94
+ // close-bounds (rightBounds); a conjunction terminates the item and starts the next.
95
+ const tokenize = (code: string): RawItem[] => {
96
+ const tokens = code.match(/[A-Za-z0-9_]+|[-,.{}<>\[\]]/g) || [];
97
+ const items: RawItem[] = [];
98
+ let cur: RawItem = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
99
+ let seenId = false; // id slot filled for the current item
100
+ let seenRight = false; // started collecting right bounds / closing
101
+
102
+ const pushItem = () => {
103
+ items.push(cur);
104
+ cur = { id: null, leftBounds: [], rightBounds: [], conjunction: null };
105
+ seenId = false;
106
+ seenRight = false;
107
+ };
108
+
109
+ for (const tok of tokens) {
110
+ if (tok in CONJUNCTIONS_MAP) {
111
+ cur.conjunction = tok;
112
+ pushItem();
113
+ continue;
114
+ }
115
+ if (OPEN_BOUNDS.includes(tok)) {
116
+ // An open bound after the id/closing starts a fresh item's left bounds.
117
+ if (seenId || seenRight) pushItem();
118
+ cur.leftBounds.push(tok);
119
+ continue;
120
+ }
121
+ if (CLOSE_BOUNDS.includes(tok)) {
122
+ cur.rightBounds.push(tok);
123
+ seenRight = true;
124
+ continue;
125
+ }
126
+ // id token
127
+ if (seenId || seenRight) pushItem();
128
+ cur.id = tok;
129
+ seenId = true;
130
+ }
131
+ // Flush the final item if it carries any content.
132
+ if (cur.id !== null || cur.leftBounds.length || cur.rightBounds.length) pushItem();
133
+
134
+ return items;
135
+ };
136
+
137
+ const makeGroupsFromRaw = (parent: StaffGroup, seq: string[]): string[] => {
138
+ let remains = seq;
139
+ while (remains.length) {
140
+ const word = remains.shift() as string;
141
+ const bound = BOUNDS_TO_GROUPTYPE[word];
142
+ if (bound !== undefined) {
143
+ if (CLOSE_BOUNDS.includes(word) && bound === parent.type) break;
144
+
145
+ if (OPEN_BOUNDS.includes(word)) {
146
+ const group: StaffGroup = { type: bound, level: Number.isFinite(parent.level as number) ? (parent.level as number) + 1 : 0 };
147
+ remains = makeGroupsFromRaw(group, remains);
148
+
149
+ parent.subs = parent.subs || [];
150
+ parent.subs.push(group);
151
+ }
152
+ } else {
153
+ parent.subs = parent.subs || [];
154
+ parent.subs.push(singleGroup(word));
155
+ }
156
+ }
157
+
158
+ while (parent.type === StaffGroupType.Default && parent.subs && parent.subs.length === 1) {
159
+ const sub = parent.subs[0];
160
+ parent.type = sub.type;
161
+ parent.subs = sub.subs;
162
+ parent.staff = sub.staff;
163
+ parent.level = sub.level;
164
+ }
165
+
166
+ while (parent.subs && parent.subs.length === 1 && parent.subs[0].type === StaffGroupType.Default) {
167
+ const sub = parent.subs[0];
168
+ parent.subs = sub.subs;
169
+ parent.staff = sub.staff;
170
+ }
171
+
172
+ parent.grand = parent.type === StaffGroupType.Brace && !!parent.subs && parent.subs.every(sub => !!sub.staff);
173
+
174
+ return remains;
175
+ };
176
+
177
+ const groupHead = (group: StaffGroup): string | undefined => {
178
+ if (group.staff) return group.staff;
179
+ else if (group.subs) return groupHead(group.subs[0]);
180
+ };
181
+
182
+ const groupTail = (group: StaffGroup): string | undefined => {
183
+ if (group.staff) return group.staff;
184
+ else if (group.subs) return groupTail(group.subs[group.subs.length - 1]);
185
+ };
186
+
187
+ export const groupKey = (group: StaffGroup): string | undefined => {
188
+ if (group.staff) return group.staff;
189
+ else if (group.subs) return `${groupHead(group)}-${groupTail(group)}`;
190
+ };
191
+
192
+ const groupDict = (group: StaffGroup, dict: { [key: string]: StaffGroup }): void => {
193
+ const key = groupKey(group);
194
+ if (key !== undefined) dict[key] = group;
195
+ if (group.subs) group.subs.forEach(sub => groupDict(sub, dict));
196
+ };
197
+
198
+ export class StaffLayout {
199
+ staffIds: string[];
200
+ conjunctions: StaffConjunctionType[];
201
+ group: StaffGroup;
202
+ groups: StaffGroupTrait[];
203
+
204
+ constructor(raw: RawItem[]) {
205
+ // make unique ids (anonymous slots get "1","2",… ; named collisions disambiguated)
206
+ const ids = new Set<string>();
207
+ raw.forEach((item, i) => {
208
+ item.id = makeUniqueName(ids, i + 1, item.id || undefined);
209
+ ids.add(item.id);
210
+ });
211
+ this.staffIds = raw.map(item => item.id as string);
212
+ this.conjunctions = raw.slice(0, raw.length - 1).map(item => item.conjunction ? CONJUNCTIONS_MAP[item.conjunction] : StaffConjunctionType.Blank);
213
+
214
+ // make groups
215
+ const seq = ([] as string[]).concat(...raw.map(item => [...item.leftBounds, item.id as string, ...item.rightBounds]));
216
+ this.group = { type: StaffGroupType.Default };
217
+ makeGroupsFromRaw(this.group, seq);
218
+
219
+ const dict: { [key: string]: StaffGroup } = {};
220
+ groupDict(this.group, dict);
221
+ this.groups = Object.entries(dict).map(([key, group]) => {
222
+ let ids = key.split("-");
223
+ if (ids.length === 1) ids = [ids[0], ids[0]];
224
+ const range = ids.map(id => this.staffIds.indexOf(id)) as [number, number];
225
+
226
+ const cons = this.conjunctions.slice(range[0], range[1]);
227
+ const bar = cons.length ? Math.min(...cons) : 0;
228
+
229
+ group.key = key;
230
+ group.bar = bar;
231
+
232
+ return { group, range, key };
233
+ });
234
+ }
235
+
236
+ get stavesCount(): number {
237
+ return this.staffIds.length;
238
+ }
239
+ }
240
+
241
+ export const parseStaffLayout = (code: string): StaffLayout => new StaffLayout(tokenize(code));
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
+
319
+ // ── MEI staffGrp encoding (ported from FindLab staffLayout/encoding.js encodeMEI) ──
320
+ // Recursively emit nested <staffGrp> with symbol (brace/bracket/square) and bar.thru,
321
+ // with <staffDef n="..."> leaves keyed by staff index. nameDict maps a group key to a
322
+ // label. Returns the inner XML (no <scoreDef> wrapper); the caller positions it.
323
+
324
+ const bool = (x: boolean): string => (x ? "true" : "false");
325
+
326
+ const stateMEIGroup = (
327
+ statements: string[],
328
+ group: StaffGroup,
329
+ nameDict: { [key: string]: string },
330
+ ids: string[],
331
+ indent: number,
332
+ tab: string,
333
+ ): void => {
334
+ const pad = tab.repeat(indent);
335
+ const name = group.key !== undefined ? nameDict[group.key] : undefined;
336
+
337
+ if (group.subs) {
338
+ const symbol = GROUP_SYMBOLS_MEI[group.type] ? ` symbol="${GROUP_SYMBOLS_MEI[group.type]}"` : "";
339
+ statements.push(`${pad}<staffGrp bar.thru="${bool((group.bar ?? 0) > 1)}"${symbol}>`);
340
+ if (name) statements.push(`${pad}${tab}<label>${name}</label>`);
341
+ group.subs.forEach(sub => stateMEIGroup(statements, sub, nameDict, ids, indent + 1, tab));
342
+ statements.push(`${pad}</staffGrp>`);
343
+ }
344
+
345
+ if (group.staff) statements.push(`${pad}<staffDef n="${ids.indexOf(group.staff) + 1}">`);
346
+ };
347
+
348
+ export const encodeStaffLayoutMEI = (
349
+ layout: StaffLayout,
350
+ nameDict: { [key: string]: string } = {},
351
+ indent = 0,
352
+ tab = "\t",
353
+ ): string => {
354
+ const statements: string[] = [];
355
+ stateMEIGroup(statements, layout.group, nameDict, layout.staffIds, indent, tab);
356
+ return statements.join("\n");
357
+ };
@@ -301,9 +301,19 @@ export interface Metadata {
301
301
  opus?: string;
302
302
  instrument?: string;
303
303
  genre?: string;
304
+ staves?: string; // Raw staff-layout code, e.g. "<[v1-v2].va> {pl-pr} <b>"
305
+ // Per-staff / per-group instrument names, keyed by staff-layout group key (a single
306
+ // staff id like "1"/"v1", or a range like "1-2"/"pl-pr"). Declared via the
307
+ // [instrument-<key> "Name" "Short"] header. Maps to MEI <label>/<labelAbbr>.
308
+ instruments?: { [key: string]: InstrumentName };
304
309
  autoBeam?: 'auto' | 'on' | 'off';
305
310
  }
306
311
 
312
+ export interface InstrumentName {
313
+ name: string;
314
+ shortName?: string;
315
+ }
316
+
307
317
  // Part within a measure: can be a single staff or grand staff (multiple staves)
308
318
  // When voices have staff > 1, it's a grand staff
309
319
  export interface Part {